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");