Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/modrinth-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ jobs:
26.1
26.1.1
26.1.2
26.2

# Path to the built JAR — Maven produces Limits-<version>.jar in target/
# Note: glob patterns are not supported; use the release tag directly.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
<!-- Do not change unless you want different name for local builds. -->
<build.number>-LOCAL</build.number>
<!-- This allows to change between versions. -->
<build.version>1.28.2</build.version>
<build.version>1.28.3</build.version>
<sonar.projectKey>BentoBoxWorld_Limits</sonar.projectKey>
<sonar.organization>bentobox-world</sonar.organization>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package world.bentobox.limits.listeners;

import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent;
import org.bukkit.*;
import org.bukkit.World.Environment;
import org.bukkit.block.Block;
Expand Down Expand Up @@ -275,15 +276,45 @@ private void trackSpawn(Entity entity) {
});
}

/**
* Populate {@link #entityIslandMap} for entities loaded from chunks on
* server start or chunk reload. Freshly spawned entities go through
* {@link #trackSpawn} and are already in the map, so we skip them to
* avoid an unnecessary {@code getIslandAt} lookup.
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onEntityAddToWorld(EntityAddToWorldEvent e) {
Entity entity = e.getEntity();
if (!(entity instanceof LivingEntity) && !(entity instanceof Vehicle)
&& !(entity instanceof Hanging)) {
return;
}

UUID uuid = entity.getUniqueId();
if (entityIslandMap.containsKey(uuid)) return;

World w = entity.getWorld();
if (!addon.inGameModeWorld(w)) return;

addon.getIslands().getIslandAt(entity.getLocation())
.filter(island -> !island.isSpawn())
.ifPresent(island -> entityIslandMap.put(uuid, island.getUniqueId()));
}

/* =========================================================================
* Decrement on permanent removal
* ========================================================================= */

@EventHandler(priority = EventPriority.MONITOR)
public void onEntityRemove(EntityRemoveEvent e) {
// Entities unloaded with their chunk are still alive — don't decrement.
if (e.getCause() == EntityRemoveEvent.Cause.UNLOAD) return;
Entity entity = e.getEntity();
// Entities unloaded with their chunk are still alive — don't decrement. Drop the cached
// island mapping so it can't leak for entities that never load again; onEntityAddToWorld
// re-populates it on chunk reload.
if (e.getCause() == EntityRemoveEvent.Cause.UNLOAD) {
entityIslandMap.remove(entity.getUniqueId());
return;
}
World w = entity.getWorld();
if (!addon.inGameModeWorld(w)) return;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@
import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent;
import org.bukkit.event.entity.CreatureSpawnEvent;
import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason;
import org.bukkit.event.entity.EntityBreedEvent;
import org.bukkit.event.entity.EntityRemoveEvent;
import org.bukkit.event.Event;
import org.bukkit.event.block.Action;
import org.bukkit.event.hanging.HangingPlaceEvent;
Expand Down Expand Up @@ -577,6 +579,110 @@ void testEntityGroupLimitAllowsSpawnWhenGroupUnderLimit() {
assertFalse(event.isCancelled());
}

// --- EntityAddToWorldEvent / restart-drift tests (#270) ---

@Test
void testEntityAddToWorldPopulatesMapForLoadedEntity() throws Exception {
LivingEntity enderman = mockEntity(EntityType.ENDERMAN, location);
EntityAddToWorldEvent event = new EntityAddToWorldEvent(enderman, world);

ell.onEntityAddToWorld(event);

assertEquals("test-island-id", entityIslandMap().get(enderman.getUniqueId()));
}

@Test
void testEntityAddToWorldIgnoresNonTrackedEntity() throws Exception {
// A projectile/item is neither LivingEntity, Vehicle nor Hanging — never mapped, no lookup.
Entity arrow = mock(Entity.class);
when(arrow.getUniqueId()).thenReturn(UUID.randomUUID());
EntityAddToWorldEvent event = new EntityAddToWorldEvent(arrow, world);

ell.onEntityAddToWorld(event);

assertTrue(entityIslandMap().isEmpty());
verify(islandsManager, never()).getIslandAt(any(Location.class));
}

@Test
void testEntityAddToWorldOutsideGameModeWorldIgnored() throws Exception {
when(addon.inGameModeWorld(world)).thenReturn(false);
LivingEntity enderman = mockEntity(EntityType.ENDERMAN, location);
EntityAddToWorldEvent event = new EntityAddToWorldEvent(enderman, world);

ell.onEntityAddToWorld(event);

assertTrue(entityIslandMap().isEmpty());
}

@Test
void testEntityAddToWorldOnSpawnIslandNotMapped() throws Exception {
when(island.isSpawn()).thenReturn(true);
LivingEntity enderman = mockEntity(EntityType.ENDERMAN, location);
EntityAddToWorldEvent event = new EntityAddToWorldEvent(enderman, world);

ell.onEntityAddToWorld(event);

assertTrue(entityIslandMap().isEmpty());
}

@Test
void testEntityAddToWorldSkipsAlreadyMappedEntity() throws Exception {
LivingEntity enderman = mockEntity(EntityType.ENDERMAN, location);
entityIslandMap().put(enderman.getUniqueId(), "existing-island");
EntityAddToWorldEvent event = new EntityAddToWorldEvent(enderman, world);

ell.onEntityAddToWorld(event);

// Mapping is untouched and no redundant getIslandAt lookup is performed.
assertEquals("existing-island", entityIslandMap().get(enderman.getUniqueId()));
verify(islandsManager, never()).getIslandAt(any(Location.class));
}

@Test
void testEntityRemoveUnloadEvictsMappingWithoutDecrement() throws Exception {
LivingEntity enderman = mockEntity(EntityType.ENDERMAN, location);
entityIslandMap().put(enderman.getUniqueId(), "test-island-id");
int before = ibc.getEntityCount(Environment.NORMAL, EntityType.ENDERMAN);

ell.onEntityRemove(new EntityRemoveEvent(enderman, EntityRemoveEvent.Cause.UNLOAD));

// Mapping dropped so it can't leak, but the count is untouched — the entity is still alive,
// just unloaded with its chunk; onEntityAddToWorld re-populates it on reload.
assertFalse(entityIslandMap().containsKey(enderman.getUniqueId()));
assertEquals(before, ibc.getEntityCount(Environment.NORMAL, EntityType.ENDERMAN));
}

@Test
void testReloadedEntityDecrementsWhenItDiesOffIsland() throws Exception {
// Simulate a restart: the in-memory map starts empty and the entity is reloaded from a chunk.
LivingEntity enderman = mockEntity(EntityType.ENDERMAN, location);
ell.onEntityAddToWorld(new EntityAddToWorldEvent(enderman, world));
assertEquals("test-island-id", entityIslandMap().get(enderman.getUniqueId()));

int before = ibc.getEntityCount(Environment.NORMAL, EntityType.ENDERMAN);

// It wanders off the island and dies there, so getIslandAt no longer resolves an island.
Location offIsland = mock(Location.class);
when(offIsland.getWorld()).thenReturn(world);
when(islandsManager.getIslandAt(offIsland)).thenReturn(Optional.empty());
when(enderman.getLocation()).thenReturn(offIsland);

ell.onEntityRemove(new EntityRemoveEvent(enderman, EntityRemoveEvent.Cause.DEATH));

// The cached mapping lets the decrement happen even though the death was off-island —
// before #270 this entry was lost on restart and the count drifted upward.
assertEquals(before - 1, ibc.getEntityCount(Environment.NORMAL, EntityType.ENDERMAN));
assertFalse(entityIslandMap().containsKey(enderman.getUniqueId()));
}

@SuppressWarnings("unchecked")
private Map<UUID, String> entityIslandMap() throws Exception {
Field f = EntityLimitListener.class.getDeclaredField("entityIslandMap");
f.setAccessible(true);
return (Map<UUID, String>) f.get(ell);
}

// --- helper methods ---

private LivingEntity mockEntity(EntityType type, Location location) {
Expand Down
Loading