diff --git a/pom.xml b/pom.xml
index c18e98c..ab56300 100644
--- a/pom.xml
+++ b/pom.xml
@@ -54,7 +54,7 @@
5.10.25.11.0
- v1.21-SNAPSHOT
+ 4.110.01.21.11-R0.1-SNAPSHOT3.15.1-SNAPSHOT
@@ -69,7 +69,7 @@
-LOCAL
- 2.27.0
+ 2.28.0BentoBoxWorld_Levelbentobox-worldhttps://sonarcloud.io
@@ -182,8 +182,8 @@
- com.github.MockBukkit
- MockBukkit
+ org.mockbukkit.mockbukkit
+ mockbukkit-v1.21${mock-bukkit.version}test
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..eabf928 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,38 @@ 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). Keys are full
+ * {@code worldName:chunkKey} strings so chunks at the same {@code (x,z)}
+ * in different dimensions don't collide.
+ */
+ private final Map> zeroScanVisitedChunks = new ConcurrentHashMap<>();
+ /**
+ * 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. Keys mirror {@link #zeroScanVisitedChunks} —
+ * {@code worldName:chunkKey} — so the same chunk position in different
+ * dimensions is tracked separately.
+ */
+ private final Map> zeroScanDeferredCredits = new ConcurrentHashMap<>();
public LevelsManager(Level addon) {
this.addon = addon;
@@ -480,7 +514,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 +523,334 @@ 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);
+ }
+
+ // ---- 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. The increment runs inside {@code compute} so it is
+ * atomic with the removal done by {@code completePendingZero} — a
+ * decrement that empties the entry cannot race a concurrent add into
+ * leaving an orphaned counter outside the map.
+ */
+ public void addPendingZero(@NonNull Island island) {
+ pendingZeros.compute(island.getUniqueId(), (k, c) -> {
+ if (c == null) {
+ c = new AtomicInteger();
+ }
+ c.incrementAndGet();
+ return c;
+ });
+ }
+
+ /**
+ * Mark that a previously {@link #addPendingZero queued} snapshot has
+ * finished. Safe to call from any thread. Clamps at zero (an extra
+ * complete with no matching add is a no-op rather than producing a
+ * negative count that would let {@link #getPendingZeroCount} read 0 and
+ * release a scan early) and drops the per-island map entry once the
+ * counter reaches zero so the map size stays bounded by the number of
+ * islands with in-flight work, not the total number of zero scans ever.
+ */
+ public void completePendingZero(@NonNull Island island) {
+ pendingZeros.compute(island.getUniqueId(), (k, c) -> {
+ if (c == null) {
+ return null;
+ }
+ if (c.get() <= 0) {
+ // No matching add — drop the orphaned entry instead of
+ // letting it drift negative.
+ return null;
+ }
+ int v = c.decrementAndGet();
+ return v <= 0 ? null : c;
+ });
+ }
+
+ /**
+ * @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
+ * in the given world. Called from the scanner on the worker thread.
+ */
+ public void recordScanVisitedChunk(@NonNull Island island, @NonNull String worldName,
+ int chunkX, int chunkZ) {
+ Set set = zeroScanVisitedChunks.get(island.getUniqueId());
+ if (set != null) {
+ set.add(worldName + ":" + chunkKey(chunkX, chunkZ));
+ }
+ }
+
+ /**
+ * Try to record a listener credit during an active zero scan. If no
+ * scan is active for this island, returns false and the caller should
+ * fall back to {@link #addHandicapChunk}. If a scan is active, the
+ * credit is stored against the {@code worldName:chunkKey} for later
+ * processing by {@link #drainZeroScanDeferred}.
+ */
+ public boolean tryDeferZeroScanCredit(@NonNull Island island, @NonNull String worldName,
+ int chunkX, int chunkZ, long value) {
+ Map deferred = zeroScanDeferredCredits.get(island.getUniqueId());
+ if (deferred == null) {
+ return false;
+ }
+ deferred.put(worldName + ":" + chunkKey(chunkX, chunkZ), value);
+ return true;
+ }
+
+ /**
+ * End the active zero scan for {@code island} and return the deferred
+ * listener credits for chunks the scan did NOT visit, keyed by
+ * {@code worldName:chunkKey}. The caller passes this map to {@link
+ * #reconcileHandicapChunks} so the missed chunks become part of the
+ * persisted per-chunk handicap and are added to the initial count in one
+ * atomic save. Chunks the scan visited are dropped because their values
+ * are already in {@code results.totalPoints}.
+ */
+ public Map drainZeroScanDeferred(@NonNull Island island) {
+ String id = island.getUniqueId();
+ Set visited = zeroScanVisitedChunks.remove(id);
+ Map deferred = zeroScanDeferredCredits.remove(id);
+ if (deferred == null || deferred.isEmpty()) {
+ return Collections.emptyMap();
+ }
+ Map missed = new HashMap<>();
+ for (Map.Entry e : deferred.entrySet()) {
+ if (visited == null || !visited.contains(e.getKey())) {
+ missed.put(e.getKey(), e.getValue());
+ }
+ }
+ return missed;
+ }
+
+ // ---- Per-chunk handicap reconciliation ----
+
+ /**
+ * Fold per-chunk scan results into the island's persistent handicap-chunks
+ * map and return the net delta to add to the initial-count handicap.
+ *
+ * Behaviour:
+ *
+ *
Zero scan: the map is overwritten with {@code scannedChunkValues}.
+ * The caller is expected to {@code setInitialIslandCount(island, totalPoints)}
+ * (a hard reset of the baseline), so the delta returned here is 0 — the new
+ * baseline is conveyed via the totalPoints write, not added on top.
+ *
Regular scan, empty existing map, non-zero initialCount: legacy
+ * migration. Seed the map with the current scan values without touching
+ * initialCount — the player's existing level progress is preserved, and
+ * from this scan onward the map is the source of truth. Returns 0.
+ *
Regular scan, normal case: for every chunk in
+ * {@code scannedChunkValues} whose key is not already in the persisted map,
+ * add the value to the map and accumulate it into the return delta. The
+ * caller adds that delta to initialCount, which is what makes the handicap
+ * self-healing: chunks the lazy-zero listener missed get credited the
+ * first time a regular level scan visits them.
+ *
+ * Once a chunk is in the map its value is frozen — subsequent growth
+ * (player builds) increases the live scan total without changing the
+ * handicap, which is the intended behaviour. Subsequent shrinkage (player
+ * breaks naturally-generated blocks) likewise leaves the handicap alone;
+ * the negative {@code totalPoints - initialCount} delta correctly drags
+ * the level below zero.
+ *
+ * @param island the island being reconciled
+ * @param scannedChunkValues per-chunk values from the calculator, keyed
+ * by {@code worldName:chunkKey}
+ * @param zeroScan true when this is a zero-island handicap
+ * scan (overwrites the map)
+ * @return the number of points to add to the island's initial count; 0
+ * for zero scans and legacy-migration scans
+ */
+ public long reconcileHandicapChunks(@NonNull Island island,
+ @NonNull Map scannedChunkValues, boolean zeroScan) {
+ IslandLevels data = getLevelsData(island);
+ Map persisted = data.getHandicapChunks();
+ if (zeroScan) {
+ // Hard reset — the new baseline is everything the scan saw. The
+ // initial count is rewritten separately by the caller, since a
+ // zero scan is meant to be the canonical re-baseline.
+ persisted.clear();
+ persisted.putAll(scannedChunkValues);
+ handler.saveObjectAsync(data);
+ return 0L;
+ }
+ if (persisted.isEmpty()) {
+ Long existingCount = data.getInitialCount();
+ if (existingCount != null && existingCount > 0L) {
+ // Legacy migration: an island that was zeroed before this
+ // feature existed. The existing initialCount already covers
+ // every chunk currently on disk, so seeding the map without
+ // touching initialCount keeps the level stable while moving
+ // future drift detection onto the per-chunk path.
+ persisted.putAll(scannedChunkValues);
+ handler.saveObjectAsync(data);
+ return 0L;
+ }
+ }
+ long delta = 0L;
+ int newChunks = 0;
+ int growingChunks = 0;
+ int shrinkingChunks = 0;
+ long totalGrowth = 0L;
+ long totalShrinkage = 0L;
+ java.util.List> topDiffs = new java.util.ArrayList<>();
+ for (Map.Entry e : scannedChunkValues.entrySet()) {
+ long scanned = e.getValue() == null ? 0L : e.getValue();
+ Long current = persisted.get(e.getKey());
+ if (current == null) {
+ // Self-heal: any chunk the scan saw but the persisted map
+ // didn't know about gets folded into both the map and the
+ // initialCount in the same write, so the next scan reads a
+ // level of zero for that previously-missing terrain.
+ persisted.put(e.getKey(), scanned);
+ delta += scanned;
+ newChunks++;
+ } else {
+ long diff = scanned - current;
+ if (diff > 0) {
+ growingChunks++;
+ totalGrowth += diff;
+ topDiffs.add(new java.util.AbstractMap.SimpleEntry<>(e.getKey(), diff));
+ } else if (diff < 0) {
+ shrinkingChunks++;
+ totalShrinkage += -diff;
+ }
+ }
+ }
+ if (delta != 0L) {
+ long existing = data.getInitialCount() == null ? 0L : data.getInitialCount();
+ data.setInitialCount(existing + delta);
+ handler.saveObjectAsync(data);
+ }
+ // Diagnostic: surface any drift between what the live scan saw and
+ // what the persisted handicap says. Growing chunks usually mean
+ // decoration evolution (lava+water → obsidian forming late, fluid
+ // simulation spreading, trial spawners activating, chunk-border ore
+ // patches completing); shrinking chunks mean a player broke
+ // naturally-generated blocks. Only logged when a delta exists so
+ // routine /level commands don't spam.
+ if (newChunks + growingChunks + shrinkingChunks > 0) {
+ addon.log(String.format(
+ "Handicap diagnostic for island %s: new=%d (+%,d) growing=%d (+%,d) shrinking=%d (-%,d)",
+ island.getUniqueId(), newChunks, delta, growingChunks, totalGrowth,
+ shrinkingChunks, totalShrinkage));
+ topDiffs.sort((a, b) -> Long.compare(b.getValue(), a.getValue()));
+ int top = Math.min(5, topDiffs.size());
+ for (int i = 0; i < top; i++) {
+ addon.log(String.format(" Top growth chunk #%d: %s +%,d",
+ i + 1, topDiffs.get(i).getKey(), topDiffs.get(i).getValue()));
+ }
+ }
+ return delta;
+ }
+
+ /**
+ * Record a single chunk's handicap value and add its value to {@link
+ * IslandLevels#getInitialCount initialCount} in one atomic save. Used by
+ * {@link world.bentobox.level.listeners.NewChunkListener} when no zero
+ * scan is active. If the chunk is already in the map, this is a no-op
+ * (frozen-once semantics — see {@link #reconcileHandicapChunks}). The
+ * deferred path used during an active zero scan goes through {@link
+ * #tryDeferZeroScanCredit} and {@link #drainZeroScanDeferred} →
+ * {@link #reconcileHandicapChunks} instead, so the same chunk is never
+ * credited twice.
+ *
+ * @return true if the chunk was newly added (and initialCount adjusted)
+ */
+ public boolean addHandicapChunk(@NonNull Island island, @NonNull String key, long value) {
+ IslandLevels data = getLevelsData(island);
+ Map persisted = data.getHandicapChunks();
+ if (persisted.containsKey(key)) {
+ return false;
+ }
+ persisted.put(key, value);
+ long existing = data.getInitialCount() == null ? 0L : data.getInitialCount();
+ data.setInitialCount(existing + value);
+ handler.saveObjectAsync(data);
+ return true;
+ }
+
/**
* 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..4c4d611 100644
--- a/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java
+++ b/src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java
@@ -60,6 +60,7 @@
import world.bentobox.bentobox.util.Pair;
import world.bentobox.bentobox.util.Util;
import world.bentobox.level.Level;
+import world.bentobox.level.LevelsManager;
import world.bentobox.level.calculators.Results.Result;
import world.bentobox.level.config.BlockConfig;
@@ -70,8 +71,21 @@ 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