fix(passives): retain exact native cleanup ownership
This commit is contained in:
1 parent
3a184d5298
commit
0acf90909b
6 files changed
+768
-32
No files matched your search
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user