From 7ab75a935b936b2798c69f4b48bb5f71fd07ec42 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 15 May 2026 07:00:19 -0700 Subject: [PATCH 1/2] 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 663f4294f6c4722211b9e4706e9e5d479695da68 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 15 May 2026 17:23:04 -0700 Subject: [PATCH 2/2] 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; + } }