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
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.29.1</build.version>
<build.version>1.30.0</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
Expand Up @@ -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<NamespacedKey> STACKABLE = List.of(Material.SUGAR_CANE.getKey(), Material.BAMBOO.getKey());
public static final List<NamespacedKey> 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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -224,7 +228,9 @@ private void registerLimit(Map<NamespacedKey, Integer> 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;
}
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 (/<gamemode> limits recount) after changing this so stored counts
# match the new counting rule.
stacked-plants-count-as-one: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading