mirror of
https://github.com/mcMMO-Dev/mcMMO.git
synced 2026-08-18 03:05:10 +00:00
2
.github/workflows/maven.yml
vendored
2
.github/workflows/maven.yml
vendored
@ -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
|
||||
|
||||
@ -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
2
Jenkinsfile
vendored
@ -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
34
pom.xml
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
@ -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
|
||||
###
|
||||
|
||||
49
src/test/java/com/gmail/nossr50/McMMOTimingFormatTest.java
Normal file
49
src/test/java/com/gmail/nossr50/McMMOTimingFormatTest.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user