Skip to content

Commit 178d85a

Browse files
tastybentoclaude
andcommitted
fix: first /level after zero scan adopts live total as canonical baseline
Diagnostics from a series of /po create + /po reset + /level runs surfaced three distinct sources of fresh-island drift that the per-chunk self-heal couldn't close on its own: 1. Spawner entity and chest content values are added to results.rawBlockCount in the post-scan handle* phase, not inside scanAsync's bracket, so they never make it into scannedChunkValues. A scan with 14 spawners leaves exactly 14 * 50 = 700 points of follow-up floating in totalPoints but absent from the per-chunk map — which is the 700 the user saw appear as level 7 in the first scan of one test. 2. Block-level evolution inside already-known chunks (lava+water forming obsidian after the zero scan snapshot, fluid sim propagating, trial spawner state) shows up as growing chunks (+821 in one diagnostic) that frozen-once won't absorb. 3. When the regular scan races the zero scan on a shared awaitPendingZeros counter and runs tidyUp first, persisted is still empty and the reconcile credits everything as new — but only at block-only values, leaving the entity/chest gap visible as level. (Seen as new=625 in the first scan of scenario 3, before zero scan's reconcile had fired.) Closing all three with one mechanism instead of trying to plug each: the zero scan now flags handicapPending=true and the next regular scan adopts its live totalPoints (which already includes spawners, chests, furniture, evolution) as the persisted initialCount. Subsequent scans see handicapPending=false and use the normal frozen-once self-heal, so player builds count toward level as before. The same flag is also set by the fresh-island reconcile branch (empty map + zero initialCount), so a regular scan that wins the race against zero scan still produces level=0 the first time. Legacy migration (empty map + positive initialCount) explicitly does NOT set the flag, preserving established player progress across the upgrade. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7715a12 commit 178d85a

4 files changed

Lines changed: 111 additions & 2 deletions

File tree

src/main/java/world/bentobox/level/LevelsManager.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -749,9 +749,14 @@ public long reconcileHandicapChunks(@NonNull Island island,
749749
if (zeroScan) {
750750
// Hard reset — the new baseline is everything the scan saw. The
751751
// initial count is rewritten separately by the caller, since a
752-
// zero scan is meant to be the canonical re-baseline.
752+
// zero scan is meant to be the canonical re-baseline. Flag
753+
// handicapPending so the next regular scan adopts its live total
754+
// as the final baseline — that's what absorbs decoration
755+
// evolution, spawner/chest follow-up values, and any
756+
// listener-deferred chunks the per-chunk capture missed.
753757
persisted.clear();
754758
persisted.putAll(scannedChunkValues);
759+
data.setHandicapPending(Boolean.TRUE);
755760
handler.saveObjectAsync(data);
756761
return 0L;
757762
}
@@ -762,11 +767,22 @@ public long reconcileHandicapChunks(@NonNull Island island,
762767
// feature existed. The existing initialCount already covers
763768
// every chunk currently on disk, so seeding the map without
764769
// touching initialCount keeps the level stable while moving
765-
// future drift detection onto the per-chunk path.
770+
// future drift detection onto the per-chunk path. Don't
771+
// flip handicapPending here — that would wipe established
772+
// player progress on the next /level.
766773
persisted.putAll(scannedChunkValues);
767774
handler.saveObjectAsync(data);
768775
return 0L;
769776
}
777+
// Fresh island whose zero scan either hasn't run yet, hasn't
778+
// finalised yet (waiting on awaitPendingZeros), or finished
779+
// out-of-order with this regular scan. Flag handicapPending so
780+
// tidyUp's baseline finalisation adopts the live total as the
781+
// canonical baseline. Without this, the per-chunk reconcile
782+
// below seeds the map with block-only values, leaving the
783+
// spawner/chest follow-up values floating in totalPoints and
784+
// appearing as level on a fresh island.
785+
data.setHandicapPending(Boolean.TRUE);
770786
}
771787
long delta = 0L;
772788
int newChunks = 0;

src/main/java/world/bentobox/level/calculators/IslandLevelCalculator.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,28 @@ public void tidyUp() {
925925
long blockAndDeathPoints = this.results.rawBlockCount.get();
926926
this.results.totalPoints.set(blockAndDeathPoints);
927927

928+
// First regular scan after a zero scan: adopt the live total as the
929+
// canonical baseline. The zero scan set handicapPending=true; this
930+
// overwrite absorbs decoration evolution, listener-deferred chunks,
931+
// spawner/chest follow-up values, and any race between zero scan
932+
// and regular scan into one consistent number, so the player sees
933+
// level=0 on a fresh island. Subsequent regular scans see
934+
// handicapPending=false and use the normal frozen-once handicap so
935+
// player builds count toward level.
936+
if (!zeroIsland && addon.getSettings().isZeroNewIslandLevels()
937+
&& !addon.getSettings().isDonationsOnly()) {
938+
world.bentobox.level.objects.IslandLevels data =
939+
addon.getManager().getLevelsData(island);
940+
if (data != null && Boolean.TRUE.equals(data.getHandicapPending())) {
941+
long total = this.results.totalPoints.get();
942+
addon.getManager().setInitialIslandCount(island, total);
943+
data.setHandicapPending(Boolean.FALSE);
944+
results.initialCount.set(total);
945+
addon.log("Handicap baseline finalised at " + total
946+
+ " for island " + island.getUniqueId());
947+
}
948+
}
949+
928950
if (this.addon.getSettings().getDeathPenalty() > 0) {
929951
// Proper death penalty calculation.
930952
blockAndDeathPoints -= this.results.deathHandicap.get() * this.addon.getSettings().getDeathPenalty();

src/main/java/world/bentobox/level/objects/IslandLevels.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,22 @@ public class IslandLevels implements DataObject {
119119
@Expose
120120
private Map<String, Long> handicapChunks;
121121

122+
/**
123+
* Set to {@code true} when a zero scan has just populated the handicap
124+
* map but the player hasn't run {@code /level} yet; the next regular
125+
* scan adopts its live total as the canonical baseline, absorbing any
126+
* decoration evolution, listener-deferred chunks, and spawner/chest
127+
* follow-up values that fell outside the per-chunk capture. Cleared
128+
* back to {@code false} on that overwrite so subsequent scans see the
129+
* normal frozen-once handicap.
130+
* <p>
131+
* Null on legacy data — treated as false, so existing islands aren't
132+
* accidentally rebaselined on upgrade (which would wipe player
133+
* progress).
134+
*/
135+
@Expose
136+
private Boolean handicapPending;
137+
122138
/**
123139
* Constructor for new island
124140
* @param islandUUID - island UUID
@@ -393,6 +409,22 @@ public void setHandicapChunks(Map<String, Long> handicapChunks) {
393409
this.handicapChunks = handicapChunks;
394410
}
395411

412+
/**
413+
* @return {@code true} if a zero scan has just finished and the next
414+
* regular scan should adopt its live total as the baseline.
415+
* Null on legacy data (treated as false by callers).
416+
*/
417+
public Boolean getHandicapPending() {
418+
return handicapPending;
419+
}
420+
421+
/**
422+
* @param handicapPending the handicapPending to set
423+
*/
424+
public void setHandicapPending(Boolean handicapPending) {
425+
this.handicapPending = handicapPending;
426+
}
427+
396428
/**
397429
* @return the initialLevel
398430
* @deprecated only used for backwards compatibility. Use {@link #getInitialCount()} instead

src/test/java/world/bentobox/level/LevelsManagerTest.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,45 @@ void testReconcileHandicapChunksLegacyMigration() throws Exception {
528528
assertEquals(100L, legacy.getHandicapChunks().get("world:1"));
529529
}
530530

531+
/**
532+
* Pin down that zero-scan reconcile sets handicapPending=true and a
533+
* fresh-island reconcile (empty map + zero initialCount) does the same.
534+
* tidyUp will then adopt the live scan total as the canonical baseline.
535+
* Legacy migration (empty map + positive initialCount) must NOT set the
536+
* flag — flipping it on an old island would wipe established player
537+
* progress on the next /level.
538+
*/
539+
@Test
540+
void testReconcileSetsHandicapPendingForBaselineFinalisation() throws Exception {
541+
// Zero scan: pending=true so first regular scan overwrites.
542+
IslandLevels zeroScanData = new IslandLevels(uuid.toString());
543+
when(handler.loadObject(uuid.toString())).thenReturn(zeroScanData);
544+
lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), true);
545+
assertEquals(Boolean.TRUE, zeroScanData.getHandicapPending(),
546+
"zero scan flags the next regular scan for baseline overwrite");
547+
548+
// Fresh island, no prior scan: pending=true so the very first regular
549+
// scan establishes the baseline correctly even if zero scan raced.
550+
UUID otherId = UUID.randomUUID();
551+
when(island.getUniqueId()).thenReturn(otherId.toString());
552+
IslandLevels freshData = new IslandLevels(otherId.toString());
553+
when(handler.loadObject(otherId.toString())).thenReturn(freshData);
554+
lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), false);
555+
assertEquals(Boolean.TRUE, freshData.getHandicapPending(),
556+
"fresh-island regular scan flags itself for baseline overwrite");
557+
558+
// Legacy migration: pending stays null. Old islands keep their
559+
// established progress across the upgrade.
560+
UUID legacyId = UUID.randomUUID();
561+
when(island.getUniqueId()).thenReturn(legacyId.toString());
562+
IslandLevels legacyData = new IslandLevels(legacyId.toString());
563+
legacyData.setInitialCount(5000L);
564+
when(handler.loadObject(legacyId.toString())).thenReturn(legacyData);
565+
lm.reconcileHandicapChunks(island, Map.of("world:1", 100L), false);
566+
assertEquals(null, legacyData.getHandicapPending(),
567+
"legacy migration does NOT flag baseline overwrite");
568+
}
569+
531570
/**
532571
* Zero-scan reconciliation hard-resets the map and returns 0 (the caller
533572
* rewrites initialCount separately via setInitialIslandCount).

0 commit comments

Comments
 (0)