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..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/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..c5da93b 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; @@ -13,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; @@ -25,6 +28,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 +41,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,12 +55,16 @@ 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"); 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; @@ -74,6 +86,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 +95,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 +118,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 +162,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 +193,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 +218,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()); + } }