From c210d66ede080a769e4fecf8493191e3ac4a55bb Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 16 May 2026 17:15:33 -0700 Subject: [PATCH 1/3] 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 c18e98ca..687951e6 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 5ede5372..ac950192 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 47905e11..3aca6cdb 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 252083e7..c7b4df5b 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 af28d6f7..42c3bdf2 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 85bef5bc..257f808d 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 fbd7f881..aa18eafb 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 51398b90..2c922005 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 189631d8..16492882 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 82f5f68d..d8d55f79 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 2cc89aa3..a464064a 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 c221e1e7..6b54d65c 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 b9f4af5a..009040bd 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 0e64a8ac..1c8b7f77 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 327dc155..8536c76b 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 d8494922..036929f4 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 27189632..3fdb2bb2 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 710fde47..fefe2276 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 81e040df..01e0fe47 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 7a296346..8d0fcb78 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 eeecc961..94d41896 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 816165ee..2a212f97 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 2/3] 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 3ecdf88a..49f704a2 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 3/3] 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 3d62ba9d..f0fd1f50 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 ac950192..53bb296c 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 3aca6cdb..9fd20a07 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 c7b4df5b..49e716ca 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 ef470032..f07df3cf 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 f139d70b..534f930e 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 49f704a2..b9323026 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 42c3bdf2..d9ed72bb 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 257f808d..0720187e 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 aa18eafb..1d4de1b5 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 2c922005..a130a515 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 16492882..2a747846 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 d8d55f79..87dae781 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 a464064a..e1bd8f75 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 6b54d65c..3f9349fc 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 009040bd..4033c9a8 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 1c8b7f77..11510376 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 8536c76b..01453fca 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 036929f4..6d00e1a3 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 3fdb2bb2..edaa61fe 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 fefe2276..4d029838 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 01e0fe47..06678eb3 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 8d0fcb78..b967b717 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 94d41896..974ce32a 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 91d841d0..fc7d9f11 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 00000000..545989b5 --- /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 2a212f97..3d98c619 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 d0d80fb6..c77cf7ad 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);