From 7f6b3b53f00385066de8a5076c57fce1674f57fc Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 10 Jul 2026 08:36:27 -0700 Subject: [PATCH] Add opt-in support for team members' limit permissions Permission-based limits were applied from the island owner only, which surprises team servers where a member buys a rank with higher limits. New config option apply-member-limit-perms (default false). When enabled, a team member's login merges their limit permissions into the island's limits (highest value wins), and the owner's login still recalculates from scratch and then merges the permissions of all online team members. Coop and trusted players do not qualify. Limits recalculate on login, so revoking a member's permission takes effect at the owner's next login. Fixes #241 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015dbJyrv2uxHfTmiWkqGUJB --- .../java/world/bentobox/limits/Settings.java | 10 ++ .../limits/listeners/JoinListener.java | 67 +++++++++++-- src/main/resources/config.yml | 10 ++ .../bentobox/limits/JoinListenerTest.java | 93 +++++++++++++++++++ 4 files changed, 171 insertions(+), 9 deletions(-) diff --git a/src/main/java/world/bentobox/limits/Settings.java b/src/main/java/world/bentobox/limits/Settings.java index 5732ad0..d243264 100644 --- a/src/main/java/world/bentobox/limits/Settings.java +++ b/src/main/java/world/bentobox/limits/Settings.java @@ -51,6 +51,7 @@ enum GeneralGroup { private final boolean asyncGolums; private final boolean showLimitMessages; private final boolean stackedPlantsCountAsOne; + private final boolean applyMemberLimitPerms; private static final List DISALLOWED = Arrays.asList( EntityType.TNT, EntityType.EVOKER_FANGS, @@ -99,6 +100,8 @@ public Settings(Limits addon) { showLimitMessages = addon.getConfig().getBoolean("show-limit-messages", true); // Count a stackable plant column (sugar cane, bamboo) as a single plant stackedPlantsCountAsOne = addon.getConfig().getBoolean("stacked-plants-count-as-one", false); + // Apply team members' limit permissions, not just the owner's + applyMemberLimitPerms = addon.getConfig().getBoolean("apply-member-limit-perms", false); addon.log("Entity limits:"); envLimits.forEach((env, m) -> m.entrySet().stream() @@ -277,6 +280,13 @@ public boolean isStackedPlantsCountAsOne() { return stackedPlantsCountAsOne; } + /** + * @return true if team members' limit permissions are applied to the island, not just the owner's + */ + public boolean isApplyMemberLimitPerms() { + return applyMemberLimitPerms; + } + public Map getGeneral() { return general; } diff --git a/src/main/java/world/bentobox/limits/listeners/JoinListener.java b/src/main/java/world/bentobox/limits/listeners/JoinListener.java index 1fcafa2..1ec5384 100644 --- a/src/main/java/world/bentobox/limits/listeners/JoinListener.java +++ b/src/main/java/world/bentobox/limits/listeners/JoinListener.java @@ -20,6 +20,7 @@ import org.bukkit.permissions.PermissionAttachmentInfo; import org.eclipse.jdt.annotation.NonNull; +import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.events.island.IslandEvent; import world.bentobox.bentobox.api.events.island.IslandEvent.Reason; import world.bentobox.bentobox.api.events.team.TeamSetownerEvent; @@ -34,6 +35,12 @@ /** * Applies permission-based block, entity, and entity-group limits to islands. * + *

By default only the island owner's permissions count. When + * {@code apply-member-limit-perms} is enabled in the config, team members' permissions + * are merged in as well: a member's login merges their limits on top (highest value + * wins), and the owner's login recalculates from scratch and then merges the + * permissions of all online members. + * *

Permission format: *

    *
  • 5-segment: {@code .island.limit..} — same limit applied independently @@ -64,6 +71,16 @@ public void checkPerms(Player player, String permissionPrefix, String islandId, islandBlockCount.clearAllEntityGroupLimits(); islandBlockCount.clearAllBlockLimits(); } + mergePerms(player, permissionPrefix, islandId, gameMode); + } + + /** + * Reads every limit-shaped permission the player has and merges it into the island's + * existing permission-based limits — the highest value wins. Unlike + * {@link #checkPerms}, nothing is cleared first. + */ + public void mergePerms(Player player, String permissionPrefix, String islandId, String gameMode) { + IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(islandId); for (PermissionAttachmentInfo permissionInfo : player.getEffectivePermissions()) { if (!permissionInfo.getValue() || !permissionInfo.getPermission().startsWith(permissionPrefix)) { continue; @@ -251,16 +268,47 @@ public void onOwnerChange(TeamSetownerEvent event) { @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onPlayerJoin(PlayerJoinEvent event) { + UUID playerUUID = event.getPlayer().getUniqueId(); addon.getGameModes().forEach(gameMode -> addon.getIslands() - .getIslands(gameMode.getOverWorld(), event.getPlayer().getUniqueId()).stream() - .filter(island -> event.getPlayer().getUniqueId().equals(island.getOwner())) - .map(Island::getUniqueId).forEach(islandId -> { - IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(islandId); - if (!joinEventCheck(event.getPlayer(), islandId, islandBlockCount)) { - checkPerms(event.getPlayer(), gameMode.getPermissionPrefix() + "island.limit.", islandId, - gameMode.getDescription().getName()); - } - })); + .getIslands(gameMode.getOverWorld(), playerUUID).stream() + .filter(island -> playerUUID.equals(island.getOwner()) + || (addon.getSettings().isApplyMemberLimitPerms() + && island.getMemberSet().contains(playerUUID))) + .forEach(island -> processJoin(event.getPlayer(), island, gameMode))); + } + + private void processJoin(Player player, Island island, GameModeAddon gameMode) { + String islandId = island.getUniqueId(); + IslandBlockCount islandBlockCount = addon.getBlockLimitListener().getIsland(islandId); + if (joinEventCheck(player, islandId, islandBlockCount)) { + return; + } + String permissionPrefix = gameMode.getPermissionPrefix() + "island.limit."; + String gameModeName = gameMode.getDescription().getName(); + if (player.getUniqueId().equals(island.getOwner())) { + // Owner login recalculates the limits from scratch... + checkPerms(player, permissionPrefix, islandId, gameModeName); + // ...then merges in the perms of any online team members + mergeOnlineMemberPerms(island, permissionPrefix, gameModeName); + } else { + // Member login merges their perms on top of whatever is set — highest wins + mergePerms(player, permissionPrefix, islandId, gameModeName); + } + } + + /** + * Merges the limit permissions of every online team member (excluding the owner) + * into the island's limits. No-op unless {@code apply-member-limit-perms} is enabled. + */ + private void mergeOnlineMemberPerms(Island island, String permissionPrefix, String gameModeName) { + if (!addon.getSettings().isApplyMemberLimitPerms()) { + return; + } + island.getMemberSet().stream() + .filter(memberUUID -> !memberUUID.equals(island.getOwner())) + .map(Bukkit::getPlayer) + .filter(Objects::nonNull) + .forEach(member -> mergePerms(member, permissionPrefix, island.getUniqueId(), gameModeName)); } private boolean joinEventCheck(Player player, String islandId, IslandBlockCount islandBlockCount) { @@ -303,6 +351,7 @@ private void setOwnerPerms(Island island, UUID ownerUUID) { if (!permissionPrefix.isEmpty() && !gameModeName.isEmpty() && owner.getPlayer() != null) { checkPerms(Objects.requireNonNull(owner.getPlayer()), permissionPrefix + "island.limit.", island.getUniqueId(), gameModeName); + mergeOnlineMemberPerms(island, permissionPrefix + "island.limit.", gameModeName); } } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 056c374..91590cc 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -34,6 +34,16 @@ async-golums: true # silently - placements and spawns are still blocked, but no message is sent. show-limit-messages: true +# Apply limit permissions from team members as well as the island owner. +# When enabled, a team member's .island.limit.* permissions are merged into +# the island's limits when the member logs in (the highest value wins). The owner's +# login still recalculates the limits from scratch and then merges in the permissions +# of any online team members. +# Limits only recalculate on login, so removing a member (or revoking their permission) +# takes effect the next time the owner logs in. +# Coop and trusted players are not team members and their permissions never apply. +apply-member-limit-perms: false + # General block limiting # Use this section to limit how many blocks can be added to an island. # 0 means the item will be blocked from placement completely. diff --git a/src/test/java/world/bentobox/limits/JoinListenerTest.java b/src/test/java/world/bentobox/limits/JoinListenerTest.java index 96e8049..950f9cf 100644 --- a/src/test/java/world/bentobox/limits/JoinListenerTest.java +++ b/src/test/java/world/bentobox/limits/JoinListenerTest.java @@ -46,6 +46,8 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import com.google.common.collect.ImmutableSet; + import world.bentobox.bentobox.api.addons.AddonDescription; import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.events.island.IslandEvent; @@ -398,6 +400,97 @@ void testOnPlayerJoinWithPermLimitsMultiPerms() { verify(ibc).setEntityLimit(Environment.NORMAL, EntityType.CAVE_SPIDER, 4); } + // --- Team member limit perms (#241) --- + + private PermissionAttachmentInfo mockPerm(String permission) { + PermissionAttachmentInfo permAtt = mock(PermissionAttachmentInfo.class); + when(permAtt.getPermission()).thenReturn(permission); + when(permAtt.getValue()).thenReturn(true); + return permAtt; + } + + @Test + void testOnPlayerJoinMemberIgnoredWhenDisabled() { + // Player is a team member, not the owner; feature off (default) + when(island.getOwner()).thenReturn(UUID.randomUUID()); + when(settings.isApplyMemberLimitPerms()).thenReturn(false); + Set perms = Set.of(mockPerm("bskyblock.island.limit.STONE.24")); + when(player.getEffectivePermissions()).thenReturn(perms); + + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + + verify(ibc, never()).setBlockLimit(any(), any(), anyInt()); + verify(bll, never()).setIsland(anyString(), any()); + } + + @Test + void testOnPlayerJoinMemberPermsMergedWhenEnabled() { + // Player is a team member, not the owner; feature on + when(island.getOwner()).thenReturn(UUID.randomUUID()); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid)); + when(settings.isApplyMemberLimitPerms()).thenReturn(true); + Set perms = Set.of(mockPerm("bskyblock.island.limit.STONE.24")); + when(player.getEffectivePermissions()).thenReturn(perms); + + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + + verify(ibc).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 24); + // A member join merges — it must never clear the island's existing perm limits + verify(ibc, never()).clearAllBlockLimits(); + verify(ibc, never()).clearAllEntityLimits(); + verify(ibc, never()).clearAllEntityGroupLimits(); + } + + @Test + void testOnPlayerJoinMemberMergeKeepsHigherExistingLimit() { + // Island already has STONE at 30 (e.g. from the owner); member perm of 24 must not lower it + when(island.getOwner()).thenReturn(UUID.randomUUID()); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid)); + when(settings.isApplyMemberLimitPerms()).thenReturn(true); + when(ibc.getBlockLimit(any(Environment.class), any(NamespacedKey.class))).thenReturn(30); + Set perms = Set.of(mockPerm("bskyblock.island.limit.STONE.24")); + when(player.getEffectivePermissions()).thenReturn(perms); + + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + + verify(ibc).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 30); + verify(ibc, never()).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 24); + } + + @Test + void testOnPlayerJoinOwnerMergesOnlineMemberPerms() { + // Owner joins with no perms of their own; an online member has STONE.44 + UUID memberUUID = UUID.randomUUID(); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid, memberUUID)); + when(settings.isApplyMemberLimitPerms()).thenReturn(true); + Player member = mock(Player.class); + when(member.getUniqueId()).thenReturn(memberUUID); + when(member.getName()).thenReturn("member"); + Set memberPerms = Set.of(mockPerm("bskyblock.island.limit.STONE.44")); + when(member.getEffectivePermissions()).thenReturn(memberPerms); + mockedBukkit.when(() -> Bukkit.getPlayer(memberUUID)).thenReturn(member); + + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + + // Owner join recalculates from scratch, then the member's perm is merged in + verify(ibc).clearAllBlockLimits(); + verify(ibc).setBlockLimit(Environment.NORMAL, Material.STONE.getKey(), 44); + } + + @Test + void testOnPlayerJoinOwnerIgnoresOfflineMemberPerms() { + // Owner joins; the only member is offline, so nothing extra is merged + UUID memberUUID = UUID.randomUUID(); + when(island.getMemberSet()).thenReturn(ImmutableSet.of(uuid, memberUUID)); + when(settings.isApplyMemberLimitPerms()).thenReturn(true); + mockedBukkit.when(() -> Bukkit.getPlayer(memberUUID)).thenReturn(null); + + jl.onPlayerJoin(new PlayerJoinEvent(player, Component.text("welcome"))); + + verify(ibc).clearAllBlockLimits(); + verify(ibc, never()).setBlockLimit(any(), any(), anyInt()); + } + /** * Test method for {@link world.bentobox.limits.listeners.JoinListener#onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent)}. */