mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
Correct Java 26.2 Iris world lifecycle
This commit is contained in:
+115
@@ -14,6 +14,7 @@ import art.arcane.iris.core.nms.container.BiomeColor;
|
||||
import art.arcane.iris.core.nms.container.Pair;
|
||||
import art.arcane.iris.core.nms.container.BlockProperty;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementSeed;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
@@ -53,6 +54,8 @@ import art.arcane.volmlib.util.nbt.tag.CompoundTag;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import it.unimi.dsi.fastutil.objects.Object2IntMap;
|
||||
import it.unimi.dsi.fastutil.shorts.ShortList;
|
||||
import io.papermc.paper.world.saveddata.PaperLevelOverrides;
|
||||
import io.papermc.paper.world.saveddata.PaperWorldMetadata;
|
||||
import net.bytebuddy.ByteBuddy;
|
||||
import net.bytebuddy.agent.builder.AgentBuilder;
|
||||
import net.bytebuddy.agent.builder.ResettableClassFileTransformer;
|
||||
@@ -116,6 +119,8 @@ import net.minecraft.world.level.levelgen.structure.StructureCheck;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
|
||||
import net.minecraft.world.level.storage.PrimaryLevelData;
|
||||
import net.minecraft.world.level.storage.SavedDataStorage;
|
||||
import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
|
||||
import net.minecraft.world.level.levelgen.feature.FallenTreeFeature;
|
||||
@@ -151,10 +156,14 @@ import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Iterator;
|
||||
@@ -163,11 +172,18 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class NMSBinding implements INMSBinding {
|
||||
private static final long CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS = 30L;
|
||||
|
||||
private final KMap<Biome, Object> baseBiomeCache = new KMap<>();
|
||||
private volatile DataVersion dataVersion;
|
||||
private final BlockData AIR = Material.AIR.createBlockData();
|
||||
@@ -1593,6 +1609,105 @@ public class NMSBinding implements INMSBinding {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeCurrentPaperWorldData(
|
||||
Path sourceWorldDirectory,
|
||||
Path targetWorldDirectory,
|
||||
long seed
|
||||
) throws IOException {
|
||||
CraftServer craftServer = (CraftServer) Bukkit.getServer();
|
||||
MinecraftServer server = craftServer.getHandle().getServer();
|
||||
PaperLevelOverrides levelOverrides = captureCurrentPaperLevelOverrides(craftServer, server);
|
||||
|
||||
Path targetWorld = targetWorldDirectory.toAbsolutePath().normalize();
|
||||
UUID metadataUuid = UUID.randomUUID();
|
||||
WorldReplacementSeed.copyWithAuthoritativeSeed(sourceWorldDirectory, targetWorld, seed);
|
||||
try (SavedDataStorage savedDataStorage = new SavedDataStorage(
|
||||
targetWorld.resolve("data"),
|
||||
server.getFixerUpper(),
|
||||
server.registryAccess()
|
||||
)) {
|
||||
savedDataStorage.set(PaperWorldMetadata.TYPE, new PaperWorldMetadata(metadataUuid));
|
||||
savedDataStorage.set(
|
||||
PaperLevelOverrides.TYPE,
|
||||
levelOverrides
|
||||
);
|
||||
}
|
||||
|
||||
List<Path> requiredDataFiles = List.of(
|
||||
targetWorld.resolve("data/minecraft/world_gen_settings.dat"),
|
||||
targetWorld.resolve("data/paper/metadata.dat"),
|
||||
targetWorld.resolve("data/paper/level_overrides.dat")
|
||||
);
|
||||
for (Path requiredDataFile : requiredDataFiles) {
|
||||
if (!Files.isRegularFile(requiredDataFile, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Current Paper world data was not written: " + requiredDataFile);
|
||||
}
|
||||
}
|
||||
|
||||
long writtenSeed = WorldReplacementSeed.readAuthoritativeSeed(targetWorld);
|
||||
if (writtenSeed != seed) {
|
||||
throw new IOException("Current Paper world data did not retain the requested seed.");
|
||||
}
|
||||
|
||||
try (SavedDataStorage verificationStorage = new SavedDataStorage(
|
||||
targetWorld.resolve("data"),
|
||||
server.getFixerUpper(),
|
||||
server.registryAccess()
|
||||
)) {
|
||||
PaperWorldMetadata metadata = verificationStorage.get(PaperWorldMetadata.TYPE);
|
||||
if (metadata == null || !metadataUuid.equals(metadata.uuid())) {
|
||||
throw new IOException("Current Paper world metadata could not be verified.");
|
||||
}
|
||||
PaperLevelOverrides overrides = verificationStorage.get(PaperLevelOverrides.TYPE);
|
||||
if (overrides == null || overrides.isInitialized()) {
|
||||
throw new IOException("Current Paper level overrides could not be verified.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PaperLevelOverrides captureCurrentPaperLevelOverrides(
|
||||
CraftServer craftServer,
|
||||
MinecraftServer server
|
||||
) throws IOException {
|
||||
if (craftServer.isGlobalTickThread()) {
|
||||
return createCurrentPaperLevelOverrides(server);
|
||||
}
|
||||
if (J.isFolia() && J.isPrimaryThread()) {
|
||||
throw new IOException("Current Paper world data cannot be staged from a Folia region tick thread.");
|
||||
}
|
||||
|
||||
CompletableFuture<PaperLevelOverrides> captured = new CompletableFuture<>();
|
||||
boolean scheduled = J.runGlobal(() -> {
|
||||
try {
|
||||
captured.complete(createCurrentPaperLevelOverrides(server));
|
||||
} catch (Throwable failure) {
|
||||
captured.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
throw new IOException("Could not schedule the current Paper level-data snapshot on the global thread.");
|
||||
}
|
||||
|
||||
try {
|
||||
return captured.get(CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while capturing current Paper level data.", failure);
|
||||
} catch (ExecutionException failure) {
|
||||
throw new IOException("Could not capture current Paper level data.", failure.getCause());
|
||||
} catch (TimeoutException failure) {
|
||||
throw new IOException("Timed out while capturing current Paper level data.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private PaperLevelOverrides createCurrentPaperLevelOverrides(MinecraftServer server) throws IOException {
|
||||
if (!(server.getWorldData().overworldData() instanceof PrimaryLevelData primaryLevelData)) {
|
||||
throw new IOException("Paper primary level data is unavailable for current world data staging.");
|
||||
}
|
||||
return PaperLevelOverrides.createFromLiveLevelData(primaryLevelData);
|
||||
}
|
||||
|
||||
@Override
|
||||
public KMap<Material, List<BlockProperty>> getBlockProperties() {
|
||||
KMap<Material, List<BlockProperty>> states = new KMap<>();
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package art.arcane.iris.core.nms.v26_2_R1;
|
||||
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class NMSBindingCurrentPaperWorldDataContractTest {
|
||||
@Test
|
||||
public void stagesAllCurrentPaperWorldDataFromLiveServerState() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource")));
|
||||
String writer = section(
|
||||
source,
|
||||
"public void writeCurrentPaperWorldData(",
|
||||
"public KMap<Material, List<BlockProperty>> getBlockProperties()"
|
||||
);
|
||||
|
||||
assertTrue(writer.contains("WorldReplacementSeed.copyWithAuthoritativeSeed("));
|
||||
assertTrue(writer.contains("UUID metadataUuid = UUID.randomUUID()"));
|
||||
assertTrue(writer.contains("new PaperWorldMetadata(metadataUuid)"));
|
||||
assertTrue(writer.contains("captureCurrentPaperLevelOverrides(craftServer, server)"));
|
||||
assertTrue(writer.contains("new SavedDataStorage("));
|
||||
assertTrue(writer.contains("server.getFixerUpper()"));
|
||||
assertTrue(writer.contains("server.registryAccess()"));
|
||||
assertTrue(writer.contains("data/minecraft/world_gen_settings.dat"));
|
||||
assertTrue(writer.contains("data/paper/metadata.dat"));
|
||||
assertTrue(writer.contains("data/paper/level_overrides.dat"));
|
||||
assertTrue(writer.contains("WorldReplacementSeed.readAuthoritativeSeed(targetWorld)"));
|
||||
assertTrue(writer.contains("verificationStorage.get(PaperWorldMetadata.TYPE)"));
|
||||
assertTrue(writer.contains("metadataUuid.equals(metadata.uuid())"));
|
||||
assertTrue(writer.contains("verificationStorage.get(PaperLevelOverrides.TYPE)"));
|
||||
assertTrue(writer.contains("overrides == null || overrides.isInitialized()"));
|
||||
assertTrue(writer.contains("Files.isRegularFile(requiredDataFile, LinkOption.NOFOLLOW_LINKS)"));
|
||||
assertFalse(writer.toLowerCase().contains("migrat"));
|
||||
assertFalse(writer.toLowerCase().contains("fallback"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void capturesOnlyLiveLevelOverridesOnTheGlobalThread() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource")));
|
||||
String capture = section(
|
||||
source,
|
||||
"private PaperLevelOverrides captureCurrentPaperLevelOverrides(",
|
||||
"private PaperLevelOverrides createCurrentPaperLevelOverrides("
|
||||
);
|
||||
String create = section(
|
||||
source,
|
||||
"private PaperLevelOverrides createCurrentPaperLevelOverrides(",
|
||||
"public KMap<Material, List<BlockProperty>> getBlockProperties()"
|
||||
);
|
||||
|
||||
assertTrue(capture.contains("craftServer.isGlobalTickThread()"));
|
||||
assertTrue(capture.contains("J.isFolia() && J.isPrimaryThread()"));
|
||||
assertTrue(capture.contains("J.runGlobal("));
|
||||
assertTrue(capture.contains("captured.get(CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS"));
|
||||
assertTrue(capture.contains("Thread.currentThread().interrupt()"));
|
||||
assertTrue(create.contains("PaperLevelOverrides.createFromLiveLevelData(primaryLevelData)"));
|
||||
assertFalse(capture.contains("WorldReplacementSeed"));
|
||||
assertFalse(capture.contains("SavedDataStorage"));
|
||||
assertFalse(capture.contains("Files."));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedBindingsRejectCurrentPaperWorldDataStaging() {
|
||||
INMSBinding binding = (INMSBinding) Proxy.newProxyInstance(
|
||||
INMSBinding.class.getClassLoader(),
|
||||
new Class<?>[]{INMSBinding.class},
|
||||
(proxy, method, arguments) -> InvocationHandler.invokeDefault(proxy, method, arguments)
|
||||
);
|
||||
|
||||
UnsupportedOperationException error = assertThrows(
|
||||
UnsupportedOperationException.class,
|
||||
() -> binding.writeCurrentPaperWorldData(Path.of("source"), Path.of("target"), 1L)
|
||||
);
|
||||
assertTrue(error.getMessage().contains("does not support current Paper world data staging"));
|
||||
}
|
||||
|
||||
private static String section(String source, String startMarker, String endMarker) {
|
||||
int start = source.indexOf(startMarker);
|
||||
int end = source.indexOf(endMarker, start);
|
||||
assertTrue("Missing source section starting with " + startMarker, start >= 0);
|
||||
assertTrue("Missing source section ending with " + endMarker, end > start);
|
||||
return source.substring(start, end);
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -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);
|
||||
|
||||
+7
-2
@@ -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();
|
||||
|
||||
+34
-62
@@ -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 {
|
||||
|
||||
+99
-300
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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();
|
||||
|
||||
+13
@@ -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"));
|
||||
|
||||
+126
@@ -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) {
|
||||
}
|
||||
}
|
||||
+25
-10
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -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("));
|
||||
}
|
||||
}
|
||||
-93
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user