feat(events): audit Speed I owned native layer lifecycle
Release / release (push) Failing after 11m20s

This commit is contained in:
dmg committed 2026-10-01 22:52:49 -04:00
1 parent 03c121287c
commit d20c38a216
7 files changed
+390 -15

No files matched your search

@@ -161,6 +161,8 @@ public final class FantasyStickPlugin extends JavaPlugin {
private games.dmg.fantasystick.passive.HealthSteal healthSteal;
private games.dmg.fantasystick.events.HealthStealEventBridge healthStealEvents;
private java.util.concurrent.CompletionStage<Void> healthStealAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.events.PassivePotionEventBridge passivePotionEvents;
private java.util.concurrent.CompletionStage<Void> passivePotionAuditDrain = java.util.concurrent.CompletableFuture.completedFuture(null);
private games.dmg.fantasystick.passive.SpeedI speedI;
private games.dmg.fantasystick.passive.LowLightVision lowLightVision;
private games.dmg.fantasystick.passive.PassiveRegistry passiveRegistry;
@@ -870,6 +872,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Health Steal event intent failed", failure));
healthSteal.attachEvents(healthStealEvents);
passivePotionEvents = new games.dmg.fantasystick.events.PassivePotionEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Passive potion event intent failed", failure));
speedI.attachEvents(passivePotionEvents);
recoveryEvents = new games.dmg.fantasystick.events.RecoveryEventBridge(
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(), sessions,
failure -> getLogger().log(Level.WARNING, "Recovery event intent failed", failure));
@@ -1055,6 +1061,11 @@ public final class FantasyStickPlugin extends JavaPlugin {
if (failure != null) getLogger().log(Level.WARNING, "Previous admin experience intent drain failed", failure);
if (replayGrantEvents != null) replayGrantEvents.replay();
});
var replayPassivePotionEvents = passivePotionEvents;
passivePotionAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous passive potion event drain failed", failure);
if (replayPassivePotionEvents != null) replayPassivePotionEvents.replay();
});
var replayHealthStealEvents = healthStealEvents;
healthStealAuditDrain.whenComplete((ignored, failure) -> {
if (failure != null) getLogger().log(Level.WARNING, "Previous Health Steal event drain failed", failure);
@@ -1149,6 +1160,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
var closingSheetPackEvents = sheetResourcePackEvents; sheetResourcePackEvents = null;
var closingGrantEvents = adminExperienceEvents; adminExperienceEvents = null;
var closingHealthStealEvents = healthStealEvents; healthStealEvents = null;
var closingPassivePotionEvents = passivePotionEvents; passivePotionEvents = null;
var closingRecoveryEvents = recoveryEvents; recoveryEvents = null;
var closingIdentityAuditEvents = identityAuditEvents; identityAuditEvents = null;
var closingAdminRevivalAuditEvents = adminRevivalEvents; adminRevivalEvents = null;
@@ -1511,6 +1523,10 @@ public final class FantasyStickPlugin extends JavaPlugin {
return null;
}).thenCompose(ignored -> closingGrantEvents.drained());
});
if (closingPassivePotionEvents != null) cleanup("passive potion event intent drain", () -> {
closingPassivePotionEvents.close();
passivePotionAuditDrain = closingPassivePotionEvents.drained();
});
if (closingHealthStealEvents != null) cleanup("Health Steal event intent drain", () -> {
var storageDrained = closingCharacters == null
? java.util.concurrent.CompletableFuture.<Void>completedFuture(null) : closingCharacters.drained();
@@ -898,7 +898,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
|| event.getTag() == null) return;
var player=connection.getPlayer();String raw=event.getTag().string();if(raw.length()>120)return;var match=ABILITY_PAYLOAD.matcher(raw);if(!match.matches())return;String passive=match.group(2);var definition=passives.find(passive).orElse(null);if(definition==null)return;var view=passiveViews.get(player.getUniqueId());boolean owned=games.dmg.fantasystick.passive.QuickerRegenerationI.ID.equals(passive)
|| games.dmg.fantasystick.passive.QuickerRegenerationII.ID.equals(passive)
|| games.dmg.fantasystick.passive.HealthSteal.ID.equals(passive);
|| games.dmg.fantasystick.passive.HealthSteal.ID.equals(passive)
|| games.dmg.fantasystick.passive.SpeedI.ID.equals(passive);
if (view == null || view.input().player() != player || !view.nonce().equals(match.group(1)) || !view.ids().contains(passive)
|| !passiveViews.remove(player.getUniqueId(), view) || !view.input().stillCurrent()) {if(owned)refusePassive(player,definition,unlock,"stale_forged_or_repeated_action");return;}
var audit=owned&&passiveEvents!=null?passiveEvents.begin(view.input(),view.before(),definition,unlock):null;
@@ -0,0 +1,92 @@
package games.dmg.fantasystick.events;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
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.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Function;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
/** Records real owned potion layers, not periodic evaluation or a desired passive state. */
public final class PassivePotionEventBridge implements AutoCloseable {
public record Application(UUID id, String passive, EventInput.Context context, Instant startedAt,
String effectType, int amplifier) { }
private final Consumer<EventInput> sink;
private final Clock clock;
private final FeatureEventOutbox outbox;
public PassivePotionEventBridge(Consumer<EventInput> sink, Clock clock) {
this.sink = Objects.requireNonNull(sink); this.clock = Objects.requireNonNull(clock); outbox = null;
}
public PassivePotionEventBridge(Function<EventInput, CompletionStage<LocalEventLog.Delivery>> sink,
Clock clock, Path data, Consumer<Throwable> failures) {
this.sink = event -> sink.apply(event); this.clock = Objects.requireNonNull(clock);
outbox = new FeatureEventOutbox(data, "passive-potion-event-intents",
java.util.Set.of("effect.applied", "effect.ended"), sink, failures);
}
public Application applied(String passive, Player player, EquipmentSessions.Active active,
String characterName, PotionEffect owned, PotionEffect previous, String reason) {
try {
var context = new EventInput.Context(player.getUniqueId(), player.getName(), active.character(),
characterName, active.session(), location(player));
var application = new Application(UUID.randomUUID(), passive, context, clock.instant(),
owned.getType().getKey().toString(), owned.getAmplifier());
emit("effect.applied", application, context, reason,
previous == null ? "no_unrelated_layer_displaced" : "weaker_native_layer_retained", previous, false);
return application;
} catch (RuntimeException unavailable) { return null; }
}
public void ended(Application application, Player player, String reason, String disposition,
PotionEffect current, boolean restored) {
if (application == null) return;
try {
var before = application.context();
var context = new EventInput.Context(before.playerId(), before.playerName(), before.characterId(),
before.characterName(), before.sessionId(), location(player));
emit("effect.ended", application, context, reason, disposition, current, restored);
} catch (RuntimeException ignored) { /* Audit failure cannot retry or veto native cleanup. */ }
}
private void emit(String type, Application application, EventInput.Context context, String reason,
String disposition, PotionEffect nativeEffect, boolean restored) {
try {
var data = new LinkedHashMap<String, Object>();
data.put("passive_id", application.passive()); data.put("effect_id", application.id().toString());
data.put("application_id", application.id().toString());
data.put("owner_character_id", context.characterId().toString()); data.put("owner_session_id", context.sessionId().toString());
data.put("target_player_id", context.playerId().toString()); data.put("effect_type", application.effectType());
data.put("amplifier", application.amplifier()); data.put("started_at", application.startedAt().toString());
data.put("reason", reason); data.put("native_disposition", disposition); data.put("unrelated_effect_restored", restored);
data.put("native_publication", "acknowledged");
data.put("location_capture_quality", context.location() == null ? "unavailable" : "exact");
if (nativeEffect != null) data.put("unrelated_native_effect", Map.of(
"effect_type", nativeEffect.getType().getKey().toString(), "amplifier", nativeEffect.getAmplifier(),
"duration_ticks", nativeEffect.getDuration(), "infinite", nativeEffect.isInfinite()));
var event = new EventInput(id(application.id(), type), application.id(), clock.instant(), type, context, Map.copyOf(data));
if (outbox != null) outbox.accepted(event); else sink.accept(event);
} catch (RuntimeException ignored) { /* No native effect operation is reachable from the event exporter. */ }
}
private EventInput.Location location(Player player) {
try {
var value = player.getLocation(); if (value == null || value.getWorld() == null) return null;
return new EventInput.Location(value.getWorld().getUID(), value.getWorld().getName(), value.getX(), value.getY(),
value.getZ(), clock.instant(), "exact");
} catch (RuntimeException unavailable) { return null; }
}
private static UUID id(UUID application, String type) {
return UUID.nameUUIDFromBytes(("fantasystick:passive-potion:" + application + ":" + type).getBytes(StandardCharsets.UTF_8));
}
public void replay() { if (outbox != null) outbox.replay(); }
public CompletionStage<Void> drained() {
return outbox == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : outbox.drained();
}
@Override public void close() { if (outbox != null) outbox.close(); }
}
@@ -1,6 +1,7 @@
package games.dmg.fantasystick.passive;
import games.dmg.fantasystick.session.EquipmentSessions;
import games.dmg.fantasystick.events.PassivePotionEventBridge;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -20,6 +21,8 @@ final class PersistentPotionEffect implements AutoCloseable {
PotionEffect displaced;
long displacedUntil;
long refreshedAt;
long applications;
PassivePotionEventBridge.Application application;
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
void refresh() {
@@ -28,8 +31,11 @@ final class PersistentPotionEffect implements AutoCloseable {
PotionEffect current;
try { current = player.getPotionEffect(type); }
catch (RuntimeException unavailable) { return; }
if (owned != null && !same(current, owned)) { owned = null; displaced = null; }
if (!eligible()) { remove(); return; }
if (owned != null && !same(current, owned)) {
endAudit("native_layer_replaced_or_removed", current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
owned = null; displaced = null;
}
if (!eligible()) { remove(endReason()); return; }
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
try {
@@ -37,6 +43,14 @@ final class PersistentPotionEffect implements AutoCloseable {
owned = effect; displaced = current;
displacedUntil = current == null ? 0 : current.isInfinite()
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
if (events != null) {
try {
var record = sessions.currentRecord(active).orElse(null);
if (record != null) application = events.applied(passive, player, active, record.name(), owned, current,
applications == 0 ? "learned_current_session" : "owned_layer_reestablished");
} catch (RuntimeException ignored) { /* Audit capture cannot discard native ownership. */ }
}
applications++;
}
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
}
@@ -47,7 +61,21 @@ final class PersistentPotionEffect implements AutoCloseable {
return record != null && !record.dead() && record.passives().contains(passive);
}
void remove() {
String endReason() {
if (events == null) return "owner_no_longer_eligible";
try {
if (closed) return "plugin_shutdown";
if (!sessions.current(active)) return "session_no_longer_current";
var record = sessions.currentRecord(active).orElse(null);
if (record != null && record.dead()) return "character_defeated";
return "passive_unlearned_or_character_unavailable";
} catch (RuntimeException ignored) { return "owner_no_longer_eligible"; }
}
void endAudit(String reason, String disposition, PotionEffect current, boolean restored) {
if (events != null && application != null) events.ended(application, player, reason, disposition, current, restored);
application = null;
}
void remove(String reason) {
if (owned != null) {
PotionEffect current;
try { current = player.getPotionEffect(type); }
@@ -55,14 +83,21 @@ final class PersistentPotionEffect implements AutoCloseable {
if (same(current, owned)) {
try {
player.removePotionEffect(type);
boolean restored = false;
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
restored = player.addPotionEffect(new PotionEffect(displaced.getType(), duration, displaced.getAmplifier(),
displaced.isAmbient(), displaced.hasParticles(), displaced.hasIcon()), true);
}
if (events != null) {
try {
var after = player.getPotionEffect(type);
if (!same(after, owned)) endAudit(reason, "owned_layer_removed", after, restored);
} catch (RuntimeException unavailable) { /* No native acknowledgement: do not invent an end record. */ }
}
} catch (RuntimeException unavailable) { return; }
}
} else endAudit(reason, current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
owned = null; displaced = null;
}
states.remove(active.player(), this);
@@ -80,6 +115,12 @@ final class PersistentPotionEffect implements AutoCloseable {
private long sweepGeneration;
private org.bukkit.scheduler.BukkitTask sweepTask;
private boolean closed;
private PassivePotionEventBridge events;
void attachEvents(PassivePotionEventBridge bridge) {
if (closed || events != null || !states.isEmpty()) throw new IllegalStateException("Passive potion events already attached or evaluation started");
events = Objects.requireNonNull(bridge);
}
PersistentPotionEffect(String passive, PotionEffectType type, int amplifier, boolean replaceWeaker,
EquipmentSessions sessions) {
@@ -119,7 +160,7 @@ final class PersistentPotionEffect implements AutoCloseable {
var state = states.get(active.player());
boolean learned = !snapshot.record().dead() && snapshot.record().passives().contains(passive);
if (!learned || !sessions.current(active)) { if (state != null) state.refresh(); return; }
if (state != null && !state.active.equals(active)) { state.remove(); state = states.get(active.player()); }
if (state != null && !state.active.equals(active)) { state.remove("owner_session_replaced"); state = states.get(active.player()); }
if (state == null) {
var player = sessions.currentPlayer(active).orElse(null);
if (player == null) return;
@@ -139,7 +180,7 @@ final class PersistentPotionEffect implements AutoCloseable {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(states.values())) state.remove();
for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown");
states.clear();
}
}
@@ -23,6 +23,7 @@ public final class SpeedI implements AutoCloseable {
"Does not stack with equal Speed I and preserves stronger unrelated Speed effects."));
}
public void attachEvents(games.dmg.fantasystick.events.PassivePotionEventBridge bridge) { effect.attachEvents(bridge); }
public void install(org.bukkit.plugin.Plugin plugin) { effect.install(plugin); }
public void sweep() { effect.sweep(); }
@Override public void close() { effect.close(); }
@@ -0,0 +1,161 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import com.google.gson.JsonObject;
import games.dmg.fantasystick.events.LocalEventLog;
import games.dmg.fantasystick.events.PassivePotionEventBridge;
import games.dmg.fantasystick.passive.SpeedI;
import games.dmg.fantasystick.persistence.CharacterStorage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SpeedIEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void feedbackOffMilkAndNativeOverlapCreateOnlyRealOwnedLayersWithUnrelatedRestoration() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f);
var a = new FantasyStickLifecycleNativeTest.Actor(data);
SpeedINativeTest.withDex(data, a, List.of("speed_i", "", ""));
var effects = SpeedINativeTest.effects(a.player); f.players.put(a.core.id, a.player);
assertTrue(f.playerCommand.getExecutor().onCommand(a.player, f.playerCommand, "fstick", new String[]{"feedback", "off"}));
f.tick(); f.activate(a); var speed = speed(f); speed.sweep(); await(f, "effect.applied", 1);
var initial = current(f, a); int messages = a.messageComponents.size();
for (int n = 0; n < 30; n++) speed.sweep();
assertEquals(1, rows("effect.applied").size()); assertTrue(rows("effect.ended").isEmpty());
effects.clear(); speed.sweep(); await(f, "effect.applied", 2); await(f, "effect.ended", 1);
var stronger = new PotionEffect(PotionEffectType.SPEED, 200, 1);
effects.put(PotionEffectType.SPEED, stronger); speed.sweep(); await(f, "effect.ended", 2);
assertSame(stronger, effects.get(PotionEffectType.SPEED)); assertEquals(2, rows("effect.applied").size());
effects.clear(); speed.sweep(); await(f, "effect.applied", 3);
var equal = new PotionEffect(PotionEffectType.SPEED, 100, 0);
effects.put(PotionEffectType.SPEED, equal); speed.sweep(); await(f, "effect.ended", 3);
assertSame(equal, effects.get(PotionEffectType.SPEED));
effects.clear(); speed.sweep(); await(f, "effect.applied", 4);
var weaker = new PotionEffect(PotionEffectType.SPEED, 20, -1);
effects.put(PotionEffectType.SPEED, weaker); speed.sweep(); await(f, "effect.applied", 5); await(f, "effect.ended", 4);
assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier());
assertEquals(initial, current(f, a), "Effect lifecycle/reconciliation cannot grant support or mutate saved progression");
assertEquals(messages, a.messageComponents.size(), "Feedback-off lifecycle remains audited, not messaged");
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, SpeedINativeTest.action(a, "[Unlearn]", "speed_i"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); speed.sweep(); await(f, "effect.ended", 5);
assertEquals(-1, effects.get(PotionEffectType.SPEED).getAmplifier());
assertTrue(effects.get(PotionEffectType.SPEED).getDuration() > 0);
var applied = rows("effect.applied"); var ended = rows("effect.ended");
assertEquals(5, applied.stream().map(row -> values(row).get("effect_id").getAsString()).distinct().count());
assertEquals(applied.stream().map(row -> values(row).get("effect_id").getAsString()).collect(java.util.stream.Collectors.toSet()),
ended.stream().map(row -> values(row).get("effect_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
assertEquals("exact", values(applied.getFirst()).get("location_capture_quality").getAsString());
assertEquals(12d, values(applied.getFirst()).getAsJsonObject("location").get("x").getAsDouble());
assertEquals(a.core.id.toString(), values(applied.getFirst()).get("target_player_id").getAsString());
assertTrue(ended.stream().anyMatch(row -> values(row).get("unrelated_effect_restored").getAsBoolean()));
assertTrue(ended.stream().anyMatch(row -> values(row).get("native_disposition").getAsString().equals("different_native_layer_preserved")));
assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row -> row.get("type").getAsString().contains("support")
|| row.get("type").getAsString().contains("reward")));
f.plugin.setEnabled(false); assertEquals(-1, effects.get(PotionEffectType.SPEED).getAmplifier());
}
}
@Test void rejectedApplyAndShutdownIntentsReplaySameIdentitiesWithoutNativeReapplication() throws Exception {
List<Properties> intents;
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = new FantasyStickLifecycleNativeTest.Actor(data);
SpeedINativeTest.withDex(data, a, List.of("speed_i", "", ""));
var effects = SpeedINativeTest.effects(a.player);
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
f.players.put(a.core.id, a.player); f.activate(a); var speed = speed(f); speed.sweep();
assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier()); var saved = current(f, a);
for (int n = 0; n < 20; n++) speed.sweep();
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
assertEquals(saved, current(f, a)); f.plugin.setEnabled(false); f.awaitWorkers();
((java.util.concurrent.CompletionStage<?>) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "passivePotionAuditDrain"))
.toCompletableFuture().get(5, TimeUnit.SECONDS);
assertTrue(rows("effect.applied").isEmpty()); assertTrue(rows("effect.ended").isEmpty());
intents = new ArrayList<>();
try (var files = Files.list(data.resolve("passive-potion-event-intents"))) {
for (var pending : files.filter(p -> p.getFileName().toString().endsWith(".pending")).toList()) {
var properties = new Properties(); try (var input = Files.newInputStream(pending)) { properties.load(input); }
intents.add(properties);
}
}
assertEquals(2, intents.size()); clearInvocations(a.player);
try (var log = LocalEventLog.open(data, Clock.systemUTC(), new LocalEventLog.RuntimeInfo("native", "26.2", "test"), 1024, 1048576, ignored -> { })) {
var replay = new PassivePotionEventBridge(log::emit, Clock.systemUTC(), data, failure -> fail(failure));
try {
log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
replay.replay();
} finally { replay.close(); }
replay.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 1 && rows("effect.ended").size() == 1);
assertEquals(1, rows("effect.applied").size()); assertEquals(1, rows("effect.ended").size());
for (var fields : intents) {
var row = ReskillEventsNativeTest.records(data).stream().filter(r -> r.get("id").getAsString().equals(fields.getProperty("id"))).findFirst().orElseThrow();
assertEquals(fields.getProperty("action"), values(row).get("action_id").getAsString());
assertEquals(fields.getProperty("action"), values(row).get("effect_id").getAsString());
}
assertEquals("plugin_shutdown", values(rows("effect.ended").getFirst()).get("reason").getAsString());
verify(a.player, never()).addPotionEffect(any(), eq(true)); verify(a.player, never()).removePotionEffect(any());
assertEquals(saved, new games.dmg.fantasystick.persistence.YamlCharacterRepository(data).load(saved.id()).orElseThrow());
}
}
}
@Test void rejectedNativeAdditionAndThrowingAuditCannotInventOrPreventOwnedNativeLifecycle() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = new FantasyStickLifecycleNativeTest.Actor(data);
SpeedINativeTest.withDex(data, a, List.of("speed_i", "", ""));
var effects = SpeedINativeTest.effects(a.player);
doReturn(false).when(a.player).addPotionEffect(any(), eq(true)); f.players.put(a.core.id, a.player); f.activate(a);
var speed = speed(f); speed.sweep(); assertTrue(rows("effect.applied").isEmpty()); assertTrue(effects.isEmpty());
speed.close();
var isolated = new SpeedI((games.dmg.fantasystick.session.EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions")); var attempts = new ArrayList<games.dmg.fantasystick.events.EventInput>();
isolated.attachEvents(new PassivePotionEventBridge(event -> { attempts.add(event); throw new IllegalStateException("audit unavailable"); }, Clock.systemUTC()));
effects = SpeedINativeTest.effects(a.player);
var unavailable = mock(org.bukkit.Location.class); when(unavailable.getWorld()).thenThrow(new IllegalArgumentException("world unloaded"));
doReturn(unavailable).when(a.player).getLocation(); isolated.sweep();
assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier());
for (int n = 0; n < 20; n++) isolated.sweep(); assertEquals(1, attempts.size());
assertNull(attempts.getFirst().context().location()); assertEquals("unavailable", attempts.getFirst().data().get("location_capture_quality"));
isolated.close(); isolated.close(); isolated.sweep(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
assertEquals(List.of("effect.applied", "effect.ended"), attempts.stream().map(event -> event.type()).toList());
assertEquals(attempts.getFirst().actionId(), attempts.getLast().actionId());
}
}
private static SpeedI speed(FantasyStickLifecycleNativeTest.Fixture f) {
return (SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI");
}
private static games.dmg.fantasystick.persistence.CharacterRecord current(FantasyStickLifecycleNativeTest.Fixture f,
FantasyStickLifecycleNativeTest.Actor a) throws Exception {
return ((CharacterStorage) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "characters")).find(a.core.record.id()).get().orElseThrow();
}
private static JsonObject values(JsonObject row) { return row.getAsJsonObject("data"); }
private List<JsonObject> rows(String type) {
try { return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().equals(LocalEventLog.TYPE_PREFIX + type)
&& values(row).has("passive_id") && values(row).get("passive_id").getAsString().equals("speed_i")).toList(); }
catch (Exception failure) { throw new IllegalStateException("Cannot parse Speed I JSONL", failure); }
}
private void await(FantasyStickLifecycleNativeTest.Fixture f, String type, int count) throws Exception {
ReskillEventsNativeTest.await(f, () -> rows(type).size() == count);
}
private static void ready(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
var log = (LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog");
log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
}
@@ -40,9 +40,13 @@ class SpeedINativeTest {
var aEffects = effects(a.player); f.players.put(a.core.id, a.player); f.activate(a);
var speed = (SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI");
PartyBookNativeTest.open(f, a); assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("SpeedIDEX14[Learn]"));
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "speed_i")); settle(f); speed.sweep();
var learnAction = action(a, "[Learn]", "speed_i");
PartyBookNativeTest.click(f, a, learnAction); settle(f); speed.sweep();
var learned = record(f, a); assertEquals(List.of("speed_i", "", ""), learned.passives());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints()); assertEquals(0, aEffects.get(PotionEffectType.SPEED).getAmplifier());
PartyBookNativeTest.click(f, a, learnAction); settle(f); speed.sweep();
assertEquals(learned, record(f, a), "Duplicate book token cannot spend or acknowledge a second learn");
verify(a.player, times(1)).addPotionEffect(any(), eq(true));
var sheet = a.core.inventory.getItemInOffHand().clone(); a.core.inventory.setItemInOffHand(null);
f.changed(a); PartyBookNativeTest.drain(f); f.awaitCharacterAndReceipt(a); speed.sweep();
@@ -50,6 +54,10 @@ class SpeedINativeTest {
var b = new FantasyStickLifecycleNativeTest.Actor(data); var bEffects = effects(b.player);
b.core.inventory.setItemInOffHand(sheet); f.players.put(b.core.id, b.player); f.activate(b); speed.sweep();
assertEquals(0, bEffects.get(PotionEffectType.SPEED).getAmplifier());
PartyBookNativeTest.click(f, a, learnAction); settle(f); speed.sweep();
assertEquals(learned.passives(), storage(f).find(learned.id()).get().orElseThrow().passives());
assertFalse(aEffects.containsKey(PotionEffectType.SPEED), "Transferred-away stale token cannot reestablish an old holder's layer");
assertEquals(0, bEffects.get(PotionEffectType.SPEED).getAmplifier());
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "speed_i")); settle(f); speed.sweep();
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("speed_i"));
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints()); assertFalse(bEffects.containsKey(PotionEffectType.SPEED));
@@ -74,6 +82,32 @@ class SpeedINativeTest {
var death = new org.bukkit.event.entity.PlayerDeathEvent(c.player, mock(org.bukkit.damage.DamageSource.class),
new java.util.ArrayList<>(), 0, net.kyori.adventure.text.Component.text("defeat"), true);
f.dispatcher.accept(death); settle(f); speed.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.SPEED));
// Native acknowledgement does not itself imply asynchronous intent export has completed.
ReskillEventsNativeTest.await(f, () -> ofType(events(data), "effect.ended").size() == 3);
f.plugin.setEnabled(false);
var rows = events(data);
var learns = ofType(rows, "passive.learned"); var refunds = ofType(rows, "passive.unlearned");
assertEquals(1, learns.size(), "Actual native Speed I book learning must reach JSONL");
assertEquals(1, refunds.size(), "Only the acknowledged unlearn can refund and claim success");
var learn = learns.getFirst().getAsJsonObject("data");
assertEquals("40", learn.get("points_before").getAsString()); assertEquals("39", learn.get("points_after").getAsString());
assertEquals("14", learn.getAsJsonObject("stat_values_before").get("DEX").getAsString());
assertEquals(a.core.id.toString(), learn.get("player_id").getAsString());
assertEquals(b.core.id.toString(), refunds.getFirst().getAsJsonObject("data").get("player_id").getAsString());
var applied = ofType(rows, "effect.applied"); var ended = ofType(rows, "effect.ended");
assertEquals(3, applied.size(), "Learn, transfer and restored character each own one real native layer");
assertEquals(3, ended.size(), "Exit, unlearn and defeat each end only their matching owned layer");
assertEquals(applied.stream().map(row -> row.getAsJsonObject("data").get("effect_id").getAsString()).collect(java.util.stream.Collectors.toSet()),
ended.stream().map(row -> row.getAsJsonObject("data").get("effect_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
for (var row : applied) {
var values = row.getAsJsonObject("data"); assertEquals(0, values.get("amplifier").getAsInt());
assertEquals("minecraft:speed", values.get("effect_type").getAsString());
assertEquals(learned.id().toString(), values.get("character_id").getAsString());
assertTrue(values.has("session_id")); assertTrue(values.has("location"));
assertEquals("acknowledged", values.get("native_publication").getAsString());
}
assertEquals(java.util.Set.of(a.core.id.toString(), b.core.id.toString(), c.core.id.toString()),
applied.stream().map(row -> row.getAsJsonObject("data").get("player_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
}
}
@@ -122,7 +156,7 @@ class SpeedINativeTest {
expiredWeak.close();
}
@Test void rallyOverlapNeverStacksAndEitherOwnerLeavesTheOtherApplicableLayer() {
@Test void rallyOverlapNeverStacksAndEitherOwnerLeavesTheOtherApplicableLayer() throws Exception {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var player = mock(Player.class);
var effects = effects(player); UUID id = UUID.randomUUID(); var active = new EquipmentSessions.Active(id, UUID.randomUUID(), UUID.randomUUID());
var record = mock(CharacterRecord.class); when(record.passives()).thenReturn(List.of("speed_i", "", ""));
@@ -131,7 +165,14 @@ class SpeedINativeTest {
when(sessions.active(id)).thenReturn(java.util.Optional.of(active)); when(sessions.current(active)).thenReturn(true);
when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
when(sessions.currentRecord(active)).thenReturn(java.util.Optional.of(record)); when(sessions.currentPlayer(active)).thenReturn(java.util.Optional.of(player));
var speed = new SpeedI(sessions); speed.sweep();
when(player.getName()).thenReturn("Speed holder"); when(record.name()).thenReturn("Speed owner");
var world = mock(org.bukkit.World.class); when(world.getUID()).thenReturn(UUID.randomUUID()); when(world.getName()).thenReturn("native-speed-world");
when(player.getLocation()).thenReturn(new Location(world, 12, 70, -8));
try (var log = games.dmg.fantasystick.events.LocalEventLog.open(data, java.time.Clock.systemUTC(),
new games.dmg.fantasystick.events.LocalEventLog.RuntimeInfo("native", "26.2", "test"), 1024, 1048576, ignored -> { })) {
log.ready().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS);
var audit = new games.dmg.fantasystick.events.PassivePotionEventBridge(event -> log.emit(event), java.time.Clock.systemUTC());
var speed = new SpeedI(sessions); speed.attachEvents(audit); speed.sweep();
var rally = new RallyingCry(RallyingCry.Settings.defaults(), sessions, parties,
new OutgoingDamageBonus(sessions, parties, ignored -> { }));
var execution = mock(AbilityExecution.class); var input = mock(ControlInput.class);
@@ -145,20 +186,42 @@ class SpeedINativeTest {
new OutgoingDamageBonus(sessions, parties, ignored -> { }));
when(execution.definition()).thenReturn(rallyFirst.definition()); rallyFirst.activate(execution);
assertTrue(effects.containsKey(PotionEffectType.SPEED));
var passiveSecond = new SpeedI(sessions); passiveSecond.sweep(); rallyFirst.close();
var passiveSecond = new SpeedI(sessions); passiveSecond.attachEvents(audit); passiveSecond.sweep(); rallyFirst.close();
assertFalse(effects.containsKey(PotionEffectType.SPEED)); passiveSecond.sweep();
assertTrue(effects.containsKey(PotionEffectType.SPEED), "The learned passive resumes after Rally-owned Speed I ends");
passiveSecond.close();
}
var rows = events(data); var applied = ofType(rows, "effect.applied"); var ended = ofType(rows, "effect.ended");
assertEquals(2, applied.size(), "An equal Rally-owned Speed layer cannot claim another passive application");
assertEquals(2, ended.size(), "Closing each actually established passive layer ends only that owner");
assertEquals(applied.stream().map(row -> row.getAsJsonObject("data").get("effect_id").getAsString()).collect(java.util.stream.Collectors.toSet()),
ended.stream().map(row -> row.getAsJsonObject("data").get("effect_id").getAsString()).collect(java.util.stream.Collectors.toSet()));
for (var row : applied) {
assertEquals(active.session().toString(), row.getAsJsonObject("data").get("session_id").getAsString());
assertEquals(active.character().toString(), row.getAsJsonObject("data").get("owner_character_id").getAsString());
assertEquals(id.toString(), row.getAsJsonObject("data").get("target_player_id").getAsString());
}
}
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
private static List<com.google.gson.JsonObject> events(Path data) {
try {
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.getAsJsonObject("data").has("passive_id")
&& row.getAsJsonObject("data").get("passive_id").getAsString().equals("speed_i")).toList();
} catch (Exception failure) { throw new IllegalStateException("Cannot parse actual Speed I exports", failure); }
}
private static List<com.google.gson.JsonObject> ofType(List<com.google.gson.JsonObject> rows, String type) {
return rows.stream().filter(row -> row.get("type").getAsString()
.equals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + type)).toList();
}
static Map<PotionEffectType, PotionEffect> effects(Player player) {
var result = new HashMap<PotionEffectType, PotionEffect>();
doAnswer(call -> result.get(call.getArgument(0))).when(player).getPotionEffect(any());
doAnswer(call -> { PotionEffect effect = call.getArgument(0); result.put(effect.getType(), effect); return true; })
.when(player).addPotionEffect(any(), eq(true));
doAnswer(call -> { result.remove(call.getArgument(0)); return null; }).when(player).removePotionEffect(any()); return result;
}
private static void withDex(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
static void withDex(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
var repository = new YamlCharacterRepository(data); var old = repository.load(actor.core.record.id()).orElseThrow();
var stats = new EnumMap<>(old.stats()); stats.put(Stat.DEX, BigInteger.valueOf(14));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(),
@@ -174,7 +237,7 @@ class SpeedINativeTest {
private static void settle(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f);
}
private static net.minecraft.network.chat.ClickEvent.Custom action(
static net.minecraft.network.chat.ClickEvent.Custom action(
FantasyStickLifecycleNativeTest.Actor actor, String label, String passive) {
var content = org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(actor.books.getLast())
.get(net.minecraft.core.component.DataComponents.WRITTEN_BOOK_CONTENT); assertNotNull(content);