From 029297e259f8aa746022416c8896125a8cb28229 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 07:50:02 -0700 Subject: [PATCH 1/3] Count KELP_PLANT as KELP so kelp columns count and limit correctly Kelp growth fires BlockSpreadEvent for the new KELP tip, but the old tip's conversion to KELP_PLANT is a plain neighbour-shape update with no Bukkit event, so the old KELP was never decremented and counts only ever grew. Treat the two as one material, like BAMBOO_SAPLING/BAMBOO: - VARIANT_MAP normalises KELP_PLANT to KELP, making the eventless tip-to-stalk conversion count-neutral - KELP joins STACKABLE and the break cascade compares canonical plant keys, so breaking the base of a mixed KELP_PLANT/KELP column decrements every segment, and stacked-plants-count-as-one counts a kelp column as a single plant - Config limit keys are canonicalised, so variant names such as KELP_PLANT or CHIPPED_ANVIL configure the working canonical limit Fixes #294 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CMYP4U4v7vUvf3guZ1ejhG --- .../limits/listeners/BlockLimitsListener.java | 16 +++- src/main/resources/config.yml | 5 +- .../listeners/BlockLimitsListenerTest.java | 96 +++++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java index 101a9a1..3583bc0 100644 --- a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java +++ b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java @@ -68,7 +68,8 @@ public class BlockLimitsListener implements Listener { Material.AIR.getKey(), Material.FIRE.getKey(), Material.END_PORTAL.getKey(), Material.NETHER_PORTAL.getKey()); /** Plants that grow as a vertical column on top of themselves. */ - public static final List STACKABLE = List.of(Material.SUGAR_CANE.getKey(), Material.BAMBOO.getKey()); + public static final List STACKABLE = List.of(Material.SUGAR_CANE.getKey(), + Material.BAMBOO.getKey(), Material.KELP.getKey()); /* * Materials added in Minecraft 1.21.9 ("Copper Age"). Resolved by name so the @@ -102,6 +103,9 @@ public class BlockLimitsListener implements Listener { VARIANT_MAP.put(Material.PLAYER_WALL_HEAD, Material.PLAYER_HEAD); VARIANT_MAP.put(Material.DRAGON_WALL_HEAD, Material.DRAGON_HEAD); VARIANT_MAP.put(Material.BAMBOO_SAPLING, Material.BAMBOO); + // A kelp column is KELP_PLANT segments topped by KELP; growth converts the old + // KELP tip to KELP_PLANT with no Bukkit event, so both must count as one material (#294) + VARIANT_MAP.put(Material.KELP_PLANT, Material.KELP); // 1.21.9 materials: only mapped when present on this server if (COPPER_WALL_TORCH != null && COPPER_TORCH != null) { VARIANT_MAP.put(COPPER_WALL_TORCH, COPPER_TORCH); @@ -224,7 +228,9 @@ private void registerLimit(Map limits, NamespacedKey nsK } else if (DO_NOT_COUNT.contains(mat.getKey())) { Bukkit.getLogger().warning(() -> "Uncountable material in block limits config: " + key); } else { - limits.put(mat.getKey(), limit); + // Store under the canonical key so variant names (KELP_PLANT, CHIPPED_ANVIL, ...) + // configure the same limit that block counting resolves to + limits.put(canonicalKey(mat), limit); } return; } @@ -278,12 +284,12 @@ private void handleBreak(Block b) { if (!addon.inGameModeWorld(b.getWorld())) { return; } - Material mat = b.getType(); // When stacked plants count as one, only the base segment was ever counted, // so the stems above must not be decremented here. - if (!addon.getSettings().isStackedPlantsCountAsOne() && STACKABLE.contains(b.getType().getKey())) { + NamespacedKey plantKey = canonicalKey(b.getType()); + if (!addon.getSettings().isStackedPlantsCountAsOne() && STACKABLE.contains(plantKey)) { Block block = b; - while (block.getRelative(BlockFace.UP).getType().equals(mat) + while (isSamePlant(block.getRelative(BlockFace.UP).getType(), plantKey) && block.getY() < b.getWorld().getMaxHeight()) { block = block.getRelative(BlockFace.UP); process(block, false); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 74591b8..686cbc2 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -63,8 +63,9 @@ apply-member-limit-perms: false blocklimits: HOPPER: 10 -# Count stackable plants (SUGAR_CANE, BAMBOO) as a single plant no matter how tall -# they grow. When false (default), every segment of the plant counts toward the limit. +# Count stackable plants (SUGAR_CANE, BAMBOO, KELP) as a single plant no matter how +# tall they grow. When false (default), every segment of the plant counts toward the +# limit (for KELP, the KELP_PLANT stalk segments count as KELP). # Run a recount (/ limits recount) after changing this so stored counts # match the new counting rule. stacked-plants-count-as-one: false diff --git a/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java b/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java index 7e7591f..fe79744 100644 --- a/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java @@ -232,6 +232,14 @@ void testFixMaterialBambooSapling() { assertEquals(Material.BAMBOO.getKey(), listener.fixMaterial(blockData)); } + @Test + void testFixMaterialKelpPlant() { + // The stalk segments of a kelp column count as KELP (#294) + BlockData blockData = mock(BlockData.class); + when(blockData.getMaterial()).thenReturn(Material.KELP_PLANT); + assertEquals(Material.KELP.getKey(), listener.fixMaterial(blockData)); + } + @Test void testFixMaterialPistonHeadNormal() { TechnicalPiston tp = mock(TechnicalPiston.class); @@ -730,6 +738,45 @@ void testBlockSpreadAtLimitCancelsAndRestoresOld() { assertEquals(1, ibc.getBlockCount(Material.GRASS_BLOCK.getKey())); } + @Test + void testBlockSpreadKelpGrowthIncrementsKelp() { + // Kelp growth fires BlockSpreadEvent with the water block above the tip as the + // target and the new KELP tip as the new state (#294) + Block block = mockBlock(Material.WATER, blockLocation); + Block source = mockBlock(Material.KELP, new Location(world, 100, 64, 100)); + BlockState newState = mock(BlockState.class); + BlockData newBlockData = mock(BlockData.class); + when(newBlockData.getMaterial()).thenReturn(Material.KELP); + when(newState.getBlockData()).thenReturn(newBlockData); + BlockSpreadEvent event = new BlockSpreadEvent(block, source, newState); + + listener.onBlock(event); + + assertFalse(event.isCancelled()); + assertEquals(1, listener.getIsland("test-island-id").getBlockCount(Material.KELP.getKey())); + } + + @Test + void testBlockSpreadKelpGrowthAtLimitCancelled() { + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + ibc.setBlockLimit(Environment.NORMAL, Material.KELP.getKey(), 1); + ibc.add(Environment.NORMAL, Material.KELP.getKey()); + listener.setIsland("test-island-id", ibc); + + Block block = mockBlock(Material.WATER, blockLocation); + Block source = mockBlock(Material.KELP, new Location(world, 100, 64, 100)); + BlockState newState = mock(BlockState.class); + BlockData newBlockData = mock(BlockData.class); + when(newBlockData.getMaterial()).thenReturn(Material.KELP); + when(newState.getBlockData()).thenReturn(newBlockData); + BlockSpreadEvent event = new BlockSpreadEvent(block, source, newState); + + listener.onBlock(event); + + assertTrue(event.isCancelled()); + assertEquals(1, ibc.getBlockCount(Material.KELP.getKey())); + } + // --- BlockFromToEvent tests --- @Test @@ -1022,6 +1069,27 @@ void testStackedPlantBreakBaseDecrementsOnlyOneWhenEnabled() { assertEquals(0, listener.getIsland("test-island-id").getBlockCount(Material.SUGAR_CANE.getKey())); } + @Test + void testStackedKelpGrowthNotCountedWhenEnabled() { + when(limitsSettings.isStackedPlantsCountAsOne()).thenReturn(true); + // Growth target is the water block sitting on the old KELP tip — same plant, not counted + Block below = mockBlock(Material.KELP, new Location(world, 100, 64, 100)); + Block block = mockBlock(Material.WATER, blockLocation); + when(block.getRelative(BlockFace.DOWN)).thenReturn(below); + + BlockState newState = mock(BlockState.class); + BlockData newBlockData = mock(BlockData.class); + when(newBlockData.getMaterial()).thenReturn(Material.KELP); + when(newState.getBlockData()).thenReturn(newBlockData); + BlockSpreadEvent event = new BlockSpreadEvent(block, mockBlock(Material.KELP, new Location(world, 100, 64, 100)), newState); + + listener.onBlock(event); + + assertFalse(event.isCancelled()); + IslandBlockCount ibc = listener.getIsland("test-island-id"); + assertTrue(ibc == null || ibc.getBlockCount(Material.KELP.getKey()) == 0); + } + // --- Block group limits (#12) --- private void setUpPistonGroup(int limit) { @@ -1210,6 +1278,34 @@ void testBlockBreakBambooCascade() { assertEquals(0, listener.getIsland("test-island-id").getBlockCount(Material.BAMBOO.getKey())); } + @Test + void testBlockBreakKelpCascade() { + // A kelp column is KELP_PLANT segments topped by KELP; all normalise to KELP, + // so breaking the base must decrement the whole column (#294) + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + ibc.add(Environment.NORMAL, Material.KELP.getKey()); + ibc.add(Environment.NORMAL, Material.KELP.getKey()); + ibc.add(Environment.NORMAL, Material.KELP.getKey()); + listener.setIsland("test-island-id", ibc); + + when(world.getMaxHeight()).thenReturn(320); + + Block bottomBlock = mockBlock(Material.KELP_PLANT, new Location(world, 100, 65, 100)); + when(bottomBlock.getY()).thenReturn(65); + Block midBlock = mockBlock(Material.KELP_PLANT, new Location(world, 100, 66, 100)); + when(midBlock.getY()).thenReturn(66); + Block topBlock = mockBlock(Material.KELP, new Location(world, 100, 67, 100)); + when(topBlock.getY()).thenReturn(67); + + when(bottomBlock.getRelative(BlockFace.UP)).thenReturn(midBlock); + when(midBlock.getRelative(BlockFace.UP)).thenReturn(topBlock); + + BlockBreakEvent event = new BlockBreakEvent(bottomBlock, player); + listener.onBlock(event); + + assertEquals(0, listener.getIsland("test-island-id").getBlockCount(Material.KELP.getKey())); + } + @Test void testBlockBreakRedstoneOnTopRemoved() { IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); From d68ed5112b05a5869db30b2c2cf010154567eb8c Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 07:50:45 -0700 Subject: [PATCH 2/3] Let bees exit hives even when the island is at its bee limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An admin report: at 12/12 bees, bees stored in hives could never come out — every release attempt was cancelled by the limit check, the server kept the bee as a hive occupant and retried every few ticks, spamming "Bee spawning limited to 12!" at nearby players and permanently trapping the bees. A bee leaving its hive is not a new bee: its count was decremented when it entered (EntityRemoveEvent ENTER_BLOCK) and the MONITOR tracker re-increments on exit, so the enter/exit cycle is net-zero. Exempt SpawnReason.BEEHIVE from the limit check while keeping the exit counted. If a placed hive item carries stored (never-counted) bees, the island can end slightly over its limit, which simply blocks further spawns and breeding until the population drops. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015uo8XWUxGr6VEn4TRRQ6YB --- .../limits/listeners/EntityLimitListener.java | 8 +++ .../listeners/EntityLimitListenerTest.java | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java index 07f997f..f61233c 100644 --- a/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java +++ b/src/main/java/world/bentobox/limits/listeners/EntityLimitListener.java @@ -133,7 +133,15 @@ public void onCreatureSpawn(final CreatureSpawnEvent creatureSpawnEvent) { justSpawned.remove(creatureSpawnEvent.getEntity().getUniqueId()); return; } + // BEEHIVE: a bee leaving its hive is not a new bee — its count was decremented when it + // entered (EntityRemoveEvent ENTER_BLOCK), and the MONITOR tracker re-increments on exit, + // so the enter/exit cycle is net-zero. Cancelling the exit would strand the bee: the + // server keeps it as a hive occupant and retries every few ticks, spamming the hit-limit + // message and permanently trapping bees whenever the island is at its limit for any other + // reason. Over-limit bees (e.g. from placing a hive item with stored bees) simply block + // further spawns and breeding until the population drops. if (creatureSpawnEvent.getSpawnReason().equals(SpawnReason.SHOULDER_ENTITY) + || creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BEEHIVE) || (!(creatureSpawnEvent.getEntity() instanceof Villager) && creatureSpawnEvent.getSpawnReason().equals(SpawnReason.BREEDING))) { return; diff --git a/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java b/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java index 3786df5..dec7466 100644 --- a/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/EntityLimitListenerTest.java @@ -497,6 +497,57 @@ void testCreatureSpawnDebounceSkipsSecond() throws Exception { assertFalse(event.isCancelled()); } + // --- Bee hive tests --- + + @Test + void testBeehiveExitAtLimitNotCancelled() { + // A bee leaving its hive was decremented when it entered (ENTER_BLOCK), so the exit + // must never be limit-checked — cancelling it strands the bee in the hive and the + // server retries forever, spamming the hit-limit message. + ibc.setEntityLimit(Environment.NORMAL, EntityType.BEE, 1); + ibc.incrementEntity(Environment.NORMAL, EntityType.BEE); + LivingEntity bee = mockEntity(EntityType.BEE, location); + + CreatureSpawnEvent event = new CreatureSpawnEvent(bee, SpawnReason.BEEHIVE); + + ell.onCreatureSpawn(event); + + assertFalse(event.isCancelled()); + verify(islandsManager, never()).getIslandAt(any(Location.class)); + } + + @Test + void testBeehiveExitStillCounted() throws Exception { + // Exempting the exit from the limit check must not exempt it from counting. + LivingEntity bee = mockEntity(EntityType.BEE, location); + CreatureSpawnEvent event = new CreatureSpawnEvent(bee, SpawnReason.BEEHIVE); + + ell.onCreatureSpawnTrack(event); + + assertEquals(1, ibc.getEntityCount(Environment.NORMAL, EntityType.BEE)); + assertEquals("test-island-id", entityIslandMap().get(bee.getUniqueId())); + } + + @Test + void testBeeHiveEnterExitCycleNetZero() throws Exception { + // Full cycle: a counted bee enters a hive (ENTER_BLOCK removal decrements) and is + // later released (BEEHIVE spawn re-increments) — the count must end where it started. + LivingEntity bee = mockEntity(EntityType.BEE, location); + ibc.incrementEntity(Environment.NORMAL, EntityType.BEE); + entityIslandMap().put(bee.getUniqueId(), "test-island-id"); + + ell.onEntityRemove(new EntityRemoveEvent(bee, EntityRemoveEvent.Cause.ENTER_BLOCK)); + assertEquals(0, ibc.getEntityCount(Environment.NORMAL, EntityType.BEE)); + + LivingEntity released = mockEntity(EntityType.BEE, location); + CreatureSpawnEvent exit = new CreatureSpawnEvent(released, SpawnReason.BEEHIVE); + ell.onCreatureSpawn(exit); + assertFalse(exit.isCancelled()); + ell.onCreatureSpawnTrack(exit); + + assertEquals(1, ibc.getEntityCount(Environment.NORMAL, EntityType.BEE)); + } + // --- Copper golem / copper chest limit tests (#276) --- @Test From e4d23435847e31c784d5179955e6fe729c774695 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 14 Aug 2026 08:27:12 -0700 Subject: [PATCH 3/3] Version 1.30.0 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015uo8XWUxGr6VEn4TRRQ6YB --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 15a4fb0..8cf8e0b 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ -LOCAL - 1.29.1 + 1.30.0 BentoBoxWorld_Limits bentobox-world https://sonarcloud.io