diff --git a/pom.xml b/pom.xml index c18e98c..ab56300 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,7 @@ 5.10.2 5.11.0 - v1.21-SNAPSHOT + 4.110.0 1.21.11-R0.1-SNAPSHOT 3.15.1-SNAPSHOT @@ -69,7 +69,7 @@ -LOCAL - 2.27.0 + 2.28.0 BentoBoxWorld_Level bentobox-world https://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 limitCount; + /** + * Captured value per chunk visited during this scan, keyed by + * {@code worldName:chunkKey}. Filled by {@link #scanAsync} so {@link + * world.bentobox.level.LevelsManager#reconcileHandicapChunks} can + * fold any not-yet-known chunks into the per-chunk handicap map + * after the scan completes. Concurrent because furniture/spawner + * follow-up handlers may overlap with the main scan path. + */ + private final java.util.concurrent.ConcurrentMap scannedChunkValues = + new java.util.concurrent.ConcurrentHashMap<>(); private final CompletableFuture r; private final Results results; @@ -101,6 +115,7 @@ public IslandLevelCalculator(Level addon, Island island, CompletableFuture(); // Get the initial island level // TODO: results.initialLevel.set(addon.getInitialIslandLevel(island)); @@ -270,6 +285,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); @@ -305,7 +327,7 @@ private List getReport() { donatedBlocks.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .forEach(entry -> { - Integer value = addon.getBlockConfig().getBlockValues().getOrDefault(entry.getKey().toLowerCase(java.util.Locale.ENGLISH), 0); + Integer value = Objects.requireNonNullElse(addon.getBlockConfig().getValue(island.getWorld(), entry.getKey().toLowerCase(java.util.Locale.ENGLISH)), 0); long totalValue = (long) value * entry.getValue(); reportLines.add(" " + Util.prettifyText(entry.getKey()) + " x " + String.format("%,d", entry.getValue()) @@ -359,20 +381,58 @@ private CompletableFuture> getWorldChunk(Environment env, Queue> r2, World world, Queue> pairList, List chunkList) { if (pairList.isEmpty()) { r2.complete(chunkList); return; } - Pair p = pairList.poll(); - // We need to generate now all the time because some game modes are not voids - Util.getChunkAtAsync(world, p.x, p.z, true).thenAccept(chunk -> { - 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)) { + 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); + 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); + } } - loadChunks(r2, world, pairList, chunkList); // Iteration + loadChunks(r2, world, pairList, chunkList); }); } @@ -519,7 +579,55 @@ record ChunkPair(World world, Chunk chunk, ChunkSnapshot chunkSnapshot) { } private void scanAsync(ChunkPair cp) { -// Track chunks for furniture entity scanning (Oraxen and Nexo are entity-based) + // Bracket the AtomicLong counters around this chunk so we can + // capture exactly what this chunk contributed. Safe because the + // batch path in scanChunk runs scanAsync calls sequentially on the + // worker thread — there's no overlap of scanAsync invocations for + // different chunks to interleave the counters. + long preMd = results.rawBlockCount.get(); + long preUw = results.underWaterBlockCount.get(); + scanChunkBlocks(cp); + long deltaMd = results.rawBlockCount.get() - preMd; + long deltaUw = results.underWaterBlockCount.get() - preUw; + long value = deltaMd + (long) (deltaUw * addon.getSettings().getUnderWaterMultiplier()); + scannedChunkValues.put(handicapKey(cp.world, cp.chunk.getX(), cp.chunk.getZ()), value); + // Record that the zero scan visited this chunk so the post-scan drain + // skips any listener credit that was deferred for the same chunk. + if (zeroIsland) { + addon.getManager().recordScanVisitedChunk(island, cp.world.getName(), + cp.chunk.getX(), cp.chunk.getZ()); + } + } + + /** + * Compose the per-chunk handicap key — {@code worldName:chunkKey} — used + * to disambiguate chunks across the overworld, nether and end. Kept in + * sync with {@link world.bentobox.level.listeners.NewChunkListener}. + */ + public static String handicapKey(World world, int chunkX, int chunkZ) { + return world.getName() + ":" + LevelsManager.chunkKey(chunkX, chunkZ); + } + + /** + * @return per-chunk scored values captured during this scan, for the + * post-scan reconciliation done by {@link + * world.bentobox.level.LevelsManager#reconcileHandicapChunks}. + */ + public java.util.Map getScannedChunkValues() { + return scannedChunkValues; + } + + /** + * Iterate every block in {@code cp}'s intersection with the island's + * protected area and dispatch it through {@link #processBlock} — the same + * path the regular scan uses, so custom blocks, double slabs, + * spawners-as-blocks, and configured per-block values are all counted + * identically. Package-private so the new-chunk listener can score a + * freshly generated chunk via {@link #scoreNewChunkSnapshot} without + * touching the visited-chunk tracking owned by the active scan. + */ + void scanChunkBlocks(ChunkPair cp) { + // Track chunks for furniture entity scanning (Oraxen and Nexo are entity-based) if (BentoBox.getInstance().getHooks().getHook("Oraxen").isPresent() || addon.isNexo()) { furnitureChunks.add(cp.chunk); } @@ -548,6 +656,31 @@ private void scanAsync(ChunkPair cp) { } } + /** + * One-shot scoring of a single chunk snapshot for {@link + * world.bentobox.level.listeners.NewChunkListener}. Builds a throwaway + * calculator and runs {@link #scanChunkBlocks} on it so the per-block + * logic (custom blocks, double slabs, configured values, limit handling) + * is exactly what the regular scan uses — no parallel hand-rolled scan. + *

+ * The returned value already includes the underwater multiplier and is + * ready to be added to the island's initial-count handicap. Per-material + * limits are applied per call (each new chunk gets a fresh limit budget), + * since the listener has no shared state with the island-wide scan; the + * resulting handicap can be slightly higher than a full scan would credit + * for islands with many limited blocks, which simply makes the level-0 + * floor more durable for fresh islands. + */ + public static long scoreNewChunkSnapshot(Level addon, Island island, ChunkSnapshot snapshot, + Chunk chunk, World world) { + IslandLevelCalculator calc = new IslandLevelCalculator(addon, island, + new CompletableFuture<>(), true); + calc.scanChunkBlocks(new ChunkPair(world, chunk, snapshot)); + long md = calc.results.rawBlockCount.get(); + long uw = calc.results.underWaterBlockCount.get(); + return md + (long) (uw * addon.getSettings().getUnderWaterMultiplier()); + } + /** * Processes a single block from a chunk snapshot to calculate its contribution to the island's level. * This method is designed to be efficient by minimizing object creation and using direct checks. @@ -726,12 +859,55 @@ private Collection sortedReport(int total, Multiset uwCount) { * Finalizes the calculations and makes the report */ public void tidyUp() { + // Self-healing handicap reconciliation. For zero scans this overwrites + // the per-chunk map with what we just saw (the new baseline). For + // regular scans, any chunk the scan visited that the persisted map + // doesn't already know about is added to both the map and the + // initialCount — that's what catches chunks pregenerated before the + // island existed, async-load misses at zero-scan time, and late + // chunk decoration. + if (addon.getSettings().isZeroNewIslandLevels() && !addon.getSettings().isDonationsOnly() + && !scannedChunkValues.isEmpty()) { + addon.getManager().reconcileHandicapChunks(island, scannedChunkValues, zeroIsland); + if (!zeroIsland) { + // Race-safe refresh of the in-memory baseline. Between the + // constructor's snapshot and now, two updates can land on the + // database that this calculator instance won't otherwise see: + // (a) a still-in-flight zero scan's setInitialIslandCount — + // happens when the player runs /level seconds after island + // creation while the zero scan is parked on + // awaitPendingZeros for newly-generated chunks; + // (b) the reconcile call above, which folds chunks the scan + // just discovered into the initialCount. + // The level math below uses results.initialCount, so reading + // the DB value back here keeps the live computation aligned + // with the actual persisted baseline. + results.initialCount.set(addon.getInitialIslandCount(island)); + } + } + // Finalize calculations results.rawBlockCount .addAndGet((long) (results.underWaterBlockCount.get() * addon.getSettings().getUnderWaterMultiplier())); - // Add donated block points (permanent contributions that persist across recalculations) - long donatedPoints = addon.getManager().getDonatedPoints(island); + // Add donated block points (permanent contributions that persist across recalculations). + // Recalculate from the donated blocks map using current block config values so the + // level always reflects the current configuration, even if block values changed since donation. + // Also apply the current block limit: if the limit was lowered after donation, only count + // up to the current limit (donated blocks over the limit are silently ignored). + Map donatedBlocksMap = addon.getManager().getDonatedBlocks(island); + long donatedPoints = donatedBlocksMap.entrySet().stream() + .mapToLong(entry -> { + String key = entry.getKey().toLowerCase(java.util.Locale.ENGLISH); + Integer value = addon.getBlockConfig().getValue(island.getWorld(), key); + int count = entry.getValue(); + Integer limit = addon.getBlockConfig().getLimit(key); + if (limit != null) { + count = Math.min(count, limit); + } + return (long) Objects.requireNonNullElse(value, 0) * count; + }) + .sum(); results.rawBlockCount.addAndGet(donatedPoints); results.donatedPoints.set(donatedPoints); @@ -800,6 +976,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 @@ -838,12 +1050,26 @@ 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()) .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 47905e1..9fd20a0 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,34 @@ 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())); + // 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); } @@ -147,10 +165,17 @@ public void stop() { /** * @return the inProcessQueue */ - protected Map getInProcessQueue() { + public Map getInProcessQueue() { return inProcessQueue; } + /** + * @return the queue of islands waiting to start scanning + */ + public Queue getToProcessQueue() { + return toProcessQueue; + } + /** * @return the task */ diff --git a/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java b/src/main/java/world/bentobox/level/commands/AdminLevelStatusCommand.java index 252083e..49e716c 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,59 @@ 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)); + 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())); + 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), + "[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/java/world/bentobox/level/commands/IslandDonateCommand.java b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java index 80af368..58b8842 100644 --- a/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java +++ b/src/main/java/world/bentobox/level/commands/IslandDonateCommand.java @@ -124,15 +124,41 @@ private boolean handleHandDonation(User user, Island island, List args) } } - final int previewAmount = Math.min(requested, hand.getAmount()); - final long previewPoints = (long) previewAmount * blockValue; + int previewAmount = Math.min(requested, hand.getAmount()); final int finalRequested = requested; + // Apply blockconfig limit to the preview so the confirm prompt shows the + // amount that will actually be destroyed, not the raw request. Object displayKey = customId != null ? customId : material; + String donationId = customId != null ? customId : material.name(); + Object blockObj = customId != null ? (Object) customId : material; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + boolean limited = false; + if (limit != null) { + int already = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - already); + if (remaining == 0) { + user.sendMessage("island.donate.limit-reached", + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user)); + return false; + } + if (previewAmount > remaining) { + previewAmount = remaining; + limited = true; + } + } + + long previewPoints = (long) previewAmount * blockValue; String prompt = user.getTranslation("island.donate.hand.confirm-prompt", TextVariables.NUMBER, String.valueOf(previewAmount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, previewPoints)); + if (limited) { + // The limit-notice locale uses '|' as a lore line-break for the GUI; + // translate to '\n' here so the chat confirmation prompt wraps cleanly. + prompt = prompt + "\n" + + user.getTranslation("island.donate.limit-notice").replace('|', '\n'); + } askConfirmation(user, prompt, () -> performHandDonation(user, island, material, customId, blockValue, finalRequested)); return true; @@ -151,6 +177,23 @@ private void performHandDonation(User user, Island island, Material material, St return; } int amount = Math.min(requested, currentHand.getAmount()); + + // Apply blockconfig donation limit + String donationId = customId != null ? customId : material.name(); + Object blockObj = customId != null ? (Object) customId : material; + Object displayKey = customId != null ? customId : material; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + int currentDonated = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - currentDonated); + if (remaining == 0) { + user.sendMessage("island.donate.limit-reached", + MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user)); + return; + } + amount = Math.min(amount, remaining); + } + long points = (long) amount * blockValue; if (amount >= currentHand.getAmount()) { @@ -159,11 +202,9 @@ private void performHandDonation(User user, Island island, Material material, St currentHand.setAmount(currentHand.getAmount() - amount); } - String donationId = customId != null ? customId : material.name(); addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, amount, points); addon.getManager().recalculateAfterDonation(island); - Object displayKey = customId != null ? customId : material; user.sendMessage("island.donate.hand.success", TextVariables.NUMBER, String.valueOf(amount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), @@ -184,7 +225,9 @@ private boolean handleInvDonation(User user, Island island) { return false; } + Map alreadyDonated = addon.getManager().getDonatedBlocks(island); long totalPoints = 0L; + boolean limited = false; StringBuilder prompt = new StringBuilder( user.getTranslation("island.donate.inv.confirm-header")); for (Map.Entry e : totals.entrySet()) { @@ -197,13 +240,35 @@ private boolean handleInvDonation(User user, Island island) { Object displayKey = mat != null ? mat : e.getKey(); Integer rawValue = addon.getBlockConfig().getValue(getWorld(), displayKey); if (rawValue == null) continue; - long points = (long) rawValue * e.getValue(); + + String donationId = mat != null ? mat.name() : e.getKey(); + Object blockObj = mat != null ? mat : e.getKey(); + int amount = e.getValue(); + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + int remaining = Math.max(0, limit - alreadyDonated.getOrDefault(donationId, 0)); + int accepted = Math.min(amount, remaining); + if (accepted < amount) { + limited = true; + } + amount = accepted; + } + if (amount <= 0) { + continue; + } + long points = (long) rawValue * amount; totalPoints += points; prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-line", - TextVariables.NUMBER, String.valueOf(e.getValue()), + TextVariables.NUMBER, String.valueOf(amount), MATERIAL_PLACEHOLDER, Utils.prettifyObject(displayKey, user), POINTS_PLACEHOLDER, Utils.formatNumber(user, points))); } + if (limited) { + // The limit-notice locale uses '|' as a lore line-break for the GUI; + // translate to '\n' here so the chat confirmation prompt wraps cleanly. + prompt.append('\n').append( + user.getTranslation("island.donate.limit-notice").replace('|', '\n')); + } prompt.append('\n').append(user.getTranslation("island.donate.inv.confirm-total", POINTS_PLACEHOLDER, Utils.formatNumber(user, totalPoints))); @@ -223,14 +288,35 @@ private void performInvDonation(User user, Island island) { if (value == null) { continue; } - int amount = item.getAmount(); - long points = (long) value * amount; String customId = addon.getCustomBlockId(item); String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? (Object) customId : item.getType(); + + // Apply blockconfig donation limit: only take up to the remaining capacity + int amount = item.getAmount(); + Integer limit = addon.getBlockConfig().getLimit(blockObj); + if (limit != null) { + // getDonatedBlocks returns the live cached map, already updated by earlier donateBlocks calls + int alreadyDonated = addon.getManager().getDonatedBlocks(island).getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - alreadyDonated); + if (remaining == 0) { + // Limit already reached for this material — leave item in inventory + continue; + } + amount = Math.min(amount, remaining); + } + + // Remove accepted amount from inventory slot + if (amount >= item.getAmount()) { + contents[i] = null; + } else { + item.setAmount(item.getAmount() - amount); + } + + long points = (long) value * amount; donated.merge(donationId, amount, Integer::sum); totalPoints += points; addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, amount, points); - contents[i] = null; } pInv.setStorageContents(contents); diff --git a/src/main/java/world/bentobox/level/config/ConfigSettings.java b/src/main/java/world/bentobox/level/config/ConfigSettings.java index ef47003..f07df3c 100644 --- a/src/main/java/world/bentobox/level/config/ConfigSettings.java +++ b/src/main/java/world/bentobox/level/config/ConfigSettings.java @@ -54,6 +54,23 @@ public class ConfigSettings implements ConfigObject { @ConfigEntry(path = "zero-new-island-levels") private boolean zeroNewIslandLevels = true; + @ConfigComment("") + @ConfigComment("Delay (in ticks) between a chunk being freshly generated and that chunk") + @ConfigComment("being scanned for the lazy-zero handicap. The delay lets neighbouring") + @ConfigComment("chunks finish their decoration phase (obsidian from lava-water, ore") + @ConfigComment("patches that spill across borders, broken nether portal spawns, etc.)") + @ConfigComment("so the captured snapshot matches what a regular level scan would later") + @ConfigComment("find. The level scan also waits for any in-flight delayed captures to") + @ConfigComment("complete before returning the result, so the handicap is always up to") + @ConfigComment("date with the chunks that have generated. 20 ticks = 1 second.") + @ConfigComment("Underwater obsidian on AcidIsland needs lava sources to flow into") + @ConfigComment("adjacent water before they convert — that can take 20-30 seconds, so") + @ConfigComment("the default is set high enough to catch it. Lower if you don't care") + @ConfigComment("about slow-forming terrain and want /level to settle faster after a") + @ConfigComment("burst of exploration.") + @ConfigEntry(path = "zero-scan-delay-ticks") + private int zeroScanDelayTicks = 600; + @ConfigComment("") @ConfigComment("Donations-only mode") @ConfigComment("If true, the island block scan is skipped entirely and the island level") @@ -397,6 +414,20 @@ public void setZeroNewIslandLevels(boolean zeroNewIslandLevels) { this.zeroNewIslandLevels = zeroNewIslandLevels; } + /** + * @return the zeroScanDelayTicks + */ + public int getZeroScanDelayTicks() { + return zeroScanDelayTicks; + } + + /** + * @param zeroScanDelayTicks the zeroScanDelayTicks to set + */ + public void setZeroScanDelayTicks(int zeroScanDelayTicks) { + this.zeroScanDelayTicks = zeroScanDelayTicks; + } + /** * @return the calculationTimeout diff --git a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java index f139d70..b9c4cc8 100644 --- a/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java +++ b/src/main/java/world/bentobox/level/listeners/IslandActivitiesListeners.java @@ -41,8 +41,15 @@ public IslandActivitiesListeners(Level addon) { @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onNewIsland(IslandCreatedEvent e) { if (addon.getSettings().isZeroNewIslandLevels()) { - // Wait a few seconds before performing the zero - Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> zeroIsland(e.getIsland()), 150L); + // Delay the zero scan so decoration (fluid simulation, + // late-arriving obsidian/portal frames, trial-spawner setup, + // chunk-border ore patches) has time to settle before we + // snapshot the baseline. Reuses the listener's decoration-settle + // setting (zero-scan-delay-ticks, default 600 = 30s) so both + // paths honour the same "give the world a chance to finish + // generating" window. + long delay = Math.max(150L, addon.getSettings().getZeroScanDelayTicks()); + Bukkit.getScheduler().runTaskLater(addon.getPlugin(), () -> zeroIsland(e.getIsland()), delay); } } @@ -57,7 +64,33 @@ 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; + } + // Hard reset the baseline. The calculator already + // overwrote the per-chunk handicap map with the + // scanned values via reconcileHandicapChunks(..., + // zeroScan=true) inside tidyUp; this aligns the + // initialCount with that same baseline. + addon.getManager().setInitialIslandCount(island, results.getTotalPoints()); + // Fold in any listener credits captured during the + // scan for chunks the scan didn't visit (e.g. + // chunks generated mid-scan after their position + // was already polled). Reconciling these as a + // non-zero scan adds them to both the per-chunk + // map and the initialCount in one atomic write, + // so subsequent scans see those chunks as fully + // accounted for. + java.util.Map missed = addon.getManager().drainZeroScanDeferred(island); + if (!missed.isEmpty()) { + addon.getManager().reconcileHandicapChunks(island, missed, false); + } + }); } } diff --git a/src/main/java/world/bentobox/level/listeners/NewChunkListener.java b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java new file mode 100644 index 0000000..166acb5 --- /dev/null +++ b/src/main/java/world/bentobox/level/listeners/NewChunkListener.java @@ -0,0 +1,120 @@ +package world.bentobox.level.listeners; + +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.ChunkSnapshot; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.world.ChunkLoadEvent; + +import world.bentobox.bentobox.database.objects.Island; +import world.bentobox.level.Level; +import world.bentobox.level.calculators.IslandLevelCalculator; + +/** + * 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. + *

+ * 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. + *

+ * The actual scoring is delegated to + * {@link IslandLevelCalculator#scoreNewChunkSnapshot} so the per-block logic + * stays in lockstep with the regular scan — custom blocks, double slabs and + * configured values are all counted identically. No per-listener state is + * kept across chunks: {@link ChunkLoadEvent#isNewChunk()} already guarantees + * each generated chunk is reported once per server run, so a separate dedup + * set would only ever grow unbounded. + */ +public class NewChunkListener implements Listener { + + private final Level addon; + + public NewChunkListener(Level addon) { + this.addon = addon; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkLoad(ChunkLoadEvent e) { + if (!e.isNewChunk()) { + return; + } + if (!addon.getSettings().isZeroNewIslandLevels()) { + return; + } + Chunk chunk = e.getChunk(); + World world = chunk.getWorld(); + if (!addon.isRegisteredGameModeWorld(world)) { + return; + } + // Use the chunk centre to look up the island that owns it. + // The + 8.0 keeps the addition in double arithmetic so SonarQube does not + // flag a theoretical int-overflow before the implicit widening. + Location centre = new Location(world, (chunk.getX() << 4) + 8.0, world.getMinHeight(), + (chunk.getZ() << 4) + 8.0); + Island island = addon.getIslands().getIslandAt(centre).orElse(null); + if (island == null || island.getOwner() == null) { + return; + } + + 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 score it on a worker + * thread using the real {@link IslandLevelCalculator} logic. The chunk may + * have been unloaded by the time this runs; Bukkit's {@link ChunkSnapshot} + * is immutable, so as long as the chunk is loaded here the scoring can + * safely run off-thread. Once done, + * {@link world.bentobox.level.LevelsManager#completePendingZero} releases + * the in-flight counter so any waiting level scan can finalise. + */ + private void processChunk(World world, Chunk chunk, Island island) { + // Skip if the island was deleted while waiting for the delay. + if (island.isDeleted() || island.getOwner() == null) { + addon.getManager().completePendingZero(island); + return; + } + ChunkSnapshot snapshot = chunk.getChunkSnapshot(); + int chunkX = chunk.getX(); + int chunkZ = chunk.getZ(); + + Bukkit.getScheduler().runTaskAsynchronously(addon.getPlugin(), () -> { + long total = IslandLevelCalculator.scoreNewChunkSnapshot(addon, island, snapshot, chunk, world); + Bukkit.getScheduler().runTask(addon.getPlugin(), () -> { + // During an active zero-island scan, route the credit + // through the deferral map so the post-scan drain can + // decide whether this chunk's value belongs in the + // baseline (scan missed it) or should be dropped (scan + // counted it). + String worldName = world.getName(); + if (!addon.getManager().tryDeferZeroScanCredit(island, worldName, chunkX, chunkZ, total) + && total != 0L) { + // No active zero scan — fold the chunk into the + // per-chunk handicap map and initialCount in one + // atomic save. The map keeps the credit frozen so a + // future regular scan sees this chunk as already + // accounted for and doesn't double-credit it. + addon.getManager().addHandicapChunk(island, + IslandLevelCalculator.handicapKey(world, chunkX, chunkZ), total); + } + addon.getManager().completePendingZero(island); + }); + }); + } +} diff --git a/src/main/java/world/bentobox/level/objects/IslandLevels.java b/src/main/java/world/bentobox/level/objects/IslandLevels.java index b15bb69..dcf5a9a 100644 --- a/src/main/java/world/bentobox/level/objects/IslandLevels.java +++ b/src/main/java/world/bentobox/level/objects/IslandLevels.java @@ -98,6 +98,27 @@ public class IslandLevels implements DataObject { @Expose private List donationLog; + /** + * Per-chunk "natural baseline" map for the lazy-zero handicap. Key is + * {@code worldName:chunkKey} (see {@link world.bentobox.level.LevelsManager#chunkKey}) + * and value is the chunk's scored contribution at the time it was first + * folded into the handicap. The map exists so the handicap is + * self-healing: any time a level scan visits a chunk that is not in the + * map yet, the chunk's value is added to both the map and {@link + * #initialCount}, which catches chunks the lazy-zero listener missed + * (chunks pregenerated before the island existed, async-load misses at + * zero-scan time, late chunk decoration). Once a chunk is in the map its + * value is frozen — subsequent block changes are attributed to player + * activity. + *

+ * Null on legacy data; treated as an empty map by callers. An empty map + * with a non-zero {@link #initialCount} is the legacy-migration state — + * the next scan seeds the map without re-crediting the existing count + * so player progress is preserved across the upgrade. + */ + @Expose + private Map handicapChunks; + /** * Constructor for new island * @param islandUUID - island UUID @@ -352,6 +373,26 @@ public void addDonation(String donorUUID, String material, int count, long point getDonationLog().add(new DonationRecord(System.currentTimeMillis(), donorUUID, material, count, points)); } + // ---- Handicap chunks (null-safe for backwards compatibility) ---- + + /** + * Per-chunk handicap map. Never null — initialised lazily so callers + * can iterate or {@code put} without a null check. + */ + public Map getHandicapChunks() { + if (handicapChunks == null) { + handicapChunks = new HashMap<>(); + } + return handicapChunks; + } + + /** + * @param handicapChunks the handicapChunks to set + */ + public void setHandicapChunks(Map handicapChunks) { + this.handicapChunks = handicapChunks; + } + /** * @return the initialLevel * @deprecated only used for backwards compatibility. Use {@link #getInitialCount()} instead diff --git a/src/main/java/world/bentobox/level/panels/DonationPanel.java b/src/main/java/world/bentobox/level/panels/DonationPanel.java index 760611e..72baef8 100644 --- a/src/main/java/world/bentobox/level/panels/DonationPanel.java +++ b/src/main/java/world/bentobox/level/panels/DonationPanel.java @@ -130,33 +130,69 @@ private void build() { } /** - * Calculate the total point value of items in the donation slots. + * Per-material donation totals with blockconfig limits applied. */ - private long calculateDonationValue() { - long total = 0; + private static final class DonationTotals { + final Map accepted = new HashMap<>(); + long acceptedPoints = 0; + boolean limited = false; + } + + /** + * Walk the donation slots, total each material's items, and cap each total + * by the remaining capacity allowed by the blockconfig limit. The accepted + * counts and resulting point value drive both the preview and the actual + * donation; the {@code limited} flag triggers the limit-notice lore line. + */ + private DonationTotals computeDonationTotals() { + DonationTotals result = new DonationTotals(); + Map rawTotals = new HashMap<>(); + Map values = new HashMap<>(); for (int slot : layout.donationSlots) { ItemStack item = inventory.getItem(slot); - if (item != null && !item.getType().isAir()) { - String customId = addon.getCustomBlockId(item); - Integer value = customId != null - ? addon.getBlockConfig().getValue(world, customId) - : addon.getBlockConfig().getValue(world, item.getType()); - if (value != null && value > 0) { - total += (long) value * item.getAmount(); + if (item == null || item.getType().isAir()) continue; + String customId = addon.getCustomBlockId(item); + String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? customId : item.getType(); + Integer value = addon.getBlockConfig().getValue(world, blockObj); + if (value == null || value <= 0) continue; + rawTotals.merge(donationId, item.getAmount(), Integer::sum); + values.putIfAbsent(donationId, value); + } + Map donated = addon.getManager().getDonatedBlocks(island); + for (Map.Entry e : rawTotals.entrySet()) { + String donationId = e.getKey(); + int total = e.getValue(); + Object blockObj = donationId.contains(":") ? donationId : Material.matchMaterial(donationId); + if (blockObj == null) blockObj = donationId; + Integer limit = addon.getBlockConfig().getLimit(blockObj); + int accept = total; + if (limit != null) { + int already = donated.getOrDefault(donationId, 0); + int remaining = Math.max(0, limit - already); + accept = Math.min(total, remaining); + if (accept < total) { + result.limited = true; } } + result.accepted.put(donationId, accept); + result.acceptedPoints += (long) accept * values.get(donationId); } - return total; + return result; } /** - * Update the preview pane to show current point value. + * Update the preview pane to show the limited point value, with an extra + * lore line when any material exceeds its blockconfig limit. */ private void updatePreview() { - long points = calculateDonationValue(); - ItemStack preview = createNamedItem(layout.previewMaterial, - user.getTranslation("island.donate.preview", - POINTS_PLACEHOLDER, Utils.formatNumber(user, points))); + DonationTotals totals = computeDonationTotals(); + String text = user.getTranslation("island.donate.preview", + POINTS_PLACEHOLDER, Utils.formatNumber(user, totals.acceptedPoints)); + if (totals.limited) { + text = text + "|" + user.getTranslation("island.donate.limit-notice"); + } + ItemStack preview = createNamedItem(layout.previewMaterial, text); inventory.setItem(layout.previewSlot, preview); } @@ -168,52 +204,61 @@ private boolean isDonationSlot(int slot) { } /** - * Process the donation - consume items and record them. Items with no - * configured value are returned to the player rather than consumed. + * Process the donation - consume items up to each material's blockconfig + * limit and record them. Items beyond the limit (and valueless items) are + * returned to the player rather than consumed. */ private void processDonation() { - Map donations = new HashMap<>(); + DonationTotals totals = computeDonationTotals(); + Map remaining = new HashMap<>(totals.accepted); + Map donatedThisCall = new HashMap<>(); long totalPoints = 0; Player player = user.getPlayer(); for (int slot : layout.donationSlots) { ItemStack item = inventory.getItem(slot); - if (item != null && !item.getType().isAir()) { - String customId = addon.getCustomBlockId(item); - String donationId; - Integer value; - if (customId != null) { - value = addon.getBlockConfig().getValue(world, customId); - donationId = customId; - } else { - Material mat = item.getType(); - value = addon.getBlockConfig().getValue(world, mat); - donationId = mat.name(); - } - if (value != null && value > 0) { - int count = item.getAmount(); - long points = (long) value * count; - donations.merge(donationId, count, Integer::sum); - totalPoints += points; - // Record each material type as a separate donation log entry - addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, count, points); - // Clear the slot - items are consumed - inventory.setItem(slot, null); - } else { - // Return valueless items to the player rather than consuming them - inventory.setItem(slot, null); - Map overflow = player.getInventory().addItem(item); - overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); - } + if (item == null || item.getType().isAir()) continue; + String customId = addon.getCustomBlockId(item); + String donationId = customId != null ? customId : item.getType().name(); + Object blockObj = customId != null ? customId : item.getType(); + Integer value = addon.getBlockConfig().getValue(world, blockObj); + + if (value == null || value <= 0) { + // Return valueless items to the player rather than consuming them + inventory.setItem(slot, null); + Map overflow = player.getInventory().addItem(item); + overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); + continue; + } + + int slotCount = item.getAmount(); + int canAccept = remaining.getOrDefault(donationId, 0); + int take = Math.min(slotCount, canAccept); + int leftover = slotCount - take; + + if (take > 0) { + long points = (long) value * take; + totalPoints += points; + donatedThisCall.merge(donationId, take, Integer::sum); + addon.getManager().donateBlocks(island, user.getUniqueId(), donationId, take, points); + remaining.put(donationId, canAccept - take); + } + + inventory.setItem(slot, null); + if (leftover > 0) { + ItemStack ret = item.clone(); + ret.setAmount(leftover); + Map overflow = player.getInventory().addItem(ret); + overflow.values().forEach(drop -> player.getWorld().dropItemNaturally(player.getLocation(), drop)); } } - if (donations.isEmpty()) { + if (donatedThisCall.isEmpty()) { user.sendMessage("island.donate.empty"); } else { user.sendMessage("island.donate.success", POINTS_PLACEHOLDER, Utils.formatNumber(user, totalPoints), - TextVariables.NUMBER, String.valueOf(donations.values().stream().mapToInt(Integer::intValue).sum())); + TextVariables.NUMBER, String.valueOf(donatedThisCall.values().stream().mapToInt(Integer::intValue).sum())); // Queue a full level recalculation so the donation is reflected immediately addon.getManager().recalculateAfterDonation(island); } @@ -322,7 +367,7 @@ private void handleCancel(InventoryClickEvent event, Player player) { private void handleConfirm(InventoryClickEvent event, Player player) { event.setCancelled(true); - if (calculateDonationValue() <= 0) { + if (computeDonationTotals().acceptedPoints <= 0) { user.sendMessage("island.donate.empty"); return; } diff --git a/src/main/resources/locales/cs.yml b/src/main/resources/locales/cs.yml index 7c1da5b..d9ed72b 100644 --- a/src/main/resources/locales/cs.yml +++ b/src/main/resources/locales/cs.yml @@ -14,6 +14,11 @@ 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é" + 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!' @@ -62,6 +67,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Limit darování pro [material] byl dosažen." + limit-notice: "Některé bloky podléhají limitům|a nebudou darovány" hand: keyword: "ruka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/de.yml b/src/main/resources/locales/de.yml index 75ebec9..0720187 100644 --- a/src/main/resources/locales/de.yml +++ b/src/main/resources/locales/de.yml @@ -15,6 +15,11 @@ 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" + pending-zeros: "Ausstehende Null-Scans (verzögerte Chunk-Erfassungen): [number]" top: description: Zeige die Top-10 Liste unknown-world: " Unbekannte Welt!" @@ -63,6 +68,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Das Spendenlimit für [material] wurde erreicht." + limit-notice: "Einige Blöcke unterliegen Limits|und werden nicht gespendet" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index e18ba4a..1d4de1b 100755 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -19,6 +19,11 @@ 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" + pending-zeros: "Pending zero-scans (delayed chunk captures): [number]" top: description: "show the top ten list" unknown-world: "Unknown world!" @@ -67,6 +72,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "The donation limit for [material] has been reached." + limit-notice: "Some blocks are subject to limits|and will not be donated" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/es.yml b/src/main/resources/locales/es.yml index 8571223..a130a51 100644 --- a/src/main/resources/locales/es.yml +++ b/src/main/resources/locales/es.yml @@ -12,6 +12,11 @@ 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" + pending-zeros: "Escaneos cero pendientes (capturas de chunks retrasadas): [number]" top: description: Muestra la lista de las diez primeras islas unknown-world: "¡Mundo desconocido!" @@ -60,6 +65,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Se ha alcanzado el límite de donación para [material]." + limit-notice: "Algunos bloques están sujetos a límites|y no serán donados" hand: keyword: "mano" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/fr.yml b/src/main/resources/locales/fr.yml index d939b6b..2a74784 100644 --- a/src/main/resources/locales/fr.yml +++ b/src/main/resources/locales/fr.yml @@ -12,6 +12,11 @@ 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" + 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." @@ -62,6 +67,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "La limite de don pour [material] a été atteinte." + limit-notice: "Certains blocs sont soumis à des limites|et ne seront pas donnés" hand: keyword: "main" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index e9113e5..87dae78 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -15,6 +15,11 @@ 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" + 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!" @@ -63,6 +68,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "A(z) [material] adományozási limitje elérve." + limit-notice: "Néhány blokkra korlátozás vonatkozik|és nem lesznek adományozva" hand: keyword: "kez" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/id.yml b/src/main/resources/locales/id.yml index ff360f0..e1bd8f7 100644 --- a/src/main/resources/locales/id.yml +++ b/src/main/resources/locales/id.yml @@ -11,6 +11,11 @@ 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" + pending-zeros: "Pemindaian nol tertunda (penangkapan chunk yang tertunda): [number]" top: description: menunjukkan daftar sepuluh besar unknown-world: " Dunia tidak ditemukan!" @@ -60,6 +65,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Batas donasi untuk [material] telah tercapai." + limit-notice: "Beberapa blok memiliki batasan|dan tidak akan didonasikan" hand: keyword: "tangan" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/ko.yml b/src/main/resources/locales/ko.yml index 073e26c..3f9349f 100644 --- a/src/main/resources/locales/ko.yml +++ b/src/main/resources/locales/ko.yml @@ -15,6 +15,11 @@ admin: levelstatus: description: 스캔 대기열에 있는 섬 수를 표시합니다 islands-in-queue: " 대기열에 있는 섬: [number]" + island-detail: "- [type] [world] [xyz], 경과 [elapsed], 청크 [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (대기 중)" + type-zero: "영" + type-regular: "일반" + pending-zeros: "대기 중인 제로 스캔 (지연된 청크 캡처): [number]" top: description: 상위 10개 목록을 표시합니다 unknown-world: " 알 수 없는 세계입니다!" @@ -63,6 +68,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material]의 기부 한도에 도달했습니다." + limit-notice: "일부 블록에는 제한이 있으며|기부되지 않습니다" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/lv.yml b/src/main/resources/locales/lv.yml index a9f6feb..4033c9a 100644 --- a/src/main/resources/locales/lv.yml +++ b/src/main/resources/locales/lv.yml @@ -15,6 +15,11 @@ 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" + 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]" @@ -63,6 +68,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] ziedošanas limits ir sasniegts." + limit-notice: "Dažiem blokiem ir ierobežojumi|un tie netiks ziedoti" hand: keyword: "roka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/nl.yml b/src/main/resources/locales/nl.yml index 9ddd410..1151037 100644 --- a/src/main/resources/locales/nl.yml +++ b/src/main/resources/locales/nl.yml @@ -12,6 +12,11 @@ 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" + pending-zeros: "Wachtende nul-scans (vertraagde chunk-opnames): [number]" top: description: Laat de top tien zien unknown-world: " Ongeldige wereld!" @@ -60,6 +65,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "De donatielimiet voor [material] is bereikt." + limit-notice: "Sommige blokken zijn onderworpen aan limieten|en worden niet gedoneerd" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/pl.yml b/src/main/resources/locales/pl.yml index 67b6940..01453fc 100644 --- a/src/main/resources/locales/pl.yml +++ b/src/main/resources/locales/pl.yml @@ -11,6 +11,11 @@ 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" + pending-zeros: "Oczekujące skany zerowe (opóźnione przechwytywania chunków): [number]" top: description: pokazuje Top 10 wysp unknown-world: "Nieznany świat!" @@ -60,6 +65,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Limit darowizn dla [material] został osiągnięty." + limit-notice: "Niektóre bloki podlegają limitom|i nie zostaną przekazane" hand: keyword: "reka" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/pt.yml b/src/main/resources/locales/pt.yml index ffb289c..6d00e1a 100644 --- a/src/main/resources/locales/pt.yml +++ b/src/main/resources/locales/pt.yml @@ -15,6 +15,11 @@ 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" + pending-zeros: "Verificações zero pendentes (capturas de chunks atrasadas): [number]" top: description: Mostra a lista dos dez primeiros unknown-world: " Mundo desconhecido!" @@ -63,6 +68,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "O limite de doação para [material] foi atingido." + limit-notice: "Alguns blocos estão sujeitos a limites|e não serão doados" hand: keyword: "mao" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index e483b21..edaa61f 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -15,6 +15,11 @@ admin: levelstatus: description: показать, сколько островов находится в очереди на сканирование islands-in-queue: 'Островов в очереди: [number]' + island-detail: "- [type] [world] [xyz], прошло [elapsed], чанки [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (ожидает)" + type-zero: "обнуление" + type-regular: "обычное" + pending-zeros: "Ожидающие нулевые сканирования (отложенные снимки чанков): [number]" top: description: открывает панель с десяткой лучших по уровню острова unknown-world: 'Неизвестный мир!' @@ -63,6 +68,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Лимит пожертвования для [material] достигнут." + limit-notice: "Некоторые блоки подлежат ограничениям|и не будут пожертвованы" hand: keyword: "рука" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/tr.yml b/src/main/resources/locales/tr.yml index 2f8ae2b..4d02983 100644 --- a/src/main/resources/locales/tr.yml +++ b/src/main/resources/locales/tr.yml @@ -19,6 +19,11 @@ 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" + pending-zeros: "Bekleyen sıfır taramalar (geciktirilmiş yığın yakalamaları): [number]" top: description: "İlk 10 adayı sırala" unknown-world: " Bilinmeyen dünya!" @@ -67,6 +72,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] için bağış limitine ulaşıldı." + limit-notice: "Bazı bloklar limitlere tabidir|ve bağışlanmayacaktır" hand: keyword: "el" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/uk.yml b/src/main/resources/locales/uk.yml index d8286b3..06678eb 100644 --- a/src/main/resources/locales/uk.yml +++ b/src/main/resources/locales/uk.yml @@ -11,6 +11,11 @@ admin: levelstatus: description: показати, скільки островів у черзі на сканування islands-in-queue: " Острови в черзі: [number]" + island-detail: "- [type] [world] [xyz], минуло [elapsed], чанки [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (чекає)" + type-zero: "обнулення" + type-regular: "звичайне" + pending-zeros: "Очікувані нульові сканування (відкладені знімки чанків): [number]" top: description: показати першу десятку списку unknown-world: " Невідомий світ!" @@ -60,6 +65,8 @@ island: gui-title: "Пожертвувати блоки" gui-info: "Пожертвуйте блоки своєму острову|Пожертвовано: [points] очок|Увага: пожертвувані предмети|знищуються і не можуть бути повернуті!" preview: "Очок буде додано: [points]|Ці предмети будуть знищені!" + limit-reached: "Ліміт пожертвування для [material] досягнуто." + limit-notice: "Деякі блоки підлягають обмеженням|і не будуть пожертвувані" hand: keyword: "рука" success: "Пожертвовано [number] x [material] за [points] постійних очок!" diff --git a/src/main/resources/locales/vi.yml b/src/main/resources/locales/vi.yml index 9c19d5d..b967b71 100644 --- a/src/main/resources/locales/vi.yml +++ b/src/main/resources/locales/vi.yml @@ -18,6 +18,11 @@ 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" + 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!' @@ -66,6 +71,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "Đã đạt đến giới hạn quyên góp cho [material]." + limit-notice: "Một số khối có giới hạn|và sẽ không được quyên góp" hand: keyword: "tay" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/main/resources/locales/zh-CN.yml b/src/main/resources/locales/zh-CN.yml index 6dd71c6..974ce32 100644 --- a/src/main/resources/locales/zh-CN.yml +++ b/src/main/resources/locales/zh-CN.yml @@ -10,6 +10,11 @@ admin: levelstatus: description: 显示等级计算队列中的岛屿 islands-in-queue: '列队中的岛屿: [number]' + island-detail: "- [type] [world] [xyz], 已用时 [elapsed], 区块 [scanned]/[total]" + island-queued: "- [type] [world] [xyz] (等待中)" + type-zero: "清零" + type-regular: "常规" + pending-zeros: "等待中的清零扫描(延迟的区块快照): [number]" top: description: 显示前十名 unknown-world: '未知的世界!' @@ -58,6 +63,8 @@ island: gui-title: "Donate Blocks" gui-info: "Donate blocks to your island|Currently donated: [points] points|Warning: donated items are|destroyed and cannot be returned!" preview: "Points to add: [points]|These items will be destroyed!" + limit-reached: "[material] 的捐赠限额已达到。" + limit-notice: "某些方块受到限制|将不会被捐赠" hand: keyword: "hand" success: "Donated [number] x [material] for [points] permanent points!" diff --git a/src/test/java/world/bentobox/level/LevelsManagerTest.java b/src/test/java/world/bentobox/level/LevelsManagerTest.java index 91d841d..993f67e 100644 --- a/src/test/java/world/bentobox/level/LevelsManagerTest.java +++ b/src/test/java/world/bentobox/level/LevelsManagerTest.java @@ -417,6 +417,193 @@ 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() { + String w = "bskyblock-world"; + // Before beginZeroScan: tryDefer returns false (no active scan), so + // the listener takes the normal addHandicapChunk path. + assertFalse(lm.tryDeferZeroScanCredit(island, w, 0, 0, 99L)); + + lm.beginZeroScan(island); + + // While scan is active: tryDefer returns true, capturing the value. + assertTrue(lm.tryDeferZeroScanCredit(island, w, 1, 1, 50L)); + assertTrue(lm.tryDeferZeroScanCredit(island, w, 2, 2, 30L)); + assertTrue(lm.tryDeferZeroScanCredit(island, w, 3, 3, 20L)); + + // Scan visits chunk (1,1) and (3,3). The drain should drop those + // and only return (2,2) which the scan missed. + lm.recordScanVisitedChunk(island, w, 1, 1); + lm.recordScanVisitedChunk(island, w, 3, 3); + + Map missed = lm.drainZeroScanDeferred(island); + assertEquals(1, missed.size()); + assertEquals(30L, missed.values().iterator().next().longValue()); + + // After drain: scan state is cleared, tryDefer falls back to false. + assertFalse(lm.tryDeferZeroScanCredit(island, w, 4, 4, 100L)); + // Drain on a non-active scan returns an empty map. + assertTrue(lm.drainZeroScanDeferred(island).isEmpty()); + } + + /** + * Pin down the self-healing handicap contract: + *

    + *
  • A zero-scan reconciliation overwrites the persisted per-chunk map + * with the scanned values and does not touch initialCount (caller does + * the hard reset separately via setInitialIslandCount).
  • + *
  • A regular scan on an island whose persisted map is empty but whose + * initialCount > 0 is treated as legacy migration: seed the map from + * the scan, leave initialCount alone (no double-credit).
  • + *
  • A regular scan on an already-tracked island adds only the + * chunks the persisted map didn't already know about into both the map + * and initialCount, and freezes existing entries even if the scan saw a + * different value (player builds keep showing as level progress).
  • + *
+ */ + @Test + void testReconcileHandicapChunksContract() throws Exception { + // First call ever — empty map, zero initialCount, regular scan: every + // scanned chunk is "new" and gets credited. This is the normal fresh- + // island path when zero scan hasn't happened (defensive — exercises + // the "no legacy migration" branch). + IslandLevels fresh = new IslandLevels(uuid.toString()); + when(handler.loadObject(uuid.toString())).thenReturn(fresh); + + Map firstScan = Map.of("world:1", 100L, "world:2", 50L); + long delta = lm.reconcileHandicapChunks(island, firstScan, false); + assertEquals(150L, delta, "every chunk is new on a fresh island"); + assertEquals(150L, fresh.getInitialCount(), "initialCount += delta"); + assertEquals(2, fresh.getHandicapChunks().size()); + + // Frozen value: re-scan the same chunks at a different value (e.g. + // player built more blocks in chunk world:1). The persisted map + // doesn't update, the delta is 0, and player progress goes through + // the live scan total instead of into the handicap. + Map rescan = Map.of("world:1", 500L, "world:2", 50L); + long delta2 = lm.reconcileHandicapChunks(island, rescan, false); + assertEquals(0L, delta2, "known chunks are frozen — no double-credit"); + assertEquals(150L, fresh.getInitialCount(), "initialCount stays put"); + assertEquals(100L, fresh.getHandicapChunks().get("world:1")); + + // Self-heal: a new chunk shows up that the lazy-zero listener missed + // (e.g. pre-generated before the island existed). Regular scan picks + // it up and adds it to both the map and initialCount, so the level + // result this scan computes is zero for that natural terrain. + Map selfHeal = Map.of("world:1", 100L, "world:2", 50L, "world:3", 77L); + long delta3 = lm.reconcileHandicapChunks(island, selfHeal, false); + assertEquals(77L, delta3, "only the new chunk contributes to the delta"); + assertEquals(227L, fresh.getInitialCount()); + assertEquals(3, fresh.getHandicapChunks().size()); + } + + /** + * Legacy migration: empty handicapChunks + non-zero initialCount means + * an island that was zeroed before this feature existed. The current + * scan seeds the map but does NOT change initialCount — that would + * double-count the chunks already represented by the legacy count and + * push the level negative. + */ + @Test + void testReconcileHandicapChunksLegacyMigration() throws Exception { + IslandLevels legacy = new IslandLevels(uuid.toString()); + legacy.setInitialCount(5000L); // existing baseline from before the upgrade + when(handler.loadObject(uuid.toString())).thenReturn(legacy); + + Map scan = Map.of("world:1", 100L, "world:2", 200L); + long delta = lm.reconcileHandicapChunks(island, scan, false); + assertEquals(0L, delta, "no delta on legacy migration"); + assertEquals(5000L, legacy.getInitialCount(), + "initialCount preserved across the migration"); + assertEquals(2, legacy.getHandicapChunks().size(), + "map seeded from the scan"); + assertEquals(100L, legacy.getHandicapChunks().get("world:1")); + } + + /** + * Zero-scan reconciliation hard-resets the map and returns 0 (the caller + * rewrites initialCount separately via setInitialIslandCount). + */ + @Test + void testReconcileHandicapChunksZeroScanOverwrites() throws Exception { + IslandLevels data = new IslandLevels(uuid.toString()); + data.setInitialCount(9999L); + data.getHandicapChunks().put("world:stale", 1234L); + when(handler.loadObject(uuid.toString())).thenReturn(data); + + Map scan = Map.of("world:1", 10L, "world:2", 20L); + long delta = lm.reconcileHandicapChunks(island, scan, true); + assertEquals(0L, delta, "zero scans return 0 — caller does the reset"); + assertEquals(9999L, data.getInitialCount(), + "initialCount untouched — IslandActivitiesListeners sets it"); + assertEquals(2, data.getHandicapChunks().size(), + "stale entries gone, new ones in"); + assertFalse(data.getHandicapChunks().containsKey("world:stale")); + } + + /** + * The listener-side {@link LevelsManager#addHandicapChunk} freezes the + * chunk value the first time it's recorded — a second call with a + * different value (e.g. the same chunk firing twice through some + * external pregen pipeline) is a no-op for both the map and initialCount. + */ + @Test + void testAddHandicapChunkFreezesValue() throws Exception { + IslandLevels data = new IslandLevels(uuid.toString()); + when(handler.loadObject(uuid.toString())).thenReturn(data); + + assertTrue(lm.addHandicapChunk(island, "world:42", 200L)); + assertEquals(200L, data.getInitialCount()); + + // Second call: chunk already known, both map and initialCount stay put. + assertFalse(lm.addHandicapChunk(island, "world:42", 999L)); + assertEquals(200L, data.getInitialCount()); + assertEquals(200L, data.getHandicapChunks().get("world:42")); + } + + /** + * Pin down that completePendingZero never lets the per-island counter drift + * negative and removes the map entry when it reaches zero. Without these + * guarantees the map would leak one AtomicInteger per island that ever had + * a queued snapshot, and an extra complete (e.g. from a duplicated chunk + * event) would push the counter below zero so awaitPendingZeros could + * release a scan before later-queued snapshots had landed. + */ + @Test + void testPendingZeroClampsAndRemovesEntry() { + // No add → complete is a no-op and leaves no entry behind. + assertEquals(0, lm.getPendingZeroCount(island)); + lm.completePendingZero(island); + assertEquals(0, lm.getPendingZeroCount(island)); + + // Pair of add/complete: counter ends at 0 and the entry is dropped. + lm.addPendingZero(island); + assertEquals(1, lm.getPendingZeroCount(island)); + lm.completePendingZero(island); + assertEquals(0, lm.getPendingZeroCount(island)); + + // Extra complete after the counter is empty cannot push it negative. + lm.completePendingZero(island); + assertEquals(0, lm.getPendingZeroCount(island)); + + // Add → complete → add still tracks the second add correctly even + // though the first cycle removed the map entry mid-flight. + lm.addPendingZero(island); + lm.completePendingZero(island); + lm.addPendingZero(island); + assertEquals(1, lm.getPendingZeroCount(island)); + lm.completePendingZero(island); + assertEquals(0, lm.getPendingZeroCount(island)); + } + /** * Test method for * {@link world.bentobox.level.LevelsManager#getRank(World, UUID)} diff --git a/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java new file mode 100644 index 0000000..18c9b97 --- /dev/null +++ b/src/test/java/world/bentobox/level/calculators/IslandLevelCalculatorTidyUpTest.java @@ -0,0 +1,280 @@ +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.Map; +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); + } + + @Test + @DisplayName("Donated blocks under limit: full donation count is used") + void donatedBlocksUnderLimit() { + // Player donated 500 iron blocks; limit is 1000; block value is 3. + // Expected donated points = 500 * 3 = 1500. + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 500)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(0); + return "iron_block".equals(key) ? 1000 : null; + }); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(1500L, r.getDonatedPoints(), "donated points should equal 500 * 3 = 1500"); + } + + @Test + @DisplayName("Donated blocks exceed limit: only blocks up to the current limit count") + void donatedBlocksExceedLimit() { + // Player donated 1000 iron blocks; limit was later changed to 500; block value is 3. + // Expected donated points = 500 * 3 = 1500 (NOT 1000 * 3 = 3000). + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 1000)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(0); + return "iron_block".equals(key) ? 500 : null; + }); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(1500L, r.getDonatedPoints(), + "donated points should be capped at 500 * 3 = 1500, not 1000 * 3 = 3000"); + } + + @Test + @DisplayName("Race: zero scan setInitialIslandCount lands after constructor — refresh keeps level math honest") + void initialCountRefreshAfterReconcile() { + // Constructor reads 0 (zero scan still in flight, hasn't called + // setInitialIslandCount yet). By the time tidyUp runs, the zero scan + // has finished and the DB has 130. Without the refresh, results + // .initialCount stays at 0 from the constructor and the level math + // computes 200/100=2 instead of the correct (200-130)/100=0. The + // refresh inside tidyUp picks up the updated DB value, producing the + // expected level. + when(addon.getInitialIslandCount(any(Island.class))).thenReturn(0L, INITIAL_COUNT); + when(manager.getInitialCount(any(Island.class))).thenReturn(INITIAL_COUNT); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + // Force a non-empty scanned-chunk map so the reconcile/refresh branch fires. + calc.getScannedChunkValues().put("world:1", 200L); + r.rawBlockCount.set(200L); + calc.tidyUp(); + + assertEquals(0L, r.getLevel(), + "level should be 0 — refresh pulls the up-to-date initialCount=130 from the DB, " + + "so modifiedPoints = 200 - 130 = 70 → 0 levels"); + } + + @Test + @DisplayName("Donated blocks with no limit: full count is used") + void donatedBlocksNoLimit() { + // Player donated 1000 iron blocks; no limit configured; block value is 3. + // Expected donated points = 1000 * 3 = 3000. + when(manager.getDonatedBlocks(any(Island.class))).thenReturn(Map.of("IRON_BLOCK", 1000)); + when(blockConfig.getValue(any(), any(String.class))).thenAnswer(inv -> { + String key = inv.getArgument(1); + return "iron_block".equals(key) ? 3 : 0; + }); + when(blockConfig.getLimit(any(String.class))).thenReturn(null); + + IslandLevelCalculator calc = newCalculator(); + Results r = calc.getResults(); + r.rawBlockCount.set(INITIAL_COUNT); + calc.tidyUp(); + + assertEquals(3000L, r.getDonatedPoints(), + "donated points should equal 1000 * 3 = 3000 when no limit is configured"); + } +} diff --git a/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java b/src/test/java/world/bentobox/level/commands/AdminLevelStatusCommandTest.java index 816165e..3d98c61 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; @@ -58,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")); } @@ -65,6 +68,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")); } diff --git a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java index 40771de..5945420 100644 --- a/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java +++ b/src/test/java/world/bentobox/level/commands/IslandDonateCommandTest.java @@ -248,4 +248,86 @@ void testExecuteInvShowsConfirmationPrompt() { // No donation yet — only confirmation requested verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); } + + @Test + void testExecuteInvPromptShowsLimitNoticeWhenCapped() { + // 441 cobblestone in inventory, blockconfig limit of 100, nothing donated yet + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(441); + + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { cobble }); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Collections.emptyMap()); + + assertTrue(cmd.execute(user, "donate", List.of("inv"))); + + // limit-notice should be requested because 441 > limit of 100 + verify(user).getTranslation("island.donate.limit-notice"); + // confirm-line should be built using the limited amount (100), not the raw 441 + verify(user).getTranslation(eq("island.donate.inv.confirm-line"), + eq("[number]"), eq("100"), + eq("[material]"), anyString(), + eq("[points]"), anyString()); + } + + @Test + void testExecuteHandPromptShowsLimitNoticeWhenCapped() { + // 64 cobblestone in hand, limit 100, already donated 64 -> only 36 can be donated + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(64); + when(inventory.getItemInMainHand()).thenReturn(cobble); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Map.of("COBBLESTONE", 64)); + + assertTrue(cmd.execute(user, "donate", List.of("hand"))); + + // The confirm prompt should be built using the limited amount (36), not the raw 64 + verify(user).getTranslation(eq("island.donate.hand.confirm-prompt"), + eq("[number]"), eq("36"), + eq("[material]"), anyString(), + eq("[points]"), anyString()); + verify(user).getTranslation("island.donate.limit-notice"); + // No donation yet — only confirmation requested + verify(manager, never()).donateBlocks(any(), any(UUID.class), anyString(), anyInt(), anyLong()); + } + + @Test + void testExecuteHandRejectsWhenLimitAlreadyReached() { + // limit 100, already donated 100 -> no prompt, immediate limit-reached message + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(64); + when(inventory.getItemInMainHand()).thenReturn(cobble); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Map.of("COBBLESTONE", 100)); + + assertFalse(cmd.execute(user, "donate", List.of("hand"))); + + verify(user).sendMessage(eq("island.donate.limit-reached"), + eq("[material]"), anyString()); + verify(user, never()).getTranslation(eq("island.donate.hand.confirm-prompt"), + anyString(), anyString(), anyString(), anyString(), anyString(), anyString()); + } + + @Test + void testExecuteInvPromptNoLimitNoticeWhenUnderCap() { + // 50 cobblestone, limit 100, nothing donated yet — no notice + ItemStack cobble = mock(ItemStack.class); + when(cobble.getType()).thenReturn(Material.COBBLESTONE); + when(cobble.getAmount()).thenReturn(50); + + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { cobble }); + when(blockConfig.getValue(any(), eq(Material.COBBLESTONE))).thenReturn(1); + when(blockConfig.getLimit(Material.COBBLESTONE)).thenReturn(100); + when(manager.getDonatedBlocks(island)).thenReturn(java.util.Collections.emptyMap()); + + assertTrue(cmd.execute(user, "donate", List.of("inv"))); + + verify(user, never()).getTranslation("island.donate.limit-notice"); + } } diff --git a/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java b/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java index d0d80fb..43c58eb 100644 --- a/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java +++ b/src/test/java/world/bentobox/level/listeners/IslandActivitiesListenersTest.java @@ -1,6 +1,7 @@ package world.bentobox.level.listeners; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -8,6 +9,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Collections; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -98,6 +101,35 @@ 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 reconciled into the per-chunk handicap + // map (which also updates initialCount). Without this, mid-scan chunk + // generation would silently leave a positive delta and the island + // would never read level=0 right after reset. + when(settings.isZeroNewIslandLevels()).thenReturn(true); + Map missed = Map.of("world:42", 42L); + when(manager.drainZeroScanDeferred(island)).thenReturn(missed); + IslandResettedEvent event = new IslandResettedEvent(island, uuid, false, location, island); + listener.onNewIsland(event); + verify(manager).setInitialIslandCount(island, 100L); + verify(manager).reconcileHandicapChunks(island, missed, false); + } + + @Test + void testZeroIslandSkipsAddWhenNoDeferredCredits() { + // Empty drain → no reconciliation call, since the no-op would + // otherwise pad the database write path with a redundant save. + when(settings.isZeroNewIslandLevels()).thenReturn(true); + when(manager.drainZeroScanDeferred(island)).thenReturn(Collections.emptyMap()); + IslandResettedEvent event = new IslandResettedEvent(island, uuid, false, location, island); + listener.onNewIsland(event); + verify(manager).setInitialIslandCount(island, 100L); + verify(manager, never()).reconcileHandicapChunks(any(), any(), anyBoolean()); + } + @Test void testOnIslandResettedZeroNewIslandLevelsFalse() { when(settings.isZeroNewIslandLevels()).thenReturn(false);