From 2f8d8ce3f15cb5507d1e275ea528c13e45484630 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 08:59:24 -0700 Subject: [PATCH] Add block group limits Completes #12: entity groups shipped earlier; this adds the block half. A blockgrouplimits config section (mirroring entitygrouplimits) defines named groups of materials sharing one limit - the counts of all members are summed and checked at placement, so players cannot dodge a limit by converting between related blocks (grass->dirt, #132) or splitting across variants (piston/sticky piston, #84). - Settings parses group definitions plus blockgrouplimits-nether/-end per-environment limit overrides; member names normalise through the existing variant map. - checkLimit consults groups after the individual limit cascade, so a tighter individual limit still applies inside a group. - The recount tracks group members even when they have no individual limit, and the limits GUI shows a row per group (icon, member list, summed count/limit). Fixes #12 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015dbJyrv2uxHfTmiWkqGUJB --- .../world/bentobox/limits/BlockGroup.java | 59 +++++++++ .../java/world/bentobox/limits/Settings.java | 121 ++++++++++++++++-- .../limits/calculators/RecountCalculator.java | 6 +- .../limits/commands/player/LimitTab.java | 25 ++++ .../limits/listeners/BlockLimitsListener.java | 35 +++++ src/main/resources/config.yml | 30 +++++ src/main/resources/locales/en-US.yml | 1 + .../world/bentobox/limits/SettingsTest.java | 41 ++++++ .../listeners/BlockLimitsListenerTest.java | 77 ++++++++++- 9 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 src/main/java/world/bentobox/limits/BlockGroup.java diff --git a/src/main/java/world/bentobox/limits/BlockGroup.java b/src/main/java/world/bentobox/limits/BlockGroup.java new file mode 100644 index 0000000..db555b2 --- /dev/null +++ b/src/main/java/world/bentobox/limits/BlockGroup.java @@ -0,0 +1,59 @@ +package world.bentobox.limits; + +import java.util.Objects; +import java.util.Set; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; + +/** + * A named group of block materials sharing a single limit. The counts of every + * member material are summed and checked against the group limit. + */ +public class BlockGroup { + private final String name; + private final Set keys; + private final int limit; + private final Material icon; + + public BlockGroup(String name, Set keys, int limit, Material icon) { + this.name = name; + this.keys = keys; + this.limit = limit; + this.icon = icon; + } + + public boolean contains(NamespacedKey key) { + return keys.contains(key); + } + + public String getName() { + return name; + } + + public Set getKeys() { + return keys; + } + + public int getLimit() { + return limit; + } + + public Material getIcon() { + return icon; + } + + @Override + public int hashCode() { + return 83 * 7 + Objects.hashCode(this.name); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + return Objects.equals(this.name, ((BlockGroup) obj).name); + } +} diff --git a/src/main/java/world/bentobox/limits/Settings.java b/src/main/java/world/bentobox/limits/Settings.java index d243264..4148501 100644 --- a/src/main/java/world/bentobox/limits/Settings.java +++ b/src/main/java/world/bentobox/limits/Settings.java @@ -38,6 +38,8 @@ enum GeneralGroup { private static final String SKIPPING = " - skipping..."; private static final String LIMIT_SUFFIX = ".limit"; + private static final String LIMIT_LOG_PREFIX = "Limit "; + private static final String GROUP_OVERRIDE_PREFIX = "Group override "; private final Map general = new EnumMap<>(GeneralGroup.class); /** Per-env entity type limits (env defaults from config). */ @@ -46,6 +48,10 @@ enum GeneralGroup { private final Map> groupLimits = new EnumMap<>(EntityType.class); /** Per-env group-limit overrides (env defaults from config). */ private final Map> envGroupLimits = new EnumMap<>(Environment.class); + /** Block group definitions and which groups each canonical block key belongs to. */ + private final Map> blockGroups = new java.util.HashMap<>(); + /** Per-env block-group limits (defaults from config plus env overrides). */ + private final Map> envBlockGroupLimits = new EnumMap<>(Environment.class); private final List gameModes; private final boolean logLimitsOnJoin; private final boolean asyncGolums; @@ -84,6 +90,7 @@ public Settings(Limits addon) { for (Environment env : ENVIRONMENTS) { envLimits.put(env, new EnumMap<>(EntityType.class)); envGroupLimits.put(env, new java.util.HashMap<>()); + envBlockGroupLimits.put(env, new java.util.HashMap<>()); } // Pass 1: parse the unsuffixed entitylimits as the default for every env @@ -105,7 +112,7 @@ public Settings(Limits addon) { addon.log("Entity limits:"); envLimits.forEach((env, m) -> m.entrySet().stream() - .map(e -> "Limit " + e.getKey() + " in " + env + " to " + e.getValue()) + .map(e -> LIMIT_LOG_PREFIX + e.getKey() + " in " + env + " to " + e.getValue()) .forEach(addon::log)); // Group definitions live in the unsuffixed entitygrouplimits section. The entire @@ -117,10 +124,78 @@ public Settings(Limits addon) { addon.log("Entity group limits:"); getGroupLimitDefinitions().stream() - .map(e -> "Limit " + e.getName() + " (" + .map(e -> LIMIT_LOG_PREFIX + e.getName() + " (" + e.getTypes().stream().map(Enum::name).collect(Collectors.joining(", ")) + ") to " + e.getLimit()) .forEach(addon::log); + + // Block groups follow the same pattern as entity groups. + loadBlockGroupDefinitions(addon); + loadBlockGroupLimitOverrides(addon, "blockgrouplimits-nether", Environment.NETHER); + loadBlockGroupLimitOverrides(addon, "blockgrouplimits-end", Environment.THE_END); + + addon.log("Block group limits:"); + getBlockGroupDefinitions().stream() + .map(g -> LIMIT_LOG_PREFIX + g.getName() + " (" + + g.getKeys().stream().map(org.bukkit.NamespacedKey::getKey) + .collect(Collectors.joining(", ")) + + ") to " + g.getLimit()) + .forEach(addon::log); + } + + private void loadBlockGroupDefinitions(Limits addon) { + ConfigurationSection el = addon.getConfig().getConfigurationSection("blockgrouplimits"); + if (el == null) return; + for (String name : el.getKeys(false)) { + int limit = el.getInt(name + LIMIT_SUFFIX); + Material icon = parseIcon(addon, el.getString(name + ".icon", "BARRIER")); + Set keys = el.getStringList(name + ".materials").stream().map(m -> { + Material material = Material.matchMaterial(m); + if (material == null || !material.isBlock()) { + addon.logError("Unknown block material in blockgrouplimits." + name + ": " + m + SKIPPING); + return null; + } + return world.bentobox.limits.listeners.BlockLimitsListener.canonicalKey(material); + }).filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + if (keys.isEmpty()) continue; + BlockGroup group = new BlockGroup(name, keys, limit, icon); + keys.forEach(k -> blockGroups.computeIfAbsent(k, x -> new ArrayList<>()).add(group)); + // Default group limit applies to every env unless overridden. + ENVIRONMENTS.forEach(env -> envBlockGroupLimits.get(env).put(name, limit)); + } + } + + private void loadBlockGroupLimitOverrides(Limits addon, String section, Environment env) { + ConfigurationSection el = addon.getConfig().getConfigurationSection(section); + if (el == null) return; + for (String name : el.getKeys(false)) { + applyBlockGroupOverride(addon, el, section, name, env); + } + } + + private void applyBlockGroupOverride(Limits addon, ConfigurationSection el, String section, String name, + Environment env) { + Integer limit = resolveGroupLimit(el, name); + if (limit == null) { + addon.logError(GROUP_OVERRIDE_PREFIX + section + "." + name + " missing limit - skipping."); + return; + } + // Group must already be defined in the base blockgrouplimits section + if (getBlockGroupDefinitions().stream().noneMatch(g -> g.getName().equals(name))) { + addon.logError(GROUP_OVERRIDE_PREFIX + section + "." + name + + " refers to an undefined group - define it under blockgrouplimits first."); + return; + } + envBlockGroupLimits.get(env).put(name, limit); + } + + private Material parseIcon(Limits addon, String iconName) { + try { + return Material.valueOf(iconName.toUpperCase(Locale.ENGLISH)); + } catch (Exception e) { + addon.logError("Invalid group icon name: " + iconName + ". Use a Bukkit Material."); + return Material.BARRIER; + } } private void loadEntityLimits(Limits addon, String section, List targetEnvs) { @@ -161,14 +236,7 @@ private void loadGroupDefinitions(Limits addon) { if (el == null) return; for (String name : el.getKeys(false)) { int limit = el.getInt(name + LIMIT_SUFFIX); - String iconName = el.getString(name + ".icon", "BARRIER"); - Material icon; - try { - icon = Material.valueOf(iconName.toUpperCase(Locale.ENGLISH)); - } catch (Exception e) { - addon.logError("Invalid group icon name: " + iconName + ". Use a Bukkit Material."); - icon = Material.BARRIER; - } + Material icon = parseIcon(addon, el.getString(name + ".icon", "BARRIER")); Set entities = el.getStringList(name + ".entities").stream().map(s -> { EntityType type = getType(s); if (type == null) { @@ -202,13 +270,13 @@ private void applyGroupOverride(Limits addon, ConfigurationSection el, String se // Accept either a flat int (Monsters: 100) or a nested .limit (Monsters.limit: 100) Integer limit = resolveGroupLimit(el, name); if (limit == null) { - addon.logError("Group override " + section + "." + name + " missing limit - skipping."); + addon.logError(GROUP_OVERRIDE_PREFIX + section + "." + name + " missing limit - skipping."); return; } // Group must already be defined in the base entitygrouplimits section boolean exists = getGroupLimitDefinitions().stream().anyMatch(g -> g.getName().equals(name)); if (!exists) { - addon.logError("Group override " + section + "." + name + addon.logError(GROUP_OVERRIDE_PREFIX + section + "." + name + " refers to an undefined group - define it under entitygrouplimits first."); return; } @@ -287,6 +355,35 @@ public boolean isApplyMemberLimitPerms() { return applyMemberLimitPerms; } + /** + * @param key canonical block key + * @return block groups containing this key; empty if none + */ + public List getBlockGroups(org.bukkit.NamespacedKey key) { + return blockGroups.getOrDefault(key, Collections.emptyList()); + } + + /** + * @return true if the key belongs to any block group + */ + public boolean isInBlockGroup(org.bukkit.NamespacedKey key) { + return blockGroups.containsKey(key); + } + + /** + * @return all defined block groups + */ + public List getBlockGroupDefinitions() { + return blockGroups.values().stream().flatMap(Collection::stream).distinct().toList(); + } + + /** + * @return the block group's limit in this environment, or -1 if not defined + */ + public int getBlockGroupLimit(Environment env, String name) { + return envBlockGroupLimits.getOrDefault(env, Collections.emptyMap()).getOrDefault(name, -1); + } + public Map getGeneral() { return general; } diff --git a/src/main/java/world/bentobox/limits/calculators/RecountCalculator.java b/src/main/java/world/bentobox/limits/calculators/RecountCalculator.java index 47df21d..ebd8049 100644 --- a/src/main/java/world/bentobox/limits/calculators/RecountCalculator.java +++ b/src/main/java/world/bentobox/limits/calculators/RecountCalculator.java @@ -88,8 +88,10 @@ public RecountCalculator(Limits addon, Island island, CompletableFuture private void checkBlock(Environment env, BlockData b) { NamespacedKey md = bll.fixMaterial(b); - // Only count materials that are tracked at any level (env default, world override, island). - if (bll.getMaterialLimits(worlds.get(env), island.getUniqueId()).containsKey(md)) { + // Only count materials that are tracked at any level (env default, world override, + // island, or block group membership). + if (bll.getMaterialLimits(worlds.get(env), island.getUniqueId()).containsKey(md) + || addon.getSettings().isInBlockGroup(md)) { results.getBlockCount(env).add(md); } } diff --git a/src/main/java/world/bentobox/limits/commands/player/LimitTab.java b/src/main/java/world/bentobox/limits/commands/player/LimitTab.java index 94d29eb..d3e12d2 100644 --- a/src/main/java/world/bentobox/limits/commands/player/LimitTab.java +++ b/src/main/java/world/bentobox/limits/commands/player/LimitTab.java @@ -87,6 +87,7 @@ public LimitTab(Limits addon, IslandBlockCount ibc, Map this.env = env; result = new ArrayList<>(); addMaterialIcons(ibc, matLimits); + addBlockGroupIcons(ibc); addEntityLimits(ibc); addEntityGroupLimits(ibc); result.sort(Comparator.comparing(PanelItem::getName)); @@ -188,6 +189,30 @@ private void addMaterialIcons(IslandBlockCount ibc, Map } } + private void addBlockGroupIcons(IslandBlockCount ibc) { + for (world.bentobox.limits.BlockGroup g : addon.getSettings().getBlockGroupDefinitions()) { + int limit = addon.getSettings().getBlockGroupLimit(env, g.getName()); + if (limit < 0) { + continue; + } + PanelItemBuilder pib = new PanelItemBuilder(); + pib.name(user.getTranslation("island.limits.panel.block-group-name-syntax", TextVariables.NAME, + g.getName())); + String description = "(" + g.getKeys().stream().map(k -> Util.prettifyText(k.getKey())) + .collect(Collectors.joining(", ")) + ")\n"; + pib.icon(g.getIcon()); + int count = ibc == null ? 0 + : g.getKeys().stream().mapToInt(k -> ibc.getBlockCount(env, k)).sum(); + String color = count >= limit ? user.getTranslation(MAX_COLOR_KEY) + : user.getTranslation(REGULAR_COLOR_KEY); + description += color + user.getTranslation(BLOCK_LIMIT_SYNTAX_KEY, + TextVariables.NUMBER, String.valueOf(count), + LIMIT_PLACEHOLDER, String.valueOf(limit)); + pib.description(description); + result.add(pib.build()); + } + } + @Override public PanelItem getIcon() { Material icon = switch (env) { diff --git a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java index 4dc8ec0..558b94e 100644 --- a/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java +++ b/src/main/java/world/bentobox/limits/listeners/BlockLimitsListener.java @@ -416,6 +416,13 @@ private static boolean isSamePlant(Material below, NamespacedKey plantKey) { return VARIANT_MAP.getOrDefault(below, below).getKey().equals(plantKey); } + /** + * @return the canonical key for a material after variant normalisation + */ + public static NamespacedKey canonicalKey(Material m) { + return VARIANT_MAP.getOrDefault(m, m).getKey(); + } + /** * Map variant materials to their canonical form. */ @@ -503,6 +510,34 @@ private void updateSaveMap(String id) { * @return limit if at or over, or -1 if no limit */ private int checkLimit(World w, Environment env, NamespacedKey m, String id) { + int single = checkSingleLimit(w, env, m, id); + if (single > -1) { + return single; + } + return checkBlockGroupLimit(env, m, islandCountMap.get(id)); + } + + /** + * Check the block groups this material belongs to: the counts of all group members + * are summed and compared against the group's limit for this environment. + * + * @return group limit if at or over, or -1 if no group limit is hit + */ + private int checkBlockGroupLimit(Environment env, NamespacedKey m, IslandBlockCount ibc) { + for (world.bentobox.limits.BlockGroup group : addon.getSettings().getBlockGroups(m)) { + int limit = addon.getSettings().getBlockGroupLimit(env, group.getName()); + if (limit < 0) { + continue; + } + int sum = group.getKeys().stream().mapToInt(k -> ibc.getBlockCount(env, k)).sum(); + if (sum >= limit) { + return limit; + } + } + return -1; + } + + private int checkSingleLimit(World w, Environment env, NamespacedKey m, String id) { IslandBlockCount ibc = islandCountMap.get(id); if (ibc.isBlockLimited(env, m)) { int offset = ibc.getBlockLimitOffset(env, m); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 91590cc..bafb55d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -71,6 +71,36 @@ stacked-plants-count-as-one: false #blocklimits-end: # HOPPER: 5 +# Block Groups +# Same idea as entity groups below: one shared limit across a set of block materials. +# The counts of every member are summed and checked against the group limit, so players +# cannot dodge a limit by converting between related blocks (e.g. grass -> dirt) or +# splitting across variants (e.g. pistons and sticky pistons). +# Individual blocklimits still apply on top if both are set. +# Run a recount (/ limits recount) after adding a group so existing blocks +# are counted. +#blockgrouplimits: +# Pistons: +# icon: PISTON +# limit: 10 +# materials: +# - PISTON +# - STICKY_PISTON +# Soil: +# icon: GRASS_BLOCK +# limit: 200 +# materials: +# - GRASS_BLOCK +# - DIRT +# - DIRT_PATH +# - FARMLAND +# Override a block group's limit per environment. The group must already be defined +# in blockgrouplimits; only the numeric limit can be overridden. +#blockgrouplimits-nether: +# Pistons: 5 +#blockgrouplimits-end: +# Pistons: 5 + # This section is for world-named limits and overrides any environment-default limit # above for that specific world. Specify each world you want to limit individually # (including nether and end worlds). If these worlds are not covered by the game modes diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 4fea8dc..5194fba 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -62,6 +62,7 @@ island: entity-group-name-syntax: '[name]' entity-name-syntax: '[name]' block-name-syntax: '[name]' + block-group-name-syntax: '[name]' env-overworld: "Overworld" env-nether: "Nether" env-end: "End" diff --git a/src/test/java/world/bentobox/limits/SettingsTest.java b/src/test/java/world/bentobox/limits/SettingsTest.java index 1e1a58c..c900e07 100644 --- a/src/test/java/world/bentobox/limits/SettingsTest.java +++ b/src/test/java/world/bentobox/limits/SettingsTest.java @@ -163,6 +163,47 @@ void testDisallowedEntityTypeLogsError() { assertFalse(s.getLimits(Environment.NORMAL).containsKey(EntityType.TNT)); } + // --- Block group limits (#12) --- + + @Test + void testBlockGroupLimitsParsed() { + config.set("blockgrouplimits.Pistons.icon", "PISTON"); + config.set("blockgrouplimits.Pistons.limit", 10); + config.set("blockgrouplimits.Pistons.materials", List.of("PISTON", "STICKY_PISTON")); + config.set("blockgrouplimits-nether.Pistons", 5); + Settings s = new Settings(addon); + + assertEquals(1, s.getBlockGroupDefinitions().size()); + BlockGroup group = s.getBlockGroupDefinitions().get(0); + assertEquals("Pistons", group.getName()); + assertEquals(Material.PISTON, group.getIcon()); + assertTrue(s.isInBlockGroup(Material.PISTON.getKey())); + assertTrue(s.isInBlockGroup(Material.STICKY_PISTON.getKey())); + assertFalse(s.isInBlockGroup(Material.DIRT.getKey())); + assertEquals(10, s.getBlockGroupLimit(Environment.NORMAL, "Pistons")); + assertEquals(5, s.getBlockGroupLimit(Environment.NETHER, "Pistons")); + assertEquals(10, s.getBlockGroupLimit(Environment.THE_END, "Pistons")); + assertEquals(-1, s.getBlockGroupLimit(Environment.NORMAL, "Nope")); + } + + @Test + void testBlockGroupUnknownMaterialSkipped() { + config.set("blockgrouplimits.Bad.limit", 10); + config.set("blockgrouplimits.Bad.materials", List.of("NOT_A_BLOCK_XYZ")); + Settings s = new Settings(addon); + verify(addon).logError("Unknown block material in blockgrouplimits.Bad: NOT_A_BLOCK_XYZ - skipping..."); + // All members invalid -> group not created + assertTrue(s.getBlockGroupDefinitions().isEmpty()); + } + + @Test + void testBlockGroupOverrideForUndefinedGroupLogsError() { + config.set("blockgrouplimits-end.Ghost", 5); + new Settings(addon); + verify(addon).logError("Group override blockgrouplimits-end.Ghost" + + " refers to an undefined group - define it under blockgrouplimits first."); + } + @Test void testHangingEntityLimitsParsed() { config.set("entitylimits.ITEM_FRAME", 10); diff --git a/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java b/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java index e7ca6f0..c03eeee 100644 --- a/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java +++ b/src/test/java/world/bentobox/limits/listeners/BlockLimitsListenerTest.java @@ -138,7 +138,7 @@ void setUp() throws Exception { doAnswer(invocation -> null).when(addon).log(anyString()); doAnswer(invocation -> null).when(addon).logError(anyString()); when(addon.isCoveredGameMode(anyString())).thenReturn(true); - // Limits settings: stacked-plants defaults false (mock default), messages on + // Limits settings mock: stacked-plants and block groups default off, messages on when(addon.getSettings()).thenReturn(limitsSettings); when(limitsSettings.isShowLimitMessages()).thenReturn(true); when(addon.inGameModeWorld(any(World.class))).thenReturn(false); @@ -1022,6 +1022,81 @@ void testStackedPlantBreakBaseDecrementsOnlyOneWhenEnabled() { assertEquals(0, listener.getIsland("test-island-id").getBlockCount(Material.SUGAR_CANE.getKey())); } + // --- Block group limits (#12) --- + + private void setUpPistonGroup(int limit) { + world.bentobox.limits.BlockGroup group = new world.bentobox.limits.BlockGroup("Pistons", + java.util.Set.of(Material.PISTON.getKey(), Material.STICKY_PISTON.getKey()), limit, Material.PISTON); + when(limitsSettings.getBlockGroups(Material.PISTON.getKey())).thenReturn(List.of(group)); + when(limitsSettings.getBlockGroups(Material.STICKY_PISTON.getKey())).thenReturn(List.of(group)); + when(limitsSettings.getBlockGroupLimit(Environment.NORMAL, "Pistons")).thenReturn(limit); + } + + @Test + void testBlockGroupLimitCancelsWhenGroupFull() { + setUpPistonGroup(10); + // 6 pistons + 4 sticky pistons = 10, group full + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + for (int i = 0; i < 6; i++) { + ibc.add(Environment.NORMAL, Material.PISTON.getKey()); + } + for (int i = 0; i < 4; i++) { + ibc.add(Environment.NORMAL, Material.STICKY_PISTON.getKey()); + } + listener.setIsland("test-island-id", ibc); + + Block block = mockBlock(Material.PISTON, blockLocation); + BlockState replacedState = mock(BlockState.class); + BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block, + new ItemStack(Material.PISTON), player, true, EquipmentSlot.HAND); + listener.onBlock(event); + + assertTrue(event.isCancelled()); + // Nothing was added on the cancel path + assertEquals(6, listener.getIsland("test-island-id").getBlockCount(Material.PISTON.getKey())); + } + + @Test + void testBlockGroupLimitAllowsWhenUnderAndCounts() { + setUpPistonGroup(10); + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + for (int i = 0; i < 5; i++) { + ibc.add(Environment.NORMAL, Material.PISTON.getKey()); + } + for (int i = 0; i < 4; i++) { + ibc.add(Environment.NORMAL, Material.STICKY_PISTON.getKey()); + } + listener.setIsland("test-island-id", ibc); + + Block block = mockBlock(Material.STICKY_PISTON, blockLocation); + BlockState replacedState = mock(BlockState.class); + BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block, + new ItemStack(Material.STICKY_PISTON), player, true, EquipmentSlot.HAND); + listener.onBlock(event); + + assertFalse(event.isCancelled()); + assertEquals(5, listener.getIsland("test-island-id").getBlockCount(Material.STICKY_PISTON.getKey())); + } + + @Test + void testIndividualLimitStillAppliesInsideGroup() { + setUpPistonGroup(10); + // Island-specific individual limit of 2 on PISTON is tighter than the group + IslandBlockCount ibc = new IslandBlockCount("test-island-id", "BSkyBlock"); + ibc.setBlockLimit(Environment.NORMAL, Material.PISTON.getKey(), 2); + ibc.add(Environment.NORMAL, Material.PISTON.getKey()); + ibc.add(Environment.NORMAL, Material.PISTON.getKey()); + listener.setIsland("test-island-id", ibc); + + Block block = mockBlock(Material.PISTON, blockLocation); + BlockState replacedState = mock(BlockState.class); + BlockPlaceEvent event = new BlockPlaceEvent(block, replacedState, block, + new ItemStack(Material.PISTON), player, true, EquipmentSlot.HAND); + listener.onBlock(event); + + assertTrue(event.isCancelled()); + } + // --- Block cascade tests --- @Test