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 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/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..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) { @@ -61,6 +70,8 @@ public boolean execute(User user, String label, List args) { user.sendMessage("chunkblock.chunks.info", "[unlocked]", String.valueOf(unlocked), "[max]", String.valueOf(max), "[credit]", String.valueOf(credit), "[cost]", String.valueOf(cm.getChunkCost())); + user.sendMessage("chunkblock.chunks.rings", "[rings]", String.valueOf(cm.completedRings(island)), "[max]", + String.valueOf(cm.maxRingRadius(island))); showMap(user, island, unlocked, max); return true; } @@ -83,7 +94,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▣"); @@ -91,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/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/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;