From f09cd86a8fb78276c12244c4090448aeda8369c6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Wed, 29 Jul 2026 21:25:17 -0700 Subject: [PATCH 1/7] Remove AOneBlock's CurseForge id from the publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit curseforge_id 1512493 was AOneBlock's project id, left over when the workflow was copied — publishing ChunkBlock releases sent the jar to AOneBlock's CurseForge page (the stray file has been archived there). ChunkBlock has no CurseForge or Hangar project yet, so both ids are now blank, which makes the shared workflow skip those platforms. The TODO in the file lists what to fill in once the projects exist. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y7xSdVPS5vRu6wqf6XshRq --- .github/workflows/publish.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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: From 3367f4029ea17af9780063c0fb4da677a23cc4b6 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:05:27 -0700 Subject: [PATCH 2/7] 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 f991a0077594810c47fc99a6384a84fb4b0bdd65 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:35:11 -0700 Subject: [PATCH 3/7] fix: do not lose island progress when the server restarts Ported from AOneBlock, which this addon is forked from and shares the bug with verbatim - same code, same line numbers. See BentoBoxWorld/AOneBlock#550. A player breaking blocks and then sitting through a restart would come back to their block count rolled back to the last checkpoint - up to 49 blocks of progress gone, repeatedly, on every restart. The shutdown save was queued, not written. onDisable() called saveCache(), which uses saveObjectAsync(), and this addon is a Pladdon: the server disables it before BentoBox, so the write landed in a queue that BentoBox had to drain on its way out. BentoBox 3.22.0 added that drain (flushAll), but every earlier version discarded it silently. Write directly on shutdown instead, via a new saveCacheNow() using saveObjectNow(). That removes the dependency on core behaviour entirely rather than relying on a specific BentoBox version getting it right. saveObjectNow() is BentoBox 3.22.0 API, so bump the dependency and raise api-version to match. Without the api-version bump the addon would still load on an older core and throw NoSuchMethodError at shutdown, which is worse than the bug being fixed. It now refuses to load with "Please update BentoBox". Also make the periodic save interval configurable as island.save-every, defaulting to 10 rather than the hardcoded 50. Nothing helps if the server is SIGKILLed, but this caps what an unclean kill can cost at 9 blocks. Test notes: the BentoBox bump broke the same 5 PhasesPanelTest tests it broke in AOneBlock, at the same line numbers. None were regressions - all called when(user.getTranslation(...)) on a real User rather than a mock, which stubs nothing on the User and instead attaches to whichever mock the real method last touched. 3.22.0's getTranslation(World, ...) calls getIWM().getAddon() first, moving the target. Ported the stubTranslation() helper that stubs the LocalesManager these actually read from. The same pattern remains elsewhere in that class and is worth a follow-up sweep. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014t1DSo2wMbTWZLcwXpwUmQ --- pom.xml | 2 +- .../world/bentobox/chunkblock/ChunkBlock.java | 5 +- .../world/bentobox/chunkblock/Settings.java | 26 ++++++++++ .../chunkblock/listeners/BlockListener.java | 21 +++++--- src/main/resources/addon.yml | 2 +- src/main/resources/config.yml | 5 ++ .../bentobox/chunkblock/SettingsTest.java | 29 +++++++++++ .../listeners/BlockListenerTest.java | 39 +++++++++++++- .../chunkblock/panels/PhasesPanelTest.java | 52 ++++++++++++------- 9 files changed, 151 insertions(+), 30 deletions(-) diff --git a/pom.xml b/pom.xml index d3265e0..3d0aeb3 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 diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java index b2fb86e..0cf8a60 100644 --- a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java +++ b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java @@ -277,9 +277,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..1fb1c57 100644 --- a/src/main/java/world/bentobox/chunkblock/Settings.java +++ b/src/main/java/world/bentobox/chunkblock/Settings.java @@ -452,6 +452,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 +1940,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 */ 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/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..4183285 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -446,6 +446,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/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/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); From c087383506c96937bae747d46b486f0fdfb33048 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:40:18 -0700 Subject: [PATCH 4/7] 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); From dceea761e705487611d1267c39d36f98f551d5b5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:39:32 -0700 Subject: [PATCH 5/7] Let islands choose who may claim chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claiming was hard-coded to the island owner, and a teammate who hit the border got nothing back at all — no message, no sound — which reads as a broken mechanic rather than as a permission. Who may spend the island's level credit is now the CHUNKBLOCK_CLAIM_CHUNKS protection flag, so an island can open expansion to sub-owners, members, trusted or coop through the ordinary island settings GUI, and servers can move the default in default-island-flags. The flag defaults to owner rank, so nothing changes until an island says otherwise. A teammate below the rank is now told why instead of being ignored. Visitors and passers-by still get nothing: they see the ordinary locked-chunk message from the guard listener, and another island's credit is none of their business. The guard listener carried the same hard-coded owner check, so a teammate bumping into the border never saw the claim hint even on an island that had opened claiming to them. It now asks the same flag. Both listener tests move from mocking ChunkBlock to spying on a real one: a mock leaves the final flag fields null, and BossBarListenerTest already establishes the spy pattern for exactly this reason. Part of #6. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Taa2snoHkvtcZ6e5rAHDQS --- .../world/bentobox/chunkblock/ChunkBlock.java | 11 ++++ .../listeners/ChunkClaimListener.java | 32 ++++++++--- .../listeners/ChunkGuardListener.java | 8 +-- src/main/resources/config.yml | 1 + src/main/resources/locales/en-US.yml | 7 +++ .../listeners/ChunkClaimListenerTest.java | 55 +++++++++++++++---- .../listeners/ChunkGuardListenerTest.java | 35 +++++++++--- 7 files changed, 119 insertions(+), 30 deletions(-) diff --git a/src/main/java/world/bentobox/chunkblock/ChunkBlock.java b/src/main/java/world/bentobox/chunkblock/ChunkBlock.java index b2fb86e..8d7072b 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); } } diff --git a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java index bc710b5..15519e5 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/ChunkClaimListener.java @@ -22,14 +22,17 @@ 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 @@ -107,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(); @@ -133,7 +138,20 @@ 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); + } } /** @@ -185,7 +203,7 @@ private int[] findTargetLockedChunk(Player player, Island island) { * 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 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/config.yml b/src/main/resources/config.yml index 9694d78..b782e34 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -409,6 +409,7 @@ world: CHORUS_FRUIT: 500 CONTAINER: 500 MAGIC_BLOCK: 200 + CHUNKBLOCK_CLAIM_CHUNKS: 1000 JUKEBOX: 500 POTION_THROWING: 500 BARREL: 500 diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index d1087d4..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: |- diff --git a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java index c5da93b..949228c 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/ChunkClaimListenerTest.java @@ -6,8 +6,11 @@ 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; @@ -25,6 +28,8 @@ 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; @@ -51,24 +56,25 @@ class ChunkClaimListenerTest 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 = new Settings(); - when(addon.getSettings()).thenReturn(settings); + addon.setSettings(settings); borderDisplay = mock(BorderDisplay.class); - when(addon.getBorderDisplay()).thenReturn(borderDisplay); + 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); + doReturn(data).when(addon).getOneBlocksIsland(island); BlockListener blockListener = mock(BlockListener.class); - when(addon.getBlockListener()).thenReturn(blockListener); + 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); @@ -77,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 @@ -133,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 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); From 77fa4d72145ba0f76a9d69f9adcbc754c959d9f9 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:50:29 -0700 Subject: [PATCH 6/7] Update build version from 1.0.1 to 1.1.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d3265e0..2c8bcad 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ -LOCAL - 1.0.1 + 1.1.0 BentoBoxWorld_ChunkBlock bentobox-world From a4a4f2d3508733e6f8ad2a6835f58b3f230867e8 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 8 Aug 2026 12:51:44 -0700 Subject: [PATCH 7/7] ci: build on JDK 25 so BentoBox 3.22.0 class files can be read BentoBox 3.18.0 onwards is compiled for Java 25 (Minecraft 26.x), so its class files are version 69. A JDK 21 javac cannot parse those at all, and the build dies with "class file has wrong version 69.0, should be 65.0" against every BentoBox type before it reaches any of our code. This only surfaces once the dependency moves to 3.22.0, as it does in this branch. It did not show up locally because the dev machine is already on JDK 25 - the compiler reads the newer class files happily and 21 still emits Java 21 bytecode, which is what the addon ships. The addon's own target is unchanged: still Java 21 via in the pom. Only the JDK doing the compiling moves. Also moves setup-java to v4 and the 'adopt' distribution to 'temurin', since neither v3 nor adopt offers a Java 25 build. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014t1DSo2wMbTWZLcwXpwUmQ --- .github/workflows/build.yml | 12 ++++++++---- .github/workflows/modrinth-publish.yml | 7 ++++--- 2 files changed, 12 insertions(+), 7 deletions(-) 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