diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index d02f5132a..3dedd4d4b 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -49,4 +49,4 @@ jobs: # 4. Build via Maven - name: Build via Maven - run: mvn verify -B --file pom.xml -DdisableXmlReport=true + run: mvn verify -B -Psql-tests --file pom.xml -DdisableXmlReport=true diff --git a/Changelog.txt b/Changelog.txt index 36e90f50d..93355e352 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,16 +1,83 @@ Version 2.2.053 + !! -- This build has important fixes for anyone using Paper (or forks of Paper), please read the notes carefully. + It is completely safe to update to this version of mcMMO. + A recent change in Paper can cause data loss in mcMMO region files (which track blocks that should NOT give rewards) if Paper is updated before mcMMO. + mcMMO now backs up region data on Spigot and older Paper builds to help prevent this. + mcMMO will also migrate backed-up region data when present and when server software is using the Paper 26.1.2+ world layout (See notes) + Added 'General.RegionDataMigrationBackups.Enabled' config option to config.yml, which can disabled region data backups for anyone who doesn't plan on updating to newer Paper builds or using Paper + Fixed entities retaining their healthbar display name after chunk unload (Thanks Warriorrrr) + Fixed traveling block metadata leaking on Folia servers (Thanks Warriorrrr) + Fixed Magic Hunter (Fishing) enchantment conflict check not accounting for enchantments already accumulated during the same roll (Thanks Warriorrrr) Fixed KnockOnWood XP orbs never spawning on nether/warped tree cap blocks during Tree Feller Fixed Impale (Tridents) damage bonus formula applying one fewer rank of the multiplier than intended Fixed melee attack strength scale resolving to near-zero after Paper fixed a vanilla attack cooldown bug in 26.1.2 (See notes) Fixed server-side diminished returns state being evicted too early, allowing reconnects to bypass the DR window (See notes) + Added option to allow Magic Hunter (Fishing) to grant items with conflicting enchantments; disabled by default (Thanks Warriorrrr) + Added 'Skills.Fishing.Allow_Conflicting_Enchants' to config.yml (Thanks Warriorrrr) (Codebase) Removed 27 dead JSON.* locale keys from all locale files (See notes) (Codebase) Extracted per-player diminished returns tracking from PlayerProfile into DiminishedReturnsCache and DiminishedReturnsState NOTES: - As a reminder, Diminished Returns for XP is an optional feature and is disabled by default, this update has some bugfixes related to it for those using it. - Paper 26.1.2 fixed a bug where the player attack cooldown ticker was not resetting at the correct point during melee hits. mcMMO was reading the cooldown during the damage event and relied on the old (incorrect) order, so after Paper's fix it always returned near-zero. Attack strength is now back-derived from the raw event damage and the player's attack damage attribute instead. No config changes required. + -- Read this first if you already updated to a newer Paper 26.1.2+ build -- + mcMMO stores block data as '.mcm' files in each world's 'mcmmo_regions' folder. + Paper's world migration copies world_nether and world_the_end into new directories, but did not copy over mcMMO region files for those worlds, which results in data loss. + The main risk is for 'world_nether' and 'world_the_end', because Paper migration can remove old non-overworld roots before plugins load. + The Overworld (your main 'world') is safe because Paper keeps that folder and all mcMMO data in-tact (but no longer in the "right" spot), so mcMMO can still migrate existing overworld '.mcm' data and will do so when you run this update if it finds any. + + Given you are reading this if you already updated Paper and got the new world migration BEFORE updating mcMMO, then you have this option which is not perfect to recover lost mcmmo_region data. + Recovery steps: + + 1) Stop the server fully. + 2) Find mcmmo_regions data in any manual backups you make of your server/worlds + 3) Copy recovered '.mcm' files for Nether/End only into these live folders (create folders if missing): + Nether: 'world/dimensions/minecraft/the_nether/mcmmo_regions/' + The End: 'world/dimensions/minecraft/the_end/mcmmo_regions/' + Do NOT manually copy old overworld '.mcm' files into 'world/mcmmo_regions/' in this scenario, only do this for nether and the end. + For your main world, mcMMO safely merges surviving overworld data on startup, and manual overworld copy/overwrite can lose some data. + 4) Start the server. + + This is an imperfect solution depending on how long your server has been running post updating Paper 26.1.2 before updating mcMMO, as the data will have drifted to some degree (mostly impacts data for the end and nether as stated before). + + -- Read this if you have NOT updated Paper yet, or if you use Spigot instead -- + Update mcMMO first, then run the server at least once with this mcMMO build before updating server software. + On a normal shutdown, mcMMO will make backups of its region files. + After that, you are safe to update to the newest Paper builds. + + -- Read this if you don't know whether you already updated to the new Paper world format -- + Identify the format by checking your world folders: + Old/legacy layout looks like this: + 'world/' + 'world_nether/' + 'world_the_end/' + New Paper 26.1+ layout looks like this: + 'world/' + 'world/dimensions/minecraft/the_nether/' + 'world/dimensions/minecraft/the_end/' + Quick check: if you see 'world/dimensions/minecraft/the_nether/' and 'world/dimensions/minecraft/the_end/', you are already on the new Paper format. + If you already have the new layout and only updated mcMMO now, mcMMO will safely migrate any surviving overworld '.mcm' data from 'world/mcmmo_regions/'. + This operation is safe and merges with any new data mcMMO finds. + In this scenario, only restore '.mcm' files manually for Nether/End. + Do NOT manually copy old overworld '.mcm' files into 'world/mcmmo_regions/' because that can overwrite merged data and lose some entries. + In that same scenario, old nether/end '.mcm' data is already gone, so copy those '.mcm' files from any manual backups you have and place them in: + 'world/dimensions/minecraft/the_nether/mcmmo_regions/' + 'world/dimensions/minecraft/the_end/mcmmo_regions/' + If you are already on the new format, follow the first section above for full recovery steps. + If you still have the old layout, follow the section above for Spigot/old Paper: update mcMMO first, run once, then update Paper. + + -- Read this if you use Spigot and never plan to use Paper in the future -- + You can disable the shutdown migration backup behavior with 'General.RegionDataMigrationBackups' in config.yml + Default is 'true'. Set it to 'false' if you are sure you will stay on Spigot and do not need mcMMO to make backups of its region files. + In simple terms: this setting controls whether mcMMO makes extra '.mcm' safety copies during shutdown for future Paper world-layout migration. + Turning it off reduces extra backup work on shutdown, but removes that Paper-migration safety net. + + OTHER NOTES: + Diminished Returns for XP is an optional feature and is disabled by default, this update includes bug fixes for servers using it. + evictExpired() was removing freshly created DiminishedReturnsState entries before the player had registered any XP, orphaning the PlayerProfile reference and letting players bypass the DR window by reconnecting. + State is now preserved in a server-side DiminishedReturnsCache keyed by UUID and is only evicted after being idle for the full DR window. + Paper 26.1.2 fixed a bug where the player attack cooldown ticker was not resetting at the correct point during melee hits. + mcMMO was reading cooldown during the damage event and relied on the old (incorrect) order, so after Paper's fix it resolved to near-zero. + Attack strength is now back-derived from raw event damage and the player's attack damage attribute. No config changes are required. The removed locale keys (JSON.Rank, JSON.JWrapper.Header, JSON.JWrapper.Target.{Type,Block,Player}, JSON.Hover.{SuperAbility,Mystery2}, JSON.Notification.SuperAbility, JSON.Acrobatics.Roll.Interaction.Activated, and all JSON. skill-name keys) can safely be removed from any locale_override.properties — they had no effect. - evictExpired() was removing freshly-created DiminishedReturnsState entries before the player had registered any XP, orphaning the PlayerProfile reference and letting players bypass the DR window by reconnecting. State is now preserved in a server-side DiminishedReturnsCache keyed by UUID and is only evicted after it has been idle for the full DR window. Version 2.2.052 diff --git a/Jenkinsfile b/Jenkinsfile index 19c560f04..e5f795455 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -21,7 +21,7 @@ pipeline { stage('Build') { steps { - sh 'mvn -V -B clean package' + sh 'mvn -V -B -Psql-tests clean package' } } diff --git a/pom.xml b/pom.xml index 97e74f8fb..14175e31f 100644 --- a/pom.xml +++ b/pom.xml @@ -25,6 +25,7 @@ 17 17 17 + skip,stress,docker @@ -108,7 +109,7 @@ org.junit.jupiter:junit-jupiter false - skip + ${surefire.excludedGroups} -javaagent:${org.mockito:mockito-core:jar} @@ -575,6 +576,24 @@ + + sql-tests + + skip,stress + + + + stress-tests + + skip,docker + + + + all-heavy-tests + + skip + + skip-docker-tests @@ -583,16 +602,9 @@ true - - - - maven-surefire-plugin - - skip,docker - - - - + + skip,docker,stress + diff --git a/src/main/java/com/gmail/nossr50/config/GeneralConfig.java b/src/main/java/com/gmail/nossr50/config/GeneralConfig.java index befac48f1..9067f8ceb 100644 --- a/src/main/java/com/gmail/nossr50/config/GeneralConfig.java +++ b/src/main/java/com/gmail/nossr50/config/GeneralConfig.java @@ -236,6 +236,10 @@ public class GeneralConfig extends BukkitConfig { return config.getBoolean("General.Refresh_Chunks", false); } + public boolean getRegionDataMigrationBackupsEnabled() { + return config.getBoolean("General.RegionDataMigrationBackups", true); + } + public boolean getMobHealthbarEnabled() { return config.getBoolean("Mob_Healthbar.Enabled", true); } diff --git a/src/main/java/com/gmail/nossr50/listeners/WorldListener.java b/src/main/java/com/gmail/nossr50/listeners/WorldListener.java index ef2286e8d..0df1281f8 100644 --- a/src/main/java/com/gmail/nossr50/listeners/WorldListener.java +++ b/src/main/java/com/gmail/nossr50/listeners/WorldListener.java @@ -1,7 +1,9 @@ package com.gmail.nossr50.listeners; +import com.gmail.nossr50.config.PersistentDataConfig; import com.gmail.nossr50.config.WorldBlacklist; import com.gmail.nossr50.mcMMO; +import com.gmail.nossr50.util.blockmeta.McMMORegionBackupStore; import org.bukkit.Chunk; import org.bukkit.block.BlockState; import org.bukkit.event.EventHandler; @@ -9,6 +11,7 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.world.ChunkUnloadEvent; import org.bukkit.event.world.StructureGrowEvent; +import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.WorldUnloadEvent; public class WorldListener implements Listener { @@ -39,7 +42,31 @@ public class WorldListener implements Listener { } /** - * Monitor WorldUnload events. + * Restores mcMMO block-tracker data from the backup store for any world that loads after + * plugin enable (Multiverse worlds, lazy-loaded dimensions). Only runs when Paper 26.1+ has + * reshaped the world and the in-world {@code mcmmo_regions/} folder is empty; a no-op in + * all other cases. + * + * @param event The event to watch + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onWorldLoad(WorldLoadEvent event) { + if (!PersistentDataConfig.getInstance().useBlockTracker() + || !plugin.getGeneralConfig().getRegionDataMigrationBackupsEnabled()) { + return; + } + if (WorldBlacklist.isWorldBlacklisted(event.getWorld())) { + return; + } + McMMORegionBackupStore.restoreWorld(event.getWorld(), plugin.getLogger(), + plugin.getDataFolder().toPath()); + } + + /** + * Flushes chunk-store data for the unloading world and, on Spigot / pre-26.1 Paper layouts + * (the "legacy shape"), writes a backup snapshot into the mcMMO plugin data directory so + * the block-tracker data survives if a future Paper upgrade deletes the world's old folder + * layout. Skipped for worlds already on the Paper 26.1+ layout and for blacklisted worlds. * * @param event The event to watch */ @@ -51,6 +78,12 @@ public class WorldListener implements Listener { } mcMMO.getChunkManager().unloadWorld(event.getWorld()); + + if (PersistentDataConfig.getInstance().useBlockTracker() + && plugin.getGeneralConfig().getRegionDataMigrationBackupsEnabled()) { + McMMORegionBackupStore.backupWorld(event.getWorld(), plugin.getLogger(), + plugin.getDataFolder().toPath()); + } } /** diff --git a/src/main/java/com/gmail/nossr50/mcMMO.java b/src/main/java/com/gmail/nossr50/mcMMO.java index c57aeb9ad..6f5a0e561 100644 --- a/src/main/java/com/gmail/nossr50/mcMMO.java +++ b/src/main/java/com/gmail/nossr50/mcMMO.java @@ -7,9 +7,11 @@ import com.gmail.nossr50.config.CoreSkillsConfig; import com.gmail.nossr50.config.CustomItemSupportConfig; import com.gmail.nossr50.config.GeneralConfig; import com.gmail.nossr50.config.HiddenConfig; +import com.gmail.nossr50.config.PersistentDataConfig; import com.gmail.nossr50.config.RankConfig; import com.gmail.nossr50.config.SoundConfig; import com.gmail.nossr50.config.WorldBlacklist; +import com.gmail.nossr50.util.blockmeta.McMMORegionBackupStore; import com.gmail.nossr50.config.experience.ExperienceConfig; import com.gmail.nossr50.config.party.PartyConfig; import com.gmail.nossr50.config.skills.alchemy.PotionConfig; @@ -75,6 +77,7 @@ import java.io.File; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.logging.Level; @@ -291,6 +294,44 @@ public class mcMMO extends JavaPlugin { chunkManager = ChunkManagerFactory.getChunkManager(); // Get our ChunkletManager + if (PersistentDataConfig.getInstance().useBlockTracker() + && generalConfig.getRegionDataMigrationBackupsEnabled()) { + long migrationRestoreTotalNanos = 0L; + int migrationRestoreWorldsWithWork = 0; + boolean migrationAnnouncementLogged = false; + + for (org.bukkit.World loadedWorld : getServer().getWorlds()) { + if (WorldBlacklist.isWorldBlacklisted(loadedWorld)) { + continue; + } + + final long migrationRestoreStartNanos = System.nanoTime(); + final boolean restoreApplied = McMMORegionBackupStore.restoreWorld( + loadedWorld, getLogger(), getDataFolder().toPath()); + final long migrationRestoreElapsedNanos = + System.nanoTime() - migrationRestoreStartNanos; + + if (!restoreApplied) { + continue; + } + + if (!migrationAnnouncementLogged) { + getLogger().info("Detected Paper world migration, starting data " + + "migration for mcMMO region files..."); + migrationAnnouncementLogged = true; + } + + migrationRestoreTotalNanos += migrationRestoreElapsedNanos; + migrationRestoreWorldsWithWork++; + } + + if (migrationRestoreWorldsWithWork > 0) { + getLogger().info("[RegionDataMigration] total restore time across " + + migrationRestoreWorldsWithWork + " world(s): " + + formatDurationHms(migrationRestoreTotalNanos)); + } + } + if (generalConfig.getPTPCommandWorldPermissions()) { Permissions.generateWorldTeleportPermissions(); } @@ -404,6 +445,48 @@ public class mcMMO extends JavaPlugin { formulaManager.saveFormula(); chunkManager.closeAll(); + if (PersistentDataConfig.getInstance().useBlockTracker() + && generalConfig.getRegionDataMigrationBackupsEnabled()) { + long backupTotalNanos = 0L; + int backupWorldsWithWork = 0; + boolean backupAnnouncementLogged = false; + + for (org.bukkit.World loadedWorld : getServer().getWorlds()) { + if (WorldBlacklist.isWorldBlacklisted(loadedWorld)) { + continue; + } + + final long backupStartNanos = System.nanoTime(); + final boolean backupApplied = McMMORegionBackupStore.backupWorld( + loadedWorld, getLogger(), getDataFolder().toPath()); + final long backupElapsedNanos = System.nanoTime() - backupStartNanos; + + if (!backupApplied) { + continue; + } + + if (!backupAnnouncementLogged) { + getLogger().info("Legacy region format detected, mcMMO will back up " + + "region data files to prevent data loss, do NOT force a " + + "shutdown until this completes."); + backupAnnouncementLogged = true; + } + + backupTotalNanos += backupElapsedNanos; + backupWorldsWithWork++; + + getLogger().fine("[RegionDataBackups] world '" + loadedWorld.getName() + + "': mcMMO region file(s) backup finished in " + + formatDurationHms(backupElapsedNanos)); + } + + if (backupWorldsWithWork > 0) { + getLogger().info("[RegionDataBackups] Region data backup completed, " + + "total time spent to complete this operation across " + + backupWorldsWithWork + " world(s): " + + formatDurationHms(backupTotalNanos)); + } + } } catch (Exception e) { getLogger().log(Level.SEVERE, "An error occurred while disabling mcMMO!", e); } @@ -784,6 +867,39 @@ public class mcMMO extends JavaPlugin { return serverShutdownExecuted; } + static String formatDurationHms(long elapsedNanos) { + final Duration elapsedDuration = Duration.ofNanos(Math.max(0L, elapsedNanos)); + final long totalMillis = elapsedDuration.toMillis(); + + if (totalMillis < 1000L) { + return totalMillis + "ms"; + } + + final long totalSeconds = elapsedDuration.getSeconds(); + final long hours = totalSeconds / 3600; + final long minutes = (totalSeconds % 3600) / 60; + final long seconds = totalSeconds % 60; + + final StringBuilder displayBuilder = new StringBuilder(); + if (hours > 0L) { + displayBuilder.append(hours).append("h"); + } + if (minutes > 0L) { + if (displayBuilder.length() > 0) { + displayBuilder.append(' '); + } + displayBuilder.append(minutes).append("m"); + } + if (seconds > 0L) { + if (displayBuilder.length() > 0) { + displayBuilder.append(' '); + } + displayBuilder.append(seconds).append("s"); + } + + return displayBuilder.length() == 0 ? totalMillis + "ms" : displayBuilder.toString(); + } + private static synchronized void setServerShutdown(boolean bool) { serverShutdownExecuted = bool; } diff --git a/src/main/java/com/gmail/nossr50/util/blockmeta/BitSetChunkStore.java b/src/main/java/com/gmail/nossr50/util/blockmeta/BitSetChunkStore.java index 17e8cb0c7..29aac4de7 100644 --- a/src/main/java/com/gmail/nossr50/util/blockmeta/BitSetChunkStore.java +++ b/src/main/java/com/gmail/nossr50/util/blockmeta/BitSetChunkStore.java @@ -106,6 +106,25 @@ public class BitSetChunkStore implements ChunkStore { return store.isEmpty(); } + /** + * Merge anti-exploit "block is player-placed" markers from {@code other} into this store. + * Only set bits are copied; cleared bits in {@code other} never clear a bit that is set in + * this store. Used by the Paper world-folder layout migrator to fold legacy region data into + * post-migration region data without losing reward-denial markers. + */ + void mergeFrom(@NotNull BitSetChunkStore other) { + if (!worldUid.equals(other.worldUid)) { + throw new IllegalArgumentException( + "Cannot merge chunk stores from different worlds (this=" + worldUid + + ", other=" + other.worldUid + ")"); + } + if (other.store.isEmpty()) { + return; + } + store.or(other.store); + dirty = true; + } + private int coordToIndex(int x, int y, int z) { return coordToIndex(x, y, z, worldMin, worldMax); } diff --git a/src/main/java/com/gmail/nossr50/util/blockmeta/HashChunkManager.java b/src/main/java/com/gmail/nossr50/util/blockmeta/HashChunkManager.java index d25beacc0..b2ce92a5d 100644 --- a/src/main/java/com/gmail/nossr50/util/blockmeta/HashChunkManager.java +++ b/src/main/java/com/gmail/nossr50/util/blockmeta/HashChunkManager.java @@ -85,11 +85,31 @@ public class HashChunkManager implements ChunkManager { }); } + /** + * Resolves the on-disk region file for a chunk's region. + * + *

Region files live inside the world folder at + * {@code [worldFolder]/mcmmo_regions/mcmmo_[regionX]_[regionZ]_.mcm}, where + * {@code worldFolder} is whatever {@link World#getWorldFolder()} returns on the running + * server. On Spigot and pre-26.1 Paper this resolves to + * {@code [container]/[worldName]/mcmmo_regions/}; on Paper 26.1+ (PaperMC/Paper PR #13736) + * it resolves to + * {@code [container]/[worldName]/dimensions/minecraft//mcmmo_regions/}. + * + *

Because Paper's {@code LegacyCraftBukkitWorldMigration} runs before plugins load and + * deletes the old per-world roots for non-overworld dimensions, mcMMO maintains a restore + * store inside the mcMMO plugin data directory that is populated by + * {@link McMMORegionBackupStore#backupWorld} on shutdown and replayed by + * {@link McMMORegionBackupStore#restoreWorld} on the next startup if the in-world data has + * been removed. + */ private @NotNull File getRegionFile(@NotNull World world, @NotNull CoordinateKey regionKey) { if (world.getUID() != regionKey.worldID) { throw new IllegalArgumentException(); } - return new File(new File(world.getWorldFolder(), "mcmmo_regions"), + final File worldRegionRoot = new File(world.getWorldFolder(), + McMMORegionBackupStore.IN_WORLD_FOLDER_NAME); + return new File(worldRegionRoot, "mcmmo_" + regionKey.x + "_" + regionKey.z + "_.mcm"); } diff --git a/src/main/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStore.java b/src/main/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStore.java new file mode 100644 index 000000000..9fc7630ff --- /dev/null +++ b/src/main/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStore.java @@ -0,0 +1,1060 @@ +package com.gmail.nossr50.util.blockmeta; + +import com.gmail.nossr50.util.LogUtils; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryNotEmptyException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.bukkit.World; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Protects mcMMO block-tracker data from being lost when a Minecraft server upgrades from + * Spigot or pre-26.1 Paper to Paper 26.1 or later. + * + *

What is block-tracker data?

+ *

mcMMO tracks which blocks in each world were placed by players (as opposed to naturally + * generated). This prevents players from gaining XP by placing and mining the same block + * repeatedly. The tracking data is stored as {@code .mcm} binary files inside each world + * folder at {@code [worldFolder]/mcmmo_regions/}. + * + *

What are "legacy shape" and "new shape" worlds?

+ *

On Spigot and Paper before version 26.1 (the "legacy shape"), every world + * lives directly inside the server container folder: + *

+ *   [serverFolder]/
+ *     world/                  ← overworld
+ *     world_nether/           ← nether
+ *     world_the_end/          ← end
+ * 
+ *

On Paper 26.1+ (the "new shape"), multidimensional worlds are reorganized so + * that each world's non-overworld dimensions live in a subfolder: + *

+ *   [serverFolder]/
+ *     world/                  ← overworld (unchanged)
+ *     world/dimensions/minecraft/the_nether/    ← nether (moved here)
+ *     world/dimensions/minecraft/the_end/       ← end (moved here)
+ * 
+ *

mcMMO detects which shape is active at runtime by comparing + * {@link World#getWorldFolder()} against the expected legacy path. This check happens on + * every backup and restore call, so it is always accurate even if the server software changes. + * + *

The problem this class solves

+ *

Paper 26.1+ runs a one-time migration (LegacyCraftBukkitWorldMigration, + * PaperMC/Paper PR #13736) on startup before any plugins load. This migration deletes + * the old per-world root directories for non-overworld dimensions. Any + * {@code mcmmo_regions/} subfolder that lived inside those directories is permanently lost. + * mcMMO cannot intervene because it has not been loaded yet. + * + *

How this class protects your data

+ *

mcMMO keeps a backup store inside its own plugin data folder + * ({@code plugins/mcMMO/region_data_backups_for_migration/}). The store works in two halves: + * + *

+ * + *

Why backups only run on the legacy shape

+ *

The migration from legacy to new shape is a one-time, one-way event. Once a world is on + * the new Paper layout, future server updates have no reason to delete + * {@code mcmmo_regions/} again. Writing backup snapshots for new-shape worlds would just + * waste disk space. mcMMO detects the shape on every backup call and skips the backup + * automatically when the world is already on the new shape. + * + *

Admin deletes a world to start fresh

+ *

If an admin deletes a world and recreates it from scratch, mcMMO will not back up the + * fresh world until players start placing blocks (no {@code .mcm} files → no backup). Stale + * snapshots for the deleted world remain in the backup store until three new successful + * backups for that same world push them out under the + * {@value #MAX_BACKUPS_RETAINED}-snapshot retention limit. If the world is permanently gone, + * the snapshots remain until deleted manually. + * + *

If the server is on the legacy shape when the world is deleted and the admin + * later upgrades to Paper 26.1+, mcMMO will see an empty in-world folder and existing + * snapshots and will restore the pre-deletion data into the fresh world. To prevent this, + * delete {@code plugins/mcMMO/region_data_backups_for_migration//} while the server is stopped before + * upgrading. + * + *

If the server is already on the new shape (Paper 26.1+) when the world is + * deleted, this is not a concern — mcMMO archives migration backup data for each world as + * soon as it confirms that block-tracker data is present in the new-shape location. + * + *

See the README written into the backup store folder for full operator guidance. + * + *

Merge semantics

+ *

When both an incoming backup file and an existing in-world file contain data for the same + * chunk, mcMMO uses {@link BitSetChunkStore#mergeFrom(BitSetChunkStore)} to OR the two sets of + * placed-block bits together. No player-placed block record is ever silently discarded. This + * makes both backup and restore safe to re-run after a partial failure. + * + *

Error handling

+ *

Neither {@link #backupWorld} nor {@link #restoreWorld} ever throws an exception. Any I/O + * or runtime failure is logged at WARNING or SEVERE level so the server continues starting + * and stopping cleanly even when disk errors occur. + */ +public final class McMMORegionBackupStore { + + /** + * Name of the subfolder inside each world folder that holds canonical mcMMO region files. + * Resolves to {@code [worldFolder]/mcmmo_regions/}. + */ + public static final String IN_WORLD_FOLDER_NAME = "mcmmo_regions"; + + /** + * Name of the backup-store subfolder inside mcMMO's plugin data directory. + * Resolves to {@code plugins/mcMMO/region_data_backups_for_migration/}. + */ + public static final String BACKUP_ROOT_FOLDER_NAME = "region_data_backups_for_migration"; + + /** + * Subfolder under the backup store root that keeps world backups after a restore has used + * them once. + */ + static final String ARCHIVE_ROOT_FOLDER_NAME = "archive"; + + /** + * File written last inside a completed snapshot folder. Its presence is the only + * thing that distinguishes a complete snapshot from one that was interrupted mid-write. + */ + public static final String BACKUP_COMPLETE_SENTINEL = "BACKUP_COMPLETE"; + + /** README file written into the backup-store root for operator reference. */ + public static final String README_FILE_NAME = "README.txt"; + + /** + * Suffix applied to a snapshot folder while it is still being assembled. The folder is + * atomically renamed to drop this suffix once every file has been successfully copied. + */ + static final String IN_PROGRESS_SUFFIX = ".tmp"; + + /** Maximum number of complete snapshots to keep per world in the backup store. */ + static final int MAX_BACKUPS_RETAINED = 3; + + /** + * Timestamp format used to name snapshot folders. Lexically sortable (newest = last when + * sorted A→Z), uses UTC, and contains no characters that are illegal in folder names on + * Windows or Linux. + */ + static final DateTimeFormatter SNAPSHOT_TIMESTAMP_FORMAT = DateTimeFormatter + .ofPattern("yyyy-MM-dd_HH-mm-ss'Z'") + .withZone(ZoneOffset.UTC); + + private static final Pattern SNAPSHOT_DIR_PATTERN = Pattern.compile( + "\\d{4}-\\d{2}-\\d{2}_\\d{2}-\\d{2}-\\d{2}Z"); + private static final Pattern REGION_FILE_PATTERN = Pattern.compile( + "mcmmo_(-?\\d+)_(-?\\d+)_\\.mcm"); + private static final String BACKUP_LOG_TAG = "[RegionDataBackups]"; + private static final String MIGRATION_LOG_TAG = "[RegionDataMigration]"; + + private static final String README_BODY = """ + mcMMO region backup store + ========================= + Location: plugins/mcMMO/region_data_backups_for_migration/ + + What is in this folder? + ----------------------- + mcMMO tracks which blocks in each world were placed by players so that players + cannot gain XP by repeatedly placing and breaking the same block. This tracking + data is stored as .mcm binary files inside each world folder: + + [worldFolder]/mcmmo_regions/mcmmo___.mcm + + This folder (region_data_backups_for_migration/) is NOT where that data normally lives. It is a backup + store that mcMMO keeps so the tracking data survives if a server + software upgrade accidentally deletes the world folder layout it was stored in. + + Each world has its own subfolder under this root: + + region_data_backups_for_migration/// + + Why does this backup store exist? + --------------------------------- + Paper 26.1+ (PaperMC PR #13736) runs a one-time migration when the server starts + that reorganises how dimension folders (nether, end, custom dimensions) are stored + on disk. This migration runs BEFORE any plugins load, so mcMMO cannot intervene. + It deletes the old per-world root folders for non-overworld dimensions, which + takes any mcmmo_regions/ subfolder stored there with it. + + To protect against this, mcMMO saves a backup of the region data into this folder + on every clean shutdown, then restores it on the next startup if it detects that + Paper has moved the world to the new layout. + + What does "world shape" mean? + ----------------------------- + "Legacy shape" means the server is running Spigot or Paper before version 26.1. + In this layout, every world lives directly inside the server folder: + + [serverFolder]/world/ <- overworld + [serverFolder]/world_nether/ <- nether + [serverFolder]/world_the_end/ <- end + + "New shape" means Paper 26.1 or later has reorganised the folders: + + [serverFolder]/world/ <- overworld (unchanged) + [serverFolder]/world/dimensions/minecraft/the_nether/ <- nether (moved) + [serverFolder]/world/dimensions/minecraft/the_end/ <- end (moved) + + mcMMO checks which shape is active at runtime on every backup and restore, so the + check is always accurate even after a server software upgrade. + + How backup works + ---------------- + On every clean shutdown, for each world still on the LEGACY shape, mcMMO: + + 1. Copies all .mcm files from [worldFolder]/mcmmo_regions/ into a staging folder + named after the current UTC date and time + ".tmp", for example: + region_data_backups_for_migration//2026-05-31_14-23-05Z.tmp/ + + 2. Once every file is copied successfully, atomically renames the staging folder + by removing the .tmp suffix: + region_data_backups_for_migration//2026-05-31_14-23-05Z/ + + 3. Writes a small BACKUP_COMPLETE file into the snapshot folder as a + "completion stamp" — proof that the snapshot is whole and can be trusted. + + 4. Keeps only the 3 newest complete snapshots and deletes older ones. + + Backups do NOT run for worlds already on the new Paper shape. Once Paper has + migrated a world, future server-jar updates have no reason to delete + mcmmo_regions/ again, so a backup snapshot is no longer needed. + + How restore works + ----------------- + On startup (and whenever a world loads at runtime), mcMMO checks each world that + is on the new Paper shape (Paper 26.1+). Two things can happen: + + Scenario A — in-world mcmmo_regions/ is empty and a complete snapshot exists: + + mcMMO copies the files from the newest complete snapshot into + [worldFolder]/mcmmo_regions/, then moves the old backup-store tree into + region_data_backups_for_migration/archive/// so an admin can re-use the same + data for another merge pass if needed. + + Scenario B — in-world mcmmo_regions/ already has .mcm files: + + The Paper migration already completed successfully (either mcMMO already did + the restore on a previous startup, or Paper preserved the folder). mcMMO + moves the backup-store tree into the archive folder immediately if it still + contains real snapshots; otherwise it deletes the empty per-world folder. + + In both scenarios the active per-world backup folder is cleared out. Any restored + snapshots that are worth keeping are preserved under the archive folder. + + Legacy-root leftover files from Paper migration + ----------------------------------------------- + Paper migration can leave .mcm files behind at: + + [container]//mcmmo_regions/ + + mcMMO reconciles these leftovers on restore. If a snapshot restore was used, mcMMO + deletes the leftover files. If no snapshot restore was used, mcMMO merges leftover + files into the active in-world data and then deletes the leftover source files. + Unlike snapshot data, these leftovers are never archived. + + How to retry a merge + -------------------- + If mcMMO already archived a backup but you want it to try the same merge again, + stop the server and move the archived world folder back here: + + region_data_backups_for_migration/// + + Then delete the archive folder for that world so only the active copy is left. + The archive folder by itself is ignored; mcMMO only acts on the active copy. + On the next startup, mcMMO will see the backup data again and repeat the merge. + + Merging is safe because mcMMO never guesses about player-placed blocks. If the + world and the backup both know about the same chunk, mcMMO combines the two so + any block either copy recorded is kept. + + If an admin deletes a world to start fresh + ------------------------------------------ + mcMMO will NOT back up an empty world — if the world has no .mcm files, backup + is skipped for that world. Old snapshots for the deleted world remain here until + three new SUCCESSFUL backups for that same world push them out under the + 3-snapshot retention limit. If the world is permanently gone, the snapshots stay + here until you delete them manually. + + DANGER — legacy-shape server + world deletion + Paper 26.1+ upgrade: + + If your server is on the LEGACY SHAPE when you delete a world, and you later + upgrade to Paper 26.1+, mcMMO will see the empty new-shape in-world folder + and the old snapshot here, and will RESTORE THE PRE-DELETION DATA into the + fresh world. To prevent this: + + 1. While the server is stopped, delete the backup-store folder for that + world: region_data_backups_for_migration// + 2. Do this BEFORE starting the server with Paper 26.1+ for the first time. + + If your server is ALREADY on the new Paper shape (Paper 26.1+) when you delete + a world, this danger does not apply — mcMMO removes the backup store for each + world as soon as it confirms block-tracker data is present in the new-shape + location, so there is nothing left here to accidentally restore. + + Understanding snapshot folder names + ------------------------------------ + Each snapshot is a folder named by the UTC date and time when it was written, + for example: 2026-05-31_14-23-05Z/ + + The Z at the end stands for UTC (Coordinated Universal Time). The format sorts + lexically (alphabetically) so the newest snapshot is always the last one when + the folder list is sorted A→Z. + + The BACKUP_COMPLETE file inside a snapshot folder is a plain text file that + records the world name, file count, and timestamp. mcMMO only reads a snapshot + if this file is present. A snapshot without this file was interrupted (by a + crash, power loss, etc.) and is automatically cleaned up on the next startup or + shutdown. + + Edge cases + ---------- + 1. Brand-new world on Paper 26.1+ with no prior data: no snapshot exists here, + in-world is empty, nothing to restore. mcMMO just starts writing fresh data + inside the world folder as players place blocks. + + 2. mcMMO 2.2.053-SNAPSHOT layout (flat .mcm files directly under + region_data_backups_for_migration// with a migration_complete_marker_file): on first + startup after upgrading from that snapshot, mcMMO copies those files back into + [worldFolder]/mcmmo_regions/ (union-merging on any overlap), then deletes the + marker and those flat files. Any timestamped snapshot subfolders are left alone. + + 3. Crash or power loss mid-backup: the *.tmp staging folder or a snapshot folder + without a BACKUP_COMPLETE file is cleaned up on the next startup. Previously + completed snapshots are unaffected. + + 4. Operator force-deletes in-world mcmmo_regions/ on a legacy-shape server: + mcMMO will NOT auto-restore from this backup store on the legacy shape — + restore is gated on the new Paper layout. To force a restore on the legacy + shape, copy the .mcm files from the newest BACKUP_COMPLETE snapshot folder + into [container]/[worldName]/mcmmo_regions/ manually while the server is + stopped. + + 5. World blacklisted in World_Blacklist.yml: mcMMO does not back up or restore + blacklisted worlds. + + What is safe to delete? + ----------------------- + - Deleting a whole Z/ snapshot folder just reduces how far back mcMMO + can restore from. The oldest snapshots are deleted automatically anyway. + - Deleting individual .mcm files from a snapshot will cause data loss for that + 512×512-block region when the snapshot is restored. + - The BACKUP_COMPLETE file content does not matter; mcMMO only checks that the + file exists. + - You can freely add operator notes, README files, or any other files to this + folder — mcMMO ignores files it does not recognise. + """; + + private McMMORegionBackupStore() { + } + + /** + * On-shutdown entry point. Writes a complete snapshot of the in-world {@code mcmmo_regions/} + * folder into {@code [pluginDataFolder]/region_data_backups_for_migration/[worldName]/Z/} and prunes + * incomplete and over-retention snapshots. Silently skipped when the world is on the new + * Paper shape (post-PR-#13736) or when the in-world folder has no {@code .mcm} files. + * + * @param world the world whose block-tracker data should be backed up + * @param logger logger for progress and error messages + * @param pluginDataFolder mcMMO's plugin data directory (e.g. {@code plugins/mcMMO/}) + */ + public static boolean backupWorld(@NotNull World world, @NotNull Logger logger, + @NotNull Path pluginDataFolder) { + try { + final Path container = normalize(org.bukkit.Bukkit.getWorldContainer().toPath()); + final Path worldFolder = normalize(world.getWorldFolder().toPath()); + return backup(container, pluginDataFolder, world.getName(), worldFolder, logger, + Clock.systemUTC()); + } catch (RuntimeException unexpected) { + logger.log(Level.SEVERE, BACKUP_LOG_TAG + " backup failed for world '" + + world.getName() + "'", unexpected); + return false; + } + } + + /** + * On-startup / on-world-load entry point. Prunes incomplete snapshots and then handles + * restore for worlds on the new Paper shape: + *

+ * + * @param world the world to inspect and potentially restore data into + * @param logger logger for progress and error messages + * @param pluginDataFolder mcMMO's plugin data directory (e.g. {@code plugins/mcMMO/}) + */ + public static boolean restoreWorld(@NotNull World world, @NotNull Logger logger, + @NotNull Path pluginDataFolder) { + try { + final Path container = normalize(org.bukkit.Bukkit.getWorldContainer().toPath()); + final Path worldFolder = normalize(world.getWorldFolder().toPath()); + return restore(container, pluginDataFolder, world.getName(), worldFolder, logger); + } catch (RuntimeException unexpected) { + logger.log(Level.SEVERE, MIGRATION_LOG_TAG + " restore check failed for world '" + + world.getName() + "'", unexpected); + return false; + } + } + + /** + * Returns {@code true} when the world is on the Spigot / pre-26.1 Paper shape where every + * world is a top-level child of the server container directory. On Paper 26.1+, non-overworld + * dimensions resolve to a deeper path and this method returns {@code false}. + * + *

Public so that callers outside this package can gate logic on the current shape. + */ + public static boolean isLegacyShape(@NotNull Path container, @NotNull String worldName, + @NotNull Path worldFolder) { + return normalize(worldFolder).equals(normalize(container.resolve(worldName))); + } + + /** + * Core backup implementation. Package-private for unit testing. + * + * @param container the server container directory (parent of world folders on legacy + * shape); used only for shape detection + * @param pluginDataFolder mcMMO's plugin data directory; the backup store lives at + * {@code pluginDataFolder/region_data_backups_for_migration/} + * @param worldName the name of the world being backed up + * @param worldFolder the world's current folder as returned by + * {@link World#getWorldFolder()} + * @param logger logger for progress and error messages + * @param clock clock used to generate the snapshot timestamp; injectable for tests + */ + static boolean backup(@NotNull Path container, @NotNull Path pluginDataFolder, + @NotNull String worldName, @NotNull Path worldFolder, + @NotNull Logger logger, @NotNull Clock clock) { + if (!isLegacyShape(container, worldName, worldFolder)) { + // This world is already on the new Paper layout; backup snapshots are not needed. + return false; + } + final Path inWorldFolder = worldFolder.resolve(IN_WORLD_FOLDER_NAME); + final List regionFiles = listRegionFiles(inWorldFolder); + if (regionFiles.isEmpty()) { + // Nothing to back up — world has no tracked block data. + // Still clean up any crash-interrupted incomplete snapshots so the backup store + // stays tidy even when no new snapshot is written this shutdown. + final Path existingBackupRoot = pluginDataFolder.resolve(BACKUP_ROOT_FOLDER_NAME) + .resolve(worldName); + if (Files.isDirectory(existingBackupRoot)) { + pruneIncompleteSnapshots(existingBackupRoot, logger); + } + return false; + } + final Path backupStoreRoot = pluginDataFolder.resolve(BACKUP_ROOT_FOLDER_NAME); + final Path worldBackupRoot = backupStoreRoot.resolve(worldName); + try { + Files.createDirectories(worldBackupRoot); + } catch (IOException ioException) { + logger.log(Level.SEVERE, BACKUP_LOG_TAG + " could not create backup-store root " + + worldBackupRoot, ioException); + return false; + } + writeReadme(backupStoreRoot, logger); + pruneIncompleteSnapshots(worldBackupRoot, logger); + + final String snapshotName = SNAPSHOT_TIMESTAMP_FORMAT.format(clock.instant()); + final Path snapshotFinal = worldBackupRoot.resolve(snapshotName); + if (Files.exists(snapshotFinal)) { + logger.fine(BACKUP_LOG_TAG + " snapshot " + snapshotName + + " already exists for world '" + worldName + "', skipping"); + return false; + } + + logger.info(BACKUP_LOG_TAG + " Backing up region data for world named '" + + worldName + "' to " + snapshotFinal); + + // Stage the snapshot under a *.tmp name so that a crash mid-copy leaves a clearly + // incomplete artifact rather than a folder that looks complete but is not. + final Path snapshotTemp = worldBackupRoot.resolve(snapshotName + IN_PROGRESS_SUFFIX); + deleteRecursivelyQuietly(snapshotTemp); + try { + Files.createDirectories(snapshotTemp); + } catch (IOException ioException) { + logger.log(Level.WARNING, BACKUP_LOG_TAG + " could not create staging folder " + + snapshotTemp, ioException); + return false; + } + + int copied = 0; + for (Path source : regionFiles) { + final Path destination = snapshotTemp.resolve(source.getFileName().toString()); + try { + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + copied++; + } catch (IOException ioException) { + logger.log(Level.WARNING, BACKUP_LOG_TAG + " failed to copy " + source + + " into staging folder " + snapshotTemp.getFileName() + + "; aborting snapshot", ioException); + deleteRecursivelyQuietly(snapshotTemp); + return false; + } + } + + // Atomic rename: other processes always see either the old staging name or the final + // name, never a partially-renamed state. + try { + try { + Files.move(snapshotTemp, snapshotFinal, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException atomicNotSupported) { + Files.move(snapshotTemp, snapshotFinal); + } + } catch (IOException ioException) { + logger.log(Level.WARNING, BACKUP_LOG_TAG + " could not rename staging folder to " + + snapshotFinal, ioException); + deleteRecursivelyQuietly(snapshotTemp); + return false; + } + + // Write the completion stamp LAST. Any snapshot without this file is treated as + // incomplete by all readers and is automatically cleaned up. + try { + writeSentinel(snapshotFinal, worldName, copied, clock.instant()); + } catch (IOException ioException) { + logger.log(Level.WARNING, + BACKUP_LOG_TAG + " could not write completion stamp in " + snapshotFinal + + "; snapshot will be pruned on next startup", ioException); + return false; + } + logger.info(BACKUP_LOG_TAG + " Backup complete for world '" + worldName + "', " + + copied + " mcMMO region file(s) were successfully backed up."); + + pruneOldSnapshots(worldBackupRoot, logger); + return true; + } + + /** + * Core restore implementation. Package-private for unit testing. + * + * @param container the server container directory; used for shape detection + * @param pluginDataFolder mcMMO's plugin data directory; the backup store lives at + * {@code pluginDataFolder/region_data_backups_for_migration/} + * @param worldName the name of the world being inspected + * @param worldFolder the world's current folder as returned by + * {@link World#getWorldFolder()} + * @param logger logger for progress and error messages + */ + static boolean restore(@NotNull Path container, @NotNull Path pluginDataFolder, + @NotNull String worldName, @NotNull Path worldFolder, @NotNull Logger logger) { + final Path worldBackupRoot = pluginDataFolder.resolve(BACKUP_ROOT_FOLDER_NAME) + .resolve(worldName); + final Path inWorldFolder = worldFolder.resolve(IN_WORLD_FOLDER_NAME); + final Path legacyRootRegionFolder = container.resolve(worldName) + .resolve(IN_WORLD_FOLDER_NAME); + + if (Files.isDirectory(worldBackupRoot)) { + pruneIncompleteSnapshots(worldBackupRoot, logger); + } + + if (isLegacyShape(container, worldName, worldFolder)) { + // On the legacy shape, the in-world folder is authoritative. Restoring from the + // backup store here would silently overwrite data that the operator considers live. + return false; + } + + if (!listRegionFiles(inWorldFolder).isEmpty()) { + // In-world already has data on the new Paper layout — either mcMMO already + // completed the restore on a prior startup, or Paper's migration preserved the + // mcmmo_regions/ folder. If the backup store still contains real snapshots, move + // that tree aside so an admin can re-use it for another merge pass later. + int mergedCount; + if (Files.isDirectory(worldBackupRoot)) { + if (hasRestorableContent(worldBackupRoot)) { + archiveRestoredBackupStore(pluginDataFolder.resolve(BACKUP_ROOT_FOLDER_NAME), + worldBackupRoot, worldName, logger); + } else { + deleteRecursivelyQuietly(worldBackupRoot); + } + } + mergedCount = mergeLegacyRootRegionDataAndDeleteSource( + legacyRootRegionFolder, inWorldFolder, worldName, logger); + return mergedCount > 0; + } + + final Path newestSnapshot = Files.isDirectory(worldBackupRoot) + ? newestCompleteSnapshot(worldBackupRoot) + : null; + if (newestSnapshot == null) { + final int mergedCount = mergeLegacyRootRegionDataAndDeleteSource( + legacyRootRegionFolder, inWorldFolder, worldName, logger); + return mergedCount > 0; + } + final long restoreStartNanos = System.nanoTime(); + logger.info(MIGRATION_LOG_TAG + " Restoring region data for world named '" + + worldName + "'... this may take a while."); + try { + Files.createDirectories(inWorldFolder); + } catch (IOException ioException) { + logger.log(Level.SEVERE, MIGRATION_LOG_TAG + " could not create in-world folder " + + inWorldFolder + " during restore", ioException); + return false; + } + final int restored = restoreSnapshot(newestSnapshot, inWorldFolder, worldName, logger); + final long restoreElapsedNanos = System.nanoTime() - restoreStartNanos; + logger.info(MIGRATION_LOG_TAG + " Restore complete for world '" + worldName + "', " + + restored + " mcMMO region file(s) were successfully restored in " + + formatDurationNanos(restoreElapsedNanos) + "."); + deleteLegacyRootRegionDataAfterSnapshotRestore(legacyRootRegionFolder, worldName, logger); + // The backup store has served its purpose for this world. Move it into an archive so + // an admin can re-use the same data for another merge pass if needed. + archiveRestoredBackupStore(pluginDataFolder.resolve(BACKUP_ROOT_FOLDER_NAME), + worldBackupRoot, worldName, logger); + return restored > 0; + } + + private static void deleteLegacyRootRegionDataAfterSnapshotRestore( + @NotNull Path legacyRootRegionFolder, @NotNull String worldName, + @NotNull Logger logger) { + final List legacyRegionFiles = listRegionFiles(legacyRootRegionFolder); + if (legacyRegionFiles.isEmpty()) { + return; + } + logger.fine(MIGRATION_LOG_TAG + " world '" + worldName + + "': migration cleanup START - deleting " + legacyRegionFiles.size() + + " legacy-root region file(s) from " + legacyRootRegionFolder + + " after successful snapshot restore (no archive)"); + int deletedCount = 0; + int failedCount = 0; + for (Path source : legacyRegionFiles) { + try { + Files.deleteIfExists(source); + deletedCount++; + } catch (IOException ioException) { + failedCount++; + logger.log(Level.WARNING, MIGRATION_LOG_TAG + " failed to delete legacy-root region file " + + source + " for world '" + worldName + "'", ioException); + } + } + if (failedCount == 0) { + deleteRecursivelyQuietly(legacyRootRegionFolder); + } + logger.fine(MIGRATION_LOG_TAG + " world '" + worldName + + "': migration cleanup COMPLETE - deleted " + deletedCount + + " legacy-root region file(s)" + + (failedCount > 0 ? " (" + failedCount + " failed; files left in place)" : "")); + } + + private static int mergeLegacyRootRegionDataAndDeleteSource( + @NotNull Path legacyRootRegionFolder, @NotNull Path inWorldFolder, + @NotNull String worldName, @NotNull Logger logger) { + final List legacyRegionFiles = listRegionFiles(legacyRootRegionFolder); + if (legacyRegionFiles.isEmpty()) { + return 0; + } + logger.fine(MIGRATION_LOG_TAG + " world '" + worldName + + "': migration reconcile START - merging " + legacyRegionFiles.size() + + " legacy-root region file(s) from " + legacyRootRegionFolder + " into " + + inWorldFolder + " and deleting source files (no archive)"); + try { + Files.createDirectories(inWorldFolder); + } catch (IOException ioException) { + logger.log(Level.SEVERE, MIGRATION_LOG_TAG + " could not create in-world folder " + + inWorldFolder + " during legacy-root reconcile", ioException); + return 0; + } + int mergedCount = 0; + int failedCount = 0; + for (Path source : legacyRegionFiles) { + final Path destination = inWorldFolder.resolve(source.getFileName()); + try { + copyOrMergeRegionFile(source, destination); + Files.deleteIfExists(source); + mergedCount++; + } catch (IOException | RuntimeException failure) { + failedCount++; + logger.log(Level.WARNING, MIGRATION_LOG_TAG + " failed to reconcile legacy-root file " + + source.getFileName() + " for world '" + worldName + "'", failure); + } + } + if (failedCount == 0) { + deleteRecursivelyQuietly(legacyRootRegionFolder); + } + logger.fine(MIGRATION_LOG_TAG + " world '" + worldName + + "': migration reconcile COMPLETE - merged " + mergedCount + + " legacy-root region file(s)" + + (failedCount > 0 ? " (" + failedCount + " failed; files left in place)" : "")); + return mergedCount; + } + + private static boolean hasRestorableContent(@NotNull Path worldBackupRoot) { + return newestCompleteSnapshot(worldBackupRoot) != null + || !listRegionFiles(worldBackupRoot).isEmpty(); + } + + private static void archiveRestoredBackupStore(@NotNull Path backupStoreRoot, + @NotNull Path worldBackupRoot, @NotNull String worldName, @NotNull Logger logger) { + final Path archiveRoot = backupStoreRoot + .resolve(ARCHIVE_ROOT_FOLDER_NAME) + .resolve(worldName); + try { + Files.createDirectories(archiveRoot); + } catch (IOException ioException) { + logger.log(Level.WARNING, MIGRATION_LOG_TAG + " could not create archive root " + + archiveRoot + " for world '" + worldName + "'", ioException); + return; + } + final String archiveName = SNAPSHOT_TIMESTAMP_FORMAT.format(Instant.now()); + final Path archiveDestination = archiveRoot.resolve(archiveName); + if (Files.exists(archiveDestination)) { + logger.fine(MIGRATION_LOG_TAG + " world '" + worldName + "': archive destination " + + archiveDestination + " already exists; leaving restored data in place"); + return; + } + try { + Files.move(worldBackupRoot, archiveDestination); + } catch (IOException ioException) { + logger.log(Level.WARNING, MIGRATION_LOG_TAG + " could not archive restored backup store " + + worldBackupRoot + " to " + archiveDestination, ioException); + return; + } + logger.fine(MIGRATION_LOG_TAG + " world '" + worldName + + "': migration backup archive COMPLETE - saved previous migration backup data to " + + archiveDestination); + } + + /** Copies or union-merges every region file from {@code snapshot} into {@code inWorldFolder}. */ + private static int restoreSnapshot(@NotNull Path snapshot, @NotNull Path inWorldFolder, + @NotNull String worldName, @NotNull Logger logger) { + final List regionFiles; + try (Stream stream = Files.list(snapshot)) { + regionFiles = stream + .filter(Files::isRegularFile) + .filter(McMMORegionBackupStore::isRegionFile) + .sorted() + .toList(); + } catch (IOException ioException) { + logger.log(Level.WARNING, MIGRATION_LOG_TAG + " could not list snapshot " + snapshot, + ioException); + return 0; + } + int restored = 0; + for (Path source : regionFiles) { + final Path destination = inWorldFolder.resolve(source.getFileName()); + try { + copyOrMergeRegionFile(source, destination); + restored++; + } catch (IOException | RuntimeException failure) { + logger.log(Level.WARNING, MIGRATION_LOG_TAG + " failed to restore " + + source.getFileName() + " for world '" + worldName + "'", failure); + } + } + return restored; + } + + /** + * Copies {@code source} to {@code destination}. If {@code destination} already exists, + * union-merges the two region files chunk-by-chunk via + * {@link #mergeRegionFile(Path, Path, int, int)} so that no placed-block record from + * either side is lost. + */ + static void copyOrMergeRegionFile(@NotNull Path source, @NotNull Path destination) + throws IOException { + if (!Files.exists(destination)) { + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + return; + } + final Matcher matcher = REGION_FILE_PATTERN.matcher(source.getFileName().toString()); + if (!matcher.matches()) { + return; + } + final int regionX = Integer.parseInt(matcher.group(1)); + final int regionZ = Integer.parseInt(matcher.group(2)); + mergeRegionFile(source, destination, regionX, regionZ); + } + + /** + * Prunes snapshot folders that lack the {@value #BACKUP_COMPLETE_SENTINEL} completion stamp + * (including {@code *.tmp} staging folders left by a crashed prior backup). Package-private + * for testing. + */ + static void pruneIncompleteSnapshots(@NotNull Path worldBackupRoot, @NotNull Logger logger) { + if (!Files.isDirectory(worldBackupRoot)) { + return; + } + final List children; + try (Stream stream = Files.list(worldBackupRoot)) { + children = stream.filter(Files::isDirectory).toList(); + } catch (IOException ioException) { + logger.log(Level.FINE, BACKUP_LOG_TAG + " could not list backup-store root " + + worldBackupRoot + " for cleanup", ioException); + return; + } + for (Path child : children) { + final String name = child.getFileName().toString(); + final boolean isTemp = name.endsWith(IN_PROGRESS_SUFFIX); + final boolean isSnapshot = SNAPSHOT_DIR_PATTERN.matcher(name).matches(); + if (!isTemp && !isSnapshot) { + continue; + } + if (isSnapshot && Files.isRegularFile(child.resolve(BACKUP_COMPLETE_SENTINEL))) { + continue; + } + logger.fine(BACKUP_LOG_TAG + " pruning incomplete snapshot " + child); + deleteRecursivelyQuietly(child); + } + } + + /** + * Trims the world backup-store root to the {@value #MAX_BACKUPS_RETAINED} newest complete + * snapshots, deleting the oldest extras. Package-private for testing. + */ + static void pruneOldSnapshots(@NotNull Path worldBackupRoot, @NotNull Logger logger) { + final List complete = listCompleteSnapshots(worldBackupRoot); + if (complete.size() <= MAX_BACKUPS_RETAINED) { + return; + } + // Sort oldest-first (timestamp folder names sort lexically A→Z = oldest→newest). + complete.sort(Comparator.comparing(p -> p.getFileName().toString())); + final int dropCount = complete.size() - MAX_BACKUPS_RETAINED; + for (int i = 0; i < dropCount; i++) { + final Path drop = complete.get(i); + LogUtils.debug(logger, BACKUP_LOG_TAG + " pruning old snapshot " + drop.getFileName() + + " (mcMMO only keeps the " + MAX_BACKUPS_RETAINED + " newest backups)"); + deleteRecursivelyQuietly(drop); + } + } + + /** + * Returns the lexicographically newest complete snapshot (i.e., one that contains a + * {@value #BACKUP_COMPLETE_SENTINEL} file) under {@code worldBackupRoot}, or {@code null} if + * none exists. Package-private for testing. + */ + static @Nullable Path newestCompleteSnapshot(@NotNull Path worldBackupRoot) { + final List complete = listCompleteSnapshots(worldBackupRoot); + if (complete.isEmpty()) { + return null; + } + complete.sort(Comparator.comparing(p -> p.getFileName().toString())); + return complete.get(complete.size() - 1); + } + + private static @NotNull List listCompleteSnapshots(@NotNull Path worldBackupRoot) { + if (!Files.isDirectory(worldBackupRoot)) { + return Collections.emptyList(); + } + try (Stream stream = Files.list(worldBackupRoot)) { + return stream + .filter(Files::isDirectory) + .filter(p -> SNAPSHOT_DIR_PATTERN.matcher(p.getFileName().toString()).matches()) + .filter(p -> Files.isRegularFile(p.resolve(BACKUP_COMPLETE_SENTINEL))) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + } catch (IOException ioException) { + return Collections.emptyList(); + } + } + + private static @NotNull List listRegionFiles(@NotNull Path folder) { + if (!Files.isDirectory(folder)) { + return Collections.emptyList(); + } + try (Stream stream = Files.list(folder)) { + return stream + .filter(Files::isRegularFile) + .filter(McMMORegionBackupStore::isRegionFile) + .sorted() + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + } catch (IOException ioException) { + return Collections.emptyList(); + } + } + + private static boolean isRegionFile(@NotNull Path path) { + return REGION_FILE_PATTERN.matcher(path.getFileName().toString()).matches(); + } + + private static void writeSentinel(@NotNull Path snapshot, @NotNull String worldName, + int fileCount, @NotNull Instant timestamp) throws IOException { + final String body = "timestamp=" + timestamp + '\n' + + "world_name=" + worldName + '\n' + + "file_count=" + fileCount + '\n' + + "format_version=1\n"; + Files.writeString(snapshot.resolve(BACKUP_COMPLETE_SENTINEL), body, + StandardCharsets.UTF_8); + } + + /** + * Writes a README into {@code backupStoreRoot} if one is not already present. The README + * explains the purpose of the backup store and gives operators guidance on edge cases. + * This method is idempotent and silently ignores write failures — the README is + * documentation, not data. + */ + public static void writeReadme(@NotNull Path backupStoreRoot, @NotNull Logger logger) { + final Path readme = backupStoreRoot.resolve(README_FILE_NAME); + if (Files.exists(readme)) { + return; + } + try { + Files.writeString(readme, README_BODY, StandardCharsets.UTF_8); + } catch (IOException ioException) { + logger.log(Level.FINE, BACKUP_LOG_TAG + " could not write README in " + + backupStoreRoot, ioException); + } + } + + private static void deleteRecursivelyQuietly(@NotNull Path root) { + if (!Files.exists(root)) { + return; + } + try (Stream stream = Files.walk(root)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (DirectoryNotEmptyException ignored) { + // Another writer added a file mid-walk; leave it for the next cleanup pass. + } catch (IOException ignored) { + // Best-effort cleanup; swallow all other I/O errors. + } + }); + } catch (IOException ignored) { + // Best-effort cleanup; swallow. + } + } + + private static @NotNull Path normalize(@NotNull Path path) { + return path.toAbsolutePath().normalize(); + } + + private static @NotNull String formatDurationNanos(long elapsedNanos) { + final long sanitizedNanos = Math.max(0L, elapsedNanos); + final long totalMillis = java.time.Duration.ofNanos(sanitizedNanos).toMillis(); + if (totalMillis < 1000L) { + return totalMillis + "ms"; + } + + final long totalSeconds = sanitizedNanos / 1_000_000_000L; + final long hours = totalSeconds / 3600L; + final long minutes = (totalSeconds % 3600L) / 60L; + final long seconds = totalSeconds % 60L; + + final StringBuilder displayBuilder = new StringBuilder(); + if (hours > 0L) { + displayBuilder.append(hours).append("h"); + } + if (minutes > 0L) { + if (displayBuilder.length() > 0) { + displayBuilder.append(' '); + } + displayBuilder.append(minutes).append("m"); + } + if (seconds > 0L) { + if (displayBuilder.length() > 0) { + displayBuilder.append(' '); + } + displayBuilder.append(seconds).append("s"); + } + + return displayBuilder.length() == 0 ? totalMillis + "ms" : displayBuilder.toString(); + } + + /** + * Merges a single region file from {@code source} into an existing {@code destination}. + * For every 32×32 chunk slot in the region: + *

+ * + * @return the number of chunk slots written or merged into {@code destination} + */ + static int mergeRegionFile(@NotNull Path source, @NotNull Path destination, + int regionX, int regionZ) throws IOException { + int affected = 0; + final McMMOSimpleRegionFile sourceRegion = new McMMOSimpleRegionFile( + source.toFile(), regionX, regionZ); + try { + final McMMOSimpleRegionFile destinationRegion = new McMMOSimpleRegionFile( + destination.toFile(), regionX, regionZ); + try { + final int chunkOriginX = regionX << 5; + final int chunkOriginZ = regionZ << 5; + for (int dx = 0; dx < 32; dx++) { + for (int dz = 0; dz < 32; dz++) { + final int chunkX = chunkOriginX + dx; + final int chunkZ = chunkOriginZ + dz; + final ChunkStore sourceChunk; + try (DataInputStream in = sourceRegion.getInputStream(chunkX, chunkZ)) { + if (in == null) { + continue; + } + sourceChunk = BitSetChunkStore.Serialization.readChunkStore(in); + } + if (sourceChunk == null) { + continue; + } + final ChunkStore destinationChunk; + try (DataInputStream in = destinationRegion.getInputStream(chunkX, chunkZ)) { + destinationChunk = in == null + ? null + : BitSetChunkStore.Serialization.readChunkStore(in); + } + if (destinationChunk == null) { + try (DataOutputStream out = destinationRegion.getOutputStream(chunkX, + chunkZ)) { + BitSetChunkStore.Serialization.writeChunkStore(out, sourceChunk); + } + affected++; + } else if (destinationChunk instanceof BitSetChunkStore destinationBitSet + && sourceChunk instanceof BitSetChunkStore sourceBitSet) { + destinationBitSet.mergeFrom(sourceBitSet); + if (destinationBitSet.isDirty()) { + try (DataOutputStream out = destinationRegion.getOutputStream( + chunkX, chunkZ)) { + BitSetChunkStore.Serialization.writeChunkStore(out, + destinationBitSet); + } + affected++; + } + } + } + } + } finally { + destinationRegion.close(); + } + } finally { + sourceRegion.close(); + } + return affected; + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c63343dc9..a014befd4 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -78,6 +78,10 @@ General: # Giga Drill Breaker, and Berserk. Resource intensive for larger servers. Refresh_Chunks: false + # Enables backup snapshots used for Paper world migration safety. + # Set to false to disable region-data backup creation on shutdown. + RegionDataMigrationBackups: true + # # Settings for the mcMMO scoreboards ### diff --git a/src/test/java/com/gmail/nossr50/McMMOTimingFormatTest.java b/src/test/java/com/gmail/nossr50/McMMOTimingFormatTest.java new file mode 100644 index 000000000..c51ddb6e6 --- /dev/null +++ b/src/test/java/com/gmail/nossr50/McMMOTimingFormatTest.java @@ -0,0 +1,49 @@ +package com.gmail.nossr50; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class McMMOTimingFormatTest { + + @Nested + class FormatDurationHms { + + @ParameterizedTest + @CsvSource({ + "0,0ms", + "999000000,999ms", + "1000000000,1s", + "60000000000,1m", + "3600000000000,1h", + "3661000000000,1h 1m 1s", + "3605000000000,1h 5s", + "65000000000,1m 5s", + "3726000000000,1h 2m 6s" + }) + void formatsElapsedNanosecondsInHumanReadableForm(long elapsedNanos, + String expectedDisplay) { + // Given a measured elapsed duration in nanoseconds + + // When the duration formatter is invoked + final String actualDisplay = mcMMO.formatDurationHms(elapsedNanos); + + // Then the output is formatted as human-readable hours, minutes, and seconds + assertThat(actualDisplay).isEqualTo(expectedDisplay); + } + + @ParameterizedTest + @CsvSource({ "-1", "-1000000" }) + void clampsNegativeElapsedNanosecondsToZero(long elapsedNanos) { + // Given a negative elapsed value from a bad caller + + // When the duration formatter is invoked + final String actualDisplay = mcMMO.formatDurationHms(elapsedNanos); + + // Then the output is clamped to a zero-duration representation + assertThat(actualDisplay).isEqualTo("0ms"); + } + } +} diff --git a/src/test/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStoreStressTest.java b/src/test/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStoreStressTest.java new file mode 100644 index 000000000..84f849daa --- /dev/null +++ b/src/test/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStoreStressTest.java @@ -0,0 +1,471 @@ +package com.gmail.nossr50.util.blockmeta; + +import static com.gmail.nossr50.util.blockmeta.BlockStoreTestUtils.LEGACY_WORLD_HEIGHT_MAX; +import static com.gmail.nossr50.util.blockmeta.BlockStoreTestUtils.LEGACY_WORLD_HEIGHT_MIN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Logger; +import java.util.stream.Stream; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +/** + * Stress and scale-oriented migration tests for {@link McMMORegionBackupStore}. + * + *

These tests intentionally generate large datasets and are tagged as {@code stress} so + * default Surefire runs can skip them. + */ +@Tag("stress") +class McMMORegionBackupStoreStressTest { + + private static final class PlacedBlockExpectation { + private final int chunkX; + private final int chunkZ; + private final int[][] expectedTrueBits; + + private PlacedBlockExpectation(int chunkX, int chunkZ, int[][] expectedTrueBits) { + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.expectedTrueBits = expectedTrueBits; + } + } + + private enum WorldDatasetMode { + RANDOM_DENSE, + ALL_TRUE, + ALL_FALSE, + NO_DATA + } + + private static final class WorldStressConfig { + private final WorldDatasetMode datasetMode; + + private WorldStressConfig(WorldDatasetMode datasetMode) { + this.datasetMode = datasetMode; + } + } + + private static final class MigrationStressScenario { + private final String name; + private final WorldStressConfig overworldConfig; + private final WorldStressConfig netherConfig; + private final WorldStressConfig endConfig; + + private MigrationStressScenario(String name, WorldStressConfig overworldConfig, + WorldStressConfig netherConfig, WorldStressConfig endConfig) { + this.name = name; + this.overworldConfig = overworldConfig; + this.netherConfig = netherConfig; + this.endConfig = endConfig; + } + + @Override + public String toString() { + return name; + } + } + + @TempDir + Path containerRoot; + + @TempDir + Path pluginDataRoot; + + private World mockWorld; + private UUID worldUid; + private MockedStatic bukkitMock; + private final Logger silentLogger = Logger.getLogger("McMMORegionBackupStoreStressTest"); + + @BeforeEach + void setUp() { + worldUid = UUID.randomUUID(); + mockWorld = Mockito.mock(World.class); + when(mockWorld.getUID()).thenReturn(worldUid); + when(mockWorld.getMinHeight()).thenReturn(LEGACY_WORLD_HEIGHT_MIN); + when(mockWorld.getMaxHeight()).thenReturn(LEGACY_WORLD_HEIGHT_MAX); + + bukkitMock = mockStatic(Bukkit.class); + bukkitMock.when(() -> Bukkit.getWorld(worldUid)).thenReturn(mockWorld); + } + + @AfterEach + void tearDown() { + bukkitMock.close(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("migrationStressScenarios") + void restoresLargeMigrationDatasetAcrossScenarios(MigrationStressScenario migrationStressScenario) + throws IOException { + // Given + final int regionFilesPerWorld = 1000; + final String overworldName = "world"; + final String netherWorldName = "world_nether"; + final String endWorldName = "world_the_end"; + + final Path overworldLegacyFolder = legacyWorldFolder(overworldName); + final Path netherLegacyFolder = legacyWorldFolder(netherWorldName); + final Path endLegacyFolder = legacyWorldFolder(endWorldName); + + final Map> expectedPlacedBlocksByWorld = new HashMap<>(); + expectedPlacedBlocksByWorld.put(overworldName, + writeLegacyRegionDataset(overworldLegacyFolder, regionFilesPerWorld, + migrationStressScenario.overworldConfig.datasetMode)); + expectedPlacedBlocksByWorld.put(netherWorldName, + writeLegacyRegionDataset(netherLegacyFolder, regionFilesPerWorld, + migrationStressScenario.netherConfig.datasetMode)); + expectedPlacedBlocksByWorld.put(endWorldName, + writeLegacyRegionDataset(endLegacyFolder, regionFilesPerWorld, + migrationStressScenario.endConfig.datasetMode)); + + // When + // Step 1: Simulate shutdown on legacy shape by writing migration backups. + final Clock backupClock = fixedUtc("2026-06-01T17:50:22Z"); + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, overworldName, + overworldLegacyFolder, silentLogger, backupClock); + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, netherWorldName, + netherLegacyFolder, silentLogger, backupClock); + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, endWorldName, + endLegacyFolder, silentLogger, backupClock); + + // Step 2: Simulate startup on new Paper layout by restoring backups into new shape. + final Path overworldNewShapeFolder = newPaperWorldFolder(overworldName, "overworld"); + final Path netherNewShapeFolder = newPaperWorldFolder(netherWorldName, "the_nether"); + final Path endNewShapeFolder = newPaperWorldFolder(endWorldName, "the_end"); + + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, overworldName, + overworldNewShapeFolder, silentLogger); + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, netherWorldName, + netherNewShapeFolder, silentLogger); + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, endWorldName, + endNewShapeFolder, silentLogger); + + // Then + assertRestoredDatasetContainsAllExpectedPlacedBlocks( + inWorld(overworldNewShapeFolder), + expectedPlacedBlocksByWorld.get(overworldName)); + assertRestoredDatasetContainsAllExpectedPlacedBlocks( + inWorld(netherNewShapeFolder), + expectedPlacedBlocksByWorld.get(netherWorldName)); + assertRestoredDatasetContainsAllExpectedPlacedBlocks( + inWorld(endNewShapeFolder), + expectedPlacedBlocksByWorld.get(endWorldName)); + + // Worlds with NO_DATA should remain a no-op after backup+restore. + assertNoOpWhenWorldHasNoMigrationDataset( + inWorld(overworldNewShapeFolder), + expectedPlacedBlocksByWorld.get(overworldName), + migrationStressScenario.overworldConfig.datasetMode); + assertNoOpWhenWorldHasNoMigrationDataset( + inWorld(netherNewShapeFolder), + expectedPlacedBlocksByWorld.get(netherWorldName), + migrationStressScenario.netherConfig.datasetMode); + assertNoOpWhenWorldHasNoMigrationDataset( + inWorld(endNewShapeFolder), + expectedPlacedBlocksByWorld.get(endWorldName), + migrationStressScenario.endConfig.datasetMode); + } + + @Test + void skipsCorruptSnapshotFilesAndKeepsOtherData() throws IOException { + // Given + final int regionFilesPerWorld = 1000; + final String overworldName = "world"; + final String netherWorldName = "world_nether"; + final String endWorldName = "world_the_end"; + + final Path overworldLegacyFolder = legacyWorldFolder(overworldName); + final Path netherLegacyFolder = legacyWorldFolder(netherWorldName); + final Path endLegacyFolder = legacyWorldFolder(endWorldName); + + final List overworldExpectations = writeLegacyRegionDataset( + overworldLegacyFolder, regionFilesPerWorld, WorldDatasetMode.RANDOM_DENSE); + final List netherExpectations = writeLegacyRegionDataset( + netherLegacyFolder, regionFilesPerWorld, WorldDatasetMode.RANDOM_DENSE); + final List endExpectations = writeLegacyRegionDataset( + endLegacyFolder, regionFilesPerWorld, WorldDatasetMode.RANDOM_DENSE); + + // And + final Clock backupClock = fixedUtc("2026-06-01T18:05:00Z"); + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, overworldName, + overworldLegacyFolder, silentLogger, backupClock); + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, netherWorldName, + netherLegacyFolder, silentLogger, backupClock); + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, endWorldName, + endLegacyFolder, silentLogger, backupClock); + + final Path newestOverworldSnapshot = McMMORegionBackupStore.newestCompleteSnapshot( + worldBackupRoot(overworldName)); + assertThat(newestOverworldSnapshot).isNotNull(); + final Path corruptSnapshotFile = newestOverworldSnapshot.resolve("mcmmo_0_0_.mcm"); + Files.writeString(corruptSnapshotFile, "corrupt-data"); + + final Path overworldNewShapeFolder = newPaperWorldFolder(overworldName, "overworld"); + writeRegionFileWithChunk(inWorld(overworldNewShapeFolder), 0, 0, + new int[][] { { 9, 9, 9 } }); + + // When + final Path netherNewShapeFolder = newPaperWorldFolder(netherWorldName, "the_nether"); + final Path endNewShapeFolder = newPaperWorldFolder(endWorldName, "the_end"); + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, overworldName, + overworldNewShapeFolder, silentLogger); + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, netherWorldName, + netherNewShapeFolder, silentLogger); + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, endWorldName, + endNewShapeFolder, silentLogger); + + // Then + assertRestoredDatasetContainsAllExpectedPlacedBlocks( + inWorld(netherNewShapeFolder), netherExpectations); + assertRestoredDatasetContainsAllExpectedPlacedBlocks( + inWorld(endNewShapeFolder), endExpectations); + + final ChunkStore preservedChunk = readChunkFromRegionFile( + inWorld(overworldNewShapeFolder).resolve("mcmmo_0_0_.mcm"), 0, 0); + assertThat(preservedChunk).isNotNull(); + assertThat(preservedChunk.isTrue(9, 9, 9)).isTrue(); + + final PlacedBlockExpectation safeOverworldExpectation = overworldExpectations.stream() + .filter(placedBlockExpectation -> placedBlockExpectation.chunkX != 0) + .findFirst() + .orElseThrow(); + final Path safeOverworldRegionFile = inWorld(overworldNewShapeFolder).resolve( + "mcmmo_" + (safeOverworldExpectation.chunkX >> 5) + "_" + + (safeOverworldExpectation.chunkZ >> 5) + "_.mcm"); + final ChunkStore restoredSafeChunk = readChunkFromRegionFile(safeOverworldRegionFile, + safeOverworldExpectation.chunkX, safeOverworldExpectation.chunkZ); + assertThat(restoredSafeChunk).isNotNull(); + for (int[] expectedTrueBit : safeOverworldExpectation.expectedTrueBits) { + assertThat(restoredSafeChunk.isTrue(expectedTrueBit[0], expectedTrueBit[1], + expectedTrueBit[2])).isTrue(); + } + } + + private Path writeRegionFileWithChunk(Path regionFolder, int chunkX, int chunkZ, + int[][] trueBits) throws IOException { + Files.createDirectories(regionFolder); + final Path regionFile = regionFolder.resolve( + "mcmmo_" + (chunkX >> 5) + "_" + (chunkZ >> 5) + "_.mcm"); + final BitSetChunkStore store = new BitSetChunkStore(mockWorld, chunkX, chunkZ); + for (int[] xyz : trueBits) { + store.setTrue(xyz[0], xyz[1], xyz[2]); + } + final McMMOSimpleRegionFile regionFileStore = new McMMOSimpleRegionFile( + regionFile.toFile(), chunkX >> 5, chunkZ >> 5); + try (DataOutputStream out = regionFileStore.getOutputStream(chunkX, chunkZ)) { + BitSetChunkStore.Serialization.writeChunkStore(out, store); + } + regionFileStore.close(); + return regionFile; + } + + private ChunkStore readChunkFromRegionFile(Path regionFile, int chunkX, int chunkZ) + throws IOException { + final McMMOSimpleRegionFile regionFileStore = new McMMOSimpleRegionFile( + regionFile.toFile(), chunkX >> 5, chunkZ >> 5); + try (DataInputStream in = regionFileStore.getInputStream(chunkX, chunkZ)) { + if (in == null) { + return null; + } + return BitSetChunkStore.Serialization.readChunkStore(in); + } finally { + regionFileStore.close(); + } + } + + private Path legacyWorldFolder(String worldName) { + return containerRoot.resolve(worldName); + } + + private Path newPaperWorldFolder(String worldName, String dimensionKey) { + return containerRoot.resolve(worldName).resolve("dimensions").resolve("minecraft") + .resolve(dimensionKey); + } + + private Path inWorld(Path worldFolder) { + return worldFolder.resolve(McMMORegionBackupStore.IN_WORLD_FOLDER_NAME); + } + + private Path worldBackupRoot(String worldName) { + return pluginDataRoot.resolve(McMMORegionBackupStore.BACKUP_ROOT_FOLDER_NAME) + .resolve(worldName); + } + + private static Clock fixedUtc(String isoInstant) { + return Clock.fixed(Instant.parse(isoInstant), ZoneOffset.UTC); + } + + private List writeLegacyRegionDataset( + Path legacyWorldFolder, int regionFileCount, WorldDatasetMode worldDatasetMode) + throws IOException { + if (worldDatasetMode == WorldDatasetMode.NO_DATA) { + return new ArrayList<>(); + } + + final List expectations = new ArrayList<>(regionFileCount); + final Path legacyRegionFolder = inWorld(legacyWorldFolder); + + for (int regionIndex = 0; regionIndex < regionFileCount; regionIndex++) { + final int chunkX = regionIndex << 5; + final int chunkZ = 0; + + final int[][] regionFileTrueBits = switch (worldDatasetMode) { + case RANDOM_DENSE -> { + final int minimumTrueValuesPerRegionFile = 20; + final int maximumAdditionalTrueValuesPerRegionFile = 20; + final int trueValueCountForRegionFile = minimumTrueValuesPerRegionFile + + ThreadLocalRandom.current().nextInt( + maximumAdditionalTrueValuesPerRegionFile + 1); + yield generateUniqueRandomTrueBits(trueValueCountForRegionFile); + } + case ALL_TRUE -> generateDeterministicAllTrueBits(); + case ALL_FALSE -> new int[][] {}; + case NO_DATA -> throw new IllegalStateException( + "NO_DATA should return before file generation"); + }; + + writeRegionFileWithChunk(legacyRegionFolder, chunkX, chunkZ, regionFileTrueBits); + expectations.add(new PlacedBlockExpectation(chunkX, chunkZ, regionFileTrueBits)); + } + + return expectations; + } + + private int[][] generateDeterministicAllTrueBits() { + final List allTrueBits = new ArrayList<>(); + for (int blockX = 0; blockX < 4; blockX++) { + for (int blockZ = 0; blockZ < 4; blockZ++) { + for (int blockY = LEGACY_WORLD_HEIGHT_MIN; blockY < LEGACY_WORLD_HEIGHT_MIN + + 4; blockY++) { + allTrueBits.add(new int[] { blockX, blockY, blockZ }); + } + } + } + return allTrueBits.toArray(int[][]::new); + } + + private int[][] generateUniqueRandomTrueBits(int trueValueCount) { + final List randomizedTrueBits = new ArrayList<>(trueValueCount); + final Set usedCoordinates = new HashSet<>(trueValueCount * 2); + + while (randomizedTrueBits.size() < trueValueCount) { + final int randomizedBlockX = ThreadLocalRandom.current().nextInt(0, 16); + final int randomizedBlockY = ThreadLocalRandom.current() + .nextInt(LEGACY_WORLD_HEIGHT_MIN, LEGACY_WORLD_HEIGHT_MAX); + final int randomizedBlockZ = ThreadLocalRandom.current().nextInt(0, 16); + + final long coordinateKey = (((long) randomizedBlockX) << 40) + | (((long) (randomizedBlockY - LEGACY_WORLD_HEIGHT_MIN)) << 8) + | randomizedBlockZ; + + if (!usedCoordinates.add(coordinateKey)) { + continue; + } + + randomizedTrueBits.add(new int[] { randomizedBlockX, randomizedBlockY, randomizedBlockZ }); + } + + return randomizedTrueBits.toArray(int[][]::new); + } + + private void assertRestoredDatasetContainsAllExpectedPlacedBlocks(Path restoredRegionFolder, + List expectations) throws IOException { + for (PlacedBlockExpectation expectedPlacedBlock : expectations) { + final Path expectedRegionFile = restoredRegionFolder.resolve( + "mcmmo_" + (expectedPlacedBlock.chunkX >> 5) + "_" + + (expectedPlacedBlock.chunkZ >> 5) + "_.mcm"); + + assertThat(Files.isRegularFile(expectedRegionFile)).isTrue(); + + final ChunkStore restoredChunkStore = readChunkFromRegionFile(expectedRegionFile, + expectedPlacedBlock.chunkX, expectedPlacedBlock.chunkZ); + assertThat(restoredChunkStore).isNotNull(); + for (int[] expectedTrueBit : expectedPlacedBlock.expectedTrueBits) { + assertThat(restoredChunkStore.isTrue(expectedTrueBit[0], + expectedTrueBit[1], expectedTrueBit[2])).isTrue(); + } + } + } + + private void assertNoOpWhenWorldHasNoMigrationDataset(Path restoredRegionFolder, + List expectations, WorldDatasetMode worldDatasetMode) { + if (worldDatasetMode != WorldDatasetMode.NO_DATA) { + return; + } + + assertThat(expectations).isEmpty(); + assertThat(Files.exists(restoredRegionFolder)).isFalse(); + } + + private static Stream migrationStressScenarios() { + return Stream.of( + Arguments.of(new MigrationStressScenario( + "scenario1_denseRandom_allWorlds", + new WorldStressConfig(WorldDatasetMode.RANDOM_DENSE), + new WorldStressConfig(WorldDatasetMode.RANDOM_DENSE), + new WorldStressConfig(WorldDatasetMode.RANDOM_DENSE))), + Arguments.of(new MigrationStressScenario( + "scenario2_allTrue_allWorlds", + new WorldStressConfig(WorldDatasetMode.ALL_TRUE), + new WorldStressConfig(WorldDatasetMode.ALL_TRUE), + new WorldStressConfig(WorldDatasetMode.ALL_TRUE))), + Arguments.of(new MigrationStressScenario( + "scenario3_allFalse_allWorlds", + new WorldStressConfig(WorldDatasetMode.ALL_FALSE), + new WorldStressConfig(WorldDatasetMode.ALL_FALSE), + new WorldStressConfig(WorldDatasetMode.ALL_FALSE))), + Arguments.of(new MigrationStressScenario( + "scenario4_oneWorldNoData", + new WorldStressConfig(WorldDatasetMode.RANDOM_DENSE), + new WorldStressConfig(WorldDatasetMode.RANDOM_DENSE), + new WorldStressConfig(WorldDatasetMode.NO_DATA))), + Arguments.of(new MigrationStressScenario( + "scenario5_twoWorldsNoData", + new WorldStressConfig(WorldDatasetMode.RANDOM_DENSE), + new WorldStressConfig(WorldDatasetMode.NO_DATA), + new WorldStressConfig(WorldDatasetMode.NO_DATA))), + Arguments.of(new MigrationStressScenario( + "scenario6_allWorldsNoData_noOp", + new WorldStressConfig(WorldDatasetMode.NO_DATA), + new WorldStressConfig(WorldDatasetMode.NO_DATA), + new WorldStressConfig(WorldDatasetMode.NO_DATA))), + Arguments.of(new MigrationStressScenario( + "scenario7_mixedDenseTrueFalse", + new WorldStressConfig(WorldDatasetMode.RANDOM_DENSE), + new WorldStressConfig(WorldDatasetMode.ALL_TRUE), + new WorldStressConfig(WorldDatasetMode.ALL_FALSE))), + Arguments.of(new MigrationStressScenario( + "scenario8_mixedFalseDenseNoData", + new WorldStressConfig(WorldDatasetMode.ALL_FALSE), + new WorldStressConfig(WorldDatasetMode.RANDOM_DENSE), + new WorldStressConfig(WorldDatasetMode.NO_DATA)))); + } +} diff --git a/src/test/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStoreTest.java b/src/test/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStoreTest.java new file mode 100644 index 000000000..1ab57907f --- /dev/null +++ b/src/test/java/com/gmail/nossr50/util/blockmeta/McMMORegionBackupStoreTest.java @@ -0,0 +1,972 @@ +package com.gmail.nossr50.util.blockmeta; + +import static com.gmail.nossr50.util.blockmeta.BlockStoreTestUtils.LEGACY_WORLD_HEIGHT_MAX; +import static com.gmail.nossr50.util.blockmeta.BlockStoreTestUtils.LEGACY_WORLD_HEIGHT_MIN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.Stream; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +/** + * Tests for {@link McMMORegionBackupStore}. + * + *

The canonical on-disk location for mcMMO region files is + * {@code [worldFolder]/mcmmo_regions/}. On Spigot / pre-26.1 Paper ("legacy shape") that + * resolves to {@code [container]/[worldName]/mcmmo_regions/}; on Paper 26.1+ ("new shape") it + * resolves to {@code [container]/[worldName]/dimensions/minecraft//mcmmo_regions/}. To + * survive Paper's destructive {@code LegacyCraftBukkitWorldMigration} (PR #13736), mcMMO writes + * a flat snapshot of the legacy-shape data into the mcMMO plugin data directory under + * {@code region_data_backups_for_migration/[worldName]/Z/} on shutdown, then restores the newest complete + * snapshot into the new in-world location on the next startup once Paper has reshaped the world. + * + *

{@code containerRoot} is the simulated server container directory (equivalent to the + * server working directory, parent of world folders on legacy shape). + * {@code pluginDataRoot} is the simulated mcMMO plugin data directory (equivalent to + * {@code plugins/mcMMO/}). These are kept separate to match the real on-disk layout where the + * backup store lives inside the plugin folder, not inside a world folder. + */ +class McMMORegionBackupStoreTest { + + private static final class PlacedBlockExpectation { + private final int chunkX; + private final int chunkZ; + private final int[][] expectedTrueBits; + + private PlacedBlockExpectation(int chunkX, int chunkZ, int[][] expectedTrueBits) { + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.expectedTrueBits = expectedTrueBits; + } + } + + @TempDir + Path containerRoot; + + @TempDir + Path pluginDataRoot; + + private World mockWorld; + private UUID worldUid; + private MockedStatic bukkitMock; + private final Logger silentLogger = Logger.getLogger("McMMORegionBackupStoreTest"); + + @BeforeEach + void setUp() { + worldUid = UUID.randomUUID(); + mockWorld = Mockito.mock(World.class); + when(mockWorld.getUID()).thenReturn(worldUid); + when(mockWorld.getMinHeight()).thenReturn(LEGACY_WORLD_HEIGHT_MIN); + when(mockWorld.getMaxHeight()).thenReturn(LEGACY_WORLD_HEIGHT_MAX); + + bukkitMock = mockStatic(Bukkit.class); + bukkitMock.when(() -> Bukkit.getWorld(worldUid)).thenReturn(mockWorld); + } + + @AfterEach + void tearDown() { + bukkitMock.close(); + } + + /** + * Writes a region file containing one chunk with the given placed-block bits set to true. + * The region file is named after the region coordinates derived from {@code chunkX} and + * {@code chunkZ} (each shifted right 5 bits to get region-space coordinates). + */ + private Path writeRegionFileWithChunk(Path regionFolder, int chunkX, int chunkZ, + int[][] trueBits) throws IOException { + Files.createDirectories(regionFolder); + final Path regionFile = regionFolder.resolve( + "mcmmo_" + (chunkX >> 5) + "_" + (chunkZ >> 5) + "_.mcm"); + final BitSetChunkStore store = new BitSetChunkStore(mockWorld, chunkX, chunkZ); + for (int[] xyz : trueBits) { + store.setTrue(xyz[0], xyz[1], xyz[2]); + } + final McMMOSimpleRegionFile rf = new McMMOSimpleRegionFile( + regionFile.toFile(), chunkX >> 5, chunkZ >> 5); + try (DataOutputStream out = rf.getOutputStream(chunkX, chunkZ)) { + BitSetChunkStore.Serialization.writeChunkStore(out, store); + } + rf.close(); + return regionFile; + } + + private ChunkStore readChunkFromRegionFile(Path regionFile, int chunkX, int chunkZ) + throws IOException { + final McMMOSimpleRegionFile rf = new McMMOSimpleRegionFile( + regionFile.toFile(), chunkX >> 5, chunkZ >> 5); + try (DataInputStream in = rf.getInputStream(chunkX, chunkZ)) { + if (in == null) { + return null; + } + return BitSetChunkStore.Serialization.readChunkStore(in); + } finally { + rf.close(); + } + } + + /** Returns the world folder path for a world still on the Spigot / pre-26.1 Paper layout. */ + private Path legacyWorldFolder(String worldName) { + return containerRoot.resolve(worldName); + } + + /** + * Returns the world folder path for a dimension on the Paper 26.1+ layout, where each + * dimension lives under {@code [worldName]/dimensions/minecraft//}. + */ + private Path newPaperWorldFolder(String worldName, String dimensionKey) { + return containerRoot.resolve(worldName).resolve("dimensions").resolve("minecraft") + .resolve(dimensionKey); + } + + /** Returns the in-world mcmmo_regions folder for the given world folder. */ + private Path inWorld(Path worldFolder) { + return worldFolder.resolve(McMMORegionBackupStore.IN_WORLD_FOLDER_NAME); + } + + /** + * Returns the per-world backup-store folder inside the simulated plugin data directory. + * On a real server this resolves to + * {@code plugins/mcMMO/region_data_backups_for_migration//}. + */ + private Path worldBackupRoot(String worldName) { + return pluginDataRoot.resolve(McMMORegionBackupStore.BACKUP_ROOT_FOLDER_NAME) + .resolve(worldName); + } + + /** Returns the backup-store root folder inside the simulated plugin data directory. */ + private Path backupStoreRoot() { + return pluginDataRoot.resolve(McMMORegionBackupStore.BACKUP_ROOT_FOLDER_NAME); + } + + private Path archivedWorldBackupRoot(String worldName) { + return backupStoreRoot().resolve(McMMORegionBackupStore.ARCHIVE_ROOT_FOLDER_NAME) + .resolve(worldName); + } + + private static Clock fixedUtc(String isoInstant) { + return Clock.fixed(Instant.parse(isoInstant), ZoneOffset.UTC); + } + + private static String snapshotName(String isoInstant) { + return McMMORegionBackupStore.SNAPSHOT_TIMESTAMP_FORMAT.format( + Instant.parse(isoInstant)); + } + + @Nested + class ShapeDetection { + + @Test + void legacyShapeWhenWorldFolderEqualsContainerSlashWorldName() { + // Given Spigot / pre-26.1 Paper layout + assertThat(McMMORegionBackupStore.isLegacyShape( + containerRoot, "world_nether", legacyWorldFolder("world_nether"))).isTrue(); + } + + @Test + void newShapeWhenWorldFolderHasDimensionsSubpath() { + // Given Paper 26.1+ layout + assertThat(McMMORegionBackupStore.isLegacyShape( + containerRoot, "world_nether", + newPaperWorldFolder("world_nether", "the_nether"))).isFalse(); + } + + @Test + void normalisesDotSegmentsBeforeComparison() { + // Given a non-normalised worldFolder with a trailing dot-segment + final Path nonNormalised = containerRoot.resolve("world").resolve("."); + assertThat(McMMORegionBackupStore.isLegacyShape( + containerRoot, "world", nonNormalised)).isTrue(); + } + } + + @Nested + class BackupWritesSnapshot { + + @Test + void writesEveryInWorldRegionFileIntoTimestampedSnapshotWithSentinel() throws IOException { + // Given a legacy-shape world with two region files in-world + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 1, 64, 2 } }); + writeRegionFileWithChunk(inWorld(worldFolder), 32, 0, new int[][] { { 3, 65, 4 } }); + final Clock clock = fixedUtc("2026-05-31T14:23:05Z"); + + // When backup runs + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, clock); + + // Then a snapshot directory exists with both .mcm files and a BACKUP_COMPLETE stamp + final Path snapshot = worldBackupRoot(worldName) + .resolve(snapshotName("2026-05-31T14:23:05Z")); + assertThat(Files.isDirectory(snapshot)).isTrue(); + assertThat(Files.isRegularFile(snapshot.resolve("mcmmo_0_0_.mcm"))).isTrue(); + assertThat(Files.isRegularFile(snapshot.resolve("mcmmo_1_0_.mcm"))).isTrue(); + assertThat(Files.isRegularFile( + snapshot.resolve(McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL))).isTrue(); + } + + @Test + void sentinelContentIdentifiesWorldAndFileCount() throws IOException { + // Given a legacy-shape world with one region file + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 0, 0, 0 } }); + final Clock clock = fixedUtc("2026-05-31T14:23:05Z"); + + // When backup runs + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, clock); + + // Then the completion stamp records the world name, file count, and timestamp + final Path sentinel = worldBackupRoot(worldName) + .resolve(snapshotName("2026-05-31T14:23:05Z")) + .resolve(McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL); + final String body = Files.readString(sentinel, StandardCharsets.UTF_8); + assertThat(body).contains("world_name=" + worldName); + assertThat(body).contains("file_count=1"); + assertThat(body).contains("timestamp=2026-05-31T14:23:05Z"); + } + + @Test + void retainsOnlyTheNewestThreeCompleteSnapshots() throws IOException { + // Given a legacy-shape world with one region file and four backups taken in order + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 0, 0, 0 } }); + final String[] timestamps = { + "2026-05-28T10:00:00Z", + "2026-05-29T10:00:00Z", + "2026-05-30T10:00:00Z", + "2026-05-31T10:00:00Z" + }; + + // When four backups are written + for (String iso : timestamps) { + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, fixedUtc(iso)); + } + + // Then only the three most recent snapshots are retained + final List remaining; + try (Stream stream = Files.list(worldBackupRoot(worldName))) { + remaining = stream.filter(Files::isDirectory) + .map(p -> p.getFileName().toString()) + .sorted() + .toList(); + } + assertThat(remaining).isEqualTo( + List.of(snapshotName(timestamps[1]), snapshotName(timestamps[2]), + snapshotName(timestamps[3]))); + } + + @Test + void doesNothingWhenInWorldHasNoRegionFiles() throws IOException { + // Given a legacy-shape world with an empty (or missing) in-world folder + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + Files.createDirectories(inWorld(worldFolder)); + + // When backup runs + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, fixedUtc("2026-05-31T14:23:05Z")); + + // Then no backup root is created inside the plugin data directory + assertThat(Files.exists(worldBackupRoot(worldName))).isFalse(); + } + + @Test + void prunesIncompleteSnapshotsFromExistingBackupStoreWhenWorldHasNoData() + throws IOException { + // Given a legacy-shape world with NO in-world .mcm files (e.g. brand-new world or + // world was just deleted/reset) but an existing backup store with a crash-interrupted + // incomplete snapshot. Backup is skipped (nothing to back up) but the crash artifact + // in the backup store should still be cleaned up on this shutdown. + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + Files.createDirectories(inWorld(worldFolder)); // folder exists, but no .mcm files + final Path existingBackupRoot = worldBackupRoot(worldName); + final Path incomplete = existingBackupRoot.resolve( + snapshotName("2026-05-30T10:00:00Z")); + Files.createDirectories(incomplete); + Files.writeString(incomplete.resolve("mcmmo_0_0_.mcm"), "partial"); // no sentinel + + // When backup runs + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, fixedUtc("2026-05-31T14:23:05Z")); + + // Then the crash-interrupted snapshot is pruned + assertThat(Files.exists(incomplete)).isFalse(); + // And no new snapshot is created (nothing to back up) + assertThat(Files.exists(existingBackupRoot.resolve( + snapshotName("2026-05-31T14:23:05Z")))).isFalse(); + } + + @Test + void doesNothingWhenWorldIsOnTheNewPaperShape() throws IOException { + // Given a new-shape (Paper 26.1+) world with in-world data + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 0, 0, 0 } }); + + // When backup runs + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, fixedUtc("2026-05-31T14:23:05Z")); + + // Then no backup root is created — backup snapshots are only needed on legacy shape + assertThat(Files.exists(worldBackupRoot(worldName))).isFalse(); + } + + @Test + void logsStartMessageToWarnAgainstForceShutdownDuringLegacyBackup() throws IOException { + // Given a legacy-shape world with tracked block data and a logger that captures INFO + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 0, 0, 0 } }); + final Clock clock = fixedUtc("2026-05-31T14:23:05Z"); + final Path expectedSnapshotPath = worldBackupRoot(worldName) + .resolve(snapshotName("2026-05-31T14:23:05Z")); + + final Logger captureLogger = Logger.getLogger("McMMORegionBackupStoreTest.capture"); + captureLogger.setUseParentHandlers(false); + captureLogger.setLevel(Level.ALL); + final List loggedMessages = new ArrayList<>(); + final Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + loggedMessages.add(record.getMessage()); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + captureLogger.addHandler(handler); + + try { + // When backup runs + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, + worldFolder, captureLogger, clock); + } finally { + captureLogger.removeHandler(handler); + } + + // Then an explicit start warning is logged before completion + assertThat(loggedMessages.stream().anyMatch(message -> message.contains( + "Backing up region data for world named '" + worldName + "'"))).isTrue(); + // And the backup destination path is included in progress and completion logs + assertThat(loggedMessages.stream().anyMatch(message -> message.contains( + "to " + expectedSnapshotPath))).isTrue(); + assertThat(loggedMessages.stream().anyMatch(message -> message.contains( + "Backup complete for world '" + worldName + "'"))).isTrue(); + } + } + + @Nested + class Pruning { + + @Test + void deletesSnapshotDirectoryWithoutSentinel() throws IOException { + // Given an orphaned snapshot directory missing the BACKUP_COMPLETE stamp + final Path worldBackupRoot = worldBackupRoot("world"); + final Path orphan = worldBackupRoot.resolve(snapshotName("2026-05-31T14:23:05Z")); + Files.createDirectories(orphan); + Files.writeString(orphan.resolve("mcmmo_0_0_.mcm"), "stale"); + + // When the incomplete-snapshot janitor runs + McMMORegionBackupStore.pruneIncompleteSnapshots(worldBackupRoot, silentLogger); + + // Then the orphan is gone + assertThat(Files.exists(orphan)).isFalse(); + } + + @Test + void deletesInProgressTempDirectoryLeftByCrash() throws IOException { + // Given a *.tmp staging folder left by a crashed prior backup + final Path worldBackupRoot = worldBackupRoot("world"); + final Path tempLeftover = worldBackupRoot.resolve( + snapshotName("2026-05-31T14:23:05Z") + + McMMORegionBackupStore.IN_PROGRESS_SUFFIX); + Files.createDirectories(tempLeftover); + Files.writeString(tempLeftover.resolve("partial.txt"), "in-progress"); + + // When the janitor runs + McMMORegionBackupStore.pruneIncompleteSnapshots(worldBackupRoot, silentLogger); + + // Then the temp leftover is gone + assertThat(Files.exists(tempLeftover)).isFalse(); + } + + @Test + void keepsCompleteSnapshotsAndIgnoresUnknownNamedFolders() throws IOException { + // Given one complete snapshot and a non-snapshot folder (e.g., operator notes) + final Path worldBackupRoot = worldBackupRoot("world"); + final Path completeSnapshot = worldBackupRoot.resolve( + snapshotName("2026-05-31T14:23:05Z")); + Files.createDirectories(completeSnapshot); + Files.writeString(completeSnapshot.resolve( + McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL), "ok"); + final Path operatorNotes = worldBackupRoot.resolve("operator_notes"); + Files.createDirectories(operatorNotes); + Files.writeString(operatorNotes.resolve("readme.txt"), "do not touch"); + + // When the janitor runs + McMMORegionBackupStore.pruneIncompleteSnapshots(worldBackupRoot, silentLogger); + + // Then the complete snapshot is preserved and the unrelated folder is untouched + assertThat(Files.exists(completeSnapshot)).isTrue(); + assertThat(Files.exists(operatorNotes.resolve("readme.txt"))).isTrue(); + } + } + + @Nested + class RestoreFromBackup { + + @Test + void logsOneTimeMigrationRestoreStartAndCompletion() throws IOException { + // Given a new-shape world with one complete migration backup snapshot + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + createCompleteSnapshot(worldName, "2026-05-31T10:00:00Z", + "mcmmo_0_0_.mcm", "restore-payload"); + + final Logger captureLogger = Logger.getLogger("McMMORegionBackupStoreTest.restore"); + captureLogger.setUseParentHandlers(false); + captureLogger.setLevel(Level.ALL); + final List loggedMessages = new ArrayList<>(); + final Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + loggedMessages.add(record.getMessage()); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + captureLogger.addHandler(handler); + + try { + // When restore runs + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, + worldFolder, captureLogger); + } finally { + captureLogger.removeHandler(handler); + } + + // Then restore progress is clearly logged in concise operator-friendly wording + assertThat(loggedMessages.stream().anyMatch(message -> message.contains( + "Restoring region data for world named '" + worldName + "'"))).isTrue(); + assertThat(loggedMessages.stream().anyMatch(message -> message.contains( + "Restore complete for world '" + worldName + "'"))).isTrue(); + assertThat(loggedMessages.stream().anyMatch(message -> message.contains( + "were successfully restored in "))).isTrue(); + assertThat(loggedMessages.stream().anyMatch(message -> message.contains( + "migration backup archive COMPLETE - saved previous migration backup data to"))).isTrue(); + } + + @Test + void restoresIntoNewShapeInWorldFolderWhenEmpty() throws IOException { + // Given a new-shape world with no in-world data + final String worldName = "world_nether"; + final Path worldFolder = newPaperWorldFolder(worldName, "the_nether"); + // And two complete backup snapshots (the newer one should be restored) + createCompleteSnapshot(worldName, "2026-05-30T10:00:00Z", + "mcmmo_0_0_.mcm", "older-data"); + createCompleteSnapshot(worldName, "2026-05-31T10:00:00Z", + "mcmmo_0_0_.mcm", "newest-data"); + + // When restore runs + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then the in-world folder receives the newest snapshot's file + final Path restored = inWorld(worldFolder).resolve("mcmmo_0_0_.mcm"); + assertThat(Files.isRegularFile(restored)).isTrue(); + assertThat(Files.readString(restored)).isEqualTo("newest-data"); + // And the restored backup is archived for possible re-use + assertThat(Files.exists(worldBackupRoot(worldName))).isFalse(); + assertThat(Files.isDirectory(archivedWorldBackupRoot(worldName))).isTrue(); + } + + @Test + void ignoresSnapshotsMissingTheBackupCompleteSentinel() throws IOException { + // Given a newer incomplete snapshot and an older complete snapshot + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + createCompleteSnapshot(worldName, "2026-05-29T10:00:00Z", + "mcmmo_0_0_.mcm", "complete-payload"); + // Newer but incomplete (no completion stamp) + final Path incomplete = worldBackupRoot(worldName) + .resolve(snapshotName("2026-05-31T10:00:00Z")); + Files.createDirectories(incomplete); + Files.writeString(incomplete.resolve("mcmmo_0_0_.mcm"), "torn-payload"); + + // When restore runs + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then the older complete snapshot wins + assertThat(Files.readString(inWorld(worldFolder).resolve("mcmmo_0_0_.mcm"))) + .isEqualTo("complete-payload"); + } + + @Test + void archivesBackupStoreWhenNewShapeInWorldAlreadyHasData() throws IOException { + // Given a new-shape world with existing in-world data AND a backup-store snapshot. + // This happens when Paper's migration already moved the data (or mcMMO already + // restored it on a prior startup). The backup store should be archived so an admin + // can re-use those snapshots for another merge pass later. + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 1, 1, 1 } }); + createCompleteSnapshot(worldName, "2026-05-31T10:00:00Z", + "mcmmo_0_0_.mcm", "restore-payload"); + + // When restore runs + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then the in-world data is left untouched + final ChunkStore preserved = readChunkFromRegionFile( + inWorld(worldFolder).resolve("mcmmo_0_0_.mcm"), 0, 0); + assertThat(preserved).isNotNull(); + assertThat(preserved.isTrue(1, 1, 1)).isTrue(); + // And the backup store is archived instead of deleted + assertThat(Files.exists(worldBackupRoot(worldName))).isFalse(); + assertThat(Files.isDirectory(archivedWorldBackupRoot(worldName))).isTrue(); + try (Stream archiveEntries = Files.list(archivedWorldBackupRoot(worldName))) { + assertThat(archiveEntries.anyMatch(Files::isDirectory)).isTrue(); + } + } + + @Test + void mergesLegacyRootDataAndArchivesSnapshotsWhenInWorldAlreadyHasData() + throws IOException { + // Given a new-shape world with existing in-world data + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 1, 1, 1 } }); + + // And backup-store snapshot data that should NOT be applied in this code path + writeRegionFileWithChunk( + worldBackupRoot(worldName).resolve(snapshotName("2026-05-31T10:00:00Z")), + 0, + 0, + new int[][] { { 7, 7, 7 } }); + Files.writeString( + worldBackupRoot(worldName) + .resolve(snapshotName("2026-05-31T10:00:00Z")) + .resolve(McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL), + "timestamp=2026-05-31T10:00:00Z\nworld_name=" + worldName + "\n", + StandardCharsets.UTF_8); + + // And surviving legacy-root data that SHOULD be merged into the new in-world folder + writeRegionFileWithChunk(inWorld(legacyWorldFolder(worldName)), 0, 0, + new int[][] { { 2, 2, 2 } }); + + // When restore runs on the new layout with in-world data already present + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then in-world data keeps its existing entries and merges legacy-root entries + final ChunkStore merged = readChunkFromRegionFile( + inWorld(worldFolder).resolve("mcmmo_0_0_.mcm"), 0, 0); + assertThat(merged).isNotNull(); + assertThat(merged.isTrue(1, 1, 1)).isTrue(); + assertThat(merged.isTrue(2, 2, 2)).isTrue(); + + // And snapshot data is NOT applied in this path (in-world was already authoritative) + assertThat(merged.isTrue(7, 7, 7)).isFalse(); + + // And backup snapshots are archived, while legacy-root source files are removed + assertThat(Files.exists(worldBackupRoot(worldName))).isFalse(); + assertThat(Files.isDirectory(archivedWorldBackupRoot(worldName))).isTrue(); + assertThat(Files.exists( + inWorld(legacyWorldFolder(worldName)).resolve("mcmmo_0_0_.mcm"))).isFalse(); + } + + @Test + void doesNotRestoreOnLegacyShapeEvenWhenBackupExists() throws IOException { + // Given a legacy-shape world with empty in-world AND a backup present in the restore + // store — restore must not auto-restore on the legacy shape because the in-world + // location is authoritative there and may be intentionally empty + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + createCompleteSnapshot(worldName, "2026-05-31T10:00:00Z", + "mcmmo_0_0_.mcm", "restore-payload"); + + // When restore runs + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then the in-world folder stays empty and the backup store is preserved + assertThat(Files.exists(inWorld(worldFolder).resolve("mcmmo_0_0_.mcm"))).isFalse(); + assertThat(Files.exists(worldBackupRoot(worldName))).isTrue(); + } + + @Test + void doesNothingWhenNoBackupStoreEntryExists() { + // Given no backup store at all for this world + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + + // When restore runs, it should be a no-op and never throw + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then no in-world folder is created + assertThat(Files.exists(inWorld(worldFolder))).isFalse(); + } + + @Test + void prunesIncompleteSnapshotsAndDeletesEmptyBackupStoreWhenNewShapeInWorldHasData() + throws IOException { + // Given a new-shape world with in-world data (no restore needed) and an incomplete + // snapshot left from a crashed previous backup. restore() should first prune the + // incomplete snapshot, then remove the empty per-world folder because there is + // nothing worth archiving. + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 0, 0, 0 } }); + final Path incomplete = worldBackupRoot(worldName).resolve( + snapshotName("2026-05-30T10:00:00Z")); + Files.createDirectories(incomplete); + + // When restore runs + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then the incomplete snapshot is gone + assertThat(Files.exists(incomplete)).isFalse(); + // And the backup store itself is deleted because there is no snapshot worth keeping + assertThat(Files.exists(worldBackupRoot(worldName))).isFalse(); + } + + @Test + void deletesLegacyRootRegionFilesAfterSnapshotRestore() throws IOException { + // Given a new-shape world with a restorable snapshot + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + createCompleteSnapshot(worldName, "2026-05-31T10:00:00Z", + "mcmmo_0_0_.mcm", "snapshot-data"); + // And leftover legacy-root region data that Paper migration did not remove + final Path legacyRootRegionFolder = inWorld(legacyWorldFolder(worldName)); + Files.createDirectories(legacyRootRegionFolder); + Files.writeString(legacyRootRegionFolder.resolve("mcmmo_1_0_.mcm"), "legacy-data"); + + // When restore runs and uses the snapshot + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then snapshot data is restored + assertThat(Files.readString(inWorld(worldFolder).resolve("mcmmo_0_0_.mcm"))) + .isEqualTo("snapshot-data"); + // And leftover legacy-root data is deleted (not merged, not archived) + assertThat(Files.exists(legacyRootRegionFolder.resolve("mcmmo_1_0_.mcm"))).isFalse(); + assertThat(Files.exists(inWorld(worldFolder).resolve("mcmmo_1_0_.mcm"))).isFalse(); + } + + @Test + void mergesLegacyRootRegionFilesAndDeletesSourceWhenNoSnapshotExists() + throws IOException { + // Given a new-shape world with existing in-world data and no migration snapshot + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 1, 1, 1 } }); + // And leftover legacy-root region data + final Path legacyRootRegionFolder = inWorld(legacyWorldFolder(worldName)); + writeRegionFileWithChunk(legacyRootRegionFolder, 0, 0, new int[][] { { 2, 2, 2 } }); + + // When restore runs + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then legacy-root data is merged into in-world data + final ChunkStore merged = readChunkFromRegionFile( + inWorld(worldFolder).resolve("mcmmo_0_0_.mcm"), 0, 0); + assertThat(merged).isNotNull(); + assertThat(merged.isTrue(1, 1, 1)).isTrue(); + assertThat(merged.isTrue(2, 2, 2)).isTrue(); + // And source files are deleted afterwards + assertThat(Files.exists(legacyRootRegionFolder.resolve("mcmmo_0_0_.mcm"))).isFalse(); + } + + private void createCompleteSnapshot(String worldName, String isoTimestamp, + String regionFileName, String content) throws IOException { + final Path snapshot = worldBackupRoot(worldName).resolve(snapshotName(isoTimestamp)); + Files.createDirectories(snapshot); + Files.writeString(snapshot.resolve(regionFileName), content); + Files.writeString( + snapshot.resolve(McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL), + "timestamp=" + isoTimestamp + "\nworld_name=" + worldName + "\n"); + } + } + + @Nested + class IdempotencyAndCrashRecovery { + + @Test + void backupIsIdempotentWhenSameTimestampIsReplayedAfterSuccess() throws IOException { + // Given a successful backup at a fixed clock + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 0, 0, 0 } }); + final Clock clock = fixedUtc("2026-05-31T14:23:05Z"); + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, clock); + final Path snapshot = worldBackupRoot(worldName) + .resolve(snapshotName("2026-05-31T14:23:05Z")); + final long sentinelMtimeBefore = Files.getLastModifiedTime( + snapshot.resolve(McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL)) + .toMillis(); + + // When backup is invoked again with the same clock + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, clock); + + // Then the existing snapshot is not rewritten (sentinel mtime unchanged) + final long sentinelMtimeAfter = Files.getLastModifiedTime( + snapshot.resolve(McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL)) + .toMillis(); + assertThat(sentinelMtimeAfter).isEqualTo(sentinelMtimeBefore); + } + + @Test + void backupCleansUpAnyPriorTempFolderAtSameTimestampBeforeRewriting() throws IOException { + // Given a *.tmp staging folder left from a crash at the same timestamp the new + // backup will use — the stale temp must be removed before the fresh copy starts + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 0, 0, 0 } }); + final Clock clock = fixedUtc("2026-05-31T14:23:05Z"); + final Path stale = worldBackupRoot(worldName) + .resolve(snapshotName("2026-05-31T14:23:05Z") + + McMMORegionBackupStore.IN_PROGRESS_SUFFIX); + Files.createDirectories(stale); + Files.writeString(stale.resolve("garbage.txt"), "stale"); + + // When backup runs + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, clock); + + // Then the stale *.tmp is gone and a clean complete snapshot is in its place + assertThat(Files.exists(stale)).isFalse(); + final Path finalSnapshot = worldBackupRoot(worldName) + .resolve(snapshotName("2026-05-31T14:23:05Z")); + assertThat(Files.isRegularFile(finalSnapshot.resolve("mcmmo_0_0_.mcm"))).isTrue(); + assertThat(Files.isRegularFile( + finalSnapshot.resolve(McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL))).isTrue(); + assertThat(Files.exists(finalSnapshot.resolve("garbage.txt"))).isFalse(); + } + + @Test + void archivesBackupStoreAfterSuccessfulRestoreAndLeavesInWorldDataUntouched() + throws IOException { + // Given a complete snapshot AND in-world data that already contains the chunks we + // care about. Re-running restore should keep the in-world data as-is and archive the + // old backup so an admin can use it again later if needed. + final String worldName = "world"; + final Path worldFolder = newPaperWorldFolder(worldName, "overworld"); + final Path snapshot = worldBackupRoot(worldName).resolve( + snapshotName("2026-05-31T10:00:00Z")); + Files.createDirectories(snapshot); + writeRegionFileWithChunk(snapshot, 0, 0, new int[][] { { 1, 1, 1 } }); + Files.writeString(snapshot.resolve( + McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL), "ok"); + // Simulate prior partial: in-world already has the same chunk with a different bit + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 2, 2, 2 } }); + + // When restore runs again + McMMORegionBackupStore.restore(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger); + + // Then the in-world chunk is preserved unmodified (no overwrite or merge) + final ChunkStore inWorldChunk = readChunkFromRegionFile( + inWorld(worldFolder).resolve("mcmmo_0_0_.mcm"), 0, 0); + assertThat(inWorldChunk).isNotNull(); + assertThat(inWorldChunk.isTrue(2, 2, 2)).isTrue(); + assertThat(inWorldChunk.isTrue(1, 1, 1)).isFalse(); + // And the backup store is archived instead of removed + assertThat(Files.exists(worldBackupRoot(worldName))).isFalse(); + assertThat(Files.isDirectory(archivedWorldBackupRoot(worldName))).isTrue(); + } + } + + @Nested + class NewestCompleteSnapshotPicker { + + @Test + void returnsLexicographicallyNewestCompleteSnapshot() throws IOException { + // Given several snapshots, only some complete + final Path worldBackupRoot = worldBackupRoot("world"); + createCompleteSnapshot(worldBackupRoot, "2026-05-29T10:00:00Z"); + createIncompleteSnapshot(worldBackupRoot, "2026-05-31T10:00:00Z"); + createCompleteSnapshot(worldBackupRoot, "2026-05-30T10:00:00Z"); + + // When asking for the newest complete snapshot + final Path newest = McMMORegionBackupStore.newestCompleteSnapshot(worldBackupRoot); + + // Then the 2026-05-30 snapshot wins (the 31st is incomplete and has no stamp) + assertThat(newest).isNotNull(); + assertThat(newest.getFileName().toString()) + .isEqualTo(snapshotName("2026-05-30T10:00:00Z")); + } + + @Test + void returnsNullWhenNoCompleteSnapshotExists() throws IOException { + // Given a backup root with only incomplete snapshots + final Path worldBackupRoot = worldBackupRoot("world"); + createIncompleteSnapshot(worldBackupRoot, "2026-05-31T10:00:00Z"); + + // When asking for the newest complete snapshot + // Then null is returned + assertThat(McMMORegionBackupStore.newestCompleteSnapshot(worldBackupRoot)).isNull(); + } + + @Test + void returnsNullWhenWorldBackupRootDoesNotExist() { + // Given a non-existent root + // When asking for the newest complete snapshot + // Then null is returned without throwing + assertThat(McMMORegionBackupStore.newestCompleteSnapshot( + worldBackupRoot("nonexistent"))).isNull(); + } + + private void createCompleteSnapshot(Path root, String iso) throws IOException { + final Path snapshot = root.resolve(snapshotName(iso)); + Files.createDirectories(snapshot); + Files.writeString(snapshot.resolve( + McMMORegionBackupStore.BACKUP_COMPLETE_SENTINEL), "ok"); + } + + private void createIncompleteSnapshot(Path root, String iso) throws IOException { + Files.createDirectories(root.resolve(snapshotName(iso))); + } + } + + @Nested + class Readme { + + @Test + void writesReadmeWhenAbsent() throws IOException { + // Given a fresh backup-store root with no README + final Path backupStoreRoot = backupStoreRoot(); + Files.createDirectories(backupStoreRoot); + + // When ensureReadme runs + McMMORegionBackupStore.writeReadme(backupStoreRoot, silentLogger); + + // Then a README.txt with operator documentation is written + final Path readme = backupStoreRoot.resolve(McMMORegionBackupStore.README_FILE_NAME); + assertThat(Files.isRegularFile(readme)).isTrue(); + assertThat(Files.readString(readme)).contains("mcMMO region backup store"); + } + + @Test + void doesNotOverwriteAnExistingReadme() throws IOException { + // Given an operator-edited README already in place + final Path backupStoreRoot = backupStoreRoot(); + Files.createDirectories(backupStoreRoot); + final Path readme = backupStoreRoot.resolve(McMMORegionBackupStore.README_FILE_NAME); + Files.writeString(readme, "OPERATOR NOTES — DO NOT TOUCH"); + + // When ensureReadme runs + McMMORegionBackupStore.writeReadme(backupStoreRoot, silentLogger); + + // Then the operator content is preserved unchanged + assertThat(Files.readString(readme)).isEqualTo("OPERATOR NOTES — DO NOT TOUCH"); + } + + @Test + void writtenByBackupOnFirstRun() throws IOException { + // Given a legacy-shape world with one in-world region file and no existing README + final String worldName = "world"; + final Path worldFolder = legacyWorldFolder(worldName); + writeRegionFileWithChunk(inWorld(worldFolder), 0, 0, new int[][] { { 0, 0, 0 } }); + + // When backup runs for the first time + McMMORegionBackupStore.backup(containerRoot, pluginDataRoot, worldName, worldFolder, + silentLogger, fixedUtc("2026-05-31T14:23:05Z")); + + // Then the README is written into the backup-store root + assertThat(Files.isRegularFile( + backupStoreRoot().resolve(McMMORegionBackupStore.README_FILE_NAME))).isTrue(); + // And no per-world README is created + assertThat(Files.exists( + worldBackupRoot(worldName).resolve(McMMORegionBackupStore.README_FILE_NAME))).isFalse(); + } + } + + @Nested + class CopyOrMergeRegionFile { + + @Test + void copiesIntactWhenDestinationMissing(@TempDir Path scratch) throws IOException { + // Given a region file at source and no destination file yet + final Path source = writeRegionFileWithChunk(scratch.resolve("src"), 0, 0, + new int[][] { { 1, 1, 1 } }); + final Path destination = scratch.resolve("dst").resolve("mcmmo_0_0_.mcm"); + Files.createDirectories(destination.getParent()); + + // When copy-or-merge runs + McMMORegionBackupStore.copyOrMergeRegionFile(source, destination); + + // Then the destination is byte-equal to the source (straight copy) + assertThat(Files.size(destination)).isEqualTo(Files.size(source)); + } + + @Test + void unionMergesWhenDestinationExists(@TempDir Path scratch) throws IOException { + // Given source with bit A at (1,64,2) and destination with bit B at (5,32,6) in the + // same chunk — both bits must survive the merge + final Path source = writeRegionFileWithChunk(scratch.resolve("src"), 0, 0, + new int[][] { { 1, 64, 2 } }); + final Path destination = writeRegionFileWithChunk(scratch.resolve("dst"), 0, 0, + new int[][] { { 5, 32, 6 } }); + + // When copy-or-merge runs + McMMORegionBackupStore.copyOrMergeRegionFile(source, destination); + + // Then both bits survive in the destination + final ChunkStore merged = readChunkFromRegionFile(destination, 0, 0); + assertThat(merged).isNotNull(); + assertThat(merged.isTrue(1, 64, 2)).isTrue(); + assertThat(merged.isTrue(5, 32, 6)).isTrue(); + } + } + +}