Handle Paper world-layout migration for region data

Fixes #5290
This commit is contained in:
nossr50
2026-05-31 22:40:32 -07:00
parent ae76eed87b
commit ff2b335af4
14 changed files with 2845 additions and 18 deletions

View File

@ -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

View File

@ -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.<SkillName> 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

2
Jenkinsfile vendored
View File

@ -21,7 +21,7 @@ pipeline {
stage('Build') {
steps {
sh 'mvn -V -B clean package'
sh 'mvn -V -B -Psql-tests clean package'
}
}

34
pom.xml
View File

@ -25,6 +25,7 @@
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<java.version>17</java.version>
<surefire.excludedGroups>skip,stress,docker</surefire.excludedGroups>
</properties>
<issueManagement>
@ -108,7 +109,7 @@
<configuration>
<junitArtifactName>org.junit.jupiter:junit-jupiter</junitArtifactName>
<trimStackTrace>false</trimStackTrace>
<excludedGroups>skip</excludedGroups>
<excludedGroups>${surefire.excludedGroups}</excludedGroups>
<!-- https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 -->
<argLine>-javaagent:${org.mockito:mockito-core:jar}</argLine>
</configuration>
@ -575,6 +576,24 @@
</dependencyManagement>
<profiles>
<profile>
<id>sql-tests</id>
<properties>
<surefire.excludedGroups>skip,stress</surefire.excludedGroups>
</properties>
</profile>
<profile>
<id>stress-tests</id>
<properties>
<surefire.excludedGroups>skip,docker</surefire.excludedGroups>
</properties>
</profile>
<profile>
<id>all-heavy-tests</id>
<properties>
<surefire.excludedGroups>skip</surefire.excludedGroups>
</properties>
</profile>
<profile>
<id>skip-docker-tests</id>
<activation>
@ -583,16 +602,9 @@
<value>true</value>
</property>
</activation>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludedGroups>skip,docker</excludedGroups>
</configuration>
</plugin>
</plugins>
</build>
<properties>
<surefire.excludedGroups>skip,docker,stress</surefire.excludedGroups>
</properties>
</profile>
</profiles>
</project>

View File

@ -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);
}

View File

@ -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());
}
}
/**

View File

@ -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;
}

View File

@ -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);
}

View File

@ -85,11 +85,31 @@ public class HashChunkManager implements ChunkManager {
});
}
/**
* Resolves the on-disk region file for a chunk's region.
*
* <p>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/<dim>/mcmmo_regions/}.
*
* <p>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");
}

File diff suppressed because it is too large Load Diff

View File

@ -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
###

View File

@ -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");
}
}
}

View File

@ -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}.
*
* <p>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<Bukkit> 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<String, List<PlacedBlockExpectation>> 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<PlacedBlockExpectation> overworldExpectations = writeLegacyRegionDataset(
overworldLegacyFolder, regionFilesPerWorld, WorldDatasetMode.RANDOM_DENSE);
final List<PlacedBlockExpectation> netherExpectations = writeLegacyRegionDataset(
netherLegacyFolder, regionFilesPerWorld, WorldDatasetMode.RANDOM_DENSE);
final List<PlacedBlockExpectation> 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<PlacedBlockExpectation> writeLegacyRegionDataset(
Path legacyWorldFolder, int regionFileCount, WorldDatasetMode worldDatasetMode)
throws IOException {
if (worldDatasetMode == WorldDatasetMode.NO_DATA) {
return new ArrayList<>();
}
final List<PlacedBlockExpectation> 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<int[]> 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<int[]> randomizedTrueBits = new ArrayList<>(trueValueCount);
final Set<Long> 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<PlacedBlockExpectation> 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<PlacedBlockExpectation> expectations, WorldDatasetMode worldDatasetMode) {
if (worldDatasetMode != WorldDatasetMode.NO_DATA) {
return;
}
assertThat(expectations).isEmpty();
assertThat(Files.exists(restoredRegionFolder)).isFalse();
}
private static Stream<Arguments> 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))));
}
}

View File

@ -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}.
*
* <p>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/<dim>/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]/<timestamp>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.
*
* <p>{@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<Bukkit> 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/<dimensionKey>/}.
*/
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/<worldName>/}.
*/
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<String> remaining;
try (Stream<Path> 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<String> 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<String> 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<Path> 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();
}
}
}