From a51533c6b983662fe7ffcbde1c04a5cfa1c58d4c Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:54:00 -0700 Subject: [PATCH 01/10] feat: support MiniMessage and hex colours in phase text Ported from AOneBlock, which this addon is forked from and shares the display code with. See BentoBoxWorld/AOneBlock#551. An admin coloured a phase hologram with ... and got white text with the tags shown literally. Holograms deserialized with LegacyComponentSerializer.legacyAmpersand(), which understands the 16 legacy & codes and nothing else - Adventure builds that instance with hexColours=false, so even &#RRGGBB did not work. The action bar used a second, separately configured serializer that did support hex but not MiniMessage. Two display paths, two different answers to "what formatting can I use here", neither documented where anyone would look. Both now go through Util.parseMiniMessageOrLegacy, which accepts MiniMessage, & and section codes, hex, and any mixture, and is cached on the BentoBox side. This also fixes section codes being rendered as literal text. Translations come back from User.getTranslation already converted to section codes, so anything locale-sourced was being handed to a serializer bound to '&' - the starting hologram and the action bar both took that path. Phase file hologram lines never see the translation layer, which is why the reported case showed raw MiniMessage tags rather than raw section codes. The boss bar title is untouched: it uses the String-based Bukkit BossBar API and BentoBox has already resolved its formatting by then. Existing configs are unaffected. BentoBox's MiniMessage instance is the non-strict one, so text containing stray angle brackets is left as literal text rather than throwing. Util.parseMiniMessageOrLegacy is BentoBox 3.2.0 API, so this needs no dependency bump. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014t1DSo2wMbTWZLcwXpwUmQ --- .../chunkblock/listeners/BossBarListener.java | 22 +++--- .../chunkblock/listeners/HoloListener.java | 7 +- src/main/resources/phases/0_plains.yml | 6 ++ .../listeners/BossBarListenerTest.java | 70 +++++++++++++++++++ .../listeners/HoloListenerTest.java | 66 +++++++++++++++++ 5 files changed, 158 insertions(+), 13 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/listeners/BossBarListener.java b/src/main/java/world/bentobox/chunkblock/listeners/BossBarListener.java index 8ad2450..36fe748 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/BossBarListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/BossBarListener.java @@ -19,7 +19,6 @@ import org.eclipse.jdt.annotation.NonNull; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.chunkblock.dataobjects.OneBlockIslands; import world.bentobox.chunkblock.events.MagicBlockEvent; @@ -28,6 +27,7 @@ import world.bentobox.bentobox.api.events.island.IslandExitEvent; import world.bentobox.bentobox.api.metadata.MetaDataValue; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.util.Util; import world.bentobox.bentobox.database.objects.Island; public class BossBarListener implements Listener { @@ -35,11 +35,6 @@ public class BossBarListener implements Listener { private static final String BOSSBAR_METADATA = "chunkblock.bossbar"; public static final String ACTIONBAR_METADATA = "chunkblock.actionbar"; - private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.builder() - .character('&') - .hexColors() // Enables support for modern hex codes (e.g., &#FF0000) alongside legacy codes. - .build(); - public BossBarListener(ChunkBlock addon) { super(); this.addon = addon; @@ -94,16 +89,21 @@ public void onFlagChange(FlagSettingChangeEvent e) { } /** - * Converts a string containing Bukkit color codes ('&') into an Adventure Component. + * Converts a formatted string into an Adventure Component. + *

+ * Accepts MiniMessage tags, {@code &} or {@code §} legacy codes, hex ({@code &#RRGGBB}), or a + * mixture of them. Handling {@code §} matters here because translations arrive already + * converted to {@code §} codes by BentoBox - a serializer bound to {@code &} would leave those + * in the output as literal text. * - * @param legacyString The string with Bukkit color and format codes. + * @param text The string with color and format codes. * @return The resulting Adventure Component. */ - public static Component bukkitToAdventure(String legacyString) { - if (legacyString == null) { + public static Component bukkitToAdventure(String text) { + if (text == null) { return Component.empty(); } - return LEGACY_SERIALIZER.deserialize(legacyString); + return Util.parseMiniMessageOrLegacy(text); } private void tryToShowActionBar(UUID uuid, Island island) { diff --git a/src/main/java/world/bentobox/chunkblock/listeners/HoloListener.java b/src/main/java/world/bentobox/chunkblock/listeners/HoloListener.java index 1cf5de3..3827971 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/HoloListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/HoloListener.java @@ -15,7 +15,6 @@ import org.bukkit.util.Vector; import org.eclipse.jdt.annotation.NonNull; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.bentobox.util.Util; import world.bentobox.chunkblock.dataobjects.OneBlockIslands; @@ -131,6 +130,10 @@ private Location getHologramLocation(Island island) { /** * Creates a new hologram (TextDisplay) at the given location. * Caches the hologram for future reference. + *

+ * The text may use MiniMessage tags, {@code &} or {@code §} legacy codes, hex + * ({@code &#RRGGBB}), or a mixture. Phase file hologram lines are read straight from YAML and + * never see BentoBox's translation, so this is the only place their formatting is resolved. * * @param pos the location to create the hologram at * @param text the text to display @@ -140,7 +143,7 @@ private void createHologram(Location pos, String text) { display.setAlignment(TextDisplay.TextAlignment.CENTER); display.setBillboard(Billboard.CENTER); display.setPersistent(true); - display.text(LegacyComponentSerializer.legacyAmpersand().deserialize(text)); + display.text(Util.parseMiniMessageOrLegacy(text)); activeHolograms.add(pos); } diff --git a/src/main/resources/phases/0_plains.yml b/src/main/resources/phases/0_plains.yml index bd21f74..66fcf76 100644 --- a/src/main/resources/phases/0_plains.yml +++ b/src/main/resources/phases/0_plains.yml @@ -16,6 +16,12 @@ 700: CHEST_WITH_WATER_BUCKET # Hologram Lines to Display # The First (Before Phase 1) Hologram is Located in your Locale. + # The text can use any of these, and they can be mixed: + # &a&lGood Luck! legacy colour and format codes + # 7FF55Good Luck! hex colour + # Good Luck! MiniMessage tags + # MiniMessage also gives you gradients, e.g. + # Good Luck! holograms: 0: "&aGood Luck!" biome: PLAINS diff --git a/src/test/java/world/bentobox/chunkblock/listeners/BossBarListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/BossBarListenerTest.java index 1ac17ba..4bd49b7 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/BossBarListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/BossBarListenerTest.java @@ -1,5 +1,7 @@ package world.bentobox.chunkblock.listeners; +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.anyString; import static org.mockito.Mockito.doNothing; @@ -23,6 +25,9 @@ import org.mockito.Mock; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import world.bentobox.bentobox.api.events.flags.FlagSettingChangeEvent; import world.bentobox.bentobox.api.events.island.IslandEnterEvent; import world.bentobox.bentobox.api.events.island.IslandExitEvent; @@ -179,4 +184,69 @@ void testAllHandlersInertWhenAddonNeverEnabled() { verify(bossBar, never()).addPlayer(any()); verify(bossBar, never()).removePlayer(any()); } + + /** + * Serializes to legacy section codes so a test can assert on the formatting that actually + * comes out, without depending on how the component tree happens to be nested. + */ + private static String legacy(Component c) { + return LegacyComponentSerializer.legacySection().serialize(c); + } + + /** + * MiniMessage tags used to be rendered as literal text because the serializer only understood + * legacy codes. + */ + @Test + void testBukkitToAdventureParsesMiniMessage() { + Component c = BossBarListener.bukkitToAdventure("Plains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + assertTrue(legacy(c).contains("\u00a7a"), "expected green in " + legacy(c)); + assertTrue(legacy(c).contains("\u00a7l"), "expected bold in " + legacy(c)); + } + + /** + * MiniMessage gradients, which legacy codes cannot express at all. + */ + @Test + void testBukkitToAdventureParsesGradient() { + Component c = BossBarListener.bukkitToAdventure("Plains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + } + + /** + * Translations reach this method already converted to section codes by BentoBox, so a + * serializer bound to '&' would leave them in the output as literal text. + */ + @Test + void testBukkitToAdventureParsesSectionCodes() { + Component c = BossBarListener.bukkitToAdventure("\u00a7aPlains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + assertTrue(legacy(c).contains("\u00a7a"), "expected green in " + legacy(c)); + } + + /** + * Legacy '&' codes must keep working - every existing locale file uses them. + */ + @Test + void testBukkitToAdventureParsesLegacyAmpersand() { + Component c = BossBarListener.bukkitToAdventure("&aPlains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + assertTrue(legacy(c).contains("\u00a7a"), "expected green in " + legacy(c)); + } + + /** + * Hex colours, which the previous serializer supported here and must not regress. + */ + @Test + void testBukkitToAdventureParsesHex() { + Component c = BossBarListener.bukkitToAdventure("7FF55Plains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + assertEquals(TextColor.fromHexString("#55FF55"), c.color()); + } + + @Test + void testBukkitToAdventureNullIsEmpty() { + assertEquals(Component.empty(), BossBarListener.bukkitToAdventure(null)); + } } diff --git a/src/test/java/world/bentobox/chunkblock/listeners/HoloListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/HoloListenerTest.java index e06b58b..e77ec3d 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/HoloListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/HoloListenerTest.java @@ -1,6 +1,8 @@ package world.bentobox.chunkblock.listeners; import static org.junit.jupiter.api.Assertions.assertNotNull; +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.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; @@ -29,8 +31,14 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.chunkblock.CommonTestSetup; import world.bentobox.chunkblock.Settings; @@ -198,4 +206,62 @@ void testProcess() { verify(sch).runTaskLater(isNull(), any(Runnable.class), anyLong()); } + /** + * Captures the component the hologram was actually given. + */ + private Component displayed(String hologramLine) { + when(phase.getHologramLine(anyInt())).thenReturn(hologramLine); + // process() writes the line to the data object then reads it straight back, and that + // object is a mock, so the read has to be stubbed too or it returns the setUp default. + when(is.getHologram()).thenReturn(hologramLine); + hl.process(island, is, phase); + ArgumentCaptor captor = ArgumentCaptor.forClass(Component.class); + verify(hologram).text(captor.capture()); + return captor.getValue(); + } + + /** + * Phase file hologram lines are read straight from YAML, so this is the only place their + * formatting is resolved. MiniMessage tags used to appear as literal text. + */ + @Test + void testHologramParsesMiniMessage() { + Component c = displayed("Plains"); + assertEquals("Plains", PlainTextComponentSerializer.plainText().serialize(c)); + String legacy = LegacyComponentSerializer.legacySection().serialize(c); + assertTrue(legacy.contains("\u00a7a"), "expected green in " + legacy); + assertTrue(legacy.contains("\u00a7l"), "expected bold in " + legacy); + } + + /** + * Legacy '&' codes must keep working - every existing phase file uses them. + */ + @Test + void testHologramParsesLegacyAmpersand() { + Component c = displayed("&aGood Luck!"); + assertEquals("Good Luck!", PlainTextComponentSerializer.plainText().serialize(c)); + assertTrue(LegacyComponentSerializer.legacySection().serialize(c).contains("\u00a7a")); + } + + /** + * Hex was not supported here before - the serializer was built without hex enabled. + */ + @Test + void testHologramParsesHex() { + Component c = displayed("7FF55Good Luck!"); + assertEquals("Good Luck!", PlainTextComponentSerializer.plainText().serialize(c)); + assertEquals(TextColor.fromHexString("#55FF55"), c.color()); + } + + /** + * The starting hologram comes from the locale file via User.getTranslation, which hands back + * section codes. A serializer bound to '&' left those in as literal text. + */ + @Test + void testHologramParsesSectionCodes() { + Component c = displayed("\u00a7aWelcome"); + assertEquals("Welcome", PlainTextComponentSerializer.plainText().serialize(c)); + assertTrue(LegacyComponentSerializer.legacySection().serialize(c).contains("\u00a7a")); + } + } From 2477f1201cce1c0ee3b4bd1622e5bae3ed0baf56 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 9 Aug 2026 12:27:50 -0700 Subject: [PATCH 02/10] 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 03/10] 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 04/10] 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); + } +} From 51f24ec2e6b8bc4e9118f869f706ca5f9f63fca8 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 9 Aug 2026 16:31:46 -0700 Subject: [PATCH 05/10] fix: render the territory map in a monospaced font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat's default font is proportional, so a grid of mixed glyphs came out ragged — how far a row reached across the screen depended on which chunks happened to be claimed, which is the opposite of what a map is for. The rows now go out as components in minecraft:uniform, Minecraft's built-in fixed-width font, so the columns line up. Only the rows change font; the title and legend stay in the normal chat font. The row locale key is still used — getTranslationAsComponent() resolves the legacy colour codes, and the font is set on the result. --- .../commands/island/IslandChunksCommand.java | 12 ++++++- .../island/IslandChunksCommandTest.java | 31 ++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) 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 0d5db2e..0aa6d43 100644 --- a/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java +++ b/src/main/java/world/bentobox/chunkblock/commands/island/IslandChunksCommand.java @@ -4,6 +4,7 @@ import java.util.Objects; import java.util.Optional; +import net.kyori.adventure.key.Key; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; @@ -23,6 +24,14 @@ public class IslandChunksCommand extends CompositeCommand { /** Widest map that still fits comfortably in chat */ private static final int MAX_MAP_RADIUS = 7; + /** + * Minecraft's built-in fixed-width font. Chat's default font is proportional, so a + * grid built from mixed glyphs comes out ragged — a row's width depends on which + * chunks happen to be claimed. Only the map rows use it; the rest of the chat stays + * in the normal font. + */ + private static final Key MONOSPACE_FONT = Key.key("minecraft", "uniform"); + private ChunkBlock addon; public IslandChunksCommand(CompositeCommand islandCommand, String label, String[] aliases) { @@ -97,7 +106,8 @@ private void showMap(User user, Island island, int unlocked, int max) { row.append(here ? "&b◇" : "&7□"); } } - user.sendMessage("chunkblock.chunks.map.row", "[row]", row.toString()); + user.sendMessage(user.getTranslationAsComponent("chunkblock.chunks.map.row", "[row]", row.toString()) + .font(MONOSPACE_FONT)); } user.sendMessage("chunkblock.chunks.map.legend", "[cost]", String.valueOf(cm.getChunkCost())); } diff --git a/src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java b/src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java index a82fe0f..98b7b41 100644 --- a/src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java +++ b/src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java @@ -2,6 +2,9 @@ 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.anyString; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -10,6 +13,10 @@ import java.util.List; import java.util.Optional; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; + import org.bukkit.Location; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -72,6 +79,12 @@ public void setUp() throws Exception { when(user.getWorld()).thenReturn(world); when(im.getIslandAt(playerLocation)).thenReturn(Optional.of(island)); + // Hand the row string straight back as a component so the test can read it out again + when(user.getTranslationAsComponent(anyString(), any(String[].class))).thenAnswer(invocation -> { + Object[] args = invocation.getArguments(); + return Component.text((String) args[args.length - 1]); + }); + command = new IslandChunksCommand(ac, "chunks", new String[] { "chunks" }); } @@ -130,12 +143,20 @@ void testMapIsCappedAtTheWidestRowThatFitsChat() { assertEquals(15, mapRows().size()); } - /** The rendered map rows, in order, as passed to the row locale key */ + /** + * The rendered map rows, in order. Rows are sent as components so they can carry the + * monospace font, so they are read back out of the component rather than the arguments. + */ 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()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Component.class); + verify(user, atLeastOnce()).sendMessage(captor.capture()); + List rows = new ArrayList<>(); + for (Component component : captor.getAllValues()) { + assertEquals(Key.key("minecraft", "uniform"), component.font(), + "map rows must be monospaced or the grid comes out ragged"); + rows.add(((TextComponent) component).content()); + } + return rows; } /** The glyph at the middle of a row, colour code included */ From 270927d926e8988625ea0e9c7343e844b2040387 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 9 Aug 2026 17:46:07 -0700 Subject: [PATCH 06/10] fix: prefix the magic block flag ID so it cannot collide with AOneBlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAGIC_BLOCK is the ID AOneBlock uses too, and BentoBox's flags manager drops the second registration of an ID without a word — so with both gamemodes installed, whichever loaded second was quietly running on the other addon's flag definition. Nothing misbehaved yet only because the two definitions are identical and neither sets a game mode on the builder; both are coincidences. This is the same bug class as the CHUNKBLOCK_* renames in 1.0.1, missed then. Renamed to CHUNKBLOCK_MAGIC_BLOCK in the flag, the field, default-island-flags and all 18 locale files, so translations follow the key. registerFlagOrWarn() now logs when the flags manager refuses a flag, for all five flags rather than just this one — the failure was invisible, which is why it went unnoticed for two releases. Addon already has a registerFlag() that returns the boolean; it just was not being checked. ChunkBlockTest asserts by reflection that every Flag field carries the prefix, so a new flag cannot reintroduce this. Fixes #24 --- .../world/bentobox/chunkblock/ChunkBlock.java | 28 +++++++++++++++---- .../chunkblock/listeners/BlockListener.java | 6 ++-- src/main/resources/config.yml | 2 +- src/main/resources/locales/cs.yml | 2 +- src/main/resources/locales/de.yml | 2 +- src/main/resources/locales/en-US.yml | 2 +- src/main/resources/locales/es.yml | 2 +- src/main/resources/locales/fr.yml | 2 +- src/main/resources/locales/hr.yml | 2 +- src/main/resources/locales/hu.yml | 2 +- src/main/resources/locales/id.yml | 2 +- src/main/resources/locales/it.yml | 2 +- src/main/resources/locales/ja.yml | 2 +- src/main/resources/locales/pl.yml | 2 +- src/main/resources/locales/pt.yml | 2 +- src/main/resources/locales/ru.yml | 2 +- src/main/resources/locales/tr.yml | 2 +- src/main/resources/locales/uk.yml | 2 +- src/main/resources/locales/vi.yml | 2 +- src/main/resources/locales/zh-CN.yml | 2 +- src/main/resources/locales/zh-TW.yml | 2 +- .../bentobox/chunkblock/ChunkBlockTest.java | 22 +++++++++++++++ .../listeners/BlockListenerTest2.java | 2 +- 23 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java index 38e3eda..8df0ff6 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java @@ -125,7 +125,7 @@ public class ChunkBlock extends GameModeAddon { /** * Flag to set who can break the magic block. */ - public final Flag MAGIC_BLOCK = new Flag.Builder("MAGIC_BLOCK", Material.GRASS_BLOCK) + public final Flag CHUNKBLOCK_MAGIC_BLOCK = new Flag.Builder("CHUNKBLOCK_MAGIC_BLOCK", Material.GRASS_BLOCK) .mode(Mode.BASIC) .type(Type.PROTECTION) .defaultRank(RanksManager.COOP_RANK) @@ -175,19 +175,35 @@ public void onLoad() { adminCommand = new AdminCommand(this); // Register flag with BentoBox // Register protection flag with BentoBox - getPlugin().getFlagsManager().registerFlag(this, CHUNKBLOCK_START_SAFETY); + registerFlagOrWarn(CHUNKBLOCK_START_SAFETY); // Bossbar if (getSettings().isBossBar()) { - getPlugin().getFlagsManager().registerFlag(this, this.CHUNKBLOCK_BOSSBAR); + registerFlagOrWarn(this.CHUNKBLOCK_BOSSBAR); } // Actionbar if (getSettings().isActionBar()) { - getPlugin().getFlagsManager().registerFlag(this, this.CHUNKBLOCK_ACTIONBAR); + registerFlagOrWarn(this.CHUNKBLOCK_ACTIONBAR); } // Magic Block protection - getPlugin().getFlagsManager().registerFlag(this, this.MAGIC_BLOCK); + registerFlagOrWarn(this.CHUNKBLOCK_MAGIC_BLOCK); // Who may spend level credit on chunks - getPlugin().getFlagsManager().registerFlag(this, this.CHUNKBLOCK_CLAIM_CHUNKS); + registerFlagOrWarn(this.CHUNKBLOCK_CLAIM_CHUNKS); + } + } + + /** + * Registers a flag and complains if it is refused. A flag whose ID is already taken by + * another addon is dropped silently by the flags manager, and this addon then runs + * against whichever definition won — so the only symptom would be settings that + * quietly do nothing. Every ID here is prefixed to avoid that, and this says so out + * loud if one ever collides anyway. + * + * @param flag the flag to register + */ + private void registerFlagOrWarn(Flag flag) { + if (!registerFlag(flag)) { + logError("Flag " + flag.getID() + " is already registered by another addon, so ChunkBlock's own " + + "definition was dropped. Its island settings will behave as that addon defines them."); } } diff --git a/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java b/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java index 722fdea..d9c50ae 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java @@ -230,7 +230,7 @@ public void onBlockFromTo(final BlockFromToEvent e) { /** * Cancels a magic-block break as early as possible when the player lacks the - * {@link ChunkBlock#MAGIC_BLOCK} permission. + * {@link ChunkBlock#CHUNKBLOCK_MAGIC_BLOCK} permission. *

* The full magic-block processing runs at {@link EventPriority#HIGHEST} so that * other protection plugins get a chance to cancel first. However, reward-granting @@ -256,7 +256,7 @@ public void onBlockBreakDeny(final BlockBreakEvent e) { // checkIsland cancels the event and sends the protection message if the player // is not allowed to break the magic block. addon.getIslands().getIslandAt(l).filter(i -> l.equals(i.getCenter())) - .ifPresent(i -> checkIsland(e, e.getPlayer(), i.getCenter(), addon.MAGIC_BLOCK)); + .ifPresent(i -> checkIsland(e, e.getPlayer(), i.getCenter(), addon.CHUNKBLOCK_MAGIC_BLOCK)); } /** @@ -394,7 +394,7 @@ private void process(@NonNull Cancellable e, @NonNull Island island, @Nullable P // player (e.g. the block is broken by a JetsMinions minion) the protection flag // check is skipped: it requires a User and would otherwise throw an NPE inside // BentoBox's FlagListener. See https://github.com/BentoBoxWorld/ChunkBlock/issues/525 - if (player != null && !checkIsland((@NonNull Event) e, player, island.getCenter(), addon.MAGIC_BLOCK)) { + if (player != null && !checkIsland((@NonNull Event) e, player, island.getCenter(), addon.CHUNKBLOCK_MAGIC_BLOCK)) { // Not allowed return; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 03577ef..5c5aec4 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -425,7 +425,7 @@ world: BREAK_BLOCKS: 500 CHORUS_FRUIT: 500 CONTAINER: 500 - MAGIC_BLOCK: 200 + CHUNKBLOCK_MAGIC_BLOCK: 200 CHUNKBLOCK_CLAIM_CHUNKS: 1000 JUKEBOX: 500 POTION_THROWING: 500 diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index dbf0f44..5f61bf5 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Ochrana Kouzelného Bloku description: |- &b Hodnost, která může rozbít diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 5cd4cb5..073f740 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Schutz des Magischen Blocks description: |- &b Rang, der den magischen diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index dfc6990..21e4da2 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -5,7 +5,7 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Magic Block Protection description: |- &b Rank that can break the magic diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 3558929..eda91e8 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Protección de Bloque Mágico description: |- &b Rango que puede romper el diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 531f1a4..8e33ba3 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Protection du Bloc Magique description: |- &b Rang qui peut casser le bloc diff --git a/src/main/resources/locales/hr.yml b/src/main/resources/locales/hr.yml index 6cb4138..c31c5a1 100644 --- a/src/main/resources/locales/hr.yml +++ b/src/main/resources/locales/hr.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Zaštita Magičnog Bloka description: |- &b Rang koji može razbiti diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index aaba727..590c841 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Mágikus Blokk Védelem description: |- &b Rang, amely képes diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index dce0c28..a08e651 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Perlindungan Blok Ajaib description: |- &b Pangkat yang dapat diff --git a/src/main/resources/locales/it.yml b/src/main/resources/locales/it.yml index f9bcbf4..300d221 100644 --- a/src/main/resources/locales/it.yml +++ b/src/main/resources/locales/it.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Protezione Blocco Magico description: |- &b Rango che può rompere il diff --git a/src/main/resources/locales/ja.yml b/src/main/resources/locales/ja.yml index fc08cba..8239163 100644 --- a/src/main/resources/locales/ja.yml +++ b/src/main/resources/locales/ja.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: 魔法ブロック保護 description: |- &b ブロックを破壊できる場合、 diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 24e7225..2fdb553 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Ochrona Magicznego Bloku description: |- &b Ranga, która może zniszczyć diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index ef33ccb..5e417a0 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Proteção de Bloco Mágico description: |- &b Rank que pode quebrar o diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 1f3ece3..6b8fc56 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -5,7 +5,7 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Магический блок description: |- Предотвращает ломание diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index 351ac03..6854346 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Büyülü Blok Koruması description: |- &b Blokları kırabilirlerse diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 76b12bb..b1af17c 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Захист Магічного Блоку description: |- &b Ранг, який може зламати diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 53510a5..1473291 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: Bảo Vệ Khối Ma Thuật description: |- &b Xếp hạng có thể phá diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index dc17eb0..934a27a 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: 魔法方块保护 description: |- &b 如果玩家可以破坏方块, diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index b3dd13f..48dd328 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -1,6 +1,6 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: 魔法方塊保護 description: |- &b 如果玩家可以破壞方塊, diff --git a/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java b/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java index 8c6d1e5..73c8bf4 100644 --- a/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java +++ b/src/test/java/world/bentobox/chunkblock/ChunkBlockTest.java @@ -15,6 +15,7 @@ import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -38,6 +39,7 @@ import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.addons.Addon.State; import world.bentobox.bentobox.api.addons.AddonDescription; +import world.bentobox.bentobox.api.flags.Flag; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.AbstractDatabaseHandler; import world.bentobox.bentobox.database.DatabaseSetup; @@ -209,6 +211,26 @@ void testOnLoad() { } + /** + * Every flag ID must be prefixed. AOneBlock, which this addon was forked from, declares + * flags of its own, and BentoBox's flags manager silently drops the second registration + * of an ID — so an unprefixed ID means that whichever gamemode loads second quietly runs + * on the other one's definition. See issue #24. + */ + @Test + void testEveryFlagIdIsPrefixed() throws IllegalAccessException { + int checked = 0; + for (Field field : ChunkBlock.class.getDeclaredFields()) { + if (Flag.class.isAssignableFrom(field.getType())) { + String id = ((Flag) field.get(addon)).getID(); + assertTrue(id.startsWith("CHUNKBLOCK_"), + "Flag " + id + " needs a CHUNKBLOCK_ prefix or it collides with another addon"); + checked++; + } + } + assertTrue(checked > 0, "no flags found to check — has the field type changed?"); + } + /** * Test method for {@link world.bentobox.chunkblock.ChunkBlock#onReload()}. */ diff --git a/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest2.java b/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest2.java index b59c377..541a569 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest2.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest2.java @@ -1079,7 +1079,7 @@ void testOnBlockBreakDenyRegisteredAtLowestPriority() throws NoSuchMethodExcepti /** * Test method for * {@link world.bentobox.chunkblock.listeners.BlockListener#onBlockBreakDeny(BlockBreakEvent)} - * When the player lacks the MAGIC_BLOCK permission the break is cancelled at this + * When the player lacks the CHUNKBLOCK_MAGIC_BLOCK permission the break is cancelled at this * early stage (via checkIsland), so later reward plugins are skipped. Regression * test for https://github.com/BentoBoxWorld/ChunkBlock/issues/534 */ From 6427c128996e26a340369f1c01c4f3de67761c86 Mon Sep 17 00:00:00 2001 From: qwe664 Date: Mon, 10 Aug 2026 10:37:23 +0800 Subject: [PATCH 07/10] Add missing v1.1.0 translation keys (chunk claiming system, phase editor) --- src/main/resources/locales/zh-TW.yml | 129 +++++++++++++++++++++------ 1 file changed, 102 insertions(+), 27 deletions(-) diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index 48dd328..eda6ef4 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -1,30 +1,45 @@ +# ########################################################################################## +# This is a YML file. Be careful when editing. Check your edits in a YAML checker like # +# the one at http://yaml-online-parser.appspot.com # +# ########################################################################################## + protection: flags: - CHUNKBLOCK_MAGIC_BLOCK: + MAGIC_BLOCK: name: 魔法方塊保護 - description: |- - &b 如果玩家可以破壞方塊, + description: '&b 如果玩家可以破壞方塊, + &b 則該等級可以破壞 - &b 魔法方塊。 - hint: "&c 您的等級無法破壞魔法方塊!" + + &b 魔法方塊。' + hint: '&c 您的等級無法破壞魔法方塊!' CHUNKBLOCK_START_SAFETY: name: 初始安全保護 - description: |- - &b 阻止新玩家在1分鐘內 - &b 移動,以防他們跌落。 - hint: "&c 出於安全考慮,移動已被阻止 [number] 秒!" - free-to-move: "&a 您可以自由移動了。請小心!" + description: '&b 阻止新玩家在1分鐘內 + + &b 移動,以防他們跌落。' + hint: '&c 出於安全考慮,移動已被阻止 [number] 秒!' + free-to-move: '&a 您可以自由移動了。請小心!' CHUNKBLOCK_BOSSBAR: - name: Boss 血條 - description: |- - &b 為每個階段 - &b 顯示一個狀態條。 + name: Boss 血條 + description: '&b 為每個階段 + + &b 顯示一個狀態條。' CHUNKBLOCK_ACTIONBAR: name: 動作欄 - description: |- - &b 在動作欄中 + description: '&b 在動作欄中 + &b 顯示每個階段 - &b 的狀態。 + + &b 的狀態。' + CHUNKBLOCK_CLAIM_CHUNKS: + name: 認領區塊 + description: '&b 可花費島嶼的等級額度 + + &b 認領新區塊 + + &b 的身分組。' + hint: '&c 你的身分組無法為這座島嶼認領區塊!' chunkblock: bossbar: title: 剩餘的塊 @@ -33,8 +48,8 @@ chunkblock: style: SEGMENTED_20 not-active: '&c 老闆酒吧對這個島不活躍' actionbar: - status: "&a 階段: &b [phase-name] &d | &a 方塊數: &b [done] &d / &b [total] &d | &a 進度: &b [percent-done]" - not-active: "&c 該島嶼的動作欄未啟用" + status: '&a 階段: &b [phase-name] &d | &a 方塊數: &b [done] &d / &b [total] &d | &a 進度: &b [percent-done]' + not-active: '&c 該島嶼的動作欄未啟用' commands: admin: setcount: @@ -56,6 +71,41 @@ chunkblock: parameters: <階段> description: 在控制台中顯示相概率的健全性檢查 see-console: &a請參閱控制台以獲取報告 + bypass: + description: 切換你自己是否受區塊鎖定限制 + 'off': '&a 區塊鎖定已重新套用在你身上。' + 'on': '&a 你現在可以無視區塊鎖定,且邊界視覺效果對你隱藏。' + chunks: + description: 查看玩家已解鎖的區塊,或將其重新鎖回起始狀態 + info: '&a [name]:&b [number]&a/&b[max] &a 個區塊,已花費 &b [spent] &a 點等級,剩餘額度 &b [credit] &a 點。' + parameters: <玩家> [reset] + reset: '&a [name] 的區塊已重新鎖回只剩中心區塊。' + phases: + description: 開啟階段順序編輯器 + no-index: '&c 尚未載入階段索引,因此無法重新排序階段' + save-failed: '&c 無法儲存階段順序!請查看主控台錯誤訊息' + saved: '&a 階段順序已儲存並套用' + gui: + cancel-word: cancel + disabled: '&c 已停用' + drop-at-end: '&a 放到最後' + drop-here: '&e 點擊以放置於此' + enter-length: '&e 請在聊天室輸入 &a [name] &e 的新長度-目前為 &b [number] &e 個方塊。輸入 &c cancel &e 可保持不變。' + held: '&e 移動中:[name]' + info-title: '&f 使用方式' + instructions: '&7 點擊一個階段以拿起它,\n&7 再點擊要放置的位置。\n&7 右鍵點擊可切換\n&7 階段的啟用/停用。' + invalid-length: '&c 長度必須是大於 0 的整數' + length: '&7 長度:&b [number]' + length-cancelled: '&c 長度未變更' + phase-name: '&a [name]' + pick-up: '&e 點擊以移動' + put-back: '&e 點擊以放回' + repeat: '&7 最後一個階段結束後,計數將跳至 &b [number]' + set-length: '&e Shift + 左鍵點擊以設定長度' + start: '&7 起始:&b [number]' + title: '&2 階段順序' + toggle: '&e 右鍵點擊以切換' + version-locked: '&c 需要 Minecraft [version]+' count: description: 顯示塊數和相位 info: '&a您正在&a[name]]階段中阻止&b[number]' @@ -73,8 +123,8 @@ chunkblock: status_off: '&b Bossbar&c 關閉' actionbar: description: 切換階段動作欄 - status_on: "&b 動作欄已 &a 開啟" - status_off: "&b 動作欄已 &c 關閉" + status_on: '&b 動作欄已 &a 開啟' + status_off: '&b 動作欄已 &c 關閉' setcount: parameters: <計數> description: 將區塊計數設定為之前完成的值 @@ -84,6 +134,8 @@ chunkblock: description: 在魔法塊消失的情況下重生 block-exist: '&a 塊存在,不需要重生。我給你標記了。' block-respawned: '&a 塊重生。' + chunks: + description: 顯示你已解鎖的區塊與你的領土地圖 phase: insufficient-level: '&c 你的島嶼等級太低,無法繼續!必須是[number]。' insufficient-funds: '&c 您的資金太低,無法繼續!他們必須是[數字]。' @@ -105,13 +157,17 @@ chunkblock: description: '&7 切換到[number]頁' phase: name: '&f&l [階段]' - description: |- - [starting-block] + description: '[starting-block] + [biome] + [bank] + [economy] + [level] - [permission] + + [permission]' starting-block: '&7 在破壞 &e [number] 區塊後開始。' biome: '&7 生物群落:&e [biome]' bank: '&7 需要銀行帳戶中有 &e $[number] &7。' @@ -126,6 +182,25 @@ chunkblock: click-to-next: '&e 點選&7 查看下一頁。' click-to-change: '&e 點選 &7 進行更改。' island: - starting-hologram: |- - &a歡迎來到 ChunkBlock - 打破此區塊以開始(&E) + starting-hologram: '&a歡迎來到 ChunkBlock + + 打破此區塊以開始(&E)' + chunks: + beyond-limit: '&c 那個區塊超出你的島嶼保護範圍。' + claim-confirm: '&e 要花費 &b [cost] &e 點等級額度認領這個區塊嗎?認領後你將剩下 &b [after] &e 點額度。&6請潛行並在 &e &b [seconds]s &e 內再次撞擊邊界以確認。' + claim-hint: '&e 撞擊邊界即可花費 &b [cost] &e 點等級額度認領這個區塊!你目前有 &b [credit] &e 點額度。' + claimed: '&a &l 區塊已認領!&r&a 你的島嶼現在有 &b [number] &a 個區塊。剩餘額度:&b [credit] &a 點等級。' + credit: '&a 你還可以認領 &b [count] &a 個區塊!前往你的邊界,朝想擴張的方向撞擊它。' + ejected: '&c 你所在的區塊被重新鎖定,因此你已被移動到安全地點。' + entry-denied: '&c 那個區塊已被鎖定。' + info: '&a 區塊:&b [unlocked]&a/&b[max]&a。額度:&b [credit] &a 點等級 — 認領一個區塊需要 &b [cost]&a。' + locked: '&c 你無法碰觸那裡 — 該區塊已被鎖定。' + map: + legend: '&a ■ 你的 &e ▣ 可認領(每個 [cost] 點等級) &7 □ 已鎖定' + row: '&a [row]' + title: '&a 你的島嶼領土([unlocked]/[max] 個區塊):' + you-are-here: '&b 你目前站在標記的區塊上。' + max-reached: '&d 你的島嶼已達到最大尺寸 [number] 個區塊!' + no-credit: '&c 你還需要 &b [needed] &c 點等級額度才能認領這個區塊。' + relocked: '&c 你的島嶼等級下降 — [count] 個區塊已重新鎖定(由最新認領的開始)。提升等級即可重新奪回!' + sethome-denied: '&c 你不能在已鎖定的區塊內設定重生點。' From f97c93c1ea791f1cdd55dce956de56cef5ac2896 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 10 Aug 2026 07:46:52 -0700 Subject: [PATCH 08/10] Update pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b83442c..be981e6 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ -LOCAL - 1.1.0 + 1.1.1 BentoBoxWorld_ChunkBlock bentobox-world From 5363f177a6600ec491b6967a93f10736f19e02a4 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 10 Aug 2026 07:56:51 -0700 Subject: [PATCH 09/10] fix: only complain about claim rank when a chunk is actually targeted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claim rank was checked before anything had established that the click was a claim gesture, so every left or right click in the world ran the denial branch. Any player below the claim rank was told "Your rank cannot claim chunks for this island!" for opening a chest, pressing a button or mining — throttled to once every two seconds, uncancelled and unlogged, so the interaction still went through and nothing showed in console. The checks that recognise ordinary interaction — clicked block sits in unlocked territory, aim ray finds no locked chunk — all sat below the rank check and never got the chance to bail out. Move the rank check to after the target chunk is resolved, so a low-rank teammate only hears about the claim flag when they genuinely aim at a locked chunk, which is what that message was for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017EZEwab2kL4i1FNnBYvSmp --- .../listeners/ChunkClaimListener.java | 18 ++++++++++-------- .../listeners/ChunkClaimListenerTest.java | 12 ++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java index 15519e5..3b330e8 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java @@ -110,20 +110,14 @@ public void onBorderHit(PlayerInteractEvent e) { return; } Island island = optionalIsland.get(); - 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(); // The player must be standing in their own territory, aiming at a locked chunk if (cm.isLocked(island, player.getLocation())) { return; } // A click on a block inside unlocked territory is ordinary interaction (mining a - // generator, pressing a button...), never a claim gesture — regardless of where - // the aim line would end up beyond it. + // generator, opening a chest, pressing a button...), never a claim gesture — + // regardless of where the aim line would end up beyond it. Block clicked = e.getClickedBlock(); if (clicked != null && cm.isUnlocked(island, clicked.getX() >> 4, clicked.getZ() >> 4)) { return; @@ -138,6 +132,14 @@ public void onBorderHit(PlayerInteractEvent e) { if (target == null) { return; } + // Only now that this is genuinely a claim gesture is rank worth raising: who may + // spend the island's credit is an island setting, owner-only by default. Checking + // any earlier turns every ordinary click into a rank complaint. + User user = User.getInstance(player); + if (!island.isAllowed(user, addon.CHUNKBLOCK_CLAIM_CHUNKS)) { + denyClaim(user, island); + return; + } attemptClaim(user, island, target[0], target[1]); } diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java index 949228c..ab70f0d 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java @@ -220,6 +220,18 @@ void testMiningOwnBlockNearBorderIsNotAClaim() { verify(notifier, never()).notify(any(), any()); } + @Test + void testUsingABlockInOwnTerritoryNeverRaisesTheRankComplaint() { + // Reported bug: a member opening a chest anywhere in the island's own chunks was + // told their rank could not claim, because rank was checked before the gesture was + // known to be a claim at all. + 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); + listener.onBorderHit(hitBlock(Action.RIGHT_CLICK_BLOCK, 14, 8)); + verify(notifier, never()).notify(any(), any()); + } + @Test void testPunchingBlockInLockedChunkClaimsIt() { level = 1; From c4a849b7651f80f5a930e2e96a9b1eb2a5d0f22d Mon Sep 17 00:00:00 2001 From: qwe664 Date: Mon, 10 Aug 2026 23:20:39 +0800 Subject: [PATCH 10/10] Fix flag key back to CHUNKBLOCK_MAGIC_BLOCK per maintainer feedback, and revise terminology for Taiwan Minecraft community usage --- src/main/resources/locales/zh-TW.yml | 48 ++++++++++++++-------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main/resources/locales/zh-TW.yml b/src/main/resources/locales/zh-TW.yml index eda6ef4..f6c40ce 100644 --- a/src/main/resources/locales/zh-TW.yml +++ b/src/main/resources/locales/zh-TW.yml @@ -5,21 +5,21 @@ protection: flags: - MAGIC_BLOCK: + CHUNKBLOCK_MAGIC_BLOCK: name: 魔法方塊保護 description: '&b 如果玩家可以破壞方塊, &b 則該等級可以破壞 &b 魔法方塊。' - hint: '&c 您的等級無法破壞魔法方塊!' + hint: '&c 你的等級無法破壞魔法方塊!' CHUNKBLOCK_START_SAFETY: name: 初始安全保護 description: '&b 阻止新玩家在1分鐘內 &b 移動,以防他們跌落。' hint: '&c 出於安全考慮,移動已被阻止 [number] 秒!' - free-to-move: '&a 您可以自由移動了。請小心!' + free-to-move: '&a 你可以自由移動了,請小心!' CHUNKBLOCK_BOSSBAR: name: Boss 血條 description: '&b 為每個階段 @@ -43,10 +43,10 @@ protection: chunkblock: bossbar: title: 剩餘的塊 - status: '&a 相位塊&b [done] &d / &b [total]' + status: '&a 階段方塊&b [done] &d / &b [total]' color: RED style: SEGMENTED_20 - not-active: '&c 老闆酒吧對這個島不活躍' + not-active: '&c 此島嶼的 Boss 血條未啟用' actionbar: status: '&a 階段: &b [phase-name] &d | &a 方塊數: &b [done] &d / &b [total] &d | &a 進度: &b [percent-done]' not-active: '&c 該島嶼的動作欄未啟用' @@ -54,23 +54,23 @@ chunkblock: admin: setcount: parameters: <名稱> <計數> - description: 設置玩家的蓋帽數 - set: a [name]的計數設置為[number] + description: 設定玩家的方塊數量 + set: '&a [name] 的計數設定為 [number]' set-lifetime: '&a [name] 的生命週期計數設定為 [number]' setchest: parameters: <階段> <稀有> description: 將所看的箱子放在指定稀有度的階段 - chest-is-empty: &c該箱子為空,因此無法添加 - unknown-phase: &c未知階段。使用製表符完成功能來查看它們 - unknown-rarity: &c未知稀有。使用COMMON,UNCOMMON,RARE或EPIC - look-at-chest: &c看看裝滿的箱子 - only-single-chest: &c只能設置單個箱子 + chest-is-empty: '&c該箱子為空,因此無法添加' + unknown-phase: '&c未知階段。使用 Tab 鍵自動補全查看' + unknown-rarity: '&c未知稀有。使用COMMON,UNCOMMON,RARE或EPIC' + look-at-chest: '&c請看向裝滿的箱子' + only-single-chest: '&c只能設定單一箱子' success: '&a 箱子成功添加' - failure: &c 無法將胸部添加到該階段! 請參閱控制台以獲取錯誤 + failure: '&c 無法將箱子加入該階段! 請參閱控制台以獲取錯誤' sanity: parameters: <階段> - description: 在控制台中顯示相概率的健全性檢查 - see-console: &a請參閱控制台以獲取報告 + description: 在主控台顯示各階段機率的健全性檢查 + see-console: '&a請參閱控制台以獲取報告' bypass: description: 切換你自己是否受區塊鎖定限制 'off': '&a 區塊鎖定已重新套用在你身上。' @@ -107,18 +107,18 @@ chunkblock: toggle: '&e 右鍵點擊以切換' version-locked: '&c 需要 Minecraft [version]+' count: - description: 顯示塊數和相位 - info: '&a您正在&a[name]]階段中阻止&b[number]' + description: 顯示方塊數量與所在階段 + info: '&a你目前在 &a[name] &r階段,已破壞 &b[number] &r個方塊' info: count: '&a 島位於 &b [names] &a 階段的 &b [number]&a 區塊。生命週期計數 &b [lifetime] &a。' phases: description: 顯示所有階段的列表 - title: &2 OneBlock階段 + title: '&2 ChunkBlock 階段' name-syntax: '&a [name]' description-syntax: '&b [number]塊' island: bossbar: - description: 切換相位欄 + description: 切換階段狀態列 status_on: '&b Bossbar&a 打開' status_off: '&b Bossbar&c 關閉' actionbar: @@ -129,7 +129,7 @@ chunkblock: parameters: <計數> description: 將區塊計數設定為之前完成的值 set: '&a 計數設定為 [number]。' - too-high: '&c 您可以設定的最大值是[number]!' + too-high: '&c 你可以設定的最大值是 [number]!' respawn-block: description: 在魔法塊消失的情況下重生 block-exist: '&a 塊存在,不需要重生。我給你標記了。' @@ -138,16 +138,16 @@ chunkblock: description: 顯示你已解鎖的區塊與你的領土地圖 phase: insufficient-level: '&c 你的島嶼等級太低,無法繼續!必須是[number]。' - insufficient-funds: '&c 您的資金太低,無法繼續!他們必須是[數字]。' + insufficient-funds: '&c 你的資金太低,無法繼續!至少需要 [number]。' insufficient-bank-balance: '&c 島上銀行餘額太低,無法繼續!必須是[number]。' - insufficient-permission: '&c 在獲得 [name] 許可之前,您不能繼續操作!' + insufficient-permission: '&c 在獲得 [name] 許可之前,你不能繼續操作!' cooldown: '&c [number] 秒後即可進入下一階段!' placeholders: infinite: 無窮 my-island-phase-default: 未知 gui: titles: - phases: '&0&l OneBlock 階段' + phases: '&0&l ChunkBlock 階段' buttons: previous: name: '&f&l 上一頁' @@ -169,7 +169,7 @@ chunkblock: [permission]' starting-block: '&7 在破壞 &e [number] 區塊後開始。' - biome: '&7 生物群落:&e [biome]' + biome: '&7 生態域:&e [biome]' bank: '&7 需要銀行帳戶中有 &e $[number] &7。' economy: '&7 需要玩家帳號中有 &e $[number] &7。' level: '&7 需要 &e [number] &7 島嶼等級。'