From 752e3bf97c45a745db1cc073933b21c482371c54 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 07:23:16 -0700 Subject: [PATCH 1/6] Quieten permission-limit join logging by default (#277) Permission-based limits logged a "Setting login limit via perm" banner plus one line per environment for every matching permission on join, and this logging defaulted to on. On servers where owners carry many limit permissions this spammed the console on every login. - Default `log-limits-on-join` to false; document it as a debug-only toggle. - Log the per-login banner once instead of once per matching permission. - Bump version to 1.29.0 (behaviour change, not just a fix). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014ZEmzuDykNhVyUGHLyrSAL --- pom.xml | 2 +- src/main/java/world/bentobox/limits/Settings.java | 2 +- .../java/world/bentobox/limits/listeners/JoinListener.java | 6 +++++- src/main/resources/config.yml | 5 +++-- src/test/java/world/bentobox/limits/SettingsTest.java | 4 ++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/pom.xml b/pom.xml index e3dbcef..1acc4c6 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ -LOCAL - 1.28.4 + 1.29.0 BentoBoxWorld_Limits bentobox-world https://sonarcloud.io diff --git a/src/main/java/world/bentobox/limits/Settings.java b/src/main/java/world/bentobox/limits/Settings.java index 7c6cb79..eda14b2 100644 --- a/src/main/java/world/bentobox/limits/Settings.java +++ b/src/main/java/world/bentobox/limits/Settings.java @@ -92,7 +92,7 @@ public Settings(Limits addon) { loadEntityLimits(addon, "entitylimits-end", List.of(Environment.THE_END)); // Log limits on join - logLimitsOnJoin = addon.getConfig().getBoolean("log-limits-on-join", true); + logLimitsOnJoin = addon.getConfig().getBoolean("log-limits-on-join", false); // Async Golums asyncGolums = addon.getConfig().getBoolean("async-golums", true); diff --git a/src/main/java/world/bentobox/limits/listeners/JoinListener.java b/src/main/java/world/bentobox/limits/listeners/JoinListener.java index 1fcafa2..7fa4967 100644 --- a/src/main/java/world/bentobox/limits/listeners/JoinListener.java +++ b/src/main/java/world/bentobox/limits/listeners/JoinListener.java @@ -64,10 +64,15 @@ public void checkPerms(Player player, String permissionPrefix, String islandId, islandBlockCount.clearAllEntityGroupLimits(); islandBlockCount.clearAllBlockLimits(); } + boolean bannerLogged = false; for (PermissionAttachmentInfo permissionInfo : player.getEffectivePermissions()) { if (!permissionInfo.getValue() || !permissionInfo.getPermission().startsWith(permissionPrefix)) { continue; } + if (!bannerLogged) { + logIfEnabled("Setting login limit via perm for " + player.getName() + "..."); + bannerLogged = true; + } islandBlockCount = applyOnePerm(player, permissionInfo, permissionPrefix, islandId, gameMode, islandBlockCount); } @@ -93,7 +98,6 @@ private IslandBlockCount applyOnePerm(Player player, PermissionAttachmentInfo pe } IslandBlockCount ibc = current != null ? current : new IslandBlockCount(islandId, gameMode); - logIfEnabled("Setting login limit via perm for " + player.getName() + "..."); LimitsPermCheckEvent limitsPermCheckEvent = new LimitsPermCheckEvent(player, islandId, ibc, entityGroup, entityType, material, parsed.value); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 358ea02..de1cad5 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -24,8 +24,9 @@ ignore-center-block: true # Cooldown for player recount command in seconds cooldown: 120 -# Log limits on join (to console) -log-limits-on-join: true +# Log limits on join (to console). Off by default - enable only for debugging as it +# can be noisy for players who have many permission-based limits. +log-limits-on-join: false # Use async checks for snowmen and iron golums. Set to false if you see problems. async-golums: true diff --git a/src/test/java/world/bentobox/limits/SettingsTest.java b/src/test/java/world/bentobox/limits/SettingsTest.java index 3a7382d..ab75169 100644 --- a/src/test/java/world/bentobox/limits/SettingsTest.java +++ b/src/test/java/world/bentobox/limits/SettingsTest.java @@ -94,8 +94,8 @@ void testGetGroupLimitDefinitions() { } @Test - void testLogLimitsOnJoinDefaultsTrue() { - assertTrue(settings.isLogLimitsOnJoin()); + void testLogLimitsOnJoinDefaultsFalse() { + assertFalse(settings.isLogLimitsOnJoin()); } @Test From dbc7a962a0eb57815c054a27f1602b1be0008208 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 07:34:46 -0700 Subject: [PATCH 2/6] Cancel copper golem build when at COPPER_CHEST limit (#276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building a copper golem replaces its copper block with a copper chest and spawns the golem. That block swap fires no BlockPlaceEvent, so the COPPER_CHEST block limit was never checked and the count never incremented — players could build unlimited copper chests past the configured limit. Handle it on the CreatureSpawnEvent (SpawnReason BUILD_COPPERGOLEM, matched by name so the addon still links on servers < 1.21.9): - LOW priority: cancel the build and notify nearby players when the island is already at its COPPER_CHEST limit. The copper block and pumpkin are left untouched, consistent with iron golem / snowman / wither handling. - MONITOR priority: on a successful build, count the created copper chest so the limit stays enforceable and the count does not drift below reality. Adds three BlockLimitsListener helpers (getCopperChestMaterial, checkBlockLimit, addBlockCount) and tests for the at-limit, under-limit and successful-build paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015dbJyrv2uxHfTmiWkqGUJB --- .../limits/listeners/BlockLimitsListener.java | 59 +++++++++++++++++ .../limits/listeners/EntityLimitListener.java | 64 +++++++++++++++++++ .../listeners/EntityLimitListenerTest.java | 45 +++++++++++++ 3 files changed, 168 insertions(+) diff --git a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java index 3cf800a..def8d2e 100644 --- a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java +++ b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java @@ -9,6 +9,7 @@ import java.util.Objects; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.Registry; @@ -479,6 +480,64 @@ private void updateSaveMap(String id) { } } + /** + * The canonical copper chest material, or {@code null} on servers older than 1.21.9 + * where copper chests (and copper golems) do not exist. + */ + @Nullable + public Material getCopperChestMaterial() { + return COPPER_CHEST; + } + + /** + * Read-only check of whether counting one more block of {@code key} on the island at + * {@code loc} would exceed the active limit. Used for blocks that appear without a + * place event — e.g. the copper chest a copper golem creates from a copper block, which + * fires no {@link BlockPlaceEvent} and so escapes the normal check (see + * issue #276). + * + * @return the limit value if already at/over it, otherwise -1 + */ + public int checkBlockLimit(Location loc, NamespacedKey key) { + World w = loc.getWorld(); + if (w == null || !addon.inGameModeWorld(w)) { + return -1; + } + Environment env = envOf(w); + return addon.getIslands().getIslandAt(loc).map(i -> { + String id = i.getUniqueId(); + String gameMode = addon.getGameModeName(w); + if (gameMode.isEmpty()) { + return -1; + } + islandCountMap.putIfAbsent(id, new IslandBlockCount(id, gameMode)); + return checkLimit(w, env, key, id); + }).orElse(-1); + } + + /** + * Unconditionally count one block of {@code key} against the island at {@code loc}. + * Mirror of {@link #removeBlock(Block)} for blocks that are created without a place + * event; the block really exists, so not counting it would let the count drift below + * reality (see issue #276). + */ + public void addBlockCount(Location loc, NamespacedKey key) { + World w = loc.getWorld(); + if (w == null || !addon.inGameModeWorld(w)) { + return; + } + addon.getIslands().getIslandAt(loc).ifPresent(i -> { + String id = i.getUniqueId(); + String gameMode = addon.getGameModeName(w); + if (gameMode.isEmpty()) { + return; + } + Environment env = envOf(w); + islandCountMap.computeIfAbsent(id, k -> new IslandBlockCount(id, gameMode)).add(env, key); + updateSaveMap(id); + }); + } + /** * Resolve the active limit for this material in this world for this island. * Priority: island-env limit, world-named limit, env-default, none. diff --git a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java index 54e3c8c..6f9cd5a 100644 --- a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java +++ b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java @@ -123,6 +123,12 @@ public void onCreatureSpawn(final CreatureSpawnEvent creatureSpawnEvent) { && creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BREEDING))) { return; } + // A copper golem turns its copper block into a copper chest with no place event, + // bypassing the COPPER_CHEST block limit (#276). Cancel the build if at that limit. + if (isBuildCopperGolem(creatureSpawnEvent.getSpawnReason()) + && checkCopperChestLimit(creatureSpawnEvent)) { + return; + } if (creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BUILD_SNOWMAN) || creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BUILD_IRONGOLEM)) { checkLimit(creatureSpawnEvent, creatureSpawnEvent.getEntity(), creatureSpawnEvent.getSpawnReason(), @@ -251,6 +257,14 @@ private void notifyEntityLimit(Player player, EntityType type, AtLimitResult res @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onCreatureSpawnTrack(final CreatureSpawnEvent e) { trackSpawn(e.getEntity()); + // The copper block became a copper chest as part of the (uncancelled) build; count it + // so the COPPER_CHEST limit is enforceable and the count stays accurate (#276). + if (isBuildCopperGolem(e.getSpawnReason())) { + Material chest = addon.getBlockLimitListener().getCopperChestMaterial(); + if (chest != null) { + addon.getBlockLimitListener().addBlockCount(e.getLocation(), chest.getKey()); + } + } } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -373,6 +387,56 @@ public void onEntityPortal(EntityPortalEvent e) { * Limit-checking core * ========================================================================= */ + /** + * Whether the spawn reason is a copper golem build. Matched by name so the addon links + * and runs on servers older than 1.21.9, where {@code BUILD_COPPERGOLEM} is absent. + */ + private static boolean isBuildCopperGolem(SpawnReason reason) { + return reason.name().equals("BUILD_COPPERGOLEM"); + } + + /** + * When a copper golem is built its copper block is replaced by a copper chest without a + * {@link org.bukkit.event.block.BlockPlaceEvent}, so the {@code COPPER_CHEST} block limit + * is never checked. Cancel the build if the island is already at that limit (#276). + * + * @return true if the spawn was cancelled because the copper chest limit was hit + */ + private boolean checkCopperChestLimit(CreatureSpawnEvent e) { + Material chest = addon.getBlockLimitListener().getCopperChestMaterial(); + if (chest == null) { + return false; + } + Location loc = e.getLocation(); + int limit = addon.getBlockLimitListener().checkBlockLimit(loc, chest.getKey()); + if (limit < 0) { + return false; + } + e.setCancelled(true); + tellPlayersBlockLimit(loc, chest, limit); + return true; + } + + /** + * Notify players near {@code location} that a block limit was hit, mirroring the block + * listener's own {@code block-limits.hit-limit} message. + */ + private void tellPlayersBlockLimit(Location location, Material material, int limit) { + World w = location.getWorld(); + if (w == null) { + return; + } + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { + for (Entity ent : w.getNearbyEntities(location, 5, 5, 5)) { + if (ent instanceof Player p) { + p.updateInventory(); + User.getInstance(p).notify("block-limits.hit-limit", "[material]", + Util.prettifyText(material.toString()), TextVariables.NUMBER, String.valueOf(limit)); + } + } + }); + } + private boolean checkLimit(Cancellable cancelableEvent, LivingEntity livingEntity, SpawnReason spawnReason, boolean runAsync) { Location l = livingEntity.getLocation(); diff --git a/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java b/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java index 65735aa..02d94d7 100644 --- a/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java @@ -432,6 +432,51 @@ void testCreatureSpawnDebounceSkipsSecond() throws Exception { assertFalse(event.isCancelled()); } + // --- Copper golem / copper chest limit tests (#276) --- + + @Test + void testCopperGolemAtChestLimitCancels() { + // Building a copper golem turns its copper block into a copper chest with no place + // event; the spawn must be cancelled when the COPPER_CHEST block limit is reached. + when(bll.getCopperChestMaterial()).thenReturn(Material.COPPER_CHEST); + when(bll.checkBlockLimit(any(Location.class), any())).thenReturn(1); + LivingEntity golem = mockEntity(EntityType.COPPER_GOLEM, location); + + CreatureSpawnEvent event = new CreatureSpawnEvent(golem, SpawnReason.BUILD_COPPERGOLEM); + + ell.onCreatureSpawn(event); + + assertTrue(event.isCancelled()); + verify(bll).checkBlockLimit(location, Material.COPPER_CHEST.getKey()); + } + + @Test + void testCopperGolemUnderChestLimitNotCancelled() { + when(bll.getCopperChestMaterial()).thenReturn(Material.COPPER_CHEST); + when(bll.checkBlockLimit(any(Location.class), any())).thenReturn(-1); + LivingEntity golem = mockEntity(EntityType.COPPER_GOLEM, location); + + CreatureSpawnEvent event = new CreatureSpawnEvent(golem, SpawnReason.BUILD_COPPERGOLEM); + + ell.onCreatureSpawn(event); + + assertFalse(event.isCancelled()); + } + + @Test + void testCopperGolemTrackCountsChest() { + // On a successful build the created copper chest must be counted so the limit is + // enforceable and the count does not drift below reality. + when(bll.getCopperChestMaterial()).thenReturn(Material.COPPER_CHEST); + LivingEntity golem = mockEntity(EntityType.COPPER_GOLEM, location); + + CreatureSpawnEvent event = new CreatureSpawnEvent(golem, SpawnReason.BUILD_COPPERGOLEM); + + ell.onCreatureSpawnTrack(event); + + verify(bll).addBlockCount(location, Material.COPPER_CHEST.getKey()); + } + // --- VehicleCreateEvent tests --- @Test From deb2a1145e31caf83d1b3e97107492782804bc8a Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 07:39:35 -0700 Subject: [PATCH 3/6] Extract duplicated notification literals into constants (S1192) SonarCloud flagged 'block-limits.hit-limit' and '[material]' as duplicated 3 times in EntityLimitListener. Extract them into BLOCK_LIMIT_HIT and BLOCK_PLACEHOLDER constants, matching the existing ENTITY_LIMIT_HIT / ENTITY_PLACEHOLDER pattern. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015dbJyrv2uxHfTmiWkqGUJB --- .../bentobox/limits/listeners/EntityLimitListener.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java index 6f9cd5a..a4e5d18 100644 --- a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java +++ b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java @@ -51,6 +51,10 @@ public class EntityLimitListener implements Listener { private static final String ENTITY_LIMIT_HIT = "entity-limits.hit-limit"; /** Notification placeholder for the entity name. */ private static final String ENTITY_PLACEHOLDER = "[entity]"; + /** Locale reference for the block limit notification. */ + private static final String BLOCK_LIMIT_HIT = "block-limits.hit-limit"; + /** Notification placeholder for the block material name. */ + private static final String BLOCK_PLACEHOLDER = "[material]"; private final Limits addon; /** Entity UUIDs that have just spawned to prevent double-processing. */ private final List justSpawned = new ArrayList<>(); @@ -155,11 +159,11 @@ public void onBlock(HangingPlaceEvent hangingPlaceEvent) { if (res.hit()) { hangingPlaceEvent.setCancelled(true); if (res.getTypelimit() != null) { - User.getInstance(player).notify("block-limits.hit-limit", "[material]", + User.getInstance(player).notify(BLOCK_LIMIT_HIT, BLOCK_PLACEHOLDER, Util.prettifyText(hangingPlaceEvent.getEntity().getType().toString()), TextVariables.NUMBER, String.valueOf(res.getTypelimit().getValue())); } else { - User.getInstance(player).notify("block-limits.hit-limit", "[material]", + User.getInstance(player).notify(BLOCK_LIMIT_HIT, BLOCK_PLACEHOLDER, res.getGrouplimit().getKey().getName() + " (" + res.getGrouplimit().getKey().getTypes().stream() .map(x -> Util.prettifyText(x.toString())) @@ -430,7 +434,7 @@ private void tellPlayersBlockLimit(Location location, Material material, int lim for (Entity ent : w.getNearbyEntities(location, 5, 5, 5)) { if (ent instanceof Player p) { p.updateInventory(); - User.getInstance(p).notify("block-limits.hit-limit", "[material]", + User.getInstance(p).notify(BLOCK_LIMIT_HIT, BLOCK_PLACEHOLDER, Util.prettifyText(material.toString()), TextVariables.NUMBER, String.valueOf(limit)); } } From 4802d28663339b0b08b74736b29797848b68573f Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 14:03:45 -0700 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/main/resources/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b914fea..74591b8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -25,7 +25,7 @@ ignore-center-block: true cooldown: 120 # Log limits on join (to console). Off by default - enable only for debugging as it -# can be noisy for players who have many permission-based limits. +# can be noisy in console logs for servers with many permission-based limits. log-limits-on-join: false # Use async checks for snowmen and iron golums. Set to false if you see problems. From 9e3f0e5419ad4f5b86e694f80d5e4197ff1ccbd3 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 14:06:47 -0700 Subject: [PATCH 5/6] Respect ignore-center-block in the no-place-event block helpers Addresses the Copilot review on #280: checkBlockLimit and addBlockCount (used for the copper golem's chest, which appears without a place event) bypassed the ignore-center-block exclusion that process() applies, so a build at the island center could be limited or counted when a normal placement there would not be. The comparison also has to be by block coordinates: these helpers receive entity spawn locations with fractional coordinates and yaw/pitch, which never equal the block-aligned center Location. A shared predicate now covers both helpers and processKey. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015dbJyrv2uxHfTmiWkqGUJB --- .../limits/listeners/BlockLimitsListener.java | 23 ++++++++++++--- .../listeners/BlockLimitsListenerTest.java | 29 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java index e20a1a0..101a9a1 100644 --- a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java +++ b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java @@ -472,6 +472,22 @@ && isSamePlant(b.getRelative(BlockFace.DOWN).getType(), fixMaterial(blockData))) * * @return limit amount if at/over the limit (nothing was counted), or -1 on success */ + /** + * True when the config ignores the island's center block and {@code loc} is that + * block. Compares block coordinates so entity locations (fractional coordinates, + * yaw/pitch) are matched correctly. + */ + private boolean isIgnoredCenterBlock(Island island, Location loc) { + if (!addon.getConfig().getBoolean("ignore-center-block", true)) { + return false; + } + Location center = island.getCenter(); + return center != null && Objects.equals(center.getWorld(), loc.getWorld()) + && center.getBlockX() == loc.getBlockX() + && center.getBlockY() == loc.getBlockY() + && center.getBlockZ() == loc.getBlockZ(); + } + public int processKey(World world, Location location, NamespacedKey key, boolean add) { if (!addon.inGameModeWorld(world)) { return -1; @@ -483,8 +499,7 @@ public int processKey(World world, Location location, NamespacedKey key, boolean if (gameMode.isEmpty()) { return -1; } - if (addon.getConfig().getBoolean("ignore-center-block", true) - && i.getCenter().equals(location)) { + if (isIgnoredCenterBlock(i, location)) { return -1; } islandCountMap.putIfAbsent(id, new IslandBlockCount(id, gameMode)); @@ -551,7 +566,7 @@ public int checkBlockLimit(Location loc, NamespacedKey key) { return addon.getIslands().getIslandAt(loc).map(i -> { String id = i.getUniqueId(); String gameMode = addon.getGameModeName(w); - if (gameMode.isEmpty()) { + if (gameMode.isEmpty() || isIgnoredCenterBlock(i, loc)) { return -1; } islandCountMap.putIfAbsent(id, new IslandBlockCount(id, gameMode)); @@ -573,7 +588,7 @@ public void addBlockCount(Location loc, NamespacedKey key) { addon.getIslands().getIslandAt(loc).ifPresent(i -> { String id = i.getUniqueId(); String gameMode = addon.getGameModeName(w); - if (gameMode.isEmpty()) { + if (gameMode.isEmpty() || isIgnoredCenterBlock(i, loc)) { return; } Environment env = envOf(w); diff --git a/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java b/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java index f6c1941..7e7591f 100644 --- a/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java @@ -1097,6 +1097,35 @@ void testIndividualLimitStillAppliesInsideGroup() { assertTrue(event.isCancelled()); } + // --- Center-block exclusion for the no-place-event helpers (#276 review) --- + + @Test + void testCheckBlockLimitIgnoresCenterBlock() { + // At the limit everywhere else, but the location is the island center block + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + ibc.setBlockLimit(Environment.NORMAL, Material.CHEST.getKey(), 0); + listener.setIsland("test-island-id", ibc); + + // Entity-style location inside the center block (fractional coordinates) + Location entityLoc = new Location(world, 0.5, 65.0, 0.3); + assertEquals(-1, listener.checkBlockLimit(entityLoc, Material.CHEST.getKey())); + // Away from the center the limit applies + assertEquals(0, listener.checkBlockLimit(blockLocation, Material.CHEST.getKey())); + } + + @Test + void testAddBlockCountIgnoresCenterBlock() { + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + listener.setIsland("test-island-id", ibc); + + Location entityLoc = new Location(world, 0.5, 65.0, 0.3); + listener.addBlockCount(entityLoc, Material.CHEST.getKey()); + assertEquals(0, listener.getIsland("test-island-id").getBlockCount(Material.CHEST.getKey())); + + listener.addBlockCount(blockLocation, Material.CHEST.getKey()); + assertEquals(1, listener.getIsland("test-island-id").getBlockCount(Material.CHEST.getKey())); + } + // --- Custom block keys (#176) --- @Test From 4157970758991aa1ce68968047387e521d3583a9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 15:39:18 -0700 Subject: [PATCH 6/6] 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");