Skip to content

Commit 89607bb

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 874bfa8 commit 89607bb

6 files changed

Lines changed: 113 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
@@ -49,6 +49,7 @@ enum GeneralGroup {
4949
private final List<String> gameModes;
5050
private final boolean logLimitsOnJoin;
5151
private final boolean asyncGolums;
52+
private final boolean stackedPlantsCountAsOne;
5253
private static final List<EntityType> DISALLOWED = Arrays.asList(
5354
EntityType.TNT,
5455
EntityType.EVOKER_FANGS,
@@ -95,6 +96,8 @@ public Settings(Limits addon) {
9596
logLimitsOnJoin = addon.getConfig().getBoolean("log-limits-on-join", true);
9697
// Async Golums
9798
asyncGolums = addon.getConfig().getBoolean("async-golums", true);
99+
// Count a stackable plant column (sugar cane, bamboo) as a single plant
100+
stackedPlantsCountAsOne = addon.getConfig().getBoolean("stacked-plants-count-as-one", false);
98101

99102
addon.log("Entity limits:");
100103
envLimits.forEach((env, m) -> m.entrySet().stream()
@@ -258,6 +261,14 @@ public boolean isAsyncGolums() {
258261
return asyncGolums;
259262
}
260263

264+
/**
265+
* @return true if a column of stackable plants (sugar cane, bamboo) counts as one
266+
* plant; false if every segment counts (default)
267+
*/
268+
public boolean isStackedPlantsCountAsOne() {
269+
return stackedPlantsCountAsOne;
270+
}
271+
261272
public Map<GeneralGroup, Integer> getGeneral() {
262273
return general;
263274
}

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

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

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

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()) {
@@ -405,6 +408,11 @@ public void onBlock(BlockFromToEvent e) {
405408
}
406409
}
407410

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

src/main/resources/config.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ async-golums: true
4242
blocklimits:
4343
HOPPER: 10
4444

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

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,18 @@ void testAsyncGolumsDefaultsTrue() {
103103
assertTrue(settings.isAsyncGolums());
104104
}
105105

106+
@Test
107+
void testStackedPlantsCountAsOneDefaultsFalse() {
108+
assertFalse(settings.isStackedPlantsCountAsOne());
109+
}
110+
111+
@Test
112+
void testStackedPlantsCountAsOneEnabled() {
113+
config.set("stacked-plants-count-as-one", true);
114+
Settings s = new Settings(addon);
115+
assertTrue(s.isStackedPlantsCountAsOne());
116+
}
117+
106118
@Test
107119
void testGetGeneralEmpty() {
108120
// Default config.yml does not have ANIMALS or MOBS entries in entitylimits

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ class BlockLimitsListenerTest {
9191
@Mock
9292
private Limits addon;
9393

94+
@Mock
95+
private world.bentobox.limits.Settings limitsSettings;
96+
9497
@Mock
9598
private World world;
9699

@@ -135,6 +138,8 @@ void setUp() throws Exception {
135138
doAnswer(invocation -> null).when(addon).log(anyString());
136139
doAnswer(invocation -> null).when(addon).logError(anyString());
137140
when(addon.isCoveredGameMode(anyString())).thenReturn(true);
141+
// Limits settings: stacked-plants-count-as-one defaults to false (mock default)
142+
when(addon.getSettings()).thenReturn(limitsSettings);
138143
when(addon.inGameModeWorld(any(World.class))).thenReturn(false);
139144
// Override to true for our test world so event handlers don't bail early
140145
when(addon.inGameModeWorld(world)).thenReturn(true);
@@ -919,6 +924,61 @@ void testEntityChangeBlockFarmlandRemovesCropAbove() {
919924
assertEquals(1, ibc.getBlockCount(Material.DIRT.getKey()));
920925
}
921926

927+
// --- Stacked plants count as one (#53) ---
928+
929+
@Test
930+
void testStackedPlantSegmentNotCountedWhenEnabled() {
931+
when(limitsSettings.isStackedPlantsCountAsOne()).thenReturn(true);
932+
Block below = mockBlock(Material.SUGAR_CANE, new Location(world, 100, 64, 100));
933+
Block block = mockBlock(Material.SUGAR_CANE, blockLocation);
934+
when(block.getRelative(BlockFace.DOWN)).thenReturn(below);
935+
936+
BlockState replacedState = mock(BlockState.class);
937+
BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block,
938+
new ItemStack(Material.SUGAR_CANE), player, true, EquipmentSlot.HAND);
939+
listener.onBlock(event);
940+
941+
assertFalse(event.isCancelled());
942+
IslandBlockCount ibc = listener.getIsland("test-island-id");
943+
assertTrue(ibc == null || ibc.getBlockCount(Material.SUGAR_CANE.getKey()) == 0);
944+
}
945+
946+
@Test
947+
void testStackedPlantBaseCountedWhenEnabled() {
948+
when(limitsSettings.isStackedPlantsCountAsOne()).thenReturn(true);
949+
Block block = mockBlock(Material.SUGAR_CANE, blockLocation);
950+
// Below is AIR from the mockBlock helper — this is the base segment
951+
952+
BlockState replacedState = mock(BlockState.class);
953+
BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block,
954+
new ItemStack(Material.SUGAR_CANE), player, true, EquipmentSlot.HAND);
955+
listener.onBlock(event);
956+
957+
assertEquals(1, listener.getIsland("test-island-id").getBlockCount(Material.SUGAR_CANE.getKey()));
958+
}
959+
960+
@Test
961+
void testStackedPlantBreakBaseDecrementsOnlyOneWhenEnabled() {
962+
when(limitsSettings.isStackedPlantsCountAsOne()).thenReturn(true);
963+
// Only the base was ever counted
964+
IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock");
965+
ibc.add(Environment.NORMAL, Material.SUGAR_CANE.getKey());
966+
listener.setIsland("test-island-id", ibc);
967+
968+
when(world.getMaxHeight()).thenReturn(320);
969+
Block bottomBlock = mockBlock(Material.SUGAR_CANE, new Location(world, 100, 65, 100));
970+
when(bottomBlock.getY()).thenReturn(65);
971+
Block midBlock = mockBlock(Material.SUGAR_CANE, new Location(world, 100, 66, 100));
972+
when(midBlock.getY()).thenReturn(66);
973+
when(bottomBlock.getRelative(BlockFace.UP)).thenReturn(midBlock);
974+
975+
BlockBreakEvent event = new BlockBreakEvent(bottomBlock, player);
976+
listener.onBlock(event);
977+
978+
// Exactly the one counted base is removed; the stem walk must not run
979+
assertEquals(0, listener.getIsland("test-island-id").getBlockCount(Material.SUGAR_CANE.getKey()));
980+
}
981+
922982
// --- Block cascade tests ---
923983

924984
@Test

0 commit comments

Comments
 (0)