Correct Java 26.2 Iris world lifecycle

This commit is contained in:
Brian Neumann-Fopiano
2026-08-15 11:27:46 -04:00
parent 32beca236c
commit 57072d5bcd
61 changed files with 2180 additions and 695 deletions
@@ -89,14 +89,17 @@ public final class BukkitWorldReconciler {
DimensionResolution dimensionResolution;
Long configuredSeed;
String configuredWorldName;
try {
configuredWorldName = backend.configuredWorldName(worldKey);
dimensionResolution = backend.resolveDimension(worldKey);
configuredSeed = dimensionResolution.succeeded()
? backend.configuredSeed(IrisWorldStorage.logicalName(worldKey))
? backend.configuredSeed(configuredWorldName)
: null;
} catch (Throwable failure) {
dimensionResolution = DimensionResolution.failed(failure);
configuredSeed = null;
configuredWorldName = IrisWorldStorage.logicalName(worldKey);
}
if (!dimensionResolution.succeeded()) {
lease.close();
@@ -106,6 +109,7 @@ public final class BukkitWorldReconciler {
}
return loadWithLease(
configurationFile,
configuredWorldName,
worldKey,
dimensionResolution.dimension(),
configuredSeed,
@@ -137,7 +141,7 @@ public final class BukkitWorldReconciler {
}
NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.keyFromName(worldName);
worldKey = backend.worldKeyFromConfiguration(worldName);
} catch (Throwable failure) {
chain = chain.thenApply(results -> {
results.add(LoadResult.validationFailure(worldName, failure));
@@ -158,6 +162,7 @@ public final class BukkitWorldReconciler {
}
chain = chain.thenCompose(results -> loadConfiguredWorld(
ServerProperties.BUKKIT_YML,
worldName,
worldKey,
dimension,
seed)
@@ -176,6 +181,7 @@ public final class BukkitWorldReconciler {
private CompletableFuture<LoadResult> loadConfiguredWorld(
File configurationFile,
String configuredWorldName,
NamespacedKey worldKey,
String dimension,
Long seed
@@ -187,7 +193,7 @@ public final class BukkitWorldReconciler {
return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure));
}
return loadWithLease(configurationFile, worldKey, dimension, seed, lease);
return loadWithLease(configurationFile, configuredWorldName, worldKey, dimension, seed, lease);
}
private LifecycleOperationCoordinator.Lease acquireWorldLoad(NamespacedKey worldKey) {
@@ -199,6 +205,7 @@ public final class BukkitWorldReconciler {
private CompletableFuture<LoadResult> loadWithLease(
File configurationFile,
String configuredWorldName,
NamespacedKey worldKey,
String dimension,
Long seed,
@@ -214,11 +221,10 @@ public final class BukkitWorldReconciler {
}
BukkitWorldConfiguration.Registration registration;
String worldName = IrisWorldStorage.logicalName(worldKey);
try {
registration = BukkitWorldConfiguration.register(
configurationFile,
worldName,
configuredWorldName,
dimension,
seed);
} catch (Throwable failure) {
@@ -250,7 +256,7 @@ public final class BukkitWorldReconciler {
try {
boolean rolledBack = BukkitWorldConfiguration.removeIfMatching(
configurationFile,
worldName,
configuredWorldName,
dimension,
seed);
return new LoadResult(settled, registration, true, rolledBack, null);
@@ -426,6 +432,10 @@ public final class BukkitWorldReconciler {
Long configuredSeed(String worldName);
String configuredWorldName(NamespacedKey worldKey);
NamespacedKey worldKeyFromConfiguration(String configuredWorldName);
Optional<World> loadedWorld(NamespacedKey worldKey);
CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed);
@@ -667,6 +677,19 @@ public final class BukkitWorldReconciler {
return IrisWorlds.readBukkitWorldSeed(worldName);
}
@Override
public String configuredWorldName(NamespacedKey worldKey) {
return IrisWorldStorage.configuredWorldName(worldKey, IrisWorldStorage.levelRoot().getName());
}
@Override
public NamespacedKey worldKeyFromConfiguration(String configuredWorldName) {
return IrisWorldStorage.keyFromConfiguredWorldName(
configuredWorldName,
IrisWorldStorage.levelRoot().getName()
);
}
@Override
public Optional<World> loadedWorld(NamespacedKey worldKey) {
return WorldIdentity.resolve(worldKey);
@@ -165,7 +165,8 @@ public final class IrisWorldGeneratorResolver {
@Nullable
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
File pack = IrisWorldStorage.packRoot(IrisWorldStorage.keyFromName(worldName));
NamespacedKey worldKey = configuredWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
File pack = IrisWorldStorage.packRoot(worldKey);
IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null;
if (dimension == null) dimension = IrisData.loadAnyDimension(id, null);
if (dimension == null) {
@@ -182,6 +183,10 @@ public final class IrisWorldGeneratorResolver {
return dimension;
}
static NamespacedKey configuredWorldKey(String worldName, String levelName) {
return IrisWorldStorage.keyFromConfiguredWorldName(worldName, levelName);
}
/**
* Resolves the biome provider for a world, falling back to the supplied Bukkit default when
* Iris has nothing staged.
@@ -212,7 +217,7 @@ public final class IrisWorldGeneratorResolver {
if (dim == null) {
throw new RuntimeException("Can't find dimension " + id + "!");
}
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
NamespacedKey worldKey = configuredWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
File dimensionPackRoot = dim.getLoader().getDataFolder();
String packName = dimensionPackRoot.getName();
@@ -13,6 +13,7 @@ import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPath
import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
import art.arcane.iris.core.lifecycle.WorldReplacementSeed;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
@@ -33,7 +34,6 @@ import org.bukkit.event.world.WorldLoadEvent;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
@@ -56,19 +56,10 @@ public final class PendingWorldReplacementManager implements Listener {
}
public NamespacedKey resolveRequestedWorldKey(String requestedName) {
String requested = Objects.requireNonNull(requestedName, "requestedName").trim();
if (requested.isEmpty()) {
throw new IllegalArgumentException("World name cannot be empty.");
}
if (requested.contains("/") || requested.contains("\\") || requested.contains("..")) {
throw new IllegalArgumentException("World name must be a safe single path segment.");
}
NamespacedKey worldKey = requested.contains(":")
? NamespacedKey.fromString(requested.toLowerCase(Locale.ENGLISH))
: IrisWorldStorage.keyFromName(requested);
if (worldKey == null) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
NamespacedKey worldKey = IrisWorldStorage.replacementKeyFromName(
requestedName,
IrisWorldStorage.levelRoot().getName()
);
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), toWorldSlotKey(worldKey));
return worldKey;
}
@@ -76,14 +67,13 @@ public final class PendingWorldReplacementManager implements Listener {
public synchronized StagedReplacement stageReplacement(
VolmitSender sender,
NamespacedKey worldKey,
IrisDimension dimension,
long seed
IrisDimension dimension
) throws IOException {
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
IrisStartupValidation.requireWorldCreationReady();
IrisStartupValidation.requireWorldReplacementStagingReady();
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
}
@@ -97,18 +87,19 @@ public final class PendingWorldReplacementManager implements Listener {
if (findTransaction(requiredWorldSlotKey) != null) {
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
}
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldSlotKey);
ExactWorldSlotPathPolicy.Target target = resolveTarget(requiredWorldSlotKey);
requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment());
long effectiveSeed = resolveEffectiveSeed(target.slotKind(), seed);
requireVanillaSlotEnabled(target.slotKind());
UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
WorldReplacementFilesystem.requireExistingTarget(paths);
long effectiveSeed = WorldReplacementSeed.readAuthoritativeSeed(paths.target());
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) {
throw new IOException("Iris could not compile the dimension datapacks.");
}
UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
WorldReplacementFilesystem.requireExistingTarget(paths);
boolean targetPresent = true;
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
ServerProperties.BUKKIT_YML,
@@ -435,23 +426,8 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
private ExactWorldSlotPathPolicy.Target prepareTarget(WorldSlotKey worldKey) throws IOException {
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
Path dimensions = target.levelRoot().resolve("dimensions");
createDirectoryIfMissing(dimensions);
createDirectoryIfMissing(target.namespaceRoot());
return ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
}
private static void createDirectoryIfMissing(Path directory) throws IOException {
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("World storage parent is unsafe: " + directory);
}
return;
}
Files.createDirectory(directory);
private static ExactWorldSlotPathPolicy.Target resolveTarget(WorldSlotKey worldKey) {
return ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
}
private Transaction findTransaction(NamespacedKey worldKey) throws IOException {
@@ -510,7 +486,7 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
private static void requireCompatibleEnvironment(SlotKind slotKind, IrisEnvironment environment) {
static void requireCompatibleEnvironment(SlotKind slotKind, IrisEnvironment environment) {
IrisEnvironment expected = switch (slotKind) {
case VANILLA_OVERWORLD -> IrisEnvironment.NORMAL;
case VANILLA_NETHER -> IrisEnvironment.NETHER;
@@ -524,62 +500,58 @@ public final class PendingWorldReplacementManager implements Listener {
}
/**
* Captures the primary level context on the main thread at startup. resolveEffectiveSeed
* reads this snapshot so staging never blocks on a main-thread hop while holding the
* manager monitor — that inversion froze the server for the full 30s hop timeout whenever
* another world loaded during staging. The context is stable for the process lifetime:
* slot replacements only commit across a restart.
* Captures vanilla-slot availability on the main thread at startup so staging never blocks
* on a main-thread hop while holding the manager monitor. The context is stable for the
* process lifetime because slot replacements only commit across a restart.
*/
public void captureVanillaLevelContext() {
try {
vanillaLevelContext = new VanillaLevelContext(
WorldIdentity.resolve(NamespacedKey.minecraft("overworld"))
.orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded."))
.getSeed(),
Iris.instance.getServer().getAllowNether(),
Iris.instance.getServer().getAllowEnd()
);
} catch (Throwable failure) {
Iris.debug("Could not capture the primary level context yet: " + detail(failure));
Iris.debug("Could not capture vanilla-slot availability yet: " + detail(failure));
}
}
private static long resolveEffectiveSeed(SlotKind slotKind, long requestedSeed) throws IOException {
if (slotKind == SlotKind.IRIS_MANAGED) {
return requestedSeed;
private static void requireVanillaSlotEnabled(SlotKind slotKind) throws IOException {
if (slotKind != SlotKind.VANILLA_NETHER && slotKind != SlotKind.VANILLA_END) {
return;
}
VanillaLevelContext context = vanillaLevelContext;
if (context == null) {
context = resolveVanillaLevelContext();
vanillaLevelContext = context;
}
if (slotKind == SlotKind.VANILLA_NETHER && !context.allowNether()) {
requireVanillaSlotEnabled(slotKind, context.allowNether(), context.allowEnd());
}
static void requireVanillaSlotEnabled(SlotKind slotKind, boolean allowNether, boolean allowEnd)
throws IOException {
if (slotKind == SlotKind.VANILLA_NETHER && !allowNether) {
throw new IOException("allow-nether must be true before the vanilla Nether can be replaced.");
}
if (slotKind == SlotKind.VANILLA_END && !context.allowEnd()) {
if (slotKind == SlotKind.VANILLA_END && !allowEnd) {
throw new IOException("Bukkit allow-end must be true before the vanilla End can be replaced.");
}
return context.seed();
}
private static VanillaLevelContext resolveVanillaLevelContext() throws IOException {
CompletableFuture<VanillaLevelContext> contextFuture = J.sfut(() -> new VanillaLevelContext(
WorldIdentity.resolve(NamespacedKey.minecraft("overworld"))
.orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded."))
.getSeed(),
Iris.instance.getServer().getAllowNether(),
Iris.instance.getServer().getAllowEnd()
));
if (contextFuture == null) {
throw new IOException("Could not schedule primary level-seed resolution.");
throw new IOException("Could not schedule vanilla-slot availability resolution.");
}
try {
return contextFuture.get(30L, TimeUnit.SECONDS);
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IOException("Primary level-seed resolution was interrupted.", failure);
throw new IOException("Vanilla-slot availability resolution was interrupted.", failure);
} catch (ExecutionException | TimeoutException failure) {
throw new IOException("Could not resolve the authoritative primary level seed.", failure);
throw new IOException("Could not resolve vanilla-slot availability.", failure);
}
}
@@ -628,7 +600,7 @@ public final class PendingWorldReplacementManager implements Listener {
private static volatile VanillaLevelContext vanillaLevelContext;
private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) {
private record VanillaLevelContext(boolean allowNether, boolean allowEnd) {
}
private static final class RestartBoundaryRequired extends IOException {
@@ -33,6 +33,7 @@ import art.arcane.iris.core.lifecycle.IrisWorldRemovalService;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackDirectoryResolver;
@@ -52,7 +53,6 @@ import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import art.arcane.iris.util.common.director.specialhandlers.NullablePlayerHandler;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Bukkit;
@@ -62,28 +62,17 @@ import org.bukkit.World;
import org.bukkit.entity.Player;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
import static org.bukkit.Bukkit.getServer;
@@ -109,7 +98,6 @@ public class CommandIris implements DirectorExecutor {
private CommandPack pack;
private CommandFind find;
private CommandDatapack datapack;
private static final AtomicReference<Thread> mainWorld = new AtomicReference<>();
VolmitSender sender = Iris.getSender();
@Director(description = "Create a new world", descriptionKey = "iris.director.commandiris.director.create_new_world", aliases = {"c"})
@@ -124,29 +112,17 @@ public class CommandIris implements DirectorExecutor {
)
String type,
@Param(description = "The seed to generate the world with", descriptionKey = "iris.director.commandiris.param.seed_generate_world_with", defaultValue = "1337")
long seed,
@Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", descriptionKey = "iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world", defaultValue = "false")
boolean main,
@Param(name = "overwrite", aliases = "force", description = "Replace the exact existing world slot on the next restart", descriptionKey = "iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart", defaultValue = "false")
boolean overwrite
long seed
) {
NamespacedKey worldKey;
try {
if (overwrite) {
worldKey = Iris.instance.pendingWorldReplacements().resolveRequestedWorldKey(name);
} else {
worldKey = IrisWorldStorage.managedKeyFromName(name);
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
}
worldKey = IrisWorldStorage.managedKeyFromName(name);
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
} catch (IllegalArgumentException e) {
sender().sendMessage(C.RED + e.getMessage());
return;
}
String worldName = IrisWorldStorage.logicalName(worldKey);
if (overwrite && main && !NamespacedKey.minecraft("overworld").equals(worldKey)) {
sender().sendMessage(C.RED + "overwrite=true with main=true must target the configured main-world name.");
return;
}
if (worldName.equalsIgnoreCase("iris")) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD));
@@ -159,7 +135,7 @@ public class CommandIris implements DirectorExecutor {
return;
}
if (!overwrite && IrisWorldStorage.dimensionRoot(worldName).exists()) {
if (IrisWorldStorage.dimensionRoot(worldName).exists()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS));
return;
}
@@ -177,31 +153,13 @@ public class CommandIris implements DirectorExecutor {
return;
}
if (overwrite) {
try {
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
.pendingWorldReplacements()
.stageReplacement(sender(), worldKey, dimension, seed);
if (staged.seed() != seed) {
sender().sendMessage(C.YELLOW + "Exact vanilla slots preserve the shared level seed; using "
+ staged.seed() + " instead of " + seed + ".");
}
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
+ ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement.");
} catch (Throwable failure) {
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
? failure.getClass().getSimpleName()
: failure.getMessage();
sender().sendMessage(C.RED + "Could not stage the world replacement: " + detail);
}
return;
}
if (J.isFolia()) {
if (stageFoliaWorldCreation(worldName, dimension, seed, main)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTART_SERVER_GENERATE_LOAD, MessageArgument.untrusted("worldName", worldName)));
boolean staged = stageFoliaWorldCreation(worldName, dimension, seed);
if (!staged) {
return;
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTARTING_SERVER_GENERATE_LOAD, MessageArgument.untrusted("worldName", worldName)));
ServerConfigurator.restart("Iris staged Folia world \"" + worldName + "\" for startup.");
return;
}
@@ -213,12 +171,6 @@ public class CommandIris implements DirectorExecutor {
.sender(sender())
.studio(false)
.create();
if (main) {
Runtime.getRuntime().addShutdownHook(mainWorld.updateAndGet(old -> {
if (old != null) Runtime.getRuntime().removeShutdownHook(old);
return new Thread(() -> updateMainWorld(worldName));
}));
}
} catch (Throwable e) {
if (reportExpectedCreationInterruption(e)) {
return;
@@ -229,207 +181,65 @@ public class CommandIris implements DirectorExecutor {
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_CREATED_YOUR_WORLD));
if (main) sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOUR_WORLD_WILL_AUTOMATICALLY_BE_SET_AS_MAIN_WORLD_WHEN));
}
private boolean updateMainWorld(String newName) {
LifecycleOperationCoordinator.Lease lease;
@Director(
description = "Replace an existing world with Iris generation on the next restart",
descriptionKey = "iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart",
aliases = {"override", "overwrite"}
)
public void replace(
@Param(
name = "target",
aliases = "world-name",
description = "The exact existing world slot to replace",
descriptionKey = "iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart"
)
String target,
@Param(
aliases = {"dimension", "pack"},
description = "The dimension/pack to replace the world with",
defaultValue = "default",
customHandler = PackDimensionTypeHandler.class
)
String type
) {
NamespacedKey worldKey;
try {
lease = LifecycleOperationCoordinator.get().acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_PROMOTE,
newName
);
} catch (LifecycleOperationCoordinator.BusyException e) {
Iris.error("Could not promote Iris world \"" + newName + "\": " + e.getMessage());
return false;
}
try {
return updateMainWorldUnderLease(newName);
} finally {
lease.close();
}
}
private boolean updateMainWorldUnderLease(String newName) {
try {
File oldLevelRoot = IrisWorldStorage.levelRoot();
File worldContainer = oldLevelRoot.getParentFile();
if (worldContainer == null) {
throw new IllegalStateException("Current level folder has no world container.");
}
Properties data = new Properties();
try (FileInputStream in = new FileInputStream(ServerProperties.SERVER_PROPERTIES)) {
data.load(in);
}
File sourceDimensionRoot = IrisWorldStorage.dimensionRoot(IrisWorldStorage.keyFromName(newName));
if (!sourceDimensionRoot.isDirectory()) {
throw new IllegalStateException("Source dimension folder does not exist: " + sourceDimensionRoot.getAbsolutePath());
}
File newLevelRoot = new File(worldContainer, newName);
World sourceWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(newName)).orElse(null);
Long stagedSeed = IrisWorlds.readBukkitWorldSeed(newName);
if (sourceWorld == null && stagedSeed == null) {
throw new IllegalStateException("Cannot determine the promoted world's seed.");
}
long promotedSeed = sourceWorld == null ? stagedSeed : sourceWorld.getSeed();
data.setProperty("level-name", newName);
data.setProperty("level-seed", Long.toString(promotedSeed));
try (MainWorldPublication publication = publishMainWorldFiles(
oldLevelRoot.toPath(),
sourceDimensionRoot.toPath(),
newLevelRoot.toPath()
)) {
writeServerPropertiesAtomically(ServerProperties.SERVER_PROPERTIES.toPath(), data);
publication.commit();
}
synchronized (ServerProperties.DATA) {
ServerProperties.DATA.clear();
ServerProperties.DATA.putAll(data);
}
return true;
} catch (Throwable e) {
Iris.error("Failed to update server.properties main world to \"" + newName + "\"");
Iris.reportError(e);
return false;
}
}
static MainWorldPublication publishMainWorldFiles(
Path currentLevelRoot,
Path sourceDimensionRoot,
Path targetLevelRoot
) throws IOException {
Path current = Objects.requireNonNull(currentLevelRoot, "currentLevelRoot").toAbsolutePath().normalize();
Path sourceDimension = Objects.requireNonNull(sourceDimensionRoot, "sourceDimensionRoot").toAbsolutePath().normalize();
Path target = Objects.requireNonNull(targetLevelRoot, "targetLevelRoot").toAbsolutePath().normalize();
Path worldContainer = current.getParent();
Path sourceNamespace = current.resolve("dimensions/iris");
if (worldContainer == null || !Objects.equals(target.getParent(), worldContainer)) {
throw new IOException("Promoted main world must be a direct child of the world container.");
}
if (!Objects.equals(sourceDimension.getParent(), sourceNamespace)) {
throw new IOException("Promoted source must be a direct Iris dimension.");
}
if (Objects.equals(current, target)) {
throw new IOException("Promoted main world cannot replace the current main world.");
}
if (Files.isSymbolicLink(worldContainer)
|| Files.isSymbolicLink(current)
|| !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Current world storage is missing or unsafe.");
}
if (Files.isSymbolicLink(sourceDimension)
|| !Files.isDirectory(sourceDimension, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Promoted Iris dimension is missing or unsafe: " + sourceDimension);
}
requireAbsentMainWorldTarget(target);
Path stage = Files.createTempDirectory(worldContainer, "." + target.getFileName() + ".promoting-");
boolean published = false;
try {
for (String subdirectory : List.of("data", "datapacks", "players")) {
Path source = current.resolve(subdirectory);
if (!Files.exists(source, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(source)) {
continue;
}
copyWorldTree(source, stage.resolve(subdirectory));
}
Path targetDimension = IrisWorldStorage.dimensionRoot(
stage.toFile(),
NamespacedKey.minecraft("overworld")
).toPath();
copyWorldTree(sourceDimension, targetDimension);
requireAbsentMainWorldTarget(target);
Files.move(stage, target);
published = true;
return new MainWorldPublication(target);
} finally {
if (!published) {
AtomicDirectoryPublisher.deleteTree(stage);
}
}
}
private static void requireAbsentMainWorldTarget(Path target) throws IOException {
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
throw new FileAlreadyExistsException("Main-world target already exists: " + target);
}
}
private static void copyWorldTree(Path source, Path target) throws IOException {
if (Files.isSymbolicLink(source)) {
throw new IOException("World data contains a symbolic link: " + source);
}
if (Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
worldKey = Iris.instance.pendingWorldReplacements().resolveRequestedWorldKey(target);
} catch (IllegalArgumentException e) {
sender().sendMessage(C.RED + e.getMessage());
return;
}
if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("World data contains an unsupported entry: " + source);
}
try (Stream<Path> entries = Files.walk(source)) {
for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) {
if (Files.isSymbolicLink(entry)) {
throw new IOException("World data contains a symbolic link: " + entry);
}
Path destination = target.resolve(source.relativize(entry)).normalize();
if (!destination.startsWith(target)) {
throw new IOException("World data escapes its promotion stage: " + entry);
}
if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination);
} else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination.getParent());
Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES);
} else {
throw new IOException("World data contains an unsupported entry: " + entry);
}
}
}
}
private static void writeServerPropertiesAtomically(Path propertiesFile, Properties data) throws IOException {
Path target = propertiesFile.toAbsolutePath().normalize();
Path parent = target.getParent();
if (parent == null) {
throw new IOException("server.properties has no parent directory.");
String resolvedType = type.equalsIgnoreCase("default")
? IrisSettings.get().getGenerator().getDefaultWorldType()
: type;
IrisDimension dimension = IrisToolbelt.getDimension(resolvedType);
if (dimension == null) {
sender().sendMessage("Could not find dimension '" + resolvedType + "'.");
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_TRY_ONE_OVERWORLD_VANILLA_FLAT_THEEND));
sender().sendMessage("Install its pack with " + PackDownloader.downloadCommandFor(resolvedType)
+ " and restart the server.");
return;
}
Path stage = Files.createTempFile(parent, ".server.properties.promoting-", ".tmp");
IOException operationFailure = null;
try {
try (FileOutputStream out = new FileOutputStream(stage.toFile())) {
data.store(out, null);
out.getFD().sync();
}
try {
Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(stage, target, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
operationFailure = e;
throw e;
} finally {
try {
Files.deleteIfExists(stage);
} catch (IOException cleanupFailure) {
if (operationFailure != null) {
operationFailure.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
.pendingWorldReplacements()
.stageReplacement(sender(), worldKey, dimension);
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
+ ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement.");
} catch (Throwable failure) {
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
? failure.getClass().getSimpleName()
: failure.getMessage();
sender().sendMessage(C.RED + "Could not stage the world replacement: " + detail);
}
}
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) {
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed) {
try {
IrisStartupValidation.requireWorldCreationReady();
PackValidationRegistry.requireLoadable(
@@ -441,6 +251,7 @@ public class CommandIris implements DirectorExecutor {
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(name);
LifecycleOperationCoordinator.Lease worldLease = null;
File worldFolder = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
Path stagedWorld = null;
try {
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
worldLease = coordinator.acquire(
@@ -459,53 +270,68 @@ public class CommandIris implements DirectorExecutor {
sender().sendMessage(C.RED + "Failed to compile the Iris datapack. No world files were staged.");
return false;
}
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder);
Path targetWorld = worldFolder.toPath().toAbsolutePath().normalize();
Path namespaceRoot = targetWorld.getParent();
if (namespaceRoot == null) {
throw new IOException("Iris world target has no namespace directory: " + targetWorld);
}
Files.createDirectories(namespaceRoot);
stagedWorld = Files.createTempDirectory(namespaceRoot, ".iris-create-" + worldKey.getKey() + "-");
Path sourceOverworld = IrisWorldStorage.dimensionRoot(
IrisWorldStorage.levelRoot(),
NamespacedKey.minecraft("overworld")
).toPath();
INMS.get().writeCurrentPaperWorldData(sourceOverworld, stagedWorld, seed);
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(
sender(),
dimension,
stagedWorld.toFile()
);
if (installed == null) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey())));
deleteDirectorySafely(worldFolder);
return false;
}
if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) {
deleteDirectorySafely(worldFolder);
return false;
}
if (main) {
if (updateMainWorldUnderLease(name)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name)));
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD));
try {
BukkitWorldConfiguration.remove(BUKKIT_YML, name);
} catch (IOException e) {
Iris.reportError("Failed to roll back bukkit.yml after main-world staging failed.", e);
}
deleteDirectorySafely(worldFolder);
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publishAbsent(
stagedWorld,
targetWorld
)) {
stagedWorld = null;
if (!registerWorldInBukkitYml(worldKey, dimension.getLoadKey(), seed)) {
return false;
}
publication.commit();
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed)));
if (main) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART));
}
return true;
} catch (LifecycleOperationCoordinator.BusyException e) {
sender().sendMessage(C.YELLOW + e.getMessage());
return false;
} catch (Throwable e) {
sender().sendMessage(C.RED + "Failed to stage the complete Iris world: " + e.getMessage());
Iris.reportError("Failed to stage complete Folia world \"" + worldKey + "\".", e);
return false;
} finally {
if (stagedWorld != null) {
deleteDirectorySafely(stagedWorld.toFile());
}
if (worldLease != null) {
worldLease.close();
}
}
}
private boolean registerWorldInBukkitYml(String worldName, String dimension, Long seed) {
String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(worldName));
private boolean registerWorldInBukkitYml(NamespacedKey worldKey, String dimension, Long seed) {
String configuredWorldName = IrisWorldStorage.configuredWorldName(
worldKey,
IrisWorldStorage.levelRoot().getName()
);
try {
BukkitWorldConfiguration.register(BUKKIT_YML, logicalWorldName, dimension, seed);
Iris.info("Registered \"" + logicalWorldName + "\" in bukkit.yml");
BukkitWorldConfiguration.register(BUKKIT_YML, configuredWorldName, dimension, seed);
Iris.info("Registered \"" + configuredWorldName + "\" in bukkit.yml");
return true;
} catch (IOException e) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UPDATE_BUKKIT_YML, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
@@ -1097,31 +923,4 @@ public class CommandIris implements DirectorExecutor {
}
}
static final class MainWorldPublication implements AutoCloseable {
private final Path target;
private boolean committed;
private boolean closed;
MainWorldPublication(Path target) {
this.target = target;
}
void commit() {
if (closed) {
throw new IllegalStateException("Main-world publication is already closed.");
}
committed = true;
}
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
if (!committed) {
AtomicDirectoryPublisher.deleteTree(target);
}
}
}
}
@@ -378,6 +378,16 @@ public class BukkitWorldReconcilerTest {
return configuredSeed;
}
@Override
public String configuredWorldName(NamespacedKey requestedWorldKey) {
return requestedWorldKey.getKey();
}
@Override
public NamespacedKey worldKeyFromConfiguration(String configuredWorldName) {
return worldKey;
}
@Override
public Optional<World> loadedWorld(NamespacedKey requestedWorldKey) {
return worldKey.equals(requestedWorldKey) ? loaded : Optional.empty();
@@ -7,6 +7,7 @@ import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.bukkit.NamespacedKey;
import java.io.File;
import java.nio.charset.StandardCharsets;
@@ -63,6 +64,18 @@ public class IrisWorldGeneratorResolverTest {
assertFalse(invalid.getBlockingErrors().toString(), invalid.isLoadable());
}
@Test
public void paperStartupAliasResolvesToCanonicalRuntimeKey() {
assertEquals(
new NamespacedKey("iris", "moon"),
IrisWorldGeneratorResolver.configuredWorldKey("world_iris_moon", "world")
);
assertEquals(
new NamespacedKey("iris", "moon"),
IrisWorldGeneratorResolver.configuredWorldKey("moon", "world")
);
}
private static void writeValidPack(Path packRoot) throws Exception {
Files.createDirectories(packRoot.resolve("dimensions"));
Files.createDirectories(packRoot.resolve("regions"));
@@ -0,0 +1,126 @@
package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.ExactWorldSlotPathPolicy.SlotKind;
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEnvironment;
import art.arcane.iris.util.common.plugin.VolmitSender;
import org.bukkit.NamespacedKey;
import org.junit.After;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class PendingWorldReplacementManagerPolicyTest {
@After
public void disableValidation() {
IrisStartupValidation.disable();
}
@Test
public void irisManagedSlotsAllowEveryReplacementEnvironment() {
for (IrisEnvironment environment : IrisEnvironment.values()) {
PendingWorldReplacementManager.requireCompatibleEnvironment(SlotKind.IRIS_MANAGED, environment);
}
}
@Test
public void vanillaSlotsRequireTheirIdentityEnvironment() {
List<EnvironmentExpectation> expectations = List.of(
new EnvironmentExpectation(SlotKind.VANILLA_OVERWORLD, IrisEnvironment.NORMAL),
new EnvironmentExpectation(SlotKind.VANILLA_NETHER, IrisEnvironment.NETHER),
new EnvironmentExpectation(SlotKind.VANILLA_END, IrisEnvironment.THE_END)
);
for (EnvironmentExpectation expectation : expectations) {
for (IrisEnvironment environment : IrisEnvironment.values()) {
if (environment == expectation.environment()) {
PendingWorldReplacementManager.requireCompatibleEnvironment(
expectation.slotKind(),
environment
);
continue;
}
assertThrows(
IllegalArgumentException.class,
() -> PendingWorldReplacementManager.requireCompatibleEnvironment(
expectation.slotKind(),
environment
)
);
}
}
}
@Test
public void disabledVanillaSlotsFailWithoutRestrictingOtherSlots() throws Exception {
PendingWorldReplacementManager.requireVanillaSlotEnabled(SlotKind.IRIS_MANAGED, false, false);
PendingWorldReplacementManager.requireVanillaSlotEnabled(SlotKind.VANILLA_OVERWORLD, false, false);
PendingWorldReplacementManager.requireVanillaSlotEnabled(SlotKind.VANILLA_NETHER, true, false);
PendingWorldReplacementManager.requireVanillaSlotEnabled(SlotKind.VANILLA_END, false, true);
IOException netherFailure = assertThrows(
IOException.class,
() -> PendingWorldReplacementManager.requireVanillaSlotEnabled(
SlotKind.VANILLA_NETHER,
false,
true
)
);
IOException endFailure = assertThrows(
IOException.class,
() -> PendingWorldReplacementManager.requireVanillaSlotEnabled(
SlotKind.VANILLA_END,
true,
false
)
);
assertEquals(
"allow-nether must be true before the vanilla Nether can be replaced.",
netherFailure.getMessage()
);
assertEquals(
"Bukkit allow-end must be true before the vanilla End can be replaced.",
endFailure.getMessage()
);
}
@Test
public void replacementStagingPassesTheRestartBoundaryWithoutUnlockingCreation() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
IrisStartupValidation.requireRestart("restart boundary");
PendingWorldReplacementManager manager = new PendingWorldReplacementManager(mock(Iris.class));
VolmitSender sender = mock(VolmitSender.class);
IrisDimension dimension = mock(IrisDimension.class);
assertThrows(IllegalStateException.class, IrisStartupValidation::requireWorldCreationReady);
try (MockedStatic<WorldReplacementBootstrapMarker> bootstrapMarker =
mockStatic(WorldReplacementBootstrapMarker.class)) {
bootstrapMarker.when(WorldReplacementBootstrapMarker::wasBootstrappedThisProcess).thenReturn(false);
IOException failure = assertThrows(
IOException.class,
() -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension)
);
assertEquals(
"Exact world replacement requires a full Paper-family startup bootstrap.",
failure.getMessage()
);
}
}
private record EnvironmentExpectation(SlotKind slotKind, IrisEnvironment environment) {
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core.commands;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
@@ -8,28 +9,42 @@ import java.lang.reflect.Parameter;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CommandIrisCreateOverwriteContractTest {
@Test
public void createExposesOptInRestartReplacementFlag() throws Exception {
public void createOnlyAcceptsNameTypeAndSeed() throws Exception {
Method command = CommandIris.class.getDeclaredMethod(
"create",
String.class,
String.class,
long.class,
boolean.class,
boolean.class
long.class
);
Parameter overwriteParameter = command.getParameters()[4];
Param overwrite = overwriteParameter.getAnnotation(Param.class);
assertEquals("overwrite", overwrite.name());
assertEquals("false", overwrite.defaultValue());
assertTrue(Arrays.asList(overwrite.aliases()).contains("force"));
assertEquals(3, command.getParameterCount());
assertFalse(Arrays.stream(command.getParameterTypes()).anyMatch(type -> type == boolean.class));
}
@Test
public void replaceOwnsOverrideAndOverwriteAliasesWithoutASeed() throws Exception {
Method command = CommandIris.class.getDeclaredMethod("replace", String.class, String.class);
Director director = command.getAnnotation(Director.class);
Parameter targetParameter = command.getParameters()[0];
Param target = targetParameter.getAnnotation(Param.class);
Parameter typeParameter = command.getParameters()[1];
Param type = typeParameter.getAnnotation(Param.class);
assertTrue(Arrays.asList(director.aliases()).contains("override"));
assertTrue(Arrays.asList(director.aliases()).contains("overwrite"));
assertEquals(
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart",
overwrite.descriptionKey()
director.descriptionKey()
);
assertEquals("target", target.name());
assertEquals(director.descriptionKey(), target.descriptionKey());
assertEquals("default", type.defaultValue());
assertEquals(CommandIris.PackDimensionTypeHandler.class, type.customHandler());
assertFalse(Arrays.stream(command.getParameterTypes()).anyMatch(parameterType -> parameterType == long.class));
}
}
@@ -0,0 +1,48 @@
package art.arcane.iris.core.commands;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class CommandIrisFoliaCreateContractTest {
@Test
public void ordinaryFoliaCreateRestartsOnlyAfterSuccessfulStagingAndFeedback() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
String foliaCreate = source.substring(
source.indexOf("if (J.isFolia()) {"),
source.indexOf(" try {", source.indexOf("if (J.isFolia()) {"))
);
int stage = foliaCreate.indexOf("stageFoliaWorldCreation(worldName, dimension, seed)");
int failureExit = foliaCreate.indexOf("if (!staged)");
int feedback = foliaCreate.indexOf("COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTARTING_SERVER_GENERATE_LOAD");
int restart = foliaCreate.indexOf("ServerConfigurator.restart(\"Iris staged Folia world");
assertTrue(stage >= 0);
assertTrue(stage < failureExit);
assertTrue(failureExit < feedback);
assertTrue(feedback < restart);
}
@Test
public void foliaStagePublishesCurrentPaperDataBeforeRegisteringStartupAlias() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
int methodStart = source.indexOf("private boolean stageFoliaWorldCreation(");
int methodEnd = source.indexOf("private boolean registerWorldInBukkitYml(", methodStart);
String staging = source.substring(methodStart, methodEnd);
int currentPaperData = staging.indexOf("INMS.get().writeCurrentPaperWorldData(");
int pack = staging.indexOf("installIntoWorld(");
int publication = staging.indexOf("AtomicDirectoryPublisher.publishAbsent(");
int registration = staging.indexOf("registerWorldInBukkitYml(worldKey");
assertTrue(currentPaperData >= 0);
assertTrue(currentPaperData < pack);
assertTrue(pack < publication);
assertTrue(publication < registration);
assertTrue(source.contains("IrisWorldStorage.configuredWorldName("));
}
}
@@ -1,93 +0,0 @@
package art.arcane.iris.core.commands;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class CommandIrisMainWorldPromotionTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void existingTopLevelWorldIsRefusedWithoutMerging() throws IOException {
PromotionPaths paths = createPromotionPaths("existing-target");
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("sentinel.txt"), "keep");
assertThrows(FileAlreadyExistsException.class, () -> CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
));
assertEquals("keep", Files.readString(paths.target().resolve("sentinel.txt")));
assertFalse(Files.exists(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
@Test
public void uncommittedPromotionRollsBackThePublishedWorld() throws IOException {
PromotionPaths paths = createPromotionPaths("rollback-target");
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
)) {
assertTrue(Files.isRegularFile(paths.target().resolve("data/map.dat")));
assertTrue(Files.isRegularFile(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
}
assertFalse(Files.exists(paths.target()));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
@Test
public void committedPromotionKeepsTheCompleteStagedWorld() throws IOException {
PromotionPaths paths = createPromotionPaths("committed-target");
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
)) {
publication.commit();
}
assertEquals("map", Files.readString(paths.target().resolve("data/map.dat")));
assertEquals("region", Files.readString(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
private PromotionPaths createPromotionPaths(String targetName) throws IOException {
Path root = temporaryFolder.newFolder(targetName + "-root").toPath();
Path current = root.resolve("world");
Path sourceDimension = current.resolve("dimensions/iris/" + targetName);
Path target = root.resolve(targetName);
Files.createDirectories(current.resolve("data"));
Files.writeString(current.resolve("data/map.dat"), "map");
Files.createDirectories(sourceDimension.resolve("region"));
Files.writeString(sourceDimension.resolve("region/r.0.0.mca"), "region");
return new PromotionPaths(root, current, sourceDimension, target);
}
private boolean hasPromotionStage(Path root, String targetName) throws IOException {
try (Stream<Path> entries = Files.list(root)) {
return entries.anyMatch(path -> path.getFileName().toString().startsWith("." + targetName + ".promoting-"));
}
}
private record PromotionPaths(Path root, Path current, Path sourceDimension, Path target) {
}
}