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/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/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/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/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/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..5c5aec4 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
@@ -408,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 fa4282d..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
@@ -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/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..f6c40ce 100644
--- a/src/main/resources/locales/zh-TW.yml
+++ b/src/main/resources/locales/zh-TW.yml
@@ -1,101 +1,153 @@
+# ##########################################################################################
+# 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:
- MAGIC_BLOCK:
+ CHUNKBLOCK_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: 剩餘的塊
- 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 該島嶼的動作欄未啟用"
+ status: '&a 階段: &b [phase-name] &d | &a 方塊數: &b [done] &d / &b [total] &d | &a 進度: &b [percent-done]'
+ not-active: '&c 該島嶼的動作欄未啟用'
commands:
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 區塊鎖定已重新套用在你身上。'
+ '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]'
+ 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:
description: 切換階段動作欄
- status_on: "&b 動作欄已 &a 開啟"
- status_off: "&b 動作欄已 &c 關閉"
+ status_on: '&b 動作欄已 &a 開啟'
+ status_off: '&b 動作欄已 &c 關閉'
setcount:
parameters: <計數>
description: 將區塊計數設定為之前完成的值
set: '&a 計數設定為 [number]。'
- too-high: '&c 您可以設定的最大值是[number]!'
+ too-high: '&c 你可以設定的最大值是 [number]!'
respawn-block:
description: 在魔法塊消失的情況下重生
block-exist: '&a 塊存在,不需要重生。我給你標記了。'
block-respawned: '&a 塊重生。'
+ chunks:
+ 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 上一頁'
@@ -105,15 +157,19 @@ 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]'
+ biome: '&7 生態域:&e [biome]'
bank: '&7 需要銀行帳戶中有 &e $[number] &7。'
economy: '&7 需要玩家帳號中有 &e $[number] &7。'
level: '&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 你不能在已鎖定的區塊內設定重生點。'
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/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/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..98b7b41
--- /dev/null
+++ b/src/test/java/world/bentobox/chunkblock/commands/island/IslandChunksCommandTest.java
@@ -0,0 +1,169 @@
+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.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;
+
+import java.util.ArrayList;
+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;
+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));
+
+ // 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" });
+ }
+
+ @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. 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(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 */
+ 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/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
*/
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/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;
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"));
+ }
+
}
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;