From dceea761e705487611d1267c39d36f98f551d5b5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:39:32 -0700 Subject: [PATCH] Let islands choose who may claim chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming was hard-coded to the island owner, and a teammate who hit the border got nothing back at all — no message, no sound — which reads as a broken mechanic rather than as a permission. Who may spend the island's level credit is now the CHUNKBLOCK_CLAIM_CHUNKS protection flag, so an island can open expansion to sub-owners, members, trusted or coop through the ordinary island settings GUI, and servers can move the default in default-island-flags. The flag defaults to owner rank, so nothing changes until an island says otherwise. A teammate below the rank is now told why instead of being ignored. Visitors and passers-by still get nothing: they see the ordinary locked-chunk message from the guard listener, and another island's credit is none of their business. The guard listener carried the same hard-coded owner check, so a teammate bumping into the border never saw the claim hint even on an island that had opened claiming to them. It now asks the same flag. Both listener tests move from mocking ChunkBlock to spying on a real one: a mock leaves the final flag fields null, and BossBarListenerTest already establishes the spy pattern for exactly this reason. Part of #6. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Taa2snoHkvtcZ6e5rAHDQS --- .../world/bentobox/chunkblock/ChunkBlock.java | 11 ++++ .../listeners/ChunkClaimListener.java | 32 ++++++++--- .../listeners/ChunkGuardListener.java | 8 +-- src/main/resources/config.yml | 1 + src/main/resources/locales/en-US.yml | 7 +++ .../listeners/ChunkClaimListenerTest.java | 55 +++++++++++++++---- .../listeners/ChunkGuardListenerTest.java | 35 +++++++++--- 7 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java index b2fb86e..8d7072b 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java @@ -130,6 +130,15 @@ public class ChunkBlock extends GameModeAddon { .type(Type.PROTECTION) .defaultRank(RanksManager.COOP_RANK) .build(); + /** + * Flag to set who can spend the island's level credit on new chunks. Defaults to the + * owner alone, because a claim is irreversible until the levels are earned back. + */ + public final Flag CHUNKBLOCK_CLAIM_CHUNKS = new Flag.Builder("CHUNKBLOCK_CLAIM_CHUNKS", Material.OAK_FENCE_GATE) + .mode(Mode.BASIC) + .type(Type.PROTECTION) + .defaultRank(RanksManager.OWNER_RANK) + .build(); @Override public void onLoad() { @@ -177,6 +186,8 @@ public void onLoad() { } // Magic Block protection getPlugin().getFlagsManager().registerFlag(this, this.MAGIC_BLOCK); + // Who may spend level credit on chunks + getPlugin().getFlagsManager().registerFlag(this, this.CHUNKBLOCK_CLAIM_CHUNKS); } } diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java index bc710b5..15519e5 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java @@ -22,14 +22,17 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.RanksManager; import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.chunkblock.chunks.ChunkManager; import world.bentobox.chunkblock.chunks.ChunkManager.ClaimResult; /** - * Lets the island owner spend level credit by hitting the border: when they punch (or - * right-click) toward the locked chunk blocking them, that chunk is claimed and opens up. - * Expansion is the owner's choice, in any direction, up to the island's protection range. + * Lets an island spend level credit by hitting the border: when a player punches (or + * right-clicks) toward the locked chunk blocking them, that chunk is claimed and opens up. + * Expansion goes in any direction, up to the island's protection range. Who is allowed to + * spend is the CHUNKBLOCK_CLAIM_CHUNKS island setting, owner-only unless an island opens it + * to lower ranks. *

* Levels are hard-won, so by default a claim takes two deliberate gestures: the first hit * outlines the target chunk and quotes the price, and only a second hit made while sneaking @@ -107,8 +110,10 @@ public void onBorderHit(PlayerInteractEvent e) { return; } Island island = optionalIsland.get(); - // Claiming is the owner's call - if (!player.getUniqueId().equals(island.getOwner())) { + User user = User.getInstance(player); + // Who may spend the island's credit is an island setting, owner-only by default + if (!island.isAllowed(user, addon.CHUNKBLOCK_CLAIM_CHUNKS)) { + denyClaim(user, island); return; } ChunkManager cm = addon.getChunkManager(); @@ -133,7 +138,20 @@ public void onBorderHit(PlayerInteractEvent e) { if (target == null) { return; } - attemptClaim(User.getInstance(player), island, target[0], target[1]); + attemptClaim(user, island, target[0], target[1]); + } + + /** + * Tells a teammate whose rank is too low that expansion is not theirs to spend on. + * Visitors and passers-by are told nothing: they get the ordinary locked-chunk message + * from the guard listener instead, and have no business hearing about the island's + * credit. + */ + private void denyClaim(User user, Island island) { + if (island.getRank(user) > RanksManager.VISITOR_RANK && feedbackReady(user.getUniqueId())) { + user.notify(addon.CHUNKBLOCK_CLAIM_CHUNKS.getHintReference()); + user.getPlayer().playSound(user.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, 1F, 0.6F); + } } /** @@ -185,7 +203,7 @@ private int[] findTargetLockedChunk(Player player, Island island) { * enabled a claimable chunk is only previewed the first time round; the credit is spent * on the confirming hit. * - * @param user the island owner + * @param user the player spending the credit, already checked against the claim flag * @param island the island * @param chunkX target world chunk x * @param chunkZ target world chunk z diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ChunkGuardListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ChunkGuardListener.java index 750d4df..699a89c 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ChunkGuardListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ChunkGuardListener.java @@ -131,14 +131,14 @@ public void onPlayerMove(PlayerMoveEvent e) { } /** - * Tells a player bumping into the border what to do about it: owners aiming at a - * claimable chunk are invited to hit the border (or told how many levels they still - * need); everyone else just learns the chunk is locked. + * Tells a player bumping into the border what to do about it: those allowed to spend + * the island's credit on a claimable chunk are invited to hit the border (or told how + * many levels they still need); everyone else just learns the chunk is locked. */ private void sendBumpMessage(Player player, Location to) { User user = User.getInstance(player); Optional optionalIsland = islandAt(to); - if (optionalIsland.isPresent() && player.getUniqueId().equals(optionalIsland.get().getOwner())) { + if (optionalIsland.isPresent() && optionalIsland.get().isAllowed(user, addon.CHUNKBLOCK_CLAIM_CHUNKS)) { Island island = optionalIsland.get(); ChunkManager cm = addon.getChunkManager(); if (cm.checkGeometry(island, to.getBlockX() >> 4, to.getBlockZ() >> 4) == ChunkManager.ClaimResult.OK) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 9694d78..b782e34 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -409,6 +409,7 @@ world: CHORUS_FRUIT: 500 CONTAINER: 500 MAGIC_BLOCK: 200 + CHUNKBLOCK_CLAIM_CHUNKS: 1000 JUKEBOX: 500 POTION_THROWING: 500 BARREL: 500 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index d1087d4..fa4282d 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -11,6 +11,13 @@ protection: &b Rank that can break the magic &b block if they can break blocks. hint: "&c Your rank cannot break the magic block!" + CHUNKBLOCK_CLAIM_CHUNKS: + name: Claim Chunks + description: |- + &b Rank that can spend the + &b island's level credit to + &b claim new chunks. + hint: "&c Your rank cannot claim chunks for this island!" CHUNKBLOCK_START_SAFETY: name: Starting Safety description: |- diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java index c5da93b..949228c 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java @@ -6,8 +6,11 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -25,6 +28,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.managers.RanksManager; import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.chunkblock.CommonTestSetup; import world.bentobox.chunkblock.Settings; @@ -51,24 +56,25 @@ class ChunkClaimListenerTest extends CommonTestSetup { @BeforeEach public void setUp() throws Exception { super.setUp(); - addon = mock(ChunkBlock.class); - when(addon.getPlugin()).thenReturn(plugin); - when(addon.inWorld(world)).thenReturn(true); - when(addon.getIslands()).thenReturn(im); + // A spy on a real addon, so the CHUNKBLOCK_CLAIM_CHUNKS flag field is built + addon = spy(new ChunkBlock()); + doReturn(plugin).when(addon).getPlugin(); + doReturn(true).when(addon).inWorld(world); + doReturn(im).when(addon).getIslands(); settings = new Settings(); - when(addon.getSettings()).thenReturn(settings); + addon.setSettings(settings); borderDisplay = mock(BorderDisplay.class); - when(addon.getBorderDisplay()).thenReturn(borderDisplay); + doReturn(borderDisplay).when(addon).getBorderDisplay(); ChunkManager cm = new ChunkManager(addon); - when(addon.getChunkManager()).thenReturn(cm); + doReturn(cm).when(addon).getChunkManager(); data = new OneBlockIslands("test"); - when(addon.getOneBlocksIsland(island)).thenReturn(data); + doReturn(data).when(addon).getOneBlocksIsland(island); BlockListener blockListener = mock(BlockListener.class); - when(addon.getBlockListener()).thenReturn(blockListener); + doReturn(blockListener).when(addon).getBlockListener(); levelListener = mock(LevelListener.class); - when(addon.getLevelListener()).thenReturn(levelListener); + doReturn(levelListener).when(addon).getLevelListener(); level = 0; - when(addon.getIslandLevel(island)).thenAnswer(i -> level); + doAnswer(i -> level).when(addon).getIslandLevel(island); // Island center chunk (0, 0) when(island.getCenter()).thenReturn(location); @@ -77,6 +83,8 @@ public void setUp() throws Exception { when(island.getProtectionRange()).thenReturn(240); when(island.getOwner()).thenReturn(uuid); when(im.getIslandAt(any())).thenReturn(Optional.of(island)); + // By default the player may claim: rank checks have their own tests + when(island.isAllowed(any(User.class), eq(addon.CHUNKBLOCK_CLAIM_CHUNKS))).thenReturn(true); // Player stands near the east edge of the center chunk, looking east (+x): // yaw -90 in Bukkit faces +x @@ -133,11 +141,34 @@ void testNoCreditNoClaim() { } @Test - void testNonOwnerCannotClaim() { + void testPlayerBelowTheClaimRankCannotClaim() { level = 100; + when(island.isAllowed(any(User.class), eq(addon.CHUNKBLOCK_CLAIM_CHUNKS))).thenReturn(false); + when(island.getRank(any(User.class))).thenReturn(RanksManager.MEMBER_RANK); + hitAndConfirm(Action.LEFT_CLICK_AIR); + assertFalse(data.isChunkUnlocked(1, 0)); + // A teammate who cannot claim is told why rather than left wondering + verify(notifier).notify(any(), eq("protection.flags.CHUNKBLOCK_CLAIM_CHUNKS.hint")); + } + + @Test + void testTeammateAtOrAboveTheClaimRankCanClaim() { + // The island has opened claiming up: a member who is not the owner may spend + level = 1; when(island.getOwner()).thenReturn(UUID.randomUUID()); + when(island.getRank(any(User.class))).thenReturn(RanksManager.MEMBER_RANK); + hitAndConfirm(Action.LEFT_CLICK_AIR); + assertTrue(data.isChunkUnlocked(1, 0)); + } + + @Test + void testVisitorIsNotToldAboutTheIslandsCredit() { + level = 100; + when(island.isAllowed(any(User.class), eq(addon.CHUNKBLOCK_CLAIM_CHUNKS))).thenReturn(false); + when(island.getRank(any(User.class))).thenReturn(RanksManager.VISITOR_RANK); listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); assertFalse(data.isChunkUnlocked(1, 0)); + verify(notifier, never()).notify(any(), any()); } @Test diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ChunkGuardListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkGuardListenerTest.java index 7f0d3bf..ba0b00d 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkGuardListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkGuardListenerTest.java @@ -3,8 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -25,6 +28,7 @@ import com.google.common.collect.ImmutableSet; +import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.managers.RanksManager; import world.bentobox.bentobox.util.Util; import world.bentobox.chunkblock.ChunkBlock; @@ -48,16 +52,17 @@ class ChunkGuardListenerTest extends CommonTestSetup { @BeforeEach public void setUp() throws Exception { super.setUp(); - addon = mock(ChunkBlock.class); - when(addon.getPlugin()).thenReturn(plugin); - when(addon.inWorld(world)).thenReturn(true); - when(addon.getIslands()).thenReturn(im); + // A spy on a real addon, so the CHUNKBLOCK_CLAIM_CHUNKS flag field is built + addon = spy(new ChunkBlock()); + doReturn(plugin).when(addon).getPlugin(); + doReturn(true).when(addon).inWorld(world); + doReturn(im).when(addon).getIslands(); Settings settings = new Settings(); - when(addon.getSettings()).thenReturn(settings); + addon.setSettings(settings); ChunkManager cm = new ChunkManager(addon); - when(addon.getChunkManager()).thenReturn(cm); + doReturn(cm).when(addon).getChunkManager(); data = new OneBlockIslands("test"); - when(addon.getOneBlocksIsland(island)).thenReturn(data); + doReturn(data).when(addon).getOneBlocksIsland(island); // Island centered at block (8, y, 8) → center chunk (0, 0); only that chunk unlocked when(island.getCenter()).thenReturn(location); @@ -95,6 +100,22 @@ void testMoveIntoLockedChunkCancelledAndTeleportedBack() { mockedUtil.verify(() -> Util.teleportAsync(mockPlayer, location)); } + @Test + void testBumpInvitesAPlayerAllowedToClaim() { + when(island.isAllowed(any(User.class), eq(addon.CHUNKBLOCK_CLAIM_CHUNKS))).thenReturn(true); + doReturn(100L).when(addon).getIslandLevel(island); + listener.onPlayerMove(new PlayerMoveEvent(mockPlayer, location, lockedTo)); + verify(notifier).notify(any(), eq("chunkblock.chunks.claim-hint")); + } + + @Test + void testBumpJustSaysLockedToAPlayerWhoCannotClaim() { + // island.isAllowed is false by default in CommonTestSetup + doReturn(100L).when(addon).getIslandLevel(island); + listener.onPlayerMove(new PlayerMoveEvent(mockPlayer, location, lockedTo)); + verify(notifier).notify(any(), eq("chunkblock.chunks.entry-denied")); + } + @Test void testMoveWithinUnlockedChunkAllowed() { PlayerMoveEvent e = new PlayerMoveEvent(mockPlayer, location, unlockedTo);