diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e856bb..a1b36da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,11 +14,15 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up JDK 21 - uses: actions/setup-java@v3 + # BentoBox 3.18.0+ is compiled for Java 25 (Minecraft 26.x), so its class files + # cannot be read by a JDK 21 javac at all - the build fails with + # "class file has wrong version 69.0, should be 65.0" before reaching our code. + # The addon itself still targets 21 via in the pom. + - name: Set up JDK 25 + uses: actions/setup-java@v4 with: - distribution: 'adopt' - java-version: 21 + distribution: 'temurin' + java-version: 25 - name: Cache SonarCloud packages uses: actions/cache@v3 with: diff --git a/.github/workflows/modrinth-publish.yml b/.github/workflows/modrinth-publish.yml index 6d9a248..5574415 100644 --- a/.github/workflows/modrinth-publish.yml +++ b/.github/workflows/modrinth-publish.yml @@ -22,11 +22,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - # 2. Set up Java 21 (required by ChunkBlock' build) - - name: Set up Java 21 + # 2. Set up Java 25 - required to read BentoBox 3.18.0+ class files, which are + # compiled for Java 25. The addon itself still targets 21 via in the pom. + - name: Set up Java 25 uses: actions/setup-java@v4 with: - java-version: '21' + java-version: '25' distribution: 'temurin' # 3. Cache Maven dependencies to speed up builds diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4e34bb2..aa8d215 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,8 +21,13 @@ jobs: uses: bentoboxworld/.github/.github/workflows/publish-platforms.yml@1f91a0edf72e8c86d671b3b8fdd3121ac6fb88e1 # master with: use_release_asset: "true" # publish the jar attached to the release; do not rebuild - hangar_slug: "ChunkBlock" # blank = skip Hangar - curseforge_id: "1512493" + # TODO: ChunkBlock is not set up on Hangar or CurseForge yet. When the projects + # exist, set hangar_slug to the Hangar slug and curseforge_id to ChunkBlock's own + # CurseForge project id (1512493 was AOneBlock's — never reuse it), add the + # HANGAR_API_KEY / CURSEFORGE_TOKEN secrets, and re-enable this workflow + # (gh workflow enable publish.yml). + hangar_slug: "" # blank = skip Hangar + curseforge_id: "" # blank = skip CurseForge game_versions: "26.2,26.1.2,26.1.1,26.1,1.21.11,1.21.10,1.21.9,1.21.8,1.21.7,1.21.6,1.21.5" version: ${{ inputs.version }} # empty on release events -> falls back to the release tag secrets: diff --git a/pom.xml b/pom.xml index d3265e0..b83442c 100644 --- a/pom.xml +++ b/pom.xml @@ -56,7 +56,7 @@ 5.11.0 4.110.0 - 3.15.0-SNAPSHOT + 3.22.0 4.0.10 1.8.0 0.0.67 @@ -67,7 +67,7 @@ -LOCAL - 1.0.1 + 1.1.0 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 b2fb86e..38e3eda 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java @@ -130,6 +130,15 @@ public class ChunkBlock extends GameModeAddon { .type(Type.PROTECTION) .defaultRank(RanksManager.COOP_RANK) .build(); + /** + * Flag to set who can spend the island's level credit on new chunks. Defaults to the + * owner alone, because a claim is irreversible until the levels are earned back. + */ + public final Flag CHUNKBLOCK_CLAIM_CHUNKS = new Flag.Builder("CHUNKBLOCK_CLAIM_CHUNKS", Material.OAK_FENCE_GATE) + .mode(Mode.BASIC) + .type(Type.PROTECTION) + .defaultRank(RanksManager.OWNER_RANK) + .build(); @Override public void onLoad() { @@ -177,6 +186,8 @@ public void onLoad() { } // Magic Block protection getPlugin().getFlagsManager().registerFlag(this, this.MAGIC_BLOCK); + // Who may spend level credit on chunks + getPlugin().getFlagsManager().registerFlag(this, this.CHUNKBLOCK_CLAIM_CHUNKS); } } @@ -277,9 +288,10 @@ public boolean loadData() { @Override public void onDisable() { - // save cache + // Save cache. This must be a direct write, not a queued one: the server disables this + // Pladdon before BentoBox, so anything queued here depends on BentoBox draining it later. if (blockListener != null) { - blockListener.saveCache(); + blockListener.saveCacheNow(); } // Stop border rendering and restore client-side blocks diff --git a/src/main/java/world/bentobox/chunkblock/Settings.java b/src/main/java/world/bentobox/chunkblock/Settings.java index f8fd0ec..caa4d68 100644 --- a/src/main/java/world/bentobox/chunkblock/Settings.java +++ b/src/main/java/world/bentobox/chunkblock/Settings.java @@ -123,6 +123,18 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "chunkblock.max-chunks") private int maxChunks = 441; + @ConfigComment("Require confirmation before level credit is spent on a chunk. When true, the") + @ConfigComment("first hit on the border previews the target chunk and its cost; the player") + @ConfigComment("must then sneak and hit the border again to actually claim it. This stops a") + @ConfigComment("stray swing near the border from spending levels by accident.") + @ConfigEntry(path = "chunkblock.claim.require-confirmation") + private boolean requireClaimConfirmation = true; + + @ConfigComment("How long, in seconds, a previewed chunk stays confirmable. After this the") + @ConfigComment("player has to hit the border again to preview it afresh. Minimum 1.") + @ConfigEntry(path = "chunkblock.claim.confirmation-timeout") + private int claimConfirmationTimeout = 15; + @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") @@ -452,6 +464,13 @@ public class Settings implements WorldSettings { @ConfigEntry(path = "island.water-mob-protection") private boolean waterMobProtection = true; + @ConfigComment("How often island progress is written to the database, in blocks broken") + @ConfigComment("Progress is also saved whenever a phase changes, a player logs out and the server shuts down,") + @ConfigComment("so this only decides how much is lost if the server dies without shutting down cleanly.") + @ConfigComment("Lower is safer but writes more often. Minimum is 1 (save every block)") + @ConfigEntry(path = "island.save-every") + private int saveEvery = 10; + @ConfigComment("Default max team size") @ConfigComment("Permission size cannot be less than the default below. ") @ConfigEntry(path = "island.max-team-size") @@ -1933,6 +1952,25 @@ public void setMobWarning(int mobWarning) { this.mobWarning = mobWarning; } + /** + * How many blocks are broken between periodic saves of island progress. + * A value below 1 would make the modulo check throw, so it is clamped. + * @return the saveEvery value, never less than 1 + */ + public int getSaveEvery() { + if (saveEvery < 1) { + saveEvery = 1; + } + return saveEvery; + } + + /** + * @param saveEvery the saveEvery to set + */ + public void setSaveEvery(int saveEvery) { + this.saveEvery = saveEvery; + } + /** * @return the waterMobProtection */ @@ -2569,6 +2607,34 @@ public void setMaxChunks(int maxChunks) { this.maxChunks = maxChunks; } + /** + * @return true if a chunk must be previewed and confirmed before credit is spent + */ + public boolean isRequireClaimConfirmation() { + return requireClaimConfirmation; + } + + /** + * @param requireClaimConfirmation the requireClaimConfirmation to set + */ + public void setRequireClaimConfirmation(boolean requireClaimConfirmation) { + this.requireClaimConfirmation = requireClaimConfirmation; + } + + /** + * @return how long a previewed chunk stays confirmable in seconds, never less than 1 + */ + public int getClaimConfirmationTimeout() { + return Math.max(1, claimConfirmationTimeout); + } + + /** + * @param claimConfirmationTimeout the claimConfirmationTimeout to set + */ + public void setClaimConfirmationTimeout(int claimConfirmationTimeout) { + this.claimConfirmationTimeout = claimConfirmationTimeout; + } + /** * @return true if chunks re-lock when island level drops */ diff --git a/src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java b/src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java index a49c684..b971fc3 100644 --- a/src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java +++ b/src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java @@ -49,15 +49,25 @@ public class BorderDisplay implements Listener { private static final int OUT_OF_WORLD_DEPTH = 16; /** Dust color when the curtain is beyond the world height limits */ private static final Color OUT_OF_WORLD_COLOR = Color.ORANGE; + /** Dust color for a chunk a player has lined up to claim but not yet confirmed */ + private static final Color PREVIEW_COLOR = Color.YELLOW; + /** Heights above the viewer's feet at which the preview outline is drawn */ + private static final int[] PREVIEW_HEIGHTS = { 0, 3, 6 }; private final ChunkBlock addon; /** Client-side barrier blocks sent per player, with the original data for restore */ private final Map> barrierBlocks = new HashMap<>(); + /** Chunks outlined for a player pending claim confirmation */ + private final Map previews = new HashMap<>(); private BukkitTask task; private record BarrierBlock(Location location, BlockData oldData) { } + /** A pending claim outline: world chunk coordinates and when to stop drawing it */ + private record Preview(int chunkX, int chunkZ, long expiry) { + } + public BorderDisplay(ChunkBlock addon) { this.addon = addon; } @@ -80,6 +90,7 @@ public void stop() { barrierBlocks.keySet().stream().map(Bukkit::getPlayer).filter(java.util.Objects::nonNull) .toList().forEach(this::hideBorder); barrierBlocks.clear(); + previews.clear(); } private void redrawAll() { @@ -87,6 +98,7 @@ private void redrawAll() { if (addon.inWorld(world)) { for (Player player : world.getPlayers()) { showBorder(player); + drawPreview(player); } } } @@ -227,6 +239,61 @@ public void hideBorder(Player player) { } } + /** + * Outlines a chunk in yellow for one player until the given time, marking it as lined + * up for claiming but not yet paid for. Only that player sees it, and the outline + * disappears by itself when the confirmation window closes. + * + * @param player the player about to spend credit + * @param chunkX world chunk x coordinate of the previewed chunk + * @param chunkZ world chunk z coordinate of the previewed chunk + * @param expiry when to stop drawing, in {@link System#currentTimeMillis()} terms + */ + public void showPreview(Player player, int chunkX, int chunkZ, long expiry) { + previews.put(player.getUniqueId(), new Preview(chunkX, chunkZ, expiry)); + drawPreview(player); + } + + /** + * Stops outlining whatever chunk this player had lined up. + * + * @param uuid the player's UUID + */ + public void clearPreview(UUID uuid) { + previews.remove(uuid); + } + + /** + * Draws the pending claim outline for one player, if they have one that has not run out + * of time. The box is drawn around the player's own height so it reads as a wall even + * when the terrain beyond the border is far above or below them. + */ + private void drawPreview(Player player) { + Preview preview = previews.get(player.getUniqueId()); + if (preview == null) { + return; + } + if (System.currentTimeMillis() > preview.expiry()) { + previews.remove(player.getUniqueId()); + return; + } + // The outline traces the chunk's boundary planes, so the corners meet exactly where + // the four walls of the locked-chunk curtain would + double minX = preview.chunkX() << 4; + double minZ = preview.chunkZ() << 4; + double baseY = player.getLocation().getBlockY() + 0.5D; + Particle.DustOptions dust = new Particle.DustOptions(PREVIEW_COLOR, 1.5F); + for (int height : PREVIEW_HEIGHTS) { + double y = baseY + height; + for (int i = 0; i <= 16; i += 2) { + player.spawnParticle(Particle.DUST, minX + i, y, minZ, 1, 0, 0, 0, 0, dust); + player.spawnParticle(Particle.DUST, minX + i, y, minZ + 16, 1, 0, 0, 0, 0, dust); + player.spawnParticle(Particle.DUST, minX, y, minZ + i, 1, 0, 0, 0, 0, dust); + player.spawnParticle(Particle.DUST, minX + 16, y, minZ + i, 1, 0, 0, 0, 0, dust); + } + } + } + /** * One-shot green particle celebration along freshly unlocked chunks, visible to * everyone in the world near the island. @@ -264,6 +331,7 @@ public void celebrate(Island island, List gained) { @EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent e) { barrierBlocks.remove(e.getPlayer().getUniqueId()); + previews.remove(e.getPlayer().getUniqueId()); } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @@ -279,5 +347,6 @@ public void onRespawn(PlayerRespawnEvent e) { @EventHandler(priority = EventPriority.MONITOR) public void onChangedWorld(PlayerChangedWorldEvent e) { barrierBlocks.remove(e.getPlayer().getUniqueId()); + previews.remove(e.getPlayer().getUniqueId()); } } diff --git a/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java b/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java index b12cc4e..722fdea 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java @@ -124,11 +124,6 @@ private record BrushSession(BukkitTask task, Block block) {} */ public static final int MAX_LOOK_AHEAD = 5; - /** - * How often island data is saved to the database (in blocks broken). - */ - public static final int SAVE_EVERY = 50; - /* * Loot tables for suspicious blocks */ @@ -161,11 +156,25 @@ public BlockListener(@NonNull ChunkBlock addon) { /** * Saves all island data from the cache to the database asynchronously. + *

+ * Only safe while the server is running. On shutdown use {@link #saveCacheNow()}. */ public void saveCache() { cache.values().forEach(handler::saveObjectAsync); } + /** + * Saves all island data from the cache to the database on the calling thread. + *

+ * Used on shutdown, where an asynchronous save cannot be retried if it does not complete. + * BentoBox drains writes queued by addons as they are disabled, but this addon is a Pladdon, + * so the server disables it before BentoBox and that drain is the only thing standing between + * a queued block count and a rolled-back island. Writing directly removes the dependency. + */ + public void saveCacheNow() { + cache.values().forEach(handler::saveObjectNow); + } + // --------------------------------------------------------------------- // Section: Listeners // --------------------------------------------------------------------- @@ -448,7 +457,7 @@ private ProcessPhaseResult processPhase(Cancellable e, Island i, OneBlockIslands return new ProcessPhaseResult(phase, true, 0); } handleNewPhase(player, i, is, phase, block, prevPhaseName); - } else if (is.getBlockNumber() % SAVE_EVERY == 0) { + } else if (is.getBlockNumber() % addon.getSettings().getSaveEvery() == 0) { // Periodically save the island's progress. saveIsland(i); } diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java index e273059..15519e5 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java @@ -2,8 +2,10 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.UUID; +import java.util.function.LongSupplier; import org.bukkit.Location; import org.bukkit.Sound; @@ -14,19 +16,28 @@ import org.bukkit.event.Listener; import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.util.Vector; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.RanksManager; import world.bentobox.chunkblock.ChunkBlock; import world.bentobox.chunkblock.chunks.ChunkManager; import world.bentobox.chunkblock.chunks.ChunkManager.ClaimResult; /** - * Lets the island owner spend level credit by hitting the border: when they punch (or - * right-click) toward the locked chunk blocking them, that chunk is claimed and opens up. - * Expansion is the owner's choice, in any direction, up to the island's protection range. + * Lets an island spend level credit by hitting the border: when a player punches (or + * right-clicks) toward the locked chunk blocking them, that chunk is claimed and opens up. + * Expansion goes in any direction, up to the island's protection range. Who is allowed to + * spend is the CHUNKBLOCK_CLAIM_CHUNKS island setting, owner-only unless an island opens it + * to lower ranks. + *

+ * Levels are hard-won, so by default a claim takes two deliberate gestures: the first hit + * outlines the target chunk and quotes the price, and only a second hit made while sneaking + * spends the credit. Servers that prefer the old one-hit claim can switch confirmation off + * in the config. * * @author tastybento */ @@ -38,14 +49,46 @@ public class ChunkClaimListener implements Listener { private static final double STEP = 0.25; /** Minimum time between failure nags per player, so mining swings can't spam */ private static final long FEEDBACK_COOLDOWN_MS = 2000; + /** + * How long a preview must have been showing before a sneaking hit can confirm it. One + * physical swing can fire both LEFT_CLICK_AIR and LEFT_CLICK_BLOCK on the same tick, and + * without this gap that single swing would preview and pay in one go. + */ + private static final long CONFIRM_ARM_MS = 250; private final ChunkBlock addon; private final Map lastFeedback = new HashMap<>(); + /** Chunks each player has lined up but not yet paid for */ + private final Map pending = new HashMap<>(); + + /** + * A chunk a player has been quoted a price for, waiting on their confirming hit. + * + * @param islandId the island the chunk would join, so a preview cannot be confirmed + * on someone else's island + * @param chunkX world chunk x coordinate + * @param chunkZ world chunk z coordinate + * @param shownAt when the preview was raised + */ + private record PendingClaim(String islandId, int chunkX, int chunkZ, long shownAt) { + } + + /** Time source, overridable so tests need not sleep */ + private LongSupplier clock = System::currentTimeMillis; public ChunkClaimListener(ChunkBlock addon) { this.addon = addon; } + /** + * Replaces the time source used for confirmation windows and feedback throttling. + * + * @param clock supplier of the current time in milliseconds + */ + void setClock(LongSupplier clock) { + this.clock = clock; + } + /** * A left or right click aimed through the border claims the locked chunk on the other * side. Runs regardless of event cancellation because the protection listeners rightly @@ -67,8 +110,10 @@ public void onBorderHit(PlayerInteractEvent e) { return; } Island island = optionalIsland.get(); - // Claiming is the owner's call - if (!player.getUniqueId().equals(island.getOwner())) { + 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(); @@ -93,7 +138,34 @@ public void onBorderHit(PlayerInteractEvent e) { if (target == null) { return; } - attemptClaim(User.getInstance(player), island, target[0], target[1]); + attemptClaim(user, island, target[0], target[1]); + } + + /** + * Tells a teammate whose rank is too low that expansion is not theirs to spend on. + * Visitors and passers-by are told nothing: they get the ordinary locked-chunk message + * from the guard listener instead, and have no business hearing about the island's + * credit. + */ + private void denyClaim(User user, Island island) { + if (island.getRank(user) > RanksManager.VISITOR_RANK && feedbackReady(user.getUniqueId())) { + user.notify(addon.CHUNKBLOCK_CLAIM_CHUNKS.getHintReference()); + user.getPlayer().playSound(user.getLocation(), Sound.BLOCK_NOTE_BLOCK_BASS, 1F, 0.6F); + } + } + + /** + * Drops a player's pending preview when they log out, so it cannot be confirmed by + * whoever next holds that session. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent e) { + UUID uuid = e.getPlayer().getUniqueId(); + pending.remove(uuid); + lastFeedback.remove(uuid); + if (addon.getBorderDisplay() != null) { + addon.getBorderDisplay().clearPreview(uuid); + } } /** @@ -127,18 +199,31 @@ private int[] findTargetLockedChunk(Player player, Island island) { } /** - * Runs the claim and gives the player the appropriate feedback. + * Runs the claim and gives the player the appropriate feedback. With confirmation + * enabled a claimable chunk is only previewed the first time round; the credit is spent + * on the confirming hit. * - * @param user the island owner + * @param user the player spending the credit, already checked against the claim flag * @param island the island * @param chunkX target world chunk x * @param chunkZ target world chunk z */ public void attemptClaim(User user, Island island, int chunkX, int chunkZ) { ChunkManager cm = addon.getChunkManager(); + // Price the chunk before spending anything: only a claim that would actually go + // through is worth asking the player to confirm + if (addon.getSettings().isRequireClaimConfirmation() + && cm.checkGeometry(island, chunkX, chunkZ) == ClaimResult.OK + && cm.getCredit(island) >= cm.getChunkCost() && !confirming(user, island, chunkX, chunkZ)) { + preview(user, island, chunkX, chunkZ); + return; + } ClaimResult result = cm.claim(island, chunkX, chunkZ); switch (result) { - case OK -> addon.getLevelListener().celebrateClaim(island, chunkX, chunkZ); + case OK -> { + clearPending(user.getUniqueId()); + addon.getLevelListener().celebrateClaim(island, chunkX, chunkZ); + } case NO_CREDIT -> { if (feedbackReady(user.getUniqueId())) { long needed = cm.getChunkCost() - cm.getCredit(island); @@ -157,12 +242,71 @@ public void attemptClaim(User user, Island island, int chunkX, int chunkZ) { } } + /** + * Decides whether this hit is the confirming one: the player must be sneaking and + * aiming at the very chunk they were quoted for, on the same island, inside the + * confirmation window and no longer in the same swing that raised the preview. + */ + private boolean confirming(User user, Island island, int chunkX, int chunkZ) { + PendingClaim quote = pending.get(user.getUniqueId()); + if (quote == null || !user.getPlayer().isSneaking() + || !Objects.equals(quote.islandId(), island.getUniqueId()) || quote.chunkX() != chunkX + || quote.chunkZ() != chunkZ) { + return false; + } + long age = clock.getAsLong() - quote.shownAt(); + if (age >= timeoutMillis()) { + // The window closed; this hit re-prices the chunk instead of paying for it + pending.remove(user.getUniqueId()); + return false; + } + return age >= CONFIRM_ARM_MS; + } + + /** + * Quotes the price of a chunk and outlines it, arming the confirming hit. + */ + private void preview(User user, Island island, int chunkX, int chunkZ) { + ChunkManager cm = addon.getChunkManager(); + long now = clock.getAsLong(); + PendingClaim previous = pending.get(user.getUniqueId()); + pending.put(user.getUniqueId(), new PendingClaim(island.getUniqueId(), chunkX, chunkZ, now)); + if (addon.getBorderDisplay() != null) { + addon.getBorderDisplay().showPreview(user.getPlayer(), chunkX, chunkZ, now + timeoutMillis()); + } + // Swinging at the same chunk repeatedly keeps the outline alive but must not + // re-announce the price on every blow + boolean sameChunk = previous != null && previous.chunkX() == chunkX && previous.chunkZ() == chunkZ; + if (sameChunk && !feedbackReady(user.getUniqueId())) { + return; + } + // Aiming somewhere new always earns a fresh quote, but it still resets the throttle + // so the follow-up swings at that chunk stay quiet + lastFeedback.put(user.getUniqueId(), now); + long cost = cm.getChunkCost(); + user.notify("chunkblock.chunks.claim-confirm", "[cost]", String.valueOf(cost), "[after]", + String.valueOf(cm.getCredit(island) - cost), "[seconds]", + String.valueOf(addon.getSettings().getClaimConfirmationTimeout())); + user.getPlayer().playSound(user.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1F, 1.4F); + } + + private void clearPending(UUID uuid) { + pending.remove(uuid); + if (addon.getBorderDisplay() != null) { + addon.getBorderDisplay().clearPreview(uuid); + } + } + + private long timeoutMillis() { + return addon.getSettings().getClaimConfirmationTimeout() * 1000L; + } + /** * Rate-limits failure feedback: repeated swings while mining should not turn every * failed claim probe into a chat message and a sound. */ private boolean feedbackReady(UUID uuid) { - long now = System.currentTimeMillis(); + long now = clock.getAsLong(); Long last = lastFeedback.get(uuid); if (last != null && now - last < FEEDBACK_COOLDOWN_MS) { return false; diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ChunkGuardListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ChunkGuardListener.java index 750d4df..699a89c 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ChunkGuardListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ChunkGuardListener.java @@ -131,14 +131,14 @@ public void onPlayerMove(PlayerMoveEvent e) { } /** - * Tells a player bumping into the border what to do about it: owners aiming at a - * claimable chunk are invited to hit the border (or told how many levels they still - * need); everyone else just learns the chunk is locked. + * Tells a player bumping into the border what to do about it: those allowed to spend + * the island's credit on a claimable chunk are invited to hit the border (or told how + * many levels they still need); everyone else just learns the chunk is locked. */ private void sendBumpMessage(Player player, Location to) { User user = User.getInstance(player); Optional optionalIsland = islandAt(to); - if (optionalIsland.isPresent() && player.getUniqueId().equals(optionalIsland.get().getOwner())) { + if (optionalIsland.isPresent() && optionalIsland.get().isAllowed(user, addon.CHUNKBLOCK_CLAIM_CHUNKS)) { Island island = optionalIsland.get(); ChunkManager cm = addon.getChunkManager(); if (cm.checkGeometry(island, to.getBlockX() >> 4, to.getBlockZ() >> 4) == ChunkManager.ClaimResult.OK) { diff --git a/src/main/resources/addon.yml b/src/main/resources/addon.yml index 257373a..1904e32 100755 --- a/src/main/resources/addon.yml +++ b/src/main/resources/addon.yml @@ -1,7 +1,7 @@ name: ChunkBlock main: world.bentobox.chunkblock.ChunkBlock version: ${version}${build.number} -api-version: 3.13.0 +api-version: 3.22.0 metrics: true icon: "STONE" repository: "BentoBoxWorld/ChunkBlock" diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 3897aa6..dae6704 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -56,6 +56,15 @@ chunkblock: # island protection range can hold. The effective maximum is always capped so # claimed chunks fit inside the protection range. max-chunks: 441 + claim: + # Require confirmation before level credit is spent on a chunk. When true, the + # first hit on the border previews the target chunk and its cost; the player + # must then sneak and hit the border again to actually claim it. This stops a + # stray swing near the border from spending levels by accident. + require-confirmation: true + # 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 # 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 @@ -400,6 +409,7 @@ world: CHORUS_FRUIT: 500 CONTAINER: 500 MAGIC_BLOCK: 200 + CHUNKBLOCK_CLAIM_CHUNKS: 1000 JUKEBOX: 500 POTION_THROWING: 500 BARREL: 500 @@ -446,6 +456,11 @@ island: mob-warning: 5 # Whether spawned mobs that need water to survive will spawn in a generated water block water-mob-protection: true + # How often island progress is written to the database, in blocks broken + # Progress is also saved whenever a phase changes, a player logs out and the server shuts down, + # so this only decides how much is lost if the server dies without shutting down cleanly. + # Lower is safer but writes more often. Minimum is 1 (save every block) + save-every: 10 # Default max team size # Permission size cannot be less than the default below. max-team-size: 4 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index aae8140..fa4282d 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -11,6 +11,13 @@ protection: &b Rank that can break the magic &b block if they can break blocks. hint: "&c Your rank cannot break the magic block!" + CHUNKBLOCK_CLAIM_CHUNKS: + name: Claim Chunks + description: |- + &b Rank that can spend the + &b island's level credit to + &b claim new chunks. + hint: "&c Your rank cannot claim chunks for this island!" CHUNKBLOCK_START_SAFETY: name: Starting Safety description: |- @@ -36,6 +43,7 @@ chunkblock: entry-denied: "&c That chunk is locked." locked: "&c You can't touch that — the chunk is locked." claim-hint: "&e Hit the border to claim this chunk for &b [cost] &e level(s)! You have &b [credit] &e level(s) of credit." + claim-confirm: "&e Claim this chunk for &b [cost] &e level(s)? That leaves you &b [after] &e level(s) of credit. &6Sneak and hit the border again &e within &b [seconds]s &e to confirm." no-credit: "&c You need &b [needed] &c more level(s) of credit to claim this chunk." beyond-limit: "&c That chunk is beyond your island's protection area." claimed: "&a &l Chunk claimed! &r&a Your island is now &b [number] &a chunks. Credit left: &b [credit] &a level(s)." diff --git a/src/test/java/world/bentobox/chunkblock/SettingsTest.java b/src/test/java/world/bentobox/chunkblock/SettingsTest.java index bc2a554..30cf8f1 100644 --- a/src/test/java/world/bentobox/chunkblock/SettingsTest.java +++ b/src/test/java/world/bentobox/chunkblock/SettingsTest.java @@ -1747,6 +1747,35 @@ void testSetHologramDuration() { s.setHologramDuration(2345); assertEquals(2345, s.getHologramDuration()); } + + /** + * Test method for {@link world.bentobox.chunkblock.Settings#getSaveEvery()}. + */ + @Test + void testGetSaveEveryDefault() { + assertEquals(10, s.getSaveEvery()); + } + + /** + * Test method for {@link world.bentobox.chunkblock.Settings#setSaveEvery(int)}. + */ + @Test + void testSetSaveEvery() { + s.setSaveEvery(25); + assertEquals(25, s.getSaveEvery()); + } + + /** + * The value is used as a modulo divisor, so anything below 1 has to be clamped or + * the block break handler would throw an ArithmeticException on every block. + */ + @Test + void testGetSaveEveryClampsZeroAndBelow() { + s.setSaveEvery(0); + assertEquals(1, s.getSaveEvery()); + s.setSaveEvery(-50); + assertEquals(1, s.getSaveEvery()); + } diff --git a/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest.java index 7e4194a..2eb6844 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest.java @@ -5,6 +5,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.File; @@ -49,6 +51,8 @@ public class BlockListenerTest extends CommonTestSetup { // Class under test private BlockListener bl; + private AbstractDatabaseHandler h; + @Mock ChunkBlock addon; @Mock @@ -78,13 +82,14 @@ public class BlockListenerTest extends CommonTestSetup { public void setUp() throws Exception { super.setUp(); // This has to be done beforeClass otherwise the tests will interfere with each other - AbstractDatabaseHandler h = mock(AbstractDatabaseHandler.class); + h = mock(AbstractDatabaseHandler.class); // Database MockedStatic mockDb = Mockito.mockStatic(DatabaseSetup.class); DatabaseSetup dbSetup = mock(DatabaseSetup.class); mockDb.when(DatabaseSetup::getDatabase).thenReturn(dbSetup); when(dbSetup.getHandler(any())).thenReturn(h); when(h.saveObject(any())).thenReturn(CompletableFuture.completedFuture(true)); + when(h.saveObjectNow(any())).thenReturn(CompletableFuture.completedFuture(true)); // Addon when(addon.getPlugin()).thenReturn(plugin); @@ -187,4 +192,36 @@ void testOnBlockFromToCenterBlock() { assertTrue(e.isCancelled()); } + /** + * Test method for {@link world.bentobox.chunkblock.listeners.BlockListener#saveCache()}. + */ + @Test + void testSaveCacheQueuesTheWrite() throws Exception { + island.setUniqueId(UUID.randomUUID().toString()); + bl.getIsland(island); + + bl.saveCache(); + + verify(h).saveObject(any()); + verify(h, never()).saveObjectNow(any()); + } + + /** + * The shutdown save has to write directly. This addon is a Pladdon, so the server disables it + * before BentoBox, and a queued write only lands if BentoBox drains the queue afterwards - + * which older BentoBox versions did not do, silently rolling islands back on every restart. + * + * Test method for {@link world.bentobox.chunkblock.listeners.BlockListener#saveCacheNow()}. + */ + @Test + void testSaveCacheNowWritesDirectly() throws Exception { + island.setUniqueId(UUID.randomUUID().toString()); + bl.getIsland(island); + + bl.saveCacheNow(); + + verify(h).saveObjectNow(any()); + verify(h, never()).saveObject(any()); + } + } diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java index 2e8d19b..949228c 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java @@ -4,8 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -13,6 +18,7 @@ import java.util.Optional; import java.util.UUID; +import net.kyori.adventure.text.Component; import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.block.Block; @@ -22,9 +28,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.managers.RanksManager; 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.dataobjects.OneBlockIslands; @@ -37,26 +46,35 @@ class ChunkClaimListenerTest extends CommonTestSetup { private ChunkClaimListener listener; private OneBlockIslands data; private LevelListener levelListener; + private BorderDisplay borderDisplay; + private Settings settings; private long level; + /** Virtual clock the listener reads, so confirmation windows need no sleeping */ + private long now; @Override @BeforeEach public void setUp() throws Exception { super.setUp(); - addon = mock(ChunkBlock.class); - when(addon.getPlugin()).thenReturn(plugin); - when(addon.inWorld(world)).thenReturn(true); - when(addon.getIslands()).thenReturn(im); - when(addon.getSettings()).thenReturn(new Settings()); + // A spy on a real addon, so the CHUNKBLOCK_CLAIM_CHUNKS flag field is built + addon = spy(new ChunkBlock()); + doReturn(plugin).when(addon).getPlugin(); + doReturn(true).when(addon).inWorld(world); + doReturn(im).when(addon).getIslands(); + settings = new Settings(); + addon.setSettings(settings); + borderDisplay = mock(BorderDisplay.class); + doReturn(borderDisplay).when(addon).getBorderDisplay(); ChunkManager cm = new ChunkManager(addon); - when(addon.getChunkManager()).thenReturn(cm); + doReturn(cm).when(addon).getChunkManager(); data = new OneBlockIslands("test"); - when(addon.getOneBlocksIsland(island)).thenReturn(data); - when(addon.getBlockListener()).thenReturn(mock(BlockListener.class)); + doReturn(data).when(addon).getOneBlocksIsland(island); + BlockListener blockListener = mock(BlockListener.class); + doReturn(blockListener).when(addon).getBlockListener(); levelListener = mock(LevelListener.class); - when(addon.getLevelListener()).thenReturn(levelListener); + doReturn(levelListener).when(addon).getLevelListener(); level = 0; - when(addon.getIslandLevel(island)).thenAnswer(i -> level); + doAnswer(i -> level).when(addon).getIslandLevel(island); // Island center chunk (0, 0) when(island.getCenter()).thenReturn(location); @@ -65,6 +83,8 @@ public void setUp() throws Exception { when(island.getProtectionRange()).thenReturn(240); when(island.getOwner()).thenReturn(uuid); when(im.getIslandAt(any())).thenReturn(Optional.of(island)); + // By default the player may claim: rank checks have their own tests + when(island.isAllowed(any(User.class), eq(addon.CHUNKBLOCK_CLAIM_CHUNKS))).thenReturn(true); // Player stands near the east edge of the center chunk, looking east (+x): // yaw -90 in Bukkit faces +x @@ -74,6 +94,8 @@ public void setUp() throws Exception { when(mockPlayer.getGameMode()).thenReturn(GameMode.SURVIVAL); listener = new ChunkClaimListener(addon); + now = 1_000_000L; + listener.setClock(() -> now); } private PlayerInteractEvent hit(Action action) { @@ -81,10 +103,22 @@ private PlayerInteractEvent hit(Action action) { EquipmentSlot.HAND); } + /** + * Performs the whole default gesture: a hit to preview the chunk, then a sneaking hit a + * second later to pay for it. + */ + private void hitAndConfirm(Action action) { + listener.onBorderHit(hit(action)); + now += 1000; + when(mockPlayer.isSneaking()).thenReturn(true); + listener.onBorderHit(hit(action)); + when(mockPlayer.isSneaking()).thenReturn(false); + } + @Test void testOwnerPunchingBorderClaimsChunk() { level = 1; - listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + hitAndConfirm(Action.LEFT_CLICK_AIR); assertTrue(data.isChunkUnlocked(1, 0)); verify(levelListener).celebrateClaim(island, 1, 0); } @@ -92,7 +126,7 @@ void testOwnerPunchingBorderClaimsChunk() { @Test void testRightClickAlsoClaims() { level = 1; - listener.onBorderHit(hit(Action.RIGHT_CLICK_AIR)); + hitAndConfirm(Action.RIGHT_CLICK_AIR); assertTrue(data.isChunkUnlocked(1, 0)); } @@ -107,11 +141,34 @@ void testNoCreditNoClaim() { } @Test - void testNonOwnerCannotClaim() { + void testPlayerBelowTheClaimRankCannotClaim() { 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); + hitAndConfirm(Action.LEFT_CLICK_AIR); + assertFalse(data.isChunkUnlocked(1, 0)); + // A teammate who cannot claim is told why rather than left wondering + verify(notifier).notify(any(), eq("protection.flags.CHUNKBLOCK_CLAIM_CHUNKS.hint")); + } + + @Test + void testTeammateAtOrAboveTheClaimRankCanClaim() { + // The island has opened claiming up: a member who is not the owner may spend + level = 1; when(island.getOwner()).thenReturn(UUID.randomUUID()); + when(island.getRank(any(User.class))).thenReturn(RanksManager.MEMBER_RANK); + hitAndConfirm(Action.LEFT_CLICK_AIR); + assertTrue(data.isChunkUnlocked(1, 0)); + } + + @Test + void testVisitorIsNotToldAboutTheIslandsCredit() { + level = 100; + when(island.isAllowed(any(User.class), eq(addon.CHUNKBLOCK_CLAIM_CHUNKS))).thenReturn(false); + when(island.getRank(any(User.class))).thenReturn(RanksManager.VISITOR_RANK); listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); assertFalse(data.isChunkUnlocked(1, 0)); + verify(notifier, never()).notify(any(), any()); } @Test @@ -136,12 +193,12 @@ void testAimingAwayFromBorderDoesNothing() { @Test void testClaimingChainsOutward() { level = 2; - listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + hitAndConfirm(Action.LEFT_CLICK_AIR); assertTrue(data.isChunkUnlocked(1, 0)); // Move to the east edge of the newly claimed chunk and punch again when(mockPlayer.getLocation()).thenReturn(new Location(world, 30.5, 65, 8.5, -90F, 0F)); when(mockPlayer.getEyeLocation()).thenReturn(new Location(world, 30.5, 66.6, 8.5, -90F, 0F)); - listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + hitAndConfirm(Action.LEFT_CLICK_AIR); assertTrue(data.isChunkUnlocked(2, 0)); } @@ -167,6 +224,9 @@ void testMiningOwnBlockNearBorderIsNotAClaim() { void testPunchingBlockInLockedChunkClaimsIt() { level = 1; listener.onBorderHit(hitBlock(Action.LEFT_CLICK_BLOCK, 17, 8)); + now += 1000; + when(mockPlayer.isSneaking()).thenReturn(true); + listener.onBorderHit(hitBlock(Action.LEFT_CLICK_BLOCK, 17, 8)); assertTrue(data.isChunkUnlocked(1, 0)); } @@ -189,4 +249,108 @@ void testAimLineBlockedBySolidWallDoesNotClaim() { listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); assertFalse(data.isChunkUnlocked(1, 0)); } + + // ------------------------------------------------------------------ + // Claim confirmation + // ------------------------------------------------------------------ + + @Test + void testFirstHitOnlyPreviewsAndSpendsNothing() { + level = 1; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + assertFalse(data.isChunkUnlocked(1, 0)); + verify(levelListener, never()).celebrateClaim(any(), anyInt(), anyInt()); + // The player is quoted a price and shown which chunk they are buying + verify(notifier).notify(any(), eq("chunkblock.chunks.claim-confirm")); + verify(borderDisplay).showPreview(eq(mockPlayer), eq(1), eq(0), anyLong()); + } + + @Test + void testSecondHitWithoutSneakingDoesNotClaim() { + level = 1; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + now += 5000; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + assertFalse(data.isChunkUnlocked(1, 0)); + } + + @Test + void testDoubleFireOfOneSwingDoesNotClaim() { + // A single swing can raise both LEFT_CLICK_AIR and LEFT_CLICK_BLOCK on the same + // tick: the arming delay must stop that from previewing and paying at once + level = 1; + when(mockPlayer.isSneaking()).thenReturn(true); + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + assertFalse(data.isChunkUnlocked(1, 0)); + } + + @Test + void testConfirmationExpires() { + level = 1; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + // Well past the confirmation window + now += settings.getClaimConfirmationTimeout() * 1000L + 1; + when(mockPlayer.isSneaking()).thenReturn(true); + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + // The stale quote is re-priced rather than paid + assertFalse(data.isChunkUnlocked(1, 0)); + // Hitting again inside the fresh window does claim it + now += 1000; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + assertTrue(data.isChunkUnlocked(1, 0)); + } + + @Test + void testConfirmingWhileAimingAtADifferentChunkDoesNotClaimEither() { + level = 2; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + // Turn to the south border (yaw 0 faces +z) and sneak-hit: the east chunk was the + // one quoted, so this is a fresh preview, not a confirmation + now += 1000; + when(mockPlayer.getLocation()).thenReturn(new Location(world, 8.5, 65, 14.5, 0F, 0F)); + when(mockPlayer.getEyeLocation()).thenReturn(new Location(world, 8.5, 66.6, 14.5, 0F, 0F)); + when(mockPlayer.isSneaking()).thenReturn(true); + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + assertFalse(data.isChunkUnlocked(1, 0)); + assertFalse(data.isChunkUnlocked(0, 1)); + // Confirming that new quote claims the north chunk and leaves the east one locked + now += 1000; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + assertTrue(data.isChunkUnlocked(0, 1)); + assertFalse(data.isChunkUnlocked(1, 0)); + } + + @Test + void testPreviewIsNotReAnnouncedOnEverySwing() { + level = 1; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + now += 100; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + now += 100; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + verify(notifier, times(1)).notify(any(), eq("chunkblock.chunks.claim-confirm")); + } + + @Test + void testQuitDropsThePendingClaim() { + level = 1; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(mockPlayer, (Component) null)); + verify(borderDisplay).clearPreview(uuid); + now += 1000; + when(mockPlayer.isSneaking()).thenReturn(true); + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + // Nothing left to confirm, so the hit only re-previews + assertFalse(data.isChunkUnlocked(1, 0)); + } + + @Test + void testConfirmationCanBeSwitchedOff() { + settings.setRequireClaimConfirmation(false); + level = 1; + listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); + assertTrue(data.isChunkUnlocked(1, 0)); + verify(borderDisplay, never()).showPreview(any(), anyInt(), anyInt(), anyLong()); + } } diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ChunkGuardListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkGuardListenerTest.java index 7f0d3bf..ba0b00d 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkGuardListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkGuardListenerTest.java @@ -3,8 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -25,6 +28,7 @@ import com.google.common.collect.ImmutableSet; +import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.managers.RanksManager; import world.bentobox.bentobox.util.Util; import world.bentobox.chunkblock.ChunkBlock; @@ -48,16 +52,17 @@ class ChunkGuardListenerTest extends CommonTestSetup { @BeforeEach public void setUp() throws Exception { super.setUp(); - addon = mock(ChunkBlock.class); - when(addon.getPlugin()).thenReturn(plugin); - when(addon.inWorld(world)).thenReturn(true); - when(addon.getIslands()).thenReturn(im); + // A spy on a real addon, so the CHUNKBLOCK_CLAIM_CHUNKS flag field is built + addon = spy(new ChunkBlock()); + doReturn(plugin).when(addon).getPlugin(); + doReturn(true).when(addon).inWorld(world); + doReturn(im).when(addon).getIslands(); Settings settings = new Settings(); - when(addon.getSettings()).thenReturn(settings); + addon.setSettings(settings); ChunkManager cm = new ChunkManager(addon); - when(addon.getChunkManager()).thenReturn(cm); + doReturn(cm).when(addon).getChunkManager(); data = new OneBlockIslands("test"); - when(addon.getOneBlocksIsland(island)).thenReturn(data); + doReturn(data).when(addon).getOneBlocksIsland(island); // Island centered at block (8, y, 8) → center chunk (0, 0); only that chunk unlocked when(island.getCenter()).thenReturn(location); @@ -95,6 +100,22 @@ void testMoveIntoLockedChunkCancelledAndTeleportedBack() { mockedUtil.verify(() -> Util.teleportAsync(mockPlayer, location)); } + @Test + void testBumpInvitesAPlayerAllowedToClaim() { + when(island.isAllowed(any(User.class), eq(addon.CHUNKBLOCK_CLAIM_CHUNKS))).thenReturn(true); + doReturn(100L).when(addon).getIslandLevel(island); + listener.onPlayerMove(new PlayerMoveEvent(mockPlayer, location, lockedTo)); + verify(notifier).notify(any(), eq("chunkblock.chunks.claim-hint")); + } + + @Test + void testBumpJustSaysLockedToAPlayerWhoCannotClaim() { + // island.isAllowed is false by default in CommonTestSetup + doReturn(100L).when(addon).getIslandLevel(island); + listener.onPlayerMove(new PlayerMoveEvent(mockPlayer, location, lockedTo)); + verify(notifier).notify(any(), eq("chunkblock.chunks.entry-denied")); + } + @Test void testMoveWithinUnlockedChunkAllowed() { PlayerMoveEvent e = new PlayerMoveEvent(mockPlayer, location, unlockedTo); diff --git a/src/test/java/world/bentobox/chunkblock/panels/PhasesPanelTest.java b/src/test/java/world/bentobox/chunkblock/panels/PhasesPanelTest.java index c417882..78ab591 100644 --- a/src/test/java/world/bentobox/chunkblock/panels/PhasesPanelTest.java +++ b/src/test/java/world/bentobox/chunkblock/panels/PhasesPanelTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; @@ -71,6 +72,24 @@ class PhasesPanelTest extends CommonTestSetup { private PhasesPanel panel; + /** + * Sets what a locale reference translates to for these tests. + *

+ * The {@code user} here is a real {@link User} wrapping a mock player, not a mock, so + * {@code when(user.getTranslation(...))} does not stub anything on it - it runs the real + * method and Mockito attaches the stub to whichever mock that method happened to touch last. + * That is an implementation detail of BentoBox and moves between versions. Stub the + * {@link world.bentobox.bentobox.managers.LocalesManager} that {@code getTranslation} actually + * reads from instead, which is stable. + * + * @param reference locale key, without any addon prefix + * @param value what it should translate to + */ + private void stubTranslation(String reference, String value) { + when(lm.get(any(), eq(reference))).thenReturn(value); + when(lm.get(any(), eq("chunkblock." + reference))).thenReturn(value); + } + private void setUpAddonMocks() { when(addon.getPlugin()).thenReturn(plugin); when(addon.getOneBlockManager()).thenReturn(oneBlockManager); @@ -325,10 +344,9 @@ void testBuildBlocksText() throws Exception { OneBlockPhase phase = createTestPhase("Plains"); - when(user.getTranslation("chunkblock.gui.buttons.phase.blocks-prefix")).thenReturn("Blocks: "); - when(user.getTranslation("chunkblock.gui.buttons.phase.wrap-at")).thenReturn("50"); - when(user.getTranslation("chunkblock.gui.buttons.phase.blocks", "name", "Stone")).thenReturn("Stone, "); - when(user.getTranslation("chunkblock.gui.buttons.phase.blocks", "name", "Dirt")).thenReturn("Dirt, "); + stubTranslation("chunkblock.gui.buttons.phase.blocks-prefix", "Blocks: "); + stubTranslation("chunkblock.gui.buttons.phase.wrap-at", "50"); + stubTranslation("chunkblock.gui.buttons.phase.blocks", "[name], "); when(hooksManager.getHook("LangUtils")).thenReturn(Optional.empty()); mockedUtil.when(() -> Util.prettifyText(anyString())).thenAnswer(i -> { String arg = i.getArgument(0); @@ -827,7 +845,7 @@ void testCollectTooltipsWithRealTooltip() throws Exception { new ItemTemplateRecord.ActionRecords(ClickType.LEFT, "SELECT", "content", "tooltip.key") ); - when(user.getTranslation(world, "tooltip.key")).thenReturn("Real tooltip"); + stubTranslation("tooltip.key", "Real tooltip"); Method method = PhasesPanel.class.getDeclaredMethod("collectTooltips", List.class); method.setAccessible(true); @@ -1482,8 +1500,8 @@ void testCollectTooltipsAllBlank() throws Exception { new ItemTemplateRecord.ActionRecords(ClickType.LEFT, "VIEW", "content", "tooltip2") ); - when(user.getTranslation(world, "tooltip1")).thenReturn(" "); // Blank after translation - when(user.getTranslation(world, "tooltip2")).thenReturn(""); // Empty + stubTranslation("tooltip1", " "); // Blank after translation + stubTranslation("tooltip2", ""); // Empty Method method = PhasesPanel.class.getDeclaredMethod("collectTooltips", List.class); method.setAccessible(true); @@ -2144,8 +2162,7 @@ void testBuildDescriptionTextTemplatedWithBiome() throws Exception { try (MockedStatic ms = mockStatic(LangUtilsHook.class)) { ms.when(() -> LangUtilsHook.getBiomeName(biome, user)).thenReturn("Plains"); - when(user.getTranslationOrNothing("custom.desc", "number", "0", "[biome]", "Plains", "[bank]", "", "[economy]", "", "[level]", "", "[permission]", "", "[blocks]", "")) - .thenReturn("Plains Description"); + stubTranslation("custom.desc", "[biome] Description"); Method method = PhasesPanel.class.getDeclaredMethod("buildDescriptionText", ItemTemplateRecord.class, OneBlockPhase.class, reqTextClass, String.class); method.setAccessible(true); @@ -2179,9 +2196,8 @@ void testBuildDefaultDescription() throws Exception { reqConstructor.setAccessible(true); Object reqTexts = reqConstructor.newInstance("", "", "", ""); - when(user.getTranslationOrNothing("chunkblock.gui.buttons.phase.starting-block", "number", "0")).thenReturn("Block 0"); - when(user.getTranslationOrNothing("chunkblock.gui.buttons.phase.description", "[starting-block]", "Block 0", "[biome]", "", "[bank]", "", "[economy]", "", "[level]", "", "[permission]", "", "[blocks]", "")) - .thenReturn("Default Desc"); + stubTranslation("chunkblock.gui.buttons.phase.starting-block", "Block [number]"); + stubTranslation("chunkblock.gui.buttons.phase.description", "Default Desc [starting-block]"); Method method = PhasesPanel.class.getDeclaredMethod("buildDefaultDescription", OneBlockPhase.class, reqTextClass, String.class); method.setAccessible(true); @@ -2398,10 +2414,9 @@ void testBuildDefaultDescriptionWithBiome() throws Exception { reqConstructor.setAccessible(true); Object reqTexts = reqConstructor.newInstance("", "", "", ""); - when(user.getTranslationOrNothing("chunkblock.gui.buttons.phase.starting-block", "number", "0")).thenReturn("Block 0"); - when(user.getTranslationOrNothing("chunkblock.gui.buttons.phase.biome", "[biome]", "Plains")).thenReturn("Biome: Plains"); - when(user.getTranslationOrNothing("chunkblock.gui.buttons.phase.description", "[starting-block]", "Block 0", "[biome]", "Biome: Plains", "[bank]", "", "[economy]", "", "[level]", "", "[permission]", "", "[blocks]", "")) - .thenReturn("Description with biome"); + stubTranslation("chunkblock.gui.buttons.phase.starting-block", "Block [number]"); + stubTranslation("chunkblock.gui.buttons.phase.biome", "Biome: [biome]"); + stubTranslation("chunkblock.gui.buttons.phase.description", "Description with biome [biome]"); try (MockedStatic ms = mockStatic(LangUtilsHook.class)) { ms.when(() -> LangUtilsHook.getBiomeName(biome, user)).thenReturn("Plains"); @@ -2440,9 +2455,8 @@ void testBuildDescriptionTextNullTemplate() throws Exception { reqConstructor.setAccessible(true); Object reqTexts = reqConstructor.newInstance("", "", "", ""); - when(user.getTranslationOrNothing("chunkblock.gui.buttons.phase.starting-block", "number", "0")).thenReturn("Block 0"); - when(user.getTranslationOrNothing("chunkblock.gui.buttons.phase.description", "[starting-block]", "Block 0", "[biome]", "", "[bank]", "", "[economy]", "", "[level]", "", "[permission]", "", "[blocks]", "")) - .thenReturn("Default Description"); + stubTranslation("chunkblock.gui.buttons.phase.starting-block", "Block [number]"); + stubTranslation("chunkblock.gui.buttons.phase.description", "Default Description [starting-block]"); Method method = PhasesPanel.class.getDeclaredMethod("buildDescriptionText", ItemTemplateRecord.class, OneBlockPhase.class, reqTextClass, String.class); method.setAccessible(true);