From 2477f1201cce1c0ee3b4bd1622e5bae3ed0baf56 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 9 Aug 2026 12:27:50 -0700 Subject: [PATCH 1/3] feat: reward islands for closing a whole ring of chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming has only ever paid out per chunk. Closing a complete square around the centre is the milestone that keeps players expanding evenly rather than running one arm of chunks out to the border, so it now earns something. ChunkManager gains isRingComplete() and completedRings(); the latter counts outward from the centre and stops at the first hole, so a chunk claimed two rings out earns nothing while ring 1 is still gappy. RingCompleteEvent fires once per ring. Unlike ChunkUnlockEvent it is cancellable, so a reward plugin can take the milestone over completely; the ring stays complete either way. Rings are earned once and stay earned — highestRingRewarded is persisted on the island data, so losing levels and re-claiming the same chunks pays nothing. Only island create or reset clears it. Rewards are console commands under chunkblock.rings (once per ring, and once per member), deliberately empty by default; the config comment warns against paying island levels, since levels buy chunks and that makes each ring buy the next one. Trophies, titles and secret phase branches from the issue are left out on purpose: the event and command hooks are what lets that layer live outside the gamemode. Part of #3 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017EZEwab2kL4i1FNnBYvSmp --- .../chunkblock/ChunkBlockPlaceholders.java | 12 ++ .../world/bentobox/chunkblock/Settings.java | 64 +++++++++++ .../chunkblock/chunks/ChunkManager.java | 42 +++++++ .../commands/island/IslandChunksCommand.java | 2 + .../dataobjects/OneBlockIslands.java | 22 ++++ .../chunkblock/events/RingCompleteEvent.java | 82 +++++++++++++ .../chunkblock/listeners/LevelListener.java | 108 ++++++++++++++++++ src/main/resources/config.yml | 17 +++ src/main/resources/locales/en-US.yml | 3 + .../chunkblock/chunks/ChunkManagerTest.java | 61 ++++++++++ .../listeners/LevelListenerTest.java | 85 ++++++++++++++ 11 files changed, 498 insertions(+) create mode 100644 src/main/java/world/bentobox/chunkblock/events/RingCompleteEvent.java diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java b/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java index 36dbbc5..5279212 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlockPlaceholders.java @@ -67,6 +67,7 @@ public ChunkBlockPlaceholders(ChunkBlock addon, placeholdersManager.registerPlaceholder(addon, "island_next_chunk_level", this::getIslandNextChunkLevel); placeholdersManager.registerPlaceholder(addon, "island_chunk_credit", this::getIslandChunkCredit); placeholdersManager.registerPlaceholder(addon, "island_ring", this::getIslandRing); + placeholdersManager.registerPlaceholder(addon, "island_rings_complete", this::getIslandRingsComplete); } /** @@ -128,6 +129,17 @@ public String getIslandRing(User user) { return getUsersIsland(user).map(i -> String.valueOf(addon.getChunkManager().currentRing(i))).orElse(""); } + /** + * @param user user + * @return how many whole rings the user's island has closed around its center + */ + public String getIslandRingsComplete(User user) { + if (user == null || user.getUniqueId() == null) { + return ""; + } + return getUsersIsland(user).map(i -> String.valueOf(addon.getChunkManager().completedRings(i))).orElse(""); + } + /** * Get the user's owned island. Returns the island owned by the user, not a team * island they may be visiting as a member. If the user owns more than one island, diff --git a/src/main/java/world/bentobox/chunkblock/Settings.java b/src/main/java/world/bentobox/chunkblock/Settings.java index caa4d68..2600545 100644 --- a/src/main/java/world/bentobox/chunkblock/Settings.java +++ b/src/main/java/world/bentobox/chunkblock/Settings.java @@ -135,6 +135,28 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "chunkblock.claim.confirmation-timeout") private int claimConfirmationTimeout = 15; + @ConfigComment("Announce ring milestones to the whole server, not just the island's members.") + @ConfigComment("A ring is the square of chunks at a fixed distance from the center chunk:") + @ConfigComment("ring 1 is the eight chunks around the center, ring 2 the sixteen around those.") + @ConfigEntry(path = "chunkblock.rings.broadcast") + private boolean ringBroadcast = false; + + @ConfigComment("Console commands run once each time an island completes a whole ring.") + @ConfigComment("Placeholders: [ring] the completed ring, [chunks] the island's chunk count,") + @ConfigComment("[owner] the island owner's name.") + @ConfigComment("Rings are rewarded once per island — re-locking and re-claiming a ring pays") + @ConfigComment("nothing. Rewarding island levels here is not advised: levels buy chunks, so") + @ConfigComment("that makes each ring pay for the next one.") + @ConfigComment("Example: 'eco give [owner] 500'") + @ConfigEntry(path = "chunkblock.rings.commands") + private List ringCommands = new ArrayList<>(); + + @ConfigComment("Console commands run once for every member of the island, including the") + @ConfigComment("owner and offline members. Placeholders: [player], [ring], [chunks].") + @ConfigComment("Example: 'give [player] diamond 1'") + @ConfigEntry(path = "chunkblock.rings.player-commands") + private List ringPlayerCommands = new ArrayList<>(); + @ConfigComment("If true, losing island levels below what has been spent re-locks chunks in") @ConfigComment("reverse claim order (the most recently claimed chunks are lost first). Builds") @ConfigComment("inside re-locked chunks are untouched but cannot be reached until the levels") @@ -2607,6 +2629,48 @@ public void setMaxChunks(int maxChunks) { this.maxChunks = maxChunks; } + /** + * @return true if ring milestones are announced to the whole server + */ + public boolean isRingBroadcast() { + return ringBroadcast; + } + + /** + * @param ringBroadcast the ringBroadcast to set + */ + public void setRingBroadcast(boolean ringBroadcast) { + this.ringBroadcast = ringBroadcast; + } + + /** + * @return the console commands run once per completed ring, never null + */ + public List getRingCommands() { + return ringCommands == null ? Collections.emptyList() : ringCommands; + } + + /** + * @param ringCommands the ringCommands to set + */ + public void setRingCommands(List ringCommands) { + this.ringCommands = ringCommands; + } + + /** + * @return the console commands run for each island member per completed ring, never null + */ + public List getRingPlayerCommands() { + return ringPlayerCommands == null ? Collections.emptyList() : ringPlayerCommands; + } + + /** + * @param ringPlayerCommands the ringPlayerCommands to set + */ + public void setRingPlayerCommands(List ringPlayerCommands) { + this.ringPlayerCommands = ringPlayerCommands; + } + /** * @return true if a chunk must be previewed and confirmed before credit is spent */ diff --git a/src/main/java/world/bentobox/chunkblock/chunks/ChunkManager.java b/src/main/java/world/bentobox/chunkblock/chunks/ChunkManager.java index d8e88a2..0fb1b74 100644 --- a/src/main/java/world/bentobox/chunkblock/chunks/ChunkManager.java +++ b/src/main/java/world/bentobox/chunkblock/chunks/ChunkManager.java @@ -165,6 +165,48 @@ public int currentRing(Island island) { return ring; } + /** + * A ring is the square of chunks at a fixed Chebyshev distance from the center chunk: + * ring 1 is the eight chunks surrounding the center, ring 2 the sixteen around those. + * Ring 0 is the center chunk, which is always unlocked. + * + * @param island the island + * @param ring the ring radius in chunks + * @return true if every chunk in the ring is unlocked; false for rings that do not fit + * inside the island's protection range + */ + public boolean isRingComplete(Island island, int ring) { + if (ring <= 0) { + return true; + } + if (ring > maxRingRadius(island)) { + return false; + } + OneBlockIslands data = addon.getOneBlocksIsland(island); + for (int d = -ring; d <= ring; d++) { + // North and south edges cover the corners, so the east and west edges only + // need the same sweep to close the square + if (!data.isChunkUnlocked(d, -ring) || !data.isChunkUnlocked(d, ring) + || !data.isChunkUnlocked(-ring, d) || !data.isChunkUnlocked(ring, d)) { + return false; + } + } + return true; + } + + /** + * @param island the island + * @return the number of whole rings completed outwards from the center without a gap. + * A claimed chunk two rings out does not count while ring 1 has a hole in it. + */ + public int completedRings(Island island) { + int ring = 0; + while (isRingComplete(island, ring + 1)) { + ring++; + } + return ring; + } + /** * @param island the island * @return the island's unlocked chunk offsets in unlock order (x and z are chunk diff --git a/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java b/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java index d1fa81b..336c8ed 100644 --- a/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java +++ b/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java @@ -61,6 +61,8 @@ public boolean execute(User user, String label, List args) { user.sendMessage("chunkblock.chunks.info", "[unlocked]", String.valueOf(unlocked), "[max]", String.valueOf(max), "[credit]", String.valueOf(credit), "[cost]", String.valueOf(cm.getChunkCost())); + user.sendMessage("chunkblock.chunks.rings", "[rings]", String.valueOf(cm.completedRings(island)), "[max]", + String.valueOf(cm.maxRingRadius(island))); showMap(user, island, unlocked, max); return true; } diff --git a/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java b/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java index 6519e4d..37369ed 100644 --- a/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java +++ b/src/main/java/world/bentobox/chunkblock/dataobjects/OneBlockIslands.java @@ -69,6 +69,14 @@ public class OneBlockIslands implements DataObject { @Expose private long lastKnownLevel = 0; + /** + * The highest ring this island has already been rewarded for completing. Milestones + * are earned once and stay earned: re-locking a ring and claiming it back does not pay + * out again. Only an island create or reset clears it. + */ + @Expose + private int highestRingRewarded = 0; + /** Fast membership view of {@link #unlockedChunks}; rebuilt lazily after loads/edits */ private transient Set unlockedSet; @@ -179,6 +187,20 @@ public void setLastKnownLevel(long lastKnownLevel) { this.lastKnownLevel = lastKnownLevel; } + /** + * @return the highest ring this island has already been rewarded for + */ + public int getHighestRingRewarded() { + return highestRingRewarded; + } + + /** + * @param highestRingRewarded the highest rewarded ring + */ + public void setHighestRingRewarded(int highestRingRewarded) { + this.highestRingRewarded = highestRingRewarded; + } + /** * @return the phaseName */ diff --git a/src/main/java/world/bentobox/chunkblock/events/RingCompleteEvent.java b/src/main/java/world/bentobox/chunkblock/events/RingCompleteEvent.java new file mode 100644 index 0000000..1bf8433 --- /dev/null +++ b/src/main/java/world/bentobox/chunkblock/events/RingCompleteEvent.java @@ -0,0 +1,82 @@ +package world.bentobox.chunkblock.events; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; +import org.eclipse.jdt.annotation.NonNull; + +import world.bentobox.bentobox.api.events.BentoBoxEvent; +import world.bentobox.bentobox.database.objects.Island; + +/** + * Fired once when an island completes a whole ring of chunks around its center — every + * chunk at Chebyshev distance {@code ring} from the center chunk is unlocked. Rings are + * only ever rewarded once per island: re-locking and re-claiming the same chunks does not + * fire this again. + *

+ * Cancelling suppresses the addon's own milestone handling (reward commands, messages and + * the celebration); the ring itself stays complete either way. + * + * @author tastybento + */ +public class RingCompleteEvent extends BentoBoxEvent implements Cancellable { + + private static final HandlerList handlers = new HandlerList(); + + private final Island island; + private final int ring; + private final int unlockedChunkCount; + private boolean cancelled; + + /** + * @param island the island that completed the ring + * @param ring the ring's radius in chunks, always >= 1 + * @param unlockedChunkCount how many chunks the island has unlocked in total + */ + public RingCompleteEvent(@NonNull Island island, int ring, int unlockedChunkCount) { + this.island = island; + this.ring = ring; + this.unlockedChunkCount = unlockedChunkCount; + } + + @Override + public HandlerList getHandlers() { + return getHandlerList(); + } + + public static HandlerList getHandlerList() { + return handlers; + } + + /** + * @return the island that completed the ring + */ + @NonNull + public Island getIsland() { + return island; + } + + /** + * @return the completed ring's radius in chunks (ring 1 is the eight chunks around the + * center chunk) + */ + public int getRing() { + return ring; + } + + /** + * @return the island's total unlocked chunk count including the center chunk + */ + public int getUnlockedChunkCount() { + return unlockedChunkCount; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } +} diff --git a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java index b5602fd..ce39009 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/LevelListener.java @@ -1,7 +1,9 @@ package world.bentobox.chunkblock.listeners; +import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.Sound; @@ -13,6 +15,7 @@ import world.bentobox.bentobox.api.events.island.IslandCreatedEvent; import world.bentobox.bentobox.api.events.island.IslandResettedEvent; +import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.chunkblock.ChunkBlock; @@ -20,6 +23,7 @@ import world.bentobox.chunkblock.dataobjects.OneBlockIslands; import world.bentobox.chunkblock.events.ChunkRelockEvent; import world.bentobox.chunkblock.events.ChunkUnlockEvent; +import world.bentobox.chunkblock.events.RingCompleteEvent; import world.bentobox.level.events.IslandLevelCalculatedEvent; /** @@ -74,6 +78,7 @@ private void resetIsland(Island island) { OneBlockIslands data = addon.getOneBlocksIsland(island); data.resetUnlockedChunks(); data.setLastKnownLevel(0); + data.setHighestRingRewarded(0); } /** @@ -161,6 +166,109 @@ public void celebrateClaim(Island island, int chunkX, int chunkZ) { if (addon.getBorderDisplay() != null) { addon.getBorderDisplay().celebrate(island, List.of(offset)); } + checkRingMilestones(island); + } + + /** + * Pays out any rings the island has completed but not yet been rewarded for. Normally + * that is a single ring — the chunk just claimed closed it — but a ring completed + * while an inner one still had a hole in it is caught up here once the hole is filled. + * + * @param island the island + */ + private void checkRingMilestones(Island island) { + OneBlockIslands data = addon.getOneBlocksIsland(island); + int completed = addon.getChunkManager().completedRings(island); + if (completed <= data.getHighestRingRewarded()) { + return; + } + for (int ring = data.getHighestRingRewarded() + 1; ring <= completed; ring++) { + rewardRing(island, ring); + } + data.setHighestRingRewarded(completed); + addon.getBlockListener().saveIsland(island); + } + + /** + * Fires {@link RingCompleteEvent} for one newly completed ring and, unless a plugin + * cancels it, announces the milestone and runs the configured reward commands. + */ + private void rewardRing(Island island, int ring) { + int chunks = addon.getChunkManager().getUnlockedChunkCount(island); + RingCompleteEvent event = new RingCompleteEvent(island, ring, chunks); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return; + } + String ringText = String.valueOf(ring); + String chunkText = String.valueOf(chunks); + island.getMemberSet().forEach(uuid -> { + User user = User.getInstance(uuid); + if (user.isOnline() && addon.inWorld(user.getWorld())) { + user.sendMessage("chunkblock.chunks.ring-complete", "[ring]", ringText, "[chunks]", chunkText); + user.getPlayer().playSound(user.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1F, 1F); + } + }); + if (addon.getSettings().isRingBroadcast()) { + String ownerName = playerName(island.getOwner()); + Bukkit.getOnlinePlayers().forEach(player -> User.getInstance(player).sendMessage( + "chunkblock.chunks.ring-broadcast", TextVariables.NAME, ownerName, "[ring]", ringText, + "[chunks]", chunkText)); + } + celebrateRing(island, ring); + List ownerCommands = addon.getSettings().getRingCommands(); + if (!ownerCommands.isEmpty()) { + runCommands(ownerCommands, ringText, chunkText, "[owner]", playerName(island.getOwner())); + } + List memberCommands = addon.getSettings().getRingPlayerCommands(); + if (!memberCommands.isEmpty()) { + for (UUID uuid : island.getMemberSet()) { + runCommands(memberCommands, ringText, chunkText, "[player]", playerName(uuid)); + } + } + } + + /** + * @return the player's name, or an empty string for an unowned island or a name the + * players manager does not know + */ + private String playerName(UUID uuid) { + return uuid == null ? "" : addon.getPlayers().getName(uuid); + } + + /** + * Runs reward commands from the console, substituting the ring placeholders. Commands + * with an empty name substitution are skipped rather than run against a blank argument. + */ + private void runCommands(List commands, String ring, String chunks, String nameKey, String name) { + if (commands.isEmpty() || name == null || name.isEmpty()) { + return; + } + for (String command : commands) { + String toRun = command.replace("[ring]", ring).replace("[chunks]", chunks).replace(nameKey, name); + if (!Bukkit.dispatchCommand(Bukkit.getConsoleSender(), toRun)) { + addon.logError("Ring reward command failed: " + toRun); + } + } + } + + /** + * Sparkles the whole completed ring, not just the chunk that closed it. + */ + private void celebrateRing(Island island, int ring) { + if (addon.getBorderDisplay() == null) { + return; + } + List offsets = new ArrayList<>(); + for (int d = -ring; d <= ring; d++) { + offsets.add(new Vector(d, 0, -ring)); + offsets.add(new Vector(d, 0, ring)); + if (d != -ring && d != ring) { + offsets.add(new Vector(-ring, 0, d)); + offsets.add(new Vector(ring, 0, d)); + } + } + addon.getBorderDisplay().celebrate(island, offsets); } /** diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index dae6704..03577ef 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -65,6 +65,23 @@ chunkblock: # How long, in seconds, a previewed chunk stays confirmable. After this the # player has to hit the border again to preview it afresh. Minimum 1. confirmation-timeout: 15 + rings: + # Announce ring milestones to the whole server, not just the island's members. + # A ring is the square of chunks at a fixed distance from the center chunk: + # ring 1 is the eight chunks around the center, ring 2 the sixteen around those. + broadcast: false + # Console commands run once each time an island completes a whole ring. + # Placeholders: [ring] the completed ring, [chunks] the island's chunk count, + # [owner] the island owner's name. + # Rings are rewarded once per island — re-locking and re-claiming a ring pays + # nothing. Rewarding island levels here is not advised: levels buy chunks, so + # that makes each ring pay for the next one. + # Example: 'eco give [owner] 500' + commands: [] + # Console commands run once for every member of the island, including the + # owner and offline members. Placeholders: [player], [ring], [chunks]. + # Example: 'give [player] diamond 1' + player-commands: [] # If true, losing island levels below what has been spent re-locks chunks in # reverse claim order (the most recently claimed chunks are lost first). Builds # inside re-locked chunks are untouched but cannot be reached until the levels diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index fa4282d..35138cf 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -51,6 +51,9 @@ chunkblock: relocked: "&c Your island level dropped — [count] chunk(s) re-locked, newest first. Regain the levels to claim them back!" ejected: "&c The chunk you were in re-locked, so you were moved to safety." max-reached: "&d Your island has reached its maximum size of [number] chunks!" + ring-complete: "&6 &l Ring [ring] complete! &r&6 Your island is a perfect square of &b [chunks] &6 chunks." + ring-broadcast: "&6 [name]'s island has closed ring &b [ring] &6 — &b [chunks] &6 chunks and still growing!" + rings: "&a Rings completed: &b [rings] &a of &b [max]&a." sethome-denied: "&c You can't set a home in a locked chunk." info: "&a Chunks: &b [unlocked]&a/&b[max]&a. Credit: &b [credit] &a level(s) — a chunk costs &b [cost]&a." map: diff --git a/src/test/java/world/bentobox/chunkblock/chunks/ChunkManagerTest.java b/src/test/java/world/bentobox/chunkblock/chunks/ChunkManagerTest.java index ed632b0..c31659e 100644 --- a/src/test/java/world/bentobox/chunkblock/chunks/ChunkManagerTest.java +++ b/src/test/java/world/bentobox/chunkblock/chunks/ChunkManagerTest.java @@ -219,6 +219,67 @@ void testMaxChunksCappedByProtectionRange() { assertEquals(841, cm.getMaxChunks(island)); } + @Test + void testRingZeroIsAlwaysComplete() { + assertTrue(cm.isRingComplete(island, 0)); + assertEquals(0, cm.completedRings(island)); + } + + @Test + void testRingCompletesOnlyWhenEveryChunkIsClaimed() { + level = 8; + claimRingOne(); + assertTrue(cm.isRingComplete(island, 1)); + assertEquals(1, cm.completedRings(island)); + assertFalse(cm.isRingComplete(island, 2)); + } + + @Test + void testRingIsIncompleteWhileACornerIsMissing() { + level = 8; + // Everything in ring 1 except the far corner + for (int[] offset : new int[][] { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, + { -1, -1 } }) { + cm.claim(island, offset[0], offset[1]); + } + assertEquals(8, cm.getUnlockedChunkCount(island)); + assertFalse(cm.isRingComplete(island, 1)); + assertEquals(0, cm.completedRings(island)); + } + + @Test + void testOuterRingDoesNotCountWhileAnInnerRingHasAHole() { + level = 100; + // Ring 1 with a hole at (1, -1), then a chunk out in ring 2 + for (int[] offset : new int[][] { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, + { -1, -1 }, { 2, 0 } }) { + cm.claim(island, offset[0], offset[1]); + } + assertEquals(2, cm.currentRing(island)); + assertEquals(0, cm.completedRings(island)); + // Filling the hole closes ring 1 and only ring 1 + assertEquals(ClaimResult.OK, cm.claim(island, 1, -1)); + assertEquals(1, cm.completedRings(island)); + } + + @Test + void testRingBeyondProtectionRangeIsNeverComplete() { + when(island.getProtectionRange()).thenReturn(24); + assertEquals(1, cm.maxRingRadius(island)); + level = 8; + claimRingOne(); + assertEquals(1, cm.completedRings(island)); + assertFalse(cm.isRingComplete(island, 2)); + } + + /** Claims all eight chunks of ring 1, each face-adjacent to territory already held */ + private void claimRingOne() { + for (int[] offset : new int[][] { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, + { -1, -1 }, { 1, -1 } }) { + assertEquals(ClaimResult.OK, cm.claim(island, offset[0], offset[1])); + } + } + @Test void testGetUnlockedOffsets() { level = 2; diff --git a/src/test/java/world/bentobox/chunkblock/listeners/LevelListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/LevelListenerTest.java index 7791ed4..1ca9391 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/LevelListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/LevelListenerTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -14,14 +16,18 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import world.bentobox.bentobox.api.events.island.IslandResettedEvent; +import world.bentobox.bentobox.managers.PlayersManager; import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.chunkblock.CommonTestSetup; import world.bentobox.chunkblock.Settings; +import world.bentobox.chunkblock.chunks.BorderDisplay; import world.bentobox.chunkblock.chunks.ChunkManager; import world.bentobox.chunkblock.chunks.ChunkManager.ClaimResult; import world.bentobox.chunkblock.dataobjects.OneBlockIslands; import world.bentobox.chunkblock.events.ChunkRelockEvent; import world.bentobox.chunkblock.events.ChunkUnlockEvent; +import world.bentobox.chunkblock.events.RingCompleteEvent; /** * Tests the credit-announcement and LIFO re-lock flows in {@link LevelListener} and the @@ -51,6 +57,8 @@ public void setUp() throws Exception { data = new OneBlockIslands("test"); when(addon.getOneBlocksIsland(island)).thenReturn(data); when(addon.getBlockListener()).thenReturn(mock(BlockListener.class)); + PlayersManager playersManager = plugin.getPlayers(); + when(addon.getPlayers()).thenReturn(playersManager); level = 0; when(addon.getIslandLevel(island)).thenAnswer(i -> level); @@ -140,6 +148,83 @@ void testCelebrateClaimFiresUnlockEvent() { verify(pim).callEvent(any(ChunkUnlockEvent.class)); } + @Test + void testClosingARingFiresRingCompleteEventOnce() { + level = 8; + claimRingOne(); + verify(pim).callEvent(any(RingCompleteEvent.class)); + assertEquals(1, data.getHighestRingRewarded()); + } + + @Test + void testPartialRingFiresNothing() { + level = 8; + // Seven of the eight chunks — the ring never closes + for (int[] offset : new int[][] { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, + { -1, -1 } }) { + cm.claim(island, offset[0], offset[1]); + listener.celebrateClaim(island, offset[0], offset[1]); + } + verify(pim, never()).callEvent(any(RingCompleteEvent.class)); + assertEquals(0, data.getHighestRingRewarded()); + } + + @Test + void testRingIsRewardedOnlyOnceEvenAfterRelockAndReclaim() { + level = 8; + claimRingOne(); + // Lose a level, which re-locks the last chunk, then claim it straight back + level = 7; + listener.applyLevel(island, 7); + assertEquals(8, data.getUnlockedChunkCount()); + level = 8; + listener.applyLevel(island, 8); + assertEquals(ClaimResult.OK, cm.claim(island, 1, -1)); + listener.celebrateClaim(island, 1, -1); + assertEquals(1, data.getHighestRingRewarded()); + verify(pim, times(1)).callEvent(any(RingCompleteEvent.class)); + } + + @Test + void testCancellingRingCompleteEventSuppressesTheReward() { + BorderDisplay borderDisplay = mock(BorderDisplay.class); + when(addon.getBorderDisplay()).thenReturn(borderDisplay); + doAnswer(invocation -> { + if (invocation.getArgument(0) instanceof RingCompleteEvent event) { + event.setCancelled(true); + } + return null; + }).when(pim).callEvent(any()); + level = 8; + claimRingOne(); + verify(pim).callEvent(any(RingCompleteEvent.class)); + // The per-claim celebration still runs; the whole-ring one does not + verify(borderDisplay, never()).celebrate(any(), argThat(offsets -> offsets.size() == 8)); + // The ring still counts as rewarded, so a cancelled milestone is not retried + assertEquals(1, data.getHighestRingRewarded()); + } + + @Test + void testIslandResetClearsRingRewards() { + level = 8; + claimRingOne(); + assertEquals(1, data.getHighestRingRewarded()); + IslandResettedEvent event = mock(IslandResettedEvent.class); + when(event.getIsland()).thenReturn(island); + listener.onIslandResetted(event); + assertEquals(0, data.getHighestRingRewarded()); + assertEquals(1, data.getUnlockedChunkCount()); + } + + /** Claims and celebrates all eight chunks of ring 1, closing it with the last one */ + private void claimRingOne() { + for (int[] offset : new int[][] { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, + { -1, -1 }, { 1, -1 } }) { + assertEquals(ClaimResult.OK, cm.claim(island, offset[0], offset[1])); + listener.celebrateClaim(island, offset[0], offset[1]); + } + } + @Test void testIslandResetClearsClaimsAndLevel() { level = 3; From 95c2537d48b12c2182f1d9df96881dbffc2e879c Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 9 Aug 2026 12:37:04 -0700 Subject: [PATCH 2/3] fix: don't call a ring-complete island a perfect square MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [chunks] is the island's total chunk count, not the ring's, so an island holding chunks outside the closed ring was told it was "a perfect square of 16 chunks" — a number that is not a square at all. Say what is actually true: the ring is closed, and here is the total. --- src/main/resources/locales/en-US.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 35138cf..ee34f4d 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -51,7 +51,7 @@ chunkblock: relocked: "&c Your island level dropped — [count] chunk(s) re-locked, newest first. Regain the levels to claim them back!" ejected: "&c The chunk you were in re-locked, so you were moved to safety." max-reached: "&d Your island has reached its maximum size of [number] chunks!" - ring-complete: "&6 &l Ring [ring] complete! &r&6 Your island is a perfect square of &b [chunks] &6 chunks." + ring-complete: "&6 &l Ring [ring] complete! &r&6 The whole ring around your island is yours — &b [chunks] &6 chunks in all." ring-broadcast: "&6 [name]'s island has closed ring &b [ring] &6 — &b [chunks] &6 chunks and still growing!" rings: "&a Rings completed: &b [rings] &a of &b [max]&a." sethome-denied: "&c You can't set a home in a locked chunk." From 307a4c34fa1c268f3596a5994796871c92d3f888 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 9 Aug 2026 12:46:47 -0700 Subject: [PATCH 3/3] feat: mark the center chunk on the territory map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The center chunk drew as an ordinary owned chunk, so a grid of identical squares had nothing to orient by — reading it meant counting rows in from an edge. It now gets its own glyph, and a second one for when the player is standing on it, since that is where the magic block is and where players spend most of their time. The legend gains both. Adds IslandChunksCommandTest, which the map had gone without. --- .../commands/island/IslandChunksCommand.java | 6 +- src/main/resources/locales/en-US.yml | 2 +- .../island/IslandChunksCommandTest.java | 148 ++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java diff --git a/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java b/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java index 336c8ed..0d5db2e 100644 --- a/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java +++ b/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java @@ -85,7 +85,11 @@ private void showMap(User user, Island island, int unlocked, int max) { StringBuilder row = new StringBuilder(); for (int dx = -radius; dx <= radius; dx++) { boolean here = dx == playerDx && dz == playerDz; - if (addon.getOneBlocksIsland(island).isChunkUnlocked(dx, dz)) { + if (dx == 0 && dz == 0) { + // The center chunk holds the magic block and can never lock, so it is + // marked in its own right — without it the grid has nothing to orient by + row.append(here ? "&b◉" : "&6◎"); + } else if (addon.getOneBlocksIsland(island).isChunkUnlocked(dx, dz)) { row.append(here ? "&b◆" : "&a■"); } else if (cm.checkGeometry(island, centerChunkX + dx, centerChunkZ + dz) == ClaimResult.OK) { row.append(here ? "&b◆" : "&e▣"); diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index ee34f4d..dfc6990 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -59,7 +59,7 @@ chunkblock: map: title: "&a Your island territory ([unlocked]/[max] chunks):" row: "&a [row]" - legend: "&a ■ yours &e ▣ claimable ([cost] level(s) each) &7 □ locked" + legend: "&a ■ yours &e ▣ claimable ([cost] level(s) each) &7 □ locked &6 ◎ center &b ◆ you" you-are-here: "&b You are on the marked chunk." bossbar: title: "Blocks remaining" diff --git a/src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java b/src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java new file mode 100644 index 0000000..a82fe0f --- /dev/null +++ b/src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java @@ -0,0 +1,148 @@ +package world.bentobox.chunkblock.commands.island; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.bukkit.Location; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.chunkblock.ChunkBlock; +import world.bentobox.chunkblock.CommonTestSetup; +import world.bentobox.chunkblock.Settings; +import world.bentobox.chunkblock.chunks.ChunkManager; +import world.bentobox.chunkblock.dataobjects.OneBlockIslands; +import world.bentobox.chunkblock.listeners.BlockListener; + +/** + * Tests the territory map drawn by {@code /ch chunks} — the glyph each chunk gets and how + * far the map reaches. + */ +class IslandChunksCommandTest extends CommonTestSetup { + + @Mock + private CompositeCommand ac; + @Mock + private User user; + @Mock + private ChunkBlock addon; + @Mock + private Location playerLocation; + + private IslandChunksCommand command; + private OneBlockIslands data; + private ChunkManager cm; + private long level; + + @Override + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + when(ac.getAddon()).thenReturn(addon); + Settings settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + data = new OneBlockIslands("test"); + when(addon.getOneBlocksIsland(island)).thenReturn(data); + when(addon.getBlockListener()).thenReturn(mock(BlockListener.class)); + level = 0; + when(addon.getIslandLevel(island)).thenAnswer(i -> level); + cm = new ChunkManager(addon); + when(addon.getChunkManager()).thenReturn(cm); + + // Island center chunk-centered at chunk (0, 0) + when(island.getCenter()).thenReturn(location); + when(location.getBlockX()).thenReturn(8); + when(location.getBlockZ()).thenReturn(8); + when(island.getProtectionRange()).thenReturn(240); + // The player's own location is a separate mock, so moving them leaves the island put + when(playerLocation.getBlockX()).thenReturn(8); + when(playerLocation.getBlockZ()).thenReturn(8); + when(user.getLocation()).thenReturn(playerLocation); + when(user.getWorld()).thenReturn(world); + when(im.getIslandAt(playerLocation)).thenReturn(Optional.of(island)); + + command = new IslandChunksCommand(ac, "chunks", new String[] { "chunks" }); + } + + @Test + void testSetup() { + assertEquals("island.chunks", command.getPermission()); + assertEquals("chunkblock.commands.chunks.description", command.getDescription()); + assertTrue(command.isOnlyPlayer()); + } + + @Test + void testCenterChunkIsMarkedWhenThePlayerStandsOnIt() { + assertTrue(command.execute(user, "", List.of())); + List rows = mapRows(); + // Fresh island: one claimed chunk, so the map reaches one ring out — 3 x 3 + assertEquals(3, rows.size()); + assertEquals("&b◉", middleGlyph(rows.get(1))); + } + + @Test + void testCenterChunkIsMarkedWhenThePlayerIsElsewhere() { + // Stand one chunk east of the center + when(playerLocation.getBlockX()).thenReturn(24); + assertTrue(command.execute(user, "", List.of())); + List rows = mapRows(); + assertEquals("&6◎", middleGlyph(rows.get(1))); + } + + @Test + void testCenterKeepsItsMarkWhileTerritoryGrows() { + level = 8; + for (int[] offset : new int[][] { { 1, 0 }, { 0, 1 }, { -1, 0 }, { 0, -1 }, { 1, 1 }, { -1, 1 }, + { -1, -1 }, { 1, -1 } }) { + cm.claim(island, offset[0], offset[1]); + } + assertTrue(command.execute(user, "", List.of())); + List rows = mapRows(); + // Ring 1 claimed, so the map reaches ring 2 — 5 x 5 + assertEquals(5, rows.size()); + String center = rows.get(2); + assertEquals("&b◉", middleGlyph(center)); + // The eight chunks around the center are owned, not confused with the center itself + assertEquals("&a■&b◉&a■", center.substring(center.indexOf("&b◉") - 3, center.indexOf("&b◉") + 6)); + } + + @Test + void testMapIsCappedAtTheWidestRowThatFitsChat() { + // A far-flung claim would otherwise draw a map wider than chat can hold + when(island.getProtectionRange()).thenReturn(2000); + level = 500; + for (int dx = 1; dx <= 20; dx++) { + cm.claim(island, dx, 0); + } + assertTrue(command.execute(user, "", List.of())); + // MAX_MAP_RADIUS is 7, so 15 rows however far the territory reaches + assertEquals(15, mapRows().size()); + } + + /** The rendered map rows, in order, as passed to the row locale key */ + private List mapRows() { + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + verify(user, org.mockito.Mockito.atLeastOnce()).sendMessage(org.mockito.ArgumentMatchers.eq( + "chunkblock.chunks.map.row"), org.mockito.ArgumentMatchers.eq("[row]"), captor.capture()); + return new ArrayList<>(captor.getAllValues()); + } + + /** The glyph at the middle of a row, colour code included */ + private String middleGlyph(String row) { + // Every glyph is a two-character colour code plus one character + int glyphs = row.length() / 3; + int middle = (glyphs / 2) * 3; + return row.substring(middle, middle + 3); + } +}