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);