From 7ab75a935b936b2798c69f4b48bb5f71fd07ec42 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 15 May 2026 07:00:19 -0700 Subject: [PATCH 01/18] fix: zero new island on large protection ranges via incremental capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero-island scan force-generated every chunk in the protection range with gen=true. On islands with a large range (e.g. 1000 → ~16k chunks per dimension on a fresh world) this blew past the 5-minute calculation timeout, leaving initialCount unset. Switch the zero scan to gen=false so it only counts chunks that exist at zero time (typically just the schematic), and add NewChunkListener to accumulate generator block points (sea floor, nether ceiling, etc.) into initialCount lazily as chunks are generated during normal play. Regular level calcs subtract the now-incrementally-grown initialCount, so generator blocks always cancel out and players only get credit for their own placements. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/world/bentobox/level/Level.java | 5 + .../world/bentobox/level/LevelsManager.java | 23 +++- .../calculators/IslandLevelCalculator.java | 10 +- .../level/listeners/NewChunkListener.java | 117 ++++++++++++++++++ 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 src/main/java/world/bentobox/level/listeners/NewChunkListener.java diff --git a/src/main/java/world/bentobox/level/Level.java b/src/main/java/world/bentobox/level/Level.java index 82f6702..74f75e0 100644 --- a/src/main/java/world/bentobox/level/Level.java +++ b/src/main/java/world/bentobox/level/Level.java @@ -46,6 +46,7 @@ import world.bentobox.level.listeners.IslandActivitiesListeners; import world.bentobox.level.listeners.JoinLeaveListener; import world.bentobox.level.listeners.MigrationListener; +import world.bentobox.level.listeners.NewChunkListener; import world.bentobox.level.requests.LevelRequestHandler; import world.bentobox.level.requests.TopTenRequestHandler; import world.bentobox.visit.VisitAddon; @@ -154,6 +155,10 @@ private void registerAllListeners() { registerListener(new IslandActivitiesListeners(this)); registerListener(new JoinLeaveListener(this)); registerListener(new MigrationListener(this)); + // Accumulates generator block points into initialCount as new chunks + // are generated, so large protection ranges work with zero-new-island + // mode without forcing the initial scan to generate the whole area. + registerListener(new NewChunkListener(this)); } private void registerGameModeCommands() { diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java index 3816947..3d62ba9 100644 --- a/src/main/java/world/bentobox/level/LevelsManager.java +++ b/src/main/java/world/bentobox/level/LevelsManager.java @@ -480,7 +480,7 @@ public void removeEntry(World world, String uuid) { /** * Set an initial island count - * + * * @param island - the island to set. * @param lv - initial island count */ @@ -489,6 +489,27 @@ public void setInitialIslandCount(@NonNull Island island, long lv) { handler.saveObjectAsync(levelsCache.get(island.getUniqueId())); } + /** + * Add a delta to the island's initial-count handicap. Used by the new-chunk + * listener to accumulate generator block points (sea floor, nether ceiling, + * etc.) into the initial count as chunks are generated during normal play. + * The initial count is subtracted from the live block total in the level + * calc, so generator blocks do not inflate the level. + * + * @param island the island + * @param delta the points to add (no-op when zero) + */ + public void addToInitialCount(@NonNull Island island, long delta) { + if (delta == 0) { + return; + } + // Use getInitialCount so any legacy initialLevel is migrated first. + long current = getInitialCount(island); + IslandLevels data = getLevelsData(island); + data.setInitialCount(current + delta); + handler.saveObjectAsync(data); + } + /** * Set the island level for the owner of the island that targetPlayer is a * member diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index dc1ddcc..4cb1df0 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -366,8 +366,14 @@ private void loadChunks(CompletableFuture> r2, World world, Queue p = pairList.poll(); - // We need to generate now all the time because some game modes are not voids - Util.getChunkAtAsync(world, p.x, p.z, true).thenAccept(chunk -> { + // For zero-island scans, do not force chunk generation. Forcing the + // generator for every chunk in a large protection range (e.g. 1000 → + // ~16k chunks/dim) blows past the calculation timeout. Generator + // blocks that appear later (sea floor, nether ceiling, etc.) are + // picked up incrementally by NewChunkListener as chunks generate + // during normal play. Regular scans still generate, because some game + // modes are not voids. + Util.getChunkAtAsync(world, p.x, p.z, !zeroIsland).thenAccept(chunk -> { if (chunk != null) { chunkList.add(chunk); roseStackerCheck(chunk); diff --git a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java new file mode 100644 index 0000000..f41b178 --- /dev/null +++ b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java @@ -0,0 +1,117 @@ +package world.bentobox.level.listeners; + +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.ChunkSnapshot; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.world.ChunkLoadEvent; + +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.level.Level; + +/** + * Listens for freshly-generated chunks inside an island's protected area and + * adds the chunk's generator block points to the island's initial-count + * handicap. + *

+ * Together with the {@code gen=false} initial zero scan in + * {@link world.bentobox.level.calculators.IslandLevelCalculator}, this lets + * zero-new-island-level mode work on islands with very large protection + * ranges. The initial scan only records what is already generated at island + * creation time (typically just the schematic chunks). As the player + * explores and new chunks are generated, this listener accumulates their + * generator block points into the initial count so they cancel out of the + * regular level calc — players only get credit for blocks they actually + * place. + */ +public class NewChunkListener implements Listener { + + private final Level addon; + + public NewChunkListener(Level addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkLoad(ChunkLoadEvent e) { + if (!e.isNewChunk()) { + return; + } + if (!addon.getSettings().isZeroNewIslandLevels()) { + return; + } + Chunk chunk = e.getChunk(); + World world = chunk.getWorld(); + if (!addon.isRegisteredGameModeWorld(world)) { + return; + } + // Use the chunk centre to look up the island that owns it. + Location centre = new Location(world, (chunk.getX() << 4) + 8, world.getMinHeight(), + (chunk.getZ() << 4) + 8); + Island island = addon.getIslands().getIslandAt(centre).orElse(null); + if (island == null || island.getOwner() == null) { + return; + } + // Capture all main-thread state before going async. + ChunkSnapshot snapshot = chunk.getChunkSnapshot(); + int seaHeight = addon.getPlugin().getIWM().getSeaHeight(world); + double underwaterMultiplier = addon.getSettings().getUnderWaterMultiplier(); + int minProtectedX = island.getMinProtectedX(); + int maxProtectedX = island.getMaxProtectedX(); + int minProtectedZ = island.getMinProtectedZ(); + int maxProtectedZ = island.getMaxProtectedZ(); + int minHeight = world.getMinHeight(); + int maxHeight = world.getMaxHeight(); + int chunkBlockX = chunk.getX() << 4; + int chunkBlockZ = chunk.getZ() << 4; + + Bukkit.getScheduler().runTaskAsynchronously(addon.getPlugin(), () -> { + long total = scanSnapshot(snapshot, world, chunkBlockX, chunkBlockZ, minHeight, maxHeight, + minProtectedX, maxProtectedX, minProtectedZ, maxProtectedZ, seaHeight, + underwaterMultiplier); + if (total != 0L) { + Bukkit.getScheduler().runTask(addon.getPlugin(), + () -> addon.getManager().addToInitialCount(island, total)); + } + }); + } + + private long scanSnapshot(ChunkSnapshot snapshot, World world, int chunkBlockX, int chunkBlockZ, + int minHeight, int maxHeight, int minProtectedX, int maxProtectedX, int minProtectedZ, + int maxProtectedZ, int seaHeight, double underwaterMultiplier) { + long total = 0L; + for (int x = 0; x < 16; x++) { + int globalX = chunkBlockX + x; + if (globalX < minProtectedX || globalX >= maxProtectedX) { + continue; + } + for (int z = 0; z < 16; z++) { + int globalZ = chunkBlockZ + z; + if (globalZ < minProtectedZ || globalZ >= maxProtectedZ) { + continue; + } + for (int y = minHeight; y < maxHeight; y++) { + Material mat = snapshot.getBlockType(x, y, z); + if (mat.isAir()) { + continue; + } + Integer value = addon.getBlockConfig().getValue(world, mat); + if (value == null || value == 0) { + continue; + } + if (seaHeight > 0 && y <= seaHeight) { + total += (long) (value * underwaterMultiplier); + } else { + total += value; + } + } + } + } + return total; + } +} From caff25963ca484df79ceaf9890e1c546113b1f8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 23:51:26 +0000 Subject: [PATCH 02/18] Initial plan From 10d7c2f524d160526c66cc6cd9eb5bc325d0bf7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 23:53:48 +0000 Subject: [PATCH 03/18] Initial plan From 5d88f5b84af0585d3ab765d4794463cc01ebd099 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 00:00:04 +0000 Subject: [PATCH 04/18] fix: recalculate donated points from donatedBlocks map using current block config values Instead of reading the stale accumulated donatedPoints value stored in the database (which was calculated at donation-time using then-current block values), dynamically recalculate the donated points from the donatedBlocks map using the current block config values on every level calculation. This fixes incorrect level calculations when block config values change after blocks have been donated. Previously the stored total could be less than a single block type's current contribution, as seen in the issue where Iron Block x 3,553 = 49,742 points but the total donated points reported was only 49,348. Also update the getReport() donated block section to use getValue(world, key) for world-specific block value lookups, consistent with how the calculation works. Fixes #nnn Agent-Logs-Url: https://github.com/BentoBoxWorld/Level/sessions/8d20bea9-7362-4af9-aa27-e0ae175eec2a Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../level/calculators/IslandLevelCalculator.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index dc1ddcc..518ecfb 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -305,7 +305,7 @@ private List getReport() { donatedBlocks.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .forEach(entry -> { - Integer value = addon.getBlockConfig().getBlockValues().getOrDefault(entry.getKey().toLowerCase(java.util.Locale.ENGLISH), 0); + Integer value = Objects.requireNonNullElse(addon.getBlockConfig().getValue(island.getWorld(), entry.getKey().toLowerCase(java.util.Locale.ENGLISH)), 0); long totalValue = (long) value * entry.getValue(); reportLines.add(" " + Util.prettifyText(entry.getKey()) + " x " + String.format("%,d", entry.getValue()) @@ -730,8 +730,17 @@ public void tidyUp() { results.rawBlockCount .addAndGet((long) (results.underWaterBlockCount.get() * addon.getSettings().getUnderWaterMultiplier())); - // Add donated block points (permanent contributions that persist across recalculations) - long donatedPoints = addon.getManager().getDonatedPoints(island); + // Add donated block points (permanent contributions that persist across recalculations). + // Recalculate from the donated blocks map using current block config values so the + // level always reflects the current configuration, even if block values changed since donation. + Map donatedBlocksMap = addon.getManager().getDonatedBlocks(island); + long donatedPoints = donatedBlocksMap.entrySet().stream() + .mapToLong(entry -> { + Integer value = addon.getBlockConfig().getValue(island.getWorld(), + entry.getKey().toLowerCase(java.util.Locale.ENGLISH)); + return (long) Objects.requireNonNullElse(value, 0) * entry.getValue(); + }) + .sum(); results.rawBlockCount.addAndGet(donatedPoints); results.donatedPoints.set(donatedPoints); From e1fc2d5b16180e9e0f7be15fd9ee344f2a4eabad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 00:05:41 +0000 Subject: [PATCH 05/18] Changes before error encountered Agent-Logs-Url: https://github.com/BentoBoxWorld/Level/sessions/e64dd47d-1dfc-4c51-967f-0646c6d8af1b Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../calculators/IslandLevelCalculator.java | 19 ++++++-- .../level/commands/IslandDonateCommand.java | 46 +++++++++++++++++-- src/main/resources/locales/en-US.yml | 1 + 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index dc1ddcc..b6e5ea0 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -731,9 +731,22 @@ public void tidyUp() { .addAndGet((long) (results.underWaterBlockCount.get() * addon.getSettings().getUnderWaterMultiplier())); // Add donated block points (permanent contributions that persist across recalculations) - long donatedPoints = addon.getManager().getDonatedPoints(island); - results.rawBlockCount.addAndGet(donatedPoints); - results.donatedPoints.set(donatedPoints); + // Apply blockconfig limits so that donated blocks beyond the limit do not raise the level + long cappedDonatedPoints = 0L; + Map donatedBlocksMap = addon.getManager().getDonatedBlocks(island); + for (Map.Entry entry : donatedBlocksMap.entrySet()) { + String blockId = entry.getKey(); + int count = entry.getValue(); + // Resolve to a Material (vanilla) or leave as String (custom block) + Material mat = Material.matchMaterial(blockId); + Object blockObj = mat != null ? mat : blockId; + int value = getValue(blockObj); + Integer limit = addon.getBlockConfig().getLimit(blockObj); + int cappedCount = (limit != null) ? Math.min(count, limit) : count; + cappedDonatedPoints += (long) cappedCount * value; + } + results.rawBlockCount.addAndGet(cappedDonatedPoints); + results.donatedPoints.set(cappedDonatedPoints); // Set the death penalty if (this.addon.getSettings().isSumTeamDeaths()) { diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index 80af368..2a432d6 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -151,6 +151,23 @@ private void performHandDonation(User user, Island island, Material material, St return; } int amount = Math.min(requested, currentHand.getAmount()); + + // Apply blockconfig donation limit + String donationId = customId != null ? customId : material.name(); + Object blockObj = customId != null ? (Object) customId : material; + Object displayKey = customId != null ? customId : material; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + int currentDonated = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - currentDonated); + if (remaining == 0) { + user.sendMessage("island.donate.limit-reached", + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user)); + return; + } + amount = Math.min(amount, remaining); + } + long points = (long) amount * blockValue; if (amount >= currentHand.getAmount()) { @@ -159,11 +176,9 @@ private void performHandDonation(User user, Island island, Material material, St currentHand.setAmount(currentHand.getAmount() - amount); } - String donationId = customId != null ? customId : material.name(); addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, amount, points); addon.getManager().recalculateAfterDonation(island); - Object displayKey = customId != null ? customId : material; user.sendMessage("island.donate.hand.success", TextVariables.NUMBER, String.valueOf(amount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), @@ -223,14 +238,35 @@ private void performInvDonation(User user, Island island) { if (value == null) { continue; } - int amount = item.getAmount(); - long points = (long) value * amount; String customId = addon.getCustomBlockId(item); String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? (Object) customId : item.getType(); + + // Apply blockconfig donation limit: only take up to the remaining capacity + int amount = item.getAmount(); + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + // getDonatedBlocks returns the live cached map, already updated by earlier donateBlocks calls + int alreadyDonated = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - alreadyDonated); + if (remaining == 0) { + // Limit already reached for this material — leave item in inventory + continue; + } + amount = Math.min(amount, remaining); + } + + // Remove accepted amount from inventory slot + if (amount >= item.getAmount()) { + contents[i] = null; + } else { + item.setAmount(item.getAmount() - amount); + } + + long points = (long) value * amount; donated.merge(donationId, amount, Integer::sum); totalPoints += points; addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, amount, points); - contents[i] = null; } pInv.setStorageContents(contents); diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index e18ba4a..9103c64 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -67,6 +67,7 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "The donation limit for [material] has been reached." hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" From 663f4294f6c4722211b9e4706e9e5d479695da68 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 15 May 2026 17:23:04 -0700 Subject: [PATCH 06/18] refactor(listener): address Sonar findings in NewChunkListener - S2184: keep the chunk-centre Location arithmetic in double space by using +8.0 so SonarQube does not flag a theoretical int-overflow before implicit widening. - S107: replace the 12-parameter scan helper with a ScanContext record bundling all main-thread snapshot state. - S3776 / S135: split the nested scan into scanRow / scanColumn / valueAt helpers so each method is small, has at most one return / continue, and total cognitive complexity drops below 15. No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../level/listeners/NewChunkListener.java | 99 +++++++++++-------- 1 file changed, 58 insertions(+), 41 deletions(-) diff --git a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java index f41b178..3ecdf88 100644 --- a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java +++ b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java @@ -31,6 +31,16 @@ */ public class NewChunkListener implements Listener { + /** + * Snapshot of the main-thread state needed to score one chunk on a worker + * thread. Bundled into a record so the async scan helpers don't need to + * carry a dozen parameters each. + */ + private record ScanContext(World world, int chunkBlockX, int chunkBlockZ, int minHeight, int maxHeight, + int minProtectedX, int maxProtectedX, int minProtectedZ, int maxProtectedZ, + int seaHeight, double underwaterMultiplier) { + } + private final Level addon; public NewChunkListener(Level addon) { @@ -51,29 +61,25 @@ public void onChunkLoad(ChunkLoadEvent e) { return; } // Use the chunk centre to look up the island that owns it. - Location centre = new Location(world, (chunk.getX() << 4) + 8, world.getMinHeight(), - (chunk.getZ() << 4) + 8); + // The + 8.0 keeps the addition in double arithmetic so SonarQube does not + // flag a theoretical int-overflow before the implicit widening. + Location centre = new Location(world, (chunk.getX() << 4) + 8.0, world.getMinHeight(), + (chunk.getZ() << 4) + 8.0); Island island = addon.getIslands().getIslandAt(centre).orElse(null); if (island == null || island.getOwner() == null) { return; } // Capture all main-thread state before going async. ChunkSnapshot snapshot = chunk.getChunkSnapshot(); - int seaHeight = addon.getPlugin().getIWM().getSeaHeight(world); - double underwaterMultiplier = addon.getSettings().getUnderWaterMultiplier(); - int minProtectedX = island.getMinProtectedX(); - int maxProtectedX = island.getMaxProtectedX(); - int minProtectedZ = island.getMinProtectedZ(); - int maxProtectedZ = island.getMaxProtectedZ(); - int minHeight = world.getMinHeight(); - int maxHeight = world.getMaxHeight(); - int chunkBlockX = chunk.getX() << 4; - int chunkBlockZ = chunk.getZ() << 4; + ScanContext ctx = new ScanContext(world, chunk.getX() << 4, chunk.getZ() << 4, + world.getMinHeight(), world.getMaxHeight(), + island.getMinProtectedX(), island.getMaxProtectedX(), + island.getMinProtectedZ(), island.getMaxProtectedZ(), + addon.getPlugin().getIWM().getSeaHeight(world), + addon.getSettings().getUnderWaterMultiplier()); Bukkit.getScheduler().runTaskAsynchronously(addon.getPlugin(), () -> { - long total = scanSnapshot(snapshot, world, chunkBlockX, chunkBlockZ, minHeight, maxHeight, - minProtectedX, maxProtectedX, minProtectedZ, maxProtectedZ, seaHeight, - underwaterMultiplier); + long total = scanSnapshot(snapshot, ctx); if (total != 0L) { Bukkit.getScheduler().runTask(addon.getPlugin(), () -> addon.getManager().addToInitialCount(island, total)); @@ -81,37 +87,48 @@ public void onChunkLoad(ChunkLoadEvent e) { }); } - private long scanSnapshot(ChunkSnapshot snapshot, World world, int chunkBlockX, int chunkBlockZ, - int minHeight, int maxHeight, int minProtectedX, int maxProtectedX, int minProtectedZ, - int maxProtectedZ, int seaHeight, double underwaterMultiplier) { + private long scanSnapshot(ChunkSnapshot snapshot, ScanContext ctx) { long total = 0L; for (int x = 0; x < 16; x++) { - int globalX = chunkBlockX + x; - if (globalX < minProtectedX || globalX >= maxProtectedX) { - continue; + int globalX = ctx.chunkBlockX + x; + if (globalX >= ctx.minProtectedX && globalX < ctx.maxProtectedX) { + total += scanRow(snapshot, x, ctx); } - for (int z = 0; z < 16; z++) { - int globalZ = chunkBlockZ + z; - if (globalZ < minProtectedZ || globalZ >= maxProtectedZ) { - continue; - } - for (int y = minHeight; y < maxHeight; y++) { - Material mat = snapshot.getBlockType(x, y, z); - if (mat.isAir()) { - continue; - } - Integer value = addon.getBlockConfig().getValue(world, mat); - if (value == null || value == 0) { - continue; - } - if (seaHeight > 0 && y <= seaHeight) { - total += (long) (value * underwaterMultiplier); - } else { - total += value; - } - } + } + return total; + } + + private long scanRow(ChunkSnapshot snapshot, int x, ScanContext ctx) { + long total = 0L; + for (int z = 0; z < 16; z++) { + int globalZ = ctx.chunkBlockZ + z; + if (globalZ >= ctx.minProtectedZ && globalZ < ctx.maxProtectedZ) { + total += scanColumn(snapshot, x, z, ctx); } } return total; } + + private long scanColumn(ChunkSnapshot snapshot, int x, int z, ScanContext ctx) { + long total = 0L; + for (int y = ctx.minHeight; y < ctx.maxHeight; y++) { + total += valueAt(snapshot, x, y, z, ctx); + } + return total; + } + + private long valueAt(ChunkSnapshot snapshot, int x, int y, int z, ScanContext ctx) { + Material mat = snapshot.getBlockType(x, y, z); + if (mat.isAir()) { + return 0L; + } + Integer value = addon.getBlockConfig().getValue(ctx.world, mat); + if (value == null || value == 0) { + return 0L; + } + if (ctx.seaHeight > 0 && y <= ctx.seaHeight) { + return (long) (value * ctx.underwaterMultiplier); + } + return value; + } } From f11ccce0b3d2a23c97827515efec02d3dac478e0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 16 May 2026 10:46:49 -0700 Subject: [PATCH 07/18] fix: enforce blockconfig donation limits in GUI panel and confirm prompts PR #439 added limits to /is donate hand and inv execution, but the GUI panel still accepted any amount and both the hand and inv confirm prompts quoted pre-limit numbers. Apply limits in DonationPanel preview and confirm (returning excess to the player), and rebuild the hand/inv prompts to quote the actual amount that will be destroyed. Add a multi-line limit-notice shown when any material is capped, translated into all 16 locales. Co-Authored-By: Claude Opus 4.7 --- .../level/commands/IslandDonateCommand.java | 58 ++++++- .../bentobox/level/panels/DonationPanel.java | 143 ++++++++++++------ src/main/resources/locales/cs.yml | 2 + src/main/resources/locales/de.yml | 2 + src/main/resources/locales/en-US.yml | 1 + src/main/resources/locales/es.yml | 2 + src/main/resources/locales/fr.yml | 2 + src/main/resources/locales/hu.yml | 2 + src/main/resources/locales/id.yml | 2 + src/main/resources/locales/ko.yml | 2 + src/main/resources/locales/lv.yml | 2 + src/main/resources/locales/nl.yml | 2 + src/main/resources/locales/pl.yml | 2 + src/main/resources/locales/pt.yml | 2 + src/main/resources/locales/ru.yml | 2 + src/main/resources/locales/tr.yml | 2 + src/main/resources/locales/uk.yml | 2 + src/main/resources/locales/vi.yml | 2 + src/main/resources/locales/zh-CN.yml | 2 + .../commands/IslandDonateCommandTest.java | 82 ++++++++++ 20 files changed, 263 insertions(+), 53 deletions(-) diff --git a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index 2a432d6..58b8842 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -124,15 +124,41 @@ private boolean handleHandDonation(User user, Island island, List args) } } - final int previewAmount = Math.min(requested, hand.getAmount()); - final long previewPoints = (long) previewAmount * blockValue; + int previewAmount = Math.min(requested, hand.getAmount()); final int finalRequested = requested; + // Apply blockconfig limit to the preview so the confirm prompt shows the + // amount that will actually be destroyed, not the raw request. Object displayKey = customId != null ? customId : material; + String donationId = customId != null ? customId : material.name(); + Object blockObj = customId != null ? (Object) customId : material; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + boolean limited = false; + if (limit != null) { + int already = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - already); + if (remaining == 0) { + user.sendMessage("island.donate.limit-reached", + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user)); + return false; + } + if (previewAmount > remaining) { + previewAmount = remaining; + limited = true; + } + } + + long previewPoints = (long) previewAmount * blockValue; String prompt = user.getTranslation("island.donate.hand.confirm-prompt", TextVariables.NUMBER, String.valueOf(previewAmount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, previewPoints)); + if (limited) { + // The limit-notice locale uses '|' as a lore line-break for the GUI; + // translate to '\n' here so the chat confirmation prompt wraps cleanly. + prompt = prompt + "\n" + + user.getTranslation("island.donate.limit-notice").replace('|', '\n'); + } askConfirmation(user, prompt, () -> performHandDonation(user, island, material, customId, blockValue, finalRequested)); return true; @@ -199,7 +225,9 @@ private boolean handleInvDonation(User user, Island island) { return false; } + Map alreadyDonated = addon.getManager().getDonatedBlocks(island); long totalPoints = 0L; + boolean limited = false; StringBuilder prompt = new StringBuilder( user.getTranslation("island.donate.inv.confirm-header")); for (Map.Entry e : totals.entrySet()) { @@ -212,13 +240,35 @@ private boolean handleInvDonation(User user, Island island) { Object displayKey = mat != null ? mat : e.getKey(); Integer rawValue = addon.getBlockConfig().getValue(getWorld(), displayKey); if (rawValue == null) continue; - long points = (long) rawValue * e.getValue(); + + String donationId = mat != null ? mat.name() : e.getKey(); + Object blockObj = mat != null ? mat : e.getKey(); + int amount = e.getValue(); + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + int remaining = Math.max(0, limit - alreadyDonated.getOrDefault(donationId, 0)); + int accepted = Math.min(amount, remaining); + if (accepted < amount) { + limited = true; + } + amount = accepted; + } + if (amount <= 0) { + continue; + } + long points = (long) rawValue * amount; totalPoints += points; prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-line", - TextVariables.NUMBER, String.valueOf(e.getValue()), + TextVariables.NUMBER, String.valueOf(amount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, points))); } + if (limited) { + // The limit-notice locale uses '|' as a lore line-break for the GUI; + // translate to '\n' here so the chat confirmation prompt wraps cleanly. + prompt.append('\n').append( + user.getTranslation("island.donate.limit-notice").replace('|', '\n')); + } prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-total", POINTS_PLACEHOLDER, Utils.formatNumber(user, totalPoints))); diff --git a/src/main/java/world/bentobox/level/panels/DonationPanel.java b/src/main/java/world/bentobox/level/panels/DonationPanel.java index 760611e..72baef8 100644 --- a/src/main/java/world/bentobox/level/panels/DonationPanel.java +++ b/src/main/java/world/bentobox/level/panels/DonationPanel.java @@ -130,33 +130,69 @@ private void build() { } /** - * Calculate the total point value of items in the donation slots. + * Per-material donation totals with blockconfig limits applied. */ - private long calculateDonationValue() { - long total = 0; + private static final class DonationTotals { + final Map accepted = new HashMap<>(); + long acceptedPoints = 0; + boolean limited = false; + } + + /** + * Walk the donation slots, total each material's items, and cap each total + * by the remaining capacity allowed by the blockconfig limit. The accepted + * counts and resulting point value drive both the preview and the actual + * donation; the {@code limited} flag triggers the limit-notice lore line. + */ + private DonationTotals computeDonationTotals() { + DonationTotals result = new DonationTotals(); + Map rawTotals = new HashMap<>(); + Map values = new HashMap<>(); for (int slot : layout.donationSlots) { ItemStack item = inventory.getItem(slot); - if (item != null && !item.getType().isAir()) { - String customId = addon.getCustomBlockId(item); - Integer value = customId != null - ? addon.getBlockConfig().getValue(world, customId) - : addon.getBlockConfig().getValue(world, item.getType()); - if (value != null && value > 0) { - total += (long) value * item.getAmount(); + if (item == null || item.getType().isAir()) continue; + String customId = addon.getCustomBlockId(item); + String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? customId : item.getType(); + Integer value = addon.getBlockConfig().getValue(world, blockObj); + if (value == null || value <= 0) continue; + rawTotals.merge(donationId, item.getAmount(), Integer::sum); + values.putIfAbsent(donationId, value); + } + Map donated = addon.getManager().getDonatedBlocks(island); + for (Map.Entry e : rawTotals.entrySet()) { + String donationId = e.getKey(); + int total = e.getValue(); + Object blockObj = donationId.contains(":") ? donationId : Material.matchMaterial(donationId); + if (blockObj == null) blockObj = donationId; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + int accept = total; + if (limit != null) { + int already = donated.getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - already); + accept = Math.min(total, remaining); + if (accept < total) { + result.limited = true; } } + result.accepted.put(donationId, accept); + result.acceptedPoints += (long) accept * values.get(donationId); } - return total; + return result; } /** - * Update the preview pane to show current point value. + * Update the preview pane to show the limited point value, with an extra + * lore line when any material exceeds its blockconfig limit. */ private void updatePreview() { - long points = calculateDonationValue(); - ItemStack preview = createNamedItem(layout.previewMaterial, - user.getTranslation("island.donate.preview", - POINTS_PLACEHOLDER, Utils.formatNumber(user, points))); + DonationTotals totals = computeDonationTotals(); + String text = user.getTranslation("island.donate.preview", + POINTS_PLACEHOLDER, Utils.formatNumber(user, totals.acceptedPoints)); + if (totals.limited) { + text = text + "|" + user.getTranslation("island.donate.limit-notice"); + } + ItemStack preview = createNamedItem(layout.previewMaterial, text); inventory.setItem(layout.previewSlot, preview); } @@ -168,52 +204,61 @@ private boolean isDonationSlot(int slot) { } /** - * Process the donation - consume items and record them. Items with no - * configured value are returned to the player rather than consumed. + * Process the donation - consume items up to each material's blockconfig + * limit and record them. Items beyond the limit (and valueless items) are + * returned to the player rather than consumed. */ private void processDonation() { - Map donations = new HashMap<>(); + DonationTotals totals = computeDonationTotals(); + Map remaining = new HashMap<>(totals.accepted); + Map donatedThisCall = new HashMap<>(); long totalPoints = 0; Player player = user.getPlayer(); for (int slot : layout.donationSlots) { ItemStack item = inventory.getItem(slot); - if (item != null && !item.getType().isAir()) { - String customId = addon.getCustomBlockId(item); - String donationId; - Integer value; - if (customId != null) { - value = addon.getBlockConfig().getValue(world, customId); - donationId = customId; - } else { - Material mat = item.getType(); - value = addon.getBlockConfig().getValue(world, mat); - donationId = mat.name(); - } - if (value != null && value > 0) { - int count = item.getAmount(); - long points = (long) value * count; - donations.merge(donationId, count, Integer::sum); - totalPoints += points; - // Record each material type as a separate donation log entry - addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, count, points); - // Clear the slot - items are consumed - inventory.setItem(slot, null); - } else { - // Return valueless items to the player rather than consuming them - inventory.setItem(slot, null); - Map overflow = player.getInventory().addItem(item); - overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); - } + if (item == null || item.getType().isAir()) continue; + String customId = addon.getCustomBlockId(item); + String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? customId : item.getType(); + Integer value = addon.getBlockConfig().getValue(world, blockObj); + + if (value == null || value <= 0) { + // Return valueless items to the player rather than consuming them + inventory.setItem(slot, null); + Map overflow = player.getInventory().addItem(item); + overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); + continue; + } + + int slotCount = item.getAmount(); + int canAccept = remaining.getOrDefault(donationId, 0); + int take = Math.min(slotCount, canAccept); + int leftover = slotCount - take; + + if (take > 0) { + long points = (long) value * take; + totalPoints += points; + donatedThisCall.merge(donationId, take, Integer::sum); + addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, take, points); + remaining.put(donationId, canAccept - take); + } + + inventory.setItem(slot, null); + if (leftover > 0) { + ItemStack ret = item.clone(); + ret.setAmount(leftover); + Map overflow = player.getInventory().addItem(ret); + overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); } } - if (donations.isEmpty()) { + if (donatedThisCall.isEmpty()) { user.sendMessage("island.donate.empty"); } else { user.sendMessage("island.donate.success", POINTS_PLACEHOLDER, Utils.formatNumber(user, totalPoints), - TextVariables.NUMBER, String.valueOf(donations.values().stream().mapToInt(Integer::intValue).sum())); + TextVariables.NUMBER, String.valueOf(donatedThisCall.values().stream().mapToInt(Integer::intValue).sum())); // Queue a full level recalculation so the donation is reflected immediately addon.getManager().recalculateAfterDonation(island); } @@ -322,7 +367,7 @@ private void handleCancel(InventoryClickEvent event, Player player) { private void handleConfirm(InventoryClickEvent event, Player player) { event.setCancelled(true); - if (calculateDonationValue() <= 0) { + if (computeDonationTotals().acceptedPoints <= 0) { user.sendMessage("island.donate.empty"); return; } diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 7c1da5b..af28d6f 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -62,6 +62,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Limit darování pro [material] byl dosažen." + limit-notice: "Některé bloky podléhají limitům|a nebudou darovány" hand: keyword: "ruka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 75ebec9..85bef5b 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -63,6 +63,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Das Spendenlimit für [material] wurde erreicht." + limit-notice: "Einige Blöcke unterliegen Limits|und werden nicht gespendet" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index 9103c64..fbd7f88 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -68,6 +68,7 @@ island: gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" limit-reached: "The donation limit for [material] has been reached." + limit-notice: "Some blocks are subject to limits|and will not be donated" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 8571223..51398b9 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -60,6 +60,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Se ha alcanzado el límite de donación para [material]." + limit-notice: "Algunos bloques están sujetos a límites|y no serán donados" hand: keyword: "mano" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index d939b6b..189631d 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -62,6 +62,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "La limite de don pour [material] a été atteinte." + limit-notice: "Certains blocs sont soumis à des limites|et ne seront pas donnés" hand: keyword: "main" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index e9113e5..82f5f68 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -63,6 +63,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "A(z) [material] adományozási limitje elérve." + limit-notice: "Néhány blokkra korlátozás vonatkozik|és nem lesznek adományozva" hand: keyword: "kez" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index ff360f0..2cc89aa 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -60,6 +60,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Batas donasi untuk [material] telah tercapai." + limit-notice: "Beberapa blok memiliki batasan|dan tidak akan didonasikan" hand: keyword: "tangan" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index 073e26c..c221e1e 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -63,6 +63,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material]의 기부 한도에 도달했습니다." + limit-notice: "일부 블록에는 제한이 있으며|기부되지 않습니다" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index a9f6feb..b9f4af5 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -63,6 +63,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] ziedošanas limits ir sasniegts." + limit-notice: "Dažiem blokiem ir ierobežojumi|un tie netiks ziedoti" hand: keyword: "roka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index 9ddd410..0e64a8a 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -60,6 +60,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "De donatielimiet voor [material] is bereikt." + limit-notice: "Sommige blokken zijn onderworpen aan limieten|en worden niet gedoneerd" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 67b6940..327dc15 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -60,6 +60,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Limit darowizn dla [material] został osiągnięty." + limit-notice: "Niektóre bloki podlegają limitom|i nie zostaną przekazane" hand: keyword: "reka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index ffb289c..d849492 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -63,6 +63,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "O limite de doação para [material] foi atingido." + limit-notice: "Alguns blocos estão sujeitos a limites|e não serão doados" hand: keyword: "mao" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index e483b21..2718963 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -63,6 +63,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Лимит пожертвования для [material] достигнут." + limit-notice: "Некоторые блоки подлежат ограничениям|и не будут пожертвованы" hand: keyword: "рука" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index 2f8ae2b..710fde4 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -67,6 +67,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] için bağış limitine ulaşıldı." + limit-notice: "Bazı bloklar limitlere tabidir|ve bağışlanmayacaktır" hand: keyword: "el" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index d8286b3..81e040d 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -60,6 +60,8 @@ island: gui-title: "Пожертвувати блоки" gui-info: "Пожертвуйте блоки своєму острову|Пожертвовано: [points] очок|Увага: пожертвувані предмети|знищуються і не можуть бути повернуті!" preview: "Очок буде додано: [points]|Ці предмети будуть знищені!" + limit-reached: "Ліміт пожертвування для [material] досягнуто." + limit-notice: "Деякі блоки підлягають обмеженням|і не будуть пожертвувані" hand: keyword: "рука" success: "Пожертвовано [number] x [material] за [points] постійних очок!" diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 9c19d5d..7a29634 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -66,6 +66,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Đã đạt đến giới hạn quyên góp cho [material]." + limit-notice: "Một số khối có giới hạn|và sẽ không được quyên góp" hand: keyword: "tay" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 6dd71c6..eeecc96 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -58,6 +58,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] 的捐赠限额已达到。" + limit-notice: "某些方块受到限制|将不会被捐赠" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java index 40771de..5945420 100644 --- a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java +++ b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java @@ -248,4 +248,86 @@ void testExecuteInvShowsConfirmationPrompt() { // No donation yet — only confirmation requested verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); } + + @Test + void testExecuteInvPromptShowsLimitNoticeWhenCapped() { + // 441 cobblestone in inventory, blockconfig limit of 100, nothing donated yet + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(441); + + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { cobble }); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Collections.emptyMap()); + + assertTrue(cmd.execute(user, "donate", List.of("inv"))); + + // limit-notice should be requested because 441 > limit of 100 + verify(user).getTranslation("island.donate.limit-notice"); + // confirm-line should be built using the limited amount (100), not the raw 441 + verify(user).getTranslation(eq("island.donate.inv.confirm-line"), + eq("[number]"), eq("100"), + eq("[material]"), anyString(), + eq("[points]"), anyString()); + } + + @Test + void testExecuteHandPromptShowsLimitNoticeWhenCapped() { + // 64 cobblestone in hand, limit 100, already donated 64 -> only 36 can be donated + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(64); + when(inventory.getItemInMainHand()).thenReturn(cobble); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Map.of("COBBLESTONE", 64)); + + assertTrue(cmd.execute(user, "donate", List.of("hand"))); + + // The confirm prompt should be built using the limited amount (36), not the raw 64 + verify(user).getTranslation(eq("island.donate.hand.confirm-prompt"), + eq("[number]"), eq("36"), + eq("[material]"), anyString(), + eq("[points]"), anyString()); + verify(user).getTranslation("island.donate.limit-notice"); + // No donation yet — only confirmation requested + verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); + } + + @Test + void testExecuteHandRejectsWhenLimitAlreadyReached() { + // limit 100, already donated 100 -> no prompt, immediate limit-reached message + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(64); + when(inventory.getItemInMainHand()).thenReturn(cobble); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Map.of("COBBLESTONE", 100)); + + assertFalse(cmd.execute(user, "donate", List.of("hand"))); + + verify(user).sendMessage(eq("island.donate.limit-reached"), + eq("[material]"), anyString()); + verify(user, never()).getTranslation(eq("island.donate.hand.confirm-prompt"), + anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); + } + + @Test + void testExecuteInvPromptNoLimitNoticeWhenUnderCap() { + // 50 cobblestone, limit 100, nothing donated yet — no notice + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(50); + + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { cobble }); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Collections.emptyMap()); + + assertTrue(cmd.execute(user, "donate", List.of("inv"))); + + verify(user, never()).getTranslation("island.donate.limit-notice"); + } } From c210d66ede080a769e4fecf8493191e3ac4a55bb Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 16 May 2026 17:15:33 -0700 Subject: [PATCH 08/18] perf: parallelize chunk fetches and add levelstatus diagnostics A 1000-protection-range AcidIsland scan took 160s because loadChunks fetched 15,876 chunk positions sequentially via getChunkAtAsync, and each callback round-trip cost ~10ms even when the chunk was ungenerated and returned null. This change: - Always skip generation during scans (gen=false). Lazy zeroing via NewChunkListener already keeps the handicap aligned with chunks as they generate, so forcing the generator for every position in a large protection range is unnecessary and times out. - Fast-path ungenerated positions with World.isChunkGenerated, a synchronous region-file lookup that avoids the async-scheduler hop. - Issue up to 32 async chunk fetches in parallel per batch instead of recursing one-at-a-time. - Drop the raw Location.toString() from scan log lines in favour of " x,y,z". - Distinguish the zero-scan completion log from regular scans. The / levelstatus command now reports, per island, the scan type (zero vs regular), world + xyz, elapsed time, and a monotonically increasing chunks-scanned/total counter. The level report adds a matching "Chunks scanned = N/total" line under the initial-count line. Locale keys added: admin.levelstatus.island-detail / island-queued / type-zero / type-regular, translated into all 16 supported locales. Co-Authored-By: Claude Opus 4.7 --- pom.xml | 2 +- .../calculators/IslandLevelCalculator.java | 112 +++++++++++++++--- .../bentobox/level/calculators/Pipeliner.java | 28 ++++- .../commands/AdminLevelStatusCommand.java | 54 ++++++++- src/main/resources/locales/cs.yml | 4 + src/main/resources/locales/de.yml | 4 + src/main/resources/locales/en-US.yml | 4 + src/main/resources/locales/es.yml | 4 + src/main/resources/locales/fr.yml | 4 + src/main/resources/locales/hu.yml | 4 + src/main/resources/locales/id.yml | 4 + src/main/resources/locales/ko.yml | 4 + src/main/resources/locales/lv.yml | 4 + src/main/resources/locales/nl.yml | 4 + src/main/resources/locales/pl.yml | 4 + src/main/resources/locales/pt.yml | 4 + src/main/resources/locales/ru.yml | 4 + src/main/resources/locales/tr.yml | 4 + src/main/resources/locales/uk.yml | 4 + src/main/resources/locales/vi.yml | 4 + src/main/resources/locales/zh-CN.yml | 4 + .../commands/AdminLevelStatusCommandTest.java | 5 + 22 files changed, 249 insertions(+), 20 deletions(-) diff --git a/pom.xml b/pom.xml index c18e98c..687951e 100644 --- a/pom.xml +++ b/pom.xml @@ -69,7 +69,7 @@ -LOCAL - 2.27.0 + 2.28.0 BentoBoxWorld_Level bentobox-world https://sonarcloud.io diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 5ede537..ac95019 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -70,6 +70,9 @@ public class IslandLevelCalculator { private static final int CHUNKS_TO_SCAN = 100; private final Level addon; private final Queue> chunksToCheck; + private final int initialChunkCount; + private final java.util.concurrent.atomic.AtomicInteger scannedChunks = + new java.util.concurrent.atomic.AtomicInteger(); private final Island island; private final Map limitCount; private final CompletableFuture r; @@ -101,6 +104,7 @@ public IslandLevelCalculator(Level addon, Island island, CompletableFuture(); // Get the initial island level // TODO: results.initialLevel.set(addon.getInitialIslandLevel(island)); @@ -270,6 +274,13 @@ private List getReport() { if (addon.getSettings().isZeroNewIslandLevels() && !addon.getSettings().isDonationsOnly()) { reportLines.add("Initial island count = " + (0L - addon.getManager().getInitialCount(island))); } + // Total chunk positions visited across all enabled dimensions. At the + // end of a scan this equals the total — ungenerated chunks count as + // visited too (gen=false makes them null and skipped, but the + // position is still checked off). Lazy zeroing via NewChunkListener + // fills in the missing block data as players explore. + reportLines.add("Chunks scanned = " + String.format("%,d", scannedChunks.get()) + + "/" + String.format("%,d", getTotalChunksToScan())); reportLines.add("Previous level = " + addon.getManager().getIslandLevel(island.getWorld(), island.getOwner())); reportLines.add("New level = " + results.getLevel()); reportLines.add(LINE_BREAK); @@ -359,26 +370,57 @@ private CompletableFuture> getWorldChunk(Environment env, Queue> r2, World world, Queue> pairList, List chunkList) { if (pairList.isEmpty()) { r2.complete(chunkList); return; } - Pair p = pairList.poll(); - // For zero-island scans, do not force chunk generation. Forcing the - // generator for every chunk in a large protection range (e.g. 1000 → - // ~16k chunks/dim) blows past the calculation timeout. Generator - // blocks that appear later (sea floor, nether ceiling, etc.) are - // picked up incrementally by NewChunkListener as chunks generate - // during normal play. Regular scans still generate, because some game - // modes are not voids. - Util.getChunkAtAsync(world, p.x, p.z, !zeroIsland).thenAccept(chunk -> { - if (chunk != null) { - chunkList.add(chunk); - roseStackerCheck(chunk); + // Build a batch of up to PARALLEL_CHUNK_FETCH async fetches. Before + // each async dispatch, fast-path-skip positions whose chunk has never + // been generated: World.isChunkGenerated() is a synchronous region + // file lookup that avoids the async-scheduler hop entirely. On a + // large protection range this drops the dominant cost — most chunks + // are ungenerated, and each saved round-trip is ~10 ms. + // + // We never force chunk generation (gen=false). Generator blocks in + // ungenerated chunks (sea floor, nether ceiling, etc.) are picked up + // incrementally by NewChunkListener as chunks generate during normal + // play — the initial-count handicap and the scanned block total grow + // together, keeping the level stable. + List> batch = new ArrayList<>(PARALLEL_CHUNK_FETCH); + while (!pairList.isEmpty() && batch.size() < PARALLEL_CHUNK_FETCH) { + Pair p = pairList.poll(); + if (!world.isChunkGenerated(p.x, p.z)) { + // Position counts toward progress but contributes nothing. + scannedChunks.incrementAndGet(); + continue; + } + batch.add(Util.getChunkAtAsync(world, p.x, p.z, false)); + } + if (batch.isEmpty()) { + // All positions in this slice were ungenerated — keep draining + // without paying for an empty CompletableFuture.allOf. + loadChunks(r2, world, pairList, chunkList); + return; + } + CompletableFuture.allOf(batch.toArray(new CompletableFuture[0])).thenRun(() -> { + for (CompletableFuture cf : batch) { + Chunk chunk = cf.getNow(null); + scannedChunks.incrementAndGet(); + if (chunk != null) { + chunkList.add(chunk); + roseStackerCheck(chunk); + } } - loadChunks(r2, world, pairList, chunkList); // Iteration + loadChunks(r2, world, pairList, chunkList); }); } @@ -815,6 +857,42 @@ boolean isNotZeroIsland() { return !zeroIsland; } + /** + * @return true if this is a zero-island (handicap) scan + */ + public boolean isZeroIsland() { + return zeroIsland; + } + + /** + * @return the number of chunks in the queue at construction time + */ + public int getInitialChunkCount() { + return initialChunkCount; + } + + /** + * @return the number of chunks remaining to scan (per dimension) + */ + public int getChunksRemaining() { + return chunksToCheck.size(); + } + + /** + * @return the number of chunks scanned so far across all dimensions + */ + public int getScannedChunks() { + return scannedChunks.get(); + } + + /** + * @return the total number of chunks that will be visited during the + * scan: XZ positions × enabled dimensions + */ + public int getTotalChunksToScan() { + return initialChunkCount * Math.max(1, worlds.size()); + } + public void scanIsland(Pipeliner pipeliner) { // In donations-only mode, skip the chunk scan for regular level calcs: // tidyUp() will add the donated points and compute the level from those @@ -853,7 +931,13 @@ public void scanIsland(Pipeliner pipeliner) { } else { // Done pipeliner.getInProcessQueue().remove(this); - BentoBox.getInstance().log("Completed Level scan."); + if (zeroIsland) { + BentoBox.getInstance().log( + "Initial zero scan complete for island at " + Pipeliner.formatCenter(island.getCenter()) + + ". Lazy zeroing will continue as new chunks generate."); + } else { + BentoBox.getInstance().log("Completed Level scan."); + } // Chunk finished // This was the last chunk. Handle stacked blocks, spawners, chests and exit handleStackedBlocks().thenCompose(v -> handleSpawners()).thenCompose(v -> handleChests()) diff --git a/src/main/java/world/bentobox/level/calculators/Pipeliner.java b/src/main/java/world/bentobox/level/calculators/Pipeliner.java index 47905e1..3aca6cd 100644 --- a/src/main/java/world/bentobox/level/calculators/Pipeliner.java +++ b/src/main/java/world/bentobox/level/calculators/Pipeliner.java @@ -7,6 +7,7 @@ import java.util.concurrent.ConcurrentLinkedQueue; import org.bukkit.Bukkit; +import org.bukkit.Location; import org.bukkit.scheduler.BukkitTask; import world.bentobox.bentobox.BentoBox; @@ -49,7 +50,7 @@ public Pipeliner(Level addon) { // Ignore deleted or unowned islands if (!iD.getIsland().isDeleted() && !iD.getIsland().isUnowned()) { inProcessQueue.put(iD, System.currentTimeMillis()); - BentoBox.getInstance().log("Starting to scan island level at " + iD.getIsland().getCenter()); + BentoBox.getInstance().log("Starting to scan island level at " + formatCenter(iD.getIsland().getCenter())); // Start the scanning of a island with the first chunk scanIsland(iD); } @@ -96,17 +97,29 @@ public CompletableFuture addIsland(Island island) { .map(IslandLevelCalculator::getIsland).anyMatch(island::equals)) { return CompletableFuture.completedFuture(new Results(Result.IN_PROGRESS)); } - BentoBox.getInstance().log("Added island to Level queue: " + island.getCenter()); + BentoBox.getInstance().log("Added island to Level queue: " + formatCenter(island.getCenter())); return addToQueue(island, false); } + /** + * Render an island centre as " x,y,z" for log lines instead of + * Bukkit's verbose Location.toString(). + */ + static String formatCenter(Location loc) { + if (loc == null) { + return "?"; + } + String worldName = loc.getWorld() == null ? "?" : loc.getWorld().getName(); + return worldName + " " + loc.getBlockX() + "," + loc.getBlockY() + "," + loc.getBlockZ(); + } + /** * Adds an island to the scanning queue * @param island - the island to scan * @return CompletableFuture of the results */ public CompletableFuture zeroIsland(Island island) { - BentoBox.getInstance().log("Zeroing island level for island at " + island.getCenter()); + BentoBox.getInstance().log("Zeroing island level for island at " + formatCenter(island.getCenter())); return addToQueue(island, true); } @@ -147,10 +160,17 @@ public void stop() { /** * @return the inProcessQueue */ - protected Map getInProcessQueue() { + public Map getInProcessQueue() { return inProcessQueue; } + /** + * @return the queue of islands waiting to start scanning + */ + public Queue getToProcessQueue() { + return toProcessQueue; + } + /** * @return the task */ diff --git a/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java b/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java index 252083e..c7b4df5 100644 --- a/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java +++ b/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java @@ -1,11 +1,15 @@ package world.bentobox.level.commands; import java.util.List; +import java.util.Map; import world.bentobox.bentobox.api.commands.CompositeCommand; import world.bentobox.bentobox.api.localization.TextVariables; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.util.Util; import world.bentobox.level.Level; +import world.bentobox.level.calculators.IslandLevelCalculator; public class AdminLevelStatusCommand extends CompositeCommand { @@ -25,7 +29,55 @@ public void setup() { @Override public boolean execute(User user, String label, List args) { - user.sendMessage("admin.levelstatus.islands-in-queue", TextVariables.NUMBER, String.valueOf(addon.getPipeliner().getIslandsInQueue())); + int total = addon.getPipeliner().getIslandsInQueue(); + user.sendMessage("admin.levelstatus.islands-in-queue", TextVariables.NUMBER, String.valueOf(total)); + if (total == 0) { + return true; + } + long now = System.currentTimeMillis(); + Map inProcess = addon.getPipeliner().getInProcessQueue(); + inProcess.forEach((calc, started) -> user.sendMessage(buildDetailKey(calc), + "[world]", worldName(calc), + "[xyz]", xyz(calc), + "[type]", typeKey(user, calc), + "[elapsed]", formatElapsed(now - started), + "[scanned]", String.valueOf(calc.getScannedChunks()), + "[total]", String.valueOf(calc.getTotalChunksToScan()))); + for (IslandLevelCalculator calc : addon.getPipeliner().getToProcessQueue()) { + user.sendMessage("admin.levelstatus.island-queued", + "[world]", worldName(calc), + "[xyz]", xyz(calc), + "[type]", typeKey(user, calc)); + } return true; } + + private String buildDetailKey(IslandLevelCalculator calc) { + return "admin.levelstatus.island-detail"; + } + + private String worldName(IslandLevelCalculator calc) { + Island island = calc.getIsland(); + return island.getWorld() == null ? "?" : island.getWorld().getName(); + } + + private String xyz(IslandLevelCalculator calc) { + Island island = calc.getIsland(); + if (island.getCenter() == null) { + return "?"; + } + return Util.xyz(island.getCenter().toVector()); + } + + private String typeKey(User user, IslandLevelCalculator calc) { + return user.getTranslation(calc.isZeroIsland() + ? "admin.levelstatus.type-zero" : "admin.levelstatus.type-regular"); + } + + private String formatElapsed(long ms) { + long s = Math.max(0, ms / 1000); + long m = s / 60; + s = s % 60; + return m > 0 ? (m + "m" + s + "s") : (s + "s"); + } } diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index af28d6f..42c3bdf 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -14,6 +14,10 @@ admin: levelstatus: description: Ukažte, kolik ostrovů je ve frontě pro skenování islands-in-queue: ' Ostrovy ve frontě: [number]' + island-detail: "- [type] [world] [xyz], uplynulo [elapsed], chunky [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (čekající)" + type-zero: "nula" + type-regular: "běžné" top: description: ukázat seznam TOP 10 unknown-world: 'Neznámý svět!' diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 85bef5b..257f808 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -15,6 +15,10 @@ admin: levelstatus: description: Zeige wie viele Inseln in der Warteschlange für den Scan sind islands-in-queue: " Inseln in der Warteschlange: [number]" + island-detail: "- [type] [world] [xyz], verstrichen [elapsed], Chunks [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (wartend)" + type-zero: "Null" + type-regular: "Regulär" top: description: Zeige die Top-10 Liste unknown-world: " Unbekannte Welt!" diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index fbd7f88..aa18eaf 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -19,6 +19,10 @@ admin: levelstatus: description: "show how many islands are in the queue for scanning" islands-in-queue: "Islands in queue: [number]" + island-detail: "- [type] [world] [xyz], elapsed [elapsed], chunks [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (waiting)" + type-zero: "zero" + type-regular: "regular" top: description: "show the top ten list" unknown-world: "Unknown world!" diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 51398b9..2c92200 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -12,6 +12,10 @@ admin: levelstatus: description: Muestra cuantas islas hay en la cola para escanear islands-in-queue: "Islas en cola: [number]" + island-detail: "- [type] [world] [xyz], transcurrido [elapsed], chunks [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (en espera)" + type-zero: "cero" + type-regular: "regular" top: description: Muestra la lista de las diez primeras islas unknown-world: "¡Mundo desconocido!" diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 189631d..1649288 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -12,6 +12,10 @@ admin: levelstatus: description: affiche le nombre d'îles dans la file d'attente pour l'analyse islands-in-queue: " Nombre d'Îles dans la file d'attente: [number]" + island-detail: "- [type] [world] [xyz], écoulé [elapsed], chunks [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (en attente)" + type-zero: "zéro" + type-regular: "régulier" top: description: affiche le top 10 des îles unknown-world: "Monde inconnu." diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index 82f5f68..d8d55f7 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -15,6 +15,10 @@ admin: levelstatus: description: megmutatja, hogy hány sziget van a szkennelési sorban islands-in-queue: " Szigetek a sorban: [number]" + island-detail: "- [type] [world] [xyz], eltelt [elapsed], chunkok [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (várakozik)" + type-zero: "nulla" + type-regular: "normál" top: description: Top Tíz lista megtekintése unknown-world: "Ismeretlen világ!" diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index 2cc89aa..a464064 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -11,6 +11,10 @@ admin: levelstatus: description: menunjukkan berapa banyak pulau dalam antrian untuk pemindaian islands-in-queue: " Pulau di dalam antrian: [number]" + island-detail: "- [type] [world] [xyz], berlalu [elapsed], chunk [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (menunggu)" + type-zero: "nol" + type-regular: "reguler" top: description: menunjukkan daftar sepuluh besar unknown-world: " Dunia tidak ditemukan!" diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index c221e1e..6b54d65 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -15,6 +15,10 @@ admin: levelstatus: description: 스캔 대기열에 있는 섬 수를 표시합니다 islands-in-queue: " 대기열에 있는 섬: [number]" + island-detail: "- [type] [world] [xyz], 경과 [elapsed], 청크 [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (대기 중)" + type-zero: "영" + type-regular: "일반" top: description: 상위 10개 목록을 표시합니다 unknown-world: " 알 수 없는 세계입니다!" diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index b9f4af5..009040b 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -15,6 +15,10 @@ admin: levelstatus: description: rāda, cik salu ir skenēšanas rindā islands-in-queue: " Salas rindā: [number]" + island-detail: "- [type] [world] [xyz], pagājis [elapsed], chunki [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (gaida)" + type-zero: "nulle" + type-regular: "parasts" top: description: rādīt labākās 10 salas display: "[rank]. [name] - [level]" diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index 0e64a8a..1c8b7f7 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -12,6 +12,10 @@ admin: levelstatus: description: laat zien hoeveel eilanden er in de wachtrij staan voor het scannen islands-in-queue: " Aantal eilanden in de wachtrij: [number]" + island-detail: "- [type] [world] [xyz], verstreken [elapsed], chunks [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (wacht)" + type-zero: "nul" + type-regular: "normaal" top: description: Laat de top tien zien unknown-world: " Ongeldige wereld!" diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 327dc15..8536c76 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -11,6 +11,10 @@ admin: levelstatus: description: pokazuje ile wysp znajduje się w kolejce do skanowania islands-in-queue: " Wyspy w kolejce: [number]" + island-detail: "- [type] [world] [xyz], upłynęło [elapsed], chunki [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (oczekuje)" + type-zero: "zero" + type-regular: "regularne" top: description: pokazuje Top 10 wysp unknown-world: "Nieznany świat!" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index d849492..036929f 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -15,6 +15,10 @@ admin: levelstatus: description: mostrar quantas ilhas estão na fila para escaneamento. islands-in-queue: " Ilhas na fila: [number]" + island-detail: "- [type] [world] [xyz], decorrido [elapsed], chunks [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (aguardando)" + type-zero: "zero" + type-regular: "regular" top: description: Mostra a lista dos dez primeiros unknown-world: " Mundo desconhecido!" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 2718963..3fdb2bb 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -15,6 +15,10 @@ admin: levelstatus: description: показать, сколько островов находится в очереди на сканирование islands-in-queue: 'Островов в очереди: [number]' + island-detail: "- [type] [world] [xyz], прошло [elapsed], чанки [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (ожидает)" + type-zero: "обнуление" + type-regular: "обычное" top: description: открывает панель с десяткой лучших по уровню острова unknown-world: 'Неизвестный мир!' diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index 710fde4..fefe227 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -19,6 +19,10 @@ admin: levelstatus: description: tarama için kaç adanın kuyrukta olduğunu göster islands-in-queue: " Kuyrukta bulunan adalar: [number]" + island-detail: "- [type] [world] [xyz], geçen [elapsed], parçalar [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (bekliyor)" + type-zero: "sıfır" + type-regular: "normal" top: description: "İlk 10 adayı sırala" unknown-world: " Bilinmeyen dünya!" diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 81e040d..01e0fe4 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -11,6 +11,10 @@ admin: levelstatus: description: показати, скільки островів у черзі на сканування islands-in-queue: " Острови в черзі: [number]" + island-detail: "- [type] [world] [xyz], минуло [elapsed], чанки [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (чекає)" + type-zero: "обнулення" + type-regular: "звичайне" top: description: показати першу десятку списку unknown-world: " Невідомий світ!" diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 7a29634..8d0fcb7 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -18,6 +18,10 @@ admin: levelstatus: description: xem bao nhiêu đảo đang trong hàng chờ được quét islands-in-queue: ' Đảo đang chờ: [number]' + island-detail: "- [type] [world] [xyz], đã trôi qua [elapsed], chunks [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (đang chờ)" + type-zero: "về 0" + type-regular: "thường" top: description: xem bảng xếp hạng TOP 10 unknown-world: ' Thế giới không xác định!' diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index eeecc96..94d4189 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -10,6 +10,10 @@ admin: levelstatus: description: 显示等级计算队列中的岛屿 islands-in-queue: '列队中的岛屿: [number]' + island-detail: "- [type] [world] [xyz], 已用时 [elapsed], 区块 [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (等待中)" + type-zero: "清零" + type-regular: "常规" top: description: 显示前十名 unknown-world: '未知的世界!' diff --git a/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java b/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java index 816165e..2a212f9 100644 --- a/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java +++ b/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.when; import java.util.Collections; +import java.util.LinkedList; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -65,6 +66,10 @@ void testExecuteShowsQueueSizeZero() { @Test void testExecuteShowsQueueSizeNonZero() { when(pipeliner.getIslandsInQueue()).thenReturn(5); + // The command iterates the in-process and waiting queues for diagnostics. + // Empty maps/queues are enough to prove non-zero output reaches sendMessage. + when(pipeliner.getInProcessQueue()).thenReturn(Collections.emptyMap()); + when(pipeliner.getToProcessQueue()).thenReturn(new LinkedList<>()); assertTrue(cmd.execute(user, "levelstatus", Collections.emptyList())); verify(user).sendMessage(eq("admin.levelstatus.islands-in-queue"), eq(TextVariables.NUMBER), eq("5")); } From 0a1e6ea1842c9df892ea3a02259da625b7a7c84e Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 16 May 2026 17:39:41 -0700 Subject: [PATCH 09/18] fix: dedup chunks and apply limits in NewChunkListener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 1000-protection-range new island reported level -44551 after a 1000-block flight. The handicap was 4,455,925 but the regular scan only found 784 points of value across the same chunks — a 5,683x over-counting. Two compounding causes: - Paper can fire ChunkLoadEvent with isNewChunk=true more than once for a given chunk under heavy chunk activity (ticket churn, parallel level-scan loads, plugin-triggered reloads). Each duplicate event credited the chunk to initialCount again. - The listener summed raw getValue per block while the regular scan applies per-material limits via limitCountAndValue. Limited high-value blocks could inflate the handicap past anything the scan would ever credit. Fix: - Track counted chunks per island in an in-memory Set keyed by packed (x,z). Skip if the chunk has already contributed during this server run. After a restart Paper reports isNewChunk=false for already- generated chunks, so prior-run chunks are not at risk on re-load. - Apply per-material limits in valueAt, capping each material at its configured limit within each chunk. This bounds the handicap to what the regular scan would credit. Co-Authored-By: Claude Opus 4.7 --- .../level/listeners/NewChunkListener.java | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java index 3ecdf88..49f704a 100644 --- a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java +++ b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java @@ -1,5 +1,10 @@ package world.bentobox.level.listeners; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.ChunkSnapshot; @@ -42,6 +47,20 @@ private record ScanContext(World world, int chunkBlockX, int chunkBlockZ, int mi } private final Level addon; + /** + * Per-island set of chunk keys (x:z packed into a long) that have already + * contributed to the initial-count handicap during this server run. + * Defends against ChunkLoadEvent firing isNewChunk=true more than once for + * the same chunk under heavy chunk activity (Paper ticket churn, parallel + * level-scan loads, plugin re-triggers). Without this, every duplicate + * firing inflated the handicap by another chunk's worth of value. + *

+ * Indexed by island uniqueId. Entries persist for the lifetime of the JVM. + * After a restart Paper reports isNewChunk=false for already-generated + * chunks, so chunks counted in earlier runs are not at risk of being + * recounted on the next run. + */ + private final Map> countedChunks = new HashMap<>(); public NewChunkListener(Level addon) { this.addon = addon; @@ -69,6 +88,12 @@ public void onChunkLoad(ChunkLoadEvent e) { if (island == null || island.getOwner() == null) { return; } + // Dedup: only credit each chunk to an island once per server run. + long key = chunkKey(chunk.getX(), chunk.getZ()); + Set seen = countedChunks.computeIfAbsent(island.getUniqueId(), k -> new HashSet<>()); + if (!seen.add(key)) { + return; + } // Capture all main-thread state before going async. ChunkSnapshot snapshot = chunk.getChunkSnapshot(); ScanContext ctx = new ScanContext(world, chunk.getX() << 4, chunk.getZ() << 4, @@ -87,37 +112,51 @@ public void onChunkLoad(ChunkLoadEvent e) { }); } + /** + * Pack chunk (x, z) into a single 64-bit key. Negative coordinates are + * preserved by masking to 32 bits before shifting. + */ + private static long chunkKey(int x, int z) { + return ((long) x & 0xFFFFFFFFL) << 32 | ((long) z & 0xFFFFFFFFL); + } + private long scanSnapshot(ChunkSnapshot snapshot, ScanContext ctx) { long total = 0L; + // Per-chunk material counts so we can apply the same limits the regular + // scan applies. Without this, value-bearing limited blocks (cobblestone, + // stone with non-zero value, etc.) inflate the handicap past anything + // the regular scan would ever credit. + Map perMaterial = new HashMap<>(); for (int x = 0; x < 16; x++) { int globalX = ctx.chunkBlockX + x; if (globalX >= ctx.minProtectedX && globalX < ctx.maxProtectedX) { - total += scanRow(snapshot, x, ctx); + total += scanRow(snapshot, x, ctx, perMaterial); } } return total; } - private long scanRow(ChunkSnapshot snapshot, int x, ScanContext ctx) { + private long scanRow(ChunkSnapshot snapshot, int x, ScanContext ctx, Map perMaterial) { long total = 0L; for (int z = 0; z < 16; z++) { int globalZ = ctx.chunkBlockZ + z; if (globalZ >= ctx.minProtectedZ && globalZ < ctx.maxProtectedZ) { - total += scanColumn(snapshot, x, z, ctx); + total += scanColumn(snapshot, x, z, ctx, perMaterial); } } return total; } - private long scanColumn(ChunkSnapshot snapshot, int x, int z, ScanContext ctx) { + private long scanColumn(ChunkSnapshot snapshot, int x, int z, ScanContext ctx, Map perMaterial) { long total = 0L; for (int y = ctx.minHeight; y < ctx.maxHeight; y++) { - total += valueAt(snapshot, x, y, z, ctx); + total += valueAt(snapshot, x, y, z, ctx, perMaterial); } return total; } - private long valueAt(ChunkSnapshot snapshot, int x, int y, int z, ScanContext ctx) { + private long valueAt(ChunkSnapshot snapshot, int x, int y, int z, ScanContext ctx, + Map perMaterial) { Material mat = snapshot.getBlockType(x, y, z); if (mat.isAir()) { return 0L; @@ -126,6 +165,20 @@ private long valueAt(ChunkSnapshot snapshot, int x, int y, int z, ScanContext ct if (value == null || value == 0) { return 0L; } + // Respect per-material limits so the listener can never credit more + // than the regular scan would. Counts are local to this chunk; the + // listener does not (yet) share state with prior chunks for an island, + // so the cap is applied per chunk. This still drops infinite handicap + // accumulation from terrain-rich blocks far below what an uncapped + // listener would record. + Integer limit = addon.getBlockConfig().getLimit(mat); + if (limit != null) { + int count = perMaterial.getOrDefault(mat, 0); + if (count >= limit) { + return 0L; + } + perMaterial.put(mat, count + 1); + } if (ctx.seaHeight > 0 && y <= ctx.seaHeight) { return (long) (value * ctx.underwaterMultiplier); } From 57c8a2e9d06f70936e42554c721bbbeb0558ad0a Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 16 May 2026 22:39:48 -0700 Subject: [PATCH 10/18] fix: stop fresh islands reading level 1 from missed handicap The zero-island scan set initialCount via setInitialIslandCount(totalPoints), which wiped any listener credits captured during the scan. Chunks that generated mid-scan and were skipped by the chunk-poll (ungenerated at poll time) then appeared in the next level scan with no matching handicap, producing a stable positive level on a fresh island. Track per-island scan-visited chunks and listener-deferred credits so the post-scan drain folds in only the chunks the scan missed. Also stop the console spam from logging every pending zero-scan, only count actually-scanned chunks in the report's X/Y figure, and raise the default zero-scan-delay-ticks from 40 (2s) to 600 (30s) so underwater obsidian formation finishes before the listener snapshots. Co-Authored-By: Claude Opus 4.7 --- .../world/bentobox/level/LevelsManager.java | 165 ++++++++++++++++ .../calculators/IslandLevelCalculator.java | 20 +- .../bentobox/level/calculators/Pipeliner.java | 5 + .../commands/AdminLevelStatusCommand.java | 24 ++- .../bentobox/level/config/ConfigSettings.java | 31 +++ .../listeners/IslandActivitiesListeners.java | 23 ++- .../level/listeners/NewChunkListener.java | 107 +++++----- src/main/resources/locales/cs.yml | 1 + src/main/resources/locales/de.yml | 1 + src/main/resources/locales/en-US.yml | 1 + src/main/resources/locales/es.yml | 1 + src/main/resources/locales/fr.yml | 1 + src/main/resources/locales/hu.yml | 1 + src/main/resources/locales/id.yml | 1 + src/main/resources/locales/ko.yml | 1 + src/main/resources/locales/lv.yml | 1 + src/main/resources/locales/nl.yml | 1 + src/main/resources/locales/pl.yml | 1 + src/main/resources/locales/pt.yml | 1 + src/main/resources/locales/ru.yml | 1 + src/main/resources/locales/tr.yml | 1 + src/main/resources/locales/uk.yml | 1 + src/main/resources/locales/vi.yml | 1 + src/main/resources/locales/zh-CN.yml | 1 + .../bentobox/level/LevelsManagerTest.java | 34 ++++ .../IslandLevelCalculatorTidyUpTest.java | 186 ++++++++++++++++++ .../commands/AdminLevelStatusCommandTest.java | 2 + .../IslandActivitiesListenersTest.java | 27 +++ 28 files changed, 583 insertions(+), 58 deletions(-) create mode 100644 src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java index 3d62ba9..f0fd1f5 100644 --- a/src/main/java/world/bentobox/level/LevelsManager.java +++ b/src/main/java/world/bentobox/level/LevelsManager.java @@ -13,9 +13,11 @@ import java.util.Map.Entry; import java.util.Objects; import java.util.TreeMap; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -50,6 +52,34 @@ public class LevelsManager { private final Map topTenLists; // Cache for top tens private Map cache = new HashMap<>(); + /** + * Per-island in-flight zero-scan counter. Incremented when + * {@link NewChunkListener} schedules a delayed snapshot for a freshly + * generated chunk, decremented when that snapshot has been processed and + * its value folded into {@link #addToInitialCount}. The level scan calls + * {@link #awaitPendingZeros} so it never returns a result while there is + * unaccounted-for handicap value still queued. + */ + private final Map pendingZeros = new ConcurrentHashMap<>(); + /** + * Per-island record of chunk positions visited by the active zero-island + * scan. Populated as the scan reads each chunk's snapshot; used by the + * post-scan drain to skip chunks that the scan already credited in + * {@code totalPoints} (preventing double-counting when the chunk listener + * also fires for the same chunk during the scan window). + */ + private final Map> zeroScanVisitedChunks = new ConcurrentHashMap<>(); + /** + * Per-island deferred listener credits captured while a zero-island scan + * is in progress. Without this, listener {@code addToInitialCount} calls + * for chunks the scan SKIPPED (ungenerated at poll time, generated + * mid-scan) would be wiped by the post-scan + * {@link #setInitialIslandCount setInitialIslandCount(totalPoints)}, and + * those chunks' values would appear in future scan totals with no + * matching handicap — producing a stable positive level on a fresh + * island. + */ + private final Map> zeroScanDeferredCredits = new ConcurrentHashMap<>(); public LevelsManager(Level addon) { this.addon = addon; @@ -510,6 +540,141 @@ public void addToInitialCount(@NonNull Island island, long delta) { handler.saveObjectAsync(data); } + // ---- Pending zero-scan tracking ---- + + /** + * Mark that one more lazy-zero snapshot is queued for {@code island}. + * Paired with {@link #completePendingZero(Island)} when the snapshot has + * been processed. + */ + public void addPendingZero(@NonNull Island island) { + pendingZeros.computeIfAbsent(island.getUniqueId(), k -> new AtomicInteger()).incrementAndGet(); + } + + /** + * Mark that a previously {@link #addPendingZero queued} snapshot has + * finished. Safe to call from any thread. + */ + public void completePendingZero(@NonNull Island island) { + AtomicInteger c = pendingZeros.get(island.getUniqueId()); + if (c != null) { + c.decrementAndGet(); + } + } + + /** + * @return the number of zero-scan snapshots still queued for this island + */ + public int getPendingZeroCount(@NonNull Island island) { + AtomicInteger c = pendingZeros.get(island.getUniqueId()); + return c == null ? 0 : Math.max(0, c.get()); + } + + /** + * Return a future that completes once every queued zero-scan snapshot for + * {@code island} has been processed (counter reached zero), or after + * {@code timeoutMs} milliseconds — whichever happens first. The level + * scan awaits this before computing the final report so the handicap is + * never out of date with the chunks that have actually generated. + */ + public CompletableFuture awaitPendingZeros(@NonNull Island island, long timeoutMs) { + CompletableFuture future = new CompletableFuture<>(); + long deadline = System.currentTimeMillis() + timeoutMs; + pollPendingZeros(island, future, deadline); + return future; + } + + private void pollPendingZeros(Island island, CompletableFuture future, long deadline) { + if (getPendingZeroCount(island) == 0) { + future.complete(null); + return; + } + if (System.currentTimeMillis() >= deadline) { + addon.logWarning("Pending zero-scan snapshots did not complete within timeout for island " + + island.getUniqueId() + "; level result may be slightly stale."); + future.complete(null); + return; + } + // Re-check at every 5 ticks (250 ms). Cheap, and the scan's outer + // timeout (calculation-timeout) provides the upper bound. + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), + () -> pollPendingZeros(island, future, deadline), 5L); + } + + // ---- Zero-scan visited/deferred tracking ---- + + /** + * Pack chunk (x, z) into a single 64-bit key. Negative coordinates are + * preserved by masking to 32 bits before shifting. Kept in sync with + * NewChunkListener's identical helper. + */ + public static long chunkKey(int x, int z) { + return ((long) x & 0xFFFFFFFFL) << 32 | ((long) z & 0xFFFFFFFFL); + } + + /** + * Mark the start of a zero-island scan for {@code island}. Creates the + * visited-chunks set and the deferred-credits map so concurrent listener + * processing during the scan can be tracked and folded in after the scan + * sets the initial-count baseline. + */ + public void beginZeroScan(@NonNull Island island) { + String id = island.getUniqueId(); + zeroScanVisitedChunks.put(id, ConcurrentHashMap.newKeySet()); + zeroScanDeferredCredits.put(id, new ConcurrentHashMap<>()); + } + + /** + * Record that the zero-island scan visited (counted blocks for) a chunk. + * Called from the scanner on the worker thread. + */ + public void recordScanVisitedChunk(@NonNull Island island, int chunkX, int chunkZ) { + Set set = zeroScanVisitedChunks.get(island.getUniqueId()); + if (set != null) { + set.add(chunkKey(chunkX, chunkZ)); + } + } + + /** + * Try to record a listener credit during an active zero scan. If no + * scan is active for this island, returns false and the caller should + * fall back to {@link #addToInitialCount}. If a scan is active, the + * credit is stored against the chunk key for later processing by + * {@link #drainZeroScanDeferred}. + */ + public boolean tryDeferZeroScanCredit(@NonNull Island island, int chunkX, int chunkZ, long value) { + Map deferred = zeroScanDeferredCredits.get(island.getUniqueId()); + if (deferred == null) { + return false; + } + deferred.put(chunkKey(chunkX, chunkZ), value); + return true; + } + + /** + * End the active zero scan for {@code island} and return the sum of + * deferred listener credits for chunks the scan did NOT visit. The + * caller should add this sum to the initial count immediately after + * {@link #setInitialIslandCount}, so chunks that the scan skipped + * (ungenerated at poll time, generated mid-scan) are preserved instead + * of being wiped by the baseline reset. + */ + public long drainZeroScanDeferred(@NonNull Island island) { + String id = island.getUniqueId(); + Set visited = zeroScanVisitedChunks.remove(id); + Map deferred = zeroScanDeferredCredits.remove(id); + if (deferred == null || deferred.isEmpty()) { + return 0L; + } + long sum = 0L; + for (Map.Entry e : deferred.entrySet()) { + if (visited == null || !visited.contains(e.getKey())) { + sum += e.getValue(); + } + } + return sum; + } + /** * Set the island level for the owner of the island that targetPlayer is a * member diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index ac95019..53bb296 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -399,8 +399,6 @@ private void loadChunks(CompletableFuture> r2, World world, Queue p = pairList.poll(); if (!world.isChunkGenerated(p.x, p.z)) { - // Position counts toward progress but contributes nothing. - scannedChunks.incrementAndGet(); continue; } batch.add(Util.getChunkAtAsync(world, p.x, p.z, false)); @@ -414,8 +412,11 @@ private void loadChunks(CompletableFuture> r2, World world, Queue { for (CompletableFuture cf : batch) { Chunk chunk = cf.getNow(null); - scannedChunks.incrementAndGet(); if (chunk != null) { + // Only count chunks the scan actually reads block data + // for, so the report's "X/Y" gives a useful generated- + // vs-total ratio instead of always reading 100%. + scannedChunks.incrementAndGet(); chunkList.add(chunk); roseStackerCheck(chunk); } @@ -594,6 +595,11 @@ private void scanAsync(ChunkPair cp) { } } } + // Record that the zero scan visited this chunk so the post-scan drain + // skips any listener credit that was deferred for the same chunk. + if (zeroIsland) { + addon.getManager().recordScanVisitedChunk(island, cp.chunk.getX(), cp.chunk.getZ()); + } } /** @@ -943,6 +949,14 @@ public void scanIsland(Pipeliner pipeliner) { handleStackedBlocks().thenCompose(v -> handleSpawners()).thenCompose(v -> handleChests()) .thenCompose(v -> handleOraxenFurniture()) .thenCompose(v -> handleNexoFurniture()) + // Wait for any delayed-snapshot zero scans queued by + // NewChunkListener for this island. The level can't be + // finalised until those add their value to initialCount or + // the per-scan timeout (configured calculation-timeout) is + // reached — otherwise the handicap is out of date and the + // player sees a temporarily inflated level. + .thenCompose(v -> addon.getManager().awaitPendingZeros(island, + (long) addon.getSettings().getCalculationTimeout() * 60_000L)) .thenRun(() -> { this.tidyUp(); this.getR().complete(getResults()); diff --git a/src/main/java/world/bentobox/level/calculators/Pipeliner.java b/src/main/java/world/bentobox/level/calculators/Pipeliner.java index 3aca6cd..9fd20a0 100644 --- a/src/main/java/world/bentobox/level/calculators/Pipeliner.java +++ b/src/main/java/world/bentobox/level/calculators/Pipeliner.java @@ -120,6 +120,11 @@ static String formatCenter(Location loc) { */ public CompletableFuture zeroIsland(Island island) { BentoBox.getInstance().log("Zeroing island level for island at " + formatCenter(island.getCenter())); + // Begin tracking listener events during the scan so chunks that + // generate after the scan polls their position (and are therefore + // missed by the scan) keep their listener-credited handicap instead + // of being wiped by the post-scan baseline reset. + addon.getManager().beginZeroScan(island); return addToQueue(island, true); } diff --git a/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java b/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java index c7b4df5..49e716c 100644 --- a/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java +++ b/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java @@ -31,18 +31,22 @@ public void setup() { public boolean execute(User user, String label, List args) { int total = addon.getPipeliner().getIslandsInQueue(); user.sendMessage("admin.levelstatus.islands-in-queue", TextVariables.NUMBER, String.valueOf(total)); - if (total == 0) { - return true; - } long now = System.currentTimeMillis(); Map inProcess = addon.getPipeliner().getInProcessQueue(); - inProcess.forEach((calc, started) -> user.sendMessage(buildDetailKey(calc), - "[world]", worldName(calc), - "[xyz]", xyz(calc), - "[type]", typeKey(user, calc), - "[elapsed]", formatElapsed(now - started), - "[scanned]", String.valueOf(calc.getScannedChunks()), - "[total]", String.valueOf(calc.getTotalChunksToScan()))); + inProcess.forEach((calc, started) -> { + user.sendMessage(buildDetailKey(calc), + "[world]", worldName(calc), + "[xyz]", xyz(calc), + "[type]", typeKey(user, calc), + "[elapsed]", formatElapsed(now - started), + "[scanned]", String.valueOf(calc.getScannedChunks()), + "[total]", String.valueOf(calc.getTotalChunksToScan())); + int pending = addon.getManager().getPendingZeroCount(calc.getIsland()); + if (pending > 0) { + user.sendMessage("admin.levelstatus.pending-zeros", + TextVariables.NUMBER, String.valueOf(pending)); + } + }); for (IslandLevelCalculator calc : addon.getPipeliner().getToProcessQueue()) { user.sendMessage("admin.levelstatus.island-queued", "[world]", worldName(calc), diff --git a/src/main/java/world/bentobox/level/config/ConfigSettings.java b/src/main/java/world/bentobox/level/config/ConfigSettings.java index ef47003..f07df3c 100644 --- a/src/main/java/world/bentobox/level/config/ConfigSettings.java +++ b/src/main/java/world/bentobox/level/config/ConfigSettings.java @@ -54,6 +54,23 @@ public class ConfigSettings implements ConfigObject { @ConfigEntry(path = "zero-new-island-levels") private boolean zeroNewIslandLevels = true; + @ConfigComment("") + @ConfigComment("Delay (in ticks) between a chunk being freshly generated and that chunk") + @ConfigComment("being scanned for the lazy-zero handicap. The delay lets neighbouring") + @ConfigComment("chunks finish their decoration phase (obsidian from lava-water, ore") + @ConfigComment("patches that spill across borders, broken nether portal spawns, etc.)") + @ConfigComment("so the captured snapshot matches what a regular level scan would later") + @ConfigComment("find. The level scan also waits for any in-flight delayed captures to") + @ConfigComment("complete before returning the result, so the handicap is always up to") + @ConfigComment("date with the chunks that have generated. 20 ticks = 1 second.") + @ConfigComment("Underwater obsidian on AcidIsland needs lava sources to flow into") + @ConfigComment("adjacent water before they convert — that can take 20-30 seconds, so") + @ConfigComment("the default is set high enough to catch it. Lower if you don't care") + @ConfigComment("about slow-forming terrain and want /level to settle faster after a") + @ConfigComment("burst of exploration.") + @ConfigEntry(path = "zero-scan-delay-ticks") + private int zeroScanDelayTicks = 600; + @ConfigComment("") @ConfigComment("Donations-only mode") @ConfigComment("If true, the island block scan is skipped entirely and the island level") @@ -397,6 +414,20 @@ public void setZeroNewIslandLevels(boolean zeroNewIslandLevels) { this.zeroNewIslandLevels = zeroNewIslandLevels; } + /** + * @return the zeroScanDelayTicks + */ + public int getZeroScanDelayTicks() { + return zeroScanDelayTicks; + } + + /** + * @param zeroScanDelayTicks the zeroScanDelayTicks to set + */ + public void setZeroScanDelayTicks(int zeroScanDelayTicks) { + this.zeroScanDelayTicks = zeroScanDelayTicks; + } + /** * @return the calculationTimeout diff --git a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java index f139d70..534f930 100644 --- a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java +++ b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java @@ -57,7 +57,28 @@ private void zeroIsland(final Island island) { // Clear the island setting if (island.getOwner() != null && island.getWorld() != null) { addon.getPipeliner().zeroIsland(island) - .thenAccept(results -> addon.getManager().setInitialIslandCount(island, results.getTotalPoints())); + .thenAccept(results -> { + if (results == null) { + // Scan was aborted (island deleted/unowned mid-flight). + // Drop the tracking maps so the next zero scan + // for this island starts clean. + addon.getManager().drainZeroScanDeferred(island); + return; + } + addon.getManager().setInitialIslandCount(island, results.getTotalPoints()); + // Fold in any listener credits captured during the + // scan for chunks the scan didn't visit (e.g. + // chunks generated mid-scan after their position + // was already polled). Without this, those chunks + // would later appear in level scan totals with no + // matching handicap entry and the island would + // show a stable non-zero level despite the player + // having placed nothing. + long missed = addon.getManager().drainZeroScanDeferred(island); + if (missed != 0L) { + addon.getManager().addToInitialCount(island, missed); + } + }); } } diff --git a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java index 49f704a..b932302 100644 --- a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java +++ b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java @@ -18,21 +18,23 @@ import world.bentobox.bentobox.database.objects.Island; import world.bentobox.level.Level; +import world.bentobox.level.LevelsManager; /** * Listens for freshly-generated chunks inside an island's protected area and * adds the chunk's generator block points to the island's initial-count * handicap. *

- * Together with the {@code gen=false} initial zero scan in - * {@link world.bentobox.level.calculators.IslandLevelCalculator}, this lets - * zero-new-island-level mode work on islands with very large protection - * ranges. The initial scan only records what is already generated at island - * creation time (typically just the schematic chunks). As the player - * explores and new chunks are generated, this listener accumulates their - * generator block points into the initial count so they cancel out of the - * regular level calc — players only get credit for blocks they actually - * place. + * The snapshot is captured a configurable number of ticks after + * {@link ChunkLoadEvent#isNewChunk()} fires + * ({@code zero-scan-delay-ticks}). This delay lets neighbouring chunks finish + * their decoration phase — late-arriving blocks like obsidian (lava+water), + * ore patches spilling across chunk borders, broken nether-portal frames, + * etc. — so the captured snapshot matches what a regular level scan would + * later see. Together with + * {@link world.bentobox.level.LevelsManager#awaitPendingZeros LevelsManager#awaitPendingZeros}, + * the regular scan never returns a stale level while a delayed capture is + * still in flight. */ public class NewChunkListener implements Listener { @@ -48,19 +50,14 @@ private record ScanContext(World world, int chunkBlockX, int chunkBlockZ, int mi private final Level addon; /** - * Per-island set of chunk keys (x:z packed into a long) that have already - * contributed to the initial-count handicap during this server run. - * Defends against ChunkLoadEvent firing isNewChunk=true more than once for - * the same chunk under heavy chunk activity (Paper ticket churn, parallel - * level-scan loads, plugin re-triggers). Without this, every duplicate - * firing inflated the handicap by another chunk's worth of value. - *

- * Indexed by island uniqueId. Entries persist for the lifetime of the JVM. - * After a restart Paper reports isNewChunk=false for already-generated - * chunks, so chunks counted in earlier runs are not at risk of being - * recounted on the next run. + * Per-island set of chunk keys (x:z packed into a long) already + * queued or processed in this server run. Defends against + * ChunkLoadEvent firing more than once for the same chunk under heavy + * activity (Paper ticket churn, parallel level-scan loads). After a + * restart Paper reports isNewChunk=false for already-generated chunks so + * earlier-run chunks are not at risk of re-counting on the next run. */ - private final Map> countedChunks = new HashMap<>(); + private final Map> queuedChunks = new HashMap<>(); public NewChunkListener(Level addon) { this.addon = addon; @@ -88,15 +85,37 @@ public void onChunkLoad(ChunkLoadEvent e) { if (island == null || island.getOwner() == null) { return; } - // Dedup: only credit each chunk to an island once per server run. - long key = chunkKey(chunk.getX(), chunk.getZ()); - Set seen = countedChunks.computeIfAbsent(island.getUniqueId(), k -> new HashSet<>()); + // Dedup: only enqueue each chunk for an island once per server run. + long key = LevelsManager.chunkKey(chunk.getX(), chunk.getZ()); + Set seen = queuedChunks.computeIfAbsent(island.getUniqueId(), k -> new HashSet<>()); if (!seen.add(key)) { return; } - // Capture all main-thread state before going async. + + int delay = Math.max(0, addon.getSettings().getZeroScanDelayTicks()); + addon.getManager().addPendingZero(island); + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), + () -> processChunk(world, chunk, island), delay); + } + + /** + * Snapshot the chunk after the configured delay and process it on a + * worker thread. The chunk may have been unloaded by the time this runs; + * Bukkit's ChunkSnapshot is immutable, so as long as the chunk is loaded + * here we can scan it off-thread. Once done, + * {@code completePendingZero} releases the in-flight counter so any + * waiting level scan can finalise. + */ + private void processChunk(World world, Chunk chunk, Island island) { + // Skip if the island was deleted while waiting for the delay. + if (island.isDeleted() || island.getOwner() == null) { + addon.getManager().completePendingZero(island); + return; + } ChunkSnapshot snapshot = chunk.getChunkSnapshot(); - ScanContext ctx = new ScanContext(world, chunk.getX() << 4, chunk.getZ() << 4, + int chunkX = chunk.getX(); + int chunkZ = chunk.getZ(); + ScanContext ctx = new ScanContext(world, chunkX << 4, chunkZ << 4, world.getMinHeight(), world.getMaxHeight(), island.getMinProtectedX(), island.getMaxProtectedX(), island.getMinProtectedZ(), island.getMaxProtectedZ(), @@ -105,27 +124,27 @@ public void onChunkLoad(ChunkLoadEvent e) { Bukkit.getScheduler().runTaskAsynchronously(addon.getPlugin(), () -> { long total = scanSnapshot(snapshot, ctx); - if (total != 0L) { - Bukkit.getScheduler().runTask(addon.getPlugin(), - () -> addon.getManager().addToInitialCount(island, total)); - } + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { + // During an active zero-island scan, route the credit + // through the deferral map so the post-scan drain can + // decide whether this chunk's value belongs in the + // baseline (scan missed it) or should be dropped (scan + // counted it). + if (!addon.getManager().tryDeferZeroScanCredit(island, chunkX, chunkZ, total) + && total != 0L) { + addon.getManager().addToInitialCount(island, total); + } + addon.getManager().completePendingZero(island); + }); }); } - /** - * Pack chunk (x, z) into a single 64-bit key. Negative coordinates are - * preserved by masking to 32 bits before shifting. - */ - private static long chunkKey(int x, int z) { - return ((long) x & 0xFFFFFFFFL) << 32 | ((long) z & 0xFFFFFFFFL); - } - private long scanSnapshot(ChunkSnapshot snapshot, ScanContext ctx) { long total = 0L; // Per-chunk material counts so we can apply the same limits the regular // scan applies. Without this, value-bearing limited blocks (cobblestone, - // stone with non-zero value, etc.) inflate the handicap past anything - // the regular scan would ever credit. + // stone with non-zero value, etc.) could push the handicap past + // anything the regular scan would ever credit. Map perMaterial = new HashMap<>(); for (int x = 0; x < 16; x++) { int globalX = ctx.chunkBlockX + x; @@ -167,10 +186,10 @@ private long valueAt(ChunkSnapshot snapshot, int x, int y, int z, ScanContext ct } // Respect per-material limits so the listener can never credit more // than the regular scan would. Counts are local to this chunk; the - // listener does not (yet) share state with prior chunks for an island, - // so the cap is applied per chunk. This still drops infinite handicap - // accumulation from terrain-rich blocks far below what an uncapped - // listener would record. + // listener does not share state with prior chunks for an island, so + // the cap applies per chunk. That still drops uncapped accumulation + // from terrain-rich blocks far below what an uncapped listener + // would record. Integer limit = addon.getBlockConfig().getLimit(mat); if (limit != null) { int count = perMaterial.getOrDefault(mat, 0); diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 42c3bdf..d9ed72b 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -18,6 +18,7 @@ admin: island-queued: "- [type] [world] [xyz] (čekající)" type-zero: "nula" type-regular: "běžné" + pending-zeros: "Čekající nulové skeny (zpožděné zachycení chunků): [number]" top: description: ukázat seznam TOP 10 unknown-world: 'Neznámý svět!' diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 257f808..0720187 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -19,6 +19,7 @@ admin: island-queued: "- [type] [world] [xyz] (wartend)" type-zero: "Null" type-regular: "Regulär" + pending-zeros: "Ausstehende Null-Scans (verzögerte Chunk-Erfassungen): [number]" top: description: Zeige die Top-10 Liste unknown-world: " Unbekannte Welt!" diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index aa18eaf..1d4de1b 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -23,6 +23,7 @@ admin: island-queued: "- [type] [world] [xyz] (waiting)" type-zero: "zero" type-regular: "regular" + pending-zeros: "Pending zero-scans (delayed chunk captures): [number]" top: description: "show the top ten list" unknown-world: "Unknown world!" diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 2c92200..a130a51 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -16,6 +16,7 @@ admin: island-queued: "- [type] [world] [xyz] (en espera)" type-zero: "cero" type-regular: "regular" + pending-zeros: "Escaneos cero pendientes (capturas de chunks retrasadas): [number]" top: description: Muestra la lista de las diez primeras islas unknown-world: "¡Mundo desconocido!" diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index 1649288..2a74784 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -16,6 +16,7 @@ admin: island-queued: "- [type] [world] [xyz] (en attente)" type-zero: "zéro" type-regular: "régulier" + pending-zeros: "Scans zéro en attente (captures de chunks différées) : [number]" top: description: affiche le top 10 des îles unknown-world: "Monde inconnu." diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index d8d55f7..87dae78 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -19,6 +19,7 @@ admin: island-queued: "- [type] [world] [xyz] (várakozik)" type-zero: "nulla" type-regular: "normál" + pending-zeros: "Függőben lévő nulla-szkennelések (késleltetett chunk-rögzítések): [number]" top: description: Top Tíz lista megtekintése unknown-world: "Ismeretlen világ!" diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index a464064..e1bd8f7 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -15,6 +15,7 @@ admin: island-queued: "- [type] [world] [xyz] (menunggu)" type-zero: "nol" type-regular: "reguler" + pending-zeros: "Pemindaian nol tertunda (penangkapan chunk yang tertunda): [number]" top: description: menunjukkan daftar sepuluh besar unknown-world: " Dunia tidak ditemukan!" diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index 6b54d65..3f9349f 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -19,6 +19,7 @@ admin: island-queued: "- [type] [world] [xyz] (대기 중)" type-zero: "영" type-regular: "일반" + pending-zeros: "대기 중인 제로 스캔 (지연된 청크 캡처): [number]" top: description: 상위 10개 목록을 표시합니다 unknown-world: " 알 수 없는 세계입니다!" diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index 009040b..4033c9a 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -19,6 +19,7 @@ admin: island-queued: "- [type] [world] [xyz] (gaida)" type-zero: "nulle" type-regular: "parasts" + pending-zeros: "Gaidāmie nulles skenēšanas (aizkavēti chunk uztveršanas): [number]" top: description: rādīt labākās 10 salas display: "[rank]. [name] - [level]" diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index 1c8b7f7..1151037 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -16,6 +16,7 @@ admin: island-queued: "- [type] [world] [xyz] (wacht)" type-zero: "nul" type-regular: "normaal" + pending-zeros: "Wachtende nul-scans (vertraagde chunk-opnames): [number]" top: description: Laat de top tien zien unknown-world: " Ongeldige wereld!" diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 8536c76..01453fc 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -15,6 +15,7 @@ admin: island-queued: "- [type] [world] [xyz] (oczekuje)" type-zero: "zero" type-regular: "regularne" + pending-zeros: "Oczekujące skany zerowe (opóźnione przechwytywania chunków): [number]" top: description: pokazuje Top 10 wysp unknown-world: "Nieznany świat!" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index 036929f..6d00e1a 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -19,6 +19,7 @@ admin: island-queued: "- [type] [world] [xyz] (aguardando)" type-zero: "zero" type-regular: "regular" + pending-zeros: "Verificações zero pendentes (capturas de chunks atrasadas): [number]" top: description: Mostra a lista dos dez primeiros unknown-world: " Mundo desconhecido!" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 3fdb2bb..edaa61f 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -19,6 +19,7 @@ admin: island-queued: "- [type] [world] [xyz] (ожидает)" type-zero: "обнуление" type-regular: "обычное" + pending-zeros: "Ожидающие нулевые сканирования (отложенные снимки чанков): [number]" top: description: открывает панель с десяткой лучших по уровню острова unknown-world: 'Неизвестный мир!' diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index fefe227..4d02983 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -23,6 +23,7 @@ admin: island-queued: "- [type] [world] [xyz] (bekliyor)" type-zero: "sıfır" type-regular: "normal" + pending-zeros: "Bekleyen sıfır taramalar (geciktirilmiş yığın yakalamaları): [number]" top: description: "İlk 10 adayı sırala" unknown-world: " Bilinmeyen dünya!" diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index 01e0fe4..06678eb 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -15,6 +15,7 @@ admin: island-queued: "- [type] [world] [xyz] (чекає)" type-zero: "обнулення" type-regular: "звичайне" + pending-zeros: "Очікувані нульові сканування (відкладені знімки чанків): [number]" top: description: показати першу десятку списку unknown-world: " Невідомий світ!" diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 8d0fcb7..b967b71 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -22,6 +22,7 @@ admin: island-queued: "- [type] [world] [xyz] (đang chờ)" type-zero: "về 0" type-regular: "thường" + pending-zeros: "Quét về 0 đang chờ (chụp chunk bị trễ): [number]" top: description: xem bảng xếp hạng TOP 10 unknown-world: ' Thế giới không xác định!' diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 94d4189..974ce32 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -14,6 +14,7 @@ admin: island-queued: "- [type] [world] [xyz] (等待中)" type-zero: "清零" type-regular: "常规" + pending-zeros: "等待中的清零扫描(延迟的区块快照): [number]" top: description: 显示前十名 unknown-world: '未知的世界!' diff --git a/src/test/java/world/bentobox/level/LevelsManagerTest.java b/src/test/java/world/bentobox/level/LevelsManagerTest.java index 91d841d..fc7d9f1 100644 --- a/src/test/java/world/bentobox/level/LevelsManagerTest.java +++ b/src/test/java/world/bentobox/level/LevelsManagerTest.java @@ -417,6 +417,40 @@ void testGetTopTenSortOrder() { assertEquals(1065L, topTen.values().iterator().next().longValue()); } + /** + * Test method for the zero-scan deferred-credit drain. Pins down the + * contract that the chunk listener relies on: while a zero scan is + * active, listener credits are routed into the deferred map; at drain + * time, chunks the scan visited are dropped (already in totalPoints) + * and chunks the scan missed are summed and returned for the caller + * to add to the initial count. + */ + @Test + void testZeroScanDeferralAndDrain() { + // Before beginZeroScan: tryDefer returns false (no active scan), so + // the listener takes the normal addToInitialCount path. + assertFalse(lm.tryDeferZeroScanCredit(island, 0, 0, 99L)); + + lm.beginZeroScan(island); + + // While scan is active: tryDefer returns true, capturing the value. + assertTrue(lm.tryDeferZeroScanCredit(island, 1, 1, 50L)); + assertTrue(lm.tryDeferZeroScanCredit(island, 2, 2, 30L)); + assertTrue(lm.tryDeferZeroScanCredit(island, 3, 3, 20L)); + + // Scan visits chunk (1,1) and (3,3). The drain should drop those + // and only return the value for (2,2) which the scan missed. + lm.recordScanVisitedChunk(island, 1, 1); + lm.recordScanVisitedChunk(island, 3, 3); + + assertEquals(30L, lm.drainZeroScanDeferred(island)); + + // After drain: scan state is cleared, tryDefer falls back to false. + assertFalse(lm.tryDeferZeroScanCredit(island, 4, 4, 100L)); + // Drain on a non-active scan returns 0 (no map entry). + assertEquals(0L, lm.drainZeroScanDeferred(island)); + } + /** * Test method for * {@link world.bentobox.level.LevelsManager#getRank(World, UUID)} diff --git a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java new file mode 100644 index 0000000..545989b --- /dev/null +++ b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java @@ -0,0 +1,186 @@ +package world.bentobox.level.calculators; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.concurrent.CompletableFuture; + +import org.bukkit.Location; +import org.bukkit.util.Vector; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.bentobox.managers.PlayersManager; +import world.bentobox.level.CommonTestSetup; +import world.bentobox.level.LevelsManager; +import world.bentobox.level.config.BlockConfig; +import world.bentobox.level.config.ConfigSettings; + +/** + * Pins down the contract of {@link IslandLevelCalculator#tidyUp()} for the + * "level 0" cases described in PR #434. Each test asserts both + * {@code pointsFromCurrentLevel} ("progress") and the interval + * {@code pointsFromCurrentLevel + pointsToNextLevel} ("levelcost" as the + * player sees it). + *

+ * Drives the actual code path, not a reimplementation, so a failure here + * means the production code disagrees with the asserted contract. + */ +class IslandLevelCalculatorTidyUpTest extends CommonTestSetup { + + @Mock + private ConfigSettings settings; + @Mock + private LevelsManager manager; + @Mock + private BlockConfig blockConfig; + @Mock + private Pipeliner pipeliner; + + private static final long INITIAL_COUNT = 130L; + private static final long LEVEL_COST = 130L; + + @BeforeEach + @Override + protected void setUp() throws Exception { + super.setUp(); + + // Settings — linear formula, level 0 ends at initialCount + level_cost. + when(addon.getSettings()).thenReturn(settings); + when(settings.getLevelCalc()).thenReturn("blocks / level_cost"); + when(settings.getLevelCost()).thenReturn(LEVEL_COST); + when(settings.isZeroNewIslandLevels()).thenReturn(true); + when(settings.isDonationsOnly()).thenReturn(false); + when(settings.getDeathPenalty()).thenReturn(0); + when(settings.getUnderWaterMultiplier()).thenReturn(1.0); + when(settings.isSumTeamDeaths()).thenReturn(false); + when(settings.isNether()).thenReturn(false); + when(settings.isEnd()).thenReturn(false); + + when(addon.getManager()).thenReturn(manager); + when(manager.getDonatedPoints(any(Island.class))).thenReturn(0L); + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Collections.emptyMap()); + when(manager.getIslandLevel(any(), any())).thenReturn(0L); + when(manager.getInitialCount(any(Island.class))).thenReturn(INITIAL_COUNT); + + when(addon.getBlockConfig()).thenReturn(blockConfig); + when(blockConfig.getValue(any(), any())).thenReturn(0); + when(blockConfig.getLimit(any())).thenReturn(null); + when(blockConfig.getBlockValues()).thenReturn(Collections.emptyMap()); + + when(addon.getPipeliner()).thenReturn(pipeliner); + when(addon.getInitialIslandCount(any(Island.class))).thenReturn(INITIAL_COUNT); + + PlayersManager players = mock(PlayersManager.class); + when(players.getDeaths(any(), any())).thenReturn(0); + when(addon.getPlayers()).thenReturn(players); + + // Island — tiny protection range keeps the chunks-to-scan queue small + // (the constructor walks it, but tidyUp() does not). + when(island.getProtectionRange()).thenReturn(16); + when(island.getMinProtectedX()).thenReturn(0); + when(island.getMaxProtectedX()).thenReturn(16); + when(island.getMinProtectedZ()).thenReturn(0); + when(island.getMaxProtectedZ()).thenReturn(16); + when(island.getWorld()).thenReturn(world); + + Location centre = mock(Location.class); + when(centre.toVector()).thenReturn(new Vector(0, 0, 0)); + when(island.getCenter()).thenReturn(centre); + + // Sea height — not under water, so the multiplier never fires. + lenient().when(iwm.getSeaHeight(any())).thenReturn(0); + } + + private IslandLevelCalculator newCalculator() { + return new IslandLevelCalculator(addon, island, new CompletableFuture<>(), false); + } + + @Test + @DisplayName("At start: progress=0, interval=level_cost") + void atStart() { + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); // 130 + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), "level"); + assertEquals(0L, r.getPointsFromCurrentLevel(), "progress at start"); + assertEquals(LEVEL_COST, r.getPointsFromCurrentLevel() + r.getPointsToNextLevel(), + "interval at start"); + } + + @Test + @DisplayName("Below start: progress goes negative, interval still equals level_cost (PR #434 claim)") + void belowStart() { + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT - 8); // 122 + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), "level stays at 0 (modifiedPoints<0 truncates)"); + assertEquals(-8L, r.getPointsFromCurrentLevel(), + "progress should be -8 — the player has lost 8 blocks below the starting count"); + assertEquals(LEVEL_COST, r.getPointsFromCurrentLevel() + r.getPointsToNextLevel(), + "interval should remain level_cost when below start"); + } + + @Test + @DisplayName("Above start within level 0: progress=delta, interval=level_cost") + void aboveStartLevel0() { + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT + 70); // 200 + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), "still level 0 (70/130 truncates)"); + assertEquals(70L, r.getPointsFromCurrentLevel(), "progress = blocks - initialCount"); + assertEquals(LEVEL_COST, r.getPointsFromCurrentLevel() + r.getPointsToNextLevel(), + "interval"); + } + + @Test + @DisplayName("Exactly at level 1 boundary: progress=0, interval=level_cost") + void atLevel1Boundary() { + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT + LEVEL_COST); // 260 → level=1 + calc.tidyUp(); + + assertEquals(1L, r.getLevel(), "level=1"); + assertEquals(0L, r.getPointsFromCurrentLevel(), "just crossed: progress=0"); + assertEquals(LEVEL_COST, r.getPointsFromCurrentLevel() + r.getPointsToNextLevel(), + "interval"); + } + + @Test + @DisplayName("Non-linear sqrt formula, below start: progress is negative, interval is the level-0 width") + void belowStart_sqrtFormula() { + // Switch to a non-linear formula. With zeroing on, modifiedPoints = blocks - initialCount, + // and sqrt(negative) = NaN → cast to 0 → level=0. The earlier "Negative values in + // progression while using a non-linear function" fix (c531317) was for exactly this kind + // of formula, so PR #434 should presumably keep producing a sensible -8/ here. + when(settings.getLevelCalc()).thenReturn("sqrt(blocks)"); + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT - 8); // 122 + + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), "level stays at 0 (sqrt(-8) → NaN → 0)"); + assertEquals(-8L, r.getPointsFromCurrentLevel(), + "progress should reflect the 8-block deficit from initialCount"); + // sqrt(blocks-130) first crosses 1 at blocks=131, so the level-0 interval here is 1 block wide. + // The exact interval isn't the point — we just want progress + remaining to be self-consistent + // and the "remaining to next" not negative. + long remaining = r.getPointsToNextLevel(); + assertEquals(true, remaining > 0, "pointsToNextLevel should be positive, got " + remaining); + } +} diff --git a/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java b/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java index 2a212f9..3d98c61 100644 --- a/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java +++ b/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java @@ -59,6 +59,8 @@ void testSetup() { @Test void testExecuteShowsQueueSizeZero() { when(pipeliner.getIslandsInQueue()).thenReturn(0); + when(pipeliner.getInProcessQueue()).thenReturn(Collections.emptyMap()); + when(pipeliner.getToProcessQueue()).thenReturn(new LinkedList<>()); assertTrue(cmd.execute(user, "levelstatus", Collections.emptyList())); verify(user).sendMessage(eq("admin.levelstatus.islands-in-queue"), eq(TextVariables.NUMBER), eq("0")); } diff --git a/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java b/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java index d0d80fb..c77cf7a 100644 --- a/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java +++ b/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java @@ -98,6 +98,33 @@ void testOnIslandResettedZeroNewIslandLevelsTrue() { verify(pipeliner).zeroIsland(island); } + @Test + void testZeroIslandFoldsInDeferredListenerCredits() { + // Pin down the post-scan drain: setInitialIslandCount is called with + // scan.totalPoints, then any listener-during-scan credits for chunks + // the scan didn't visit are folded in via addToInitialCount. Without + // this, mid-scan chunk generation would silently leave a positive + // delta and the island would never read level=0 right after reset. + when(settings.isZeroNewIslandLevels()).thenReturn(true); + when(manager.drainZeroScanDeferred(island)).thenReturn(42L); + IslandResettedEvent event = new IslandResettedEvent(island, uuid, false, location, island); + listener.onNewIsland(event); + verify(manager).setInitialIslandCount(island, 100L); + verify(manager).addToInitialCount(island, 42L); + } + + @Test + void testZeroIslandSkipsAddWhenNoDeferredCredits() { + // Drain returns 0 → no addToInitialCount, since the +0 noop would + // otherwise pad the database write path with a no-op save. + when(settings.isZeroNewIslandLevels()).thenReturn(true); + when(manager.drainZeroScanDeferred(island)).thenReturn(0L); + IslandResettedEvent event = new IslandResettedEvent(island, uuid, false, location, island); + listener.onNewIsland(event); + verify(manager).setInitialIslandCount(island, 100L); + verify(manager, never()).addToInitialCount(any(), anyLong()); + } + @Test void testOnIslandResettedZeroNewIslandLevelsFalse() { when(settings.isZeroNewIslandLevels()).thenReturn(false); From 89f86a15f48f8abf5fb78afa700e258ccb528791 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:40:44 +0000 Subject: [PATCH 11/18] Initial plan From 4110ddefdf9d76c008982827e430c67a7fabd8f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 21:47:21 +0000 Subject: [PATCH 12/18] fix: cap donated block counts to current block limits during level calculation When a block limit is lowered after players have already donated blocks, the donated count is now capped to the current limit in IslandLevelCalculator.tidyUp(). This ensures excess donated blocks no longer contribute points beyond the limit. Adds three new tests to IslandLevelCalculatorTidyUpTest covering: - donated count under limit (full count used) - donated count over limit (capped to current limit) - donated count with no limit (full count used) Agent-Logs-Url: https://github.com/BentoBoxWorld/Level/sessions/3c49eef0-18f1-4340-bdd3-2afd5ce36286 Co-authored-by: tastybento <4407265+tastybento@users.noreply.github.com> --- .../calculators/IslandLevelCalculator.java | 13 +++- .../IslandLevelCalculatorTidyUpTest.java | 69 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 53bb296..1256210 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -787,12 +787,19 @@ public void tidyUp() { // Add donated block points (permanent contributions that persist across recalculations). // Recalculate from the donated blocks map using current block config values so the // level always reflects the current configuration, even if block values changed since donation. + // Also apply the current block limit: if the limit was lowered after donation, only count + // up to the current limit (donated blocks over the limit are silently ignored). Map donatedBlocksMap = addon.getManager().getDonatedBlocks(island); long donatedPoints = donatedBlocksMap.entrySet().stream() .mapToLong(entry -> { - Integer value = addon.getBlockConfig().getValue(island.getWorld(), - entry.getKey().toLowerCase(java.util.Locale.ENGLISH)); - return (long) Objects.requireNonNullElse(value, 0) * entry.getValue(); + String key = entry.getKey().toLowerCase(java.util.Locale.ENGLISH); + Integer value = addon.getBlockConfig().getValue(island.getWorld(), key); + int count = entry.getValue(); + Integer limit = addon.getBlockConfig().getLimit(key); + if (limit != null) { + count = Math.min(count, limit); + } + return (long) Objects.requireNonNullElse(value, 0) * count; }) .sum(); results.rawBlockCount.addAndGet(donatedPoints); diff --git a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java index 545989b..9dfcf81 100644 --- a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java +++ b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import java.util.Collections; +import java.util.Map; import java.util.concurrent.CompletableFuture; import org.bukkit.Location; @@ -183,4 +184,72 @@ void belowStart_sqrtFormula() { long remaining = r.getPointsToNextLevel(); assertEquals(true, remaining > 0, "pointsToNextLevel should be positive, got " + remaining); } + + @Test + @DisplayName("Donated blocks under limit: full donation count is used") + void donatedBlocksUnderLimit() { + // Player donated 500 iron blocks; limit is 1000; block value is 3. + // Expected donated points = 500 * 3 = 1500. + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 500)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(0); + return "iron_block".equals(key) ? 1000 : null; + }); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(1500L, r.getDonatedPoints(), "donated points should equal 500 * 3 = 1500"); + } + + @Test + @DisplayName("Donated blocks exceed limit: only blocks up to the current limit count") + void donatedBlocksExceedLimit() { + // Player donated 1000 iron blocks; limit was later changed to 500; block value is 3. + // Expected donated points = 500 * 3 = 1500 (NOT 1000 * 3 = 3000). + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 1000)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(0); + return "iron_block".equals(key) ? 500 : null; + }); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(1500L, r.getDonatedPoints(), + "donated points should be capped at 500 * 3 = 1500, not 1000 * 3 = 3000"); + } + + @Test + @DisplayName("Donated blocks with no limit: full count is used") + void donatedBlocksNoLimit() { + // Player donated 1000 iron blocks; no limit configured; block value is 3. + // Expected donated points = 1000 * 3 = 3000. + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 1000)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenReturn(null); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(3000L, r.getDonatedPoints(), + "donated points should equal 1000 * 3 = 3000 when no limit is configured"); + } } From 22f20bbdd33bab653eadc0a4daaef905625109e0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 23 May 2026 23:08:29 -0700 Subject: [PATCH 13/18] fix: self-healing per-chunk handicap and address Copilot review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lazy-zero handicap was a single number written once at zero scan and only updated by ChunkLoadEvent.isNewChunk=true. Chunks that materialized through any other path — Poseidon pregenerating around the island grid before the island existed, async-load misses at zero-scan time, late chunk decoration — were silently missing from the handicap and showed up as positive level on islands the player had never touched. Track handicap value per chunk on IslandLevels (keyed by worldName:chunkKey), then reconcile on every scan: chunks the live scan visits that aren't in the persisted map are folded into both the map and initialCount in one atomic write, so the next scan reads level=0 for that previously-missing terrain. Frozen-once semantics ensure player builds still grow the live total without inflating the handicap, and an empty map with a non-zero initialCount is treated as legacy migration so existing islands don't get double-credited on upgrade. Also addresses Copilot's PR #441 review: - delete unbounded queuedChunks dedup from NewChunkListener (isNewChunk() is the dedup; the defensive set grew without bound) - route per-chunk scoring through IslandLevelCalculator's real processBlock logic so custom blocks, slabs, and configured values match the regular scan - clamp completePendingZero at zero, remove the per-island map entry when it empties, and close the add/complete race via compute() Multi-dim correctness: the visited/deferred maps now key on worldName:chunkKey so the same (x,z) in different dimensions tracks separately. drainZeroScanDeferred returns the missed entries as a map which the caller reconciles into the per-chunk handicap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../world/bentobox/level/LevelsManager.java | 211 +++++++++++++++--- .../calculators/IslandLevelCalculator.java | 107 ++++++++- .../listeners/IslandActivitiesListeners.java | 21 +- .../level/listeners/NewChunkListener.java | 138 +++--------- .../bentobox/level/objects/IslandLevels.java | 41 ++++ .../bentobox/level/LevelsManagerTest.java | 177 ++++++++++++++- .../IslandActivitiesListenersTest.java | 23 +- 7 files changed, 535 insertions(+), 183 deletions(-) diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java index f0fd1f5..a18b64c 100644 --- a/src/main/java/world/bentobox/level/LevelsManager.java +++ b/src/main/java/world/bentobox/level/LevelsManager.java @@ -66,9 +66,11 @@ public class LevelsManager { * scan. Populated as the scan reads each chunk's snapshot; used by the * post-scan drain to skip chunks that the scan already credited in * {@code totalPoints} (preventing double-counting when the chunk listener - * also fires for the same chunk during the scan window). + * also fires for the same chunk during the scan window). Keys are full + * {@code worldName:chunkKey} strings so chunks at the same {@code (x,z)} + * in different dimensions don't collide. */ - private final Map> zeroScanVisitedChunks = new ConcurrentHashMap<>(); + private final Map> zeroScanVisitedChunks = new ConcurrentHashMap<>(); /** * Per-island deferred listener credits captured while a zero-island scan * is in progress. Without this, listener {@code addToInitialCount} calls @@ -77,9 +79,11 @@ public class LevelsManager { * {@link #setInitialIslandCount setInitialIslandCount(totalPoints)}, and * those chunks' values would appear in future scan totals with no * matching handicap — producing a stable positive level on a fresh - * island. + * island. Keys mirror {@link #zeroScanVisitedChunks} — + * {@code worldName:chunkKey} — so the same chunk position in different + * dimensions is tracked separately. */ - private final Map> zeroScanDeferredCredits = new ConcurrentHashMap<>(); + private final Map> zeroScanDeferredCredits = new ConcurrentHashMap<>(); public LevelsManager(Level addon) { this.addon = addon; @@ -545,21 +549,43 @@ public void addToInitialCount(@NonNull Island island, long delta) { /** * Mark that one more lazy-zero snapshot is queued for {@code island}. * Paired with {@link #completePendingZero(Island)} when the snapshot has - * been processed. + * been processed. The increment runs inside {@code compute} so it is + * atomic with the removal done by {@code completePendingZero} — a + * decrement that empties the entry cannot race a concurrent add into + * leaving an orphaned counter outside the map. */ public void addPendingZero(@NonNull Island island) { - pendingZeros.computeIfAbsent(island.getUniqueId(), k -> new AtomicInteger()).incrementAndGet(); + pendingZeros.compute(island.getUniqueId(), (k, c) -> { + if (c == null) { + c = new AtomicInteger(); + } + c.incrementAndGet(); + return c; + }); } /** * Mark that a previously {@link #addPendingZero queued} snapshot has - * finished. Safe to call from any thread. + * finished. Safe to call from any thread. Clamps at zero (an extra + * complete with no matching add is a no-op rather than producing a + * negative count that would let {@link #getPendingZeroCount} read 0 and + * release a scan early) and drops the per-island map entry once the + * counter reaches zero so the map size stays bounded by the number of + * islands with in-flight work, not the total number of zero scans ever. */ public void completePendingZero(@NonNull Island island) { - AtomicInteger c = pendingZeros.get(island.getUniqueId()); - if (c != null) { - c.decrementAndGet(); - } + pendingZeros.compute(island.getUniqueId(), (k, c) -> { + if (c == null) { + return null; + } + if (c.get() <= 0) { + // No matching add — drop the orphaned entry instead of + // letting it drift negative. + return null; + } + int v = c.decrementAndGet(); + return v <= 0 ? null : c; + }); } /** @@ -625,54 +651,167 @@ public void beginZeroScan(@NonNull Island island) { } /** - * Record that the zero-island scan visited (counted blocks for) a chunk. - * Called from the scanner on the worker thread. + * Record that the zero-island scan visited (counted blocks for) a chunk + * in the given world. Called from the scanner on the worker thread. */ - public void recordScanVisitedChunk(@NonNull Island island, int chunkX, int chunkZ) { - Set set = zeroScanVisitedChunks.get(island.getUniqueId()); + public void recordScanVisitedChunk(@NonNull Island island, @NonNull String worldName, + int chunkX, int chunkZ) { + Set set = zeroScanVisitedChunks.get(island.getUniqueId()); if (set != null) { - set.add(chunkKey(chunkX, chunkZ)); + set.add(worldName + ":" + chunkKey(chunkX, chunkZ)); } } /** * Try to record a listener credit during an active zero scan. If no * scan is active for this island, returns false and the caller should - * fall back to {@link #addToInitialCount}. If a scan is active, the - * credit is stored against the chunk key for later processing by - * {@link #drainZeroScanDeferred}. + * fall back to {@link #addHandicapChunk}. If a scan is active, the + * credit is stored against the {@code worldName:chunkKey} for later + * processing by {@link #drainZeroScanDeferred}. */ - public boolean tryDeferZeroScanCredit(@NonNull Island island, int chunkX, int chunkZ, long value) { - Map deferred = zeroScanDeferredCredits.get(island.getUniqueId()); + public boolean tryDeferZeroScanCredit(@NonNull Island island, @NonNull String worldName, + int chunkX, int chunkZ, long value) { + Map deferred = zeroScanDeferredCredits.get(island.getUniqueId()); if (deferred == null) { return false; } - deferred.put(chunkKey(chunkX, chunkZ), value); + deferred.put(worldName + ":" + chunkKey(chunkX, chunkZ), value); return true; } /** - * End the active zero scan for {@code island} and return the sum of - * deferred listener credits for chunks the scan did NOT visit. The - * caller should add this sum to the initial count immediately after - * {@link #setInitialIslandCount}, so chunks that the scan skipped - * (ungenerated at poll time, generated mid-scan) are preserved instead - * of being wiped by the baseline reset. + * End the active zero scan for {@code island} and return the deferred + * listener credits for chunks the scan did NOT visit, keyed by + * {@code worldName:chunkKey}. The caller passes this map to {@link + * #reconcileHandicapChunks} so the missed chunks become part of the + * persisted per-chunk handicap and are added to the initial count in one + * atomic save. Chunks the scan visited are dropped because their values + * are already in {@code results.totalPoints}. */ - public long drainZeroScanDeferred(@NonNull Island island) { + public Map drainZeroScanDeferred(@NonNull Island island) { String id = island.getUniqueId(); - Set visited = zeroScanVisitedChunks.remove(id); - Map deferred = zeroScanDeferredCredits.remove(id); + Set visited = zeroScanVisitedChunks.remove(id); + Map deferred = zeroScanDeferredCredits.remove(id); if (deferred == null || deferred.isEmpty()) { - return 0L; + return Collections.emptyMap(); } - long sum = 0L; - for (Map.Entry e : deferred.entrySet()) { + Map missed = new HashMap<>(); + for (Map.Entry e : deferred.entrySet()) { if (visited == null || !visited.contains(e.getKey())) { - sum += e.getValue(); + missed.put(e.getKey(), e.getValue()); + } + } + return missed; + } + + // ---- Per-chunk handicap reconciliation ---- + + /** + * Fold per-chunk scan results into the island's persistent handicap-chunks + * map and return the net delta to add to the initial-count handicap. + *

+ * Behaviour: + *

    + *
  • Zero scan: the map is overwritten with {@code scannedChunkValues}. + * The caller is expected to {@code setInitialIslandCount(island, totalPoints)} + * (a hard reset of the baseline), so the delta returned here is 0 — the new + * baseline is conveyed via the totalPoints write, not added on top.
  • + *
  • Regular scan, empty existing map, non-zero initialCount: legacy + * migration. Seed the map with the current scan values without touching + * initialCount — the player's existing level progress is preserved, and + * from this scan onward the map is the source of truth. Returns 0.
  • + *
  • Regular scan, normal case: for every chunk in + * {@code scannedChunkValues} whose key is not already in the persisted map, + * add the value to the map and accumulate it into the return delta. The + * caller adds that delta to initialCount, which is what makes the handicap + * self-healing: chunks the lazy-zero listener missed get credited the + * first time a regular level scan visits them.
  • + *
+ * Once a chunk is in the map its value is frozen — subsequent growth + * (player builds) increases the live scan total without changing the + * handicap, which is the intended behaviour. Subsequent shrinkage (player + * breaks naturally-generated blocks) likewise leaves the handicap alone; + * the negative {@code totalPoints - initialCount} delta correctly drags + * the level below zero. + * + * @param island the island being reconciled + * @param scannedChunkValues per-chunk values from the calculator, keyed + * by {@code worldName:chunkKey} + * @param zeroScan true when this is a zero-island handicap + * scan (overwrites the map) + * @return the number of points to add to the island's initial count; 0 + * for zero scans and legacy-migration scans + */ + public long reconcileHandicapChunks(@NonNull Island island, + @NonNull Map scannedChunkValues, boolean zeroScan) { + IslandLevels data = getLevelsData(island); + Map persisted = data.getHandicapChunks(); + if (zeroScan) { + // Hard reset — the new baseline is everything the scan saw. The + // initial count is rewritten separately by the caller, since a + // zero scan is meant to be the canonical re-baseline. + persisted.clear(); + persisted.putAll(scannedChunkValues); + handler.saveObjectAsync(data); + return 0L; + } + if (persisted.isEmpty()) { + Long existingCount = data.getInitialCount(); + if (existingCount != null && existingCount > 0L) { + // Legacy migration: an island that was zeroed before this + // feature existed. The existing initialCount already covers + // every chunk currently on disk, so seeding the map without + // touching initialCount keeps the level stable while moving + // future drift detection onto the per-chunk path. + persisted.putAll(scannedChunkValues); + handler.saveObjectAsync(data); + return 0L; } } - return sum; + long delta = 0L; + for (Map.Entry e : scannedChunkValues.entrySet()) { + if (!persisted.containsKey(e.getKey())) { + long value = e.getValue() == null ? 0L : e.getValue(); + persisted.put(e.getKey(), value); + delta += value; + } + } + if (delta != 0L) { + // Self-heal: any chunk the scan saw but the persisted map didn't + // know about gets folded into both the map and the initialCount + // in the same write, so the next scan reads a level of zero for + // that previously-missing terrain. + long existing = data.getInitialCount() == null ? 0L : data.getInitialCount(); + data.setInitialCount(existing + delta); + handler.saveObjectAsync(data); + } + return delta; + } + + /** + * Record a single chunk's handicap value and add its value to {@link + * IslandLevels#getInitialCount initialCount} in one atomic save. Used by + * {@link world.bentobox.level.listeners.NewChunkListener} when no zero + * scan is active. If the chunk is already in the map, this is a no-op + * (frozen-once semantics — see {@link #reconcileHandicapChunks}). The + * deferred path used during an active zero scan goes through {@link + * #tryDeferZeroScanCredit} and {@link #drainZeroScanDeferred} → + * {@link #reconcileHandicapChunks} instead, so the same chunk is never + * credited twice. + * + * @return true if the chunk was newly added (and initialCount adjusted) + */ + public boolean addHandicapChunk(@NonNull Island island, @NonNull String key, long value) { + IslandLevels data = getLevelsData(island); + Map persisted = data.getHandicapChunks(); + if (persisted.containsKey(key)) { + return false; + } + persisted.put(key, value); + long existing = data.getInitialCount() == null ? 0L : data.getInitialCount(); + data.setInitialCount(existing + value); + handler.saveObjectAsync(data); + return true; } /** diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 1256210..b819ee4 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -60,6 +60,7 @@ import world.bentobox.bentobox.util.Pair; import world.bentobox.bentobox.util.Util; import world.bentobox.level.Level; +import world.bentobox.level.LevelsManager; import world.bentobox.level.calculators.Results.Result; import world.bentobox.level.config.BlockConfig; @@ -75,6 +76,16 @@ public class IslandLevelCalculator { new java.util.concurrent.atomic.AtomicInteger(); private final Island island; private final Map limitCount; + /** + * Captured value per chunk visited during this scan, keyed by + * {@code worldName:chunkKey}. Filled by {@link #scanAsync} so {@link + * world.bentobox.level.LevelsManager#reconcileHandicapChunks} can + * fold any not-yet-known chunks into the per-chunk handicap map + * after the scan completes. Concurrent because furniture/spawner + * follow-up handlers may overlap with the main scan path. + */ + private final java.util.concurrent.ConcurrentMap scannedChunkValues = + new java.util.concurrent.ConcurrentHashMap<>(); private final CompletableFuture r; private final Results results; @@ -568,7 +579,55 @@ record ChunkPair(World world, Chunk chunk, ChunkSnapshot chunkSnapshot) { } private void scanAsync(ChunkPair cp) { -// Track chunks for furniture entity scanning (Oraxen and Nexo are entity-based) + // Bracket the AtomicLong counters around this chunk so we can + // capture exactly what this chunk contributed. Safe because the + // batch path in scanChunk runs scanAsync calls sequentially on the + // worker thread — there's no overlap of scanAsync invocations for + // different chunks to interleave the counters. + long preMd = results.rawBlockCount.get(); + long preUw = results.underWaterBlockCount.get(); + scanChunkBlocks(cp); + long deltaMd = results.rawBlockCount.get() - preMd; + long deltaUw = results.underWaterBlockCount.get() - preUw; + long value = deltaMd + (long) (deltaUw * addon.getSettings().getUnderWaterMultiplier()); + scannedChunkValues.put(handicapKey(cp.world, cp.chunk.getX(), cp.chunk.getZ()), value); + // Record that the zero scan visited this chunk so the post-scan drain + // skips any listener credit that was deferred for the same chunk. + if (zeroIsland) { + addon.getManager().recordScanVisitedChunk(island, cp.world.getName(), + cp.chunk.getX(), cp.chunk.getZ()); + } + } + + /** + * Compose the per-chunk handicap key — {@code worldName:chunkKey} — used + * to disambiguate chunks across the overworld, nether and end. Kept in + * sync with {@link world.bentobox.level.listeners.NewChunkListener}. + */ + public static String handicapKey(World world, int chunkX, int chunkZ) { + return world.getName() + ":" + LevelsManager.chunkKey(chunkX, chunkZ); + } + + /** + * @return per-chunk scored values captured during this scan, for the + * post-scan reconciliation done by {@link + * world.bentobox.level.LevelsManager#reconcileHandicapChunks}. + */ + public java.util.Map getScannedChunkValues() { + return scannedChunkValues; + } + + /** + * Iterate every block in {@code cp}'s intersection with the island's + * protected area and dispatch it through {@link #processBlock} — the same + * path the regular scan uses, so custom blocks, double slabs, + * spawners-as-blocks, and configured per-block values are all counted + * identically. Package-private so the new-chunk listener can score a + * freshly generated chunk via {@link #scoreNewChunkSnapshot} without + * touching the visited-chunk tracking owned by the active scan. + */ + void scanChunkBlocks(ChunkPair cp) { + // Track chunks for furniture entity scanning (Oraxen and Nexo are entity-based) if (BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent() || addon.isNexo()) { furnitureChunks.add(cp.chunk); } @@ -595,11 +654,31 @@ private void scanAsync(ChunkPair cp) { } } } - // Record that the zero scan visited this chunk so the post-scan drain - // skips any listener credit that was deferred for the same chunk. - if (zeroIsland) { - addon.getManager().recordScanVisitedChunk(island, cp.chunk.getX(), cp.chunk.getZ()); - } + } + + /** + * One-shot scoring of a single chunk snapshot for {@link + * world.bentobox.level.listeners.NewChunkListener}. Builds a throwaway + * calculator and runs {@link #scanChunkBlocks} on it so the per-block + * logic (custom blocks, double slabs, configured values, limit handling) + * is exactly what the regular scan uses — no parallel hand-rolled scan. + *

+ * The returned value already includes the underwater multiplier and is + * ready to be added to the island's initial-count handicap. Per-material + * limits are applied per call (each new chunk gets a fresh limit budget), + * since the listener has no shared state with the island-wide scan; the + * resulting handicap can be slightly higher than a full scan would credit + * for islands with many limited blocks, which simply makes the level-0 + * floor more durable for fresh islands. + */ + public static long scoreNewChunkSnapshot(Level addon, Island island, ChunkSnapshot snapshot, + Chunk chunk, World world) { + IslandLevelCalculator calc = new IslandLevelCalculator(addon, island, + new CompletableFuture<>(), true); + calc.scanChunkBlocks(new ChunkPair(world, chunk, snapshot)); + long md = calc.results.rawBlockCount.get(); + long uw = calc.results.underWaterBlockCount.get(); + return md + (long) (uw * addon.getSettings().getUnderWaterMultiplier()); } /** @@ -780,6 +859,22 @@ private Collection sortedReport(int total, Multiset uwCount) { * Finalizes the calculations and makes the report */ public void tidyUp() { + // Self-healing handicap reconciliation. For zero scans this overwrites + // the per-chunk map with what we just saw (the new baseline). For + // regular scans, any chunk the scan visited that the persisted map + // doesn't already know about is added to both the map and the + // initialCount — that's what catches chunks pregenerated before the + // island existed, async-load misses at zero-scan time, and late + // chunk decoration. The returned delta is folded into the in-memory + // results so the level math below sees the up-to-date baseline. + if (addon.getSettings().isZeroNewIslandLevels() && !addon.getSettings().isDonationsOnly() + && !scannedChunkValues.isEmpty()) { + long delta = addon.getManager().reconcileHandicapChunks(island, scannedChunkValues, zeroIsland); + if (delta != 0L && !zeroIsland) { + results.initialCount.addAndGet(delta); + } + } + // Finalize calculations results.rawBlockCount .addAndGet((long) (results.underWaterBlockCount.get() * addon.getSettings().getUnderWaterMultiplier())); diff --git a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java index 534f930..bbf42fd 100644 --- a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java +++ b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java @@ -65,18 +65,23 @@ private void zeroIsland(final Island island) { addon.getManager().drainZeroScanDeferred(island); return; } + // Hard reset the baseline. The calculator already + // overwrote the per-chunk handicap map with the + // scanned values via reconcileHandicapChunks(..., + // zeroScan=true) inside tidyUp; this aligns the + // initialCount with that same baseline. addon.getManager().setInitialIslandCount(island, results.getTotalPoints()); // Fold in any listener credits captured during the // scan for chunks the scan didn't visit (e.g. // chunks generated mid-scan after their position - // was already polled). Without this, those chunks - // would later appear in level scan totals with no - // matching handicap entry and the island would - // show a stable non-zero level despite the player - // having placed nothing. - long missed = addon.getManager().drainZeroScanDeferred(island); - if (missed != 0L) { - addon.getManager().addToInitialCount(island, missed); + // was already polled). Reconciling these as a + // non-zero scan adds them to both the per-chunk + // map and the initialCount in one atomic write, + // so subsequent scans see those chunks as fully + // accounted for. + java.util.Map missed = addon.getManager().drainZeroScanDeferred(island); + if (!missed.isEmpty()) { + addon.getManager().reconcileHandicapChunks(island, missed, false); } }); } diff --git a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java index b932302..166acb5 100644 --- a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java +++ b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java @@ -1,15 +1,9 @@ package world.bentobox.level.listeners; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.ChunkSnapshot; import org.bukkit.Location; -import org.bukkit.Material; import org.bukkit.World; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -18,7 +12,7 @@ import world.bentobox.bentobox.database.objects.Island; import world.bentobox.level.Level; -import world.bentobox.level.LevelsManager; +import world.bentobox.level.calculators.IslandLevelCalculator; /** * Listens for freshly-generated chunks inside an island's protected area and @@ -35,29 +29,18 @@ * {@link world.bentobox.level.LevelsManager#awaitPendingZeros LevelsManager#awaitPendingZeros}, * the regular scan never returns a stale level while a delayed capture is * still in flight. + *

+ * The actual scoring is delegated to + * {@link IslandLevelCalculator#scoreNewChunkSnapshot} so the per-block logic + * stays in lockstep with the regular scan — custom blocks, double slabs and + * configured values are all counted identically. No per-listener state is + * kept across chunks: {@link ChunkLoadEvent#isNewChunk()} already guarantees + * each generated chunk is reported once per server run, so a separate dedup + * set would only ever grow unbounded. */ public class NewChunkListener implements Listener { - /** - * Snapshot of the main-thread state needed to score one chunk on a worker - * thread. Bundled into a record so the async scan helpers don't need to - * carry a dozen parameters each. - */ - private record ScanContext(World world, int chunkBlockX, int chunkBlockZ, int minHeight, int maxHeight, - int minProtectedX, int maxProtectedX, int minProtectedZ, int maxProtectedZ, - int seaHeight, double underwaterMultiplier) { - } - private final Level addon; - /** - * Per-island set of chunk keys (x:z packed into a long) already - * queued or processed in this server run. Defends against - * ChunkLoadEvent firing more than once for the same chunk under heavy - * activity (Paper ticket churn, parallel level-scan loads). After a - * restart Paper reports isNewChunk=false for already-generated chunks so - * earlier-run chunks are not at risk of re-counting on the next run. - */ - private final Map> queuedChunks = new HashMap<>(); public NewChunkListener(Level addon) { this.addon = addon; @@ -85,12 +68,6 @@ public void onChunkLoad(ChunkLoadEvent e) { if (island == null || island.getOwner() == null) { return; } - // Dedup: only enqueue each chunk for an island once per server run. - long key = LevelsManager.chunkKey(chunk.getX(), chunk.getZ()); - Set seen = queuedChunks.computeIfAbsent(island.getUniqueId(), k -> new HashSet<>()); - if (!seen.add(key)) { - return; - } int delay = Math.max(0, addon.getSettings().getZeroScanDelayTicks()); addon.getManager().addPendingZero(island); @@ -99,12 +76,13 @@ public void onChunkLoad(ChunkLoadEvent e) { } /** - * Snapshot the chunk after the configured delay and process it on a - * worker thread. The chunk may have been unloaded by the time this runs; - * Bukkit's ChunkSnapshot is immutable, so as long as the chunk is loaded - * here we can scan it off-thread. Once done, - * {@code completePendingZero} releases the in-flight counter so any - * waiting level scan can finalise. + * Snapshot the chunk after the configured delay and score it on a worker + * thread using the real {@link IslandLevelCalculator} logic. The chunk may + * have been unloaded by the time this runs; Bukkit's {@link ChunkSnapshot} + * is immutable, so as long as the chunk is loaded here the scoring can + * safely run off-thread. Once done, + * {@link world.bentobox.level.LevelsManager#completePendingZero} releases + * the in-flight counter so any waiting level scan can finalise. */ private void processChunk(World world, Chunk chunk, Island island) { // Skip if the island was deleted while waiting for the delay. @@ -115,92 +93,28 @@ private void processChunk(World world, Chunk chunk, Island island) { ChunkSnapshot snapshot = chunk.getChunkSnapshot(); int chunkX = chunk.getX(); int chunkZ = chunk.getZ(); - ScanContext ctx = new ScanContext(world, chunkX << 4, chunkZ << 4, - world.getMinHeight(), world.getMaxHeight(), - island.getMinProtectedX(), island.getMaxProtectedX(), - island.getMinProtectedZ(), island.getMaxProtectedZ(), - addon.getPlugin().getIWM().getSeaHeight(world), - addon.getSettings().getUnderWaterMultiplier()); Bukkit.getScheduler().runTaskAsynchronously(addon.getPlugin(), () -> { - long total = scanSnapshot(snapshot, ctx); + long total = IslandLevelCalculator.scoreNewChunkSnapshot(addon, island, snapshot, chunk, world); Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { // During an active zero-island scan, route the credit // through the deferral map so the post-scan drain can // decide whether this chunk's value belongs in the // baseline (scan missed it) or should be dropped (scan // counted it). - if (!addon.getManager().tryDeferZeroScanCredit(island, chunkX, chunkZ, total) + String worldName = world.getName(); + if (!addon.getManager().tryDeferZeroScanCredit(island, worldName, chunkX, chunkZ, total) && total != 0L) { - addon.getManager().addToInitialCount(island, total); + // No active zero scan — fold the chunk into the + // per-chunk handicap map and initialCount in one + // atomic save. The map keeps the credit frozen so a + // future regular scan sees this chunk as already + // accounted for and doesn't double-credit it. + addon.getManager().addHandicapChunk(island, + IslandLevelCalculator.handicapKey(world, chunkX, chunkZ), total); } addon.getManager().completePendingZero(island); }); }); } - - private long scanSnapshot(ChunkSnapshot snapshot, ScanContext ctx) { - long total = 0L; - // Per-chunk material counts so we can apply the same limits the regular - // scan applies. Without this, value-bearing limited blocks (cobblestone, - // stone with non-zero value, etc.) could push the handicap past - // anything the regular scan would ever credit. - Map perMaterial = new HashMap<>(); - for (int x = 0; x < 16; x++) { - int globalX = ctx.chunkBlockX + x; - if (globalX >= ctx.minProtectedX && globalX < ctx.maxProtectedX) { - total += scanRow(snapshot, x, ctx, perMaterial); - } - } - return total; - } - - private long scanRow(ChunkSnapshot snapshot, int x, ScanContext ctx, Map perMaterial) { - long total = 0L; - for (int z = 0; z < 16; z++) { - int globalZ = ctx.chunkBlockZ + z; - if (globalZ >= ctx.minProtectedZ && globalZ < ctx.maxProtectedZ) { - total += scanColumn(snapshot, x, z, ctx, perMaterial); - } - } - return total; - } - - private long scanColumn(ChunkSnapshot snapshot, int x, int z, ScanContext ctx, Map perMaterial) { - long total = 0L; - for (int y = ctx.minHeight; y < ctx.maxHeight; y++) { - total += valueAt(snapshot, x, y, z, ctx, perMaterial); - } - return total; - } - - private long valueAt(ChunkSnapshot snapshot, int x, int y, int z, ScanContext ctx, - Map perMaterial) { - Material mat = snapshot.getBlockType(x, y, z); - if (mat.isAir()) { - return 0L; - } - Integer value = addon.getBlockConfig().getValue(ctx.world, mat); - if (value == null || value == 0) { - return 0L; - } - // Respect per-material limits so the listener can never credit more - // than the regular scan would. Counts are local to this chunk; the - // listener does not share state with prior chunks for an island, so - // the cap applies per chunk. That still drops uncapped accumulation - // from terrain-rich blocks far below what an uncapped listener - // would record. - Integer limit = addon.getBlockConfig().getLimit(mat); - if (limit != null) { - int count = perMaterial.getOrDefault(mat, 0); - if (count >= limit) { - return 0L; - } - perMaterial.put(mat, count + 1); - } - if (ctx.seaHeight > 0 && y <= ctx.seaHeight) { - return (long) (value * ctx.underwaterMultiplier); - } - return value; - } } diff --git a/src/main/java/world/bentobox/level/objects/IslandLevels.java b/src/main/java/world/bentobox/level/objects/IslandLevels.java index b15bb69..dcf5a9a 100644 --- a/src/main/java/world/bentobox/level/objects/IslandLevels.java +++ b/src/main/java/world/bentobox/level/objects/IslandLevels.java @@ -98,6 +98,27 @@ public class IslandLevels implements DataObject { @Expose private List donationLog; + /** + * Per-chunk "natural baseline" map for the lazy-zero handicap. Key is + * {@code worldName:chunkKey} (see {@link world.bentobox.level.LevelsManager#chunkKey}) + * and value is the chunk's scored contribution at the time it was first + * folded into the handicap. The map exists so the handicap is + * self-healing: any time a level scan visits a chunk that is not in the + * map yet, the chunk's value is added to both the map and {@link + * #initialCount}, which catches chunks the lazy-zero listener missed + * (chunks pregenerated before the island existed, async-load misses at + * zero-scan time, late chunk decoration). Once a chunk is in the map its + * value is frozen — subsequent block changes are attributed to player + * activity. + *

+ * Null on legacy data; treated as an empty map by callers. An empty map + * with a non-zero {@link #initialCount} is the legacy-migration state — + * the next scan seeds the map without re-crediting the existing count + * so player progress is preserved across the upgrade. + */ + @Expose + private Map handicapChunks; + /** * Constructor for new island * @param islandUUID - island UUID @@ -352,6 +373,26 @@ public void addDonation(String donorUUID, String material, int count, long point getDonationLog().add(new DonationRecord(System.currentTimeMillis(), donorUUID, material, count, points)); } + // ---- Handicap chunks (null-safe for backwards compatibility) ---- + + /** + * Per-chunk handicap map. Never null — initialised lazily so callers + * can iterate or {@code put} without a null check. + */ + public Map getHandicapChunks() { + if (handicapChunks == null) { + handicapChunks = new HashMap<>(); + } + return handicapChunks; + } + + /** + * @param handicapChunks the handicapChunks to set + */ + public void setHandicapChunks(Map handicapChunks) { + this.handicapChunks = handicapChunks; + } + /** * @return the initialLevel * @deprecated only used for backwards compatibility. Use {@link #getInitialCount()} instead diff --git a/src/test/java/world/bentobox/level/LevelsManagerTest.java b/src/test/java/world/bentobox/level/LevelsManagerTest.java index fc7d9f1..993f67e 100644 --- a/src/test/java/world/bentobox/level/LevelsManagerTest.java +++ b/src/test/java/world/bentobox/level/LevelsManagerTest.java @@ -427,28 +427,181 @@ void testGetTopTenSortOrder() { */ @Test void testZeroScanDeferralAndDrain() { + String w = "bskyblock-world"; // Before beginZeroScan: tryDefer returns false (no active scan), so - // the listener takes the normal addToInitialCount path. - assertFalse(lm.tryDeferZeroScanCredit(island, 0, 0, 99L)); + // the listener takes the normal addHandicapChunk path. + assertFalse(lm.tryDeferZeroScanCredit(island, w, 0, 0, 99L)); lm.beginZeroScan(island); // While scan is active: tryDefer returns true, capturing the value. - assertTrue(lm.tryDeferZeroScanCredit(island, 1, 1, 50L)); - assertTrue(lm.tryDeferZeroScanCredit(island, 2, 2, 30L)); - assertTrue(lm.tryDeferZeroScanCredit(island, 3, 3, 20L)); + assertTrue(lm.tryDeferZeroScanCredit(island, w, 1, 1, 50L)); + assertTrue(lm.tryDeferZeroScanCredit(island, w, 2, 2, 30L)); + assertTrue(lm.tryDeferZeroScanCredit(island, w, 3, 3, 20L)); // Scan visits chunk (1,1) and (3,3). The drain should drop those - // and only return the value for (2,2) which the scan missed. - lm.recordScanVisitedChunk(island, 1, 1); - lm.recordScanVisitedChunk(island, 3, 3); + // and only return (2,2) which the scan missed. + lm.recordScanVisitedChunk(island, w, 1, 1); + lm.recordScanVisitedChunk(island, w, 3, 3); - assertEquals(30L, lm.drainZeroScanDeferred(island)); + Map missed = lm.drainZeroScanDeferred(island); + assertEquals(1, missed.size()); + assertEquals(30L, missed.values().iterator().next().longValue()); // After drain: scan state is cleared, tryDefer falls back to false. - assertFalse(lm.tryDeferZeroScanCredit(island, 4, 4, 100L)); - // Drain on a non-active scan returns 0 (no map entry). - assertEquals(0L, lm.drainZeroScanDeferred(island)); + assertFalse(lm.tryDeferZeroScanCredit(island, w, 4, 4, 100L)); + // Drain on a non-active scan returns an empty map. + assertTrue(lm.drainZeroScanDeferred(island).isEmpty()); + } + + /** + * Pin down the self-healing handicap contract: + *

    + *
  • A zero-scan reconciliation overwrites the persisted per-chunk map + * with the scanned values and does not touch initialCount (caller does + * the hard reset separately via setInitialIslandCount).
  • + *
  • A regular scan on an island whose persisted map is empty but whose + * initialCount > 0 is treated as legacy migration: seed the map from + * the scan, leave initialCount alone (no double-credit).
  • + *
  • A regular scan on an already-tracked island adds only the + * chunks the persisted map didn't already know about into both the map + * and initialCount, and freezes existing entries even if the scan saw a + * different value (player builds keep showing as level progress).
  • + *
+ */ + @Test + void testReconcileHandicapChunksContract() throws Exception { + // First call ever — empty map, zero initialCount, regular scan: every + // scanned chunk is "new" and gets credited. This is the normal fresh- + // island path when zero scan hasn't happened (defensive — exercises + // the "no legacy migration" branch). + IslandLevels fresh = new IslandLevels(uuid.toString()); + when(handler.loadObject(uuid.toString())).thenReturn(fresh); + + Map firstScan = Map.of("world:1", 100L, "world:2", 50L); + long delta = lm.reconcileHandicapChunks(island, firstScan, false); + assertEquals(150L, delta, "every chunk is new on a fresh island"); + assertEquals(150L, fresh.getInitialCount(), "initialCount += delta"); + assertEquals(2, fresh.getHandicapChunks().size()); + + // Frozen value: re-scan the same chunks at a different value (e.g. + // player built more blocks in chunk world:1). The persisted map + // doesn't update, the delta is 0, and player progress goes through + // the live scan total instead of into the handicap. + Map rescan = Map.of("world:1", 500L, "world:2", 50L); + long delta2 = lm.reconcileHandicapChunks(island, rescan, false); + assertEquals(0L, delta2, "known chunks are frozen — no double-credit"); + assertEquals(150L, fresh.getInitialCount(), "initialCount stays put"); + assertEquals(100L, fresh.getHandicapChunks().get("world:1")); + + // Self-heal: a new chunk shows up that the lazy-zero listener missed + // (e.g. pre-generated before the island existed). Regular scan picks + // it up and adds it to both the map and initialCount, so the level + // result this scan computes is zero for that natural terrain. + Map selfHeal = Map.of("world:1", 100L, "world:2", 50L, "world:3", 77L); + long delta3 = lm.reconcileHandicapChunks(island, selfHeal, false); + assertEquals(77L, delta3, "only the new chunk contributes to the delta"); + assertEquals(227L, fresh.getInitialCount()); + assertEquals(3, fresh.getHandicapChunks().size()); + } + + /** + * Legacy migration: empty handicapChunks + non-zero initialCount means + * an island that was zeroed before this feature existed. The current + * scan seeds the map but does NOT change initialCount — that would + * double-count the chunks already represented by the legacy count and + * push the level negative. + */ + @Test + void testReconcileHandicapChunksLegacyMigration() throws Exception { + IslandLevels legacy = new IslandLevels(uuid.toString()); + legacy.setInitialCount(5000L); // existing baseline from before the upgrade + when(handler.loadObject(uuid.toString())).thenReturn(legacy); + + Map scan = Map.of("world:1", 100L, "world:2", 200L); + long delta = lm.reconcileHandicapChunks(island, scan, false); + assertEquals(0L, delta, "no delta on legacy migration"); + assertEquals(5000L, legacy.getInitialCount(), + "initialCount preserved across the migration"); + assertEquals(2, legacy.getHandicapChunks().size(), + "map seeded from the scan"); + assertEquals(100L, legacy.getHandicapChunks().get("world:1")); + } + + /** + * Zero-scan reconciliation hard-resets the map and returns 0 (the caller + * rewrites initialCount separately via setInitialIslandCount). + */ + @Test + void testReconcileHandicapChunksZeroScanOverwrites() throws Exception { + IslandLevels data = new IslandLevels(uuid.toString()); + data.setInitialCount(9999L); + data.getHandicapChunks().put("world:stale", 1234L); + when(handler.loadObject(uuid.toString())).thenReturn(data); + + Map scan = Map.of("world:1", 10L, "world:2", 20L); + long delta = lm.reconcileHandicapChunks(island, scan, true); + assertEquals(0L, delta, "zero scans return 0 — caller does the reset"); + assertEquals(9999L, data.getInitialCount(), + "initialCount untouched — IslandActivitiesListeners sets it"); + assertEquals(2, data.getHandicapChunks().size(), + "stale entries gone, new ones in"); + assertFalse(data.getHandicapChunks().containsKey("world:stale")); + } + + /** + * The listener-side {@link LevelsManager#addHandicapChunk} freezes the + * chunk value the first time it's recorded — a second call with a + * different value (e.g. the same chunk firing twice through some + * external pregen pipeline) is a no-op for both the map and initialCount. + */ + @Test + void testAddHandicapChunkFreezesValue() throws Exception { + IslandLevels data = new IslandLevels(uuid.toString()); + when(handler.loadObject(uuid.toString())).thenReturn(data); + + assertTrue(lm.addHandicapChunk(island, "world:42", 200L)); + assertEquals(200L, data.getInitialCount()); + + // Second call: chunk already known, both map and initialCount stay put. + assertFalse(lm.addHandicapChunk(island, "world:42", 999L)); + assertEquals(200L, data.getInitialCount()); + assertEquals(200L, data.getHandicapChunks().get("world:42")); + } + + /** + * Pin down that completePendingZero never lets the per-island counter drift + * negative and removes the map entry when it reaches zero. Without these + * guarantees the map would leak one AtomicInteger per island that ever had + * a queued snapshot, and an extra complete (e.g. from a duplicated chunk + * event) would push the counter below zero so awaitPendingZeros could + * release a scan before later-queued snapshots had landed. + */ + @Test + void testPendingZeroClampsAndRemovesEntry() { + // No add → complete is a no-op and leaves no entry behind. + assertEquals(0, lm.getPendingZeroCount(island)); + lm.completePendingZero(island); + assertEquals(0, lm.getPendingZeroCount(island)); + + // Pair of add/complete: counter ends at 0 and the entry is dropped. + lm.addPendingZero(island); + assertEquals(1, lm.getPendingZeroCount(island)); + lm.completePendingZero(island); + assertEquals(0, lm.getPendingZeroCount(island)); + + // Extra complete after the counter is empty cannot push it negative. + lm.completePendingZero(island); + assertEquals(0, lm.getPendingZeroCount(island)); + + // Add → complete → add still tracks the second add correctly even + // though the first cycle removed the map entry mid-flight. + lm.addPendingZero(island); + lm.completePendingZero(island); + lm.addPendingZero(island); + assertEquals(1, lm.getPendingZeroCount(island)); + lm.completePendingZero(island); + assertEquals(0, lm.getPendingZeroCount(island)); } /** diff --git a/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java b/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java index c77cf7a..43c58eb 100644 --- a/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java +++ b/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java @@ -1,6 +1,7 @@ package world.bentobox.level.listeners; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -8,6 +9,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Collections; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -102,27 +105,29 @@ void testOnIslandResettedZeroNewIslandLevelsTrue() { void testZeroIslandFoldsInDeferredListenerCredits() { // Pin down the post-scan drain: setInitialIslandCount is called with // scan.totalPoints, then any listener-during-scan credits for chunks - // the scan didn't visit are folded in via addToInitialCount. Without - // this, mid-scan chunk generation would silently leave a positive - // delta and the island would never read level=0 right after reset. + // the scan didn't visit are reconciled into the per-chunk handicap + // map (which also updates initialCount). Without this, mid-scan chunk + // generation would silently leave a positive delta and the island + // would never read level=0 right after reset. when(settings.isZeroNewIslandLevels()).thenReturn(true); - when(manager.drainZeroScanDeferred(island)).thenReturn(42L); + Map missed = Map.of("world:42", 42L); + when(manager.drainZeroScanDeferred(island)).thenReturn(missed); IslandResettedEvent event = new IslandResettedEvent(island, uuid, false, location, island); listener.onNewIsland(event); verify(manager).setInitialIslandCount(island, 100L); - verify(manager).addToInitialCount(island, 42L); + verify(manager).reconcileHandicapChunks(island, missed, false); } @Test void testZeroIslandSkipsAddWhenNoDeferredCredits() { - // Drain returns 0 → no addToInitialCount, since the +0 noop would - // otherwise pad the database write path with a no-op save. + // Empty drain → no reconciliation call, since the no-op would + // otherwise pad the database write path with a redundant save. when(settings.isZeroNewIslandLevels()).thenReturn(true); - when(manager.drainZeroScanDeferred(island)).thenReturn(0L); + when(manager.drainZeroScanDeferred(island)).thenReturn(Collections.emptyMap()); IslandResettedEvent event = new IslandResettedEvent(island, uuid, false, location, island); listener.onNewIsland(event); verify(manager).setInitialIslandCount(island, 100L); - verify(manager, never()).addToInitialCount(any(), anyLong()); + verify(manager, never()).reconcileHandicapChunks(any(), any(), anyBoolean()); } @Test From f254f041d514fb98d091ea011e079e433465b1b3 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 23 May 2026 23:29:58 -0700 Subject: [PATCH 14/18] fix: refresh initialCount in tidyUp to close zero-scan/regular-scan race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a player runs /level shortly after island creation and the zero scan is parked on awaitPendingZeros for newly-generated chunks (typical when /po create teleports the player and that fires isNewChunk events inside the island bounds), the regular scan's constructor reads DB initialCount before the zero scan's setInitialIslandCount has fired, snapshotting initialCount=0 into results. By the time the regular scan's tidyUp runs, the zero scan has finished, persisted is populated, and reconcile returns delta=0 — so results.initialCount stays at 0 and the level math treats every block as player-placed (level = totalPoints / level_cost). Refresh results.initialCount from the DB after reconcile so the live computation picks up: (a) a zero scan's setInitialIslandCount that landed between our constructor and now, (b) any reconcile delta this scan just folded in. This produces level=0 on a fresh island regardless of whether the regular scan or the zero scan wins the race to tidyUp. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../calculators/IslandLevelCalculator.java | 21 ++++++++++++---- .../IslandLevelCalculatorTidyUpTest.java | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index b819ee4..4c4d611 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -865,13 +865,24 @@ public void tidyUp() { // doesn't already know about is added to both the map and the // initialCount — that's what catches chunks pregenerated before the // island existed, async-load misses at zero-scan time, and late - // chunk decoration. The returned delta is folded into the in-memory - // results so the level math below sees the up-to-date baseline. + // chunk decoration. if (addon.getSettings().isZeroNewIslandLevels() && !addon.getSettings().isDonationsOnly() && !scannedChunkValues.isEmpty()) { - long delta = addon.getManager().reconcileHandicapChunks(island, scannedChunkValues, zeroIsland); - if (delta != 0L && !zeroIsland) { - results.initialCount.addAndGet(delta); + addon.getManager().reconcileHandicapChunks(island, scannedChunkValues, zeroIsland); + if (!zeroIsland) { + // Race-safe refresh of the in-memory baseline. Between the + // constructor's snapshot and now, two updates can land on the + // database that this calculator instance won't otherwise see: + // (a) a still-in-flight zero scan's setInitialIslandCount — + // happens when the player runs /level seconds after island + // creation while the zero scan is parked on + // awaitPendingZeros for newly-generated chunks; + // (b) the reconcile call above, which folds chunks the scan + // just discovered into the initialCount. + // The level math below uses results.initialCount, so reading + // the DB value back here keeps the live computation aligned + // with the actual persisted baseline. + results.initialCount.set(addon.getInitialIslandCount(island)); } } diff --git a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java index 9dfcf81..18c9b97 100644 --- a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java +++ b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java @@ -232,6 +232,31 @@ void donatedBlocksExceedLimit() { "donated points should be capped at 500 * 3 = 1500, not 1000 * 3 = 3000"); } + @Test + @DisplayName("Race: zero scan setInitialIslandCount lands after constructor — refresh keeps level math honest") + void initialCountRefreshAfterReconcile() { + // Constructor reads 0 (zero scan still in flight, hasn't called + // setInitialIslandCount yet). By the time tidyUp runs, the zero scan + // has finished and the DB has 130. Without the refresh, results + // .initialCount stays at 0 from the constructor and the level math + // computes 200/100=2 instead of the correct (200-130)/100=0. The + // refresh inside tidyUp picks up the updated DB value, producing the + // expected level. + when(addon.getInitialIslandCount(any(Island.class))).thenReturn(0L, INITIAL_COUNT); + when(manager.getInitialCount(any(Island.class))).thenReturn(INITIAL_COUNT); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + // Force a non-empty scanned-chunk map so the reconcile/refresh branch fires. + calc.getScannedChunkValues().put("world:1", 200L); + r.rawBlockCount.set(200L); + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), + "level should be 0 — refresh pulls the up-to-date initialCount=130 from the DB, " + + "so modifiedPoints = 200 - 130 = 70 → 0 levels"); + } + @Test @DisplayName("Donated blocks with no limit: full count is used") void donatedBlocksNoLimit() { From 7715a12751002c8d74388d9e2d9592c299f9a2fc Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 24 May 2026 17:20:44 -0700 Subject: [PATCH 15/18] debug: delay zero scan and log handicap drift for diagnosis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The race fix landed correctly (the latest report's level matches (total - initialCount)/level_cost so initialCount is being read right), but a 3,358-point gap still appears on a fresh island with no Poseidon pregens and no player builds. That points at decoration evolution inside already-scanned chunks (lava+water forming obsidian late, fluid simulation, trial-spawner state, chunk-border ore patches) — value the zero scan didn't see because it ran too early. Two debugging changes to narrow down what's drifting: - Lengthen the IslandCreatedEvent zero-scan delay from 150 ticks to whatever zero-scan-delay-ticks is set to (default 600 = 30s). Reuses the listener's decoration-settle window so both code paths give the world the same chance to finish generating before snapshotting. Floored at 150 so admins lowering the setting don't break the original "wait a few seconds" intent. - Log per-chunk drift on every reconcile that finds any new/growing/ shrinking chunks: counts of each plus the top 5 growing chunks by delta. Once we can see whether the gap is a handful of chunks evolving by a lot or many chunks evolving by a little, we can pick the right fix (longer delay vs absorb-on-first-scan vs something else). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../world/bentobox/level/LevelsManager.java | 53 ++++++++++++++++--- .../listeners/IslandActivitiesListeners.java | 11 +++- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java index a18b64c..eabf928 100644 --- a/src/main/java/world/bentobox/level/LevelsManager.java +++ b/src/main/java/world/bentobox/level/LevelsManager.java @@ -769,22 +769,59 @@ public long reconcileHandicapChunks(@NonNull Island island, } } long delta = 0L; + int newChunks = 0; + int growingChunks = 0; + int shrinkingChunks = 0; + long totalGrowth = 0L; + long totalShrinkage = 0L; + java.util.List> topDiffs = new java.util.ArrayList<>(); for (Map.Entry e : scannedChunkValues.entrySet()) { - if (!persisted.containsKey(e.getKey())) { - long value = e.getValue() == null ? 0L : e.getValue(); - persisted.put(e.getKey(), value); - delta += value; + long scanned = e.getValue() == null ? 0L : e.getValue(); + Long current = persisted.get(e.getKey()); + if (current == null) { + // Self-heal: any chunk the scan saw but the persisted map + // didn't know about gets folded into both the map and the + // initialCount in the same write, so the next scan reads a + // level of zero for that previously-missing terrain. + persisted.put(e.getKey(), scanned); + delta += scanned; + newChunks++; + } else { + long diff = scanned - current; + if (diff > 0) { + growingChunks++; + totalGrowth += diff; + topDiffs.add(new java.util.AbstractMap.SimpleEntry<>(e.getKey(), diff)); + } else if (diff < 0) { + shrinkingChunks++; + totalShrinkage += -diff; + } } } if (delta != 0L) { - // Self-heal: any chunk the scan saw but the persisted map didn't - // know about gets folded into both the map and the initialCount - // in the same write, so the next scan reads a level of zero for - // that previously-missing terrain. long existing = data.getInitialCount() == null ? 0L : data.getInitialCount(); data.setInitialCount(existing + delta); handler.saveObjectAsync(data); } + // Diagnostic: surface any drift between what the live scan saw and + // what the persisted handicap says. Growing chunks usually mean + // decoration evolution (lava+water → obsidian forming late, fluid + // simulation spreading, trial spawners activating, chunk-border ore + // patches completing); shrinking chunks mean a player broke + // naturally-generated blocks. Only logged when a delta exists so + // routine /level commands don't spam. + if (newChunks + growingChunks + shrinkingChunks > 0) { + addon.log(String.format( + "Handicap diagnostic for island %s: new=%d (+%,d) growing=%d (+%,d) shrinking=%d (-%,d)", + island.getUniqueId(), newChunks, delta, growingChunks, totalGrowth, + shrinkingChunks, totalShrinkage)); + topDiffs.sort((a, b) -> Long.compare(b.getValue(), a.getValue())); + int top = Math.min(5, topDiffs.size()); + for (int i = 0; i < top; i++) { + addon.log(String.format(" Top growth chunk #%d: %s +%,d", + i + 1, topDiffs.get(i).getKey(), topDiffs.get(i).getValue())); + } + } return delta; } diff --git a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java index bbf42fd..b9c4cc8 100644 --- a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java +++ b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java @@ -41,8 +41,15 @@ public IslandActivitiesListeners(Level addon) { @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onNewIsland(IslandCreatedEvent e) { if (addon.getSettings().isZeroNewIslandLevels()) { - // Wait a few seconds before performing the zero - Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> zeroIsland(e.getIsland()), 150L); + // Delay the zero scan so decoration (fluid simulation, + // late-arriving obsidian/portal frames, trial-spawner setup, + // chunk-border ore patches) has time to settle before we + // snapshot the baseline. Reuses the listener's decoration-settle + // setting (zero-scan-delay-ticks, default 600 = 30s) so both + // paths honour the same "give the world a chance to finish + // generating" window. + long delay = Math.max(150L, addon.getSettings().getZeroScanDelayTicks()); + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> zeroIsland(e.getIsland()), delay); } } From 178d85a79e5c81ecf792788da63a79f7da0013be Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 24 May 2026 17:38:16 -0700 Subject: [PATCH 16/18] fix: first /level after zero scan adopts live total as canonical baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics from a series of /po create + /po reset + /level runs surfaced three distinct sources of fresh-island drift that the per-chunk self-heal couldn't close on its own: 1. Spawner entity and chest content values are added to results.rawBlockCount in the post-scan handle* phase, not inside scanAsync's bracket, so they never make it into scannedChunkValues. A scan with 14 spawners leaves exactly 14 * 50 = 700 points of follow-up floating in totalPoints but absent from the per-chunk map — which is the 700 the user saw appear as level 7 in the first scan of one test. 2. Block-level evolution inside already-known chunks (lava+water forming obsidian after the zero scan snapshot, fluid sim propagating, trial spawner state) shows up as growing chunks (+821 in one diagnostic) that frozen-once won't absorb. 3. When the regular scan races the zero scan on a shared awaitPendingZeros counter and runs tidyUp first, persisted is still empty and the reconcile credits everything as new — but only at block-only values, leaving the entity/chest gap visible as level. (Seen as new=625 in the first scan of scenario 3, before zero scan's reconcile had fired.) Closing all three with one mechanism instead of trying to plug each: the zero scan now flags handicapPending=true and the next regular scan adopts its live totalPoints (which already includes spawners, chests, furniture, evolution) as the persisted initialCount. Subsequent scans see handicapPending=false and use the normal frozen-once self-heal, so player builds count toward level as before. The same flag is also set by the fresh-island reconcile branch (empty map + zero initialCount), so a regular scan that wins the race against zero scan still produces level=0 the first time. Legacy migration (empty map + positive initialCount) explicitly does NOT set the flag, preserving established player progress across the upgrade. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../world/bentobox/level/LevelsManager.java | 20 +++++++++- .../calculators/IslandLevelCalculator.java | 22 +++++++++++ .../bentobox/level/objects/IslandLevels.java | 32 +++++++++++++++ .../bentobox/level/LevelsManagerTest.java | 39 +++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java index eabf928..d277d19 100644 --- a/src/main/java/world/bentobox/level/LevelsManager.java +++ b/src/main/java/world/bentobox/level/LevelsManager.java @@ -749,9 +749,14 @@ public long reconcileHandicapChunks(@NonNull Island island, if (zeroScan) { // Hard reset — the new baseline is everything the scan saw. The // initial count is rewritten separately by the caller, since a - // zero scan is meant to be the canonical re-baseline. + // zero scan is meant to be the canonical re-baseline. Flag + // handicapPending so the next regular scan adopts its live total + // as the final baseline — that's what absorbs decoration + // evolution, spawner/chest follow-up values, and any + // listener-deferred chunks the per-chunk capture missed. persisted.clear(); persisted.putAll(scannedChunkValues); + data.setHandicapPending(Boolean.TRUE); handler.saveObjectAsync(data); return 0L; } @@ -762,11 +767,22 @@ public long reconcileHandicapChunks(@NonNull Island island, // feature existed. The existing initialCount already covers // every chunk currently on disk, so seeding the map without // touching initialCount keeps the level stable while moving - // future drift detection onto the per-chunk path. + // future drift detection onto the per-chunk path. Don't + // flip handicapPending here — that would wipe established + // player progress on the next /level. persisted.putAll(scannedChunkValues); handler.saveObjectAsync(data); return 0L; } + // Fresh island whose zero scan either hasn't run yet, hasn't + // finalised yet (waiting on awaitPendingZeros), or finished + // out-of-order with this regular scan. Flag handicapPending so + // tidyUp's baseline finalisation adopts the live total as the + // canonical baseline. Without this, the per-chunk reconcile + // below seeds the map with block-only values, leaving the + // spawner/chest follow-up values floating in totalPoints and + // appearing as level on a fresh island. + data.setHandicapPending(Boolean.TRUE); } long delta = 0L; int newChunks = 0; diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 4c4d611..892e49f 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -925,6 +925,28 @@ public void tidyUp() { long blockAndDeathPoints = this.results.rawBlockCount.get(); this.results.totalPoints.set(blockAndDeathPoints); + // First regular scan after a zero scan: adopt the live total as the + // canonical baseline. The zero scan set handicapPending=true; this + // overwrite absorbs decoration evolution, listener-deferred chunks, + // spawner/chest follow-up values, and any race between zero scan + // and regular scan into one consistent number, so the player sees + // level=0 on a fresh island. Subsequent regular scans see + // handicapPending=false and use the normal frozen-once handicap so + // player builds count toward level. + if (!zeroIsland && addon.getSettings().isZeroNewIslandLevels() + && !addon.getSettings().isDonationsOnly()) { + world.bentobox.level.objects.IslandLevels data = + addon.getManager().getLevelsData(island); + if (data != null && Boolean.TRUE.equals(data.getHandicapPending())) { + long total = this.results.totalPoints.get(); + addon.getManager().setInitialIslandCount(island, total); + data.setHandicapPending(Boolean.FALSE); + results.initialCount.set(total); + addon.log("Handicap baseline finalised at " + total + + " for island " + island.getUniqueId()); + } + } + if (this.addon.getSettings().getDeathPenalty() > 0) { // Proper death penalty calculation. blockAndDeathPoints -= this.results.deathHandicap.get() * this.addon.getSettings().getDeathPenalty(); diff --git a/src/main/java/world/bentobox/level/objects/IslandLevels.java b/src/main/java/world/bentobox/level/objects/IslandLevels.java index dcf5a9a..9c333d0 100644 --- a/src/main/java/world/bentobox/level/objects/IslandLevels.java +++ b/src/main/java/world/bentobox/level/objects/IslandLevels.java @@ -119,6 +119,22 @@ public class IslandLevels implements DataObject { @Expose private Map handicapChunks; + /** + * Set to {@code true} when a zero scan has just populated the handicap + * map but the player hasn't run {@code /level} yet; the next regular + * scan adopts its live total as the canonical baseline, absorbing any + * decoration evolution, listener-deferred chunks, and spawner/chest + * follow-up values that fell outside the per-chunk capture. Cleared + * back to {@code false} on that overwrite so subsequent scans see the + * normal frozen-once handicap. + *

+ * Null on legacy data — treated as false, so existing islands aren't + * accidentally rebaselined on upgrade (which would wipe player + * progress). + */ + @Expose + private Boolean handicapPending; + /** * Constructor for new island * @param islandUUID - island UUID @@ -393,6 +409,22 @@ public void setHandicapChunks(Map handicapChunks) { this.handicapChunks = handicapChunks; } + /** + * @return {@code true} if a zero scan has just finished and the next + * regular scan should adopt its live total as the baseline. + * Null on legacy data (treated as false by callers). + */ + public Boolean getHandicapPending() { + return handicapPending; + } + + /** + * @param handicapPending the handicapPending to set + */ + public void setHandicapPending(Boolean handicapPending) { + this.handicapPending = handicapPending; + } + /** * @return the initialLevel * @deprecated only used for backwards compatibility. Use {@link #getInitialCount()} instead diff --git a/src/test/java/world/bentobox/level/LevelsManagerTest.java b/src/test/java/world/bentobox/level/LevelsManagerTest.java index 993f67e..c5508cd 100644 --- a/src/test/java/world/bentobox/level/LevelsManagerTest.java +++ b/src/test/java/world/bentobox/level/LevelsManagerTest.java @@ -528,6 +528,45 @@ void testReconcileHandicapChunksLegacyMigration() throws Exception { assertEquals(100L, legacy.getHandicapChunks().get("world:1")); } + /** + * Pin down that zero-scan reconcile sets handicapPending=true and a + * fresh-island reconcile (empty map + zero initialCount) does the same. + * tidyUp will then adopt the live scan total as the canonical baseline. + * Legacy migration (empty map + positive initialCount) must NOT set the + * flag — flipping it on an old island would wipe established player + * progress on the next /level. + */ + @Test + void testReconcileSetsHandicapPendingForBaselineFinalisation() throws Exception { + // Zero scan: pending=true so first regular scan overwrites. + IslandLevels zeroScanData = new IslandLevels(uuid.toString()); + when(handler.loadObject(uuid.toString())).thenReturn(zeroScanData); + lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), true); + assertEquals(Boolean.TRUE, zeroScanData.getHandicapPending(), + "zero scan flags the next regular scan for baseline overwrite"); + + // Fresh island, no prior scan: pending=true so the very first regular + // scan establishes the baseline correctly even if zero scan raced. + UUID otherId = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn(otherId.toString()); + IslandLevels freshData = new IslandLevels(otherId.toString()); + when(handler.loadObject(otherId.toString())).thenReturn(freshData); + lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), false); + assertEquals(Boolean.TRUE, freshData.getHandicapPending(), + "fresh-island regular scan flags itself for baseline overwrite"); + + // Legacy migration: pending stays null. Old islands keep their + // established progress across the upgrade. + UUID legacyId = UUID.randomUUID(); + when(island.getUniqueId()).thenReturn(legacyId.toString()); + IslandLevels legacyData = new IslandLevels(legacyId.toString()); + legacyData.setInitialCount(5000L); + when(handler.loadObject(legacyId.toString())).thenReturn(legacyData); + lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), false); + assertEquals(null, legacyData.getHandicapPending(), + "legacy migration does NOT flag baseline overwrite"); + } + /** * Zero-scan reconciliation hard-resets the map and returns 0 (the caller * rewrites initialCount separately via setInitialIslandCount). From ce6e7a231c5a136baace62959619e6dcad171929 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sun, 24 May 2026 18:26:55 -0700 Subject: [PATCH 17/18] Revert "fix: first /level after zero scan adopts live total as canonical baseline" This reverts commit 178d85a. The overwrite was wrong: it made the first regular /level after a zero scan adopt the live totalPoints as the baseline, which absorbed any blocks the player had placed before that /level into the handicap. A fresh SkyBlock island with three diamond blocks reported level 0, defeating the entire point of the level addon. Kept in place (still correct, addresses real bugs): - per-chunk handicap reconcile + addHandicapChunk (22f20bb) - results.initialCount refresh in tidyUp (f254f04) - 30s zero-scan delay + drift diagnostic logging (7715a12) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../world/bentobox/level/LevelsManager.java | 20 +--------- .../calculators/IslandLevelCalculator.java | 22 ----------- .../bentobox/level/objects/IslandLevels.java | 32 --------------- .../bentobox/level/LevelsManagerTest.java | 39 ------------------- 4 files changed, 2 insertions(+), 111 deletions(-) diff --git a/src/main/java/world/bentobox/level/LevelsManager.java b/src/main/java/world/bentobox/level/LevelsManager.java index d277d19..eabf928 100644 --- a/src/main/java/world/bentobox/level/LevelsManager.java +++ b/src/main/java/world/bentobox/level/LevelsManager.java @@ -749,14 +749,9 @@ public long reconcileHandicapChunks(@NonNull Island island, if (zeroScan) { // Hard reset — the new baseline is everything the scan saw. The // initial count is rewritten separately by the caller, since a - // zero scan is meant to be the canonical re-baseline. Flag - // handicapPending so the next regular scan adopts its live total - // as the final baseline — that's what absorbs decoration - // evolution, spawner/chest follow-up values, and any - // listener-deferred chunks the per-chunk capture missed. + // zero scan is meant to be the canonical re-baseline. persisted.clear(); persisted.putAll(scannedChunkValues); - data.setHandicapPending(Boolean.TRUE); handler.saveObjectAsync(data); return 0L; } @@ -767,22 +762,11 @@ public long reconcileHandicapChunks(@NonNull Island island, // feature existed. The existing initialCount already covers // every chunk currently on disk, so seeding the map without // touching initialCount keeps the level stable while moving - // future drift detection onto the per-chunk path. Don't - // flip handicapPending here — that would wipe established - // player progress on the next /level. + // future drift detection onto the per-chunk path. persisted.putAll(scannedChunkValues); handler.saveObjectAsync(data); return 0L; } - // Fresh island whose zero scan either hasn't run yet, hasn't - // finalised yet (waiting on awaitPendingZeros), or finished - // out-of-order with this regular scan. Flag handicapPending so - // tidyUp's baseline finalisation adopts the live total as the - // canonical baseline. Without this, the per-chunk reconcile - // below seeds the map with block-only values, leaving the - // spawner/chest follow-up values floating in totalPoints and - // appearing as level on a fresh island. - data.setHandicapPending(Boolean.TRUE); } long delta = 0L; int newChunks = 0; diff --git a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java index 892e49f..4c4d611 100644 --- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java +++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java @@ -925,28 +925,6 @@ public void tidyUp() { long blockAndDeathPoints = this.results.rawBlockCount.get(); this.results.totalPoints.set(blockAndDeathPoints); - // First regular scan after a zero scan: adopt the live total as the - // canonical baseline. The zero scan set handicapPending=true; this - // overwrite absorbs decoration evolution, listener-deferred chunks, - // spawner/chest follow-up values, and any race between zero scan - // and regular scan into one consistent number, so the player sees - // level=0 on a fresh island. Subsequent regular scans see - // handicapPending=false and use the normal frozen-once handicap so - // player builds count toward level. - if (!zeroIsland && addon.getSettings().isZeroNewIslandLevels() - && !addon.getSettings().isDonationsOnly()) { - world.bentobox.level.objects.IslandLevels data = - addon.getManager().getLevelsData(island); - if (data != null && Boolean.TRUE.equals(data.getHandicapPending())) { - long total = this.results.totalPoints.get(); - addon.getManager().setInitialIslandCount(island, total); - data.setHandicapPending(Boolean.FALSE); - results.initialCount.set(total); - addon.log("Handicap baseline finalised at " + total - + " for island " + island.getUniqueId()); - } - } - if (this.addon.getSettings().getDeathPenalty() > 0) { // Proper death penalty calculation. blockAndDeathPoints -= this.results.deathHandicap.get() * this.addon.getSettings().getDeathPenalty(); diff --git a/src/main/java/world/bentobox/level/objects/IslandLevels.java b/src/main/java/world/bentobox/level/objects/IslandLevels.java index 9c333d0..dcf5a9a 100644 --- a/src/main/java/world/bentobox/level/objects/IslandLevels.java +++ b/src/main/java/world/bentobox/level/objects/IslandLevels.java @@ -119,22 +119,6 @@ public class IslandLevels implements DataObject { @Expose private Map handicapChunks; - /** - * Set to {@code true} when a zero scan has just populated the handicap - * map but the player hasn't run {@code /level} yet; the next regular - * scan adopts its live total as the canonical baseline, absorbing any - * decoration evolution, listener-deferred chunks, and spawner/chest - * follow-up values that fell outside the per-chunk capture. Cleared - * back to {@code false} on that overwrite so subsequent scans see the - * normal frozen-once handicap. - *

- * Null on legacy data — treated as false, so existing islands aren't - * accidentally rebaselined on upgrade (which would wipe player - * progress). - */ - @Expose - private Boolean handicapPending; - /** * Constructor for new island * @param islandUUID - island UUID @@ -409,22 +393,6 @@ public void setHandicapChunks(Map handicapChunks) { this.handicapChunks = handicapChunks; } - /** - * @return {@code true} if a zero scan has just finished and the next - * regular scan should adopt its live total as the baseline. - * Null on legacy data (treated as false by callers). - */ - public Boolean getHandicapPending() { - return handicapPending; - } - - /** - * @param handicapPending the handicapPending to set - */ - public void setHandicapPending(Boolean handicapPending) { - this.handicapPending = handicapPending; - } - /** * @return the initialLevel * @deprecated only used for backwards compatibility. Use {@link #getInitialCount()} instead diff --git a/src/test/java/world/bentobox/level/LevelsManagerTest.java b/src/test/java/world/bentobox/level/LevelsManagerTest.java index c5508cd..993f67e 100644 --- a/src/test/java/world/bentobox/level/LevelsManagerTest.java +++ b/src/test/java/world/bentobox/level/LevelsManagerTest.java @@ -528,45 +528,6 @@ void testReconcileHandicapChunksLegacyMigration() throws Exception { assertEquals(100L, legacy.getHandicapChunks().get("world:1")); } - /** - * Pin down that zero-scan reconcile sets handicapPending=true and a - * fresh-island reconcile (empty map + zero initialCount) does the same. - * tidyUp will then adopt the live scan total as the canonical baseline. - * Legacy migration (empty map + positive initialCount) must NOT set the - * flag — flipping it on an old island would wipe established player - * progress on the next /level. - */ - @Test - void testReconcileSetsHandicapPendingForBaselineFinalisation() throws Exception { - // Zero scan: pending=true so first regular scan overwrites. - IslandLevels zeroScanData = new IslandLevels(uuid.toString()); - when(handler.loadObject(uuid.toString())).thenReturn(zeroScanData); - lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), true); - assertEquals(Boolean.TRUE, zeroScanData.getHandicapPending(), - "zero scan flags the next regular scan for baseline overwrite"); - - // Fresh island, no prior scan: pending=true so the very first regular - // scan establishes the baseline correctly even if zero scan raced. - UUID otherId = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn(otherId.toString()); - IslandLevels freshData = new IslandLevels(otherId.toString()); - when(handler.loadObject(otherId.toString())).thenReturn(freshData); - lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), false); - assertEquals(Boolean.TRUE, freshData.getHandicapPending(), - "fresh-island regular scan flags itself for baseline overwrite"); - - // Legacy migration: pending stays null. Old islands keep their - // established progress across the upgrade. - UUID legacyId = UUID.randomUUID(); - when(island.getUniqueId()).thenReturn(legacyId.toString()); - IslandLevels legacyData = new IslandLevels(legacyId.toString()); - legacyData.setInitialCount(5000L); - when(handler.loadObject(legacyId.toString())).thenReturn(legacyData); - lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), false); - assertEquals(null, legacyData.getHandicapPending(), - "legacy migration does NOT flag baseline overwrite"); - } - /** * Zero-scan reconciliation hard-resets the map and returns 0 (the caller * rewrites initialCount separately via setInitialIslandCount). From f562e96531b0cfda44d7e225330dad9e5188c1a7 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 29 May 2026 17:25:49 -0700 Subject: [PATCH 18/18] Pin MockBukkit to Maven Central 4.110.0 instead of jitpack snapshot The floating jitpack snapshot v1.21-SNAPSHOT resolves to an ephemeral git-described build whose jar/POM get evicted, breaking CI even on unchanged commits. Switch to the equivalent stable Maven Central coordinates (org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.110.0). Co-Authored-By: Claude Opus 4.8 --- pom.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 687951e..ab56300 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,7 @@ 5.10.2 5.11.0 - v1.21-SNAPSHOT + 4.110.0 1.21.11-R0.1-SNAPSHOT 3.15.1-SNAPSHOT @@ -182,8 +182,8 @@ - com.github.MockBukkit - MockBukkit + org.mockbukkit.mockbukkit + mockbukkit-v1.21 ${mock-bukkit.version} test