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..0d5db2e 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; } @@ -83,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/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..dfc6990 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -51,12 +51,15 @@ 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 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." info: "&a Chunks: &b [unlocked]&a/&b[max]&a. Credit: &b [credit] &a level(s) — a chunk costs &b [cost]&a." 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/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/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); + } +} 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;