feat(events): audit Speed I owned native layer lifecycle
Release / release (push) Failing after 11m20s
Release / release (push) Failing after 11m20s
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user