Skip to content

Commit 06ee8eb

Browse files
tastybentoclaude
andcommitted
Add option to count stackable plants as a single plant
New config option stacked-plants-count-as-one (default false, current behaviour). When enabled, a column of SUGAR_CANE or BAMBOO counts as one plant toward the limit no matter how tall it grows: segments sitting on the same plant are not counted at placement or growth, the break cascade does not decrement stems, and the recount applies the same rule so live counts and recounts agree. BAMBOO_SAPLING normalises to BAMBOO so a stem above a sapling still reads as the same plant. Fixes #53 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dbJyrv2uxHfTmiWkqGUJB
1 parent 840bbe7 commit 06ee8eb

6 files changed

Lines changed: 109 additions & 3 deletions

File tree

src/main/java/world/bentobox/limits/Settings.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ enum GeneralGroup {
5050
private final boolean logLimitsOnJoin;
5151
private final boolean asyncGolums;
5252
private final boolean showLimitMessages;
53+
private final boolean stackedPlantsCountAsOne;
5354
private static final List<EntityType> DISALLOWED = Arrays.asList(
5455
EntityType.TNT,
5556
EntityType.EVOKER_FANGS,
@@ -96,6 +97,8 @@ public Settings(Limits addon) {
9697
asyncGolums = addon.getConfig().getBoolean("async-golums", true);
9798
// Show or suppress the "hit the limit" player notifications
9899
showLimitMessages = addon.getConfig().getBoolean("show-limit-messages", true);
100+
// Count a stackable plant column (sugar cane, bamboo) as a single plant
101+
stackedPlantsCountAsOne = addon.getConfig().getBoolean("stacked-plants-count-as-one", false);
99102

100103
addon.log("Entity limits:");
101104
envLimits.forEach((env, m) -> m.entrySet().stream()
@@ -266,6 +269,14 @@ public boolean isShowLimitMessages() {
266269
return showLimitMessages;
267270
}
268271

272+
/**
273+
* @return true if a column of stackable plants (sugar cane, bamboo) counts as one
274+
* plant; false if every segment counts (default)
275+
*/
276+
public boolean isStackedPlantsCountAsOne() {
277+
return stackedPlantsCountAsOne;
278+
}
279+
269280
public Map<GeneralGroup, Integer> getGeneral() {
270281
return general;
271282
}

src/main/java/world/bentobox/limits/calculators/RecountCalculator.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,20 @@ private void scanAsync(Environment env, Chunk chunk) {
161161
}
162162

163163
private void scanColumn(Environment env, ChunkSnapshot chunkSnapshot, int x, int z, int minY, int maxY) {
164+
boolean stackedAsOne = addon.getSettings().isStackedPlantsCountAsOne();
165+
NamespacedKey below = null;
164166
for (int y = minY; y < maxY; y++) {
165167
BlockData blockData = chunkSnapshot.getBlockData(x, y, z);
166168
if (Tag.SLABS.isTagged(blockData.getMaterial())
167169
&& ((Slab) blockData).getType().equals(Slab.Type.DOUBLE)) {
168170
checkBlock(env, blockData);
169171
}
170-
checkBlock(env, blockData);
172+
NamespacedKey key = bll.fixMaterial(blockData);
173+
// Stacked-plants-as-one: segments sitting on the same plant are not counted
174+
if (!(stackedAsOne && BlockLimitsListener.STACKABLE.contains(key) && key.equals(below))) {
175+
checkBlock(env, blockData);
176+
}
177+
below = key;
171178
}
172179
}
173180

src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ public class BlockLimitsListener implements Listener {
6565
private static final List<NamespacedKey> DO_NOT_COUNT = List.of(Material.LAVA.getKey(), Material.WATER.getKey(),
6666
Material.AIR.getKey(), Material.FIRE.getKey(), Material.END_PORTAL.getKey(),
6767
Material.NETHER_PORTAL.getKey());
68-
private static final List<NamespacedKey> STACKABLE = List.of(Material.SUGAR_CANE.getKey(), Material.BAMBOO.getKey());
68+
/** Plants that grow as a vertical column on top of themselves. */
69+
public static final List<NamespacedKey> STACKABLE = List.of(Material.SUGAR_CANE.getKey(), Material.BAMBOO.getKey());
6970

7071
/*
7172
* Materials added in Minecraft 1.21.9 ("Copper Age"). Resolved by name so the
@@ -269,7 +270,9 @@ private void handleBreak(Block b) {
269270
return;
270271
}
271272
Material mat = b.getType();
272-
if (STACKABLE.contains(b.getType().getKey())) {
273+
// When stacked plants count as one, only the base segment was ever counted,
274+
// so the stems above must not be decremented here.
275+
if (!addon.getSettings().isStackedPlantsCountAsOne() && STACKABLE.contains(b.getType().getKey())) {
273276
Block block = b;
274277
while (block.getRelative(BlockFace.UP).getType().equals(mat)
275278
&& block.getY() < b.getWorld().getMaxHeight()) {
@@ -407,6 +410,11 @@ public void onBlock(BlockFromToEvent e) {
407410
}
408411
}
409412

413+
/** True if the given material normalises to the same stackable plant key. */
414+
private static boolean isSamePlant(Material below, NamespacedKey plantKey) {
415+
return VARIANT_MAP.getOrDefault(below, below).getKey().equals(plantKey);
416+
}
417+
410418
/**
411419
* Map variant materials to their canonical form.
412420
*/
@@ -432,6 +440,12 @@ private int process(Block b, BlockData blockData, boolean add) {
432440
if (DO_NOT_COUNT.contains(fixMaterial(blockData)) || !addon.inGameModeWorld(b.getWorld())) {
433441
return -1;
434442
}
443+
// Stacked-plants-as-one: a segment sitting on the same plant is not counted,
444+
// limited, or decremented — only the base segment represents the plant.
445+
if (addon.getSettings().isStackedPlantsCountAsOne() && STACKABLE.contains(fixMaterial(blockData))
446+
&& isSamePlant(b.getRelative(BlockFace.DOWN).getType(), fixMaterial(blockData))) {
447+
return -1;
448+
}
435449
Environment env = envOf(b.getWorld());
436450
return addon.getIslands().getIslandAt(b.getLocation()).map(i -> {
437451
String id = i.getUniqueId();

src/main/resources/config.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ show-limit-messages: true
4646
blocklimits:
4747
HOPPER: 10
4848

49+
# Count stackable plants (SUGAR_CANE, BAMBOO) as a single plant no matter how tall
50+
# they grow. When false (default), every segment of the plant counts toward the limit.
51+
# Run a recount (/<gamemode> limits recount) after changing this so stored counts
52+
# match the new counting rule.
53+
stacked-plants-count-as-one: false
54+
4955
# Override block limits in the nether for this game mode.
5056
# Uncomment and add entries to set nether-only limits.
5157
#blocklimits-nether:

src/test/java/world/bentobox/limits/SettingsTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ void testShowLimitMessagesDisabled() {
115115
assertFalse(s.isShowLimitMessages());
116116
}
117117

118+
@Test
119+
void testStackedPlantsCountAsOneDefaultsFalse() {
120+
assertFalse(settings.isStackedPlantsCountAsOne());
121+
}
122+
123+
@Test
124+
void testStackedPlantsCountAsOneEnabled() {
125+
config.set("stacked-plants-count-as-one", true);
126+
Settings s = new Settings(addon);
127+
assertTrue(s.isStackedPlantsCountAsOne());
128+
}
129+
118130
@Test
119131
void testGetGeneralEmpty() {
120132
// Default config.yml does not have ANIMALS or MOBS entries in entitylimits

src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ void setUp() throws Exception {
138138
doAnswer(invocation -> null).when(addon).log(anyString());
139139
doAnswer(invocation -> null).when(addon).logError(anyString());
140140
when(addon.isCoveredGameMode(anyString())).thenReturn(true);
141+
// Limits settings: stacked-plants defaults false (mock default), messages on
141142
when(addon.getSettings()).thenReturn(limitsSettings);
142143
when(limitsSettings.isShowLimitMessages()).thenReturn(true);
143144
when(addon.inGameModeWorld(any(World.class))).thenReturn(false);
@@ -966,6 +967,61 @@ void testEntityChangeBlockFarmlandRemovesCropAbove() {
966967
assertEquals(1, ibc.getBlockCount(Material.DIRT.getKey()));
967968
}
968969

970+
// --- Stacked plants count as one (#53) ---
971+
972+
@Test
973+
void testStackedPlantSegmentNotCountedWhenEnabled() {
974+
when(limitsSettings.isStackedPlantsCountAsOne()).thenReturn(true);
975+
Block below = mockBlock(Material.SUGAR_CANE, new Location(world, 100, 64, 100));
976+
Block block = mockBlock(Material.SUGAR_CANE, blockLocation);
977+
when(block.getRelative(BlockFace.DOWN)).thenReturn(below);
978+
979+
BlockState replacedState = mock(BlockState.class);
980+
BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block,
981+
new ItemStack(Material.SUGAR_CANE), player, true, EquipmentSlot.HAND);
982+
listener.onBlock(event);
983+
984+
assertFalse(event.isCancelled());
985+
IslandBlockCount ibc = listener.getIsland("test-island-id");
986+
assertTrue(ibc == null || ibc.getBlockCount(Material.SUGAR_CANE.getKey()) == 0);
987+
}
988+
989+
@Test
990+
void testStackedPlantBaseCountedWhenEnabled() {
991+
when(limitsSettings.isStackedPlantsCountAsOne()).thenReturn(true);
992+
Block block = mockBlock(Material.SUGAR_CANE, blockLocation);
993+
// Below is AIR from the mockBlock helper — this is the base segment
994+
995+
BlockState replacedState = mock(BlockState.class);
996+
BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block,
997+
new ItemStack(Material.SUGAR_CANE), player, true, EquipmentSlot.HAND);
998+
listener.onBlock(event);
999+
1000+
assertEquals(1, listener.getIsland("test-island-id").getBlockCount(Material.SUGAR_CANE.getKey()));
1001+
}
1002+
1003+
@Test
1004+
void testStackedPlantBreakBaseDecrementsOnlyOneWhenEnabled() {
1005+
when(limitsSettings.isStackedPlantsCountAsOne()).thenReturn(true);
1006+
// Only the base was ever counted
1007+
IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock");
1008+
ibc.add(Environment.NORMAL, Material.SUGAR_CANE.getKey());
1009+
listener.setIsland("test-island-id", ibc);
1010+
1011+
when(world.getMaxHeight()).thenReturn(320);
1012+
Block bottomBlock = mockBlock(Material.SUGAR_CANE, new Location(world, 100, 65, 100));
1013+
when(bottomBlock.getY()).thenReturn(65);
1014+
Block midBlock = mockBlock(Material.SUGAR_CANE, new Location(world, 100, 66, 100));
1015+
when(midBlock.getY()).thenReturn(66);
1016+
when(bottomBlock.getRelative(BlockFace.UP)).thenReturn(midBlock);
1017+
1018+
BlockBreakEvent event = new BlockBreakEvent(bottomBlock, player);
1019+
listener.onBlock(event);
1020+
1021+
// Exactly the one counted base is removed; the stem walk must not run
1022+
assertEquals(0, listener.getIsland("test-island-id").getBlockCount(Material.SUGAR_CANE.getKey()));
1023+
}
1024+
9691025
// --- Block cascade tests ---
9701026

9711027
@Test

0 commit comments

Comments
 (0)