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