feat(quickstep): audit owned dash outcomes
Release / release (push) Failing after 8m47s

This commit is contained in:
dmg committed 2026-09-25 22:40:16 -04:00
1 parent fc90af3a62
commit e6b66d9e34
8 files changed
+372 -42

No files matched your search

@@ -761,6 +761,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
abilityEngine.enableCastEventsFor("great_sword");
abilityEngine.enableCastEventsFor("frost_shard");
abilityEngine.enableCastEventsFor("longbow");
abilityEngine.enableCastEventsFor("quickstep");
abilityEngine.enableCastEventsFor("mending_light");
abilityEngine.enableCastEventsFor("purify");
abilityEngine.enableCastEventsFor("guardian_bond");
@@ -71,7 +71,8 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private void refuseCast(ControlInput input, CharacterRecord record, AbilityDefinition definition, String reason) {
if (castEvents == null || record == null
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow"))
|| !(definition.id().equals("great_sword") || definition.id().equals("longbow")
|| definition.id().equals("quickstep"))
|| !eventOwnedAbilities.contains(definition.id())) return;
resolve(castEvents.begin(input, record, definition), "refused", reason);
}
@@ -252,25 +253,31 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return;
}
CharacterRecord saved = mutation.record().orElseThrow();
if (castEvents != null) castEvents.cast(audit, saved, readyAt, definition);
boolean deferNativeCast = audit != null && definition.id().equals("quickstep");
if (castEvents != null && !deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
boolean synchronizedProgress = unchangedDeadline
? sessions.currentRecord(input.session()).filter(saved::equals).isPresent()
: sessions.acknowledgeProgress(input.session(), expectedRevision, saved);
if (!synchronizedProgress) {
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
resolve(audit, "interrupted", "session_publication_changed");
input.player().sendMessage("The cooldown was saved, but the session changed; no effect was applied."); return;
}
if (!input.stillCurrent() || generations.getOrDefault(key, 0L) != generation
|| !eligible(definition.targetPolicy(), input, input.target())) {
if (deferNativeCast) castEvents.cast(audit, saved, readyAt, definition);
resolve(audit, "interrupted", "eligibility_changed");
input.player().sendMessage("The cooldown was saved, but eligibility changed; no effect was applied."); return;
}
var runtime = new Runtime(key, input, definition, generation, audit);
var runtime = new Runtime(key, input, definition, generation, audit,
deferNativeCast ? saved : null, readyAt);
active.computeIfAbsent(key, ignored -> new HashSet<>()).add(runtime);
try {
definition.effect().activate(runtime);
runtime.publishCast();
if (definition.activation() == AbilityDefinition.Activation.IMMEDIATE) runtime.finish();
} catch (RuntimeException effectFailure) {
runtime.publishCast();
runtime.resolve("failed"); runtime.close(); report(key.character(), key.ability(), effectFailure);
input.player().sendMessage("The cooldown was saved, but the ability effect failed safely.");
}
@@ -341,17 +348,40 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
private final AbilityDefinition definition;
private final long generation;
private final games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit;
private final CharacterRecord deferredCastRecord;
private final Instant deferredDeadline;
private final Map<String, Object> castEvidence = new HashMap<>();
private boolean castPublished;
private final Map<UUID, String> observedEffects = new HashMap<>();
private final Set<AutoCloseable> owned = new HashSet<>();
private String outcome;
private Map<String, Object> resolutionEvidence = Map.of();
private boolean finished;
private boolean closing;
private int scheduled;
Runtime(Key key, ControlInput input, AbilityDefinition definition, long generation,
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit) {
games.dmg.fantasystick.events.AbilityCastEventBridge.Cast audit,
CharacterRecord deferredCastRecord, Instant deferredDeadline) {
this.key = key; this.input = input; this.definition = definition; this.generation = generation;
this.audit = audit;
this.audit = audit; this.deferredCastRecord = deferredCastRecord; this.deferredDeadline = deferredDeadline;
}
void publishCast() {
if (deferredCastRecord == null || castPublished) return;
castPublished = true;
if (castEvents != null) castEvents.cast(audit, deferredCastRecord, deferredDeadline, definition,
Map.copyOf(castEvidence));
}
@Override public void castEvidence(Map<String, Object> evidence) {
if (!castPublished && !finished && deferredCastRecord != null)
castEvidence.putAll(Objects.requireNonNull(evidence));
}
@Override public void resolutionEvidence(Map<String, Object> evidence) {
if (!finished) {
var combined = new java.util.LinkedHashMap<>(resolutionEvidence);
combined.putAll(Objects.requireNonNull(evidence));
resolutionEvidence = Map.copyOf(combined);
}
}
@Override public AbilityDefinition definition() { return definition; }
@Override public ControlInput input() { return input; }
@@ -359,7 +389,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
return sessions.currentRecord(input.session()).orElseThrow(() -> new IllegalStateException("Ability session ended"));
}
@Override public boolean stillCurrent() {
return !closed && !finished && input.stillCurrent() && sessions.current(input.session())
return !closed && !finished && !closing && input.stillCurrent() && sessions.current(input.session())
&& generations.getOrDefault(key, 0L) == generation
&& sessions.currentRecord(input.session()).filter(record -> record.learned().contains(key.ability())).isPresent();
}
@@ -470,7 +500,7 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
@Override public void resolve(String result) { resolve(result, Map.of()); }
@Override public void resolve(String result, Map<String, Object> evidence) {
if (!Set.of("completed", "hit", "miss", "expired", "interrupted", "failed", "cancelled",
"healed", "cleansed", "protected", "boosted").contains(result))
"healed", "cleansed", "protected", "boosted", "obstructed", "replaced").contains(result))
throw new IllegalArgumentException("Unsupported ability resolution");
if (!finished) { outcome = result; resolutionEvidence = Map.copyOf(Objects.requireNonNull(evidence)); }
}
@@ -479,11 +509,13 @@ public final class AbilityEngine implements Consumer<ControlInput>, AutoCloseabl
close();
}
@Override public void close() {
if (finished) return;
finished = true;
if (finished || closing) return;
closing = true;
publishCast();
if (outcome == null) outcome = "interrupted";
var resources = List.copyOf(owned); owned.clear();
for (var resource : resources) try { resource.close(); } catch (Exception failure) { AbilityEngine.this.report(key.character(), key.ability(), failure); }
finished = true;
for (var application : List.copyOf(observedEffects.keySet())) effectEnded(application, outcome);
if (castEvents != null) castEvents.resolved(audit, outcome,
outcome.equals("completed") ? "effect_finished_without_hit_claim" : "effect_ended", resolutionEvidence);
@@ -50,6 +50,10 @@ public interface AbilityExecution extends AutoCloseable {
default void effectEnded(UUID applicationId, String reason, Map<String, Object> evidence) {
effectEnded(applicationId, reason);
}
/** Attach an observed native admission to the one pending cast record; never publish guessed velocity. */
default void castEvidence(Map<String, Object> evidence) { }
/** Attach actual final native position to an already chosen or interrupted resolution during cleanup. */
default void resolutionEvidence(Map<String, Object> evidence) { }
/** An owner may specify a truthful terminal outcome before finishing. */
void resolve(String outcome);
void resolve(String outcome, Map<String, Object> evidence);
@@ -41,11 +41,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
final Location origin;
final Vector horizontal;
final Settings settings;
final long started = System.nanoTime();
final long boundTicks;
Location lastObserved;
boolean owned = true;
boolean closed;
State(AbilityExecution execution, Player player, Location origin, Vector horizontal, Settings settings) {
State(AbilityExecution execution, Player player, Location origin, Vector horizontal,
Settings settings, long boundTicks) {
this.execution = execution; this.player = player; this.origin = origin;
this.horizontal = horizontal; this.settings = settings;
this.horizontal = horizontal; this.settings = settings; this.boundTicks = boundTicks;
}
void finish(String outcome, Location observed) {
lastObserved = observed == null ? null : observed.clone();
execution.resolve(outcome);
execution.finish();
}
boolean stillOwns(Vector current) {
var horizontalCurrent = new Vector(current.getX(), 0, current.getZ());
@@ -57,6 +66,20 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
@Override public void close() {
if (closed) return;
closed = true; dashes.remove(player.getUniqueId(), this);
try {
var to = lastObserved == null ? player.getLocation() : lastObserved;
if (to != null) {
var evidence = new java.util.LinkedHashMap<String, Object>();
evidence.put("to_location", locationData(to));
evidence.put("elapsed_ms", Math.max(0, (System.nanoTime() - started) / 1_000_000L));
evidence.put("bound_ticks", boundTicks);
if (to.getWorld() != null && to.getWorld().equals(origin.getWorld())) {
double dx = to.getX() - origin.getX(), dz = to.getZ() - origin.getZ();
evidence.put("horizontal_distance", Math.hypot(dx, dz));
} else evidence.put("horizontal_distance_status", "not_comparable_cross_world");
execution.resolutionEvidence(evidence);
}
} catch (RuntimeException ignored) { /* Auditing cannot change native motion or cleanup. */ }
if (!owned) return;
Vector current = player.getVelocity();
if (!stillOwns(current)) return;
@@ -98,24 +121,41 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
if (closed || !execution.stillCurrent()) return;
var current = from(execution.definition());
var player = execution.input().player();
if (!accepts(execution.input())) { execution.finish(); return; }
var origin = player.getLocation().clone();
try { execution.castEvidence(Map.of("from_location", locationData(origin))); }
catch (RuntimeException ignored) { /* Capturing an origin cannot affect admission. */ }
if (!accepts(execution.input())) {
rejectedVelocity(execution, origin, "native_recheck_refused"); return;
}
Vector direction = player.getEyeLocation().getDirection(); direction.setY(0).normalize();
Vector before = player.getVelocity();
Vector horizontal = direction.multiply(current.speedPerTick());
Vector horizontal = direction.clone().multiply(current.speedPerTick());
var proposed = new Vector(horizontal.getX(), before.getY(), horizontal.getZ());
try { execution.castEvidence(Map.of("intended_direction", vectorData(direction),
"native_velocity_status", "pending_native_decision")); }
catch (RuntimeException ignored) { /* Capturing intent cannot affect native velocity. */ }
var event = new PlayerVelocityEvent(player, proposed.clone());
velocityEvents.accept(event);
if (event.isCancelled() || !finite(event.getVelocity())) { execution.finish(); return; }
if (event.isCancelled() || !finite(event.getVelocity())) {
rejectedVelocity(execution, origin, "native_velocity_cancelled_or_invalid"); return;
}
Vector accepted = event.getVelocity().clone(); accepted.setY(before.getY());
Vector acceptedHorizontal = new Vector(accepted.getX(), 0, accepted.getZ());
if (acceptedHorizontal.lengthSquared() < 1.0e-12) { execution.finish(); return; }
var state = new State(execution, player, player.getLocation().clone(), acceptedHorizontal, current);
if (acceptedHorizontal.lengthSquared() < 1.0e-12) {
rejectedVelocity(execution, origin, "native_velocity_without_horizontal_motion"); return;
}
try { execution.castEvidence(Map.of("accepted_velocity", vectorData(accepted),
"native_velocity_status", "accepted")); }
catch (RuntimeException ignored) { /* Capturing accepted velocity cannot affect movement. */ }
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
var state = new State(execution, player, origin, acceptedHorizontal, current, ticks + 1);
var previous = dashes.put(player.getUniqueId(), state);
if (previous != null) previous.execution.finish();
if (previous != null) previous.finish("replaced", previous.player.getLocation());
execution.own(state);
player.setVelocity(accepted);
long ticks = Math.max(1, (long) Math.ceil(current.distance() / acceptedHorizontal.length()));
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), execution::finish);
execution.later(Duration.ofMillis(Math.multiplyExact(ticks + 1, 50)), () -> {
execution.resolve("completed", Map.of("reason", "elapsed_bound")); execution.finish();
});
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -123,18 +163,44 @@ public final class Quickstep implements AbilityEffect, Listener, AutoCloseable {
var state = dashes.get(event.getPlayer().getUniqueId());
if (state == null || state.player != event.getPlayer()) return;
var to = event.getTo();
if (to == null || !state.execution.stillCurrent()) { state.execution.finish(); return; }
if (to == null || !state.execution.stillCurrent()) {
state.finish("interrupted", to); return;
}
state.lastObserved = to.clone();
if (to.getWorld() == null || !to.getWorld().equals(state.origin.getWorld())) {
state.finish("interrupted", to); return;
}
Vector current = state.player.getVelocity();
if (!state.stillOwns(current)) {
state.owned = false; state.execution.finish(); return;
state.owned = false; state.finish("replaced", to); return;
}
double dx = to.getX() - state.origin.getX(); double dz = to.getZ() - state.origin.getZ();
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()
|| new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
state.execution.finish();
if (dx * dx + dz * dz >= state.settings.distance() * state.settings.distance()) {
state.finish("completed", to);
} else if (new Vector(current.getX(), 0, current.getZ()).lengthSquared() < 1.0e-12) {
state.finish("obstructed", to);
}
}
private static void rejectedVelocity(AbilityExecution execution, Location origin, String reason) {
try { execution.castEvidence(Map.of("native_velocity_status", "cancelled"));
execution.resolve("cancelled", Map.of("reason", reason,
"to_location", locationData(origin), "horizontal_distance", 0)); }
catch (RuntimeException ignored) { /* Native rejection is not contingent on audit capture. */ }
execution.finish();
}
private static Map<String, Object> vectorData(Vector vector) {
return Map.of("x", vector.getX(), "y", vector.getY(), "z", vector.getZ());
}
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 boolean finite(Vector vector) {
return Double.isFinite(vector.getX()) && Double.isFinite(vector.getY()) && Double.isFinite(vector.getZ());
}
@@ -19,12 +19,18 @@ 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, int heldTicks, UUID executionId) {
float attackCharge, int heldTicks, int slot, 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, int slot) {
this(actionId, context, abilityId, targetId, targetType, targetLocation, requestedAt,
attackCharge, heldTicks, slot, UUID.randomUUID());
}
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());
attackCharge, heldTicks, -1);
}
public Cast(UUID actionId, EventInput.Context context, String abilityId, UUID targetId,
String targetType, Map<String, Object> targetLocation, Instant requestedAt, float attackCharge) {
@@ -55,7 +61,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(), input.heldTicks());
clock.instant(), input.attackStrength(), input.heldTicks(), input.slot());
emit(cast, "action.observed", "observed", Map.of("action_kind", "ability_cast"));
return cast;
} catch (RuntimeException ignored) {
@@ -64,6 +70,11 @@ public final class AbilityCastEventBridge {
}
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition) {
cast(cast, saved, deadline, definition, Map.of());
}
public void cast(Cast cast, CharacterRecord saved, Instant deadline, AbilityDefinition definition,
Map<String, Object> nativeEvidence) {
if (cast == null) return;
try {
var settings = new LinkedHashMap<String, Object>();
@@ -71,11 +82,18 @@ public final class AbilityCastEventBridge {
var data = new LinkedHashMap<String, Object>();
data.put("cooldown_until", deadline);
data.put("after_revision", saved.revision().toString());
if (cast.slot() >= 0) data.put("slot", cast.slot());
data.put("stage", "cooldown_committed");
data.put("activation", definition.activation().name().toLowerCase(java.util.Locale.ROOT));
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("quickstep")) {
data.put("intended_distance", definition.parameters().get("distance").toPlainString());
for (String key : java.util.List.of("from_location", "intended_direction", "accepted_velocity",
"native_velocity_status"))
if (nativeEvidence.containsKey(key)) data.put(key, nativeEvidence.get(key));
}
if (cast.abilityId().equals("longbow")) {
data.put("draw_ticks", cast.heldTicks());
data.put("draw_power", games.dmg.fantasystick.ability.Longbow.power(cast.heldTicks(),
@@ -71,11 +71,12 @@ public final class QuickstepSettings implements AutoCloseable {
public Quickstep.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) {
@@ -1324,8 +1324,11 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
private void quickstepConfiguration(CommandSender sender, String[] args) {
String key = args[1];
var actor = abilityConfigEvents == null ? null : abilityConfigEvents.capture(sender);
String before = quickstepSettings.value(key);
if (args.length == 2) {
sender.sendMessage(key + "=" + quickstepSettings.value(key) + " (applies to future Quickstep dashes only)");
if (abilityConfigEvents != null) abilityConfigEvents.read(actor, "quickstep", key, before);
sender.sendMessage(key + "=" + before + " (applies to future Quickstep dashes only)");
return;
}
java.util.concurrent.CompletionStage<games.dmg.fantasystick.persistence.QuickstepSettings.Update> operation;
@@ -1339,15 +1342,17 @@ public final class EquipmentAdminCommand implements TabExecutor, AutoCloseable {
try {
nextTick.accept(() -> {
if (closed) return;
if (failure == null) {
quickstep.configure(saved.settings()); abilities.replace(quickstep.definition());
quickstepSettings.publish(saved);
boolean applied = failure == null && quickstepSettings.publish(saved);
if (applied) {
quickstep.configure(quickstepSettings.settings()); abilities.replace(quickstep.definition());
if (abilityConfigEvents != null) abilityConfigEvents.changed(actor, "quickstep", 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 Quickstep dashes; active movement and deadlines are unchanged."
: "Quickstep configuration save failed; the effective setting is unchanged.");
sender.sendMessage(failure != null
? "Quickstep configuration save failed; the effective setting is unchanged."
: applied ? "Saved " + key + "=" + saved.value() + " for future Quickstep dashes; active movement and deadlines are unchanged."
: "Saved request was superseded; effective " + key + "=" + quickstepSettings.value(key) + ".");
});
} catch (RuntimeException rejected) { report(null, "config-response", rejected); }
});
@@ -45,10 +45,32 @@ class QuickstepNativeTest {
assertEquals(new Vector(1, 0.25, 0), velocity.get());
verify(a.player, never()).setFallDistance(anyFloat());
assertNotNull(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().get("quickstep"));
var from = new Location(a.world, 0, 64, 0); var to = new Location(a.world, 4, 64, 0);
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, to));
var from = a.core.location.clone(); var to = from.clone().add(4, 0, 0);
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(1, 0, 0)));
f.dispatcher.accept(new PlayerMoveEvent(a.player, from.clone().add(1, 0, 0), from.clone().add(2, 0, 0)));
f.dispatcher.accept(new PlayerMoveEvent(a.player, from.clone().add(2, 0, 0), to));
assertEquals(new Vector(0, 0.25, 0), velocity.get(), "Only remaining owned horizontal motion is removed");
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).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");
assertEquals("completed", result.get("outcome").getAsString());
assertEquals("12", cast.get("captured_dex").getAsString());
assertEquals(2, cast.get("slot").getAsInt());
assertEquals(4, cast.get("intended_distance").getAsDouble());
assertEquals(1, cast.getAsJsonObject("accepted_velocity").get("x").getAsDouble());
assertTrue(cast.getAsJsonObject("from_location").has("world_id"));
assertEquals(cast.getAsJsonObject("from_location").get("x").getAsDouble() + 4,
result.getAsJsonObject("to_location").get("x").getAsDouble());
assertEquals(4, result.get("horizontal_distance").getAsDouble());
assertEquals(cast.get("action_id").getAsString(), result.get("action_id").getAsString());
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")).count());
}
@Test void airborneFlyingGlidingOrMountedUseRefusesBeforeCooldownAndMovement() throws Exception {
@@ -68,6 +90,14 @@ class QuickstepNativeTest {
assertFalse(storage.find(a.core.record.id()).get().orElseThrow().cooldowns().containsKey("quickstep"));
assertTrue(a.messages.stream().anyMatch(message -> message.contains("not eligible")));
}
for (int route = 0; route < 4; route++) {
var own = events(data.resolve(Integer.toString(route))).stream()
.filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("refused")).count());
assertEquals(0, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
}
}
@Test void velocityCancellationObstacleAndUnrelatedReplacementAreAuthoritative() throws Exception {
@@ -92,17 +122,60 @@ class QuickstepNativeTest {
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Cancelled velocity applies no movement");
} else if (route == 1) {
velocity.set(new Vector(0, 0.3, 0));
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 0.2, 64, 0)));
var from = a.core.location.clone();
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(0.2, 0, 0)));
assertEquals(new Vector(0, 0.3, 0), velocity.get(), "Native collision stops movement early");
} else {
velocity.set(new Vector(0, 0.6, 2));
f.dispatcher.accept(new PlayerMoveEvent(a.player, new Location(a.world, 0, 64, 0),
new Location(a.world, 0.2, 64, 0)));
var from = a.core.location.clone();
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, from.clone().add(0.2, 0, 0)));
assertEquals(new Vector(0, 0.6, 2), velocity.get(), "Replacement movement relinquishes ownership");
}
verify(a.player, never()).setFallDistance(anyFloat());
}
for (int route = 0; route < 3; route++) {
var own = events(data.resolve("motion-" + route)).stream()
.filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
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");
assertEquals(new String[]{"cancelled", "obstructed", "replaced"}[route],
result.get("outcome").getAsString());
assertEquals(route == 0 ? 0 : 0.2, result.get("horizontal_distance").getAsDouble(), 1.0e-8);
assertEquals(cast.getAsJsonObject("from_location").get("x").getAsDouble() + (route == 0 ? 0 : 0.2),
result.getAsJsonObject("to_location").get("x").getAsDouble(), 1.0e-8);
assertEquals(route == 0 ? "cancelled" : "accepted",
cast.get("native_velocity_status").getAsString());
assertEquals(route != 0, cast.has("accepted_velocity"));
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")).count());
}
}
@Test void crossWorldMoveInterruptsOwnedDashWithoutInventingComparableTravel() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a);
var velocity = prepareMovement(a, new Vector(0, 0.25, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
var otherWorld = mock(org.bukkit.World.class);
when(otherWorld.getUID()).thenReturn(java.util.UUID.randomUUID());
when(otherWorld.getName()).thenReturn("another-test-world");
var from = a.core.location.clone();
f.dispatcher.accept(new PlayerMoveEvent(a.player, from, new Location(otherWorld, 123, 70, -8)));
assertEquals(new Vector(0, 0.25, 0), velocity.get());
f.plugin.setEnabled(false);
}
var result = 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("quickstep"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("interrupted", result.get("outcome").getAsString());
assertEquals("another-test-world", result.getAsJsonObject("to_location").get("world_name").getAsString());
assertEquals("not_comparable_cross_world", result.get("horizontal_distance_status").getAsString());
assertFalse(result.has("horizontal_distance"));
}
@Test void unlearnRelearnAndTransferRetainCooldownAndClearOnlyOwnedHorizontalMotion() throws Exception {
@@ -129,6 +202,90 @@ class QuickstepNativeTest {
f.players.put(b.core.id, b.player); f.activate(b); prepareMovement(b, new Vector(0.2, 0.3, 0.4)); use(f, b);
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("quickstep")).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());
var interruption = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("interrupted")).toList();
assertEquals(1, interruption.size());
assertTrue(interruption.getFirst().getAsJsonObject("data").getAsJsonObject("to_location").has("world_id"));
}
@Test void restartedNewHolderCannotRepeatCommittedDashBeforeTheOriginalDeadline() throws Exception {
java.nio.file.Files.writeString(data.resolve("config.yml"), "quickstep-cooldown-seconds: 60\n");
org.bukkit.inventory.ItemStack sheet;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.25, 0));
use(f, a); PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
verify(a.player, times(1)).setVelocity(new Vector(1, 0.25, 0));
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);
prepareMovement(b, new Vector(0, 0.3, 0)); use(f, b);
verify(b.player, never()).setVelocity(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("quickstep")).toList();
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast")).count());
var outcomes = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved"))
.map(event -> event.getAsJsonObject("data").get("outcome").getAsString()).toList();
assertEquals(2, outcomes.size(), outcomes.toString());
assertEquals(java.util.Set.of("completed", "refused"), java.util.Set.copyOf(outcomes),
"Original bounded window ended before transfer; restart only refuses its retained deadline");
}
@Test void airborneAfterCooldownAcknowledgmentCannotClaimCompletedMovement() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.4, 0));
use(f, a);
var response = f.tasks.poll(5, java.util.concurrent.TimeUnit.SECONDS); assertNotNull(response);
doReturn(false).when(a.player).isOnGround(); response.run();
verify(a.player, never()).setVelocity(any(Vector.class));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).toList();
var cast = own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.cast"))
.findFirst().orElseThrow().getAsJsonObject("data");
assertEquals("cancelled", cast.get("native_velocity_status").getAsString());
assertTrue(cast.getAsJsonObject("from_location").has("world_id"));
assertEquals(1, own.stream().filter(event -> event.get("type").getAsString().endsWith("ability.resolved")
&& event.getAsJsonObject("data").get("outcome").getAsString().equals("cancelled")).count());
}
@Test void failedNativeCooldownSaveEmitsFailureWithoutAClaimedDashOrVelocity() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var a = new FantasyStickLifecycleNativeTest.Actor(data); withStats(data, a, 12, true);
f.players.put(a.core.id, a.player); f.activate(a); prepareMovement(a, new Vector(0, 0.4, 0));
var storage = (CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters");
storage.close();
use(f, a);
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()).setVelocity(any(Vector.class));
f.plugin.setEnabled(false);
}
var own = events(data).stream().filter(event -> event.getAsJsonObject("data").has("ability_id")
&& event.getAsJsonObject("data").get("ability_id").getAsString().equals("quickstep")).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")).count());
}
@Test void failedCooldownPersistenceAppliesNoVelocity() {
@@ -166,6 +323,38 @@ class QuickstepNativeTest {
engine.close(); quickstep.close(); parties.close();
}
@Test void authorizedRegisteredConfigurationReadAndAcknowledgedChangeReachJsonlButFailuresDoNot() 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", "quickstep-distance-blocks"});
assertTrue(admin.messages.getLast().contains("quickstep-distance-blocks=4"));
f.command.execute(admin.port, "fstickadmin", new String[]{"config", "quickstep-distance-blocks", "7"});
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("7"), registry.find("quickstep").orElseThrow()
.parameters().get("distance"));
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", "quickstep-distance-blocks", "9"});
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", "quickstep-distance-blocks"});
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("quickstep")
&& (event.get("type").getAsString().endsWith("config.read")
|| event.get("type").getAsString().endsWith("config.changed"))).toList();
assertEquals(2, config.size());
assertEquals("4", config.getLast().getAsJsonObject("data").get("before").getAsString());
assertEquals("7", config.getLast().getAsJsonObject("data").get("after").getAsString());
}
@Test void acknowledgedUpdatesAffectFutureDashesWhileActiveDashKeepsCapturedBounds() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
@@ -240,6 +429,20 @@ class QuickstepNativeTest {
}
}
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 void learnAndAssign(FantasyStickLifecycleNativeTest.Fixture fixture,
FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.getInventory().setSelectedSlot(0); PartyBookNativeTest.open(fixture, actor);