fix(passives): retain exact native cleanup ownership
CI / build (push) Successful in 54s
CI / build (pull_request) Successful in 1m9s

This commit is contained in:
dmg committed 2026-10-08 13:03:44 -04:00
1 parent 3a184d5298
commit 0acf90909b
6 files changed
+768 -32

No files matched your search

+8
View File
@@ -190,6 +190,14 @@ Exit, defeat and best-effort lifecycle/recovery cleanup restore only an owned Ad
A same-process retry can acknowledge gear already verified restored for that exact player while retrying a vetoed mode restoration; a serialized `RESTORED` marker alone cannot establish this after restart. Missing game-mode proof leaves the mode and receipt retained even when independently verified gear can be restored and synchronized. Player-data and YAML saves are not one transaction; unobserved out-of-band state edits are not detectable history. No forced player save, blanket Survival reset or deployment is introduced.
## Passive potion cleanup boundaries
Speed I and Low-Light Vision retain their exact holder/session, native effect-instance identity and displaced-effect evidence while native removal is cancelled or unconfirmed. Bounded current-lifetime sweeps retry after downed containment ends; legitimate rescue preserves the same current lease. Equal-looking foreign instances and stronger/unrelated effects are not cleanup authority. A prior snapshot can be restored only after confirmed owned removal into an empty destination and within its remaining lifetime; fixed native tiers and replacement policies are unchanged.
A read-only adapter for **pinned Purpur 2618** checks native potion ticking and instance identity; bounded stack inspection refuses reentrant mutation during event/native-effect dispatch. This prevents queued by-type requests from later deleting a replacement and avoids treating mutable, in-flight event flags as completed mutation. Native maps, flags and event cancellation are never overridden. Runtime upgrades require renewed compatibility tests; an unavailable phase/identity is not permission for an unsafe fallback. No potion-event listener is needed, including for partial installation cleanup.
Owner close cancels its sweep timer and attempts only safe cleanup. Closing during native dispatch can schedule one post-dispatch attempt while the plugin is enabled, not an endless shutdown retry. Cancelled/unreadable/unsafe cleanup remains unresolved in the owner, with no false effect-end acknowledgment; shutdown or permanently denied removal does not guarantee cleanup. There is no cross-restart effect-ownership persistence, automatic recovery command or forced removal policy. Audit availability never authorizes native mutation or discards ownership evidence.
## Native fantasy health, exit and defeat
Offhand entry applies actual native maximum health `min(10 + CON, native health limit)`, resumes saved remaining character health, and supplies the recurring blue active-character aura to native viewers who opt in with `/fstick particles players on`. The account-owned aura preference defaults to off; `/fstick particles players` reports it and `/fstick particles players off` hides only this recurring aura. The independent, default-on `/fstick particles all [on|off]` preference durably controls every explicit Fantasy Stick particle for that account without changing nearby players' delivery, sounds or gameplay. Turning `all` off temporarily takes precedence without overwriting the saved `players` choice. A fresh character's saved health starts full. CON has no gameplay cap; high values do not reject entry. Native damage/healing rules remain in effect, with the existing party/mixed-mode protection. The book reports current native health rather than waiting for an exit save.
@@ -0,0 +1,57 @@
package games.dmg.fantasystick.passive;
import java.lang.reflect.Field;
import net.minecraft.world.effect.MobEffectInstance;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.craftbukkit.potion.CraftPotionEffectType;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffectType;
/** Read-only Purpur 2618 phase/instance proof. Never changes native maps, events or mutation flags. */
final class NativePotionState {
private static final Field TICKING = tickingField();
private static final StackWalker STACK = StackWalker.getInstance();
private NativePotionState() { }
static void prepare() {
if (TICKING.getType() != boolean.class) throw new IllegalStateException("Unsupported native potion phase");
}
/** Reentrant event/lifecycle calls must not consume an in-flight native decision or mutate by type. */
static boolean dispatching() {
return STACK.walk(frames -> {
var iterator = frames.iterator();
for (int depth = 0; depth < 256 && iterator.hasNext(); depth++) {
var frame = iterator.next();
if (frame.getClassName().equals("org.bukkit.plugin.RegisteredListener")
&& frame.getMethodName().equals("callEvent")) return true;
if (frame.getClassName().equals("net.minecraft.world.entity.LivingEntity")
&& frame.getMethodName().contains("Effect")) return true;
}
return iterator.hasNext(); // Unknown deeper context cannot authorize a mutation.
});
}
static boolean quiet(Player player) {
try { return !TICKING.getBoolean(handle(player)); }
catch (IllegalAccessException failure) { throw new IllegalStateException("Native potion phase unavailable", failure); }
}
static MobEffectInstance identity(Player player, PotionEffectType type) {
return handle(player).getEffect(CraftPotionEffectType.bukkitToMinecraftHolder(type));
}
private static net.minecraft.server.level.ServerPlayer handle(Player player) {
if (!(player instanceof CraftPlayer craft)) throw new IllegalStateException("Unsupported native potion holder");
return java.util.Objects.requireNonNull(craft.getHandle(), "Native potion holder unavailable");
}
private static Field tickingField() {
try {
var field = net.minecraft.world.entity.LivingEntity.class.getDeclaredField("tickingEffects");
field.setAccessible(true); return field;
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Unsupported native potion internals; no unsafe fallback", failure);
}
}
}
@@ -6,6 +6,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import net.minecraft.world.effect.MobEffectInstance;
import org.bukkit.entity.Player;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
@@ -18,31 +19,47 @@ final class PersistentPotionEffect implements AutoCloseable {
final EquipmentSessions.Active active;
final Player player;
PotionEffect owned;
MobEffectInstance nativeOwned;
PotionEffect displaced;
long displacedUntil;
long refreshedAt;
long applications;
PassivePotionEventBridge.Application application;
int mutating;
State(EquipmentSessions.Active active, Player player) { this.active = active; this.player = player; }
boolean quiet() {
if (mutating != 0) return false;
try { return NativePotionState.quiet(player); }
catch (RuntimeException unavailable) { return false; }
}
boolean owns(PotionEffect current, MobEffectInstance identity) {
return nativeOwned != null && nativeOwned == identity && same(current, owned);
}
void refresh() {
if (!quiet()) return;
refreshedAt = sweepGeneration;
if (displaced != null && !displaced.isInfinite() && sweepGeneration >= displacedUntil) displaced = null;
PotionEffect current;
try { current = player.getPotionEffect(type); }
PotionEffect current; MobEffectInstance identity;
try { current = player.getPotionEffect(type); identity = NativePotionState.identity(player, type); }
catch (RuntimeException unavailable) { return; }
if (owned != null && !same(current, owned)) {
if (owned != null && nativeOwned == null && current != null) return; // Unconfirmed application, not removal authority.
if (owned != null && !owns(current, identity)) {
endAudit("native_layer_replaced_or_removed", current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
owned = null; displaced = null;
owned = null; nativeOwned = null; displaced = null;
}
if (!eligible()) { remove(endReason()); return; }
if (current != null && (!replaceWeaker || current.getAmplifier() >= amplifier)) return;
if (!quiet()) return;
var effect = new PotionEffect(type, PotionEffect.INFINITE_DURATION, amplifier, false, false, true);
mutating++;
try {
if (player.addPotionEffect(effect, true)) {
owned = effect; displaced = current;
displacedUntil = current == null ? 0 : current.isInfinite()
? Long.MAX_VALUE : sweepGeneration + current.getDuration();
nativeOwned = NativePotionState.identity(player, type);
if (nativeOwned == null) return; // Preserve responsibility without inventing a native acknowledgment.
if (events != null) {
try {
var record = sessions.currentRecord(active).orElse(null);
@@ -52,7 +69,11 @@ final class PersistentPotionEffect implements AutoCloseable {
}
applications++;
}
} catch (RuntimeException unavailable) { owned = null; displaced = null; }
} catch (RuntimeException unavailable) { /* Preserve any captured native ownership and displaced evidence. */ }
finally {
mutating--;
if (closed) finishClose();
}
}
boolean eligible() {
@@ -76,29 +97,36 @@ final class PersistentPotionEffect implements AutoCloseable {
application = null;
}
void remove(String reason) {
if (!quiet()) return;
if (owned != null) {
PotionEffect current;
try { current = player.getPotionEffect(type); }
PotionEffect current; MobEffectInstance identity;
try { current = player.getPotionEffect(type); identity = NativePotionState.identity(player, type); }
catch (RuntimeException unavailable) { return; }
if (same(current, owned)) {
if (nativeOwned == null && current != null) return;
if (owns(current, identity)) {
if (!quiet()) return;
mutating++;
try {
player.removePotionEffect(type);
current = player.getPotionEffect(type);
identity = NativePotionState.identity(player, type);
// The original instance, not an event flag or an equal Bukkit snapshot, is the lease.
if (identity == nativeOwned) return;
boolean restored = false;
if (displaced != null && (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
if (current == null && identity == null && displaced != null
&& (displaced.isInfinite() || sweepGeneration < displacedUntil)) {
if (!NativePotionState.quiet(player)) return;
int duration = displaced.isInfinite() ? PotionEffect.INFINITE_DURATION
: Math.toIntExact(Math.min(Integer.MAX_VALUE, displacedUntil - sweepGeneration));
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. */ }
}
if (restored) current = player.getPotionEffect(type);
endAudit(reason, current != null && !restored ? "different_native_layer_preserved" : "owned_layer_removed", current, restored);
} catch (RuntimeException unavailable) { return; }
finally { mutating--; }
} else endAudit(reason, current == null ? "native_layer_absent" : "different_native_layer_preserved", current, false);
owned = null; displaced = null;
owned = null; nativeOwned = null; displaced = null;
}
states.remove(active.player(), this);
}
@@ -114,7 +142,10 @@ final class PersistentPotionEffect implements AutoCloseable {
private int stateCursor;
private long sweepGeneration;
private org.bukkit.scheduler.BukkitTask sweepTask;
private org.bukkit.plugin.Plugin plugin;
private boolean closed;
private boolean closing;
private boolean closeScheduled;
private PassivePotionEventBridge events;
void attachEvents(PassivePotionEventBridge bridge) {
@@ -131,12 +162,15 @@ final class PersistentPotionEffect implements AutoCloseable {
void install(org.bukkit.plugin.Plugin plugin) {
Objects.requireNonNull(plugin);
if (closed || sweepTask != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
if (closed || sweepTask != null || this.plugin != null) throw new IllegalStateException("Passive potion sweep already installed or closed");
NativePotionState.prepare();
// Native identity proof needs no event listener, including during partial installation failure.
sweepTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sweep, 1, 1);
this.plugin = plugin;
}
void sweep() {
if (closed) return;
if (closed || NativePotionState.dispatching()) return;
sweepGeneration++;
var active = sessions.activeCharacters();
int activeChecks = Math.min(MAX_ACTIVE_CHECKS, active.size());
@@ -176,11 +210,33 @@ final class PersistentPotionEffect implements AutoCloseable {
&& first.hasIcon() == second.hasIcon();
}
private void finishClose() {
if (!closed || closing) return;
if (NativePotionState.dispatching() || states.values().stream().anyMatch(state -> !state.quiet())) {
if (!closeScheduled && plugin != null && plugin.isEnabled()) {
closeScheduled = true;
try { plugin.getServer().getScheduler().runTask(plugin, () -> {
// One bounded post-dispatch attempt, never a self-rescheduling shutdown loop.
if (NativePotionState.dispatching()) return;
sweepGeneration++;
closing = true;
try { for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown"); }
finally { closing = false; }
}); }
catch (RuntimeException unavailable) { /* Retain unresolved ownership; no false cleanup acknowledgment. */ }
}
return;
}
closing = true;
try { for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown"); }
finally { closing = false; }
// Unconfirmed leases remain explicit in this owner. There is no cross-restart removal authority.
}
@Override public void close() {
if (closed) return;
closed = true;
if (sweepTask != null) sweepTask.cancel();
for (var state : List.copyOf(states.values())) state.remove("plugin_shutdown");
states.clear();
finishClose();
}
}
@@ -13,7 +13,6 @@ import games.dmg.fantasystick.session.EquipmentSessions;
import java.math.BigInteger;
import java.nio.file.Path;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -109,7 +108,7 @@ class LowLightVisionNativeTest {
}
@Test void milkAndUnrelatedNightVisionReconcileWithoutTouchingBlindnessOrDarkness() {
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
var sessions = mock(EquipmentSessions.class); var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.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("low_light_vision", "", ""));
when(player.getUniqueId()).thenReturn(id); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, record)));
@@ -141,11 +140,7 @@ class LowLightVisionNativeTest {
}
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;
return SpeedINativeTest.effects(player);
}
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();
@@ -0,0 +1,598 @@
package games.dmg.fantasystick;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import games.dmg.fantasystick.passive.LowLightVision;
import games.dmg.fantasystick.passive.SpeedI;
import games.dmg.fantasystick.session.EquipmentSessions;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.world.effect.MobEffectInstance;
import org.bukkit.craftbukkit.potion.CraftPotionUtil;
import org.bukkit.event.entity.EntityPotionEffectEvent;
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;
/** Registered session/downing path with pinned native potion updates, not unconditional map removal. */
class PassivePotionCleanupNativeTest {
@TempDir Path data;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test void registeredDowningCancellationMustNotOrphanEitherPassiveAfterAcceptDeath() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true);
var actor = actor(f);
var nativeEffects = new NativePort(f, actor);
f.activate(actor); sweep(f);
assertOwned(nativeEffects);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
down(f, actor);
for (int tick = 0; tick < 4; tick++) sweep(f);
assertOwned(nativeEffects);
assertTrue(nativeEffects.decisions.stream().filter(event -> event.getAction() == EntityPotionEffectEvent.Action.REMOVED
&& event.isCancelled()).count() >= 2, "Registered downed containment must really deny native removal for both types");
assertTrue(rows("effect.ended").isEmpty(), "Cancelled cleanup is not an acknowledged effect end");
acceptDeath(f, actor);
assertTrue(sessions(f).downed(actor.core.id).isEmpty());
sweep(f);
assertNull(nativeEffects.current(PotionEffectType.SPEED), "Accepted final death must retry the retained Speed cleanup lease");
assertNull(nativeEffects.current(PotionEffectType.NIGHT_VISION), "Accepted final death must retry the retained vision cleanup lease");
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
for (int tick = 0; tick < 4; tick++) sweep(f);
assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size());
assertPairs();
}
}
@Test void actualPhoenixMenuRescueResumesTheSameLayersThenSheetExitCleansThem() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(port);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
var original = sessions(f).active(actor.core.id).orElseThrow();
var inventory = (games.dmg.fantasystick.shop.RareInventoryDirectory)
FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "rareInventory");
inventory.add(original.character(), games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN, java.util.UUID.randomUUID()).join();
down(f, actor); sweep(f); assertOwned(port);
clickMenu(f, actor, 13);
ReskillEventsNativeTest.await(f, () -> sessions(f).current(original));
for (int tick = 0; tick < 4; tick++) sweep(f);
assertOwned(port); assertEquals(2, rows("effect.applied").size()); assertTrue(rows("effect.ended").isEmpty());
assertEquals(java.math.BigInteger.ZERO, inventory.quantity(original.character(), games.dmg.fantasystick.shop.RareProduct.PHOENIX_DOWN));
exit(f, actor); sweep(f);
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2); assertPairs();
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
void unavailablePostRemovalReadCannotDiscardEvidenceOrClaimSuccess(boolean removalCancelled) throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(port);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
exit(f, actor); port.cancelRemoval = removalCancelled; port.failAfterRemoval = true;
sweep(f);
assertEquals(2, port.failedObservations, "Both actual post-removal reads must be unavailable");
if (removalCancelled) assertOwned(port);
else { assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION)); }
assertTrue(rows("effect.ended").isEmpty(), "Unconfirmed cleanup must retain original application evidence");
port.cancelRemoval = false; port.failAfterRemoval = false; sweep(f);
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
sweep(f); assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size()); assertPairs();
}
}
@Test void cancellationRetryDoesNotDependOnAnAuditBridge() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor);
// The installed owners have not been pulsed; these current-session owners have no bridge.
try (var speed = new SpeedI(sessions(f)); var vision = new LowLightVision(sessions(f))) {
speed.sweep(); vision.sweep(); assertOwned(port);
exit(f, actor); port.cancelRemoval = true;
speed.sweep(); vision.sweep(); assertOwned(port);
port.cancelRemoval = false; speed.sweep(); vision.sweep();
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
assertTrue(rows("effect.applied").isEmpty()); assertTrue(rows("effect.ended").isEmpty());
}
}
}
@Test void aReplacementSessionCannotInheritTheCancelledOwnersApplicationIdentity() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(port);
var original = sessions(f).active(actor.core.id).orElseThrow();
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
var sheet = actor.core.inventory.getItemInOffHand().clone();
port.cancelRemoval = true; exit(f, actor); sweep(f); assertOwned(port);
actor.core.inventory.setItemInOffHand(sheet); f.activate(actor);
var replacement = sessions(f).active(actor.core.id).orElseThrow();
assertNotEquals(original.session(), replacement.session()); assertEquals(original.character(), replacement.character());
sweep(f); assertOwned(port);
assertEquals(2, rows("effect.applied").size()); assertTrue(rows("effect.ended").isEmpty());
port.cancelRemoval = false; sweep(f); assertOwned(port);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 4 && rows("effect.ended").size() == 2);
var contexts = rows("effect.applied").stream().map(row -> row.getAsJsonObject("data").get("owner_session_id").getAsString())
.collect(java.util.stream.Collectors.toSet());
assertEquals(java.util.Set.of(original.session().toString(), replacement.session().toString()), contexts);
exit(f, actor); sweep(f); ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 4); assertPairs();
}
}
@Test void transferredHolderIsIndependentOfTheOldHoldersDeniedCleanup() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var oldPort = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(oldPort);
var sheet = actor.core.inventory.getItemInOffHand().clone();
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
oldPort.cancelRemoval = true; exit(f, actor); sweep(f); assertOwned(oldPort);
var holder = new FantasyStickLifecycleNativeTest.Actor(data); f.players.put(holder.core.id, holder.player);
var newPort = new NativePort(f, holder); holder.core.inventory.setItemInOffHand(sheet);
f.activate(holder); sweep(f); assertOwned(newPort); assertOwned(oldPort);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 4);
oldPort.cancelRemoval = false; sweep(f);
assertNull(oldPort.current(PotionEffectType.SPEED)); assertNull(oldPort.current(PotionEffectType.NIGHT_VISION)); assertOwned(newPort);
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
assertTrue(rows("effect.ended").stream().allMatch(row -> row.getAsJsonObject("data").get("target_player_id").getAsString().equals(actor.core.id.toString())));
exit(f, holder); sweep(f); ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 4); assertPairs();
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
void foreignReplacementBeforeOrDuringRetryAndUnrelatedTypesArePreserved(boolean reentrant) throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
var unrelated = List.of(new PotionEffect(PotionEffectType.BLINDNESS, 400, 1, true, true, false),
new PotionEffect(PotionEffectType.DARKNESS, 400, 0, false, true, true));
unrelated.forEach(effect -> assertTrue(port.add(effect)));
f.activate(actor); sweep(f); assertOwned(port);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
down(f, actor); sweep(f); assertOwned(port);
acceptDeath(f, actor);
var foreign = List.of(new PotionEffect(PotionEffectType.SPEED, PotionEffect.INFINITE_DURATION, 2, true, true, false),
new PotionEffect(PotionEffectType.NIGHT_VISION, PotionEffect.INFINITE_DURATION, 1, true, true, false));
if (reentrant) port.afterRemoval = type -> assertTrue(port.add(foreign.stream().filter(effect -> effect.getType().equals(type)).findFirst().orElseThrow()));
else foreign.forEach(effect -> assertTrue(port.add(effect)));
sweep(f);
for (var effect : foreign) assertEquals(effect, port.current(effect.getType()));
for (var effect : unrelated) assertEquals(effect, port.current(effect.getType()));
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
assertTrue(rows("effect.ended").stream().noneMatch(row -> row.getAsJsonObject("data").get("unrelated_effect_restored").getAsBoolean()));
sweep(f); assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size()); assertPairs();
}
}
@Test void aLateFinalCancellationCannotBeMistakenForAnAcceptedNativeRemoval() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(port);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
exit(f, actor); port.lateCancelRemoval = true; sweep(f); sweep(f); assertOwned(port);
assertTrue(rows("effect.ended").isEmpty(), "Final native flags, not the earlier observer decision, are authoritative");
port.lateCancelRemoval = false; sweep(f);
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2); assertPairs();
}
}
@Test void deferredNativeRemovalPreservesAnIdenticalForeignReplacement() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(port);
var foreign = List.of(port.current(PotionEffectType.SPEED), port.current(PotionEffectType.NIGHT_VISION));
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
exit(f, actor); port.ticking(true); int before = port.decisions.size(); sweep(f);
assertOwned(port); assertEquals(before, port.decisions.size()); assertTrue(port.deferred.isEmpty());
assertTrue(rows("effect.ended").isEmpty(), "An unsafe native phase neither submits removal nor claims cleanup");
port.ticking(false);
port.afterRemoval = type -> assertTrue(actor.player.addPotionEffect(
foreign.stream().filter(effect -> effect.getType().equals(type)).findFirst().orElseThrow(), true));
sweep(f); // Retry only once native ticking is complete; the accepted removal can expose a replacement.
port.afterRemoval = ignored -> {};
for (var effect : foreign) assertEquals(effect, port.current(effect.getType()));
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
sweep(f); assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size()); assertPairs();
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void unsafeTickCannotQueueRemovalOfALaterForeignLayer(boolean identical) throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(port);
exit(f, actor); port.ticking(true); sweep(f);
var foreign = new HashMap<PotionEffectType, MobEffectInstance>();
for (var type : List.of(PotionEffectType.SPEED, PotionEffectType.NIGHT_VISION)) {
var replacement = CraftPotionUtil.fromBukkit(new PotionEffect(type, PotionEffect.INFINITE_DURATION,
identical ? 0 : 2, false, false, true));
foreign.put(type, replacement); port.effects.put(type, replacement);
}
port.ticking(false);
var queued = List.copyOf(port.deferred); port.deferred.clear(); queued.forEach(Runnable::run);
for (var entry : foreign.entrySet()) assertSame(entry.getValue(), port.effects.get(entry.getKey()),
"An earlier by-type request cannot remove a foreign layer installed before its native dispatch");
sweep(f);
for (var entry : foreign.entrySet()) assertSame(entry.getValue(), port.effects.get(entry.getKey()),
"Even an identical replacement is not the old cleanup lease");
assertTrue(queued.isEmpty(), "Unsafe native ticking must not enqueue an owned removal");
NativeTestAwait.auditUntil(f, () -> rows("effect.ended"), rows -> rows.size() == 2, "Both replaced leases are acknowledged once");
assertPairs();
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void deferredDecisionCannotAuthorizeCleanupDuringReentrantSweepOrClose(boolean close) throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
var captured = captureAudits(f);
f.activate(actor); sweep(f); assertOwned(port); assertEquals(2, captured.size());
exit(f, actor); port.ticking(true); sweep(f);
// An independent native removal may also be queued. Observing its event is not our mutation proof.
actor.player.removePotionEffect(PotionEffectType.SPEED); actor.player.removePotionEffect(PotionEffectType.NIGHT_VISION);
class Reentry implements org.bukkit.event.Listener {
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
public void removal(EntityPotionEffectEvent event) {
if (event.getEntity() != actor.player || event.getAction() != EntityPotionEffectEvent.Action.REMOVED) return;
if (close) {
((SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI")).close();
((LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision")).close();
} else sweep(f);
event.setCancelled(true); // Final decision occurs after the reentrant lifecycle operation.
}
}
var reentry = new Reentry(); f.dispatcher.register(reentry, f.plugin);
port.ticking(false); var queued = List.copyOf(port.deferred); port.deferred.clear(); queued.forEach(Runnable::run);
assertOwned(port);
assertEquals(0, captured.stream().filter(event -> event.type().equals("effect.ended")).count(),
"An in-flight accepted decision followed by cancellation cannot claim completed cleanup");
org.bukkit.event.HandlerList.unregisterAll(reentry);
if (!close) sweep(f);
NativeTestAwait.until(f, () -> captured.stream().filter(event -> event.type().equals("effect.ended")).count() == 2,
"After dispatch ends, retained cleanup finishes exactly once");
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
sweep(f); assertEquals(4, captured.size());
}
}
@Test void aThrowingRemovalApiCannotTurnAnAcceptedDecisionIntoMutationProof() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(port);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
exit(f, actor); port.failRemovalAfterDecision = true; sweep(f); assertOwned(port);
assertTrue(rows("effect.ended").isEmpty());
port.failRemovalAfterDecision = false; sweep(f); sweep(f);
assertNull(port.current(PotionEffectType.SPEED), "A throwing native call must retain cleanup responsibility, not discard the still-present layer");
assertNull(port.current(PotionEffectType.NIGHT_VISION));
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2); assertPairs();
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {true, false})
void identicalForeignLayerAfterAcceptedRemovalIsNotTheOldLeaseEvenIfObservationFails(boolean observationUnavailable) throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor);
f.activate(actor); sweep(f); assertOwned(port);
var foreign = List.of(port.current(PotionEffectType.SPEED), port.current(PotionEffectType.NIGHT_VISION));
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
down(f, actor); sweep(f); acceptDeath(f, actor);
int start = port.decisions.size();
port.afterRemoval = type -> assertTrue(actor.player.addPotionEffect(
foreign.stream().filter(effect -> effect.getType().equals(type)).findFirst().orElseThrow(), true));
port.failAfterRemoval = observationUnavailable; sweep(f);
assertEquals(2, port.decisions.subList(start, port.decisions.size()).stream()
.filter(event -> event.getAction() == EntityPotionEffectEvent.Action.REMOVED && !event.isCancelled()).count());
assertEquals(2, port.decisions.subList(start, port.decisions.size()).stream()
.filter(event -> event.getAction() == EntityPotionEffectEvent.Action.ADDED && !event.isCancelled()).count());
if (observationUnavailable) assertTrue(rows("effect.ended").isEmpty(), "An unavailable post-read cannot claim cleanup confirmation");
port.afterRemoval = ignored -> {}; port.failAfterRemoval = false; sweep(f);
for (var effect : foreign) assertEquals(effect, port.current(effect.getType()),
"An actually removed/replaced identical foreign layer must never be deleted as the old owner's retry");
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
sweep(f); assertEquals(2, rows("effect.applied").size()); assertEquals(2, rows("effect.ended").size()); assertPairs();
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {20, 2})
void retainedApiPriorSnapshotCannotBeRestoredBeforeRemovalOrAfterItsBudgetExpires(int priorTicks) throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new ApiSnapshotPort(f, actor);
// Supplemental Bukkit snapshot contract only: the pinned native constructor clamps
// amplifier -1 to 0, so this is NOT a reachable below-Speed-I native tier assertion.
var prior = new PotionEffect(PotionEffectType.SPEED, priorTicks, -1, true, true, false);
port.effects.put(PotionEffectType.SPEED, prior);
f.activate(actor); sweep(f);
ReskillEventsNativeTest.await(f, () -> rows("effect.applied").size() == 2);
down(f, actor);
for (int tick = 0; tick < 4; tick++) sweep(f);
assertEquals(0, port.restoreRequests, "Cancellation must retain the prior snapshot without attempting restoration");
assertTrue(port.effects.get(PotionEffectType.SPEED).isInfinite()); assertTrue(rows("effect.ended").isEmpty());
acceptDeath(f, actor); sweep(f);
boolean live = priorTicks == 20;
if (live) assertEquals(new PotionEffect(PotionEffectType.SPEED, 15, -1, true, true, false), port.effects.get(PotionEffectType.SPEED));
else assertFalse(port.effects.containsKey(PotionEffectType.SPEED), "Expired displaced snapshot must not be resurrected");
assertFalse(port.effects.containsKey(PotionEffectType.NIGHT_VISION));
ReskillEventsNativeTest.await(f, () -> rows("effect.ended").size() == 2);
var end = rows("effect.ended").stream().map(row -> row.getAsJsonObject("data"))
.filter(values -> values.get("passive_id").getAsString().equals("speed_i")).findFirst().orElseThrow();
assertEquals(live, end.get("unrelated_effect_restored").getAsBoolean());
sweep(f); assertEquals(live ? 1 : 0, port.restoreRequests); assertEquals(2, rows("effect.ended").size()); assertPairs();
}
}
@Test void unsafeTickDefersApplicationWithoutQueueingOrClaimingANativeLayer() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor); var captured = captureAudits(f);
f.activate(actor); port.ticking(true); sweep(f);
assertTrue(port.effects.isEmpty()); assertTrue(port.deferred.isEmpty()); assertTrue(captured.isEmpty());
port.ticking(false); sweep(f); assertOwned(port); assertEquals(2, captured.size());
exit(f, actor); sweep(f);
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
assertEquals(4, captured.size());
}
}
@Test void ownerCloseImmediatelyCleansConfirmedLayersAndDisposesItsTimerWithoutFixtureCleanup() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor); var captured = captureAudits(f);
f.activate(actor); sweep(f); assertOwned(port); assertEquals(2, captured.size());
var speed = (SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI");
var vision = (LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision");
var speedTask = (org.bukkit.scheduler.BukkitTask) FantasyStickLifecycleNativeTest.Fixture.field(
FantasyStickLifecycleNativeTest.Fixture.field(speed, "effect"), "sweepTask");
var visionTask = (org.bukkit.scheduler.BukkitTask) FantasyStickLifecycleNativeTest.Fixture.field(
FantasyStickLifecycleNativeTest.Fixture.field(vision, "effect"), "sweepTask");
speed.close(); vision.close();
assertNull(port.current(PotionEffectType.SPEED)); assertNull(port.current(PotionEffectType.NIGHT_VISION));
assertEquals(4, captured.size()); assertEquals(0, passiveListeners(f));
speed.close(); vision.close(); sweep(f); assertEquals(4, captured.size());
verify(speedTask, times(1)).cancel(); verify(visionTask, times(1)).cancel();
assertTrue(f.plugin.isEnabled(), "Assertions precede fixture/plugin-wide cleanup");
}
}
@Test void partialInstallFailureCannotLeaveAPotionListenerOrTimerBehind() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); int timers = f.timers.size(); long listeners = passiveListeners(f);
var owner = new SpeedI(sessions(f)); f.failRegistrationAt = "presentation-timer";
try {
assertThrows(IllegalStateException.class, () -> owner.install(f.plugin));
assertEquals(listeners, passiveListeners(f), "Failed installation cannot leave a listener for fixture shutdown to mask");
assertEquals(timers, f.timers.size());
} finally { f.failRegistrationAt = null; owner.close(); }
assertTrue(f.plugin.isEnabled());
}
}
@Test void permanentlyCancelledCloseRetainsUnresolvedOwnershipWithoutFalseSuccessOrForcedRemoval() throws Exception {
try (var f = new FantasyStickLifecycleNativeTest.Fixture(data)) {
f.plugin.setEnabled(true); var actor = actor(f); var port = new NativePort(f, actor); var captured = captureAudits(f);
f.activate(actor); sweep(f); assertOwned(port); port.cancelRemoval = true;
for (String owner : List.of("speedI", "lowLightVision")) {
var wrapper = (AutoCloseable) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, owner); wrapper.close();
var effect = FantasyStickLifecycleNativeTest.Fixture.field(wrapper, "effect");
assertEquals(1, ((Map<?, ?>) FantasyStickLifecycleNativeTest.Fixture.field(effect, "states")).size(),
"An unresolved lease is retained, not silently discarded on close");
}
assertOwned(port); assertEquals(2, captured.size(), "Only the original applications are acknowledged");
assertEquals(0, passiveListeners(f));
assertTrue(port.decisions.stream().filter(event -> event.getAction() == EntityPotionEffectEvent.Action.REMOVED)
.allMatch(EntityPotionEffectEvent::isCancelled));
}
}
private static List<games.dmg.fantasystick.events.EventInput> captureAudits(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
var captured = new ArrayList<games.dmg.fantasystick.events.EventInput>();
var bridge = new games.dmg.fantasystick.events.PassivePotionEventBridge(captured::add, java.time.Clock.systemUTC());
for (String owner : List.of("speedI", "lowLightVision")) {
var effect = FantasyStickLifecycleNativeTest.Fixture.field(FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, owner), "effect");
var audit = effect.getClass().getDeclaredField("events"); audit.setAccessible(true); audit.set(effect, bridge);
}
return captured;
}
private static long passiveListeners(FantasyStickLifecycleNativeTest.Fixture f) {
return java.util.Arrays.stream(EntityPotionEffectEvent.getHandlerList().getRegisteredListeners())
.filter(listener -> listener.getPlugin() == f.plugin
&& listener.getListener().getClass().getName().equals("games.dmg.fantasystick.passive.PersistentPotionEffect")).count();
}
private static void acceptDeath(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
clickMenu(f, actor, 11);
ReskillEventsNativeTest.await(f, () -> sessions(f).active(actor.core.id).isEmpty());
DownedSessionNativeTest.awaitTail(f, actor);
assertTrue(sessions(f).downed(actor.core.id).isEmpty());
}
private static void clickMenu(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor, int slot) {
var menu = actor.inventories.getLast();
var view = new org.bukkit.craftbukkit.inventory.CraftAbstractInventoryView() {
@Override public org.bukkit.inventory.Inventory getTopInventory() { return menu; }
@Override public org.bukkit.inventory.PlayerInventory getBottomInventory() { return actor.core.inventory; }
@Override public org.bukkit.entity.HumanEntity getPlayer() { return actor.player; }
@Override public org.bukkit.event.inventory.InventoryType getType() { return menu.getType(); }
@Override public org.bukkit.inventory.ItemStack getCursor() { return actor.core.cursor; }
@Override public void setCursor(org.bukkit.inventory.ItemStack item) { actor.core.cursor = item; }
@Override public String getTitle() { return "Downed"; }
@Override public String getOriginalTitle() { return getTitle(); }
@Override public void setTitle(String title) { throw new AssertionError("Unexpected death-menu title mutation"); }
@Override public org.bukkit.inventory.MenuType getMenuType() { return null; }
};
var click = new org.bukkit.event.inventory.InventoryClickEvent(view, org.bukkit.event.inventory.InventoryType.SlotType.CONTAINER,
slot, org.bukkit.event.inventory.ClickType.LEFT, org.bukkit.event.inventory.InventoryAction.PICKUP_ALL);
f.dispatcher.accept(click); assertTrue(click.isCancelled());
}
private static void exit(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.inventory.setItemInOffHand(null); f.changed(actor);
ReskillEventsNativeTest.await(f, () -> sessions(f).active(actor.core.id).isEmpty());
f.awaitCharacterAndReceipt(actor); PartyBookNativeTest.drain(f);
}
private FantasyStickLifecycleNativeTest.Actor actor(FantasyStickLifecycleNativeTest.Fixture f) throws Exception {
var actor = new FantasyStickLifecycleNativeTest.Actor(data);
SpeedINativeTest.withDex(data, actor, List.of("speed_i", "low_light_vision", ""));
LowLightVisionNativeTest.withWis(data, actor, List.of("speed_i", "low_light_vision", ""));
f.players.put(actor.core.id, actor.player);
return actor;
}
private static EquipmentSessions sessions(FantasyStickLifecycleNativeTest.Fixture f) {
return (EquipmentSessions) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "sessions");
}
private static void sweep(FantasyStickLifecycleNativeTest.Fixture f) {
((SpeedI) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "speedI")).sweep();
((LowLightVision) FantasyStickLifecycleNativeTest.Fixture.field(f.plugin, "lowLightVision")).sweep();
}
private static void assertOwned(NativePort port) {
for (var type : List.of(PotionEffectType.SPEED, PotionEffectType.NIGHT_VISION)) {
var effect = port.current(type); assertNotNull(effect); assertTrue(effect.isInfinite()); assertEquals(0, effect.getAmplifier());
}
}
private static void down(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) throws Exception {
actor.core.health = 0;
doReturn(null).when(actor.player).getLastDamageCause(); doReturn(null).when(actor.player).getFishHook();
var source = new org.bukkit.craftbukkit.damage.CraftDamageSource(new net.minecraft.world.damagesource.DamageSource(
org.bukkit.craftbukkit.damage.CraftDamageType.bukkitToMinecraftHolder(org.bukkit.damage.DamageType.GENERIC)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(actor.player, source, new ArrayList<>(), 0,
net.kyori.adventure.text.Component.empty(), true);
f.dispatcher.accept(death); assertTrue(death.isCancelled());
ReskillEventsNativeTest.await(f, () -> sessions(f).downed(actor.core.id).isPresent() && !actor.inventories.isEmpty());
assertFalse(sessions(f).current(sessions(f).active(actor.core.id).orElseThrow()));
}
private List<com.google.gson.JsonObject> rows(String type) {
try {
return ReskillEventsNativeTest.records(data).stream().filter(row -> row.get("type").getAsString().endsWith("." + type)
&& row.getAsJsonObject("data").has("passive_id")).toList();
} catch (Exception failure) { throw new IllegalStateException("Cannot parse passive cleanup exports", failure); }
}
private void assertPairs() throws Exception {
var applied = rows("effect.applied"); var ended = rows("effect.ended");
assertEquals(applied.stream().map(row -> row.getAsJsonObject("data").get("effect_id")).collect(java.util.stream.Collectors.toSet()),
ended.stream().map(row -> row.getAsJsonObject("data").get("effect_id")).collect(java.util.stream.Collectors.toSet()));
for (var start : applied) {
var original = start.getAsJsonObject("data");
var end = ended.stream().map(row -> row.getAsJsonObject("data"))
.filter(value -> value.get("effect_id").equals(original.get("effect_id"))).findFirst().orElseThrow();
for (String key : List.of("application_id", "owner_character_id", "owner_session_id", "target_player_id", "passive_id"))
assertEquals(original.get(key), end.get(key));
assertEquals("acknowledged", end.get("native_publication").getAsString());
}
}
/** Supplemental API-snapshot port; native-tier fidelity is exercised separately by NativePort. */
private static final class ApiSnapshotPort {
final Map<PotionEffectType, PotionEffect> effects;
int restoreRequests;
ApiSnapshotPort(FantasyStickLifecycleNativeTest.Fixture f, FantasyStickLifecycleNativeTest.Actor actor) {
effects = SpeedINativeTest.effects(actor.player);
doAnswer(call -> effects.get(call.getArgument(0))).when(actor.player).getPotionEffect(any(PotionEffectType.class));
doAnswer(call -> List.copyOf(effects.values())).when(actor.player).getActivePotionEffects();
doAnswer(call -> {
PotionEffect requested = call.getArgument(0); var old = effects.get(requested.getType());
if (requested.getAmplifier() < 0) restoreRequests++;
var event = new EntityPotionEffectEvent(actor.player, old, requested, null, EntityPotionEffectEvent.Cause.PLUGIN,
old == null ? EntityPotionEffectEvent.Action.ADDED : EntityPotionEffectEvent.Action.CHANGED, true);
f.dispatcher.accept(event);
if (event.isCancelled()) return false;
effects.put(requested.getType(), requested); return true;
}).when(actor.player).addPotionEffect(any(PotionEffect.class), anyBoolean());
doAnswer(call -> {
PotionEffectType type = call.getArgument(0); var old = effects.get(type);
if (old != null) {
var event = new EntityPotionEffectEvent(actor.player, old, null, null, EntityPotionEffectEvent.Cause.PLUGIN,
EntityPotionEffectEvent.Action.REMOVED, false);
f.dispatcher.accept(event); if (!event.isCancelled()) effects.remove(type);
}
return null;
}).when(actor.player).removePotionEffect(any(PotionEffectType.class));
}
}
private static final class NativePort implements org.bukkit.event.Listener {
boolean cancelRemoval, lateCancelRemoval, failAfterRemoval, deferRemoval, failRemovalAfterDecision;
final List<Runnable> deferred = new ArrayList<>();
int failedObservations;
final java.util.Set<PotionEffectType> unavailableReads = new java.util.HashSet<>();
java.util.function.Consumer<PotionEffectType> afterRemoval = ignored -> {};
final FantasyStickLifecycleNativeTest.Fixture fixture;
final FantasyStickLifecycleNativeTest.Actor actor;
final Map<PotionEffectType, MobEffectInstance> effects = new HashMap<>();
final List<EntityPotionEffectEvent> decisions = new ArrayList<>();
NativePort(FantasyStickLifecycleNativeTest.Fixture fixture, FantasyStickLifecycleNativeTest.Actor actor) {
this.fixture = fixture; this.actor = actor;
doAnswer(call -> effects.values().stream().filter(effect -> effect.getEffect().equals(call.getArgument(0)))
.findFirst().orElse(null)).when(actor.nativePlayer).getEffect(any());
fixture.dispatcher.register(this, fixture.plugin);
doAnswer(call -> {
PotionEffectType type = call.getArgument(0);
if (unavailableReads.remove(type)) { failedObservations++; throw new IllegalStateException("Post-removal native read unavailable"); }
return current(type);
}).when(actor.player).getPotionEffect(any(PotionEffectType.class));
doAnswer(call -> effects.values().stream().map(CraftPotionUtil::toBukkit).toList()).when(actor.player).getActivePotionEffects();
doAnswer(call -> add(call.getArgument(0))).when(actor.player).addPotionEffect(any(PotionEffect.class), anyBoolean());
doAnswer(call -> add(call.getArgument(0))).when(actor.player).addPotionEffect(any(PotionEffect.class));
doAnswer(call -> {
PotionEffectType type = call.getArgument(0);
if (deferRemoval) deferred.add(() -> remove(type)); else remove(type);
return null;
}).when(actor.player).removePotionEffect(any(PotionEffectType.class));
}
void ticking(boolean value) throws Exception {
var field = net.minecraft.world.entity.LivingEntity.class.getDeclaredField("tickingEffects"); field.setAccessible(true);
field.setBoolean(actor.nativePlayer, value); deferRemoval = value;
}
private void remove(PotionEffectType type) {
var old = current(type);
if (old == null) return;
var event = new EntityPotionEffectEvent(actor.player, old, null, null, EntityPotionEffectEvent.Cause.PLUGIN,
EntityPotionEffectEvent.Action.REMOVED, false);
fixture.dispatcher.accept(event); decisions.add(event);
if (failRemovalAfterDecision) throw new IllegalStateException("Native removal failed after decision, before mutation");
if (!event.isCancelled()) { effects.remove(type); afterRemoval.accept(type); }
if (failAfterRemoval) unavailableReads.add(type);
}
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.MONITOR)
public void lateCancellation(EntityPotionEffectEvent event) {
if (event.getEntity() == actor.player && lateCancelRemoval && event.getAction() == EntityPotionEffectEvent.Action.REMOVED)
event.setCancelled(true);
}
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void cancellation(EntityPotionEffectEvent event) {
if (event.getEntity() == actor.player && cancelRemoval && event.getAction() == EntityPotionEffectEvent.Action.REMOVED)
event.setCancelled(true);
}
PotionEffect current(PotionEffectType type) {
var effect = effects.get(type); return effect == null ? null : CraftPotionUtil.toBukkit(effect);
}
boolean add(PotionEffect requested) {
if (deferRemoval) { deferred.add(() -> add(requested)); return true; } // Pinned native ticking queues additions too.
var old = effects.get(requested.getType()); var candidate = CraftPotionUtil.fromBukkit(requested);
boolean override = old != null && new MobEffectInstance(old).update(candidate);
var event = new EntityPotionEffectEvent(actor.player, current(requested.getType()), requested, null,
EntityPotionEffectEvent.Cause.PLUGIN, old == null ? EntityPotionEffectEvent.Action.ADDED : EntityPotionEffectEvent.Action.CHANGED, override);
fixture.dispatcher.accept(event); decisions.add(event);
if (event.isCancelled()) return false;
if (old == null) { effects.put(requested.getType(), candidate); return true; }
if (event.isOverride()) { old.update(candidate); return true; }
if (old.getAmplifier() > candidate.getAmplifier() && old.isShorterDurationThan(candidate)) {
if (old.hiddenEffect == null) old.hiddenEffect = new MobEffectInstance(candidate);
else old.hiddenEffect.update(candidate);
}
return false;
}
}
}
@@ -112,7 +112,7 @@ class SpeedINativeTest {
}
@Test void milkAndExpiredEffectsReapplyWhileStrongerAndCleanupPathsPreserveUnrelatedSpeed() {
var sessions = mock(EquipmentSessions.class); var player = mock(Player.class); var effects = effects(player);
var sessions = mock(EquipmentSessions.class); var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class); var effects = effects(player);
UUID playerId = UUID.randomUUID(); var active = new EquipmentSessions.Active(playerId, UUID.randomUUID(), UUID.randomUUID());
var learned = mock(CharacterRecord.class); when(learned.passives()).thenReturn(List.of("speed_i", "", "")); when(learned.dead()).thenReturn(false);
when(player.getUniqueId()).thenReturn(playerId); when(sessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(active, learned)));
@@ -131,7 +131,7 @@ class SpeedINativeTest {
assertEquals(0, effects.get(PotionEffectType.SPEED).getAmplifier(), "The passive resumes after stronger Speed expires");
speed.close(); assertFalse(effects.containsKey(PotionEffectType.SPEED));
var equalSessions = mock(EquipmentSessions.class); var equalPlayer = mock(Player.class); var equalEffects = effects(equalPlayer);
var equalSessions = mock(EquipmentSessions.class); var equalPlayer = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class); var equalEffects = effects(equalPlayer);
UUID equalId = UUID.randomUUID(); var equalActive = new EquipmentSessions.Active(equalId, UUID.randomUUID(), UUID.randomUUID());
var equalRecord = mock(CharacterRecord.class); when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
when(equalPlayer.getUniqueId()).thenReturn(equalId); when(equalSessions.activeCharacters()).thenReturn(List.of(new EquipmentSessions.ActiveCharacter(equalActive, equalRecord)));
@@ -145,7 +145,7 @@ class SpeedINativeTest {
var weakerSpeed = new SpeedI(equalSessions); weakerSpeed.sweep();
assertEquals(0, equalEffects.get(PotionEffectType.SPEED).getAmplifier());
when(equalRecord.passives()).thenReturn(List.of("", "", "")); weakerSpeed.sweep();
assertEquals(-1, equalEffects.get(PotionEffectType.SPEED).getAmplifier(), "An unexpired weaker native layer is preserved");
assertEquals(-1, equalEffects.get(PotionEffectType.SPEED).getAmplifier(), "An unexpired supplemental API snapshot is preserved; native amplifier -1 is not reachable");
weakerSpeed.close();
when(equalRecord.passives()).thenReturn(List.of("speed_i", "", ""));
@@ -157,7 +157,7 @@ class SpeedINativeTest {
}
@Test void rallyOverlapNeverStacksAndEitherOwnerLeavesTheOtherApplicableLayer() throws Exception {
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var player = mock(Player.class);
var sessions = mock(EquipmentSessions.class); var parties = mock(PartyRoster.class); var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.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", "", ""));
when(player.getUniqueId()).thenReturn(id); when(player.getLocation()).thenReturn(new Location(mock(org.bukkit.World.class), 0, 64, 0));
@@ -214,8 +214,30 @@ class SpeedINativeTest {
.equals(games.dmg.fantasystick.events.LocalEventLog.TYPE_PREFIX + type)).toList();
}
/** Supplemental API snapshots with stable native identity ports; registered native mutation uses NativePort. */
static Map<PotionEffectType, PotionEffect> effects(Player player) {
var result = new HashMap<PotionEffectType, PotionEffect>();
var identities = new HashMap<net.minecraft.core.Holder<net.minecraft.world.effect.MobEffect>, net.minecraft.world.effect.MobEffectInstance>();
var result = new HashMap<PotionEffectType, PotionEffect>() {
private static final long serialVersionUID = 1L;
@Override public PotionEffect put(PotionEffectType type, PotionEffect value) {
identities.put(org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(type),
org.bukkit.craftbukkit.potion.CraftPotionUtil.fromBukkit(value));
return super.put(type, value);
}
@Override public PotionEffect remove(Object type) {
if (type instanceof PotionEffectType potion)
identities.remove(org.bukkit.craftbukkit.potion.CraftPotionEffectType.bukkitToMinecraftHolder(potion));
return super.remove(type);
}
@Override public void clear() { identities.clear(); super.clear(); }
};
var craft = assertInstanceOf(org.bukkit.craftbukkit.entity.CraftPlayer.class, player);
var nativePlayer = craft.getHandle();
if (nativePlayer == null) {
nativePlayer = mock(net.minecraft.server.level.ServerPlayer.class);
when(craft.getHandle()).thenReturn(nativePlayer);
}
doAnswer(call -> identities.get(call.getArgument(0))).when(nativePlayer).getEffect(any());
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));