feat(events): audit Low-Light Vision native lifecycle
Release / release (push) Failing after 10m47s

This commit is contained in:
dmg committed 2026-10-02 07:21:11 -04:00
1 parent d20c38a216
commit f1b4479e83
5 files changed
+205 -4

No files matched your search

@@ -876,6 +876,7 @@ public final class FantasyStickPlugin extends JavaPlugin {
eventLog::emit, Clock.systemUTC(), getDataFolder().toPath(),
failure -> getLogger().log(Level.WARNING, "Passive potion event intent failed", failure));
speedI.attachEvents(passivePotionEvents);
lowLightVision.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));
@@ -899,7 +899,8 @@ public final class CharacterBookController implements Consumer<ControlInput>, Au
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.SpeedI.ID.equals(passive);
|| games.dmg.fantasystick.passive.SpeedI.ID.equals(passive)
|| games.dmg.fantasystick.passive.LowLightVision.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;
@@ -23,6 +23,7 @@ public final class LowLightVision implements AutoCloseable {
"Preserves unrelated Night Vision and does not change light, darkness or mob behavior."));
}
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,156 @@
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.LowLightVision;
import games.dmg.fantasystick.persistence.CharacterStorage;
import games.dmg.fantasystick.session.EquipmentSessions;
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.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 LowLightVisionEventsNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void feedbackOffUnrelatedVisionMilkAndBookRefundPreserveBlindnessDarknessWithoutSpam() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(); var effects = LowLightVisionNativeTest.effects(a.player);
var blindness = new PotionEffect(PotionEffectType.BLINDNESS, 200, 0);
var darkness = new PotionEffect(PotionEffectType.DARKNESS, 200, 0);
// Vision's policy preserves every unrelated Night Vision layer, even a weaker one.
var unrelated = new PotionEffect(PotionEffectType.NIGHT_VISION, 40, -1);
effects.put(PotionEffectType.BLINDNESS, blindness); effects.put(PotionEffectType.DARKNESS, darkness);
effects.put(PotionEffectType.NIGHT_VISION, unrelated); f.players.put(a.core.id, a.player);
var ordinary = new FantasyStickLifecycleNativeTest.Actor(data); var ordinaryEffects = LowLightVisionNativeTest.effects(ordinary.player);
f.players.put(ordinary.core.id, ordinary.player);
vision(f).sweep(); assertTrue(ordinaryEffects.isEmpty()); assertTrue(rows("effect.applied").isEmpty());
assertSame(unrelated, effects.get(PotionEffectType.NIGHT_VISION), "An inactive learned record cannot take ownership from native vision");
assertTrue(f.playerCommand.getExecutor().onCommand(a.player, f.playerCommand, "fstick", new String[]{"feedback", "off"}));
f.tick(); f.activate(a); var vision = vision(f); vision.sweep(); var saved = current(f, a);
for (int n = 0; n < 30; n++) vision.sweep();
assertSame(unrelated, effects.get(PotionEffectType.NIGHT_VISION)); assertTrue(rows("effect.applied").isEmpty());
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep(); await(f, "effect.applied", 1);
int messages = a.messageComponents.size();
for (int n = 0; n < 30; n++) vision.sweep(); assertEquals(1, rows("effect.applied").size());
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep(); await(f, "effect.applied", 2); await(f, "effect.ended", 1);
var stronger = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 1);
effects.put(PotionEffectType.NIGHT_VISION, stronger); vision.sweep(); await(f, "effect.ended", 2);
assertSame(stronger, effects.get(PotionEffectType.NIGHT_VISION)); assertEquals(2, rows("effect.applied").size());
effects.remove(PotionEffectType.NIGHT_VISION); vision.sweep(); await(f, "effect.applied", 3);
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
assertEquals(saved, current(f, a), "Visibility/reconciliation cannot change saved progression or health");
assertEquals(messages, a.messageComponents.size());
assertTrue(ordinaryEffects.isEmpty(), "An ordinary online player never receives a visibility layer");
PartyBookNativeTest.open(f, a);
PartyBookNativeTest.click(f, a, SpeedINativeTest.action(a, "[Unlearn]", "low_light_vision"));
PartyBookNativeTest.drain(f); PartyBookNativeTest.drain(f); vision.sweep(); await(f, "effect.ended", 3);
assertFalse(effects.containsKey(PotionEffectType.NIGHT_VISION), "Expired unrelated vision cannot be restored by unlearn");
assertSame(blindness, effects.get(PotionEffectType.BLINDNESS)); assertSame(darkness, effects.get(PotionEffectType.DARKNESS));
var applied = rows("effect.applied"); var ended = rows("effect.ended");
assertEquals(3, 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()));
for (var row : applied) {
var value = values(row); assertEquals("minecraft:night_vision", value.get("effect_type").getAsString());
assertEquals(0, value.get("amplifier").getAsInt()); assertEquals(a.core.id.toString(), value.get("target_player_id").getAsString());
assertEquals("exact", value.get("location_capture_quality").getAsString());
assertEquals(12d, value.getAsJsonObject("location").get("x").getAsDouble());
}
assertTrue(ended.stream().anyMatch(row -> values(row).get("native_disposition").getAsString().equals("different_native_layer_preserved")));
assertTrue(ended.stream().noneMatch(row -> values(row).get("unrelated_effect_restored").getAsBoolean()));
assertTrue(ReskillEventsNativeTest.records(data).stream().noneMatch(row -> row.get("type").getAsString().contains("support")
|| row.get("type").getAsString().contains("reward") || row.get("type").getAsString().contains("render")));
f.plugin.setEnabled(false);
}
}
@Test void rejectedApplyAndShutdownReplayOriginalVisionIdsWithoutNativeOrCharacterMutation() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(); var effects = LowLightVisionNativeTest.effects(a.player);
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).close();
f.players.put(a.core.id, a.player); f.activate(a); var vision = vision(f); vision.sweep();
assertTrue(effects.get(PotionEffectType.NIGHT_VISION).isInfinite()); var saved = current(f, a);
for (int n = 0; n < 30; n++) vision.sweep(); vision.close(); assertFalse(effects.containsKey(PotionEffectType.NIGHT_VISION));
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);
var intents = new ArrayList<Properties>();
try (var files = Files.list(data.resolve("passive-potion-event-intents"))) {
for (var pending : files.filter(p -> p.getFileName().toString().endsWith(".pending")).toList()) {
var fields = new Properties(); try (var input = Files.newInputStream(pending)) { fields.load(input); } intents.add(fields);
}
}
assertEquals(2, intents.size()); assertTrue(rows("effect.applied").isEmpty()); assertTrue(rows("effect.ended").isEmpty());
clearInvocations(a.player);
try (var log = LocalEventLog.open(data, Clock.systemUTC(), new LocalEventLog.RuntimeInfo("native", "26.2", "test"), 1024, 1048576, ignored -> { })) {
var bridge = new PassivePotionEventBridge(log::emit, Clock.systemUTC(), data, failure -> fail(failure));
try { log.ready().toCompletableFuture().get(5, TimeUnit.SECONDS); bridge.replay(); }
finally { bridge.close(); }
bridge.drained().toCompletableFuture().get(5, TimeUnit.SECONDS);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 1 && rows("effect.ended").size() == 1);
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(saved.id().toString(), values(row).get("owner_character_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 nativeRefusalAndThrowingAuditWithUnavailableWorldCannotInventOrStopVision() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); ready(f); var a = actor(); var effects = LowLightVisionNativeTest.effects(a.player);
doReturn(false).when(a.player).addPotionEffect(any(), eq(true)); f.players.put(a.core.id, a.player); f.activate(a);
var vision = vision(f); vision.sweep(); assertTrue(effects.isEmpty()); assertTrue(rows("effect.applied").isEmpty()); vision.close();
var isolated = new LowLightVision((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 = LowLightVisionNativeTest.effects(a.player);
var missing = mock(org.bukkit.Location.class); when(missing.getWorld()).thenThrow(new IllegalArgumentException("world unloaded"));
doReturn(missing).when(a.player).getLocation(); isolated.sweep(); assertTrue(effects.get(PotionEffectType.NIGHT_VISION).isInfinite());
for (int n = 0; n < 30; 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.NIGHT_VISION));
assertEquals(List.of("effect.applied", "effect.ended"), attempts.stream().map(event -> event.type()).toList());
assertEquals(attempts.getFirst().actionId(), attempts.getLast().actionId());
}
}
private FantasyStickLifecycleNativeTest.Actor actor() throws Exception {
var a = new FantasyStickLifecycleNativeTest.Actor(data); LowLightVisionNativeTest.withWis(data, a, List.of("low_light_vision", "", "")); return a;
}
private static LowLightVision vision(FantasyStickLifecycleNativeTest.Fixture f) {
return (LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision");
}
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) { return LowLightVisionNativeTest.rows(data, type); }
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 {
((LocalEventLog) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "eventLog")).ready().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
}
@@ -35,10 +35,14 @@ class LowLightVisionNativeTest {
var vision = (LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision");
PartyBookNativeTest.open(f, a);
assertTrue(PartyBookNativeTest.text(a).replaceAll("\\s+", "").contains("Low-LightVisionWIS12[Learn]"));
PartyBookNativeTest.click(f, a, action(a, "[Learn]", "low_light_vision")); settle(f); vision.sweep();
var learnAction = action(a, "[Learn]", "low_light_vision");
PartyBookNativeTest.click(f, a, learnAction); settle(f); vision.sweep();
var learned = record(f, a); assertEquals(List.of("low_light_vision", "", ""), learned.passives());
assertEquals(BigInteger.valueOf(39), learned.unspentPoints());
assertEquals(0, aEffects.get(PotionEffectType.NIGHT_VISION).getAmplifier());
PartyBookNativeTest.click(f, a, learnAction); settle(f); vision.sweep();
assertEquals(learned, record(f, a), "Duplicate token cannot spend or acknowledge another vision 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); vision.sweep();
@@ -46,6 +50,9 @@ class LowLightVisionNativeTest {
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); vision.sweep();
assertTrue(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
PartyBookNativeTest.click(f, a, learnAction); settle(f); vision.sweep();
assertFalse(aEffects.containsKey(PotionEffectType.NIGHT_VISION), "Transferred-away stale token cannot reestablish old-holder vision");
assertTrue(bEffects.containsKey(PotionEffectType.NIGHT_VISION));
PartyBookNativeTest.open(f, b); PartyBookNativeTest.click(f, b, action(b, "[Unlearn]", "low_light_vision")); settle(f); vision.sweep();
var unlearned = storage(f).find(learned.id()).get().orElseThrow(); assertFalse(unlearned.passives().contains("low_light_vision"));
assertEquals(learned.unspentPoints().add(BigInteger.ONE), unlearned.unspentPoints());
@@ -72,6 +79,32 @@ class LowLightVisionNativeTest {
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); vision.sweep(); assertFalse(cEffects.containsKey(PotionEffectType.NIGHT_VISION));
// First prove the missing owner path without waiting for an event that cannot yet exist.
ReskillEventsNativeTest.await(f, () -> rows(data, "passive.learned").size() == 1 && rows(data, "passive.unlearned").size() == 1);
assertEquals(1, rows(data, "passive.learned").size(), "Actual Low-Light Vision book learn must reach JSONL");
assertEquals(1, rows(data, "passive.unlearned").size(), "Only acknowledged refund, not failed save, is an unlearn success");
ReskillEventsNativeTest.await(f, () -> rows(data, "effect.applied").size() == 3);
assertEquals(3, rows(data, "effect.applied").size(), "Three current holders establish three distinct acknowledged native layers");
ReskillEventsNativeTest.await(f, () -> rows(data, "effect.ended").size() == 3);
f.plugin.setEnabled(false);
var learnedRow = rows(data, "passive.learned").getFirst().getAsJsonObject("data");
assertEquals("40", learnedRow.get("points_before").getAsString()); assertEquals("39", learnedRow.get("points_after").getAsString());
assertEquals("12", learnedRow.getAsJsonObject("stat_values_before").get("WIS").getAsString());
assertEquals(a.core.id.toString(), learnedRow.get("player_id").getAsString());
assertEquals(b.core.id.toString(), rows(data, "passive.unlearned").getFirst().getAsJsonObject("data").get("player_id").getAsString());
var applied = rows(data, "effect.applied"); var ended = rows(data, "effect.ended");
assertEquals(3, ended.size());
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()));
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("target_player_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:night_vision", 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());
}
}
}
@@ -98,14 +131,23 @@ class LowLightVisionNativeTest {
vision.close();
}
private static Map<PotionEffectType, PotionEffect> effects(Player player) {
static List<com.google.gson.JsonObject> rows(Path data, String type) {
try {
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString()
.equals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + type)
&& row.getAsJsonObject("data").has("passive_id")
&& row.getAsJsonObject("data").get("passive_id").getAsString().equals("low_light_vision")).toList();
} catch (Exception failure) { throw new IllegalStateException("Cannot parse Low-Light Vision exports", failure); }
}
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 withWis(Path data, FantasyStickLifecycleNativeTest.Actor actor, List<String> passives) throws Exception {
static void withWis(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.WIS, BigInteger.valueOf(12));
repository.replace(new CharacterRecord(old.id(), old.name(), old.revision().add(BigInteger.ONE), stats, old.level(), old.experience(),