From 3367f4029ea17af9780063c0fb4da677a23cc4b6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:05:27 -0700 Subject: [PATCH 1/2] Confirm chunk claims before spending level credit Hitting the border spent the island's levels on the first swing, so a stray click near the frontier could cost a chunk's worth of credit with no way back. Claiming is now two deliberate gestures: the first hit outlines the target chunk and quotes its price, and only a second hit made while sneaking pays for it. A hit that could not claim anyway (no credit, beyond the protection range, not adjacent) skips the preview and gives the same feedback as before, so players are only asked to confirm a purchase that would actually go through. Sneaking, rather than simply hitting twice, is what separates the confirming gesture from an ordinary swing: mining cadence puts a second hit ~250ms after the first, which would confirm on its own. A short arming delay sits underneath that because one physical swing can raise both LEFT_CLICK_AIR and LEFT_CLICK_BLOCK on the same tick, and without it that single swing would preview and pay at once. The outline lives in BorderDisplay and rides the existing redraw task, so it persists for the whole confirmation window and expires by itself rather than needing a scheduler of its own. Quotes are bound to island, chunk and time: aiming elsewhere re-prices, an expired window re-prices, and logging out drops the pending claim. Repeat swings at the same chunk reuse the existing feedback throttle so the quote is announced once, not once per blow; that throttle never stamped its timestamp on the first message, which is fixed here too. Configurable via chunkblock.claim.require-confirmation (default true) and chunkblock.claim.confirmation-timeout (15s), so servers preferring the old one-hit claim can switch it off. Part of #6 (the confirmation step only; rank-based claim permission is still to come). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Taa2snoHkvtcZ6e5rAHDQS --- .../world/bentobox/chunkblock/Settings.java | 40 +++++ .../chunkblock/chunks/BorderDisplay.java | 67 +++++++++ .../listeners/ChunkClaimListener.java | 132 +++++++++++++++- src/main/resources/config.yml | 9 ++ src/main/resources/locales/en-US.yml | 1 + .../listeners/ChunkClaimListenerTest.java | 141 +++++++++++++++++- 6 files changed, 382 insertions(+), 8 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/Settings.java b/src/main/java/world/bentobox/chunkblock/Settings.java index f8fd0ec..55ed14d 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") @@ -2569,6 +2581,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..62f4c1b 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,59 @@ 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; + } + int minX = preview.chunkX() << 4; + int minZ = preview.chunkZ() << 4; + int baseY = player.getLocation().getBlockY(); + Particle.DustOptions dust = new Particle.DustOptions(PREVIEW_COLOR, 1.5F); + for (int height : PREVIEW_HEIGHTS) { + double y = baseY + height + 0.5D; + 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 +329,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 +345,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/ChunkClaimListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java index e273059..bc710b5 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,6 +16,7 @@ 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; @@ -27,6 +30,11 @@ * 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. + *

+ * 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 +46,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 @@ -96,6 +136,20 @@ public void onBorderHit(PlayerInteractEvent e) { attemptClaim(User.getInstance(player), island, target[0], target[1]); } + /** + * 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); + } + } + /** * Walks a short ray along the player's line of sight and returns the first locked * chunk it enters, or null if the player is not aiming through the border. @@ -127,7 +181,9 @@ 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 island the island @@ -136,9 +192,20 @@ private int[] findTargetLockedChunk(Player player, Island island) { */ 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 +224,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/resources/config.yml b/src/main/resources/config.yml index 3897aa6..9694d78 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 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index aae8140..d1087d4 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -36,6 +36,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/listeners/ChunkClaimListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java index 2e8d19b..1a0c8e0 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java @@ -4,6 +4,8 @@ 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.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -25,6 +27,7 @@ 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,7 +40,11 @@ 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 @@ -47,7 +54,10 @@ public void setUp() throws Exception { when(addon.getPlugin()).thenReturn(plugin); when(addon.inWorld(world)).thenReturn(true); when(addon.getIslands()).thenReturn(im); - when(addon.getSettings()).thenReturn(new Settings()); + settings = new Settings(); + when(addon.getSettings()).thenReturn(settings); + borderDisplay = mock(BorderDisplay.class); + when(addon.getBorderDisplay()).thenReturn(borderDisplay); ChunkManager cm = new ChunkManager(addon); when(addon.getChunkManager()).thenReturn(cm); data = new OneBlockIslands("test"); @@ -74,6 +84,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 +93,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 +116,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)); } @@ -136,12 +160,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 +191,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 +216,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, (String) 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()); + } } From c087383506c96937bae747d46b486f0fdfb33048 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:40:18 -0700 Subject: [PATCH 2/2] Address SonarCloud findings in the claim preview The preview outline computed its particle coordinates with int arithmetic before widening to the double parameters, which Sonar reports as a reliability bug. Hold the chunk origin as a double so the whole expression is double, and fold the half-block offset into the base height rather than recomputing it per row. Also in the tests: use the Component overload of PlayerQuitEvent instead of the String one, which is deprecated for removal, and hoist an inline mock into a local. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Taa2snoHkvtcZ6e5rAHDQS --- .../bentobox/chunkblock/chunks/BorderDisplay.java | 10 ++++++---- .../chunkblock/listeners/ChunkClaimListenerTest.java | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java b/src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java index 62f4c1b..b971fc3 100644 --- a/src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java +++ b/src/main/java/world/bentobox/chunkblock/chunks/BorderDisplay.java @@ -277,12 +277,14 @@ private void drawPreview(Player player) { previews.remove(player.getUniqueId()); return; } - int minX = preview.chunkX() << 4; - int minZ = preview.chunkZ() << 4; - int baseY = player.getLocation().getBlockY(); + // 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 + 0.5D; + 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); diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java index 1a0c8e0..c5da93b 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java @@ -15,6 +15,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; @@ -62,7 +63,8 @@ public void setUp() throws Exception { when(addon.getChunkManager()).thenReturn(cm); data = new OneBlockIslands("test"); when(addon.getOneBlocksIsland(island)).thenReturn(data); - when(addon.getBlockListener()).thenReturn(mock(BlockListener.class)); + BlockListener blockListener = mock(BlockListener.class); + when(addon.getBlockListener()).thenReturn(blockListener); levelListener = mock(LevelListener.class); when(addon.getLevelListener()).thenReturn(levelListener); level = 0; @@ -303,7 +305,7 @@ void testPreviewIsNotReAnnouncedOnEverySwing() { void testQuitDropsThePendingClaim() { level = 1; listener.onBorderHit(hit(Action.LEFT_CLICK_AIR)); - listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(mockPlayer, (String) null)); + listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(mockPlayer, (Component) null)); verify(borderDisplay).clearPreview(uuid); now += 1000; when(mockPlayer.isSneaking()).thenReturn(true);