From 192ee269fa59c1e1aef62401887359efb52caafa Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Fri, 1 May 2026 12:10:42 +0800 Subject: [PATCH 01/26] Refactor compatibility utilities to use Try for method and class retrieval --- .../DynamicListenerRegistration.java | 41 ++-- .../multiverse/core/utils/ReflectHelper.java | 209 +++++++++++++++++- .../core/utils/WorldTickDeferrer.java | 42 ++-- .../compatibility/BukkitCompatibility.java | 24 +- .../compatibility/WorldCompatibility.java | 46 ++++ .../multiverse/core/world/WorldManager.java | 16 +- .../world/entity/SpawnCategoryMapper.java | 36 +-- 7 files changed, 313 insertions(+), 101 deletions(-) create mode 100644 src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCompatibility.java diff --git a/src/main/java/org/mvplugins/multiverse/core/dynamiclistener/DynamicListenerRegistration.java b/src/main/java/org/mvplugins/multiverse/core/dynamiclistener/DynamicListenerRegistration.java index dec5094b3..819526372 100644 --- a/src/main/java/org/mvplugins/multiverse/core/dynamiclistener/DynamicListenerRegistration.java +++ b/src/main/java/org/mvplugins/multiverse/core/dynamiclistener/DynamicListenerRegistration.java @@ -35,7 +35,7 @@ public final class DynamicListenerRegistration { private static final boolean hasEventExecutorCreate; static { - hasEventExecutorCreate = ReflectHelper.getMethod(EventExecutor.class, "create", Method.class, Class.class) != null; + hasEventExecutorCreate = ReflectHelper.hasMethod(EventExecutor.class, "create", Method.class, Class.class); } private final EventPriorityMapper eventPriorityMapper; @@ -98,18 +98,22 @@ private void registerAsEventClass(DynamicListener listener, Plugin plugin, Metho } method.setAccessible(true); - Object methodOutput = ReflectHelper.invokeMethod(listener, method); - if (!(methodOutput instanceof EventRunnable eventRunnable)) { - Logging.warning("Event method %s in %s did not return a SingleEventListener", - method.getName(), listener.getClass().getName()); - return; - } - EventExecutor executor = new EventRunnableExecutor<>(eventClass, eventRunnable); - EventPriority priority = getDynamicEventPriority(method); - boolean ignoreCancelled = isIgnoreIfCancelled(method); - Logging.finest("Registering dynamic event for %s with priority %s", eventClass.getName(), priority); - Bukkit.getPluginManager().registerEvent(eventClass, listener, priority, executor, plugin, ignoreCancelled); + ReflectHelper.tryInvokeMethod(listener, method) + .onFailure(e -> Logging.warning("Failed to invoke event method %s in %s: $s", + method.getName(), listener.getClass().getName(), e.getMessage())) + .filter(EventRunnable.class::isInstance) + .onFailure(e -> Logging.warning("Event method %s in %s did not return an EventRunnable instance!", + method.getName(), listener.getClass().getName())) + .map(EventRunnable.class::cast) + .map(eventRunnable -> new EventRunnableExecutor<>(eventClass, eventRunnable)) + .peek(executor -> { + EventPriority priority = getDynamicEventPriority(method); + boolean ignoreCancelled = isIgnoreIfCancelled(method); + Bukkit.getPluginManager().registerEvent(eventClass, listener, priority, executor, plugin, ignoreCancelled); + Logging.finest("Registered dynamic event: %s, priority: %s, ignore if cancelled: %s", + eventClass.getName(), priority, ignoreCancelled); + }); } private Class getEventClass(Method method) { @@ -129,13 +133,12 @@ private Class getSkipIfEventExist(Method method) { } private Class getEventClassFromString(String className) { - Class annotatedClass = ReflectHelper.getClass(className); - if (annotatedClass == null || !Event.class.isAssignableFrom(annotatedClass)) { - // Usually means the server software used did not implement the event - Logging.fine("Event class does not exist: %s", className); - return null; - } - return annotatedClass.asSubclass(Event.class); + return ReflectHelper.tryGetClass(className) + .onFailure(throwable -> Logging.fine("Failed to find event class: %s", className)) + .filter(Event.class::isAssignableFrom) + .onFailure(throwable -> Logging.warning("Class is not an Event: %s", className)) + .map(clazz -> (Class) clazz.asSubclass(Event.class)) + .getOrNull(); } private EventPriority getDynamicEventPriority(Method method) { diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/ReflectHelper.java b/src/main/java/org/mvplugins/multiverse/core/utils/ReflectHelper.java index cb5836420..25cf52cd9 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/ReflectHelper.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/ReflectHelper.java @@ -3,6 +3,8 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; +import io.vavr.control.Try; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -15,15 +17,14 @@ public final class ReflectHelper { * Try to get the {@link Class} based on its classpath. * * @param classPath The target classpath. - * @return A {@link Class} if found, else null. + * @return A {@link Try} containing the {@link Class} if found, else a failure. + * + * @since 5.7 */ - @Nullable - public static Class getClass(String classPath) { - try { - return Class.forName(classPath); - } catch (ClassNotFoundException e) { - return null; - } + @ApiStatus.AvailableSince("5.7") + @NotNull + public static Try> tryGetClass(@NotNull String classPath) { + return Try.of(() -> Class.forName(classPath)); } /** @@ -33,7 +34,168 @@ public static Class getClass(String classPath) { * @return True if class path is a valid class, else false. */ public static boolean hasClass(String classPath) { - return getClass(classPath) != null; + return tryGetClass(classPath).isSuccess(); + } + + /** + * Try to get a {@link Method} from a given class. + * + * @param clazz The class to search the method on. + * @param methodName Name of the method to get. + * @param parameterTypes Parameters present for that method. + * @param The class type. + * @return A {@link Try} containing the {@link Method} if found, else a failure. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public static Try tryGetMethod(@NotNull Class clazz, @NotNull String methodName, Class... parameterTypes) { + return Try.of(() -> clazz.getMethod(methodName, parameterTypes)) + .orElse(Try.of(() -> { + Method method = clazz.getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + return method; + })); + } + + /** + * Check if a {@link Method} exists on a given class. + * + * @param clazz The class to search the method on. + * @param methodName Name of the method to check. + * @param parameterTypes Parameters present for that method. + * @return True if method exists, else false. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static boolean hasMethod(@NotNull Class clazz, @NotNull String methodName, Class... parameterTypes) { + return tryGetMethod(clazz, methodName, parameterTypes).isSuccess(); + } + + /** + * Try to invoke a {@link Method} on a class instance. + * + * @param classInstance Instance of the class responsible for the method. + * @param method The method to invoke. + * @param parameters Parameters needed when invoking the method. + * @param The class type. + * @param The return type of the method. + * @return A {@link Try} containing the return value of the method if found, else a failure. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + @SuppressWarnings("unchecked") + public static Try tryInvokeMethod(@NotNull C classInstance, @NotNull Method method, Object...parameters) { + return Try.of(() -> (R) method.invoke(classInstance, parameters)); + } + + /** + * Try to invoke a static {@link Method}. + * + * @param method The static method to invoke. + * @param parameters Parameters needed when invoking the method. + * @param The return type of the method. + * @return A {@link Try} containing the return value of the method if found, else a failure. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + @SuppressWarnings("unchecked") + public static Try tryInvokeStaticMethod(@NotNull Method method, Object...parameters) { + return Try.of(() -> (R) method.invoke(null, parameters)); + } + + /** + * Try to get a {@link Field} from a given class. + * + * @param clazz The class to search the field on. + * @param fieldName Name of the field to get. + * @param The class type. + * @return A {@link Try} containing the {@link Field} if found, else a failure. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public static Try tryGetField(@NotNull Class clazz, @NotNull String fieldName) { + return Try.of(() -> clazz.getField(fieldName)) + .orElse(Try.of(() -> { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + return field; + })); + } + + /** + * Check if a {@link Field} exists on a given class. + * + * @param clazz The class to search the field on. + * @param fieldName Name of the field to check. + * @return True if field exists, else false. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static boolean hasField(@NotNull Class clazz, @NotNull String fieldName) { + return tryGetField(clazz, fieldName).isSuccess(); + } + + /** + * Try to get the value of a {@link Field} from an instance of the class responsible. + * + * @param classInstance Instance of the class to get the field value from. + * @param field The field to get the value from. + * @param fieldType Type of the field. + * @param The class type. + * @param The field value type. + * @return A {@link Try} containing the field value if found, else a failure. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public static Try tryGetFieldValue(@NotNull C classInstance, @NotNull Field field, @NotNull Class fieldType) { + return Try.of(() -> fieldType.cast(field.get(classInstance))); + } + + /** + * Try to get the value of a static {@link Field}. + * + * @param field The static field to get the value from. + * @param fieldType Type of the field. + * @param The field value type. + * @return A {@link Try} containing the field value if found, else a failure. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public static Try tryGetStaticFieldValue(@NotNull Field field, @NotNull Class fieldType) { + return Try.of(() -> fieldType.cast(field.get(null))); + } + + /** + * Try to get the {@link Class} based on its classpath. + * + * @param classPath The target classpath. + * @return A {@link Class} if found, else null. + * + * @deprecated Use {@link #tryGetClass(String)} instead, which returns a {@link Try} that can be used to handle + * the failure case more explicitly. + */ + @Deprecated(forRemoval = true, since = "5.7") + @Nullable + public static Class getClass(String classPath) { + try { + return Class.forName(classPath); + } catch (ClassNotFoundException e) { + return null; + } } /** @@ -44,7 +206,11 @@ public static boolean hasClass(String classPath) { * @param parameterTypes Parameters present for that method. * @param The class type. * @return A {@link Method} if found, else null. + * + * @deprecated Use {@link #tryGetMethod(Class, String, Class[])} instead, which returns a {@link Try} that can be + * used to handle the failure case more explicitly. */ + @Deprecated(forRemoval = true, since = "5.7") @Nullable public static Method getMethod(Class clazz, String methodName, Class... parameterTypes) { try { @@ -64,7 +230,11 @@ public static Method getMethod(Class clazz, String methodName, Class.. * @param parameterTypes Parameters present for that method. * @param The class type. * @return A {@link Method} if found, else null. + * + * @deprecated Use {@link #tryGetMethod(Class, String, Class[])} instead, which returns a {@link Try} that can be + * used to handle the failure case more explicitly. */ + @Deprecated(forRemoval = true, since = "5.7") @Nullable public static Method getMethod(C classInstance, String methodName, Class... parameterTypes) { return getMethod(classInstance.getClass(), methodName, parameterTypes); @@ -79,8 +249,13 @@ public static Method getMethod(C classInstance, String methodName, Class. * @param The class type. * @param The return type. * @return Return value of the method call if any, else null. + * + * @deprecated Use {@link #tryInvokeMethod(Object, Method, Object...)} instead, which returns a {@link Try} that can + * be used to handle the failure case more explicitly. */ + @Deprecated(forRemoval = true, since = "5.7") @Nullable + @SuppressWarnings("unchecked") public static R invokeMethod(C classInstance, Method method, Object...parameters) { try { return (R) method.invoke(classInstance, parameters); @@ -96,7 +271,11 @@ public static R invokeMethod(C classInstance, Method method, Object...par * @param fieldName Name of the field to get. * @param The class type. * @return A {@link Field} if found, else null. + * + * @deprecated Use {@link #tryGetField(Class, String)} instead, which returns a {@link Try} that can be used to + * handle the failure case more explicitly. */ + @Deprecated(forRemoval = true, since = "5.7") @Nullable public static Field getField(Class clazz, String fieldName) { try { @@ -115,7 +294,11 @@ public static Field getField(Class clazz, String fieldName) { * @param fieldName Name of the field to get. * @param The class type. * @return A {@link Field} if found, else null. + * + * @deprecated Use {@link #tryGetField(Class, String)} instead, which returns a {@link Try} that can be used to + * handle the failure case more explicitly. */ + @Deprecated(forRemoval = true, since = "5.7") @Nullable public static Field getField(C classInstance, String fieldName) { return getField(classInstance.getClass(), fieldName); @@ -130,7 +313,11 @@ public static Field getField(C classInstance, String fieldName) { * @param The class type. * @param The field value type. * @return The field value if any, else null. + * + * @deprecated Use {@link #tryGetFieldValue(Object, Field, Class)} instead, which returns a {@link Try} that can be + * used to handle the failure case more explicitly. */ + @Deprecated(forRemoval = true, since = "5.7") @Nullable public static V getFieldValue(C classInstance, @Nullable Field field, @NotNull Class fieldType) { try { @@ -153,7 +340,11 @@ public static V getFieldValue(C classInstance, @Nullable Field field, @No * @param The class type. * @param The field value type. * @return The field value if any, else null. + * + * @deprecated Use {@link #tryGetField(Class, String)} then map to {@link #tryGetFieldValue(Object, Field, Class)} instead, + * which returns a {@link Try} that can be used to handle the failure case more explicitly. */ + @Deprecated(forRemoval = true, since = "5.7") @Nullable public static V getFieldValue(C classInstance, @Nullable String fieldName, @NotNull Class fieldType) { return getFieldValue(classInstance, getField(classInstance, fieldName), fieldType); diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/WorldTickDeferrer.java b/src/main/java/org/mvplugins/multiverse/core/utils/WorldTickDeferrer.java index 376dacbae..a5c0ffea3 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/WorldTickDeferrer.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/WorldTickDeferrer.java @@ -1,7 +1,7 @@ package org.mvplugins.multiverse.core.utils; import com.dumptruckman.minecraft.util.Logging; -import io.vavr.control.Try; +import io.vavr.control.Option; import jakarta.inject.Inject; import org.bukkit.Server; import org.bukkit.scheduler.BukkitRunnable; @@ -10,8 +10,6 @@ import org.mvplugins.multiverse.core.MultiverseCore; import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.Objects; /** * Defers action that cannot be done during world tick. @@ -21,26 +19,20 @@ public final class WorldTickDeferrer { private final MultiverseCore plugin; - private Object console = null; - private Field isIteratingOverLevelsMethod = null; + private final Option console; + private final Option isIteratingOverLevelsMethod; @Inject WorldTickDeferrer(@NotNull MultiverseCore plugin, @NotNull Server server) { this.plugin = plugin; - Method getServerMethod = ReflectHelper.getMethod(server.getClass(), "getServer"); - if (getServerMethod == null) { - Logging.fine("Unable to find getServer method."); - return; - } - this.console = ReflectHelper.invokeMethod(server, getServerMethod); - if (console == null) { - Logging.fine("Unable to find console."); - return; - } - this.isIteratingOverLevelsMethod = Try.of(() -> console.getClass().getField("isIteratingOverLevels")).getOrNull(); - if (isIteratingOverLevelsMethod == null) { - Logging.fine("Unable to find isIteratingOverLevels field."); - } + this.console = ReflectHelper.tryGetMethod(server.getClass(), "getServer") + .onFailure(throwable -> Logging.fine("Unable to find getServer method.")) + .flatMap(getServerMethod -> ReflectHelper.tryInvokeMethod(server, getServerMethod)) + .onFailure(throwable -> Logging.fine("Unable to find console.")) + .toOption(); + this.isIteratingOverLevelsMethod = ReflectHelper.tryGetField(console.getClass(), "isIteratingOverLevels") + .onFailure(throwable -> Logging.fine("Unable to find isIteratingOverLevels field.")) + .toOption(); } /** @@ -63,16 +55,14 @@ public void run() { } /** - * Check if the the server is currently doing a world tick. + * Check if the server is currently doing a world tick. * * @return True if the server is currently doing a world tick */ private boolean isIteratingOverLevels() { - if (console == null || isIteratingOverLevelsMethod == null) { - return false; - } - return Objects.requireNonNullElse( - ReflectHelper.getFieldValue(console, isIteratingOverLevelsMethod, Boolean.class), - false); + return isIteratingOverLevelsMethod + .flatMap(field -> console + .flatMap(c -> ReflectHelper.tryGetFieldValue(c, field, Boolean.class).toOption())) + .getOrElse(false); } } diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/BukkitCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/BukkitCompatibility.java index 8cb1c219b..1c3e49429 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/BukkitCompatibility.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/BukkitCompatibility.java @@ -1,6 +1,7 @@ package org.mvplugins.multiverse.core.utils.compatibility; import io.vavr.control.Option; +import io.vavr.control.Try; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.Server; @@ -20,12 +21,12 @@ @ApiStatus.AvailableSince("5.6") public final class BukkitCompatibility { - private static final Option GET_LEVEL_DIRECTORY_METHOD; - private static final Option GET_WORLD_NAMESPACED_KEY_METHOD; + private static final Try GET_LEVEL_DIRECTORY_METHOD; + private static final Try 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)); + GET_LEVEL_DIRECTORY_METHOD = ReflectHelper.tryGetMethod(Server.class, "getLevelDirectory"); + GET_WORLD_NAMESPACED_KEY_METHOD = ReflectHelper.tryGetMethod(Bukkit.class, "getWorld", NamespacedKey.class); } /** @@ -41,8 +42,8 @@ public final class BukkitCompatibility { @ApiStatus.AvailableSince("5.6") public static boolean isUsingNewDimensionStorage() { return GET_LEVEL_DIRECTORY_METHOD - .flatMap(method -> Option.of(ReflectHelper.invokeMethod(Bukkit.getServer(), method))) - .isDefined(); + .flatMap(method -> ReflectHelper.tryInvokeMethod(Bukkit.getServer(), method)) + .isSuccess(); } /** @@ -60,7 +61,7 @@ public static boolean isUsingNewDimensionStorage() { @NotNull public static Path getWorldFoldersDirectory() { Server server = Bukkit.getServer(); - return GET_LEVEL_DIRECTORY_METHOD.map(method -> ReflectHelper.invokeMethod(server, method)) + return GET_LEVEL_DIRECTORY_METHOD.flatMap(method -> ReflectHelper.tryInvokeMethod(server, method)) .filter(Path.class::isInstance) .map(Path.class::cast) .map(path -> path.resolve("dimensions/minecraft")) @@ -84,8 +85,13 @@ public static Path getWorldFoldersDirectory() { public static Option 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))) + .flatMap(method -> ReflectHelper.tryInvokeStaticMethod(method, NamespacedKey.fromString(nameOrKey))) .filter(World.class::isInstance) - .map(World.class::cast)); + .map(World.class::cast) + .toOption()); + } + + private BukkitCompatibility() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } } diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCompatibility.java new file mode 100644 index 000000000..3f574d011 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCompatibility.java @@ -0,0 +1,46 @@ +package org.mvplugins.multiverse.core.utils.compatibility; + +import io.vavr.control.Try; +import org.bukkit.World; +import org.jetbrains.annotations.ApiStatus; +import org.mvplugins.multiverse.core.utils.ReflectHelper; + +import java.lang.reflect.Method; + +/** + * Compatibility class used to handle API changes in {@link World} class. + * + * @since 5.7 + */ +@ApiStatus.AvailableSince("5.7") +public final class WorldCompatibility { + + private static final Try SAVE_WITH_FLUSH_METHOD; + + static { + SAVE_WITH_FLUSH_METHOD = ReflectHelper.tryGetMethod(World.class, "save", boolean.class); + } + + /** + * Saves the world, with an option to wait for chunk writers to finish if the method is available. Saving with + * flush is only supported on PaperMC 1.21+. + *
+ * If the method is not available, it will fall back to the normal save method, which may not wait for chunk writers + * to finish, but is the best we can do for older versions of Minecraft. + * + * @param world The world to save + * @param flush Waits for chunk writers to finish + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static void saveWithFlush(World world, boolean flush) { + SAVE_WITH_FLUSH_METHOD + .flatMap(method -> ReflectHelper.tryInvokeMethod(world, method, flush)) + .orElseRun(ignore -> world.save()); + } + + private WorldCompatibility() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java index 0639bf2c9..72354d20b 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java @@ -1,7 +1,6 @@ package org.mvplugins.multiverse.core.world; import java.io.File; -import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -49,9 +48,9 @@ import org.mvplugins.multiverse.core.teleportation.BlockSafety; 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.compatibility.WorldCompatibility; import org.mvplugins.multiverse.core.utils.result.Attempt; import org.mvplugins.multiverse.core.utils.result.FailureReason; import org.mvplugins.multiverse.core.utils.FileUtils; @@ -788,7 +787,7 @@ private Attempt cloneWorldCopyFolder(@Not if (options.saveBukkitWorld()) { options.fromWorld().asLoadedWorld().peek(loadedWorld -> { Logging.finer("Saving world before cloning: " + loadedWorld.getName()); - loadedWorld.getBukkitWorld().peek(this::saveWorldWithFlush); + loadedWorld.getBukkitWorld().peek(bukkitWorld -> WorldCompatibility.saveWithFlush(bukkitWorld, true)); }); } File worldFolder = BukkitCompatibility.getWorldFoldersDirectory().resolve(options.fromWorld().getName()).toFile(); @@ -799,17 +798,6 @@ private Attempt cloneWorldCopyFolder(@Not success -> worldActionResult(options)); } - // This method is only available since 1.21 - private final Method saveWithFlush = ReflectHelper.getMethod(World.class, "save", boolean.class); - private void saveWorldWithFlush(World world) { - if (saveWithFlush != null) { - Logging.fine("Using world save method with flush..."); - ReflectHelper.invokeMethod(world, saveWithFlush, true); - } else { - world.save(); - } - } - private void cloneWorldTransferData(@NotNull CloneWorldOptions options, @NotNull LoadedMultiverseWorld newWorld) { if (options.keepWorldConfig()) { new DataTransfer() diff --git a/src/main/java/org/mvplugins/multiverse/core/world/entity/SpawnCategoryMapper.java b/src/main/java/org/mvplugins/multiverse/core/world/entity/SpawnCategoryMapper.java index 3ed079130..00d233868 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/entity/SpawnCategoryMapper.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/entity/SpawnCategoryMapper.java @@ -4,10 +4,8 @@ import io.vavr.control.Try; import org.bukkit.entity.EntityType; import org.bukkit.entity.SpawnCategory; -import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.utils.ReflectHelper; -import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; @@ -30,17 +28,17 @@ final class SpawnCategoryMapper { private static void buildSpawnCategoryMap() { spawnCategoryMap = new HashMap<>(); - Class entityTypeClass = ReflectHelper.getClass("net.minecraft.world.entity.EntityType"); + Class entityTypeClass = ReflectHelper.tryGetClass("net.minecraft.world.entity.EntityType").getOrNull(); if (entityTypeClass == null) { Logging.warning("Failed to find EntityType class. SpawnCategoryMapper will not work."); return; } - Method getCategoryMethod = ReflectHelper.getMethod(entityTypeClass, "getCategory"); + Method getCategoryMethod = ReflectHelper.tryGetMethod(entityTypeClass, "getCategory").getOrNull(); if (getCategoryMethod == null) { Logging.warning("Failed to find getCategory method. SpawnCategoryMapper will not work."); return; } - Class craftSpawnCategoryClass = ReflectHelper.getClass("org.bukkit.craftbukkit.util.CraftSpawnCategory"); + Class craftSpawnCategoryClass = ReflectHelper.tryGetClass("org.bukkit.craftbukkit.util.CraftSpawnCategory").getOrNull(); if (craftSpawnCategoryClass == null) { Logging.warning("Failed to find CraftSpawnCategory class. SpawnCategoryMapper will not work."); return; @@ -53,25 +51,15 @@ private static void buildSpawnCategoryMap() { Logging.warning("Failed to find toBukkit method. SpawnCategoryMapper will not work."); return; } - Field[] entityTypeFields = entityTypeClass.getFields(); - for (Field entityTypeField : entityTypeFields) { - String entityName = entityTypeField.getName(); - EntityType entityType = Try.of(() -> EntityType.valueOf(entityName)).getOrNull(); - if (entityType == null) { - continue; - } - Object nmsEntityType = ReflectHelper.getFieldValue(null, entityTypeField, entityTypeClass); - Object nsmMobCategory = ReflectHelper.invokeMethod(nmsEntityType, getCategoryMethod); - if (nsmMobCategory == null) { - continue; - } - Object bukkitSpawnCategory = ReflectHelper.invokeMethod(null, toBukkitMethod, nsmMobCategory); - if (!(bukkitSpawnCategory instanceof SpawnCategory spawnCategory)) { - continue; - } - spawnCategoryMap.computeIfAbsent(spawnCategory, ignore -> new ArrayList<>()) - .add(entityType); - } + Arrays.stream(entityTypeClass.getFields()).forEach(entityTypeField -> Try.of(() -> EntityType.valueOf(entityTypeField.getName())) + .peek(entityType -> ReflectHelper.tryGetStaticFieldValue(entityTypeField, entityTypeClass) + .flatMap(nmsEntityType -> ReflectHelper.tryInvokeMethod(nmsEntityType, getCategoryMethod)) + .flatMap(nsmMobCategory -> ReflectHelper.tryInvokeStaticMethod(toBukkitMethod, nsmMobCategory)) + .filter(bukkitSpawnCategory -> bukkitSpawnCategory instanceof SpawnCategory) + .map(bukkitSpawnCategory -> (SpawnCategory) bukkitSpawnCategory) + .peek(bukkitSpawnCategory -> spawnCategoryMap + .computeIfAbsent(bukkitSpawnCategory, ignore -> new ArrayList<>()) + .add(entityType)))); } /** From 8a1bbebb0b2478e4bc7f880ef1cd749d8bbb3a3d Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Fri, 1 May 2026 13:53:22 +0800 Subject: [PATCH 02/26] Refactor long chained if-else to use Attempt#failIf --- .../multiverse/core/utils/result/Attempt.java | 84 ++++++++++++++++++- .../multiverse/core/world/WorldManager.java | 60 ++++++------- 2 files changed, 108 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/result/Attempt.java b/src/main/java/org/mvplugins/multiverse/core/utils/result/Attempt.java index 553a0c6b6..80d30fbc9 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/result/Attempt.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/result/Attempt.java @@ -2,6 +2,7 @@ import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; import java.util.function.Supplier; import io.vavr.control.Either; @@ -26,8 +27,11 @@ public sealed interface Attempt permits Attempt.Succ * @param The type of the value. * @param The type of failure reason. * @return The new success attempt. + * + * @since 5.7 */ - static Attempt success(T value) { + @ApiStatus.AvailableSince("5.7") + static Attempt.Success successRef(T value) { return new Success<>(value); } @@ -39,12 +43,41 @@ static Attempt success(T value) { * @param The type of the value. * @param The type of failure reason. * @return The new failure attempt. + * + * @since 5.7 */ - static Attempt failure( + @ApiStatus.AvailableSince("5.7") + static Attempt.Failure failureRef( F failureReason, MessageReplacement... messageReplacements) { return new Failure<>(failureReason, Message.of(failureReason, "Failed!", messageReplacements)); } + /** + * Creates a new success attempt. + * + * @param value The value. + * @param The type of the value. + * @param The type of failure reason. + * @return The new success attempt. + */ + static Attempt success(T value) { + return successRef(value); + } + + /** + * Creates a new failure attempt. + * + * @param failureReason The reason for failure. + * @param messageReplacements The replacements for the failure message. + * @param The type of the value. + * @param The type of failure reason. + * @return The new failure attempt. + */ + static Attempt failure( + F failureReason, MessageReplacement... messageReplacements) { + return failureRef(failureReason, messageReplacements); + } + /** * Creates a new failure attempt with a custom message. * @@ -203,6 +236,33 @@ default Attempt thenRun(Runnable runnable) { */ Attempt mapAttempt(Supplier> mapper); + /** + * Fails this attempt with the given failure attempt if the value matches the given predicate.This will do nothing + * if this is already a failure attempt. + * + * @param predicate The predicate to test the value. + * @param failureAttempt The failure attempt to return if the predicate matches. + * @return The new attempt + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + Attempt failIf(Predicate predicate, Supplier> failureAttempt); + + /** + * Fails this attempt with the given failure attempt if the value matches the given predicate.This will do nothing + * if this is already a failure attempt. + * + * @param predicate The predicate to test the value. + * @param failureAttempt The failure attempt to return if the predicate matches, which can be generated based + * on the value. + * @return The new attempt + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + Attempt failIf(Predicate predicate, Function> failureAttempt); + /** * Maps to another attempt with a different fail reason. * @@ -370,6 +430,16 @@ public Attempt mapAttempt(Supplier> mapper) { return mapper.get(); } + @Override + public Attempt failIf(Predicate predicate, Supplier> failureAttempt) { + return predicate.test(value) ? failureAttempt.get() : this; + } + + @Override + public Attempt failIf(Predicate predicate, Function> failureAttempt) { + return predicate.test(value) ? failureAttempt.apply(value) : this; + } + @Override public Attempt transform(UF failureReason) { return changeFailureType(); @@ -532,6 +602,16 @@ public Attempt mapAttempt(Supplier> mapper) { return changeValueType(); } + @Override + public Attempt failIf(Predicate predicate, Supplier> failureAttempt) { + return this; + } + + @Override + public Attempt failIf(Predicate predicate, Function> failureAttempt) { + return this; + } + @Override public Attempt transform(UF failureReason) { return new Failure<>(failureReason, getFailureMessage(), this); diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java index 0639bf2c9..3a10dbf4b 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java @@ -252,16 +252,15 @@ public Attempt createWorld(CreateWor private Attempt validateCreateWorldOptions( CreateWorldOptions options) { - if (!worldNameChecker.isValidWorldName(options.worldName())) { - return worldActionResult(CreateFailureReason.INVALID_WORLDNAME, options.worldName()); - } else if (getLoadedWorld(options.worldName()).isDefined()) { - return worldActionResult(CreateFailureReason.WORLD_EXIST_LOADED, options.worldName()); - } else if (getWorld(options.worldName()).isDefined()) { - return worldActionResult(CreateFailureReason.WORLD_EXIST_UNLOADED, options.worldName()); - } else if (options.doFolderCheck() && worldNameChecker.hasWorldFolder(options.worldName())) { - return worldActionResult(CreateFailureReason.WORLD_EXIST_FOLDER, options.worldName()); - } - return worldActionResult(options); + return Attempt.success(options) + .failIf(opts -> !worldNameChecker.isValidWorldName(opts.worldName()), + opts -> worldActionResult(CreateFailureReason.INVALID_WORLDNAME, opts.worldName())) + .failIf(opts -> getLoadedWorld(opts.worldName()).isDefined(), + opts -> worldActionResult(CreateFailureReason.WORLD_EXIST_LOADED, opts.worldName())) + .failIf(opts -> getWorld(opts.worldName()).isDefined(), + opts -> worldActionResult(CreateFailureReason.WORLD_EXIST_UNLOADED, opts.worldName())) + .failIf(opts -> opts.doFolderCheck() && worldNameChecker.hasWorldFolder(opts.worldName()), + opts -> worldActionResult(CreateFailureReason.WORLD_EXIST_FOLDER, opts.worldName())); } private Attempt doCreateWorld( @@ -762,26 +761,19 @@ public Attempt cloneWorld(@NotNull Cl private Attempt cloneWorldValidateWorld( @NotNull CloneWorldOptions options) { - String newWorldName = options.newWorldName(); - if (!worldNameChecker.isValidWorldName(newWorldName)) { - Logging.severe("Invalid world name: " + newWorldName); - return worldActionResult(CloneFailureReason.INVALID_WORLDNAME, newWorldName); - } - if (isLoadedWorld(newWorldName)) { - Logging.severe("World already loaded when attempting to clone: " + newWorldName); - return worldActionResult(CloneFailureReason.WORLD_EXIST_LOADED, newWorldName); - } - if (isWorld(newWorldName)) { - Logging.severe("World already exist unloaded: " + newWorldName); - return worldActionResult(CloneFailureReason.WORLD_EXIST_UNLOADED, newWorldName); - } - 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); + return Attempt.success(options.newWorldName()) + .failIf(name -> !worldNameChecker.isValidWorldName(name), + name -> worldActionResult(CloneFailureReason.INVALID_WORLDNAME, name)) + .failIf(this::isLoadedWorld, + name -> worldActionResult(CloneFailureReason.WORLD_EXIST_LOADED, name)) + .failIf(this::isWorld, + name -> worldActionResult(CloneFailureReason.WORLD_EXIST_UNLOADED, name)) + .failIf(worldNameChecker::hasWorldFolder, + name -> worldActionResult(CloneFailureReason.WORLD_EXIST_FOLDER, name)) + .failIf(name -> !worldNameChecker.isValidWorldFolder(options.fromWorld().getName()), + name -> worldActionResult(CloneFailureReason.FROM_WORLD_FOLDER_INVALID, + options.fromWorld().getName())) + .map(ignored -> options); } private Attempt cloneWorldCopyFolder(@NotNull CloneWorldOptions options) { @@ -895,14 +887,14 @@ private Attempt worldActionResult(@NotNull T return Attempt.success(value); } - private Attempt worldActionResult( + private Attempt.Failure worldActionResult( @NotNull F failureReason, @NotNull String worldName) { - return Attempt.failure(failureReason, Replace.WORLD.with(worldName)); + return Attempt.failureRef(failureReason, Replace.WORLD.with(worldName)); } - private Attempt worldActionResult( + private Attempt.Failure worldActionResult( @NotNull F failureReason, @NotNull String worldName, @NotNull Throwable error) { - return Attempt.failure(failureReason, Replace.WORLD.with(worldName), Replace.ERROR.with(error)); + return Attempt.failureRef(failureReason, Replace.WORLD.with(worldName), Replace.ERROR.with(error)); } private Attempt addBiomeProviderToCreator( From 9643cce272038e93e3870fc66cf262a25741e310 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sat, 2 May 2026 16:18:18 +0800 Subject: [PATCH 03/26] Migrate world config to store worlds using namespace key --- .../multiverse/core/locale/MVCorei18n.java | 8 + .../locale/message/MessageReplacement.java | 2 + .../core/world/LoadedMultiverseWorld.java | 7 +- .../core/world/MultiverseWorld.java | 33 +- .../core/world/NewAndRemovedWorlds.java | 4 +- .../multiverse/core/world/WorldConfig.java | 58 +-- .../core/world/WorldConfigNodes.java | 21 +- .../multiverse/core/world/WorldManager.java | 46 ++- .../core/world/WorldsConfigManager.java | 121 ++++--- .../core/world/key/WorldKeyOrName.java | 338 ++++++++++++++++++ .../world/key/WorldKeyParseFailReason.java | 73 ++++ .../core/world/reasons/LoadFailureReason.java | 6 + .../resources/multiverse-core_en.properties | 8 + .../core/world/WorldConfigMangerTest.kt | 25 +- .../multiverse/core/world/WorldConfigTest.kt | 9 +- src/test/resources/worlds/default_worlds.yml | 16 +- src/test/resources/worlds/delete_worlds.yml | 8 +- src/test/resources/worlds/edgecase_worlds.yml | 16 +- src/test/resources/worlds/migrated_worlds.yml | 24 +- src/test/resources/worlds/newworld_worlds.yml | 26 +- .../resources/worlds/properties_worlds.yml | 16 +- src/test/resources/worlds/updated_worlds.yml | 16 +- 22 files changed, 721 insertions(+), 160 deletions(-) create mode 100644 src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyOrName.java create mode 100644 src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyParseFailReason.java diff --git a/src/main/java/org/mvplugins/multiverse/core/locale/MVCorei18n.java b/src/main/java/org/mvplugins/multiverse/core/locale/MVCorei18n.java index 62d94925c..54958b26b 100644 --- a/src/main/java/org/mvplugins/multiverse/core/locale/MVCorei18n.java +++ b/src/main/java/org/mvplugins/multiverse/core/locale/MVCorei18n.java @@ -310,12 +310,14 @@ public enum MVCorei18n implements MessageKeyProvider { IMPORTWORLD_WORLDEXISTLOADED, IMPORTWORLD_WORLDFOLDERINVALID, IMPORTWORLD_BUKKITENVIRONMENTMISMATCH, + IMPORTWORLD_BUKKITNAMESPACEDKEYMISMATCH, LOADWORLD_WORLDALREADYLOADING, LOADWORLD_WORLDNONEXISTENT, LOADWORLD_WORLDEXISTFOLDER, LOADWORLD_WORLDEXISTLOADED, LOADWORLD_BUKKITENVIRONMENTMISMATCH, + LOADWORLD_BUKKITNAMESPACEDKEYMISMATCH, REMOVEWORLD_WORLDNONEXISTENT, @@ -352,6 +354,12 @@ public enum MVCorei18n implements MessageKeyProvider { EXCEPTION_POSITIONPARSE_INVALIDCOORDINATES, EXCEPTION_POSITIONPARSE_INVALIDNUMBER, + // world key parse failures + WORLDKEYPARSE_EMPTY, + WORLDKEYPARSE_INVALIDWORLDNAME, + WORLDKEYPARSE_INVALIDNAMESPACEDKEY, + WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED, + // generic GENERIC_SUCCESS, GENERIC_FAILURE, diff --git a/src/main/java/org/mvplugins/multiverse/core/locale/message/MessageReplacement.java b/src/main/java/org/mvplugins/multiverse/core/locale/message/MessageReplacement.java index c54595f89..eb0250b4e 100644 --- a/src/main/java/org/mvplugins/multiverse/core/locale/message/MessageReplacement.java +++ b/src/main/java/org/mvplugins/multiverse/core/locale/message/MessageReplacement.java @@ -1,6 +1,7 @@ package org.mvplugins.multiverse.core.locale.message; import io.vavr.control.Either; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -107,6 +108,7 @@ public enum Replace { GAMERULE(replace("{gamerule}")), LOCATION(replace("{location}")), NAME(replace("{name}")), + NAMESPACE(replace("{namespace}")), PLAYER(replace("{player}")), REASON(replace("{reason}")), VALUE(replace("{value}")), diff --git a/src/main/java/org/mvplugins/multiverse/core/world/LoadedMultiverseWorld.java b/src/main/java/org/mvplugins/multiverse/core/world/LoadedMultiverseWorld.java index ed848d28d..5370e748b 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/LoadedMultiverseWorld.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/LoadedMultiverseWorld.java @@ -39,7 +39,7 @@ public final class LoadedMultiverseWorld extends MultiverseWorld { @NotNull LocationManipulation locationManipulation, @NotNull EntityPurger entityPurger ) { - super(world.getName(), worldConfig, config); + super(worldConfig, config); this.worldUid = world.getUID(); this.blockSafety = blockSafety; this.locationManipulation = locationManipulation; @@ -173,7 +173,7 @@ public Option getWorldBorder() { * {@inheritDoc} */ @Override - void setWorldConfig(WorldConfig worldConfig) { + void setWorldConfig(@NotNull WorldConfig worldConfig) { super.setWorldConfig(worldConfig); setupWorldConfig(getBukkitWorld().get()); } @@ -184,7 +184,8 @@ void setWorldConfig(WorldConfig worldConfig) { @Override public String toString() { return "LoadedMultiverseWorld{" - + "name='" + worldName + "', " + + "key='" + getKey() + "', " + + "name='" + getName() + "', " + "env='" + getEnvironment() + "', " + "type='" + getWorldType().getOrNull() + "', " + "gen='" + getGenerator() + "'" diff --git a/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java b/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java index f1314dca9..52735f678 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java @@ -10,6 +10,7 @@ import org.bukkit.GameMode; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -25,11 +26,6 @@ * Represents a world handled by Multiverse which has all the custom properties provided by Multiverse. */ public sealed class MultiverseWorld permits LoadedMultiverseWorld { - /** - * This world's name. - */ - protected final String worldName; - /** * This world's configuration. */ @@ -38,14 +34,25 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld { protected final CoreConfig config; private String colourlessAlias = ""; - MultiverseWorld(String worldName, WorldConfig worldConfig, CoreConfig config) { - this.worldName = worldName; + MultiverseWorld(WorldConfig worldConfig, CoreConfig config) { this.worldConfig = worldConfig; this.config = config; this.worldConfig.setMVWorld(this); updateColourlessAlias(); } + /** + * The key that represents this world. This should now be used as the unique key for the world instead + * of the world name. The key cannot be changed. + * + * @return The world key + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public NamespacedKey getKey() { + return worldConfig.getWorldKeyOrName().usableKey(); + } + /** * Gets the name of this world. The name cannot be changed. *
@@ -54,8 +61,9 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld { * * @return The name of the world as a String. */ + @NotNull public String getName() { - return worldName; + return worldConfig.getLegacyWorldName(); } /** @@ -144,7 +152,7 @@ public Try setAlias(String alias) { * @return The alias of the world as a String. */ public String getAliasOrName() { - return Strings.isNullOrEmpty(worldConfig.getAlias()) ? worldName : worldConfig.getAlias(); + return Strings.isNullOrEmpty(worldConfig.getAlias()) ? getName() : worldConfig.getAlias(); } /** @@ -689,7 +697,7 @@ public Try setWorldBlacklist(List worldBlacklist) { * * @return The world config. */ - WorldConfig getWorldConfig() { + @NotNull WorldConfig getWorldConfig() { return worldConfig; } @@ -698,7 +706,7 @@ WorldConfig getWorldConfig() { * * @param worldConfig The world config. */ - void setWorldConfig(WorldConfig worldConfig) { + void setWorldConfig(@NotNull WorldConfig worldConfig) { this.worldConfig = worldConfig; } @@ -708,7 +716,8 @@ void setWorldConfig(WorldConfig worldConfig) { @Override public String toString() { return "MultiverseWorld{" - + "name='" + worldName + "', " + + "key='" + getKey() + "', " + + "name='" + getName() + "', " + "env='" + getEnvironment() + "', " + "gen='" + getGenerator() + "'" + '}'; diff --git a/src/main/java/org/mvplugins/multiverse/core/world/NewAndRemovedWorlds.java b/src/main/java/org/mvplugins/multiverse/core/world/NewAndRemovedWorlds.java index b7caf525f..e92c7af98 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/NewAndRemovedWorlds.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/NewAndRemovedWorlds.java @@ -1,5 +1,7 @@ package org.mvplugins.multiverse.core.world; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; + import java.util.List; /** @@ -8,5 +10,5 @@ * @param newWorlds List of the new WorldConfigs added * @param removedWorlds List of the worlds removed from the config */ -record NewAndRemovedWorlds(List newWorlds, List removedWorlds) { +record NewAndRemovedWorlds(List newWorlds, List removedWorlds) { } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfig.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfig.java index a830a7bb4..369dea0bc 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfig.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfig.java @@ -29,6 +29,7 @@ import org.mvplugins.multiverse.core.config.migration.action.NullStringMigratorAction; import org.mvplugins.multiverse.core.config.migration.VersionMigrator; import org.mvplugins.multiverse.core.economy.MVEconomist; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; import org.mvplugins.multiverse.core.world.location.SpawnLocation; import org.mvplugins.multiverse.core.world.entity.EntitySpawnConfig; @@ -37,16 +38,16 @@ */ final class WorldConfig { - private final String worldName; + private final WorldKeyOrName keyOrName; private final WorldConfigNodes configNodes; private final MemoryConfigurationHandle configHandle; private final StringPropertyHandle stringPropertyHandle; WorldConfig( - @NotNull String worldName, + @NotNull WorldKeyOrName keyOrName, @NotNull ConfigurationSection configSection, @NotNull MultiverseCore multiverseCore) { - this.worldName = worldName; + this.keyOrName = keyOrName; this.configNodes = new WorldConfigNodes(multiverseCore); this.configHandle = MemoryConfigurationHandle.builder(configSection, configNodes.getNodes()) .logger(Logging.getLogger()) @@ -118,6 +119,11 @@ private ConfigMigrator migrator() { .addAction(MoveMigratorAction.of("spawning.animals", "spawning.animal")) .addAction(MoveMigratorAction.of("spawning.monsters", "spawning.monster")) .build()) + .addVersionMigrator(VersionMigrator.builder(1.3) + .addAction(config -> config.set("read-only.legacy-world-name", keyOrName.usableName())) + .addAction(MoveMigratorAction.of("environment", "read-only.environment")) + .addAction(MoveMigratorAction.of("seed", "read-only.seed")) + .build()) .build(); } @@ -132,7 +138,7 @@ Try load(ConfigurationSection section) { Try save() { return configHandle.save().onFailure(ex -> Logging.warning("Failed to save world config for world '%s'. %s", - worldName, ex.getLocalizedMessage())); + keyOrName, ex.getLocalizedMessage())); } ConfigurationSection getConfigurationSection() { @@ -143,8 +149,8 @@ StringPropertyHandle getStringPropertyHandle() { return stringPropertyHandle; } - String getWorldName() { - return worldName; + WorldKeyOrName getWorldKeyOrName() { + return keyOrName; } boolean getAdjustSpawn() { @@ -258,14 +264,6 @@ Try setEntryFeeCurrency(Material entryFeeCurrency) { return configHandle.set(configNodes.entryFeeCurrency, entryFeeCurrency); } - World.Environment getEnvironment() { - return configHandle.get(configNodes.environment); - } - - Try setEnvironment(World.Environment environment) { - return configHandle.set(configNodes.environment, environment); - } - GameMode getGameMode() { return configHandle.get(configNodes.gamemode); } @@ -345,14 +343,6 @@ Try setScale(double scale) { return configHandle.set(configNodes.scale, scale); } - long getSeed() { - return configHandle.get(configNodes.seed); - } - - Try setSeed(long seed) { - return configHandle.set(configNodes.seed, seed); - } - SpawnLocation getSpawnLocation() { return configHandle.get(configNodes.spawnLocation); } @@ -377,6 +367,30 @@ Try setWorldBlacklist(List worldBlacklist) { return configHandle.set(configNodes.worldBlacklist, worldBlacklist); } + World.Environment getEnvironment() { + return configHandle.get(configNodes.environment); + } + + Try setEnvironment(World.Environment environment) { + return configHandle.set(configNodes.environment, environment); + } + + String getLegacyWorldName() { + return configHandle.get(configNodes.legacyWorldName); + } + + Try setLegacyWorldName(String legacyWorldName) { + return configHandle.set(configNodes.legacyWorldName, legacyWorldName); + } + + long getSeed() { + return configHandle.get(configNodes.seed); + } + + Try setSeed(long seed) { + return configHandle.set(configNodes.seed, seed); + } + void setMVWorld(@NotNull MultiverseWorld world) { configNodes.setWorld(world); } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java index 7ad69efd8..5ed123ffa 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java @@ -172,11 +172,6 @@ public Object serialize(Material object, Class type) { } })); - final ConfigNode environment = node(ConfigNode - .builder("environment", World.Environment.class) - .defaultValue(World.Environment.NORMAL) - .hidden()); - final ConfigNode gamemode = node(ConfigNode.builder("gamemode", GameMode.class) .defaultValue(GameMode.SURVIVAL) .onLoadAndChange((oldValue, newValue) -> { @@ -234,10 +229,6 @@ public Object serialize(Material object, Class type) { }; })); - final ConfigNode seed = node(ConfigNode.builder("seed", Long.class) - .defaultValue(Long.MIN_VALUE) - .hidden()); - final ConfigNode spawnLocation = node(ConfigNode.builder("spawn-location", SpawnLocation.class) .defaultValue(NullSpawnLocation.get()) .hidden() @@ -274,6 +265,18 @@ public Object serialize(EntitySpawnConfig object, Class type) final ConfigNode> worldBlacklist = node(ListConfigNode.listBuilder("world-blacklist", String.class)); + final ConfigNode environment = node(ConfigNode + .builder("read-only.environment", World.Environment.class) + .defaultValue(World.Environment.NORMAL) + .hidden()); + + final ConfigNode legacyWorldName = node(ConfigNode.builder("read-only.legacy-world-name", String.class) + .hidden()); + + final ConfigNode seed = node(ConfigNode.builder("read-only.seed", Long.class) + .defaultValue(Long.MIN_VALUE) + .hidden()); + final ConfigNode version = node(ConfigNode.builder("version", Double.class) .defaultValue(CONFIG_VERSION) .hidden()); diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java index b99ac801d..7caa3903e 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java @@ -62,6 +62,7 @@ import org.mvplugins.multiverse.core.world.helpers.DataTransfer; import org.mvplugins.multiverse.core.world.helpers.DimensionFinder.DimensionFormat; import org.mvplugins.multiverse.core.world.helpers.WorldNameChecker; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; import org.mvplugins.multiverse.core.world.options.CloneWorldOptions; import org.mvplugins.multiverse.core.world.options.CreateWorldOptions; import org.mvplugins.multiverse.core.world.options.DeleteWorldOptions; @@ -173,19 +174,19 @@ private Try updateWorldsFromConfig() { } private void loadNewWorldConfigs(Collection newWorldConfigs) { - newWorldConfigs.forEach(worldConfig -> Option.of(worldsMap.get(worldConfig.getWorldName())) - .peek(unloadedWorld -> unloadedWorld.setWorldConfig(worldConfig)) - .onEmpty(() -> newMultiverseWorld(worldConfig.getWorldName(), worldConfig))); + newWorldConfigs.forEach(worldConfig -> Option.of(worldsMap.get(worldConfig.getWorldKeyOrName().usableName())) + .peek(unloadedWorld -> unloadedWorld.setWorldConfig(worldConfig)) + .onEmpty(() -> newMultiverseWorld(worldConfig))); } - private void removeWorldsNotInConfigs(Collection removedWorlds) { - removedWorlds.forEach(worldName -> getWorld(worldName) + private void removeWorldsNotInConfigs(Collection removedWorlds) { + removedWorlds.forEach(keyOrName -> getWorld(keyOrName.usableName()) .map(world -> removeWorld(RemoveWorldOptions.world(world))) - .getOrElse(() -> worldActionResult(RemoveFailureReason.WORLD_NON_EXISTENT, worldName)) + .getOrElse(() -> worldActionResult(RemoveFailureReason.WORLD_NON_EXISTENT, keyOrName.toString())) .onFailure(failure -> - Logging.severe("Failed to unload world %s: %s", worldName, failure)) + Logging.severe("Failed to unload world %s: %s", keyOrName, failure)) .onSuccess(success -> - Logging.fine("Unloaded world %s as it was removed from config", worldName))); + Logging.fine("Unloaded world %s as it was removed from config", keyOrName))); } /** @@ -365,8 +366,8 @@ private Attempt doImportBukkitWorld( return Attempt.success(loadedWorld); } - private MultiverseWorld newMultiverseWorld(String worldName, WorldConfig worldConfig) { - MultiverseWorld mvWorld = new MultiverseWorld(worldName, worldConfig, config); + private MultiverseWorld newMultiverseWorld(WorldConfig worldConfig) { + MultiverseWorld mvWorld = new MultiverseWorld(worldConfig, config); worldsMap.put(mvWorld.getName(), mvWorld); corePermissions.addWorldPermissions(mvWorld); return mvWorld; @@ -381,7 +382,7 @@ private MultiverseWorld newMultiverseWorld(String worldName, WorldConfig worldCo */ private LoadedMultiverseWorld newLoadedMultiverseWorld( @NotNull World world, @Nullable String generator, @Nullable String biome, boolean adjustSpawn) { - WorldConfig worldConfig = worldsConfigManager.addWorldConfig(world.getName()); + WorldConfig worldConfig = worldsConfigManager.addWorldConfig(world.getKey()); // Properties from multiverse input worldConfig.setAdjustSpawn(adjustSpawn); @@ -389,13 +390,14 @@ private LoadedMultiverseWorld newLoadedMultiverseWorld( worldConfig.setBiome(biome == null ? "" : biome); // Properties from the bukkit world + worldConfig.setLegacyWorldName(world.getName()); worldConfig.setDifficulty(world.getDifficulty()); worldConfig.setKeepSpawnInMemory(world.getKeepSpawnInMemory()); worldConfig.setScale(getCoordinateScale(world)); worldConfig.save(); - newMultiverseWorld(world.getName(), worldConfig); + newMultiverseWorld(worldConfig); LoadedMultiverseWorld loadedWorld = new LoadedMultiverseWorld( world, worldConfig, @@ -522,7 +524,23 @@ private Attempt doLoadWorld(@NotNull L } private Attempt newLoadedMultiverseWorld(MultiverseWorld mvWorld, World bukkitWorld) { - WorldConfig worldConfig = worldsConfigManager.getWorldConfig(mvWorld.getName()).get(); //TODO: null check here, but logically it should never be null. + WorldConfig worldConfig = mvWorld.getWorldConfig(); + + if (worldConfig.getWorldKeyOrName().isName() && mvWorld.getName().equalsIgnoreCase(bukkitWorld.getName())) { + // do migration of namespaced key + Logging.info("Migrating world config for '%s' to use namespaced key '%s'...", + mvWorld.getName(), bukkitWorld.getKey()); + worldConfig = worldsConfigManager.migrateWorldConfigKey(worldConfig, bukkitWorld.getKey()); + mvWorld.setWorldConfig(worldConfig); + } + + if (!bukkitWorld.getKey().equals(mvWorld.getKey())) { + return Attempt.failure(LoadFailureReason.BUKKIT_NAMESPACED_KEY_MISMATCH, + Replace.WORLD.with(mvWorld.getName()), + replace("{bukkitNamespace}").with(bukkitWorld.getKey()), + replace("{mvNamespace}").with(mvWorld.getKey())); + } + LoadedMultiverseWorld loadedWorld = new LoadedMultiverseWorld( bukkitWorld, worldConfig, @@ -662,7 +680,7 @@ private Attempt removeWorldFromConfig(@NotNull Mult // Remove world from config worldsMap.remove(world.getName()); world.getWorldConfig().deferenceMVWorld(); - worldsConfigManager.deleteWorldConfig(world.getName()); + worldsConfigManager.deleteWorldConfig(world.getKey()); saveWorldsConfig(); corePermissions.removeWorldPermissions(world); pluginManager.callEvent(new MVWorldRemovedEvent(world)); diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldsConfigManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldsConfigManager.java index 856f00e9d..7add5ff73 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldsConfigManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldsConfigManager.java @@ -10,6 +10,7 @@ import io.vavr.control.Option; import io.vavr.control.Try; import jakarta.inject.Inject; +import org.bukkit.NamespacedKey; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; @@ -17,6 +18,9 @@ import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.MultiverseCore; +import org.mvplugins.multiverse.core.utils.result.Attempt; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; +import org.mvplugins.multiverse.core.world.key.WorldKeyParseFailReason; import static java.nio.file.StandardCopyOption.COPY_ATTRIBUTES; import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; @@ -28,7 +32,7 @@ final class WorldsConfigManager { private static final String CONFIG_FILENAME = "worlds.yml"; - private final SortedMap worldConfigMap; + private final SortedMap worldConfigMap; private final File worldConfigFile; private YamlConfiguration worldsConfig; @@ -47,7 +51,7 @@ final class WorldsConfigManager { * * @return A {@link NewAndRemovedWorlds} record. */ - public Try load() { + Try load() { return Try.of(() -> { loadWorldYmlFile(); return parseNewAndRemovedWorlds(); @@ -113,7 +117,7 @@ private void migrateRemoveOldConfigSerializable() { config.set("worlds", null); for (Map.Entry entry : worldConfigMap.entrySet()) { - config.set(encodeWorldName(entry.getKey()), entry.getValue()); + config.set(encodeConfigKey(entry.getKey()), entry.getValue()); } config.save(worldConfigFile); }) @@ -162,33 +166,39 @@ private void recursiveGetOldConfigWorldNames(ConfigurationSection section, List< * @return A tuple containing a list of the new WorldConfigs added and a list of the worlds removed from the config. */ private NewAndRemovedWorlds parseNewAndRemovedWorlds() { - List allWorldsInConfig = worldsConfig.getKeys(false) + List allWorldsInConfig = worldsConfig.getKeys(false) .stream() - .map(this::decodeWorldName) + .map(keyStr -> decodeConfigKey(keyStr) + .onFailure(reason -> + Logging.warning("Failed to parse world key '%s' in config: %s", keyStr, reason)) + .getOrNull()) + .filter(Objects::nonNull) .toList(); List newWorldsAdded = new ArrayList<>(); - for (String worldName : allWorldsInConfig) { - getWorldConfig(worldName) - .peek(config -> config.load(getWorldConfigSection(worldName))) + for (WorldKeyOrName worldKeyStr : allWorldsInConfig) { + getWorldConfig(worldKeyStr) + .peek(config -> config.load(getWorldConfigSection(worldKeyStr)) + .onFailure(e -> Logging.warning("Failed to load world config for world '%s': %s", + worldKeyStr, e.getMessage()))) .orElse(() -> { WorldConfig newWorldConfig = new WorldConfig( - worldName, - getWorldConfigSection(worldName), + worldKeyStr, + getWorldConfigSection(worldKeyStr), multiverseCore); - worldConfigMap.put(worldName, newWorldConfig); + worldConfigMap.put(worldKeyStr, newWorldConfig); newWorldsAdded.add(newWorldConfig); return Option.of(newWorldConfig); }) .peek(WorldConfig::save); } - List worldsRemoved = worldConfigMap.keySet().stream() + List worldsRemoved = worldConfigMap.keySet().stream() .filter(worldName -> !allWorldsInConfig.contains(worldName)) .toList(); - for (String s : worldsRemoved) { + for (WorldKeyOrName s : worldsRemoved) { worldConfigMap.remove(s); } @@ -200,7 +210,7 @@ private NewAndRemovedWorlds parseNewAndRemovedWorlds() { * * @return Whether the worlds.yml file has been loaded. */ - public boolean isLoaded() { + boolean isLoaded() { return worldsConfig != null; } @@ -209,17 +219,17 @@ public boolean isLoaded() { * * @return Whether the save was successful or the error that occurred. */ - public Try save() { + Try save() { return Try.run(() -> { if (!isLoaded()) { throw new IllegalStateException("WorldsConfigManager is not loaded!"); } worldsConfig = new YamlConfiguration(); - worldConfigMap.forEach((worldName, worldConfig) -> { + worldConfigMap.forEach((keyOrName, worldConfig) -> { worldConfig.save().onFailure(e -> { - throw new RuntimeException("Failed to save world config: " + worldName, e); + throw new RuntimeException("Failed to save world %s in config: " + keyOrName, e); }); - worldsConfig.set(encodeWorldName(worldName), worldConfig.getConfigurationSection()); + worldsConfig.set(encodeConfigKey(keyOrName), worldConfig.getConfigurationSection()); }); worldsConfig.save(worldConfigFile); }).onFailure(e -> { @@ -228,63 +238,90 @@ public Try save() { } /** - * Gets the {@link WorldConfig} instance of all worlds in the worlds.yml file. + * Gets the {@link WorldConfig} instance of a world in the worlds.yml file. * - * @param worldName The name of the world to check. + * @param keyOrName The target key to get * @return The {@link WorldConfig} instance of the world, or empty option if it doesn't exist. */ - public @NotNull Option getWorldConfig(@NotNull String worldName) { - return Option.of(worldConfigMap.get(worldName)); + @NotNull Option getWorldConfig(@NotNull WorldKeyOrName keyOrName) { + return Option.of(worldConfigMap.get(keyOrName)); + } + + @NotNull WorldConfig migrateWorldConfigKey(@NotNull WorldConfig worldConfig, @NotNull NamespacedKey toKey) { + worldConfig.save(); + WorldKeyOrName newKeyOrName = WorldKeyOrName.parseKey(toKey); + WorldConfig migratedWorldConfig = new WorldConfig( + newKeyOrName, + worldConfig.getConfigurationSection(), + multiverseCore + ); + deleteWorldConfig(worldConfig.getWorldKeyOrName()); + worldConfigMap.put(newKeyOrName, migratedWorldConfig); + return migratedWorldConfig; } /** * Add a new world to the worlds.yml file. If a world with the given name already exists, an exception is thrown. * - * @param worldName The name of the world to add. + * @param namespacedKey The target key to add * @return The newly created {@link WorldConfig} instance. */ - public @NotNull WorldConfig addWorldConfig(@NotNull String worldName) { - if (worldConfigMap.containsKey(worldName)) { - throw new IllegalArgumentException("WorldConfig for world " + worldName + " already exists."); + @NotNull WorldConfig addWorldConfig(@NotNull NamespacedKey namespacedKey) { + WorldKeyOrName keyOrName = WorldKeyOrName.parseKey(namespacedKey); + if (worldConfigMap.containsKey(keyOrName)) { + throw new IllegalStateException("WorldConfig for world " + namespacedKey + " already exists."); } - WorldConfig worldConfig = new WorldConfig(worldName, getWorldConfigSection(worldName), multiverseCore); - worldConfigMap.put(worldName, worldConfig); + WorldConfig worldConfig = new WorldConfig(keyOrName, getWorldConfigSection(keyOrName), multiverseCore); + worldConfigMap.put(keyOrName, worldConfig); return worldConfig; } /** * Deletes the world config for the given world. * - * @param worldName The name of the world to delete. + * @param namespacedKey The target key to delete + */ + void deleteWorldConfig(@NotNull NamespacedKey namespacedKey) { + deleteWorldConfig(WorldKeyOrName.parseKey(namespacedKey)); + } + + /** + * Deletes the world config for the given world. + * + * @param worldKeyOrName The target key to delete */ - public void deleteWorldConfig(@NotNull String worldName) { - worldConfigMap.remove(worldName); - worldsConfig.set(encodeWorldName(worldName), null); + void deleteWorldConfig(@NotNull WorldKeyOrName worldKeyOrName) { + worldConfigMap.remove(worldKeyOrName); + worldsConfig.set(encodeConfigKey(worldKeyOrName), null); } /** * Gets the {@link ConfigurationSection} for the given world in the worlds.yml file. If the section doesn't exist, * it is created. * - * @param worldName The name of the world. + * @param keyOrName the config key of the world to get the configuration section for. * @return The {@link ConfigurationSection} for the given world. */ - private ConfigurationSection getWorldConfigSection(String worldName) { - worldName = encodeWorldName(worldName); - return worldsConfig.isConfigurationSection(worldName) - ? worldsConfig.getConfigurationSection(worldName) - : worldsConfig.createSection(worldName); + @NotNull + private ConfigurationSection getWorldConfigSection(@NotNull WorldKeyOrName keyOrName) { + String encodeWorldKey = encodeConfigKey(keyOrName); + ConfigurationSection section = worldsConfig.getConfigurationSection(encodeWorldKey); + return section == null ? worldsConfig.createSection(encodeWorldKey) : section; + } + + private String encodeConfigKey(@NotNull WorldKeyOrName worldKeyOrName) { + return encodeConfigKey(worldKeyOrName.serialise()); } /** - * Dot is a special character in YAML that causes sub-path issues. + * Remove dot . with [dot] as it is a special character in YAML that causes sub-path issues. */ - private String encodeWorldName(String worldName) { + private String encodeConfigKey(@NotNull String worldName) { return worldName.replace(".", "[dot]"); } - private String decodeWorldName(String worldName) { - return worldName.replace("[dot]", "."); + private Attempt decodeConfigKey(@NotNull String worldKeyStr) { + return WorldKeyOrName.parse(worldKeyStr.replace("[dot]", ".")); } private static final class ConfigMigratedException extends RuntimeException { diff --git a/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyOrName.java b/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyOrName.java new file mode 100644 index 000000000..18c1953b2 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyOrName.java @@ -0,0 +1,338 @@ +package org.mvplugins.multiverse.core.world.key; + +import io.vavr.control.Either; +import io.vavr.control.Option; +import io.vavr.control.Try; +import org.bukkit.NamespacedKey; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.mvplugins.multiverse.core.locale.message.MessageReplacement; +import org.mvplugins.multiverse.core.utils.result.Attempt; + +import java.util.Locale; +import java.util.Objects; + +/** + * Represents either a world key or a world name, with methods to parse from strings and retrieve the key or name. + * + * @since 5.7 + */ +@ApiStatus.AvailableSince("5.7") +public sealed abstract class WorldKeyOrName implements Comparable permits WorldKeyOrName.Key, WorldKeyOrName.Name { + + /** + * Parse a string into a {@link WorldKeyOrName} instance. + *

+ * The provided input will be interpreted as a namespaced key if it contains a ':' character, + * otherwise it will be treated as a world name. + * + * @param nameOrKey The input string to parse, may be a world name or a namespaced key string + * @return An {@link Attempt} containing either a parsed {@link WorldKeyOrName} on success or a + * {@link WorldKeyParseFailReason} describing the failure + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static Attempt parse(@Nullable String nameOrKey) { + if (nameOrKey == null || nameOrKey.isEmpty()) { + return Attempt.failure(WorldKeyParseFailReason.EMPTY); + } + return nameOrKey.contains(":") ? parseKey(nameOrKey) : parseName(nameOrKey); + } + + /** + * Parse a world name into a {@link WorldKeyOrName} instance. + *

+ * This will attempt to create a usable {@link NamespacedKey} from the given name using + * {@link NamespacedKey#minecraft(String)}. If parsing fails, a failure {@link Attempt} will be + * returned describing an invalid world name. + * + * @param name The world name to parse + * @return An {@link Attempt} containing a {@link WorldKeyOrName.Name} on success or a + * {@link WorldKeyParseFailReason#INVALID_WORLD_NAME} failure on error + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static Attempt parseName(@NonNull String name) { + final String finalLowerCaseName = name.toLowerCase(Locale.ROOT); + //TODO: usable mapping for default level-name + return Try.of(() -> NamespacedKey.minecraft(finalLowerCaseName)) + .map(usableKey -> Attempt.success(new Name(name, usableKey))) + .recover(throwable -> Attempt.failure(WorldKeyParseFailReason.INVALID_WORLD_NAME, + MessageReplacement.Replace.WORLD.with(name))) + .getOrElse(() -> Attempt.failure(WorldKeyParseFailReason.INVALID_WORLD_NAME, + MessageReplacement.Replace.WORLD.with(name))); + } + + /** + * Parse a namespaced key string into a {@link WorldKeyOrName} instance. + * + * @param nameOrKey The namespaced key string to parse (eg. "minecraft:world") + * @return An {@link Attempt} containing a {@link WorldKeyOrName.Key} on success or a + * {@link WorldKeyParseFailReason#INVALID_NAMESPACED_KEY} failure on error + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static Attempt parseKey(@NonNull String nameOrKey) { + return Option.of(NamespacedKey.fromString(nameOrKey)) + .filter(Objects::nonNull) + .map(key -> Attempt.success(new Key(key))) + .getOrElse(() -> Attempt.failure(WorldKeyParseFailReason.INVALID_NAMESPACED_KEY, + MessageReplacement.Replace.NAMESPACE.with(nameOrKey))); + } + + /** + * Create a {@link WorldKeyOrName} representing a namespaced key. + * + * @param key The {@link NamespacedKey} to wrap + * @return A {@link WorldKeyOrName} containing the provided key + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static WorldKeyOrName parseKey(@NonNull NamespacedKey key) { + return new Key(key); + } + + /** + * Returns true if this instance represents a world name (not a namespaced key). + * + * @return true if this is a name variant + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public abstract boolean isName(); + + /** + * Returns true if this instance represents a namespaced key (not a world name). + * + * @return true if this is a key variant + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public abstract boolean isKey(); + + /** + * Get the underlying {@link NamespacedKey} if this instance is a key variant. + * + * @return An {@link Option} containing the key when present, otherwise {@link Option#none()} + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public abstract @NotNull Option getKey(); + + /** + * Get the underlying world name if this instance is a name variant. + * + * @return An {@link Option} containing the name when present, otherwise {@link Option#none()} + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public abstract @NotNull Option getName(); + + /** + * Represent this value as an {@link Either} where the left side is the {@link NamespacedKey} + * and the right side is the world name. + * + * @return An {@link Either} containing either the key (left) or the name (right) + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public abstract @NotNull Either asEither(); + + /** + * Get a usable {@link NamespacedKey} for this instance. + *

+ * If this instance is a key variant, the underlying key is returned. If it is a name variant, + * an implementation-provided usable key (derived from the name) is returned. + * + * @return A non-null {@link NamespacedKey} that can be used where a key is required + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public abstract @NotNull NamespacedKey usableKey(); + + /** + * Get a usable name for this instance. + *

+ * If this instance is a name variant, the underlying name is returned. If it is a key variant, + * a reasonable string representation derived from the key is returned. + * + * @return A non-null {@link String} representing a usable name + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public abstract @NotNull String usableName(); + + /** + * Serialize this instance to a string form suitable for storage or comparison. + * + * @return The serialized representation of this value + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public abstract @NotNull String serialise(); + + /** + * Sorts the instances based on their serialized form, which ensures that keys and names are sorted in an + * alphabetical manner by default. + * + * @param o the object to be compared. + * @return a negative integer, zero, or a positive integer as this object is less than, equal to, or greater than the specified object. + */ + @Override + public int compareTo(@NonNull WorldKeyOrName o) { + return serialise().compareTo(o.serialise()); + } + + public static final class Key extends WorldKeyOrName { + + private final NamespacedKey key; + + private Key(@NotNull NamespacedKey key) { + this.key = key; + } + + @Override + public boolean isName() { + return false; + } + + @Override + public boolean isKey() { + return true; + } + + @Override + public @NotNull Option getKey() { + return Option.of(key); + } + + @Override + public @NotNull Option getName() { + return Option.none(); + } + + @Override + public @NotNull Either asEither() { + return Either.left(key); + } + + @Override + public @NotNull NamespacedKey usableKey() { + return key; + } + + @Override + public @NotNull String usableName() { + return key.getKey(); + } + + @Override + public @NotNull String serialise() { + return key.toString(); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Key key1 = (Key) o; + return Objects.equals(key, key1.key); + } + + @Override + public int hashCode() { + return Objects.hashCode(key); + } + + @Override + public String toString() { + return "Key{" + + "key=" + key + + '}'; + } + } + + public static final class Name extends WorldKeyOrName { + + private final String name; + private final NamespacedKey usableKey; + + private Name(String name, NamespacedKey usableKey) { + this.name = name; + this.usableKey = usableKey; + } + + @Override + public boolean isName() { + return true; + } + + @Override + public boolean isKey() { + return false; + } + + @Override + public @NotNull Option getKey() { + return Option.none(); + } + + @Override + public @NotNull Option getName() { + return Option.of(name); + } + + @Override + public @NotNull Either asEither() { + return Either.right(name); + } + + @Override + public @NotNull NamespacedKey usableKey() { + return usableKey; + } + + @Override + public @NotNull String usableName() { + return name; + } + + @Override + public @NotNull String serialise() { + return name; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + Name name1 = (Name) o; + return Objects.equals(name, name1.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return "Name{" + + "name='" + name + '\'' + + ", usableKey=" + usableKey + + '}'; + } + } +} diff --git a/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyParseFailReason.java b/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyParseFailReason.java new file mode 100644 index 000000000..32a5839e6 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyParseFailReason.java @@ -0,0 +1,73 @@ +package org.mvplugins.multiverse.core.world.key; + +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; + +/** + * Reasons why parsing a world key or name failed. + *
+ * These values are returned by parsing utilities such as {@link org.mvplugins.multiverse.core.world.key.WorldKeyOrName#parse} + * to indicate the specific cause of failure so callers can present an appropriate localized message. + * + * @since 5.7 + */ +@ApiStatus.AvailableSince("5.7") +public enum WorldKeyParseFailReason implements FailureReason { + /** + * The provided input was null or empty. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + EMPTY(MVCorei18n.WORLDKEYPARSE_EMPTY), + + /** + * The provided world name contains invalid characters or format. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + INVALID_WORLD_NAME(MVCorei18n.WORLDKEYPARSE_INVALIDWORLDNAME), + + /** + * The provided string was intended to be a namespaced key but did not parse as a valid + * {@link org.bukkit.NamespacedKey} (malformed namespace/key or missing parts). + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + INVALID_NAMESPACED_KEY(MVCorei18n.WORLDKEYPARSE_INVALIDNAMESPACEDKEY), + + /** + * The platform/server does not support namespaced keys for worlds. Only PaperMC can create worlds with + * namespaced keys. Spigot does not support it. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + NAMESPACED_KEY_UNSUPPORTED(MVCorei18n.WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED), + ; + + private final MessageKeyProvider message; + + WorldKeyParseFailReason(MessageKeyProvider message) { + this.message = message; + } + + /** + * Return the localized message key associated with this failure reason. + *

+ * Implementations of {@link FailureReason} can use this to provide a user-facing localized + * message for the failure. + * + * @return The {@link MessageKey} that corresponds to this failure reason + * @since 5.7 + */ + @Override + public MessageKey getMessageKey() { + return message.getMessageKey(); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/core/world/reasons/LoadFailureReason.java b/src/main/java/org/mvplugins/multiverse/core/world/reasons/LoadFailureReason.java index aacda99c9..9ae3f1d9d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/reasons/LoadFailureReason.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/reasons/LoadFailureReason.java @@ -47,6 +47,12 @@ public enum LoadFailureReason implements FailureReason { @ApiStatus.AvailableSince("5.2") BUKKIT_ENVIRONMENT_MISMATCH(MVCorei18n.LOADWORLD_BUKKITENVIRONMENTMISMATCH), + /** + * The mv world's namespace key does not match the loaded Bukkit world's key. + */ + @ApiStatus.AvailableSince("5.7") + BUKKIT_NAMESPACED_KEY_MISMATCH(MVCorei18n.LOADWORLD_BUKKITNAMESPACEDKEYMISMATCH), + /** * Bukkit API failed to create the world. */ diff --git a/src/main/resources/multiverse-core_en.properties b/src/main/resources/multiverse-core_en.properties index ea482a4fb..274c2bc25 100644 --- a/src/main/resources/multiverse-core_en.properties +++ b/src/main/resources/multiverse-core_en.properties @@ -291,12 +291,14 @@ mv-core.importworld.worldexistunloaded=World '{world}' already exists, but it's mv-core.importworld.worldexistloaded=World '{world}' already exists! 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.importworld.bukkitnamespacemismatch=Namespace mismatch detected!&f The world '{world}' is already loaded with namespace '{bukkitNamespace}', but Multiverse is trying to import it with namespace '{mvNamespace}'. mv-core.loadworld.worldalreadyloading=World '{world}' is already loading! Please wait... mv-core.loadworld.worldnonexistent=World '{world}' not found! Use '&a/mv create {world} &f' to create it. mv-core.loadworld.worldexistfolder=World '{world}' exists in server folders, but it's not known to Multiverse!&f Type '&a/mv import {world} &f' if you wish to import it. mv-core.loadworld.worldexistloaded=World '{world}' is already loaded! mv-core.loadworld.bukkitenvironmentmismatch=Environment mismatch detected!&f The world '{world}' is already loaded with environment '{bukkitEnvironment}', but Multiverse is trying to load it with environment '{mvEnvironment}'. +mv-core.loadworld.bukkitnamespacemismatch=Namespace mismatch detected!&f The world '{world}' is already loaded with namespace '{bukkitNamespace}', but Multiverse is trying to load it with namespace '{mvNamespace}'. mv-core.removeworld.worldnonexistent=World '{world}' not found! @@ -333,6 +335,12 @@ mv-core.exception.positionparse.invaliddirection=&cInvalid direction string form mv-core.exception.positionparse.invalidcoordinates=&cInvalid coordinates format: {format}. Expected format: ,, mv-core.exception.positionparse.invalidnumber=&cInvalid number format: {number}. Expects a numeric value. +# world key parse failures +mv-core.worldkeyparse.empty==&cWorld name/key cannot be empty! +mv-core.worldkeyparse.invalidworldname=&cWorld name '{world}' contains invalid characters. The allowed characters are: a-z, A-Z, 0-9, _, -, and . (dot) +mv-core.worldkeyparse.invalidnamespacedkey=&cNamespaced key '{namespace}' contains invalid characters. Expected a namespaced key format is 'namespace:key'. The allowed characters are: a-z (only lowercase), 0-9, _, -, and . (dot), with a single ':'. +mv-core.worldkeyparse.namespacedkeyunsupported=&cYour server software does not support namespaced keys, but the world name '{namespace}' is in namespaced key format! Please change the world key to the legacy format without ':', or switch to PaperMC that supports namespaced keys. + # generic mv-core.generic.success=Success! mv-core.generic.failure=Failed! diff --git a/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigMangerTest.kt b/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigMangerTest.kt index 86d6b7e92..0c48eecb6 100644 --- a/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigMangerTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigMangerTest.kt @@ -2,11 +2,13 @@ package org.mvplugins.multiverse.core.world import org.bukkit.GameMode import org.bukkit.Material +import org.bukkit.NamespacedKey import org.bukkit.World.Environment import org.bukkit.entity.EntityType import org.bukkit.entity.SpawnCategory import org.mvplugins.multiverse.core.TestWithMockBukkit import org.mvplugins.multiverse.core.economy.MVEconomist +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName import org.mvplugins.multiverse.core.world.location.SpawnLocation import java.io.File import java.nio.file.Path @@ -40,7 +42,7 @@ class WorldConfigMangerTest : TestWithMockBukkit() { assertTrue(worldConfigManager.load().isSuccess) assertTrue(worldConfigManager.save().isSuccess) - val endWorldConfig = worldConfigManager.getWorldConfig("world_the_end").orNull + val endWorldConfig = worldConfigManager.getWorldConfig(key("world_the_end")).orNull assertNotNull(endWorldConfig) assertEquals("&aworld the end", endWorldConfig.alias) @@ -49,7 +51,7 @@ class WorldConfigMangerTest : TestWithMockBukkit() { assertEquals(MVEconomist.VAULT_ECONOMY_MATERIAL, endWorldConfig.entryFeeCurrency) assertEquals(0.0, endWorldConfig.entryFeeAmount) - val worldConfig = worldConfigManager.getWorldConfig("world.a.b").orNull + val worldConfig = worldConfigManager.getWorldConfig(key("world.a.b")).orNull assertNotNull(worldConfig) assertEquals(-5176596003035866649, worldConfig.seed) @@ -58,7 +60,7 @@ class WorldConfigMangerTest : TestWithMockBukkit() { assertEquals(Material.DIRT, worldConfig.entryFeeCurrency) assertEquals(5.0, worldConfig.entryFeeAmount) - val world2Config = worldConfigManager.getWorldConfig("world.a.c").orNull + val world2Config = worldConfigManager.getWorldConfig(key("world.a.c")).orNull assertNotNull(world2Config) assertConfigEquals("/worlds/migrated_worlds.yml", "worlds.yml") @@ -66,21 +68,22 @@ class WorldConfigMangerTest : TestWithMockBukkit() { @Test fun `Add a new world to config`() { - val worldConfig = worldConfigManager.addWorldConfig("new.world") + val newWorldKey = NamespacedKey.minecraft("new.world") + val worldConfig = worldConfigManager.addWorldConfig(newWorldKey) assertNotNull(worldConfig) - assertEquals("new.world", worldConfig.worldName) + assertEquals(WorldKeyOrName.parseKey(newWorldKey), worldConfig.worldKeyOrName) assertTrue(worldConfigManager.save().isSuccess) assertConfigEquals("/worlds/newworld_worlds.yml", "worlds.yml") // Make sure the world still can be loaded after save assertTrue(worldConfigManager.load().isSuccess) - assertEquals("new.world", worldConfigManager.getWorldConfig("new.world").orNull?.worldName) + assertEquals(WorldKeyOrName.parseKey(newWorldKey), worldConfigManager.getWorldConfig(key("minecraft:new.world")).orNull?.worldKeyOrName) assertConfigEquals("/worlds/newworld_worlds.yml", "worlds.yml") } @Test fun `Updating existing world properties`() { - val worldConfig = worldConfigManager.getWorldConfig("world").orNull + val worldConfig = worldConfigManager.getWorldConfig(key("world")).orNull assertNotNull(worldConfig) assertTrue(worldConfig.stringPropertyHandle.setProperty("adjust-spawn", true).isSuccess) @@ -101,7 +104,7 @@ class WorldConfigMangerTest : TestWithMockBukkit() { @Test fun `Delete world section from config`() { - worldConfigManager.deleteWorldConfig("world") + worldConfigManager.deleteWorldConfig(key("world")) assertTrue(worldConfigManager.save().isSuccess) assertConfigEquals("/worlds/delete_worlds.yml", "worlds.yml") } @@ -115,7 +118,7 @@ class WorldConfigMangerTest : TestWithMockBukkit() { assertTrue(worldConfigManager.load().isSuccess) assertTrue(worldConfigManager.save().isSuccess) - val worldConfig = assertNotNull(worldConfigManager.getWorldConfig("world").orNull) + val worldConfig = assertNotNull(worldConfigManager.getWorldConfig(key("world")).orNull) assertEquals("1234", worldConfig.alias) assertTrue(worldConfig.bedRespawn) @@ -124,4 +127,8 @@ class WorldConfigMangerTest : TestWithMockBukkit() { assertEquals(listOf("a", "1", "2"), worldConfig.worldBlacklist) assertEquals(listOf(EntityType.COW), worldConfig.entitySpawnConfig.getSpawnCategoryConfig(SpawnCategory.ANIMAL).exceptions) } + + private fun key(worldName: String): WorldKeyOrName { + return WorldKeyOrName.parse(worldName).get() + } } diff --git a/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigTest.kt b/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigTest.kt index 218aad454..21329f628 100644 --- a/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigTest.kt @@ -2,6 +2,7 @@ package org.mvplugins.multiverse.core.world import org.bukkit.Material import org.mvplugins.multiverse.core.TestWithMockBukkit +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName import java.io.File import java.nio.file.Path import kotlin.io.path.absolutePathString @@ -23,10 +24,10 @@ class WorldConfigTest : TestWithMockBukkit() { throw IllegalStateException("WorldsConfigManager is not available as a service") } assertTrue(worldConfigManager.load().isSuccess) - worldConfig = worldConfigManager.getWorldConfig("world").orNull.takeIf { it != null } ?: run { + worldConfig = worldConfigManager.getWorldConfig(key("world")).orNull.takeIf { it != null } ?: run { throw IllegalStateException("WorldConfig for world is not available") } assertNotNull(worldConfig) - worldNetherConfig = worldConfigManager.getWorldConfig("world_nether").orNull.takeIf { it != null } ?: run { + worldNetherConfig = worldConfigManager.getWorldConfig(key("world_nether")).orNull.takeIf { it != null } ?: run { throw IllegalStateException("WorldConfig for world is not available") } assertNotNull(worldNetherConfig); } @@ -94,4 +95,8 @@ class WorldConfigTest : TestWithMockBukkit() { assertTrue(worldConfig.stringPropertyHandle.setProperty("invalid-property", false).isFailure) assertTrue(worldConfig.stringPropertyHandle.setProperty("version", 1.1).isFailure) } + + private fun key(worldName: String): WorldKeyOrName { + return WorldKeyOrName.parse(worldName).get() + } } diff --git a/src/test/resources/worlds/default_worlds.yml b/src/test/resources/worlds/default_worlds.yml index a6208a97a..2142e3218 100644 --- a/src/test/resources/worlds/default_worlds.yml +++ b/src/test/resources/worlds/default_worlds.yml @@ -13,7 +13,6 @@ world: enabled: false amount: 0.0 currency: '@vault-economy' - environment: normal gamemode: survival biome: '' generator: '' @@ -25,7 +24,6 @@ world: pvp: true respawn-world: '' scale: 1.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -75,7 +73,11 @@ world: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: normal + legacy-world-name: world + seed: -9223372036854775808 + version: 1.3 world_nether: adjust-spawn: false alias: '' @@ -91,7 +93,6 @@ world_nether: enabled: false amount: 0.0 currency: '@vault-economy' - environment: nether gamemode: survival biome: '' generator: '' @@ -103,7 +104,6 @@ world_nether: pvp: true respawn-world: '' scale: 8.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -153,4 +153,8 @@ world_nether: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: nether + legacy-world-name: world_nether + seed: -9223372036854775808 + version: 1.3 diff --git a/src/test/resources/worlds/delete_worlds.yml b/src/test/resources/worlds/delete_worlds.yml index b2f00770f..ed96af16f 100644 --- a/src/test/resources/worlds/delete_worlds.yml +++ b/src/test/resources/worlds/delete_worlds.yml @@ -13,7 +13,6 @@ world_nether: enabled: false amount: 0.0 currency: '@vault-economy' - environment: nether gamemode: survival biome: '' generator: '' @@ -25,7 +24,6 @@ world_nether: pvp: true respawn-world: '' scale: 8.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -75,4 +73,8 @@ world_nether: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: nether + legacy-world-name: world_nether + seed: -9223372036854775808 + version: 1.3 diff --git a/src/test/resources/worlds/edgecase_worlds.yml b/src/test/resources/worlds/edgecase_worlds.yml index c83f81098..cb534727e 100644 --- a/src/test/resources/worlds/edgecase_worlds.yml +++ b/src/test/resources/worlds/edgecase_worlds.yml @@ -13,7 +13,6 @@ world: enabled: false amount: 0.0 currency: dirt # should be parsed to @minecraft:dirt - environment: normal gamemode: sUrvivAl # random casing shouldn't affect parsing biome: '' generator: '' @@ -25,7 +24,6 @@ world: pvp: true respawn-world: '' scale: '4' # string number should be parsed as double - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -78,7 +76,11 @@ world: - a - 1 - 2 - version: 1.2 + read-only: + environment: normal + legacy-world-name: world + seed: -9223372036854775808 + version: 1.3 world_nether: adjust-spawn: false alias: '' @@ -93,7 +95,6 @@ world_nether: enabled: false amount: 0.0 currency: '@vault-economy' - environment: nether gamemode: survival biome: '' generator: '' @@ -105,7 +106,6 @@ world_nether: pvp: true respawn-world: '' scale: 8.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -155,4 +155,8 @@ world_nether: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: nether + legacy-world-name: world_nether + seed: -9223372036854775808 + version: 1.3 diff --git a/src/test/resources/worlds/migrated_worlds.yml b/src/test/resources/worlds/migrated_worlds.yml index 532db8cad..011777af7 100644 --- a/src/test/resources/worlds/migrated_worlds.yml +++ b/src/test/resources/worlds/migrated_worlds.yml @@ -13,7 +13,6 @@ world_the_end: enabled: false amount: 0.0 currency: '@vault-economy' - environment: the_end gamemode: survival biome: '' generator: '' @@ -25,7 +24,6 @@ world_the_end: pvp: true respawn-world: '' scale: 16.0 - seed: -5176596003035866649 spawn-location: ==: MVSpawnLocation x: 0.0 @@ -75,7 +73,11 @@ world_the_end: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: the_end + legacy-world-name: world_the_end + seed: -5176596003035866649 + version: 1.3 world[dot]a[dot]b: adjust-spawn: true alias: '' @@ -91,7 +93,6 @@ world[dot]a[dot]b: enabled: true amount: 5.0 currency: DIRT - environment: normal gamemode: survival biome: '' generator: '' @@ -103,7 +104,6 @@ world[dot]a[dot]b: pvp: true respawn-world: '' scale: 1.0 - seed: -5176596003035866649 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -154,7 +154,11 @@ world[dot]a[dot]b: tick-rate: '@unset' world-blacklist: - test - version: 1.2 + read-only: + environment: normal + legacy-world-name: world.a.b + seed: -5176596003035866649 + version: 1.3 world[dot]a[dot]c: adjust-spawn: true alias: '' @@ -170,7 +174,6 @@ world[dot]a[dot]c: enabled: true amount: 5.0 currency: DIRT - environment: normal gamemode: survival biome: '' generator: '' @@ -182,7 +185,6 @@ world[dot]a[dot]c: pvp: true respawn-world: '' scale: 1.0 - seed: -5176596003035866649 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -233,4 +235,8 @@ world[dot]a[dot]c: tick-rate: '@unset' world-blacklist: - test - version: 1.2 + read-only: + environment: normal + legacy-world-name: world.a.c + seed: -5176596003035866649 + version: 1.3 diff --git a/src/test/resources/worlds/newworld_worlds.yml b/src/test/resources/worlds/newworld_worlds.yml index 0f11f4fd9..76a2fdc0a 100644 --- a/src/test/resources/worlds/newworld_worlds.yml +++ b/src/test/resources/worlds/newworld_worlds.yml @@ -13,7 +13,6 @@ world: enabled: false amount: 0.0 currency: '@vault-economy' - environment: normal gamemode: survival biome: '' generator: '' @@ -25,7 +24,6 @@ world: pvp: true respawn-world: '' scale: 1.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -75,7 +73,11 @@ world: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: normal + legacy-world-name: world + seed: -9223372036854775808 + version: 1.3 world_nether: adjust-spawn: false alias: '' @@ -91,7 +93,6 @@ world_nether: enabled: false amount: 0.0 currency: '@vault-economy' - environment: nether gamemode: survival biome: '' generator: '' @@ -103,7 +104,6 @@ world_nether: pvp: true respawn-world: '' scale: 8.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -153,8 +153,12 @@ world_nether: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 -new[dot]world: + read-only: + environment: nether + legacy-world-name: world_nether + seed: -9223372036854775808 + version: 1.3 +minecraft:new[dot]world: adjust-spawn: false alias: '' allow-advancement-grant: true @@ -170,7 +174,6 @@ new[dot]world: enabled: false amount: 0.0 currency: '@vault-economy' - environment: normal gamemode: survival generator: '@error' hidden: false @@ -181,7 +184,6 @@ new[dot]world: pvp: true respawn-world: '' scale: 1.0 - seed: -9223372036854775808 spawn-location: ==: MVNullLocation (It's a bug if you see this in your config file) spawning: @@ -226,4 +228,8 @@ new[dot]world: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: normal + legacy-world-name: null + seed: -9223372036854775808 + version: 1.3 diff --git a/src/test/resources/worlds/properties_worlds.yml b/src/test/resources/worlds/properties_worlds.yml index 1bc355eec..6aa089acc 100644 --- a/src/test/resources/worlds/properties_worlds.yml +++ b/src/test/resources/worlds/properties_worlds.yml @@ -13,7 +13,6 @@ world: enabled: false amount: 0.0 currency: @vault-economy - environment: NORMAL gamemode: SURVIVAL biome: '' generator: '' @@ -25,7 +24,6 @@ world: pvp: true respawn-world: '' scale: 1.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -75,7 +73,11 @@ world: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: NORMAL + legacy-world-name: world + seed: -9223372036854775808 + version: 1.3 world_nether: adjust-spawn: false alias: '' @@ -91,7 +93,6 @@ world_nether: enabled: false amount: 0.0 currency: @vault-economy - environment: NETHER gamemode: SURVIVAL biome: '' generator: '' @@ -103,7 +104,6 @@ world_nether: pvp: true respawn-world: '' scale: 1.0 - seed: -9223372036854775808 spawn-location: ==: MVNullLocation (It's a bug if you see this in your config file) spawning: @@ -148,4 +148,8 @@ world_nether: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: NETHER + legacy-world-name: world_nether + seed: -9223372036854775808 + version: 1.3 diff --git a/src/test/resources/worlds/updated_worlds.yml b/src/test/resources/worlds/updated_worlds.yml index 8cffcc0ce..92aa795bf 100644 --- a/src/test/resources/worlds/updated_worlds.yml +++ b/src/test/resources/worlds/updated_worlds.yml @@ -13,7 +13,6 @@ world: enabled: false amount: 0.0 currency: '@vault-economy' - environment: normal gamemode: survival biome: '' generator: '' @@ -25,7 +24,6 @@ world: pvp: true respawn-world: '' scale: 1.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -50.0 @@ -77,7 +75,11 @@ world: spawn-limit: '@unset' exceptions: [ ] world-blacklist: [] - version: 1.2 + read-only: + environment: normal + legacy-world-name: world + seed: -9223372036854775808 + version: 1.3 world_nether: adjust-spawn: false alias: '' @@ -93,7 +95,6 @@ world_nether: enabled: false amount: 0.0 currency: '@vault-economy' - environment: nether gamemode: survival biome: '' generator: '' @@ -105,7 +106,6 @@ world_nether: pvp: true respawn-world: '' scale: 8.0 - seed: -9223372036854775808 spawn-location: ==: MVSpawnLocation x: -64.0 @@ -155,4 +155,8 @@ world_nether: spawn-limit: '@unset' tick-rate: '@unset' world-blacklist: [] - version: 1.2 + read-only: + environment: nether + legacy-world-name: world_nether + seed: -9223372036854775808 + version: 1.3 From 272e118eed157b9765e0fd85f00c317b3f752f84 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sat, 2 May 2026 23:20:25 +0800 Subject: [PATCH 04/26] Create, import and clone worlds with namespaced key --- .../core/commands/ImportCommand.java | 2 +- .../core/utils/ServerProperties.java | 18 ++ .../compatibility/BukkitCompatibility.java | 4 +- .../UnsafeValuesCompatibility.java | 41 ++++ .../WorldCreatorCompatibility.java | 107 +++++++++++ .../core/world/MultiverseWorld.java | 28 ++- .../multiverse/core/world/WorldManager.java | 180 +++++++++++------- .../core/world/helpers/DimensionFinder.java | 19 ++ .../world/helpers/WorldFolderResolver.java | 101 ++++++++++ .../core/world/helpers/WorldNameChecker.java | 69 ++++++- .../core/world/key/WorldKeyOrName.java | 63 +++++- .../world/key/WorldKeyParseFailReason.java | 9 - .../core/world/options/CloneWorldOptions.java | 59 +++++- .../world/options/CreateWorldOptions.java | 59 +++++- .../world/options/ImportWorldOptions.java | 61 +++++- .../world/reasons/CloneFailureReason.java | 9 + .../world/reasons/CreateFailureReason.java | 10 + .../world/reasons/ImportFailureReason.java | 9 + 18 files changed, 728 insertions(+), 120 deletions(-) create mode 100644 src/main/java/org/mvplugins/multiverse/core/utils/compatibility/UnsafeValuesCompatibility.java create mode 100644 src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java create mode 100644 src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldFolderResolver.java diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/ImportCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/ImportCommand.java index b0261aa44..ffee9d2c6 100644 --- a/src/main/java/org/mvplugins/multiverse/core/commands/ImportCommand.java +++ b/src/main/java/org/mvplugins/multiverse/core/commands/ImportCommand.java @@ -48,7 +48,7 @@ class ImportCommand extends CoreCommand { void onImportCommand( MVCommandIssuer issuer, - @Conditions("worldname:scope=new") + // @Conditions("worldname:scope=new") @Syntax("") @Description("{@@mv-core.import.name.description}") String worldName, diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/ServerProperties.java b/src/main/java/org/mvplugins/multiverse/core/utils/ServerProperties.java index 9a22d34e9..c99bc3b9d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/ServerProperties.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/ServerProperties.java @@ -3,6 +3,7 @@ import com.dumptruckman.minecraft.util.Logging; import io.vavr.control.Option; import jakarta.inject.Inject; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jvnet.hk2.annotations.Service; @@ -14,6 +15,21 @@ @Service public final class ServerProperties { + private static ServerProperties instance; + + /** + * A very bad static way of obtaining the level name from server.properties of older mc versions that + * don't have any way of getting main level name. + *
+ * For internal use ONLY! + * + * @return The level name if it can be obtained, else none. + */ + @ApiStatus.Internal + public static Option getStaticLevelName() { + return Option.of(instance).flatMap(ServerProperties::getLevelName); + } + private final Map properties; private final FileUtils fileUtils; @@ -22,6 +38,8 @@ public ServerProperties(@NotNull FileUtils fileUtils) { this.fileUtils = fileUtils; properties = new HashMap<>(); parseServerPropertiesFile(); + + instance = this; } private void parseServerPropertiesFile() { diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/BukkitCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/BukkitCompatibility.java index 1c3e49429..3ab34bbda 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/BukkitCompatibility.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/BukkitCompatibility.java @@ -50,7 +50,7 @@ public static boolean isUsingNewDimensionStorage() { * 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()}. *
- * After 26.1, PaperMC changed all worlds are stored in the "[level]/dimensions/minecraft" folder under the world + * After 26.1, PaperMC changed all worlds are stored in the "[level]/dimensions" 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. @@ -64,7 +64,7 @@ public static Path getWorldFoldersDirectory() { return GET_LEVEL_DIRECTORY_METHOD.flatMap(method -> ReflectHelper.tryInvokeMethod(server, method)) .filter(Path.class::isInstance) .map(Path.class::cast) - .map(path -> path.resolve("dimensions/minecraft")) + .map(path -> path.resolve("dimensions")) .getOrElse(() -> server.getWorldContainer().toPath()); } diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/UnsafeValuesCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/UnsafeValuesCompatibility.java new file mode 100644 index 000000000..9e5a8625c --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/UnsafeValuesCompatibility.java @@ -0,0 +1,41 @@ +package org.mvplugins.multiverse.core.utils.compatibility; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import org.bukkit.Bukkit; +import org.bukkit.UnsafeValues; +import org.jetbrains.annotations.ApiStatus; +import org.mvplugins.multiverse.core.utils.ReflectHelper; + +import java.lang.reflect.Method; + +/** + * Compatibility class used to handle API changes in {@link UnsafeValues} class. + * + * @since 5.6 + */ +@ApiStatus.AvailableSince("5.7") +public final class UnsafeValuesCompatibility { + + private static final Try GET_MAIN_LEVEL_NAME_METHOD; + + static { + GET_MAIN_LEVEL_NAME_METHOD = ReflectHelper.tryGetMethod(UnsafeValues.class, "getMainLevelName"); + } + + /** + * Try to call the getMainLevelName method in UnsafeValues, which is used to get level-name configured in + * server.properties file. + * + * @return The level-name configured in server.properties file, or empty if the method is not available. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static Option getMainLevelName() { + return GET_MAIN_LEVEL_NAME_METHOD + .flatMap(method -> ReflectHelper.tryInvokeMethod(Bukkit.getUnsafe(), method)) + .map(String.class::cast) + .toOption(); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java new file mode 100644 index 000000000..1592565bc --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java @@ -0,0 +1,107 @@ +package org.mvplugins.multiverse.core.utils.compatibility; + +import io.vavr.control.Try; +import org.bukkit.NamespacedKey; +import org.bukkit.WorldCreator; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.mvplugins.multiverse.core.utils.ReflectHelper; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; + +import java.lang.reflect.Method; +import java.util.Locale; + +/** + * Compatibility class used to handle {@link WorldCreator} API changes across different server versions. + * + * @since 5.7 + */ +@ApiStatus.AvailableSince("5.7") +public final class WorldCreatorCompatibility { + + private static final Try OF_KEY_METHOD; + private static final Try OF_NAME_AND_KEY_METHOD; + + static { + OF_KEY_METHOD = ReflectHelper.tryGetMethod(WorldCreator.class, "ofKey", NamespacedKey.class); + OF_NAME_AND_KEY_METHOD = ReflectHelper.tryGetMethod(WorldCreator.class, "ofNameAndKey", String.class, NamespacedKey.class); + } + + /** + * Check whether the current server version supports creating worlds with a {@link NamespacedKey}. + *
+ * This is based on whether the {@code ofKey(NamespacedKey)} method exists in the {@link WorldCreator} class, + * which was introduced in later server versions. + * + * @return True if the server supports world creation with NamespacedKey, else false. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static boolean canCreateWorldWithKey() { + return OF_KEY_METHOD.isSuccess(); + } + + /** + * Creates a {@link WorldCreator} from a {@link WorldKeyOrName}, using the most appropriate method + * based on the server version and the key/name state. + *
+ * If the server supports NamespacedKey-based creation and the provided keyOrName is a key, + * this method will use {@code ofKey()}. Otherwise, it falls back to {@code name()}. + * + * @param keyOrName Either a world key or name to create from. + * @return The {@link WorldCreator} initialized for the given key or name. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static WorldCreator ofKeyOrName(@NotNull WorldKeyOrName keyOrName) { + if (OF_KEY_METHOD.isSuccess() && keyOrName.isKey()) { + // Use namespace key + return WorldCreator.ofKey(keyOrName.usableKey()); + } + // Creating worlds with namespace key not allowed + return WorldCreator.name(keyOrName.usableName()); + } + + /** + * Creates a {@link WorldCreator} from a {@link NamespacedKey} and world name, using the most + * appropriate method based on the server version and dimension storage configuration. + *
+ * This method attempts to use the most feature-rich creation method available: + * if {@code ofNameAndKey()} is available and the parameters are compatible, use it; + * otherwise, if {@code ofKey()} is available, use it; as a fallback, use {@code name()}. + * + * @param worldKey The namespaced key for the world. + * @param worldName The display name for the world. + * @return The {@link WorldCreator} initialized with the given key and name. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static WorldCreator ofNameAndKey(@NotNull NamespacedKey worldKey, String worldName) { + if (OF_NAME_AND_KEY_METHOD.isSuccess() && canPassIntoNameAndKey(worldKey, worldName)) { + return WorldCreator.ofNameAndKey(worldName, worldKey); + } + if (OF_KEY_METHOD.isSuccess()) { + return WorldCreator.ofKey(worldKey); + } + return WorldCreator.name(worldName); + } + + /** + * Check if the given world key and name can be passed into the {@code ofNameAndKey()} method. + *
+ * The parameters are compatible only if the server is not using new dimension storage, + * or if the namespace is the Minecraft namespace and the world name (lowercased) matches the key. + * + * @param worldKey The namespaced key for the world. + * @param worldName The display name for the world. + * @return True if the parameters can be safely passed to {@code ofNameAndKey()}, else false. + */ + private static boolean canPassIntoNameAndKey(@NotNull NamespacedKey worldKey, @NotNull String worldName) { + return !BukkitCompatibility.isUsingNewDimensionStorage() + || (worldKey.getNamespace().equals(NamespacedKey.MINECRAFT) + && worldName.toLowerCase(Locale.ROOT).equals(worldKey.getKey())); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java b/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java index 52735f678..fb8a88e47 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java @@ -1,5 +1,6 @@ package org.mvplugins.multiverse.core.world; +import java.io.File; import java.util.List; import com.google.common.base.Strings; @@ -19,6 +20,7 @@ import org.mvplugins.multiverse.core.config.CoreConfig; import org.mvplugins.multiverse.core.config.handle.StringPropertyHandle; import org.mvplugins.multiverse.core.utils.text.ChatTextFormatter; +import org.mvplugins.multiverse.core.world.helpers.WorldFolderResolver; import org.mvplugins.multiverse.core.world.location.SpawnLocation; import org.mvplugins.multiverse.core.world.entity.EntitySpawnConfig; @@ -43,7 +45,7 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld { /** * The key that represents this world. This should now be used as the unique key for the world instead - * of the world name. The key cannot be changed. + * of the world name. Also note that the key cannot be modified. * * @return The world key */ @@ -54,10 +56,10 @@ public NamespacedKey getKey() { } /** - * Gets the name of this world. The name cannot be changed. + * Gets the name of this world. Prefer to use {@link #getKey()} as unique id instead of this name. + * Also note that the name cannot be modified. *
- * Note for plugin developers: Usually {@link #getAliasOrName()} - * is what you want to use instead of this method. + * Note for plugin developers: Usually {@link #getAliasOrName()}is what you want to use instead of this method. * * @return The name of the world as a String. */ @@ -66,6 +68,24 @@ public String getName() { return worldConfig.getLegacyWorldName(); } + /** + * Gets the folder where all the world contents are stored in. Generally, the location of the folder is at the server + * roots directory for pre-26.1 servers, and at "[level]/dimensions" folder under the world level directory for + * 26.1+ PaperMC servers. + *
+ * Note this folder location is based on Multiverse's understanding of Paper and Spigot's folder structure. If the + * server software does something weird, this folder will not reflect the actual world folder location. + *
+ * If the world is loaded, you should use {@link World#getWorldFolder()} instead as it is more accurate. + * This method is more of a fallback for when the world is not loaded. + * + * @return The world folder. + */ + @ApiStatus.AvailableSince("5.7") + public File getOfflineWorldFolder() { + return WorldFolderResolver.resolve(this); + } + /** * Gets the tab complete name of this world. Use alias if `resolve-alias-name` config is true, else use world name. * diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java index 7caa3903e..5ab71376d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java @@ -51,6 +51,7 @@ import org.mvplugins.multiverse.core.utils.ServerProperties; import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility; import org.mvplugins.multiverse.core.utils.compatibility.WorldCompatibility; +import org.mvplugins.multiverse.core.utils.compatibility.WorldCreatorCompatibility; import org.mvplugins.multiverse.core.utils.result.Attempt; import org.mvplugins.multiverse.core.utils.result.FailureReason; import org.mvplugins.multiverse.core.utils.FileUtils; @@ -60,7 +61,8 @@ import org.mvplugins.multiverse.core.world.generators.GeneratorProvider; import org.mvplugins.multiverse.core.world.helpers.DataStore.GameRulesStore; import org.mvplugins.multiverse.core.world.helpers.DataTransfer; -import org.mvplugins.multiverse.core.world.helpers.DimensionFinder.DimensionFormat; +import org.mvplugins.multiverse.core.world.helpers.DimensionFinder; +import org.mvplugins.multiverse.core.world.helpers.WorldFolderResolver; import org.mvplugins.multiverse.core.world.helpers.WorldNameChecker; import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; import org.mvplugins.multiverse.core.world.options.CloneWorldOptions; @@ -94,9 +96,6 @@ public final class WorldManager { "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"); - private final CaseInsensitiveStringMap worldsMap; private final CaseInsensitiveStringMap loadedWorldsMap; private final List unloadTracker; @@ -198,9 +197,10 @@ private void importExistingWorlds() { .collect(Collectors.toMap(World::getName, Function.identity())); serverProperties.getLevelName().peek(overworldName -> { + //TODO: check by namespaced key instead World overworld = bukkitWorlds.remove(overworldName); - World nether = bukkitWorlds.remove(DEFAULT_NETHER_FORMAT.replaceOverworld(overworldName)); - World end = bukkitWorlds.remove(DEFAULT_END_FORMAT.replaceOverworld(overworldName)); + World nether = bukkitWorlds.remove(DimensionFinder.DEFAULT_NETHER_FORMAT.replaceOverworld(overworldName)); + World end = bukkitWorlds.remove(DimensionFinder.DEFAULT_END_FORMAT.replaceOverworld(overworldName)); if (config.getAutoImportDefaultWorlds()) { importExistingBukkitWorld(overworld); @@ -247,33 +247,40 @@ private void autoLoadWorlds() { * @return The result of the creation. */ public Attempt createWorld(CreateWorldOptions options) { - return validateCreateWorldOptions(options).mapAttempt(this::doCreateWorld); + return parseCreateWorldOptionsKeyOrName(options).mapAttempt(this::doCreateWorld); } - private Attempt validateCreateWorldOptions( + private Attempt, CreateFailureReason> parseCreateWorldOptionsKeyOrName( CreateWorldOptions options) { - return Attempt.success(options) - .failIf(opts -> !worldNameChecker.isValidWorldName(opts.worldName()), - opts -> worldActionResult(CreateFailureReason.INVALID_WORLDNAME, opts.worldName())) - .failIf(opts -> getLoadedWorld(opts.worldName()).isDefined(), - opts -> worldActionResult(CreateFailureReason.WORLD_EXIST_LOADED, opts.worldName())) - .failIf(opts -> getWorld(opts.worldName()).isDefined(), - opts -> worldActionResult(CreateFailureReason.WORLD_EXIST_UNLOADED, opts.worldName())) - .failIf(opts -> opts.doFolderCheck() && worldNameChecker.hasWorldFolder(opts.worldName()), - opts -> worldActionResult(CreateFailureReason.WORLD_EXIST_FOLDER, opts.worldName())); + return options.keyOrName() + .fold(WorldKeyOrName::parse, Attempt::success) + .transform(CreateFailureReason.INVALID_WORLDNAME) + .failIf(keyOrName -> !worldNameChecker.isValidWorldName(keyOrName.usableName()), + keyOrName -> worldActionResult(CreateFailureReason.INVALID_WORLDNAME, keyOrName)) + .failIf(keyOrName -> keyOrName.isKey() && !WorldCreatorCompatibility.canCreateWorldWithKey(), + keyOrName -> worldActionResult(CreateFailureReason.NAMESPACEDKEY_UNSUPPORTED, keyOrName)) + .failIf(keyOrName -> getLoadedWorld(keyOrName.usableName()).isDefined(), + keyOrName -> worldActionResult(CreateFailureReason.WORLD_EXIST_LOADED, keyOrName)) + .failIf(keyOrName -> getWorld(keyOrName.usableName()).isDefined(), + keyOrName -> worldActionResult(CreateFailureReason.WORLD_EXIST_UNLOADED, keyOrName)) + .failIf(keyOrName -> options.doFolderCheck() && worldNameChecker.hasWorldFolder(keyOrName), + keyOrName -> worldActionResult(CreateFailureReason.WORLD_EXIST_FOLDER, keyOrName)) + .map(keyOrName -> new KeyOrNameWithOptions<>(keyOrName, options)); } private Attempt doCreateWorld( - CreateWorldOptions options) { - String generatorString = generatorProvider.parseGeneratorString(options.worldName(), options.generator()); - WorldCreator worldCreator = WorldCreator.name(options.worldName()) + KeyOrNameWithOptions keyOrNameWithOptions) { + WorldKeyOrName keyOrName = keyOrNameWithOptions.keyOrName(); + CreateWorldOptions options = keyOrNameWithOptions.options(); + String generatorString = generatorProvider.parseGeneratorString(keyOrName.usableName(), options.generator()); + WorldCreator worldCreator = WorldCreatorCompatibility.ofKeyOrName(keyOrName) .environment(options.environment()) .generateStructures(options.generateStructures()) .generatorSettings(options.generatorSettings()) .seed(options.seed()) .type(options.worldType()); - return addBiomeProviderToCreator(worldCreator, options.worldName(), options.biome()) + return addBiomeProviderToCreator(worldCreator, keyOrName.usableName(), options.biome()) .mapAttempt(creator -> addGeneratorToCreator(creator, generatorString)) .mapAttempt(this::createBukkitWorld) .transform(CreateFailureReason.WORLD_CREATOR_FAILED) @@ -301,43 +308,50 @@ private void postCreateWorld(LoadedMultiverseWorld loadedWorld, CreateWorldOptio * @return The result of the import. */ public Attempt importWorld(ImportWorldOptions options) { - String worldName = options.worldName(); - if (isLoadedWorld(worldName)) { - return worldActionResult(ImportFailureReason.WORLD_EXIST_LOADED, worldName); - } else if (isWorld(worldName)) { - return worldActionResult(ImportFailureReason.WORLD_EXIST_UNLOADED, worldName); - } - return Option.of(Bukkit.getWorld(worldName)) - .map(bukkitWorld -> doImportBukkitWorld(options, bukkitWorld)) - .getOrElse(() -> validateImportWorldOptions(options).mapAttempt(this::doImportWorld)); - } - - private Attempt validateImportWorldOptions(ImportWorldOptions options) { - String worldName = options.worldName(); - if (!worldNameChecker.isValidWorldName(worldName)) { - return worldActionResult(ImportFailureReason.INVALID_WORLDNAME, worldName); - } else if (options.doFolderCheck()) { + return options.keyOrName() + .fold(WorldKeyOrName::parse, Attempt::success) + .transform(ImportFailureReason.INVALID_WORLDNAME) + .failIf(keyOrName -> !worldNameChecker.isValidWorldName(keyOrName.usableName()), + keyOrName -> worldActionResult(ImportFailureReason.INVALID_WORLDNAME, keyOrName)) + .failIf(keyOrName -> keyOrName.isKey() && !WorldCreatorCompatibility.canCreateWorldWithKey(), + keyOrName -> worldActionResult(ImportFailureReason.NAMESPACEDKEY_UNSUPPORTED, keyOrName)) + .failIf(keyOrName -> getLoadedWorld(keyOrName.usableName()).isDefined(), + keyOrName -> worldActionResult(ImportFailureReason.WORLD_EXIST_LOADED, keyOrName)) + .failIf(keyOrName -> getWorld(keyOrName.usableName()).isDefined(), + keyOrName -> worldActionResult(ImportFailureReason.WORLD_EXIST_UNLOADED, keyOrName)) + .map(keyOrName -> new KeyOrNameWithOptions<>(keyOrName, options)) + .mapAttempt(pair -> Option.of(Bukkit.getWorld(pair.keyOrName().usableName())) + .map(bukkitWorld -> doImportBukkitWorld(pair, bukkitWorld)) + .getOrElse(() -> validateImportWorldOptions(pair).mapAttempt(this::doImportWorld))); + } + + private Attempt, ImportFailureReason> validateImportWorldOptions( + KeyOrNameWithOptions keyOrNameWithOptions) { + WorldKeyOrName keyOrName = keyOrNameWithOptions.keyOrName(); + if (keyOrNameWithOptions.options().doFolderCheck()) { //todo This is a duplicate of folder check in load world - WorldNameChecker.FolderStatus folderStatus = worldNameChecker.checkFolder(options.worldName()); + WorldNameChecker.FolderStatus folderStatus = worldNameChecker.checkFolder(keyOrName); if (!folderStatus.isLoadable()) { - return worldActionResult(ImportFailureReason.WORLD_FOLDER_INVALID, options.worldName()); + return worldActionResult(ImportFailureReason.WORLD_FOLDER_INVALID, keyOrName); } if (folderStatus == WorldNameChecker.FolderStatus.REQUIRES_MIGRATION) { Logging.info("World '%s' will be automatically migrated by PaperMC to the new dimension " + "location. If you face any issue with migration, please contact PaperMC support!", - options.worldName()); + keyOrName); } } - return worldActionResult(options); + return worldActionResult(keyOrNameWithOptions); } private Attempt doImportWorld( - ImportWorldOptions options) { - String generatorString = generatorProvider.parseGeneratorString(options.worldName(), options.generator()); - WorldCreator worldCreator = WorldCreator.name(options.worldName()) + KeyOrNameWithOptions keyOrNameWithOptions) { + WorldKeyOrName keyOrName = keyOrNameWithOptions.keyOrName(); + ImportWorldOptions options = keyOrNameWithOptions.options(); + String generatorString = generatorProvider.parseGeneratorString(keyOrName.usableName(), options.generator()); + WorldCreator worldCreator = WorldCreatorCompatibility.ofKeyOrName(keyOrName) .environment(options.environment()); - return addBiomeProviderToCreator(worldCreator, options.worldName(), options.biome()) + return addBiomeProviderToCreator(worldCreator, keyOrName.usableName(), options.biome()) .mapAttempt(creator -> addGeneratorToCreator(creator, generatorString)) .mapAttempt(this::createBukkitWorld) .transform(ImportFailureReason.WORLD_CREATOR_FAILED) @@ -349,7 +363,10 @@ private Attempt doImportWorld( .peek(loadedWorld -> pluginManager.callEvent(new MVWorldImportedEvent(loadedWorld))); } - private Attempt doImportBukkitWorld(ImportWorldOptions options, World bukkitWorld) { + private Attempt doImportBukkitWorld( + KeyOrNameWithOptions keyOrNameWithOptions, World bukkitWorld) { + WorldKeyOrName keyOrName = keyOrNameWithOptions.keyOrName(); + ImportWorldOptions options = keyOrNameWithOptions.options(); if (options.environment() != bukkitWorld.getEnvironment()) { return Attempt.failure(ImportFailureReason.BUKKIT_ENVIRONMENT_MISMATCH, Replace.WORLD.with(bukkitWorld.getName()), @@ -357,9 +374,11 @@ private Attempt doImportBukkitWorld( replace("{mvEnvironment}").with(options.environment().name())); } + //todo: check for key mismatch? + LoadedMultiverseWorld loadedWorld = newLoadedMultiverseWorld( bukkitWorld, - generatorProvider.parseGeneratorString(options.worldName(), options.generator()), + generatorProvider.parseGeneratorString(keyOrName.usableName(), options.generator()), options.biome(), options.useSpawnAdjust()); pluginManager.callEvent(new MVWorldImportedEvent(loadedWorld)); @@ -490,7 +509,7 @@ private Attempt doLoadWorld(@NotNull L } if (options.doFolderCheck()) { - WorldNameChecker.FolderStatus folderStatus = worldNameChecker.checkFolder(mvWorld.getName()); + WorldNameChecker.FolderStatus folderStatus = worldNameChecker.checkFolder(mvWorld.getOfflineWorldFolder()); if (!folderStatus.isLoadable()) { return worldActionResult(LoadFailureReason.WORLD_FOLDER_INVALID, mvWorld.getName()); } @@ -501,7 +520,7 @@ private Attempt doLoadWorld(@NotNull L } } - WorldCreator worldCreator = WorldCreator.name(mvWorld.getName()) + WorldCreator worldCreator = WorldCreatorCompatibility.ofNameAndKey(mvWorld.getKey(), mvWorld.getName()) .environment(mvWorld.getEnvironment()) .seed(mvWorld.getSeed()); return addBiomeProviderToCreator(worldCreator, mvWorld.getName(), mvWorld.getBiome()) @@ -755,14 +774,14 @@ private Attempt validateWorldToDelete( * @return The result of the clone. */ public Attempt cloneWorld(@NotNull CloneWorldOptions options) { - return cloneWorldValidateWorld(options) + return parseCloneWorldOptionsNewWorld(options) .mapAttempt(this::cloneWorldCopyFolder) - .mapAttempt(validatedOptions -> { + .mapAttempt(keyOrNameWithOptions -> { ImportWorldOptions importWorldOptions = ImportWorldOptions - .worldName(validatedOptions.newWorldName()) - .biome(validatedOptions.fromWorld().getBiome()) - .environment(validatedOptions.fromWorld().getEnvironment()) - .generator(validatedOptions.fromWorld().getGenerator()); + .worldKeyOrName(keyOrNameWithOptions.keyOrName()) + .biome(options.fromWorld().getBiome()) + .environment(options.fromWorld().getEnvironment()) + .generator(options.fromWorld().getGenerator()); return importWorld(importWorldOptions).transform(CloneFailureReason.IMPORT_FAILED); }) .onSuccess(newWorld -> { @@ -776,36 +795,43 @@ public Attempt cloneWorld(@NotNull Cl }); } - private Attempt cloneWorldValidateWorld( + private Attempt, CloneFailureReason> parseCloneWorldOptionsNewWorld( @NotNull CloneWorldOptions options) { - return Attempt.success(options.newWorldName()) - .failIf(name -> !worldNameChecker.isValidWorldName(name), - name -> worldActionResult(CloneFailureReason.INVALID_WORLDNAME, name)) - .failIf(this::isLoadedWorld, - name -> worldActionResult(CloneFailureReason.WORLD_EXIST_LOADED, name)) - .failIf(this::isWorld, - name -> worldActionResult(CloneFailureReason.WORLD_EXIST_UNLOADED, name)) + return options.newWorldKeyOrName() + .fold(WorldKeyOrName::parse, Attempt::success) + .transform(CloneFailureReason.INVALID_WORLDNAME) + .failIf(keyOrName -> !worldNameChecker.isValidWorldName(keyOrName.usableName()), + keyOrName -> worldActionResult(CloneFailureReason.INVALID_WORLDNAME, keyOrName)) + .failIf(keyOrName -> keyOrName.isKey() && !WorldCreatorCompatibility.canCreateWorldWithKey(), + keyOrName -> worldActionResult(CloneFailureReason.NAMESPACEDKEY_UNSUPPORTED, keyOrName)) + .failIf(keyOrName -> isLoadedWorld(keyOrName.usableName()), + keyOrName -> worldActionResult(CloneFailureReason.WORLD_EXIST_LOADED, keyOrName)) + .failIf(keyOrName -> isWorld(keyOrName.usableName()), + keyOrName -> worldActionResult(CloneFailureReason.WORLD_EXIST_UNLOADED, keyOrName)) .failIf(worldNameChecker::hasWorldFolder, - name -> worldActionResult(CloneFailureReason.WORLD_EXIST_FOLDER, name)) - .failIf(name -> !worldNameChecker.isValidWorldFolder(options.fromWorld().getName()), - name -> worldActionResult(CloneFailureReason.FROM_WORLD_FOLDER_INVALID, + keyOrName -> worldActionResult(CloneFailureReason.WORLD_EXIST_FOLDER, keyOrName)) + .failIf(keyOrName -> !worldNameChecker.isValidWorldFolder(options.fromWorld().getOfflineWorldFolder()), + keyOrName -> worldActionResult(CloneFailureReason.FROM_WORLD_FOLDER_INVALID, options.fromWorld().getName())) - .map(ignored -> options); + .map(keyOrName -> new KeyOrNameWithOptions<>(keyOrName, options)); } - private Attempt cloneWorldCopyFolder(@NotNull CloneWorldOptions options) { + private Attempt, CloneFailureReason> cloneWorldCopyFolder( + @NotNull KeyOrNameWithOptions keyOrNameWithOptions) { + WorldKeyOrName newWorldkeyOrName = keyOrNameWithOptions.keyOrName(); + CloneWorldOptions options = keyOrNameWithOptions.options(); if (options.saveBukkitWorld()) { options.fromWorld().asLoadedWorld().peek(loadedWorld -> { Logging.finer("Saving world before cloning: " + loadedWorld.getName()); loadedWorld.getBukkitWorld().peek(bukkitWorld -> WorldCompatibility.saveWithFlush(bukkitWorld, true)); }); } - File worldFolder = BukkitCompatibility.getWorldFoldersDirectory().resolve(options.fromWorld().getName()).toFile(); - File newWorldFolder = BukkitCompatibility.getWorldFoldersDirectory().resolve(options.newWorldName()).toFile(); + File worldFolder = WorldFolderResolver.resolve(options.fromWorld()); + File newWorldFolder = WorldFolderResolver.resolve(newWorldkeyOrName); return fileUtils.copyFolder(worldFolder, newWorldFolder, CLONE_IGNORE_FILES).fold( exception -> worldActionResult(CloneFailureReason.COPY_FAILED, options.fromWorld().getName(), exception), - success -> worldActionResult(options)); + success -> worldActionResult(keyOrNameWithOptions)); } private void cloneWorldTransferData(@NotNull CloneWorldOptions options, @NotNull LoadedMultiverseWorld newWorld) { @@ -894,7 +920,7 @@ private Attempt worldActionResult(@NotNull T } private Attempt.Failure worldActionResult( - @NotNull F failureReason, @NotNull String worldName) { + @NotNull F failureReason, @NotNull Object worldName) { return Attempt.failureRef(failureReason, Replace.WORLD.with(worldName)); } @@ -1244,4 +1270,14 @@ public Try saveWorldsConfig() { failure.printStackTrace(); }); } + + /** + * A simple pair wrapper for convenience to pass both the world key or name and the options together between methods + * when parsing world options. + * + * @param keyOrName The world key or name. + * @param options The world options. + * @param The world options type. + */ + private record KeyOrNameWithOptions(WorldKeyOrName keyOrName, T options) { } } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/helpers/DimensionFinder.java b/src/main/java/org/mvplugins/multiverse/core/world/helpers/DimensionFinder.java index 65071b64b..e19191558 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/helpers/DimensionFinder.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/helpers/DimensionFinder.java @@ -4,6 +4,7 @@ import io.vavr.control.Option; import jakarta.inject.Inject; import org.bukkit.World.Environment; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.config.CoreConfig; @@ -19,6 +20,24 @@ @Service public final class DimensionFinder { + /** + * Default world for nether worlds, The default is "%overworld_nether%", which means that the nether world of + * "world" will be named "world_nether". + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static final DimensionFormat DEFAULT_NETHER_FORMAT = new DimensionFormat("%overworld%_nether"); + + /** + * Default world name format for end worlds. The default is "%overworld%_the_end", which means that the end world + * of "world" will be named "world_the_end". + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static final DimensionFormat DEFAULT_END_FORMAT = new DimensionFormat("%overworld%_the_end"); + private final CoreConfig config; private final WorldManager worldManager; diff --git a/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldFolderResolver.java b/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldFolderResolver.java new file mode 100644 index 000000000..3faf43756 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldFolderResolver.java @@ -0,0 +1,101 @@ +package org.mvplugins.multiverse.core.world.helpers; + +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility; +import org.mvplugins.multiverse.core.world.MultiverseWorld; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; + +import java.io.File; + +/** + *

Utility class for resolving the file system location of a world folder.

+ * + *

This class handles the differences in world folder locations between server versions, + * particularly the new dimension storage system introduced in PaperMC 26.1 which stores worlds + * in a dimensions folder under the world level directory.

+ * + * @since 5.7 + */ +@ApiStatus.AvailableSince("5.7") +public final class WorldFolderResolver { + + /** + * Resolves the world folder file path for the given world key or name. + *
+ * This method automatically selects the appropriate resolution method based on whether the server + * is using the new dimension storage system introduced in PaperMC 26.1. + * + * @param keyOrName The world key or name to resolve. + * @return The resolved world folder file. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public static File resolve(@NotNull WorldKeyOrName keyOrName) { + return BukkitCompatibility.isUsingNewDimensionStorage() + ? resolveAsDimensionKey(keyOrName.usableKey()) + : resolveAsLegacyWorldName(keyOrName.usableName()); + } + + /** + * Resolves the world folder file path for the given Multiverse world. + *
+ * This method automatically selects the appropriate resolution method based on whether the server + * is using the new dimension storage system introduced in PaperMC 26.1. + * + * @param multiverseWorld The Multiverse world to resolve. + * @return The resolved world folder file. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public static File resolve(@NotNull MultiverseWorld multiverseWorld) { + return BukkitCompatibility.isUsingNewDimensionStorage() + ? resolveAsDimensionKey(multiverseWorld.getKey()) + : resolveAsLegacyWorldName(multiverseWorld.getName()); + } + + /** + * Gets the world folder using the new dimension storage path format of {@code [namespace]/[key]}. + *
+ * This method is only valid for PaperMC 26.1 and above. For earlier server versions, + * use {@link #resolveAsLegacyWorldName(String)} instead. + * + * @param namespacedKey The namespaced key of the world. + * @return The resolved world folder file using the new dimension storage format. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public static File resolveAsDimensionKey(@NotNull NamespacedKey namespacedKey) { + return BukkitCompatibility.getWorldFoldersDirectory() + .resolve(namespacedKey.getNamespace()) + .resolve(namespacedKey.getKey()) + .toFile(); + } + + /** + * Gets the world folder using the legacy world name format from the server root directory. + *
+ * This method is the default for server versions before PaperMC 26.1. + * + * @param worldName The name of the world. + * @return The resolved world folder file using the legacy world name format. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public static File resolveAsLegacyWorldName(@NotNull String worldName) { + return Bukkit.getWorldContainer() + .toPath() + .resolve(worldName) + .toFile(); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldNameChecker.java b/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldNameChecker.java index bea5c61b1..66848df5d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldNameChecker.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldNameChecker.java @@ -6,13 +6,13 @@ import java.util.Set; import io.vavr.control.Option; -import org.bukkit.Bukkit; import org.jetbrains.annotations.ApiStatus; 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; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; /** *

Utility class in helping to check the status of a world name and it's associated world folder.

@@ -68,7 +68,7 @@ public NameStatus checkName(@Nullable String worldName) { if (name.isEmpty()) { return NameStatus.EMPTY; } - if (BLACKLIST_NAMES.contains(name)) { + if (!BukkitCompatibility.isUsingNewDimensionStorage() && BLACKLIST_NAMES.contains(name)) { return NameStatus.BLACKLISTED; } if (!REPatterns.NAMESPACE_KEY.matcher(name).matches()) { @@ -84,21 +84,56 @@ public NameStatus checkName(@Nullable String worldName) { * * @param worldName The world name to check on. * @return True if the folder exists, else false. + * + * @deprecated Use {@link #hasWorldFolder(WorldKeyOrName)} instead, which is more robust and supports namespaced keys. */ + @Deprecated(forRemoval = true, since = "5.7") + @ApiStatus.ScheduledForRemoval(inVersion = "6.0") public boolean hasWorldFolder(@Nullable String worldName) { return checkFolder(worldName) != FolderStatus.DOES_NOT_EXIST; } + /** + * Check if a world name has a world folder directory. It may not contain valid world data. + * + * @param worldKey The world key to check on. + * @return True if the folder exists, else false. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public boolean hasWorldFolder(@Nullable WorldKeyOrName worldKey) { + return checkFolder(worldKey) != FolderStatus.DOES_NOT_EXIST; + } + /** * Checks if a world name has a valid world folder with basic world data. * * @param worldName The world name to check on. * @return True if check result is valid, else false. + * + * @deprecated Use {@link #isValidWorldFolder(WorldKeyOrName)} instead, which is more robust and supports + * namespaced keys. */ + @Deprecated(forRemoval = true, since = "5.7") + @ApiStatus.ScheduledForRemoval(inVersion = "6.0") public boolean isValidWorldFolder(@Nullable String worldName) { return checkFolder(worldName).loadable; } + /** + * Checks if a world name has a valid world folder with basic world data. + * + * @param nameOrKey The world key to check on. + * @return True if check result is valid, else false. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public boolean isValidWorldFolder(@Nullable WorldKeyOrName nameOrKey) { + return checkFolder(nameOrKey).loadable; + } + /** * Checks if a world folder is valid with basic world data. * @@ -114,23 +149,45 @@ public boolean isValidWorldFolder(@Nullable File worldFolder) { * * @param worldName The world name to check on. * @return The resulting folder status. + * + * @deprecated Use {@link #checkFolder(WorldKeyOrName)} instead, which is more robust and supports namespaced keys. */ + @Deprecated(forRemoval = true, since = "5.7") + @ApiStatus.ScheduledForRemoval(inVersion = "6.0") @NotNull public FolderStatus checkFolder(@Nullable String worldName) { if (worldName == null) { return FolderStatus.DOES_NOT_EXIST; } + return WorldKeyOrName.parse(worldName) + .map(this::checkFolder) + .getOrElse(FolderStatus.NOT_A_WORLD); + } + + /** + * Checks the current folder status for a world key or name. + * + * @param keyOrName The world key or name to check on. + * @return The resulting folder status. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull + public FolderStatus checkFolder(@Nullable WorldKeyOrName keyOrName) { + if (keyOrName == null) { + return FolderStatus.DOES_NOT_EXIST; + } + if (BukkitCompatibility.isUsingNewDimensionStorage()) { - File oldWorldFolder = Bukkit.getWorldContainer().toPath().resolve(worldName).toFile(); + File oldWorldFolder = WorldFolderResolver.resolveAsLegacyWorldName(keyOrName.usableName()); if (checkFolder(oldWorldFolder) == FolderStatus.VALID) { return FolderStatus.REQUIRES_MIGRATION; } - worldName = worldName.toLowerCase(Locale.ENGLISH); // namespace is case-insensitive and stored as lowercase } - File worldFolder = BukkitCompatibility.getWorldFoldersDirectory().resolve(worldName).toFile(); - return checkFolder(worldFolder); + return checkFolder(WorldFolderResolver.resolve(keyOrName)); } /** diff --git a/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyOrName.java b/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyOrName.java index 18c1953b2..8fdf94fb8 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyOrName.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyOrName.java @@ -9,7 +9,10 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.mvplugins.multiverse.core.locale.message.MessageReplacement; +import org.mvplugins.multiverse.core.utils.ServerProperties; +import org.mvplugins.multiverse.core.utils.compatibility.UnsafeValuesCompatibility; import org.mvplugins.multiverse.core.utils.result.Attempt; +import org.mvplugins.multiverse.core.world.helpers.DimensionFinder; import java.util.Locale; import java.util.Objects; @@ -22,6 +25,10 @@ @ApiStatus.AvailableSince("5.7") public sealed abstract class WorldKeyOrName implements Comparable permits WorldKeyOrName.Key, WorldKeyOrName.Name { + private static final String DEFAULT_OVERWORLD_KEY = "overworld"; + private static final String DEFAULT_NETHER_KEY = "the_nether"; + private static final String DEFAULT_END_KEY = "the_end"; + /** * Parse a string into a {@link WorldKeyOrName} instance. *

@@ -57,9 +64,7 @@ public static Attempt parse(@Nullable S */ @ApiStatus.AvailableSince("5.7") public static Attempt parseName(@NonNull String name) { - final String finalLowerCaseName = name.toLowerCase(Locale.ROOT); - //TODO: usable mapping for default level-name - return Try.of(() -> NamespacedKey.minecraft(finalLowerCaseName)) + return Try.of(() -> NamespacedKey.minecraft(mapWorldNameToMinecraftKey(name))) .map(usableKey -> Attempt.success(new Name(name, usableKey))) .recover(throwable -> Attempt.failure(WorldKeyParseFailReason.INVALID_WORLD_NAME, MessageReplacement.Replace.WORLD.with(name))) @@ -67,10 +72,23 @@ public static Attempt parseName(@NonNul MessageReplacement.Replace.WORLD.with(name))); } + private static String mapWorldNameToMinecraftKey(@NonNull String nameOrKey) { + String defaultLevelName = getMostAccurateLevelName(); + String lowerCaseName = nameOrKey.toLowerCase(Locale.ROOT); + if (defaultLevelName.equalsIgnoreCase(lowerCaseName)) { + lowerCaseName = DEFAULT_OVERWORLD_KEY; + } else if (DimensionFinder.DEFAULT_NETHER_FORMAT.replaceOverworld(defaultLevelName).equalsIgnoreCase(lowerCaseName)) { + lowerCaseName = DEFAULT_NETHER_KEY; + } else if (DimensionFinder.DEFAULT_END_FORMAT.replaceOverworld(defaultLevelName).equalsIgnoreCase(lowerCaseName)) { + lowerCaseName = DEFAULT_END_KEY; + } + return lowerCaseName; + } + /** * Parse a namespaced key string into a {@link WorldKeyOrName} instance. * - * @param nameOrKey The namespaced key string to parse (eg. "minecraft:world") + * @param nameOrKey The namespaced key string to parse (e.g. "minecraft:world") * @return An {@link Attempt} containing a {@link WorldKeyOrName.Key} on success or a * {@link WorldKeyParseFailReason#INVALID_NAMESPACED_KEY} failure on error * @@ -80,7 +98,7 @@ public static Attempt parseName(@NonNul public static Attempt parseKey(@NonNull String nameOrKey) { return Option.of(NamespacedKey.fromString(nameOrKey)) .filter(Objects::nonNull) - .map(key -> Attempt.success(new Key(key))) + .map(key -> Attempt.success(new Key(key, usableNameFromKey(key)))) .getOrElse(() -> Attempt.failure(WorldKeyParseFailReason.INVALID_NAMESPACED_KEY, MessageReplacement.Replace.NAMESPACE.with(nameOrKey))); } @@ -95,7 +113,33 @@ public static Attempt parseKey(@NonNull */ @ApiStatus.AvailableSince("5.7") public static WorldKeyOrName parseKey(@NonNull NamespacedKey key) { - return new Key(key); + return new Key(key, usableNameFromKey(key)); + } + + private static String usableNameFromKey(@NotNull NamespacedKey key) { + return key.getNamespace().equals(NamespacedKey.MINECRAFT) + ? mapMinecraftKeyToWorldName(key) + : mapCustomKeyToWorldName(key); + } + + private static String mapMinecraftKeyToWorldName(@NotNull NamespacedKey key) { + String defaultLevelName = getMostAccurateLevelName(); + return switch (key.getKey()) { + case DEFAULT_OVERWORLD_KEY -> defaultLevelName; + case DEFAULT_NETHER_KEY -> DimensionFinder.DEFAULT_NETHER_FORMAT.replaceOverworld(defaultLevelName); + case DEFAULT_END_KEY -> DimensionFinder.DEFAULT_END_FORMAT.replaceOverworld(defaultLevelName); + default -> key.getKey(); + }; + } + + private static String mapCustomKeyToWorldName(@NotNull NamespacedKey key) { + return key.getNamespace() + "_" + key.getKey(); + } + + private static String getMostAccurateLevelName() { + return UnsafeValuesCompatibility.getMainLevelName() + .orElse(ServerProperties::getStaticLevelName) + .getOrElse("world"); // Worse case assume its world } /** @@ -200,9 +244,11 @@ public int compareTo(@NonNull WorldKeyOrName o) { public static final class Key extends WorldKeyOrName { private final NamespacedKey key; + private final String usableName; - private Key(@NotNull NamespacedKey key) { + private Key(@NotNull NamespacedKey key, @NotNull String usableName) { this.key = key; + this.usableName = usableName; } @Override @@ -237,7 +283,7 @@ public boolean isKey() { @Override public @NotNull String usableName() { - return key.getKey(); + return usableName; } @Override @@ -261,6 +307,7 @@ public int hashCode() { public String toString() { return "Key{" + "key=" + key + + ", usableName='" + usableName + '\'' + '}'; } } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyParseFailReason.java b/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyParseFailReason.java index 32a5839e6..ffb9f4b3a 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyParseFailReason.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/key/WorldKeyParseFailReason.java @@ -40,15 +40,6 @@ public enum WorldKeyParseFailReason implements FailureReason { */ @ApiStatus.AvailableSince("5.7") INVALID_NAMESPACED_KEY(MVCorei18n.WORLDKEYPARSE_INVALIDNAMESPACEDKEY), - - /** - * The platform/server does not support namespaced keys for worlds. Only PaperMC can create worlds with - * namespaced keys. Spigot does not support it. - * - * @since 5.7 - */ - @ApiStatus.AvailableSince("5.7") - NAMESPACED_KEY_UNSUPPORTED(MVCorei18n.WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED), ; private final MessageKeyProvider message; diff --git a/src/main/java/org/mvplugins/multiverse/core/world/options/CloneWorldOptions.java b/src/main/java/org/mvplugins/multiverse/core/world/options/CloneWorldOptions.java index c64deff5b..3b4b1d518 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/options/CloneWorldOptions.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/options/CloneWorldOptions.java @@ -1,9 +1,12 @@ package org.mvplugins.multiverse.core.world.options; +import io.vavr.control.Either; +import org.bukkit.NamespacedKey; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; import org.mvplugins.multiverse.core.world.MultiverseWorld; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; /** * Options for customizing the cloning of a world. @@ -18,11 +21,13 @@ public final class CloneWorldOptions implements KeepWorldSettingsOptions { * @return A new {@link CloneWorldOptions} instance. * * @deprecated Cloning can be done from unloaded worlds as well. Use {@link #fromTo(MultiverseWorld, String)} instead. + * To ensure you use the non-deprecated method, you need to downcast the loaded world to a {@link MultiverseWorld} + * before passing it in, for example: {@code CloneWorldOptions.fromTo((MultiverseWorld) loadedWorld, 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); + return fromTo((MultiverseWorld) fromWorld, newWorldName); } /** @@ -36,20 +41,48 @@ public final class CloneWorldOptions implements KeepWorldSettingsOptions { */ @ApiStatus.AvailableSince("5.6") public static @NotNull CloneWorldOptions fromTo(@NotNull MultiverseWorld fromWorld, @NotNull String newWorldName) { - return new CloneWorldOptions(fromWorld, newWorldName); + return new CloneWorldOptions(fromWorld, Either.left(newWorldName)); + } + + /** + * Creates a new {@link CloneWorldOptions} instance with the given world and namespaced key. + * + * @param fromWorld The world to clone. + * @param key The namespaced key for the new world. + * @return A new {@link CloneWorldOptions} instance. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static @NotNull CloneWorldOptions fromTo(@NotNull MultiverseWorld fromWorld, @NotNull NamespacedKey key) { + return new CloneWorldOptions(fromWorld, Either.right(WorldKeyOrName.parseKey(key))); + } + + /** + * Creates a new {@link CloneWorldOptions} instance with the given world and world key or name. + * + * @param fromWorld The world to clone. + * @param keyOrName The key or name for the new world. + * @return A new {@link CloneWorldOptions} instance. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static @NotNull CloneWorldOptions fromTo(@NotNull MultiverseWorld fromWorld, @NotNull WorldKeyOrName keyOrName) { + return new CloneWorldOptions(fromWorld, Either.right(keyOrName)); } private final MultiverseWorld fromWorld; - private final String newWorldName; + private final Either newWorldKeyOrName; private boolean keepGameRule = true; private boolean keepWorldConfig = true; private boolean saveBukkitWorld = true; private boolean keepWorldBorder = true; - CloneWorldOptions(MultiverseWorld fromWorld, String newWorldName) { + CloneWorldOptions(MultiverseWorld fromWorld, Either newWorldKeyOrName) { this.fromWorld = fromWorld; - this.newWorldName = newWorldName; + this.newWorldKeyOrName = newWorldKeyOrName; } /** @@ -77,13 +110,27 @@ public LoadedMultiverseWorld world() { return fromWorld; } + /** + * Gets the new world key or name, either unparsed as string or the {@link WorldKeyOrName} instance. + * + * @return The new world key or name. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Either newWorldKeyOrName() { + return newWorldKeyOrName; + } + /** * Gets the name of the new world. * * @return The name of the new world. */ + @Deprecated(forRemoval = true, since = "5.7") + @ApiStatus.ScheduledForRemoval(inVersion = "6.0") public @NotNull String newWorldName() { - return newWorldName; + return newWorldKeyOrName.fold(name -> name, WorldKeyOrName::usableName); } /** diff --git a/src/main/java/org/mvplugins/multiverse/core/world/options/CreateWorldOptions.java b/src/main/java/org/mvplugins/multiverse/core/world/options/CreateWorldOptions.java index 325acafe7..2a32e96ff 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/options/CreateWorldOptions.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/options/CreateWorldOptions.java @@ -1,12 +1,15 @@ package org.mvplugins.multiverse.core.world.options; import co.aikar.commands.ACFUtil; +import io.vavr.control.Either; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.WorldType; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnmodifiableView; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; import java.util.Collections; import java.util.HashMap; @@ -24,10 +27,39 @@ public final class CreateWorldOptions { * @return A new {@link CreateWorldOptions} instance. */ public static @NotNull CreateWorldOptions worldName(@NotNull String worldName) { - return new CreateWorldOptions(worldName); + return new CreateWorldOptions(Either.left(worldName)); } - private final String worldName; + /** + * Creates a new {@link CreateWorldOptions} instance with the given namespaced key. Note that creating world with + * namespace requires PaperMC. This will not work on Spigot. + * + * @param key The namespaced key for the world to create. + * @return A new {@link CreateWorldOptions} instance. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static @NotNull CreateWorldOptions worldKey(@NotNull NamespacedKey key) { + return new CreateWorldOptions(Either.right(WorldKeyOrName.parseKey(key))); + } + + /** + * Creates a new {@link CreateWorldOptions} instance with the given world key or name. Note that creating world with + * namespace requires PaperMC. WorldKeyOrName parsed as namespaced key (i.e. {@link WorldKeyOrName#isKey()} is true) + * will not work on Spigot. + * + * @param keyOrName The key or name for the world to create. + * @return A new {@link CreateWorldOptions} instance. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static @NotNull CreateWorldOptions worldKeyOrName(@NotNull WorldKeyOrName keyOrName) { + return new CreateWorldOptions(Either.right(keyOrName)); + } + + private final Either keyOrName; private String biome = ""; private World.Environment environment = World.Environment.NORMAL; private boolean generateStructures = true; @@ -40,22 +72,35 @@ public final class CreateWorldOptions { private final Map worldPropertyStrings = new HashMap<>(); /** - * Creates a new {@link CreateWorldOptions} instance with the given world name. + * Creates a new {@link CreateWorldOptions} instance with either a world name or world key or name. * - * @param worldName The name of the world to create. + * @param keyOrName Either the world name or the world key/name instance. */ - CreateWorldOptions(@NotNull String worldName) { - this.worldName = worldName; + CreateWorldOptions(@NotNull Either keyOrName) { + this.keyOrName = keyOrName; this.seed = ACFUtil.RANDOM.nextLong(); } + /** + * Gets the new world key or name, either unparsed as string or the {@link WorldKeyOrName} instance. + * + * @return The new world key or name. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Either keyOrName() { + return keyOrName; + } + /** * Gets the name of the world to create. * * @return The name of the world to create. */ + @Deprecated(forRemoval = true, since = "5.7") public @NotNull String worldName() { - return worldName; + return keyOrName.fold(name -> name ,WorldKeyOrName::usableName); } /** diff --git a/src/main/java/org/mvplugins/multiverse/core/world/options/ImportWorldOptions.java b/src/main/java/org/mvplugins/multiverse/core/world/options/ImportWorldOptions.java index 3403e8374..b735755e2 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/options/ImportWorldOptions.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/options/ImportWorldOptions.java @@ -1,9 +1,12 @@ package org.mvplugins.multiverse.core.world.options; +import io.vavr.control.Either; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; /** * Options for customizing the import of a new world. @@ -17,18 +20,64 @@ public final class ImportWorldOptions { * @return A new {@link ImportWorldOptions} instance. */ public static @NotNull ImportWorldOptions worldName(@NotNull String worldName) { - return new ImportWorldOptions(worldName); + return new ImportWorldOptions(Either.left(worldName)); } - private final String worldName; + /** + * Creates a new {@link ImportWorldOptions} instance with the given namespaced key. Note that importing world with + * namespace requires PaperMC. This will not work on Spigot. + * + * @param key The namespaced key for the world to import. + * @return A new {@link ImportWorldOptions} instance. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static @NotNull ImportWorldOptions worldKey(@NotNull NamespacedKey key) { + return new ImportWorldOptions(Either.right(WorldKeyOrName.parseKey(key))); + } + + /** + * Creates a new {@link ImportWorldOptions} instance with the given world key or name. Note that importing world with + * namespace requires PaperMC. WorldKeyOrName parsed as namespaced key (i.e. {@link WorldKeyOrName#isKey()} is true) + * will not work on Spigot. + * + * @param keyOrName The key or name for the world to import. + * @return A new {@link ImportWorldOptions} instance. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static @NotNull ImportWorldOptions worldKeyOrName(@NotNull WorldKeyOrName keyOrName) { + return new ImportWorldOptions(Either.right(keyOrName)); + } + + private final Either keyOrName; private String biome = ""; private World.Environment environment = World.Environment.NORMAL; private String generator = null; private boolean useSpawnAdjust = true; private boolean doFolderCheck = true; - ImportWorldOptions(String worldName) { - this.worldName = worldName; + /** + * Creates a new {@link ImportWorldOptions} instance with either a world name or world key or name. + * + * @param keyOrName Either the world name or the world key/name instance. + */ + ImportWorldOptions(Either keyOrName) { + this.keyOrName = keyOrName; + } + + /** + * Gets the new world key or name, either unparsed as string or the {@link WorldKeyOrName} instance. + * + * @return The new world key or name. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Either keyOrName() { + return keyOrName; } /** @@ -36,8 +85,10 @@ public final class ImportWorldOptions { * * @return The name of the world to create. */ + @Deprecated(forRemoval = true, since = "5.7") + @ApiStatus.ScheduledForRemoval(inVersion = "6.0") public @NotNull String worldName() { - return worldName; + return keyOrName.fold(name -> name ,WorldKeyOrName::usableName); } /** diff --git a/src/main/java/org/mvplugins/multiverse/core/world/reasons/CloneFailureReason.java b/src/main/java/org/mvplugins/multiverse/core/world/reasons/CloneFailureReason.java index 2a2a6650d..8554847f2 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/reasons/CloneFailureReason.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/reasons/CloneFailureReason.java @@ -16,6 +16,15 @@ public enum CloneFailureReason implements FailureReason { */ INVALID_WORLDNAME(MVCorei18n.CLONEWORLD_INVALIDWORLDNAME), + /** + * The server software does not support create worlds using namespaced key. Only legacy world name is supported. + * Generally this will only be an issue on Spigot servers. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + NAMESPACEDKEY_UNSUPPORTED(MVCorei18n.WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED), + /** * The target new world folder already exists. */ diff --git a/src/main/java/org/mvplugins/multiverse/core/world/reasons/CreateFailureReason.java b/src/main/java/org/mvplugins/multiverse/core/world/reasons/CreateFailureReason.java index b61783591..79abdcf5c 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/reasons/CreateFailureReason.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/reasons/CreateFailureReason.java @@ -3,6 +3,7 @@ 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; @@ -15,6 +16,15 @@ public enum CreateFailureReason implements FailureReason { */ INVALID_WORLDNAME(MVCorei18n.CREATEWORLD_INVALIDWORLDNAME), + /** + * The server software does not support create worlds using namespaced key. Only legacy world name is supported. + * Generally this will only be an issue on Spigot servers. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + NAMESPACEDKEY_UNSUPPORTED(MVCorei18n.WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED), + /** * The target new world folder already exists. */ diff --git a/src/main/java/org/mvplugins/multiverse/core/world/reasons/ImportFailureReason.java b/src/main/java/org/mvplugins/multiverse/core/world/reasons/ImportFailureReason.java index 5dc150480..3172b9467 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/reasons/ImportFailureReason.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/reasons/ImportFailureReason.java @@ -16,6 +16,15 @@ public enum ImportFailureReason implements FailureReason { */ INVALID_WORLDNAME(MVCorei18n.IMPORTWORLD_INVALIDWORLDNAME), + /** + * The server software does not support create worlds using namespaced key. Only legacy world name is supported. + * Generally this will only be an issue on Spigot servers. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + NAMESPACEDKEY_UNSUPPORTED(MVCorei18n.WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED), + /** * The world folder is invalid. */ From a6cbe444114ca4bc8298d0441eeebd7df2c7dbaa Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 3 May 2026 12:39:01 +0800 Subject: [PATCH 05/26] Add world key to output in `/mv info` command --- .../java/org/mvplugins/multiverse/core/commands/InfoCommand.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/InfoCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/InfoCommand.java index ef1e65e95..1e1831c4d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/commands/InfoCommand.java +++ b/src/main/java/org/mvplugins/multiverse/core/commands/InfoCommand.java @@ -87,6 +87,7 @@ public void onInfoCommand( private Map getInfo(LoadedMultiverseWorld world) { Map outMap = new LinkedHashMap<>(); + outMap.put("World Key", world.getKey().toString()); outMap.put("World Name", world.getName()); outMap.put("World Alias", world.getAlias()); outMap.put("World UID", world.getUID().toString()); From 30524899520206e34fbb03ee988670a71e7b8910 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 3 May 2026 12:55:53 +0800 Subject: [PATCH 06/26] Move getCoordinateScale to WorldCompatibility --- .../compatibility/WorldCompatibility.java | 23 +++++++++++++++++++ .../multiverse/core/world/WorldManager.java | 14 +---------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCompatibility.java index 3f574d011..eb87bd09d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCompatibility.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCompatibility.java @@ -1,5 +1,6 @@ package org.mvplugins.multiverse.core.utils.compatibility; +import io.papermc.lib.PaperLib; import io.vavr.control.Try; import org.bukkit.World; import org.jetbrains.annotations.ApiStatus; @@ -40,6 +41,28 @@ public static void saveWithFlush(World world, boolean flush) { .orElseRun(ignore -> world.save()); } + /** + * Gets the coordinate scale for the world, which is used to convert between overworld and nether coordinates. + * On PaperMC, this can be obtained directly from the API. On older versions, it is manually determined based on the + * world environment (overworld = 1, nether = 8, end = 1). + * + * @param world The world to get the coordinate scale for + * @return The scale + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static double getCoordinateScale(World world) { + if (PaperLib.isPaper()) { + return world.getCoordinateScale(); + } + return switch (world.getEnvironment()) { + case NORMAL -> 1; + case NETHER -> 8; + default -> 1; + }; + } + private WorldCompatibility() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java index 5ab71376d..61dff7ddc 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java @@ -14,7 +14,6 @@ import com.dumptruckman.minecraft.util.Logging; import com.google.common.base.Strings; -import io.papermc.lib.PaperLib; import io.vavr.control.Option; import io.vavr.control.Try; import jakarta.inject.Inject; @@ -412,7 +411,7 @@ private LoadedMultiverseWorld newLoadedMultiverseWorld( worldConfig.setLegacyWorldName(world.getName()); worldConfig.setDifficulty(world.getDifficulty()); worldConfig.setKeepSpawnInMemory(world.getKeepSpawnInMemory()); - worldConfig.setScale(getCoordinateScale(world)); + worldConfig.setScale(WorldCompatibility.getCoordinateScale(world)); worldConfig.save(); @@ -431,17 +430,6 @@ private LoadedMultiverseWorld newLoadedMultiverseWorld( return loadedWorld; } - private double getCoordinateScale(World world) { - if (PaperLib.isPaper()) { - return world.getCoordinateScale(); - } - return switch (world.getEnvironment()) { - case NORMAL -> 1; - case NETHER -> 8; - default -> 1; - }; - } - /** * Loads an existing world in config. * From ee40eb5822ef0dc2ef5686fe010650edcb4ce8ed Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 3 May 2026 12:56:44 +0800 Subject: [PATCH 07/26] Prevent instantiation of utility classes by adding private constructors --- .../core/utils/compatibility/UnsafeValuesCompatibility.java | 4 ++++ .../core/utils/compatibility/WorldCreatorCompatibility.java | 4 ++++ .../multiverse/core/world/helpers/WorldFolderResolver.java | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/UnsafeValuesCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/UnsafeValuesCompatibility.java index 9e5a8625c..593c3d017 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/UnsafeValuesCompatibility.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/UnsafeValuesCompatibility.java @@ -38,4 +38,8 @@ public static Option getMainLevelName() { .map(String.class::cast) .toOption(); } + + private UnsafeValuesCompatibility() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } } diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java index 1592565bc..67c692815 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java @@ -104,4 +104,8 @@ private static boolean canPassIntoNameAndKey(@NotNull NamespacedKey worldKey, @N || (worldKey.getNamespace().equals(NamespacedKey.MINECRAFT) && worldName.toLowerCase(Locale.ROOT).equals(worldKey.getKey())); } + + private WorldCreatorCompatibility() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldFolderResolver.java b/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldFolderResolver.java index 3faf43756..35376478a 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldFolderResolver.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/helpers/WorldFolderResolver.java @@ -98,4 +98,8 @@ public static File resolveAsLegacyWorldName(@NotNull String worldName) { .resolve(worldName) .toFile(); } + + private WorldFolderResolver() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } } From c291df201f9c740e8403ebca5bf32d7067cd14c3 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 3 May 2026 17:28:49 +0800 Subject: [PATCH 08/26] Add support for looking up worlds with namespace key string --- .../core/world/WorldConfigNodes.java | 3 + .../multiverse/core/world/WorldManager.java | 112 +++++------ .../multiverse/core/world/WorldStore.java | 184 ++++++++++++++++++ 3 files changed, 233 insertions(+), 66 deletions(-) create mode 100644 src/main/java/org/mvplugins/multiverse/core/world/WorldStore.java diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java index 5ed123ffa..143bf1088 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java @@ -24,6 +24,7 @@ import org.mvplugins.multiverse.core.config.node.NodeGroup; import org.mvplugins.multiverse.core.economy.MVEconomist; import org.mvplugins.multiverse.core.utils.MaterialConverter; +import org.mvplugins.multiverse.core.utils.text.ChatTextFormatter; import org.mvplugins.multiverse.core.world.helpers.EnforcementHandler; import org.mvplugins.multiverse.core.world.location.NullSpawnLocation; import org.mvplugins.multiverse.core.world.location.SpawnLocation; @@ -90,6 +91,8 @@ private ConfigNode node(ConfigNode.Builder nodeBuilder) { .onLoadAndChange((oldValue, newValue) -> { if (world == null) return; world.updateColourlessAlias(); + worldManager.getWorldStore().changeAlias( + ChatTextFormatter.removeColor(oldValue), ChatTextFormatter.removeColor(newValue), world); })); final ConfigNode allowAdvancementGrant = node(ConfigNode.builder("allow-advancement-grant", Boolean.class) diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java index 5ab71376d..cf23d263a 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java @@ -29,6 +29,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.config.CoreConfig; @@ -47,7 +48,6 @@ import org.mvplugins.multiverse.core.permissions.CorePermissions; import org.mvplugins.multiverse.core.teleportation.BlockSafety; import org.mvplugins.multiverse.core.teleportation.LocationManipulation; -import org.mvplugins.multiverse.core.utils.CaseInsensitiveStringMap; import org.mvplugins.multiverse.core.utils.ServerProperties; import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility; import org.mvplugins.multiverse.core.utils.compatibility.WorldCompatibility; @@ -55,7 +55,6 @@ import org.mvplugins.multiverse.core.utils.result.Attempt; import org.mvplugins.multiverse.core.utils.result.FailureReason; import org.mvplugins.multiverse.core.utils.FileUtils; -import org.mvplugins.multiverse.core.utils.text.ChatTextFormatter; import org.mvplugins.multiverse.core.world.biomeprovider.BiomeProviderFactory; import org.mvplugins.multiverse.core.world.entity.EntityPurger; import org.mvplugins.multiverse.core.world.generators.GeneratorProvider; @@ -96,8 +95,7 @@ public final class WorldManager { "data/paper/metadata.dat" // New papermc format for 26.1+ ); - private final CaseInsensitiveStringMap worldsMap; - private final CaseInsensitiveStringMap loadedWorldsMap; + private final WorldStore worldStore; private final List unloadTracker; private final List loadTracker; private final WorldsConfigManager worldsConfigManager; @@ -115,6 +113,7 @@ public final class WorldManager { @Inject WorldManager( + @NotNull WorldStore worldStore, @NotNull WorldsConfigManager worldsConfigManager, @NotNull WorldNameChecker worldNameChecker, @NotNull BiomeProviderFactory biomeProviderFactory, @@ -127,6 +126,7 @@ public final class WorldManager { @NotNull ServerProperties serverProperties, @NotNull CoreConfig config, @NotNull EntityPurger entityPurger) { + this.worldStore = worldStore; this.worldsConfigManager = worldsConfigManager; this.worldNameChecker = worldNameChecker; this.biomeProviderFactory = biomeProviderFactory; @@ -140,8 +140,6 @@ public final class WorldManager { this.config = config; this.entityPurger = entityPurger; - this.worldsMap = new CaseInsensitiveStringMap<>(); - this.loadedWorldsMap = new CaseInsensitiveStringMap<>(); this.unloadTracker = new ArrayList<>(); this.loadTracker = new ArrayList<>(); } @@ -173,9 +171,13 @@ private Try updateWorldsFromConfig() { } private void loadNewWorldConfigs(Collection newWorldConfigs) { - newWorldConfigs.forEach(worldConfig -> Option.of(worldsMap.get(worldConfig.getWorldKeyOrName().usableName())) - .peek(unloadedWorld -> unloadedWorld.setWorldConfig(worldConfig)) - .onEmpty(() -> newMultiverseWorld(worldConfig))); + newWorldConfigs.forEach(worldConfig -> { + worldStore.getUnloadedWorldRef(worldConfig.getWorldKeyOrName().usableName()) + .peek(unloadedWorld -> unloadedWorld.setWorldConfig(worldConfig)) + .onEmpty(() -> newMultiverseWorld(worldConfig)); + worldStore.getLoadedWorld(worldConfig.getWorldKeyOrName().usableName()) + .peek(loadedWorld -> loadedWorld.setWorldConfig(worldConfig)); + }); } private void removeWorldsNotInConfigs(Collection removedWorlds) { @@ -387,7 +389,7 @@ private Attempt doImportBukkitWorld( private MultiverseWorld newMultiverseWorld(WorldConfig worldConfig) { MultiverseWorld mvWorld = new MultiverseWorld(worldConfig, config); - worldsMap.put(mvWorld.getName(), mvWorld); + worldStore.putUnloadedWorld(mvWorld); corePermissions.addWorldPermissions(mvWorld); return mvWorld; } @@ -425,7 +427,7 @@ private LoadedMultiverseWorld newLoadedMultiverseWorld( locationManipulation, entityPurger ); - loadedWorldsMap.put(loadedWorld.getName(), loadedWorld); + worldStore.putLoadedWorld(loadedWorld); saveWorldsConfig(); pluginManager.callEvent(new MVWorldLoadedEvent(loadedWorld)); return loadedWorld; @@ -568,7 +570,7 @@ private Attempt newLoadedMultiverseWor locationManipulation, entityPurger ); - loadedWorldsMap.put(loadedWorld.getName(), loadedWorld); + worldStore.putLoadedWorld(loadedWorld); saveWorldsConfig(); pluginManager.callEvent(new MVWorldLoadedEvent(loadedWorld)); return Attempt.success(loadedWorld); @@ -602,12 +604,10 @@ public Attempt unloadWorld(@NotNull Unload success -> removeLoadedMultiverseWorld(world)); } - private Attempt removeLoadedMultiverseWorld(@NotNull LoadedMultiverseWorld world) { - MultiverseWorld mvWorld = Objects.requireNonNull(loadedWorldsMap.remove(world.getName()), - "For some reason, the loaded world isn't in the map... BUGGG"); - Logging.fine("Removed MultiverseWorld from map: " + world.getName()); - var unloadedWorld = Objects.requireNonNull(worldsMap.get(world.getName()), - "For some reason, the unloaded world isn't in the map... BUGGG"); + private Attempt removeLoadedMultiverseWorld(@NotNull LoadedMultiverseWorld mvWorld) { + MultiverseWorld unloadedWorld = worldStore.getUnloadedWorldRef(mvWorld.getKey().toString()).getOrElseThrow( + () -> new IllegalStateException("Unloaded ref of world not found: " + mvWorld)); + worldStore.removeLoadedWorld(mvWorld); mvWorld.getWorldConfig().setMVWorld(unloadedWorld); pluginManager.callEvent(new MVWorldUnloadedEvent(mvWorld)); return worldActionResult(unloadedWorld); @@ -697,7 +697,7 @@ private Attempt unloadBeforeRemoveWorld(@NotNull Lo */ private Attempt removeWorldFromConfig(@NotNull MultiverseWorld world) { // Remove world from config - worldsMap.remove(world.getName()); + worldStore.removeWorld(world); world.getWorldConfig().deferenceMVWorld(); worldsConfigManager.deleteWorldConfig(world.getKey()); saveWorldsConfig(); @@ -1054,9 +1054,7 @@ public Option getWorld(@Nullable World world) { * @return The world if it exists. */ public Option getWorld(@Nullable String worldName) { - return getLoadedWorld(worldName) - .map(world -> (MultiverseWorld) world) - .orElse(() -> getUnloadedWorld(worldName)); + return worldStore.getWorld(worldName); } /** @@ -1067,9 +1065,7 @@ public Option getWorld(@Nullable String worldName) { * @return The world if it exists. */ public Option getWorldByNameOrAlias(@Nullable String worldNameOrAlias) { - return getLoadedWorldByNameOrAlias(worldNameOrAlias) - .map(world -> (MultiverseWorld) world) - .orElse(() -> getUnloadedWorldByNameOrAlias(worldNameOrAlias)); + return getWorld(worldStore.translateAlias(worldNameOrAlias)); } /** @@ -1079,14 +1075,15 @@ public Option getWorldByNameOrAlias(@Nullable String worldNameO *

If you want only unloaded worlds, use {@link #getUnloadedWorlds()}. If you want only loaded worlds, use * {@link #getLoadedWorlds()}.

* + *

Note that this is an unmodifiable copy of the current worlds. It will not update as worlds are added/removed. + * Call it everytime you need the most updated list of worlds.

+ * * @return A list of all worlds that may or may not be loaded. */ + @Unmodifiable + @NotNull public Collection getWorlds() { - return worldsMap.values().stream() - .map(world -> getLoadedWorld(world) - .map(loadedWorld -> (MultiverseWorld) loadedWorld) - .getOrElse(world)) - .toList(); + return worldStore.getWorlds(); } /** @@ -1096,7 +1093,7 @@ public Collection getWorlds() { * @return True if the world is a world is known to multiverse, but may or may not be loaded. */ public boolean isWorld(@Nullable String worldName) { - return worldName != null && worldsMap.containsKey(worldName); + return worldName != null && worldStore.getWorld(worldName).isDefined(); } /** @@ -1106,9 +1103,7 @@ public boolean isWorld(@Nullable String worldName) { * @return The world if it exists. */ public Option getUnloadedWorld(@Nullable String worldName) { - return isLoadedWorld(worldName) - ? Option.none() - : Option.of(worldName).flatMap(name -> Option.of(worldsMap.get(name))); + return worldStore.getUnloadedWorld(worldName); } /** @@ -1118,18 +1113,7 @@ public Option getUnloadedWorld(@Nullable String worldName) { * @return The world if it exists. */ public Option getUnloadedWorldByNameOrAlias(@Nullable String worldNameOrAlias) { - return getUnloadedWorld(worldNameOrAlias).orElse(() -> getUnloadedWorldByAlias(worldNameOrAlias)); - } - - private Option getUnloadedWorldByAlias(@Nullable String alias) { - if (alias == null || alias.isEmpty()) { - return Option.none(); - } - String colourlessAlias = ChatTextFormatter.removeColor(alias); - return Option.ofOptional(worldsMap.values().stream() - .filter(world -> !world.isLoaded()) - .filter(world -> world.getColourlessAlias().equalsIgnoreCase(colourlessAlias)) - .findFirst()); + return getUnloadedWorld(worldStore.translateAlias(worldNameOrAlias)); } /** @@ -1137,10 +1121,10 @@ private Option getUnloadedWorldByAlias(@Nullable String alias) * * @return A list of all worlds that are not loaded. */ + @Unmodifiable + @NotNull public Collection getUnloadedWorlds() { - return worldsMap.values().stream() - .filter(world -> !world.isLoaded()) - .toList(); + return worldStore.getUnloadedWorlds(); } /** @@ -1180,8 +1164,7 @@ public Option getLoadedWorld(@Nullable MultiverseWorld wo * @return The multiverse world if it exists. */ public Option getLoadedWorld(@Nullable String worldName) { - return Option.of(worldName) - .flatMap(name -> Option.of(loadedWorldsMap.get(name))); + return Option.of(worldName).flatMap(worldStore::getLoadedWorld); } /** @@ -1191,28 +1174,21 @@ public Option getLoadedWorld(@Nullable String worldName) * @return The multiverse world if it exists. */ public Option getLoadedWorldByNameOrAlias(@Nullable String worldNameOrAlias) { - return getLoadedWorld(worldNameOrAlias) - .orElse(() -> getLoadedWorldByAlias(worldNameOrAlias)); - } - - private Option getLoadedWorldByAlias(@Nullable String alias) { - if (alias == null || alias.isEmpty()) { - return Option.none(); - } - return Option.ofOptional(loadedWorldsMap.values().stream() - .filter(world -> world.getColourlessAlias() - .equalsIgnoreCase(ChatTextFormatter.removeColor(alias))) - .findFirst()); + return getLoadedWorld(worldStore.translateAlias(worldNameOrAlias)); } /** * Get a read-only list of all multiverse worlds that are loaded. * + *

Note that this is an unmodifiable copy of the current worlds. It will not update as worlds are added/removed. + * Call it everytime you need the most updated list of worlds.

+ * * @return A list of all multiverse worlds that are loaded. */ + @Unmodifiable + @NotNull public Collection getLoadedWorlds() { - return loadedWorldsMap.values().stream() - .toList(); + return worldStore.getLoadedWorlds(); } /** @@ -1242,7 +1218,7 @@ public boolean isLoadedWorld(@Nullable MultiverseWorld world) { * @return True if the world is a multiverse world that is loaded. */ public boolean isLoadedWorld(@Nullable String worldName) { - return worldName != null && loadedWorldsMap.containsKey(worldName); + return worldName != null && worldStore.getLoadedWorld(worldName).isDefined(); } /** @@ -1271,6 +1247,10 @@ public Try saveWorldsConfig() { }); } + WorldStore getWorldStore() { + return worldStore; + } + /** * A simple pair wrapper for convenience to pass both the world key or name and the options together between methods * when parsing world options. diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldStore.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldStore.java new file mode 100644 index 000000000..16da4b016 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldStore.java @@ -0,0 +1,184 @@ +package org.mvplugins.multiverse.core.world; + +import com.google.common.base.Strings; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import io.vavr.control.Option; +import jakarta.inject.Inject; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.utils.CaseInsensitiveStringMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +final class WorldStore { + + /** + * Only loaded worlds, i.e. will be empty if all worlds are unloaded. + */ + private final List loadedList; + + /** + * Only unloaded worlds, i.e. will be empty if all worlds are loaded. + */ + private final List unloadedList; + + /** + * Contains single reference to all worlds, either loaded or unloaded ref depending on the world's current status. + */ + private final List worldList; + + /** + * Maps all key and key to loaded ref. + */ + private final Map loadedMap; + + /** + * Maps all key and key to unloaded ref. + */ + private final Map unloadedMap; + + /** + * Maps colorless alias to world key. As alias may not be unique, this is a multimap. + */ + private final Multimap aliasMap; + + @Inject + private WorldStore() { + this.loadedList = new ArrayList<>(); + this.unloadedList = new ArrayList<>(); + this.worldList = new ArrayList<>(); + + this.loadedMap = new CaseInsensitiveStringMap<>(); + this.unloadedMap = new CaseInsensitiveStringMap<>(); + this.aliasMap = HashMultimap.create(); + } + + void putUnloadedWorld(MultiverseWorld world) { + if (world instanceof LoadedMultiverseWorld) { + throw new IllegalArgumentException("Loaded world cannot be put in unloaded map"); + } + + unloadedMap.put(world.getKey().toString(), world); + unloadedMap.put(world.getName(), world); + + unloadedList.add(world); + worldList.add(world); + } + + void putLoadedWorld(LoadedMultiverseWorld world) { + loadedMap.put(world.getKey().toString(), world); + loadedMap.put(world.getName(), world); + + unloadedList.remove(unloadedMap.get(world.getKey().toString())); + worldList.remove(unloadedMap.get(world.getKey().toString())); + + loadedList.add(world); + worldList.add(world); + } + + void removeWorld(MultiverseWorld world) { + LoadedMultiverseWorld loadedRef = loadedMap.get(world.getKey().toString()); + MultiverseWorld unloadedRef = unloadedMap.get(world.getKey().toString()); + + // remove from list + unloadedList.remove(unloadedRef); + loadedList.remove(loadedRef); + worldList.remove(unloadedRef); + worldList.remove(loadedRef); + + // remove from maps + loadedMap.remove(world.getKey().toString()); + unloadedMap.remove(world.getName()); + + unloadedMap.remove(world.getKey().toString()); + unloadedMap.remove(world.getName()); + + // remove alias + aliasMap.remove(world.getColourlessAlias(), world.getKey().toString()); + } + + void removeLoadedWorld(LoadedMultiverseWorld world) { + // remove from list + loadedList.remove(world); + worldList.remove(world); + + // remove from maps + loadedMap.remove(world.getKey().toString()); + loadedMap.remove(world.getName()); + + // Add back unloaded + unloadedList.add(unloadedMap.get(world.getKey().toString())); + } + + void changeAlias(@Nullable String oldAlias, @Nullable String newAlias, @NotNull MultiverseWorld world) { + if (Objects.equals(oldAlias, newAlias)) { + // nothing changed, ignore + return; + } + if (!Strings.isNullOrEmpty(oldAlias)) { + aliasMap.remove(oldAlias, world.getKey().toString()); + } + if (!Strings.isNullOrEmpty(newAlias)) { + aliasMap.put(newAlias, world.getKey().toString()); + } + } + + @Unmodifiable + @NotNull + List getWorlds() { + return List.copyOf(worldList); + } + + @Unmodifiable + @NotNull + List getLoadedWorlds() { + return List.copyOf(loadedList); + } + + @Unmodifiable + @NotNull + List getUnloadedWorlds() { + return List.copyOf(unloadedList); + } + + @NotNull + Option getWorld(@Nullable String worldKeyString) { + return Option.of((MultiverseWorld) loadedMap.get(worldKeyString)) + .orElse(() -> Option.of(unloadedMap.get(worldKeyString))); + } + + @NotNull + Option getLoadedWorld(@Nullable String worldKeyString) { + return Option.of(loadedMap.get(worldKeyString)) + .filter(MultiverseWorld::isLoaded); + } + + @NotNull + Option getUnloadedWorld(@Nullable String worldKeyString) { + return Option.of(unloadedMap.get(worldKeyString)) + .filter(world -> !world.isLoaded()); + } + + @NotNull + Option getUnloadedWorldRef(@Nullable String worldKeyString) { + return Option.of(unloadedMap.get(worldKeyString)); + } + + @Nullable + String translateAlias(@Nullable String worldNameOrAlias) { + if (Strings.isNullOrEmpty(worldNameOrAlias)) { + return null; + } + //TODO: Not sure if we should fail if there is multiple alias of the same name, but for now just return the first one + return aliasMap.get(worldNameOrAlias).stream() + .findFirst() + .orElse(worldNameOrAlias); + } +} From f470df6f2c2b84dfb7c2784ade98b273f21cbbbc Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 3 May 2026 17:32:47 +0800 Subject: [PATCH 09/26] Fix isIteratingOverLevels field not found --- .../mvplugins/multiverse/core/utils/WorldTickDeferrer.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/WorldTickDeferrer.java b/src/main/java/org/mvplugins/multiverse/core/utils/WorldTickDeferrer.java index a5c0ffea3..7d5c0f484 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/WorldTickDeferrer.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/WorldTickDeferrer.java @@ -30,7 +30,9 @@ public final class WorldTickDeferrer { .flatMap(getServerMethod -> ReflectHelper.tryInvokeMethod(server, getServerMethod)) .onFailure(throwable -> Logging.fine("Unable to find console.")) .toOption(); - this.isIteratingOverLevelsMethod = ReflectHelper.tryGetField(console.getClass(), "isIteratingOverLevels") + this.isIteratingOverLevelsMethod = console.toTry() + .map(Object::getClass) + .flatMap(consoleClazz -> ReflectHelper.tryGetField(consoleClazz, "isIteratingOverLevels")) .onFailure(throwable -> Logging.fine("Unable to find isIteratingOverLevels field.")) .toOption(); } From 944acd8387aef911e6a43b8b1ae692312f419735 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 3 May 2026 20:01:39 +0800 Subject: [PATCH 10/26] Add support for potential worlds finding with new 26.1 dimensions format --- .../core/command/MVCommandCompletions.java | 8 +- .../multiverse/core/world/WorldManager.java | 23 +++--- .../world/helpers/PotentialWorldFinder.java | 81 +++++++++++++++++++ .../multiverse/core/world/WorldManagerTest.kt | 11 --- .../world/helper/PotentialWorldFinderTest.kt | 44 ++++++++++ 5 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 src/main/java/org/mvplugins/multiverse/core/world/helpers/PotentialWorldFinder.java create mode 100644 src/test/java/org/mvplugins/multiverse/core/world/helper/PotentialWorldFinderTest.kt diff --git a/src/main/java/org/mvplugins/multiverse/core/command/MVCommandCompletions.java b/src/main/java/org/mvplugins/multiverse/core/command/MVCommandCompletions.java index 167ef6c5f..2fbeddf9a 100644 --- a/src/main/java/org/mvplugins/multiverse/core/command/MVCommandCompletions.java +++ b/src/main/java/org/mvplugins/multiverse/core/command/MVCommandCompletions.java @@ -52,6 +52,7 @@ import org.mvplugins.multiverse.core.world.WorldManager; import org.mvplugins.multiverse.core.world.generators.GeneratorPlugin; import org.mvplugins.multiverse.core.world.generators.GeneratorProvider; +import org.mvplugins.multiverse.core.world.helpers.PotentialWorldFinder; import static org.mvplugins.multiverse.core.utils.StringFormatter.addOnToCommaSeparated; @@ -65,6 +66,7 @@ public class MVCommandCompletions extends PaperCommandCompletions { private final CorePermissionsChecker corePermissionsChecker; private final AnchorManager anchorManager; private final GeneratorProvider generatorProvider; + private final PotentialWorldFinder potentialWorldFinder; @Inject MVCommandCompletions( @@ -74,7 +76,8 @@ public class MVCommandCompletions extends PaperCommandCompletions { @NotNull CoreConfig config, @NotNull CorePermissionsChecker corePermissionsChecker, @NotNull AnchorManager anchorManager, - @NotNull GeneratorProvider generatorProvider + @NotNull GeneratorProvider generatorProvider, + @NotNull PotentialWorldFinder potentialWorldFinder ) { super(mvCommandManager); this.commandManager = mvCommandManager; @@ -84,6 +87,7 @@ public class MVCommandCompletions extends PaperCommandCompletions { this.corePermissionsChecker = corePermissionsChecker; this.anchorManager = anchorManager; this.generatorProvider = generatorProvider; + this.potentialWorldFinder = potentialWorldFinder; registerAsyncCompletion("anchornames", this::suggestAnchorNames); registerAsyncCompletion("commands", this::suggestCommands); @@ -297,7 +301,7 @@ private Collection suggestMVWorlds(BukkitCommandCompletionContext contex .toList(); } case "potential" -> { - return worldManager.getPotentialWorlds(); + return potentialWorldFinder.findPotentialWorlds(); } } Logging.severe("Invalid MVWorld scope: " + scope); diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java index 8d1e6778a..52fe3da15 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java @@ -4,7 +4,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -17,6 +16,7 @@ import io.vavr.control.Option; import io.vavr.control.Try; import jakarta.inject.Inject; +import jakarta.inject.Provider; import org.bukkit.Bukkit; import org.bukkit.GameRule; import org.bukkit.Location; @@ -48,7 +48,6 @@ import org.mvplugins.multiverse.core.teleportation.BlockSafety; import org.mvplugins.multiverse.core.teleportation.LocationManipulation; import org.mvplugins.multiverse.core.utils.ServerProperties; -import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility; import org.mvplugins.multiverse.core.utils.compatibility.WorldCompatibility; import org.mvplugins.multiverse.core.utils.compatibility.WorldCreatorCompatibility; import org.mvplugins.multiverse.core.utils.result.Attempt; @@ -60,6 +59,7 @@ import org.mvplugins.multiverse.core.world.helpers.DataStore.GameRulesStore; import org.mvplugins.multiverse.core.world.helpers.DataTransfer; import org.mvplugins.multiverse.core.world.helpers.DimensionFinder; +import org.mvplugins.multiverse.core.world.helpers.PotentialWorldFinder; import org.mvplugins.multiverse.core.world.helpers.WorldFolderResolver; import org.mvplugins.multiverse.core.world.helpers.WorldNameChecker; import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; @@ -109,6 +109,7 @@ public final class WorldManager { private final ServerProperties serverProperties; private final CoreConfig config; private final EntityPurger entityPurger; + private final Provider potentialWorldFinder; @Inject WorldManager( @@ -124,7 +125,8 @@ public final class WorldManager { @NotNull CorePermissions corePermissions, @NotNull ServerProperties serverProperties, @NotNull CoreConfig config, - @NotNull EntityPurger entityPurger) { + @NotNull EntityPurger entityPurger, + @NotNull Provider potentialWorldFinder) { this.worldStore = worldStore; this.worldsConfigManager = worldsConfigManager; this.worldNameChecker = worldNameChecker; @@ -138,6 +140,7 @@ public final class WorldManager { this.serverProperties = serverProperties; this.config = config; this.entityPurger = entityPurger; + this.potentialWorldFinder = potentialWorldFinder; this.unloadTracker = new ArrayList<>(); this.loadTracker = new ArrayList<>(); @@ -1010,17 +1013,13 @@ private void throwUnloadException(World world) throws MultiverseWorldException { * Checks based on folder contents and name. * * @return A list of all potential worlds. + * + * @deprecated Use {@link PotentialWorldFinder#findPotentialWorlds()} instead. */ + @Deprecated(forRemoval = true, since = "5.7") + @ApiStatus.ScheduledForRemoval(inVersion = "6.0") public List getPotentialWorlds() { - File[] files = BukkitCompatibility.getWorldFoldersDirectory().toFile().listFiles(); - if (files == null) { - return Collections.emptyList(); - } - return Arrays.stream(files) - .filter(file -> BukkitCompatibility.getWorldByNameOrKey(file.getName()).isEmpty()) - .filter(worldNameChecker::isValidWorldFolder) - .map(File::getName) - .toList(); + return potentialWorldFinder.get().findPotentialWorlds(); } /** diff --git a/src/main/java/org/mvplugins/multiverse/core/world/helpers/PotentialWorldFinder.java b/src/main/java/org/mvplugins/multiverse/core/world/helpers/PotentialWorldFinder.java new file mode 100644 index 000000000..bb393513f --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/world/helpers/PotentialWorldFinder.java @@ -0,0 +1,81 @@ +package org.mvplugins.multiverse.core.world.helpers; + +import io.vavr.control.Option; +import jakarta.inject.Inject; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility; +import org.mvplugins.multiverse.core.world.WorldManager; + +import java.io.File; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +/** + * Tool that traversals the server folders to find all potential worlds that can be imported, based on folder contents + * and name. + * + * @since 5.7 + */ +@ApiStatus.AvailableSince("5.7") +@Service +public final class PotentialWorldFinder { + + private final WorldManager worldManager; + private final WorldNameChecker worldNameChecker; + + @Inject + private PotentialWorldFinder(@NotNull WorldManager worldManager, @NotNull WorldNameChecker worldNameChecker) { + this.worldManager = worldManager; + this.worldNameChecker = worldNameChecker; + } + + /** + * Gets a list of all potential worlds that can be loaded from the server folders. + * Checks based on folder contents and name. + * + * @return A list of all potential worlds. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @Unmodifiable + @NotNull + public List findPotentialWorlds() { + return BukkitCompatibility.isUsingNewDimensionStorage() + ? Stream.concat(findFromDimensionsFolder(), findFromRootFolder()).toList() + : findFromRootFolder().toList(); + } + + private Stream findFromRootFolder() { + Path worldContainer = Bukkit.getWorldContainer().toPath(); + return Arrays.stream(listFolders(worldContainer)) + .filter(worldNameChecker::isValidWorldFolder) + .map(File::getName) + .filter(worldNameChecker::isValidWorldName) + .filter(worldName -> !worldManager.isWorld(worldName)); + } + + private Stream findFromDimensionsFolder() { + Path worldContainer = BukkitCompatibility.getWorldFoldersDirectory(); + return Arrays.stream(listFolders(worldContainer)) + .map(File::getName) + .flatMap(namespace -> Arrays.stream(listFolders(worldContainer.resolve(namespace))) + .filter(worldNameChecker::isValidWorldFolder) + .map(File::getName) + .map(key -> namespace + ":" + key)) + .filter(namespacedKey -> NamespacedKey.fromString(namespacedKey) != null) + .filter(namespacedKey -> !worldManager.isWorld(namespacedKey)); + } + + private @NotNull File[] listFolders(Path folder) { + return Option.of(folder.toFile().listFiles(File::isDirectory)) + .getOrElse(() -> new File[0]); + } +} diff --git a/src/test/java/org/mvplugins/multiverse/core/world/WorldManagerTest.kt b/src/test/java/org/mvplugins/multiverse/core/world/WorldManagerTest.kt index 36bf4efe5..32edb3935 100644 --- a/src/test/java/org/mvplugins/multiverse/core/world/WorldManagerTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/world/WorldManagerTest.kt @@ -276,17 +276,6 @@ class WorldManagerTest : TestWithMockBukkit() { ) } - @Test - 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()) - } - @Test fun `Get world with alias`() { world.setAlias("testalias") diff --git a/src/test/java/org/mvplugins/multiverse/core/world/helper/PotentialWorldFinderTest.kt b/src/test/java/org/mvplugins/multiverse/core/world/helper/PotentialWorldFinderTest.kt new file mode 100644 index 000000000..1d383d16d --- /dev/null +++ b/src/test/java/org/mvplugins/multiverse/core/world/helper/PotentialWorldFinderTest.kt @@ -0,0 +1,44 @@ +package org.mvplugins.multiverse.core.world.helper + +import org.bukkit.Bukkit +import org.mvplugins.multiverse.core.TestWithMockBukkit +import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld +import org.mvplugins.multiverse.core.world.WorldManager +import org.mvplugins.multiverse.core.world.helpers.PotentialWorldFinder +import org.mvplugins.multiverse.core.world.options.CreateWorldOptions +import java.io.File +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PotentialWorldFinderTest : TestWithMockBukkit() { + + private lateinit var potentialWorldFinder: PotentialWorldFinder + private lateinit var worldManager: WorldManager + private lateinit var world: LoadedMultiverseWorld + + @BeforeTest + fun setUp() { + potentialWorldFinder = serviceLocator.getService(PotentialWorldFinder::class.java).takeIf { it != null } ?: run { + throw IllegalStateException("PotentialWorldFinder is not available as a service") } + worldManager = serviceLocator.getActiveService(WorldManager::class.java).takeIf { it != null } ?: run { + throw IllegalStateException("WorldManager is not available as a service") } + + assertTrue(worldManager.createWorld(CreateWorldOptions.worldName("world")).isSuccess) + world = worldManager.getLoadedWorld("world").get() + assertNotNull(world) + } + + @Test + 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"), potentialWorldFinder.findPotentialWorlds().toSet()) + } +} From 3e62084b2386d331f31611b67cd845cf3925a138 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 3 May 2026 20:42:03 +0800 Subject: [PATCH 11/26] Fix command typo in spawn adjustment logging message --- .../mvplugins/multiverse/core/world/LoadedMultiverseWorld.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/mvplugins/multiverse/core/world/LoadedMultiverseWorld.java b/src/main/java/org/mvplugins/multiverse/core/world/LoadedMultiverseWorld.java index 5370e748b..ea285cbae 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/LoadedMultiverseWorld.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/LoadedMultiverseWorld.java @@ -77,7 +77,7 @@ private Location readSpawnFromWorld(World world) { Logging.fine("Spawn location from world.dat file was unsafe!!"); Logging.fine("NOT adjusting spawn for '" + this.getAliasOrName() + "' because you told me not to."); Logging.fine("To turn on spawn adjustment for this world simply type:"); - Logging.fine("/mvm set adjustspawn true " + this.getAliasOrName()); + Logging.fine("/mv modify %s set adjust-spawn true", getName()); return location; } From 35133ee647220fb615c969797e64bec92b4eab9a Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Mon, 4 May 2026 14:09:21 +0800 Subject: [PATCH 12/26] Add support for creating world with bonus chest and force spawn --- .../core/commands/CreateCommand.java | 32 +++++-- .../command/MVInvalidCommandArgument.java | 18 ++++ .../WorldCreatorCompatibility.java | 84 +++++++++++++++++++ .../multiverse/core/world/WorldManager.java | 3 +- .../world/options/CreateWorldOptions.java | 61 ++++++++++++++ 5 files changed, 189 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/CreateCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/CreateCommand.java index 7be73b0a6..3dbcd3be7 100644 --- a/src/main/java/org/mvplugins/multiverse/core/commands/CreateCommand.java +++ b/src/main/java/org/mvplugins/multiverse/core/commands/CreateCommand.java @@ -11,6 +11,7 @@ import co.aikar.commands.annotation.Subcommand; import co.aikar.commands.annotation.Syntax; import com.dumptruckman.minecraft.util.Logging; +import io.vavr.control.Try; import jakarta.inject.Inject; import org.bukkit.World; import org.bukkit.WorldType; @@ -24,9 +25,11 @@ import org.mvplugins.multiverse.core.command.flag.CommandValueFlag; import org.mvplugins.multiverse.core.command.flag.FlagBuilder; import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags; +import org.mvplugins.multiverse.core.exceptions.command.MVInvalidCommandArgument; import org.mvplugins.multiverse.core.locale.MVCorei18n; import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace; import org.mvplugins.multiverse.core.utils.StringFormatter; +import org.mvplugins.multiverse.core.utils.position.EntityPosition; import org.mvplugins.multiverse.core.utils.result.Attempt.Failure; import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; import org.mvplugins.multiverse.core.world.WorldManager; @@ -52,8 +55,9 @@ class CreateCommand extends CoreCommand { @Subcommand("create") @CommandPermission("multiverse.core.create") @CommandCompletion("@empty @environments @flags:groupName=" + Flags.NAME) - @Syntax(" [--seed --generator --world-type --adjust-spawn " - + "--no-structures --biome --properties ]") + @Syntax(" [--seed --generator --world-type " + + "--adjust-spawn --no-structures --generate-bonus-chest --force-spawn-position " + + "--biome --properties ]") @Description("{@@mv-core.create.description}") void onCreateCommand( MVCommandIssuer issuer, @@ -67,8 +71,9 @@ void onCreateCommand( World.Environment environment, @Optional - @Syntax("[--seed --generator --world-type --adjust-spawn " - + "--no-structures --biome --properties ]") + @Syntax("[--seed --generator --world-type --adjust-spawn " + + "--no-structures --generate-bonus-chest --force-spawn-position --biome " + + "--properties ]") @Description("{@@mv-core.create.flags.description}") String[] flagArray) { ParsedCommandFlags parsedFlags = flags.parse(flagArray); @@ -79,14 +84,16 @@ void onCreateCommand( worldManager.createWorld(CreateWorldOptions.worldName(worldName) .biome(parsedFlags.flagValue(flags.biome, "")) + .bonusChest(parsedFlags.hasFlag(flags.bonusChest)) .environment(environment) - .seed(parsedFlags.flagValue(flags.seed)) - .worldType(parsedFlags.flagValue(flags.worldType, WorldType.NORMAL)) - .useSpawnAdjust(!parsedFlags.hasFlag(flags.noAdjustSpawn)) + .forcedSpawnPosition(parsedFlags.flagValue(flags.forceSpawnPosition)) .generator(parsedFlags.flagValue(flags.generator, "")) .generatorSettings(parsedFlags.flagValue(flags.generatorSettings, "")) .generateStructures(!parsedFlags.hasFlag(flags.noStructures)) - .worldPropertyStrings(StringFormatter.parseCSVMap(parsedFlags.flagValue(flags.properties)))) + .seed(parsedFlags.flagValue(flags.seed)) + .useSpawnAdjust(!parsedFlags.hasFlag(flags.noAdjustSpawn)) + .worldPropertyStrings(StringFormatter.parseCSVMap(parsedFlags.flagValue(flags.properties))) + .worldType(parsedFlags.flagValue(flags.worldType, WorldType.NORMAL))) .onSuccess(newWorld -> messageSuccess(issuer, newWorld)) .onFailure(failure -> messageFailure(issuer, failure)); } @@ -185,6 +192,15 @@ private Flags( private final CommandValueFlag properties = flag(CommandValueFlag.builder("--properties", String.class) .addAlias("-p") .build()); + + private final CommandFlag bonusChest = flag(CommandFlag.builder("--generate-bonus-chest") + .addAlias("-c") + .build()); + + private final CommandValueFlag forceSpawnPosition = flag(CommandValueFlag.builder("--force-spawn-position", EntityPosition.class) + .addAlias("-f") + .context(input -> Try.of(() -> EntityPosition.fromString(input)).getOrElseThrow(MVInvalidCommandArgument::causeBy)) + .build()); } @Service diff --git a/src/main/java/org/mvplugins/multiverse/core/exceptions/command/MVInvalidCommandArgument.java b/src/main/java/org/mvplugins/multiverse/core/exceptions/command/MVInvalidCommandArgument.java index ac2d8a481..10a46322f 100644 --- a/src/main/java/org/mvplugins/multiverse/core/exceptions/command/MVInvalidCommandArgument.java +++ b/src/main/java/org/mvplugins/multiverse/core/exceptions/command/MVInvalidCommandArgument.java @@ -1,6 +1,8 @@ package org.mvplugins.multiverse.core.exceptions.command; import co.aikar.commands.InvalidCommandArgument; +import org.jetbrains.annotations.ApiStatus; +import org.mvplugins.multiverse.core.locale.message.LocalizableMessage; import org.mvplugins.multiverse.core.locale.message.LocalizedMessage; import org.mvplugins.multiverse.core.locale.message.Message; @@ -9,6 +11,18 @@ */ public class MVInvalidCommandArgument extends InvalidCommandArgument { + @ApiStatus.AvailableSince("5.7") + public static MVInvalidCommandArgument causeBy(Throwable throwable) { + return causeBy(throwable, true); + } + + @ApiStatus.AvailableSince("5.7") + public static MVInvalidCommandArgument causeBy(Throwable throwable, boolean showSyntax) { + return (throwable instanceof LocalizableMessage localizableMessage) + ? of(localizableMessage.getLocalizableMessage(), showSyntax) + : new MVInvalidCommandArgument(throwable.getLocalizedMessage(), showSyntax); + } + public static MVInvalidCommandArgument of(Message message) { return of(message, true); } @@ -19,6 +33,10 @@ public static MVInvalidCommandArgument of(Message message, boolean showSyntax) { : new MVInvalidCommandArgument(message, showSyntax); } + private MVInvalidCommandArgument(String message, boolean showSyntax) { + super(message, showSyntax); + } + private MVInvalidCommandArgument(Message message, boolean showSyntax) { super(message.formatted(), showSyntax); } diff --git a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java index 67c692815..b4cdfc0c3 100644 --- a/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java +++ b/src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldCreatorCompatibility.java @@ -1,11 +1,13 @@ package org.mvplugins.multiverse.core.utils.compatibility; +import com.dumptruckman.minecraft.util.Logging; import io.vavr.control.Try; import org.bukkit.NamespacedKey; import org.bukkit.WorldCreator; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.mvplugins.multiverse.core.utils.ReflectHelper; +import org.mvplugins.multiverse.core.utils.position.EntityPosition; import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; import java.lang.reflect.Method; @@ -19,10 +21,17 @@ @ApiStatus.AvailableSince("5.7") public final class WorldCreatorCompatibility { + private static final Try> POSITION_CLASS; + private static final Try FORCED_SPAWN_POSITION_METHOD; private static final Try OF_KEY_METHOD; private static final Try OF_NAME_AND_KEY_METHOD; + private static final Try BONUS_CHEST_METHOD; static { + POSITION_CLASS = ReflectHelper.tryGetClass("io.papermc.paper.math.Position"); + FORCED_SPAWN_POSITION_METHOD = POSITION_CLASS.flatMap(positionClass -> + ReflectHelper.tryGetMethod(WorldCreator.class, "forcedSpawnPosition", positionClass, float.class, float.class)); + BONUS_CHEST_METHOD = ReflectHelper.tryGetMethod(WorldCreator.class, "bonusChest", boolean.class); OF_KEY_METHOD = ReflectHelper.tryGetMethod(WorldCreator.class, "ofKey", NamespacedKey.class); OF_NAME_AND_KEY_METHOD = ReflectHelper.tryGetMethod(WorldCreator.class, "ofNameAndKey", String.class, NamespacedKey.class); } @@ -105,6 +114,81 @@ private static boolean canPassIntoNameAndKey(@NotNull NamespacedKey worldKey, @N && worldName.toLowerCase(Locale.ROOT).equals(worldKey.getKey())); } + /** + * Checks if the server supports configuring a forced spawn position via the WorldCreator API. + *
+ * The force spawn position API is generally only available on PaperMC 26.1+ + * + * @return Whether force spawn position API is supported on the current server version. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static boolean supportsForcedSpawnPosition() { + return FORCED_SPAWN_POSITION_METHOD.isSuccess(); + } + + /** + * Tries to set the forced spawn position if the server implements the API. This call will do nothing if server + * software does not implement the required APIs. + * + * @param worldCreator Target creator instance to set on. + * @param position The position to set as the forced spawn point for the world. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static void setForcedSpawnPosition(WorldCreator worldCreator, EntityPosition position) { + if (!supportsForcedSpawnPosition()) { + Logging.fine("Server does not support forced spawn position configuration via WorldCreator API."); + return; + } + ReflectHelper.tryInvokeMethod( + worldCreator, + FORCED_SPAWN_POSITION_METHOD.get(), + io.papermc.paper.math.Position.fine( + position.getVector().getX().getRawValue(), + position.getVector().getY().getRawValue(), + position.getVector().getZ().getRawValue() + ), + (float) position.getDirection().getYaw().getRawValue(), + (float) position.getDirection().getPitch().getRawValue() + ).onFailure(ex -> + Logging.warning("Failed to set forced spawn position on WorldCreator: %s", ex.getMessage())); + } + + /** + * Checks if the server supports configuring bonus chest generation via the WorldCreator API. + *
+ * The bonus chest API is generally only available on PaperMC 1.21.5+ + * + * @return Whether bonus chest API is supported on the current server version. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static boolean supportsBonusChest() { + return BONUS_CHEST_METHOD.isSuccess(); + } + + /** + * Tries to set bonus chest if the server implements the API. This call will do nothing if server software does not + * implement the required APIs. + * + * @param worldCreator Target creator instance to set on. + * @param generateBonusChest Whether to generate a bonus chest at the world spawn point. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static void setBonusChest(WorldCreator worldCreator, boolean generateBonusChest) { + if (!supportsBonusChest()) { + Logging.fine("Server does not support bonus chest generation via WorldCreator API."); + return; + } + worldCreator.bonusChest(generateBonusChest); + } + private WorldCreatorCompatibility() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java index 8d1e6778a..7ab1d7cb4 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldManager.java @@ -280,7 +280,8 @@ private Attempt doCreateWorld( .generatorSettings(options.generatorSettings()) .seed(options.seed()) .type(options.worldType()); - + WorldCreatorCompatibility.setBonusChest(worldCreator, options.bonusChest()); + options.forcedSpawnPosition().peek(position -> WorldCreatorCompatibility.setForcedSpawnPosition(worldCreator, position)); return addBiomeProviderToCreator(worldCreator, keyOrName.usableName(), options.biome()) .mapAttempt(creator -> addGeneratorToCreator(creator, generatorString)) .mapAttempt(this::createBukkitWorld) diff --git a/src/main/java/org/mvplugins/multiverse/core/world/options/CreateWorldOptions.java b/src/main/java/org/mvplugins/multiverse/core/world/options/CreateWorldOptions.java index 2a32e96ff..2cbcd57f7 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/options/CreateWorldOptions.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/options/CreateWorldOptions.java @@ -2,6 +2,7 @@ import co.aikar.commands.ACFUtil; import io.vavr.control.Either; +import io.vavr.control.Option; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.WorldType; @@ -9,6 +10,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnmodifiableView; +import org.mvplugins.multiverse.core.utils.position.EntityPosition; import org.mvplugins.multiverse.core.world.key.WorldKeyOrName; import java.util.Collections; @@ -61,7 +63,9 @@ public final class CreateWorldOptions { private final Either keyOrName; private String biome = ""; + private boolean bonusChest = false; private World.Environment environment = World.Environment.NORMAL; + private EntityPosition forcedSpawnPosition = null; private boolean generateStructures = true; private String generator = null; private String generatorSettings = ""; @@ -125,6 +129,32 @@ public final class CreateWorldOptions { return biome; } + /** + * Sets whether bonus chest should generate at spawn upon world creation. + *
+ * This feature only works on PaperMC 1.21.5+ + * + * @param bonusChest Whether bonus chest should generate at spawn upon world creation. + * @return This {@link CreateWorldOptions} instance. + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull CreateWorldOptions bonusChest(boolean bonusChest) { + this.bonusChest = bonusChest; + return this; + } + + /** + * Gets whether bonus chest should generate at spawn upon world creation. + *
+ * This feature only works on PaperMC 1.21.5+ + * + * @return true if bonus chest should generate, else false. + */ + @ApiStatus.AvailableSince("5.7") + public boolean bonusChest() { + return bonusChest; + } + /** * Sets the environment of the world to create. * @@ -145,6 +175,37 @@ public final class CreateWorldOptions { return environment; } + /** + * Sets the forced spawn position of the world to apply. This may be null, in which case the spawn position will + * be determined by the default generator. Setting spawn position and {@link #useSpawnAdjust(boolean)} to false + * will improve world creation speed on PaperMC as chunks will not be loaded to search for spawn point. + *
+ * This feature only works on PaperMC 26.1+ + * + * @param forcedSpawnPosition The forced spawn position of the world to create. + * @return This {@link CreateWorldOptions} instance. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull CreateWorldOptions forcedSpawnPosition(@Nullable EntityPosition forcedSpawnPosition) { + this.forcedSpawnPosition = forcedSpawnPosition; + return this; + } + + /** + * Sets the forced spawn position of the world to apply. This may be null, in which case the spawn position will + * be determined by the default generator. + *
+ * This feature only works on PaperMC 26.1+ + * + * @return The force spawn position to apply if available. + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Option forcedSpawnPosition() { + return Option.of(this.forcedSpawnPosition); + } + /** * Sets whether structures such as NPC villages should be generated. * From dfeb72e501e5fc9deb33d76638e030f367d47bea Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Mon, 4 May 2026 14:09:52 +0800 Subject: [PATCH 13/26] Bump mockbukkit to fix skipped tests --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6b188da1a..826c1288d 100644 --- a/build.gradle +++ b/build.gradle @@ -27,7 +27,7 @@ repositories { configure(apiDependencies) { serverApiVersion = '1.21.11-R0.1-SNAPSHOT' mockBukkitServerApiVersion = '1.21' - mockBukkitVersion = '4.100.0' + mockBukkitVersion = '4.109.0' } dependencies { From 7937d889dae010aca362723c0b05926a556d1928 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Mon, 4 May 2026 14:15:16 +0800 Subject: [PATCH 14/26] Add preCreatureSpawn event handler to optimize spawn checks --- .../core/listeners/MVEntityListener.java | 34 +++++++++++++++++++ .../core/world/entity/EntitySpawnConfig.java | 6 ++++ .../world/entity/SpawnCategoryMapper.java | 20 ++++++++--- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/mvplugins/multiverse/core/listeners/MVEntityListener.java b/src/main/java/org/mvplugins/multiverse/core/listeners/MVEntityListener.java index 109514749..9c5dd09b9 100644 --- a/src/main/java/org/mvplugins/multiverse/core/listeners/MVEntityListener.java +++ b/src/main/java/org/mvplugins/multiverse/core/listeners/MVEntityListener.java @@ -7,6 +7,7 @@ package org.mvplugins.multiverse.core.listeners; +import com.destroystokyo.paper.event.entity.PreCreatureSpawnEvent; import com.dumptruckman.minecraft.util.Logging; import jakarta.inject.Inject; import org.bukkit.entity.LivingEntity; @@ -21,6 +22,8 @@ import org.jetbrains.annotations.NotNull; import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.dynamiclistener.EventRunnable; +import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventClass; import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventMethod; import org.mvplugins.multiverse.core.world.WorldManager; @@ -77,6 +80,37 @@ void entityRegainHealth(EntityRegainHealthEvent event) { }); } + /** + * Fired before other spawn checks is done, helps in performance by cancelling early and preventing unnecessary + * checks for spawn reasons that are not allowed in the world. + * + * @return Event wrapper + */ + @EventClass("com.destroystokyo.paper.event.entity.PreCreatureSpawnEvent") + EventRunnable preCreatureSpawn() { + return new EventRunnable() { + @Override + public void onEvent(PreCreatureSpawnEvent event) { + // Always allow custom command and plugins to spawn creatures + if (event.getReason() == SpawnReason.CUSTOM + || event.getReason() == SpawnReason.COMMAND + || event.getReason() == SpawnReason.BREEDING + || event.getReason() == SpawnReason.SPAWNER_EGG) { + return; + } + + worldManager.getLoadedWorld(event.getSpawnLocation().getWorld()) + .peek(world -> { + if (!world.getEntitySpawnConfig().shouldAllowSpawn(event.getType())) { + Logging.finest("Cancelling Pre Creature Spawn Event for: " + event.getType()); + event.setCancelled(true); + event.setShouldAbortSpawn(true); + } + }); + } + }; + } + /** * Handle Spawn Category settings. * diff --git a/src/main/java/org/mvplugins/multiverse/core/world/entity/EntitySpawnConfig.java b/src/main/java/org/mvplugins/multiverse/core/world/entity/EntitySpawnConfig.java index c33ed5de2..de4cd6345 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/entity/EntitySpawnConfig.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/entity/EntitySpawnConfig.java @@ -4,6 +4,7 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.MemoryConfiguration; import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; import org.bukkit.entity.SpawnCategory; import org.jetbrains.annotations.ApiStatus; import org.mvplugins.multiverse.core.config.CoreConfig; @@ -33,6 +34,11 @@ public SpawnCategoryConfig getSpawnCategoryConfig(SpawnCategory spawnCategory) { )); } + @ApiStatus.AvailableSince("5.7") + public boolean shouldAllowSpawn(EntityType entityType) { + return getSpawnCategoryConfig(SpawnCategoryMapper.getSpawnCategory(entityType)).shouldAllowSpawn(entityType); + } + public boolean shouldAllowSpawn(Entity entity) { return getSpawnCategoryConfig(entity.getSpawnCategory()).shouldAllowSpawn(entity); } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/entity/SpawnCategoryMapper.java b/src/main/java/org/mvplugins/multiverse/core/world/entity/SpawnCategoryMapper.java index 00d233868..5fe79eff0 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/entity/SpawnCategoryMapper.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/entity/SpawnCategoryMapper.java @@ -19,15 +19,16 @@ */ final class SpawnCategoryMapper { + private static final Map entityTypeToSpawnCategoryMap; + private static final Map> spawnCategoryMap; + static { + entityTypeToSpawnCategoryMap = new HashMap<>(); + spawnCategoryMap = new HashMap<>(); buildSpawnCategoryMap(); } - private static Map> spawnCategoryMap; - private static void buildSpawnCategoryMap() { - spawnCategoryMap = new HashMap<>(); - Class entityTypeClass = ReflectHelper.tryGetClass("net.minecraft.world.entity.EntityType").getOrNull(); if (entityTypeClass == null) { Logging.warning("Failed to find EntityType class. SpawnCategoryMapper will not work."); @@ -57,11 +58,22 @@ private static void buildSpawnCategoryMap() { .flatMap(nsmMobCategory -> ReflectHelper.tryInvokeStaticMethod(toBukkitMethod, nsmMobCategory)) .filter(bukkitSpawnCategory -> bukkitSpawnCategory instanceof SpawnCategory) .map(bukkitSpawnCategory -> (SpawnCategory) bukkitSpawnCategory) + .peek(bukkitSpawnCategory -> entityTypeToSpawnCategoryMap.put(entityType, bukkitSpawnCategory)) .peek(bukkitSpawnCategory -> spawnCategoryMap .computeIfAbsent(bukkitSpawnCategory, ignore -> new ArrayList<>()) .add(entityType)))); } + /** + * Gets the spawn category that the given entity type is in. + * + * @param entityType The entity type + * @return The spawn category that the given entity type is in, or null if it cannot be determined + */ + static SpawnCategory getSpawnCategory(EntityType entityType) { + return entityTypeToSpawnCategoryMap.get(entityType); + } + /** * Gets the entity types for a spawn category * From 5451e55f17b575b35b92967f4a3721d402c79e19 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Mon, 4 May 2026 16:53:38 +0800 Subject: [PATCH 15/26] Add support for custom destination with `--remove-player` flag --- .../core/command/flag/CommandValueFlag.java | 64 +++++++++++++----- .../flags/RemovePlayerDestinationFlags.java | 65 +++++++++++++++++++ .../core/command/flags/RemovePlayerFlags.java | 7 ++ .../core/commands/DeleteCommand.java | 23 ++++--- .../core/commands/RegenCommand.java | 28 +++++--- .../core/commands/RemoveCommand.java | 30 ++++++--- .../core/commands/UnloadCommand.java | 28 +++++--- .../destination/core/WorldDestination.java | 16 ++++- .../world/helpers/PlayerWorldTeleporter.java | 20 ++++++ 9 files changed, 226 insertions(+), 55 deletions(-) create mode 100644 src/main/java/org/mvplugins/multiverse/core/command/flags/RemovePlayerDestinationFlags.java diff --git a/src/main/java/org/mvplugins/multiverse/core/command/flag/CommandValueFlag.java b/src/main/java/org/mvplugins/multiverse/core/command/flag/CommandValueFlag.java index 24f0f67df..85b689cdb 100644 --- a/src/main/java/org/mvplugins/multiverse/core/command/flag/CommandValueFlag.java +++ b/src/main/java/org/mvplugins/multiverse/core/command/flag/CommandValueFlag.java @@ -5,8 +5,10 @@ import java.util.List; import java.util.Locale; import java.util.function.Function; +import java.util.function.Supplier; import co.aikar.commands.InvalidCommandArgument; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -40,34 +42,34 @@ public class CommandValueFlag extends CommandFlag { private final Class type; private final boolean optional; - private final T defaultValue; + private final Supplier defaultValueSupplier; private final Function context; private final Function> completion; /** * Creates a new flag. * - * @param key The key for the new flag. - * @param aliases The aliases that also refer to this flag. - * @param type The type of the value. - * @param optional Allow for flag without value. - * @param defaultValue The default value if optional is true and user does not specify a value. - * @param context Function to parse string into value type. - * @param completion Function to get completion for this flag. + * @param key The key for the new flag. + * @param aliases The aliases that also refer to this flag. + * @param type The type of the value. + * @param optional Allow for flag without value. + * @param defaultValueSupplier The default value if optional is true and user does not specify a value. + * @param context Function to parse string into value type. + * @param completion Function to get completion for this flag. */ protected CommandValueFlag( @NotNull String key, @NotNull List aliases, @NotNull Class type, boolean optional, - @Nullable T defaultValue, + @Nullable Supplier defaultValueSupplier, @Nullable Function context, @Nullable Function> completion ) { super(key, aliases); this.type = type; this.optional = optional; - this.defaultValue = defaultValue; + this.defaultValueSupplier = defaultValueSupplier; this.context = context; this.completion = completion; } @@ -96,7 +98,7 @@ public boolean isOptional() { * @return The default value. */ public @Nullable T getDefaultValue() { - return defaultValue; + return defaultValueSupplier == null ? null : defaultValueSupplier.get(); } /** @@ -126,7 +128,7 @@ public boolean isOptional() { public static class Builder> extends CommandFlag.Builder { protected final Class type; protected boolean optional = false; - protected T defaultValue = null; + protected Supplier defaultValueSupplier = null; protected Function context = null; protected Function> completion = null; @@ -158,7 +160,21 @@ public Builder(@NotNull String key, @NotNull Class type) { * @return The builder. */ public @NotNull S defaultValue(@NotNull T defaultValue) { - this.defaultValue = defaultValue; + return defaultValue(() -> defaultValue); + } + + /** + * Set the default value supplier. Used if optional is true and user does not specify a value. + * Supplier is only called when command is executed with the flag is present in input. + * + * @param defaultValueSupplier The default value supplier + * @return The builder + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull S defaultValue(@NotNull Supplier defaultValueSupplier) { + this.defaultValueSupplier = defaultValueSupplier; return (S) this; } @@ -194,7 +210,7 @@ public Builder(@NotNull String key, @NotNull Class type) { if (context == null && !String.class.equals(type)) { throw new IllegalStateException("Context is required for non-string value flags"); } - return new CommandValueFlag<>(key, aliases, type, optional, defaultValue, context, completion); + return new CommandValueFlag<>(key, aliases, type, optional, defaultValueSupplier, context, completion); } } @@ -207,7 +223,7 @@ public Builder(@NotNull String key, @NotNull Class type) { public static class EnumBuilder, S extends EnumBuilder> extends CommandFlag.Builder { protected final Class type; protected boolean optional = false; - protected T defaultValue = null; + protected Supplier defaultValueSupplier = null; protected Function context = null; protected Function> completion = null; @@ -253,7 +269,21 @@ private void setEnumCompletion() { * @return The builder. */ public @NotNull S defaultValue(@NotNull T defaultValue) { - this.defaultValue = defaultValue; + return defaultValue(() -> defaultValue); + } + + /** + * Set the default value to supply. Used if optional is true and user does not specify a value. + * Supplier is only called when command is executed with the flag is present in input. + * + * @param defaultValueSupplier The default value supplier. + * @return The builder. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull S defaultValue(@NotNull Supplier defaultValueSupplier) { + this.defaultValueSupplier = defaultValueSupplier; return (S) this; } @@ -264,7 +294,7 @@ private void setEnumCompletion() { */ @Override public @NotNull CommandValueFlag build() { - return new CommandValueFlag<>(key, aliases, type, optional, defaultValue, context, completion); + return new CommandValueFlag<>(key, aliases, type, optional, defaultValueSupplier, context, completion); } } } diff --git a/src/main/java/org/mvplugins/multiverse/core/command/flags/RemovePlayerDestinationFlags.java b/src/main/java/org/mvplugins/multiverse/core/command/flags/RemovePlayerDestinationFlags.java new file mode 100644 index 000000000..a2f7fb875 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/command/flags/RemovePlayerDestinationFlags.java @@ -0,0 +1,65 @@ +package org.mvplugins.multiverse.core.command.flags; + +import co.aikar.commands.InvalidCommandArgument; +import jakarta.inject.Inject; +import org.bukkit.Bukkit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager; +import org.mvplugins.multiverse.core.command.flag.CommandValueFlag; +import org.mvplugins.multiverse.core.command.flag.FlagBuilder; +import org.mvplugins.multiverse.core.destination.DestinationInstance; +import org.mvplugins.multiverse.core.destination.DestinationsProvider; +import org.mvplugins.multiverse.core.destination.core.WorldDestination; +import org.mvplugins.multiverse.core.exceptions.command.MVInvalidCommandArgument; +import org.mvplugins.multiverse.core.world.WorldManager; + +@ApiStatus.AvailableSince("5.7") +@Service +public class RemovePlayerDestinationFlags extends FlagBuilder { + + public static final String NAME = "removeplayer"; + + private WorldManager worldManager; + private DestinationsProvider destinationsProvider; + private WorldDestination worldDestination; + + protected RemovePlayerDestinationFlags( + @NotNull String name, + @NotNull CommandFlagsManager flagsManager, + @NotNull WorldManager worldManager, + @NotNull DestinationsProvider destinationsProvider, + @NotNull WorldDestination worldDestination + ) { + super(name, flagsManager); + this.worldManager = worldManager; + this.destinationsProvider = destinationsProvider; + this.worldDestination = worldDestination; + } + + @Inject + private RemovePlayerDestinationFlags( + @NotNull CommandFlagsManager flagsManager, + @NotNull WorldManager worldManager, + @NotNull DestinationsProvider destinationsProvider, + @NotNull WorldDestination worldDestination + ) { + super(NAME, flagsManager); + this.destinationsProvider = destinationsProvider; + this.worldManager = worldManager; + this.worldDestination = worldDestination; + } + + public final CommandValueFlag removePlayers = flag(CommandValueFlag.builder("--remove-players", DestinationInstance.class) + .addAlias("-r") + .defaultValue(() -> worldManager.getDefaultWorld() + .map(defaultWorld -> worldDestination.fromWorld(defaultWorld)) + .getOrElseThrow(() -> new InvalidCommandArgument("No default world found, so the --remove-players flag requires a destination argument."))) //TODO: locale + .completion(input -> destinationsProvider.suggestDestinationStrings(Bukkit.getConsoleSender(), input)) + .context(input -> destinationsProvider.parseDestination(input) + .getOrThrow(failure -> + MVInvalidCommandArgument.of(failure.getFailureMessage()))) + .optional() + .build()); +} diff --git a/src/main/java/org/mvplugins/multiverse/core/command/flags/RemovePlayerFlags.java b/src/main/java/org/mvplugins/multiverse/core/command/flags/RemovePlayerFlags.java index 11c37fa3f..0f16b54b7 100644 --- a/src/main/java/org/mvplugins/multiverse/core/command/flags/RemovePlayerFlags.java +++ b/src/main/java/org/mvplugins/multiverse/core/command/flags/RemovePlayerFlags.java @@ -1,12 +1,19 @@ package org.mvplugins.multiverse.core.command.flags; import jakarta.inject.Inject; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.command.flag.CommandFlag; import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager; import org.mvplugins.multiverse.core.command.flag.FlagBuilder; +/** + * @deprecated The --remove-players flag is being removed in favor of a more flexible system that allows for specifying + * a destination for players to be teleported to when a world is unloaded. See {@link RemovePlayerDestinationFlags}. + */ +@Deprecated(forRemoval = true, since = "5.7") +@ApiStatus.ScheduledForRemoval(inVersion = "6.0") @Service public class RemovePlayerFlags extends FlagBuilder { diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/DeleteCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/DeleteCommand.java index 7b6c86b9d..c5119e535 100644 --- a/src/main/java/org/mvplugins/multiverse/core/commands/DeleteCommand.java +++ b/src/main/java/org/mvplugins/multiverse/core/commands/DeleteCommand.java @@ -15,11 +15,11 @@ import org.mvplugins.multiverse.core.command.LegacyAliasCommand; import org.mvplugins.multiverse.core.command.MVCommandIssuer; -import org.mvplugins.multiverse.core.command.MVCommandManager; import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags; -import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags; +import org.mvplugins.multiverse.core.command.flags.RemovePlayerDestinationFlags; import org.mvplugins.multiverse.core.command.queue.CommandQueueManager; import org.mvplugins.multiverse.core.command.queue.CommandQueuePayload; +import org.mvplugins.multiverse.core.destination.DestinationInstance; import org.mvplugins.multiverse.core.locale.MVCorei18n; import org.mvplugins.multiverse.core.locale.message.Message; import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace; @@ -31,6 +31,8 @@ import org.mvplugins.multiverse.core.world.helpers.PlayerWorldTeleporter; import org.mvplugins.multiverse.core.world.options.DeleteWorldOptions; +import java.util.Objects; + @Service class DeleteCommand extends CoreCommand { @@ -38,7 +40,7 @@ class DeleteCommand extends CoreCommand { private final WorldManager worldManager; private final PlayerWorldTeleporter playerWorldTeleporter; private final WorldTickDeferrer worldTickDeferrer; - private final RemovePlayerFlags flags; + private final RemovePlayerDestinationFlags flags; @Inject DeleteCommand( @@ -46,7 +48,7 @@ class DeleteCommand extends CoreCommand { @NotNull WorldManager worldManager, @NotNull PlayerWorldTeleporter playerWorldTeleporter, @NotNull WorldTickDeferrer worldTickDeferrer, - @NotNull RemovePlayerFlags flags + @NotNull RemovePlayerDestinationFlags flags ) { this.commandQueueManager = commandQueueManager; this.worldManager = worldManager; @@ -57,8 +59,8 @@ class DeleteCommand extends CoreCommand { @Subcommand("delete") @CommandPermission("multiverse.core.delete") - @CommandCompletion("@mvworlds:scope=both @flags:groupName=" + RemovePlayerFlags.NAME) - @Syntax("") + @CommandCompletion("@mvworlds:scope=both @flags:groupName=" + RemovePlayerDestinationFlags.NAME) + @Syntax(" [--remove-players [destination]]") @Description("{@@mv-core.delete.description}") void onDeleteCommand( MVCommandIssuer issuer, @@ -69,7 +71,7 @@ void onDeleteCommand( MultiverseWorld world, @Optional - @Syntax("[--remove-players]") + @Syntax("[--remove-players [destination]]") @Description("") String[] flagArray) { ParsedCommandFlags parsedFlags = flags.parse(flagArray); @@ -84,10 +86,11 @@ void onDeleteCommand( private void runDeleteCommand(MVCommandIssuer issuer, MultiverseWorld world, ParsedCommandFlags parsedFlags) { issuer.sendInfo(MVCorei18n.DELETE_DELETING, Replace.WORLD.with(world.getName())); - var future = parsedFlags.hasFlag(flags.removePlayers) + DestinationInstance removeToDestination = parsedFlags.flagValue(flags.removePlayers); + var future = Objects.nonNull(removeToDestination) && world.isLoaded() && world instanceof LoadedMultiverseWorld loadedWorld - ? playerWorldTeleporter.removeFromWorld(loadedWorld) + ? playerWorldTeleporter.transferAllFromWorldToDestination(loadedWorld, removeToDestination) : AsyncAttemptsAggregate.emptySuccess(); future.onSuccess(() -> worldTickDeferrer.deferWorldTick(() -> doWorldDeleting(issuer, world))) @@ -113,7 +116,7 @@ private static final class LegacyAlias extends DeleteCommand implements LegacyAl @NotNull WorldManager worldManager, @NotNull PlayerWorldTeleporter playerWorldTeleporter, @NotNull WorldTickDeferrer worldTickDeferrer, - @NotNull RemovePlayerFlags flags) { + @NotNull RemovePlayerDestinationFlags flags) { super(commandQueueManager, worldManager, playerWorldTeleporter, worldTickDeferrer, flags); } diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/RegenCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/RegenCommand.java index 745ffe93d..971aa197c 100644 --- a/src/main/java/org/mvplugins/multiverse/core/commands/RegenCommand.java +++ b/src/main/java/org/mvplugins/multiverse/core/commands/RegenCommand.java @@ -2,6 +2,7 @@ import java.util.Collections; import java.util.List; +import java.util.Objects; import co.aikar.commands.ACFUtil; import co.aikar.commands.annotation.CommandAlias; @@ -23,9 +24,12 @@ import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager; import org.mvplugins.multiverse.core.command.flag.CommandValueFlag; import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags; -import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags; +import org.mvplugins.multiverse.core.command.flags.RemovePlayerDestinationFlags; import org.mvplugins.multiverse.core.command.queue.CommandQueueManager; import org.mvplugins.multiverse.core.command.queue.CommandQueuePayload; +import org.mvplugins.multiverse.core.destination.DestinationInstance; +import org.mvplugins.multiverse.core.destination.DestinationsProvider; +import org.mvplugins.multiverse.core.destination.core.WorldDestination; import org.mvplugins.multiverse.core.locale.MVCorei18n; import org.mvplugins.multiverse.core.locale.message.Message; import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace; @@ -65,7 +69,8 @@ class RegenCommand extends CoreCommand { @Subcommand("regen") @CommandPermission("multiverse.core.regen") @CommandCompletion("@mvworlds:scope=loaded @flags:groupName=" + Flags.NAME) - @Syntax(" [--seed [seed] --reset-world-config --reset-gamerules --reset-world-border --remove-players]") + @Syntax(" [--seed [seed]] [--reset-world-config] [--reset-gamerules] [--reset-world-border] " + + "[--remove-players [destination]]") @Description("{@@mv-core.regen.description}") void onRegenCommand( MVCommandIssuer issuer, @@ -75,7 +80,8 @@ void onRegenCommand( LoadedMultiverseWorld world, @Optional - @Syntax("[--seed [seed] --reset-world-config --reset-gamerules --reset-world-border --remove-players]") + @Syntax("[--seed [seed]] [--reset-world-config] [--reset-gamerules] [--reset-world-border] " + + "[--remove-players [destination]]") @Description("{@@mv-core.regen.other.description}") String[] flagArray) { ParsedCommandFlags parsedFlags = flags.parse(flagArray); @@ -91,8 +97,9 @@ private void runRegenCommand(MVCommandIssuer issuer, LoadedMultiverseWorld world issuer.sendInfo(MVCorei18n.REGEN_REGENERATING, Replace.WORLD.with(world.getName())); List worldPlayers = world.getPlayers().getOrElse(Collections.emptyList()); - var future = parsedFlags.hasFlag(flags.removePlayers) - ? playerWorldTeleporter.removeFromWorld(world) + DestinationInstance removeToDestination = parsedFlags.flagValue(flags.removePlayers); + var future = Objects.nonNull(removeToDestination) + ? playerWorldTeleporter.transferAllFromWorldToDestination(world, removeToDestination) : AsyncAttemptsAggregate.emptySuccess(); // todo: using future will hide stacktrace @@ -128,13 +135,18 @@ private void doWorldRegening( } @Service - private static final class Flags extends RemovePlayerFlags { + private static final class Flags extends RemovePlayerDestinationFlags { private static final String NAME = "mvregen"; @Inject - private Flags(@NotNull CommandFlagsManager flagsManager) { - super(NAME, flagsManager); + private Flags( + @NotNull CommandFlagsManager flagsManager, + @NotNull WorldManager worldManager, + @NotNull DestinationsProvider destinationsProvider, + @NotNull WorldDestination worldDestination + ) { + super(NAME, flagsManager, worldManager, destinationsProvider, worldDestination); } private final CommandValueFlag seed = flag(CommandValueFlag.builder("--seed", String.class) diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/RemoveCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/RemoveCommand.java index 0054cfd2c..22d4be28b 100644 --- a/src/main/java/org/mvplugins/multiverse/core/commands/RemoveCommand.java +++ b/src/main/java/org/mvplugins/multiverse/core/commands/RemoveCommand.java @@ -14,11 +14,13 @@ import org.mvplugins.multiverse.core.command.LegacyAliasCommand; import org.mvplugins.multiverse.core.command.MVCommandIssuer; -import org.mvplugins.multiverse.core.command.MVCommandManager; import org.mvplugins.multiverse.core.command.flag.CommandFlag; import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager; import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags; -import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags; +import org.mvplugins.multiverse.core.command.flags.RemovePlayerDestinationFlags; +import org.mvplugins.multiverse.core.destination.DestinationInstance; +import org.mvplugins.multiverse.core.destination.DestinationsProvider; +import org.mvplugins.multiverse.core.destination.core.WorldDestination; import org.mvplugins.multiverse.core.locale.MVCorei18n; import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace; import org.mvplugins.multiverse.core.utils.result.AsyncAttemptsAggregate; @@ -27,6 +29,8 @@ import org.mvplugins.multiverse.core.world.helpers.PlayerWorldTeleporter; import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions; +import java.util.Objects; + @Service class RemoveCommand extends CoreCommand { @@ -48,7 +52,7 @@ class RemoveCommand extends CoreCommand { @Subcommand("remove") @CommandPermission("multiverse.core.remove") @CommandCompletion("@mvworlds:scope=both @flags:groupName=" + Flags.NAME) - @Syntax("") + @Syntax(" [--remove-players [destination]] [--no-unload-bukkit-world] [--no-save]") @Description("{@@mv-core.remove.description}") void onRemoveCommand( MVCommandIssuer issuer, @@ -58,13 +62,16 @@ void onRemoveCommand( MultiverseWorld world, @Optional - @Syntax("[--remove-players]") + @Syntax("[--remove-players [destination]] [--no-unload-bukkit-world] [--no-save]") @Description("") String[] flagArray) { ParsedCommandFlags parsedFlags = flags.parse(flagArray); - var future = parsedFlags.hasFlag(flags.removePlayers) - ? worldManager.getLoadedWorld(world).map(playerWorldTeleporter::removeFromWorld).getOrElse(AsyncAttemptsAggregate::emptySuccess) + DestinationInstance removeToDestination = parsedFlags.flagValue(flags.removePlayers); + var future = Objects.nonNull(removeToDestination) + ? world.asLoadedWorld() + .map(loadedWorld -> playerWorldTeleporter.transferAllFromWorldToDestination(loadedWorld, removeToDestination)) + .getOrElse(AsyncAttemptsAggregate::emptySuccess) : AsyncAttemptsAggregate.emptySuccess(); future.onSuccess(() -> doWorldRemoving(issuer, world, parsedFlags)) @@ -85,13 +92,18 @@ private void doWorldRemoving(MVCommandIssuer issuer, MultiverseWorld world, Pars } @Service - private static final class Flags extends RemovePlayerFlags { + private static final class Flags extends RemovePlayerDestinationFlags { private static final String NAME = "mvremove"; @Inject - private Flags(@NotNull CommandFlagsManager flagsManager) { - super(NAME, flagsManager); + private Flags( + @NotNull CommandFlagsManager flagsManager, + @NotNull WorldManager worldManager, + @NotNull DestinationsProvider destinationsProvider, + @NotNull WorldDestination worldDestination + ) { + super(NAME, flagsManager, worldManager, destinationsProvider, worldDestination); } private final CommandFlag noUnloadBukkitWorld = flag(CommandFlag.builder("--no-unload-bukkit-world") diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/UnloadCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/UnloadCommand.java index ee639b7ae..403d2b58c 100644 --- a/src/main/java/org/mvplugins/multiverse/core/commands/UnloadCommand.java +++ b/src/main/java/org/mvplugins/multiverse/core/commands/UnloadCommand.java @@ -14,11 +14,13 @@ import org.mvplugins.multiverse.core.command.LegacyAliasCommand; import org.mvplugins.multiverse.core.command.MVCommandIssuer; -import org.mvplugins.multiverse.core.command.MVCommandManager; import org.mvplugins.multiverse.core.command.flag.CommandFlag; import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager; import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags; -import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags; +import org.mvplugins.multiverse.core.command.flags.RemovePlayerDestinationFlags; +import org.mvplugins.multiverse.core.destination.DestinationInstance; +import org.mvplugins.multiverse.core.destination.DestinationsProvider; +import org.mvplugins.multiverse.core.destination.core.WorldDestination; import org.mvplugins.multiverse.core.locale.MVCorei18n; import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace; import org.mvplugins.multiverse.core.utils.result.AsyncAttemptsAggregate; @@ -27,6 +29,8 @@ import org.mvplugins.multiverse.core.world.helpers.PlayerWorldTeleporter; import org.mvplugins.multiverse.core.world.options.UnloadWorldOptions; +import java.util.Objects; + @Service class UnloadCommand extends CoreCommand { @@ -48,7 +52,7 @@ class UnloadCommand extends CoreCommand { @Subcommand("unload") @CommandPermission("multiverse.core.unload") @CommandCompletion("@mvworlds @flags:groupName=" + Flags.NAME) - @Syntax("") + @Syntax(" [--remove-players [destination]] [--no-save]") @Description("{@@mv-core.unload.description}") void onUnloadCommand( MVCommandIssuer issuer, @@ -58,15 +62,16 @@ void onUnloadCommand( LoadedMultiverseWorld world, @Optional - @Syntax("[--remove-players] [--no-save]") + @Syntax("[--remove-players [destination]] [--no-save]") @Description("{@@mv-core.gamerules.description.page}") String[] flagArray) { ParsedCommandFlags parsedFlags = flags.parse(flagArray); issuer.sendInfo(MVCorei18n.UNLOAD_UNLOADING, Replace.WORLD.with(world.getAliasOrName())); - var future = parsedFlags.hasFlag(flags.removePlayers) - ? playerWorldTeleporter.removeFromWorld(world) + DestinationInstance removeToDestination = parsedFlags.flagValue(flags.removePlayers); + var future = Objects.nonNull(removeToDestination) + ? playerWorldTeleporter.transferAllFromWorldToDestination(world, removeToDestination) : AsyncAttemptsAggregate.emptySuccess(); future.onSuccess(() -> doWorldUnloading(issuer, world, parsedFlags)) @@ -88,13 +93,18 @@ private void doWorldUnloading(MVCommandIssuer issuer, LoadedMultiverseWorld worl } @Service - private static final class Flags extends RemovePlayerFlags { + private static final class Flags extends RemovePlayerDestinationFlags { private static final String NAME = "mvunload"; @Inject - private Flags(@NotNull CommandFlagsManager flagsManager) { - super(NAME, flagsManager); + private Flags( + @NotNull CommandFlagsManager flagsManager, + @NotNull WorldManager worldManager, + @NotNull DestinationsProvider destinationsProvider, + @NotNull WorldDestination worldDestination + ) { + super(NAME, flagsManager, worldManager, destinationsProvider, worldDestination); } private final CommandFlag noUnloadBukkitWorld = flag(CommandFlag.builder("--no-unload-bukkit-world") diff --git a/src/main/java/org/mvplugins/multiverse/core/destination/core/WorldDestination.java b/src/main/java/org/mvplugins/multiverse/core/destination/core/WorldDestination.java index 7c86c9d83..f4f2b0b91 100644 --- a/src/main/java/org/mvplugins/multiverse/core/destination/core/WorldDestination.java +++ b/src/main/java/org/mvplugins/multiverse/core/destination/core/WorldDestination.java @@ -6,6 +6,7 @@ import co.aikar.locales.MessageKeyProvider; import jakarta.inject.Inject; import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jvnet.hk2.annotations.Service; @@ -14,13 +15,11 @@ import org.mvplugins.multiverse.core.destination.Destination; import org.mvplugins.multiverse.core.destination.DestinationSuggestionPacket; import org.mvplugins.multiverse.core.locale.MVCorei18n; -import org.mvplugins.multiverse.core.locale.message.MessageReplacement; import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace; import org.mvplugins.multiverse.core.teleportation.LocationManipulation; import org.mvplugins.multiverse.core.utils.REPatterns; import org.mvplugins.multiverse.core.utils.result.Attempt; import org.mvplugins.multiverse.core.utils.result.FailureReason; -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.entrycheck.WorldEntryCheckerProvider; @@ -56,6 +55,19 @@ public final class WorldDestination implements Destination transferAllFromWorldT .getOrElse(AsyncAttemptsAggregate::emptySuccess); } + /** + * Transfers all players from the given world to the given destination. + * + * @param world The world to transfer players from. + * @param destinationInstance The destination instance to transfer players to. + * @return A list of async futures that represent the teleportation result of each player. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public AsyncAttemptsAggregate transferAllFromWorldToDestination( + @NotNull LoadedMultiverseWorld world, + @NotNull DestinationInstance destinationInstance) { + return world.getPlayers() + .map(players -> safetyTeleporter.to(destinationInstance).teleport(players)) + .getOrElse(AsyncAttemptsAggregate::emptySuccess); + } + /** * Teleports all players to the given world's spawn location. * From 5e7637bc42721c56e2407984875418f5a31f9031 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Tue, 5 May 2026 15:57:45 +0800 Subject: [PATCH 16/26] Add support for setting world border time with ticks --- .../core/command/MVCommandContexts.java | 11 + .../core/commands/WorldBorderCommand.java | 38 ++- .../multiverse/core/locale/MVCorei18n.java | 2 + .../core/utils/MinecraftTimeFormatter.java | 19 +- .../WorldBorderCompatibility.java | 170 +++++++++++ .../core/utils/tick/TickDuration.java | 274 ++++++++++++++++++ .../multiverse/core/utils/tick/TickUnit.java | 112 +++++++ .../core/world/helpers/DataStore.java | 35 ++- .../resources/multiverse-core_en.properties | 12 +- 9 files changed, 627 insertions(+), 46 deletions(-) create mode 100644 src/main/java/org/mvplugins/multiverse/core/utils/compatibility/WorldBorderCompatibility.java create mode 100644 src/main/java/org/mvplugins/multiverse/core/utils/tick/TickDuration.java create mode 100644 src/main/java/org/mvplugins/multiverse/core/utils/tick/TickUnit.java diff --git a/src/main/java/org/mvplugins/multiverse/core/command/MVCommandContexts.java b/src/main/java/org/mvplugins/multiverse/core/command/MVCommandContexts.java index 19ce84675..616f1d613 100644 --- a/src/main/java/org/mvplugins/multiverse/core/command/MVCommandContexts.java +++ b/src/main/java/org/mvplugins/multiverse/core/command/MVCommandContexts.java @@ -42,6 +42,7 @@ import org.mvplugins.multiverse.core.locale.message.Message; import org.mvplugins.multiverse.core.utils.PlayerFinder; import org.mvplugins.multiverse.core.utils.REPatterns; +import org.mvplugins.multiverse.core.utils.tick.TickDuration; import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; import org.mvplugins.multiverse.core.world.MultiverseWorld; import org.mvplugins.multiverse.core.world.WorldManager; @@ -93,6 +94,7 @@ public class MVCommandContexts extends PaperCommandContexts { registerIssuerAwareContext(PlayerArrayValue.class, playerArrayContextBuilder().generateContext(PlayerArrayValue::new)); registerIssuerAwareContext(PlayerLocation.class, this::parsePlayerLocation); registerContext(SpawnCategory[].class, this::parseSpawnCategories); + registerContext(TickDuration.class, this::parseTickDuration); } private MVCommandIssuer parseMVCommandIssuer(BukkitCommandExecutionContext context) { @@ -393,4 +395,13 @@ private SpawnCategory[] parseSpawnCategories(BukkitCommandExecutionContext conte } return categories.toArray(new SpawnCategory[0]); } + + private TickDuration parseTickDuration(BukkitCommandExecutionContext context) { + String arg = context.popFirstArg(); + return TickDuration.parseString(arg) + .getOrElseThrow(failure -> + new InvalidCommandArgument("Invalid time duration format: \"" +arg + "\". Use a number " + + "followed by an optional suffix (s for seconds, d for game days) or just a number " + + "for ticks. Examples: 100, 5s, 2d")); + } } diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/WorldBorderCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/WorldBorderCommand.java index 94e1e8cb2..06bf70850 100644 --- a/src/main/java/org/mvplugins/multiverse/core/commands/WorldBorderCommand.java +++ b/src/main/java/org/mvplugins/multiverse/core/commands/WorldBorderCommand.java @@ -12,8 +12,11 @@ import org.mvplugins.multiverse.core.command.MVCommandIssuer; import org.mvplugins.multiverse.core.locale.MVCorei18n; import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace; +import org.mvplugins.multiverse.core.utils.compatibility.WorldBorderCompatibility; +import org.mvplugins.multiverse.core.utils.tick.TickDuration; import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; +import java.time.temporal.ChronoUnit; import java.util.function.Consumer; import static org.mvplugins.multiverse.core.locale.message.MessageReplacement.replace; @@ -33,15 +36,14 @@ void onWorldBorderAdd( @Optional @Default("0") @Syntax("[time]") - int time, + TickDuration duration, @Flags("resolve=issuerAware,maxArgForAware=0") @Syntax("[world]") LoadedMultiverseWorld world ) { - worldBorderAction(issuer, world, worldBorder -> { - onWorldBorderSet(issuer, worldBorder.getSize() + size, time, world); - }); + worldBorderAction(issuer, world, worldBorder -> + onWorldBorderSet(issuer, worldBorder.getSize() + size, duration, world)); } @Subcommand("center") @@ -145,7 +147,7 @@ void onWorldBorderSet( @Optional @Default("0") @Syntax("[time]") - int time, + TickDuration duration, @Flags("resolve=issuerAware,maxArgForAware=0") @Syntax("[world]") @@ -158,15 +160,18 @@ void onWorldBorderSet( Replace.WORLD.with(world.getAliasOrName())); return; } - worldBorder.setSize(size, time); - if (time <= 0) { + if (!WorldBorderCompatibility.supportsChangeSizeInTicks() && !duration.isExactTo(ChronoUnit.SECONDS)) { + setRoundOffError(issuer, duration); + } + WorldBorderCompatibility.changeSizeDuration(worldBorder, size, duration); + if (duration.toTicks() <= 0) { issuer.sendMessage(MVCorei18n.WORLDBORDER_SET_IMMEDIATE, replace("{size}").with(worldBorder.getSize()), Replace.WORLD.with(world.getAliasOrName())); } else { issuer.sendMessage(previousSize > size ? MVCorei18n.WORLDBORDER_SET_GROWING : MVCorei18n.WORLDBORDER_SET_SHRINKING, replace("{size}").with(size), - replace("{time}").with(time), + replace("{time}").with(String.format("%.2f", duration.toSeconds())), Replace.WORLD.with(world.getAliasOrName())); } }); @@ -201,25 +206,34 @@ void onWorldBorderWarningTime( MVCommandIssuer issuer, @Syntax("