From 4157970758991aa1ce68968047387e521d3583a9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 15:39:18 -0700 Subject: [PATCH] Fix phantom entity counts left by portaled mobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paper never fires EntityRemoveEvent for a dimension change: Entity.removeAfterChangingDimensions() passes a null Bukkit cause and CraftEventFactory.callEntityRemoveEvent() skips the event when the cause is null. The justPortaled guard therefore waited for an event that never comes, and its stale entry swallowed the entity's real death decrement instead — every mob that crossed a portal left a permanent +1 in the environment where it died (and the list grew unboundedly). Remove the guard: onEntityPortal alone transfers the count between environments, and the eventual death fires exactly one EntityRemoveEvent in the destination world. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015dbJyrv2uxHfTmiWkqGUJB --- .../limits/listeners/EntityLimitListener.java | 10 +-- .../listeners/EntityLimitListenerTest.java | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java index 48de144..2a64f88 100644 --- a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java +++ b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java @@ -59,8 +59,6 @@ public class EntityLimitListener implements Listener { private final Limits addon; /** Entity UUIDs that have just spawned to prevent double-processing. */ private final List justSpawned = new ArrayList<>(); - /** Entity UUIDs that are currently portaling to prevent double-decrement on cross-world removal. */ - private final List justPortaled = new ArrayList<>(); /** Maps entity UUID to island ID so decrement works even when the entity dies off-island. */ private final Map entityIslandMap = new HashMap<>(); /** Cardinal directions used for block structure detection. */ @@ -344,9 +342,6 @@ public void onEntityRemove(EntityRemoveEvent e) { World w = entity.getWorld(); if (!addon.inGameModeWorld(w)) return; - // Entity is being portaled — count transfer was already handled by onEntityPortal. - if (justPortaled.remove(entity.getUniqueId())) return; - String islandId = entityIslandMap.remove(entity.getUniqueId()); if (islandId != null) { addon.getBlockLimitListener().decrementEntity(islandId, envOf(w), entity.getType()); @@ -374,8 +369,9 @@ public void onEntityPortal(EntityPortalEvent e) { if (fromEnv == toEnv) return; if (!addon.inGameModeWorld(fromWorld) && !addon.inGameModeWorld(toWorld)) return; - // Prevent onEntityRemove from double-decrementing for the source-world removal. - justPortaled.add(entity.getUniqueId()); + // No EntityRemoveEvent guard is needed here: Paper suppresses the event for + // dimension changes (RemovalReason.CHANGED_DIMENSION carries a null Bukkit cause), + // so the source-world removal never reaches onEntityRemove. // Decrement at source if on a tracked island if (addon.inGameModeWorld(fromWorld)) { diff --git a/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java b/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java index e3792ed..71921e1 100644 --- a/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java @@ -37,6 +37,7 @@ import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason; import org.bukkit.event.entity.EntityBreedEvent; +import org.bukkit.event.entity.EntityPortalEvent; import org.bukkit.event.entity.EntityRemoveEvent; import org.bukkit.event.Event; import org.bukkit.event.block.Action; @@ -803,6 +804,74 @@ void testReloadedEntityDecrementsWhenItDiesOffIsland() throws Exception { assertFalse(entityIslandMap().containsKey(enderman.getUniqueId())); } + // --- EntityPortalEvent / phantom-count tests --- + + @Test + void testEntityPortalTransfersCountBetweenEnvironments() throws Exception { + Location netherLoc = mockNetherLocation(); + LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); + ibc.incrementEntity(Environment.NORMAL, EntityType.CHICKEN); + + ell.onEntityPortal(new EntityPortalEvent(chicken, location, netherLoc)); + + assertEquals(0, ibc.getEntityCount(Environment.NORMAL, EntityType.CHICKEN)); + assertEquals(1, ibc.getEntityCount(Environment.NETHER, EntityType.CHICKEN)); + assertEquals("test-island-id", entityIslandMap().get(chicken.getUniqueId())); + } + + @Test + void testPortaledEntityDeathStillDecrements() throws Exception { + // Regression: Paper never fires an EntityRemoveEvent for the dimension change itself + // (RemovalReason.CHANGED_DIMENSION has a null Bukkit cause), so the old justPortaled + // guard was never consumed and instead swallowed the entity's real death decrement, + // leaving a phantom count in the destination environment. + Location netherLoc = mockNetherLocation(); + World nether = netherLoc.getWorld(); + LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); + ibc.incrementEntity(Environment.NORMAL, EntityType.CHICKEN); + ell.onEntityPortal(new EntityPortalEvent(chicken, location, netherLoc)); + assertEquals(1, ibc.getEntityCount(Environment.NETHER, EntityType.CHICKEN)); + + // The entity now lives in the nether and dies there. + when(chicken.getWorld()).thenReturn(nether); + when(chicken.getLocation()).thenReturn(netherLoc); + ell.onEntityRemove(new EntityRemoveEvent(chicken, EntityRemoveEvent.Cause.DEATH)); + + assertEquals(0, ibc.getEntityCount(Environment.NETHER, EntityType.CHICKEN)); + assertFalse(entityIslandMap().containsKey(chicken.getUniqueId())); + } + + @Test + void testRoundTripPortalThenDeathLeavesNoPhantomCount() throws Exception { + // The reported symptom: a mob portals to the nether, comes back, and dies in the + // overworld — the overworld count must return to zero, not stick at a phantom 1. + Location netherLoc = mockNetherLocation(); + World nether = netherLoc.getWorld(); + LivingEntity chicken = mockEntity(EntityType.CHICKEN, location); + ibc.incrementEntity(Environment.NORMAL, EntityType.CHICKEN); + + ell.onEntityPortal(new EntityPortalEvent(chicken, location, netherLoc)); + when(chicken.getWorld()).thenReturn(nether); + when(chicken.getLocation()).thenReturn(netherLoc); + ell.onEntityPortal(new EntityPortalEvent(chicken, netherLoc, location)); + when(chicken.getWorld()).thenReturn(world); + when(chicken.getLocation()).thenReturn(location); + + ell.onEntityRemove(new EntityRemoveEvent(chicken, EntityRemoveEvent.Cause.DEATH)); + + assertEquals(0, ibc.getEntityCount(Environment.NORMAL, EntityType.CHICKEN)); + assertEquals(0, ibc.getEntityCount(Environment.NETHER, EntityType.CHICKEN)); + } + + private Location mockNetherLocation() { + World nether = mock(World.class); + when(nether.getEnvironment()).thenReturn(Environment.NETHER); + when(addon.inGameModeWorld(nether)).thenReturn(true); + Location netherLoc = mock(Location.class); + when(netherLoc.getWorld()).thenReturn(nether); + return netherLoc; + } + @SuppressWarnings("unchecked") private Map entityIslandMap() throws Exception { Field f = EntityLimitListener.class.getDeclaredField("entityIslandMap");