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