Compare commits

...
23 Commits
Author SHA1 Message Date
Ben Woo 112505bc93 Merge pull request #3437 from Multiverse/fix/ansi-reset
Fix ANSI color format reset in log messages
2026-04-04 20:55:50 +08:00
Ben Woo e27fe73faa Fix ANSI color format reset in log messages 2026-04-04 14:46:06 +08:00
Ben Woo 9bc2b4dfaf Merge pull request #3422 from Multiverse/feat/clone-unloaded
Add support for cloning unloaded worlds
2026-04-04 14:32:14 +08:00
Ben Woo 3ba8beefc5 Merge pull request #3436 from Multiverse/fix/clone-paper-metadata
Fix clone not working due to duplicated Paper world metadata in 26.1
2026-04-04 13:28:16 +08:00
Ben Woo 05b6b380e0 Fix clone not working due to duplicated Paper world metadata in 26.1 2026-04-04 12:31:25 +08:00
Ben Woo b58ea2e04b Update error messages for invalid world folder following the format of 2026-04-04 12:17:03 +08:00
Ben Woo f4405874d1 Merge branch 'main' into feat/clone-unloaded 2026-04-04 12:05:01 +08:00
Ben Woo 247685c93d Merge pull request #3435 from Multiverse/feat/26.1-compatibility
Update world folder discovery and checking to support new 26.1 format
2026-04-04 11:38:16 +08:00
Ben Woo 490107b5d5 Fix tests due to world folder format changes 2026-04-04 11:35:30 +08:00
Ben Woo 122e35103a Fix some formatting and documentation 2026-04-04 11:22:08 +08:00
Ben Woo 25eeefbc77 Update platform upload version list to include 26.1 2026-04-04 11:16:12 +08:00
Ben Woo 6b5ce2aebb Update world folder discovery and checking to support new 26.1 format 2026-04-04 11:13:43 +08:00
Ben Woo e9905ca281 Merge pull request #3434 from Multiverse/fix/load-flags
Fix `--skip-folder-check` flag not showing for `/mv load` command
2026-04-03 22:01:02 +08:00
Ben Woo e002282b13 Fix --skip-folder-check flag not showing for /mv load command 2026-04-03 21:56:13 +08:00
Ben Woo 39711527fa Merge pull request #3431 from Multiverse/feat/placeholder-isloaded
Add PlaceholderAPI support for showing world load status (`%multiverse-core_isloaded_<worldname>%`)
2026-03-10 22:31:42 +08:00
Ben Woo 8d9b29ff87 Add PlaceholderAPI support for showing world load status (%multiverse-core_isloaded_<worldname>%) 2026-03-10 22:27:38 +08:00
Ben Woo 82fe350afa Merge pull request #3429 from Multiverse/feat/apply-passenger-mode
Apply passenger mode to join location and first spawn as well
2026-03-09 10:48:15 +08:00
Ben Woo a7b4b9bbda Merge pull request #3430 from Multiverse/chore/access-modifier
Change entry fee migrator class access modifier to private
2026-03-09 10:47:35 +08:00
Ben Woo 15f511b660 Change entry fee migrator class access modifier to private 2026-03-09 10:40:00 +08:00
Ben Woo 8347cf8fbb Apply passenger mode to join location and first spawn as well 2026-03-09 10:39:13 +08:00
Ben Woo 4c27fea26b Merge pull request #3427 from Multiverse/fix/regen-biome
Fix biome not applying during world regen
2026-02-26 10:43:47 +08:00
Ben Woo 4111da8fd6 Fix biome not applying during world regen 2026-02-26 10:34:13 +08:00
Ben Woo 5dfb098d5c Add support for cloning unloaded worlds 2026-01-26 12:18:47 +08:00
22 changed files with 340 additions and 77 deletions

No files matched your search

@@ -107,7 +107,7 @@ jobs:
files: '["${{ github.workspace }}/${{ inputs.plugin_name }}-${{ steps.release-info.outputs.tag_name }}.jar"]'
name: ${{ steps.release-info.outputs.tag_name }}
changelog: ${{ steps.release-artifact.outputs.body }}
game_versions: 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2
game_versions: 26.1.1, 26.1, 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2
version_type: ${{ steps.parse-release-type.outputs.release_type }}
loaders: bukkit, spigot, paper, purpur
dependencies: ${{ inputs.modrinth_dependencies }}
@@ -121,7 +121,7 @@ jobs:
changelog: ${{ steps.release-artifact.outputs.body }}
changelog_type: markdown
display_name: ${{ steps.release-info.outputs.tag_name }}
game_versions: 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2
game_versions: 26.1.1, 26.1, 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2
release_type: ${{ steps.parse-release-type.outputs.release_type }}
project_relations: ${{ inputs.dbo_project_relations }}
file_path: ${{ github.workspace }}/${{ inputs.plugin_name }}-${{ steps.release-info.outputs.tag_name }}.jar
@@ -136,5 +136,5 @@ jobs:
channel: ${{ steps.parse-release-type.outputs.release_type }}
files: '[{"path": "${{ github.workspace }}/${{ inputs.plugin_name }}-${{ steps.release-info.outputs.tag_name }}.jar", "platforms": ["PAPER"]}]'
description: ${{ steps.release-artifact.outputs.body }}
platform_dependencies: '{"PAPER": ["1.18.2-1.21.11"]}'
platform_dependencies: '{"PAPER": ["1.18.2-26.1.1"]}'
plugin_dependencies: ${{ inputs.hangar_plugin_dependencies }}
@@ -226,11 +226,12 @@ public class MultiverseCore extends MultiverseModule {
* Logs the enable message.
*/
private void logEnableMessage() {
Logging.config("\u001B[32mVersion %s (API v%s) Enabled - By %s\u001B[39m",
Logging.config("\u001B[32mVersion %s (API v%s) Enabled - By %s\u001B[0m",
this.getDescription().getVersion(), getVersionAsNumber(), StringFormatter.joinAnd(getDescription().getAuthors()));
if (configProvider.get().isShowingDonateMessage()) {
Logging.config("\u001B[32mLoving Multiverse-Core? Please consider supporting the project with a small donation: https://github.com/sponsors/Multiverse\u001B[39m");
Logging.config("\u001B[32mLoving Multiverse-Core? Please consider supporting the project with a " +
"small donation: https://github.com/sponsors/Multiverse\u001B[0m");
}
}
@@ -150,6 +150,7 @@ final class PlaceholderExpansionHook extends PlaceholderExpansion {
case "gamemode" -> world.getGameMode().toString().toLowerCase();
case "generator" -> world.getGenerator();
case "hunger" -> String.valueOf(world.isHunger());
case "isloaded" -> String.valueOf(world.isLoaded());
case "monstersspawn" -> String.valueOf(world.getEntitySpawnConfig()
.getSpawnCategoryConfig(SpawnCategory.MONSTER)
.isSpawn());
@@ -20,7 +20,7 @@ import org.mvplugins.multiverse.core.command.flag.FlagBuilder;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.options.CloneWorldOptions;
@@ -48,7 +48,7 @@ class CloneCommand extends CoreCommand {
@Syntax("<world>")
@Description("{@@mv-core.clone.world.description}")
LoadedMultiverseWorld world,
MultiverseWorld world,
@Syntax("<new-world-name>")
@Description("{@@mv-core.clone.newWorld.description}")
@@ -114,7 +114,7 @@ class CloneCommand extends CoreCommand {
@Override
@CommandAlias("mvcl|mvclone")
void onCloneCommand(MVCommandIssuer issuer, LoadedMultiverseWorld world, String newWorldName, String[] flags) {
void onCloneCommand(MVCommandIssuer issuer, MultiverseWorld world, String newWorldName, String[] flags) {
super.onCloneCommand(issuer, world, newWorldName, flags);
}
}
@@ -16,8 +16,8 @@ import org.mvplugins.multiverse.core.command.LegacyAliasCommand;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.command.flag.CommandFlag;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.FlagBuilder;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
@@ -38,7 +38,7 @@ class LoadCommand extends CoreCommand {
@Subcommand("load")
@CommandPermission("multiverse.core.load")
@CommandCompletion("@mvworlds:scope=unloaded")
@CommandCompletion("@mvworlds:scope=unloaded @flags:groupName=" + Flags.NAME)
@Syntax("<world>")
@Description("{@@mv-core.load.description}")
void onLoadCommand(
@@ -49,7 +49,7 @@ class LoadCommand extends CoreCommand {
MultiverseWorld world,
@Optional
@Syntax("[--remove-players]")
@Syntax("[--skip-folder-check]")
@Description("")
String[] flagArray) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
@@ -67,9 +67,9 @@ class LoadCommand extends CoreCommand {
}
@Service
private static final class Flags extends RemovePlayerFlags {
private static final class Flags extends FlagBuilder {
private static final String NAME = "mvunload";
private static final String NAME = "mvload";
@Inject
private Flags(@NotNull CommandFlagsManager flagsManager) {
@@ -220,7 +220,10 @@ final class CoreConfigNodes {
final ConfigNode<PassengerModes> passengerMode = node(ConfigNode.builder("teleport.passenger-mode", PassengerModes.class)
.comment("")
.comment("Configures how passengers and vehicles are handled when an entity is teleported.")
.comment("Configures how passengers and vehicles are handled when an entity is teleported. This config is")
.comment("only applied to /mvtp, join-destination and first-spawn-override. It will not touch any other")
.comment("teleport by other plugins or commands.")
.comment("========= Available options: =========")
.comment(" default: Server will handle passengers and vehicles. On papermc 1.21.10+, all will be retained by default.")
.comment(" On older versions, all will be dismounted if world changes.")
.comment(" dismount_passengers: Passengers will be removed from the parent entity before the teleport.")
@@ -1,8 +1,11 @@
package org.mvplugins.multiverse.core.event.world;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
/**
* Called when a world has been successfully cloned.
@@ -10,14 +13,24 @@ import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
public final class MVWorldClonedEvent extends MultiverseWorldEvent<LoadedMultiverseWorld> {
private static final HandlerList HANDLERS = new HandlerList();
private final LoadedMultiverseWorld fromWorld;
private final MultiverseWorld fromWorld;
public MVWorldClonedEvent(LoadedMultiverseWorld world, LoadedMultiverseWorld fromWorld) {
public MVWorldClonedEvent(LoadedMultiverseWorld world, MultiverseWorld fromWorld) {
super(world);
this.fromWorld = fromWorld;
}
public LoadedMultiverseWorld getFromWorld() {
/**
* @deprecated Cloning can be done from unloaded worlds as well. Use {@link #getSourceWorld()} instead.
*/
@Deprecated(forRemoval = true, since = "5.6")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public @Nullable LoadedMultiverseWorld getFromWorld() {
return fromWorld.asLoadedWorld().getOrNull();
}
@ApiStatus.AvailableSince("5.6")
public @NotNull MultiverseWorld getSourceWorld() {
return fromWorld;
}
@@ -37,5 +50,4 @@ public final class MVWorldClonedEvent extends MultiverseWorldEvent<LoadedMultive
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -244,7 +244,7 @@ final class MVPlayerListener implements CoreListener {
}
private void teleportToDestinationOnJoin(Player player, DestinationInstance<?, ?> destination) {
asyncSafetyTeleporter.to(destination).teleportSingle(player)
asyncSafetyTeleporter.to(destination).passengerMode(config.getPassengerMode()).teleportSingle(player)
.onSuccess(result -> Logging.fine("Player %s has been teleported on join",
player.getName()))
.onFailure(failure -> Logging.warning("Failed to teleport player %s on join: %s",
@@ -292,6 +292,7 @@ public enum MVCorei18n implements MessageKeyProvider {
CLONEWORLD_WORLDEXISTFOLDER,
CLONEWORLD_WORLDEXISTUNLOADED,
CLONEWORLD_WORLDEXISTLOADED,
CLONEWORLD_FROMWORLDFOLDERINVALID,
CLONEWORLD_COPYFAILED,
CREATEWORLD_INVALIDWORLDNAME,
@@ -18,6 +18,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.dumptruckman.minecraft.util.Logging;
@@ -25,6 +26,7 @@ import io.vavr.control.Try;
import jakarta.inject.Inject;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jspecify.annotations.NonNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.config.CoreConfig;
@@ -210,16 +212,19 @@ public final class FileUtils {
private final Path sourceDir;
private final Path targetDir;
private final List<String> excludeFiles;
private final List<Path> excludeFiles;
private CopyDirFileVisitor(@NotNull Path sourceDir, @NotNull Path targetDir, @NotNull List<String> excludeFiles) {
this.sourceDir = sourceDir;
this.targetDir = targetDir;
this.excludeFiles = excludeFiles;
this.excludeFiles = excludeFiles.stream()
.map(sourceDir::resolve)
.toList();
Logging.finest(this.excludeFiles.stream().map(Path::toString).collect(Collectors.joining(", ", "Exclude files: [", "]")));
}
@Override
public @NotNull FileVisitResult preVisitDirectory(Path dir, @NotNull BasicFileAttributes attrs) throws IOException {
public @NotNull FileVisitResult preVisitDirectory(@NonNull Path dir, @NotNull BasicFileAttributes attrs) throws IOException {
Path newDir = targetDir.resolve(sourceDir.relativize(dir));
if (!Files.isDirectory(newDir)) {
Files.createDirectory(newDir);
@@ -228,10 +233,10 @@ public final class FileUtils {
}
@Override
public @NotNull FileVisitResult visitFile(Path file, @NotNull BasicFileAttributes attrs) throws IOException {
public @NotNull FileVisitResult visitFile(@NonNull Path file, @NotNull BasicFileAttributes attrs) throws IOException {
// Pass files that are set to ignore
if (excludeFiles.contains(file.getFileName().toString())) {
Logging.finest("Ignoring file: " + file.getFileName());
if (excludeFiles.contains(file)) {
Logging.finest("Ignoring file: " + file);
return FileVisitResult.CONTINUE;
}
// Copy the files
@@ -0,0 +1,68 @@
package org.mvplugins.multiverse.core.utils.compatibility;
import io.vavr.control.Option;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.World;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import java.lang.reflect.Method;
import java.nio.file.Path;
/**
* Compatibility class used to handle API changes in {@link Bukkit} class.
*/
@ApiStatus.AvailableSince("5.6")
public final class BukkitCompatibility {
private static final Option<Method> GET_LEVEL_DIRECTORY_METHOD;
private static final Option<Method> GET_WORLD_NAMESPACED_KEY_METHOD;
static {
GET_LEVEL_DIRECTORY_METHOD = Option.of(ReflectHelper.getMethod(Server.class, "getLevelDirectory"));
GET_WORLD_NAMESPACED_KEY_METHOD = Option.of(ReflectHelper.getMethod(Bukkit.class, "getWorld", NamespacedKey.class));
}
/**
* Gets the folder where all the worlds will be store. Before 26.1, all worlds are stored in the root directory
* of the server, which can be obtained by {@link Server#getWorldContainer()}.
* <br />
* After 26.1, PaperMC changed all worlds are stored in the "[level]/dimensions/minecraft" folder under the world
* level directory, which needs to be manually parsed.
*
* @return The location where all the worlds folders should be, depending on server's mc version.
*/
@ApiStatus.AvailableSince("5.6")
@NotNull
public static Path getWorldFoldersDirectory() {
Server server = Bukkit.getServer();
return GET_LEVEL_DIRECTORY_METHOD.map(method -> ReflectHelper.invokeMethod(server, method))
.filter(Path.class::isInstance)
.map(Path.class::cast)
.map(path -> path.resolve("dimensions/minecraft"))
.getOrElse(() -> server.getWorldContainer().toPath());
}
/**
* Check if the world with the given name or namespaced key (e.g. minecraft:overworld) exists,
* and return it if it does.
* <br />
* Note that some default world names have different namespaced key matched with them.
* E.g.: world -> minecraft:overworld, world_nether -> minecraft:the_nether, world_the_end -> minecraft:the_end.
*
* @param nameOrKey Either a name or namespaced key string representation.
* @return The world if it exists
*/
@ApiStatus.AvailableSince("5.6")
@NotNull
public static Option<World> getWorldByNameOrKey(@NotNull String nameOrKey) {
return Option.of(Bukkit.getWorld(nameOrKey))
.orElse(() -> GET_WORLD_NAMESPACED_KEY_METHOD
.map(method -> ReflectHelper.invokeMethod(null, method, NamespacedKey.fromString(nameOrKey)))
.filter(World.class::isInstance)
.map(World.class::cast));
}
}
@@ -0,0 +1,5 @@
/**
* Contains classes to handle compatibility due to differing API methods across different server versions and the
* API gaps between PaperMC and Spigot.
*/
package org.mvplugins.multiverse.core.utils.compatibility;
@@ -396,7 +396,7 @@ final class WorldConfig {
/**
* Migrates the entry fee settings. Assumes entry fee is disabled if currency is not set.
*/
static final class EntryFeeMigrator implements MigratorAction {
private static final class EntryFeeMigrator implements MigratorAction {
@Override
public void migrate(ConfigurationSection config) {
String currency = config.getString("entry-fee.currency", "");
@@ -20,8 +20,10 @@ import io.vavr.control.Option;
import io.vavr.control.Try;
import jakarta.inject.Inject;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.WorldBorder;
import org.bukkit.WorldCreator;
import org.bukkit.WorldType;
import org.bukkit.plugin.PluginManager;
@@ -49,6 +51,7 @@ import org.mvplugins.multiverse.core.teleportation.LocationManipulation;
import org.mvplugins.multiverse.core.utils.CaseInsensitiveStringMap;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import org.mvplugins.multiverse.core.utils.ServerProperties;
import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility;
import org.mvplugins.multiverse.core.utils.result.Attempt;
import org.mvplugins.multiverse.core.utils.result.FailureReason;
import org.mvplugins.multiverse.core.utils.FileUtils;
@@ -86,7 +89,11 @@ import static org.mvplugins.multiverse.core.world.helpers.DataStore.WorldConfigS
@Service // SUPPRESS CHECKSTYLE: ClassFanOutComplexity This is the world manager, it's going to be complex.
public final class WorldManager {
private static final List<String> CLONE_IGNORE_FILES = Arrays.asList("uid.dat", "session.lock");
private static final List<String> CLONE_IGNORE_FILES = List.of(
"uid.dat", "session.lock", // All pre 26.1 format
"data/paper/metadata.dat" // New papermc format for 26.1+
);
private static final DimensionFormat DEFAULT_NETHER_FORMAT = new DimensionFormat("%overworld%_nether");
private static final DimensionFormat DEFAULT_END_FORMAT = new DimensionFormat("%overworld%_the_end");
@@ -699,6 +706,7 @@ public final class WorldManager {
private Attempt<File, DeleteFailureReason> validateWorldToDelete(
@NotNull LoadedMultiverseWorld world) {
return world.getBukkitWorld().map(World::getWorldFolder)
.peek(folder -> Logging.finer("World folder for world %s is at: %s", world.getName(), folder.getPath()))
.filter(worldNameChecker::isValidWorldFolder)
.map(this::<File, DeleteFailureReason>worldActionResult)
.getOrElse(() -> {
@@ -719,18 +727,19 @@ public final class WorldManager {
.mapAttempt(validatedOptions -> {
ImportWorldOptions importWorldOptions = ImportWorldOptions
.worldName(validatedOptions.newWorldName())
.biome(validatedOptions.world().getBiome())
.environment(validatedOptions.world().getEnvironment())
.generator(validatedOptions.world().getGenerator());
.biome(validatedOptions.fromWorld().getBiome())
.environment(validatedOptions.fromWorld().getEnvironment())
.generator(validatedOptions.fromWorld().getGenerator());
return importWorld(importWorldOptions).transform(CloneFailureReason.IMPORT_FAILED);
})
.onSuccess(newWorld -> {
cloneWorldTransferData(options, newWorld);
if (options.keepWorldConfig()) {
newWorld.setSpawnLocation(options.world().getSpawnLocation());
// Special case for spawn location as it's considered a hidden config
newWorld.setSpawnLocation(options.fromWorld().getSpawnLocation());
}
saveWorldsConfig();
pluginManager.callEvent(new MVWorldClonedEvent(newWorld, options.world()));
pluginManager.callEvent(new MVWorldClonedEvent(newWorld, options.fromWorld()));
});
}
@@ -752,19 +761,24 @@ public final class WorldManager {
if (worldNameChecker.hasWorldFolder(newWorldName)) {
return worldActionResult(CloneFailureReason.WORLD_EXIST_FOLDER, newWorldName);
}
if (!worldNameChecker.isValidWorldFolder(options.fromWorld().getName())) {
return worldActionResult(CloneFailureReason.FROM_WORLD_FOLDER_INVALID, options.fromWorld().getName());
}
return worldActionResult(options);
}
private Attempt<CloneWorldOptions, CloneFailureReason> cloneWorldCopyFolder(@NotNull CloneWorldOptions options) {
if (options.saveBukkitWorld()) {
Logging.finer("Saving bukkit world before cloning: " + options.world().getName());
options.world().getBukkitWorld().peek(this::saveWorldWithFlush);
options.fromWorld().asLoadedWorld().peek(loadedWorld -> {
Logging.finer("Saving world before cloning: " + loadedWorld.getName());
loadedWorld.getBukkitWorld().peek(this::saveWorldWithFlush);
});
}
File worldFolder = options.world().getBukkitWorld().map(World::getWorldFolder).get();
File newWorldFolder = new File(Bukkit.getWorldContainer(), options.newWorldName());
File worldFolder = BukkitCompatibility.getWorldFoldersDirectory().resolve(options.fromWorld().getName()).toFile();
File newWorldFolder = BukkitCompatibility.getWorldFoldersDirectory().resolve(options.newWorldName()).toFile();
return fileUtils.copyFolder(worldFolder, newWorldFolder, CLONE_IGNORE_FILES).fold(
exception -> worldActionResult(CloneFailureReason.COPY_FAILED,
options.world().getName(), exception),
options.fromWorld().getName(), exception),
success -> worldActionResult(options));
}
@@ -780,25 +794,30 @@ public final class WorldManager {
}
private void cloneWorldTransferData(@NotNull CloneWorldOptions options, @NotNull LoadedMultiverseWorld newWorld) {
DataTransfer<LoadedMultiverseWorld> dataTransfer = transferData(options, options.world());
dataTransfer.pasteAllTo(newWorld);
}
private DataTransfer<LoadedMultiverseWorld> transferData(
@NotNull KeepWorldSettingsOptions options, @NotNull LoadedMultiverseWorld world) {
DataTransfer<LoadedMultiverseWorld> dataTransfer = new DataTransfer<>();
if (options.keepWorldConfig()) {
dataTransfer.addDataStore(new WorldConfigStore(), world);
}
if (options.keepGameRule()) {
dataTransfer.addDataStore(new GameRulesStore(), world);
}
if (options.keepWorldBorder()) {
dataTransfer.addDataStore(new WorldBorderStore(), world);
new DataTransfer<MultiverseWorld>()
.addDataStore(new WorldConfigStore<>(), options.fromWorld())
.pasteAllTo(newWorld);
}
return dataTransfer;
newWorld.getBukkitWorld().peek(bukkitWorld -> {
if (!options.keepWorldBorder()) {
WorldBorder worldBorder = bukkitWorld.getWorldBorder();
worldBorder.reset();
}
if (!options.keepGameRule()) {
Arrays.stream(bukkitWorld.getGameRules())
.map(gameRuleName -> GameRule.getByName(gameRuleName))
.filter(Objects::nonNull)
.forEach(gameRule -> {
Object gameRuleDefault = bukkitWorld.getGameRuleDefault(gameRule);
if (gameRuleDefault != null) {
bukkitWorld.setGameRule(gameRule, gameRuleDefault);
}
});
}
});
}
/**
@@ -809,11 +828,12 @@ public final class WorldManager {
*/
public Attempt<LoadedMultiverseWorld, RegenFailureReason> regenWorld(@NotNull RegenWorldOptions options) {
LoadedMultiverseWorld world = options.world();
DataTransfer<LoadedMultiverseWorld> dataTransfer = transferData(options, world);
DataTransfer<LoadedMultiverseWorld> dataTransfer = regenWorldTransferData(options, world);
boolean shouldKeepSpawnLocation = options.keepWorldConfig() && options.seed() == world.getSeed();
Location spawnLocation = world.getSpawnLocation();
CreateWorldOptions createWorldOptions = CreateWorldOptions.worldName(world.getName())
.biome(world.getBiome())
.environment(world.getEnvironment())
.generateStructures(world.canGenerateStructures().getOrElse(true))
.generator(world.getGenerator())
@@ -837,6 +857,23 @@ public final class WorldManager {
});
}
private DataTransfer<LoadedMultiverseWorld> regenWorldTransferData(
@NotNull KeepWorldSettingsOptions options, @NotNull LoadedMultiverseWorld world) {
DataTransfer<LoadedMultiverseWorld> dataTransfer = new DataTransfer<>();
if (options.keepWorldConfig()) {
dataTransfer.addDataStore(new WorldConfigStore<>(), world);
}
if (options.keepGameRule()) {
dataTransfer.addDataStore(new GameRulesStore(), world);
}
if (options.keepWorldBorder()) {
dataTransfer.addDataStore(new WorldBorderStore(), world);
}
return dataTransfer;
}
private <T, F extends FailureReason> Attempt<T, F> worldActionResult(@NotNull T value) {
return Attempt.success(value);
}
@@ -946,12 +983,12 @@ public final class WorldManager {
* @return A list of all potential worlds.
*/
public List<String> getPotentialWorlds() {
File[] files = Bukkit.getWorldContainer().listFiles();
File[] files = BukkitCompatibility.getWorldFoldersDirectory().toFile().listFiles();
if (files == null) {
return Collections.emptyList();
}
return Arrays.stream(files)
.filter(file -> !isWorld(file.getName()))
.filter(file -> BukkitCompatibility.getWorldByNameOrKey(file.getName()).isEmpty())
.filter(worldNameChecker::isValidWorldFolder)
.map(File::getName)
.toList();
@@ -10,6 +10,7 @@ import org.bukkit.World;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.config.handle.StringPropertyHandle;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
/**
* A data store for storing and restoring data from an object.
@@ -90,14 +91,14 @@ public interface DataStore<T> {
/**
* A {@link DataStore} for storing and restoring world properties for a multiverse world.
*/
class WorldConfigStore implements DataStore<LoadedMultiverseWorld> {
class WorldConfigStore<T extends MultiverseWorld> implements DataStore<T> {
private Map<String, Object> configMap;
/**
* {@inheritDoc}
*/
@Override
public WorldConfigStore copyFrom(LoadedMultiverseWorld world) {
public WorldConfigStore<T> copyFrom(T world) {
this.configMap = new HashMap<>();
StringPropertyHandle worldPropertyHandler = world.getStringPropertyHandle();
worldPropertyHandler.getAllPropertyNames().forEach(name -> worldPropertyHandler.getProperty(name)
@@ -111,7 +112,7 @@ public interface DataStore<T> {
* {@inheritDoc}
*/
@Override
public WorldConfigStore pasteTo(LoadedMultiverseWorld world) {
public WorldConfigStore<T> pasteTo(T world) {
if (configMap == null) {
return this;
}
@@ -1,15 +1,17 @@
package org.mvplugins.multiverse.core.world.helpers;
import java.io.File;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Option;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.utils.REPatterns;
import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility;
/**
* <p>Utility class in helping to check the status of a world name and it's associated world folder.</p>
@@ -29,6 +31,18 @@ public final class WorldNameChecker {
"plugins",
"versions");
private static final List<WorldFolderSchema> WORLD_FOLDER_SCHEMA = List.of(
// OLD
WorldFolderSchema.file("level.dat"),
WorldFolderSchema.folder("DIM1"),
WorldFolderSchema.folder("DIM-1"),
// NEW
WorldFolderSchema.file("paper-world.yml"),
WorldFolderSchema.folder("data"),
WorldFolderSchema.folder("entities"),
WorldFolderSchema.folder("poi"),
WorldFolderSchema.folder("region"));
/**
* Checks if a world name is valid.
*
@@ -105,7 +119,8 @@ public final class WorldNameChecker {
if (worldName == null) {
return FolderStatus.DOES_NOT_EXIST;
}
File worldFolder = new File(Bukkit.getWorldContainer(), worldName);
File worldFolder = BukkitCompatibility.getWorldFoldersDirectory().resolve(worldName).toFile();
Logging.finer("Checking valid folder for world '%s' at: '%s'", worldName, worldFolder.getPath());
return checkFolder(worldFolder);
}
@@ -120,7 +135,7 @@ public final class WorldNameChecker {
if (worldFolder == null || !worldFolder.exists() || !worldFolder.isDirectory()) {
return FolderStatus.DOES_NOT_EXIST;
}
if (!folderHasDat(worldFolder)) {
if (!folderWorldSchemaCheck(worldFolder)) {
return FolderStatus.NOT_A_WORLD;
}
return FolderStatus.VALID;
@@ -133,9 +148,54 @@ public final class WorldNameChecker {
* @param worldFolder The File that may be a world.
* @return True if it looks like a world, else false.
*/
private boolean folderHasDat(@NotNull File worldFolder) {
File[] files = worldFolder.listFiles((file, name) -> name.toLowerCase(Locale.ENGLISH).endsWith(".dat"));
return files != null && files.length > 0;
private boolean folderWorldSchemaCheck(@NotNull File worldFolder) {
return WORLD_FOLDER_SCHEMA.stream()
.filter(schema -> schema.check(worldFolder))
.count() >= 2;
}
/**
* Helper class to check if a file or folder exist.
*/
private interface WorldFolderSchema {
static WorldFolderSchema file(String path) {
return new WorldFile(path);
}
static WorldFolderSchema folder(String path) {
return new WorldFolder(path);
}
boolean check(File worldFolder);
final class WorldFile implements WorldFolderSchema {
private final String path;
private WorldFile(String path) {
this.path = path;
}
@Override
public boolean check(File worldFolder) {
File thisFolder = worldFolder.toPath().resolve(path).toFile();
return thisFolder.exists() && thisFolder.isFile();
}
}
final class WorldFolder implements WorldFolderSchema {
private final String path;
private WorldFolder(String path) {
this.path = path;
}
@Override
public boolean check(File worldFolder) {
File thisFolder = worldFolder.toPath().resolve(path).toFile();
return thisFolder.exists() && thisFolder.isDirectory();
}
}
}
/**
@@ -1,7 +1,9 @@
package org.mvplugins.multiverse.core.world.options;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
/**
* Options for customizing the cloning of a world.
@@ -9,17 +11,35 @@ import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
public final class CloneWorldOptions implements KeepWorldSettingsOptions {
/**
* Creates a new {@link CloneWorldOptions} instance with the given world.
* Creates a new {@link CloneWorldOptions} instance with the given loaded world.
*
* @param world The world to clone.
* @param fromWorld The loaded world to clone.
* @param newWorldName The name of the new world.
* @return A new {@link CloneWorldOptions} instance.
*
* @deprecated Cloning can be done from unloaded worlds as well. Use {@link #fromTo(MultiverseWorld, String)} instead.
*/
public static CloneWorldOptions fromTo(LoadedMultiverseWorld world, String newWorldName) {
return new CloneWorldOptions(world, newWorldName);
@Deprecated(forRemoval = true, since = "5.6")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public static @NotNull CloneWorldOptions fromTo(@NotNull LoadedMultiverseWorld fromWorld, @NotNull String newWorldName) {
return new CloneWorldOptions(fromWorld, newWorldName);
}
private final LoadedMultiverseWorld world;
/**
* Creates a new {@link CloneWorldOptions} instance with the given world.
*
* @param fromWorld The world to clone.
* @param newWorldName The name of the new world.
* @return A new {@link CloneWorldOptions} instance.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
public static @NotNull CloneWorldOptions fromTo(@NotNull MultiverseWorld fromWorld, @NotNull String newWorldName) {
return new CloneWorldOptions(fromWorld, newWorldName);
}
private final MultiverseWorld fromWorld;
private final String newWorldName;
private boolean keepGameRule = true;
private boolean keepWorldConfig = true;
@@ -27,18 +47,34 @@ public final class CloneWorldOptions implements KeepWorldSettingsOptions {
private boolean keepWorldBorder = true;
CloneWorldOptions(LoadedMultiverseWorld world, String newWorldName) {
this.world = world;
CloneWorldOptions(MultiverseWorld fromWorld, String newWorldName) {
this.fromWorld = fromWorld;
this.newWorldName = newWorldName;
}
/**
* Gets the world to clone.
* Gets the loaded world to clone.
*
* @return The world to clone.
*
* @deprecated Cloning can be done from unloaded worlds as well. Use {@link #fromWorld()} instead.
*/
@Deprecated(forRemoval = true, since = "5.6")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public LoadedMultiverseWorld world() {
return world;
return fromWorld.asLoadedWorld().getOrNull();
}
/**
* Gets the loaded world to clone.
*
* @return The world to clone.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
public @NotNull MultiverseWorld fromWorld() {
return fromWorld;
}
/**
@@ -46,7 +82,7 @@ public final class CloneWorldOptions implements KeepWorldSettingsOptions {
*
* @return The name of the new world.
*/
public String newWorldName() {
public @NotNull String newWorldName() {
return newWorldName;
}
@@ -3,6 +3,7 @@ package org.mvplugins.multiverse.core.world.reasons;
import co.aikar.locales.MessageKey;
import co.aikar.locales.MessageKeyProvider;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.utils.result.FailureReason;
@@ -30,6 +31,14 @@ public enum CloneFailureReason implements FailureReason {
*/
WORLD_EXIST_LOADED(MVCorei18n.CLONEWORLD_WORLDEXISTLOADED),
/**
* The source world folder is invalid.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
FROM_WORLD_FOLDER_INVALID(MVCorei18n.CLONEWORLD_FROMWORLDFOLDERINVALID),
/**
* Failed to copy the world folder contents.
*/
@@ -273,6 +273,7 @@ mv-core.cloneworld.invalidworldname=World '{world}' contains invalid characters!
mv-core.cloneworld.worldexistfolder=World '{world}' exists in server folders! You need to delete it first before cloning.
mv-core.cloneworld.worldexistunloaded=World '{world}' already exists and it's unloaded! You need to delete it first before cloning.
mv-core.cloneworld.worldexistloaded=World '{world}' already exists! You need to delete it first before cloning.
mv-core.cloneworld.fromworldfolderinvalid=Source world '{world}' folder contents does not seem to be a valid world! Make sure it contains the correct world structure of data and region folders.
mv-core.cloneworld.copyfailed=Failed to copy world to '{world}': {error}\n&fSee console for more details.
mv-core.createworld.invalidworldname=World '{world}' contains invalid characters!
@@ -288,7 +289,7 @@ mv-core.deleteworld.failedtodeletefolder=Failed to delete world folder '{world}'
mv-core.importworld.invalidworldname=World '{world}' contains invalid characters!
mv-core.importworld.worldexistunloaded=World '{world}' already exists, but it's not loaded!&f Type '&a/mv load {world}&f' if you wish to load it.
mv-core.importworld.worldexistloaded=World '{world}' already exists!
mv-core.importworld.worldfolderinvalid=World '{world}' folder contents does not seem to be a valid world!\n&cIf the server software does something different with world folders, or you are very certain the world is valid, use '&3--skip-folder-check&c' flag.
mv-core.importworld.worldfolderinvalid=World '{world}' folder contents does not seem to be a valid world! Make sure it contains the correct world structure of data and region folders.\n&cIf the server software does something different with world folders, or you are very certain the world is valid, use '&3--skip-folder-check&c' flag.
mv-core.importworld.bukkitenvironmentmismatch=Environment mismatch detected!&f The world '{world}' is already loaded with environment '{bukkitEnvironment}', but Multiverse is trying to import it with environment '{mvEnvironment}'.
mv-core.loadworld.worldalreadyloading=World '{world}' is already loading! Please wait...
@@ -39,6 +39,8 @@ public class MVServerMock extends ServerMock {
world.getWorldFolder().mkdirs();
createFile(new File(world.getWorldFolder(), "uid.dat"));
createFile(new File(world.getWorldFolder(), "level.dat"));
new File(world.getWorldFolder(), "region").mkdir();
new File(world.getWorldFolder(), "data").mkdir();
addWorld(world);
return world;
}
@@ -7,7 +7,15 @@ import org.hamcrest.MatcherAssert.assertThat
import org.mockbukkit.mockbukkit.matcher.plugin.PluginManagerFiredEventClassMatcher.hasFiredEventInstance
import org.mockbukkit.mockbukkit.matcher.plugin.PluginManagerFiredEventClassMatcher.hasNotFiredEventInstance
import org.mvplugins.multiverse.core.TestWithMockBukkit
import org.mvplugins.multiverse.core.event.world.*
import org.mvplugins.multiverse.core.event.world.MVWorldClonedEvent
import org.mvplugins.multiverse.core.event.world.MVWorldCreatedEvent
import org.mvplugins.multiverse.core.event.world.MVWorldDeleteEvent
import org.mvplugins.multiverse.core.event.world.MVWorldImportedEvent
import org.mvplugins.multiverse.core.event.world.MVWorldLoadedEvent
import org.mvplugins.multiverse.core.event.world.MVWorldPropertyChangedEvent
import org.mvplugins.multiverse.core.event.world.MVWorldRegeneratedEvent
import org.mvplugins.multiverse.core.event.world.MVWorldRemovedEvent
import org.mvplugins.multiverse.core.event.world.MVWorldUnloadedEvent
import org.mvplugins.multiverse.core.world.options.CloneWorldOptions
import org.mvplugins.multiverse.core.world.options.CreateWorldOptions
import org.mvplugins.multiverse.core.world.options.DeleteWorldOptions
@@ -190,6 +198,8 @@ class WorldManagerTest : TestWithMockBukkit() {
fun `Load world failed - world folder exists but not imported`() {
File(Bukkit.getWorldContainer(), "worldfolder").mkdir()
File(Bukkit.getWorldContainer(), "worldfolder/level.dat").createNewFile()
File(Bukkit.getWorldContainer(), "worldfolder/data").mkdir()
File(Bukkit.getWorldContainer(), "worldfolder/region").mkdir()
assertEquals(
LoadFailureReason.WORLD_EXIST_FOLDER,
worldManager.loadWorld("worldfolder").failureReason
@@ -270,8 +280,10 @@ class WorldManagerTest : TestWithMockBukkit() {
fun `Get potential worlds`() {
File(Bukkit.getWorldContainer(), "newworld1").mkdir()
File(Bukkit.getWorldContainer(), "newworld1/level.dat").createNewFile()
File(Bukkit.getWorldContainer(), "newworld1/data").mkdir()
File(Bukkit.getWorldContainer(), "newworld2").mkdir()
File(Bukkit.getWorldContainer(), "newworld2/level.dat").createNewFile()
File(Bukkit.getWorldContainer(), "newworld2/data").mkdir()
assertEquals(setOf("newworld1", "newworld2"), worldManager.getPotentialWorlds().toSet())
}
@@ -45,6 +45,15 @@ class WorldNameCheckerTest : TestWithMockBukkit() {
fun `Valid world folder`() {
File(Bukkit.getWorldContainer(), "test").mkdir()
File(Bukkit.getWorldContainer(), "test/level.dat").createNewFile()
File(Bukkit.getWorldContainer(), "test/data").mkdir()
assertEquals(WorldNameChecker.FolderStatus.VALID, worldNameChecker.checkFolder("test"))
}
@Test
fun `Valid world folder v26-1 format`() {
File(Bukkit.getWorldContainer(), "test").mkdir()
File(Bukkit.getWorldContainer(), "test/region").mkdir()
File(Bukkit.getWorldContainer(), "test/data").mkdir()
assertEquals(WorldNameChecker.FolderStatus.VALID, worldNameChecker.checkFolder("test"))
}