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
@@ -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<>();
@@ -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);
}
}
@@ -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) {
}
}
+2
View File
@@ -123,6 +123,8 @@ nmsBindings.each { key, value ->
tasks.withType(org.gradle.api.tasks.testing.Test).configureEach {
systemProperty('iris.nmsChunkGeneratorSource',
rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/IrisChunkGenerator.java").absolutePath)
systemProperty('iris.nmsBindingSource',
rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/NMSBinding.java").absolutePath)
systemProperty('iris.nativeStructurePostProcessorSource',
rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java').absolutePath)
systemProperty('iris.nativeStructureStartInjectorSource',
@@ -1,6 +1,7 @@
package art.arcane.iris.core;
import art.arcane.iris.BuildConstants;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.nms.datapack.DataVersion;
@@ -21,6 +22,7 @@ import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
@@ -37,9 +39,29 @@ import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.stream.Stream;
public final class IrisDatapackCompiler {
private static final int INPUT_FINGERPRINT_SCHEMA = 1;
private static final int INPUT_FINGERPRINT_SCHEMA = 2;
private static final int WORLD_PACK_SCAN_DEPTH = 8;
private static final List<String> INPUT_DIRECTORIES = List.of("dimensions", "biomes", "snippet");
private static final String FLAT_VOID_LEVEL_STEM = """
{
"type": "%s",
"generator": {
"type": "minecraft:flat",
"settings": {
"biome": "minecraft:the_void",
"features": false,
"lakes": false,
"layers": [
{
"block": "minecraft:air",
"height": 1
}
],
"structure_overrides": []
}
}
}
""";
private IrisDatapackCompiler() {
}
@@ -65,28 +87,37 @@ public final class IrisDatapackCompiler {
public static String computeInputFingerprint(
List<File> packRoots,
List<IrisGeneratorBinding> bindings,
IDataFixer fixer,
boolean adjustVanillaHeight
) throws IOException {
Objects.requireNonNull(fixer, "fixer");
return computeInputFingerprint(
packRoots,
bindings,
adjustVanillaHeight,
compilerIdentity(fixer));
}
static String computeInputFingerprint(
List<File> packRoots,
List<IrisGeneratorBinding> bindings,
boolean adjustVanillaHeight,
String compilerIdentity
) throws IOException {
Objects.requireNonNull(packRoots, "packRoots");
Objects.requireNonNull(compilerIdentity, "compilerIdentity");
List<IrisGeneratorBinding> normalizedBindings = normalizeBindings(bindings);
MessageDigest digest = sha256();
updateDigestString(digest, "iris-datapack-compiler-input");
updateDigestInt(digest, INPUT_FINGERPRINT_SCHEMA);
updateDigestString(digest, compilerIdentity);
digest.update((byte) (adjustVanillaHeight ? 1 : 0));
updateDigestInt(digest, normalizedBindings.size());
for (IrisGeneratorBinding binding : normalizedBindings) {
updateDigestString(digest, binding.worldKey().toString());
updateDigestString(digest, binding.dimension());
}
updateDigestInt(digest, packRoots.size());
for (int index = 0; index < packRoots.size(); index++) {
@@ -139,12 +170,14 @@ public final class IrisDatapackCompiler {
public static CompilationResult compile(
List<File> packRoots,
KList<File> datapackRoots,
List<IrisGeneratorBinding> bindings,
IDataFixer fixer,
boolean adjustVanillaHeight
) throws IOException {
Objects.requireNonNull(packRoots, "packRoots");
Objects.requireNonNull(datapackRoots, "datapackRoots");
Objects.requireNonNull(fixer, "fixer");
List<IrisGeneratorBinding> normalizedBindings = normalizeBindings(bindings);
if (datapackRoots.isEmpty()) {
throw new IOException("No Iris datapack output roots were provided");
}
@@ -154,6 +187,7 @@ public final class IrisDatapackCompiler {
DimensionHeight height = new DimensionHeight(fixer);
Map<String, KSet<String>> biomes = new LinkedHashMap<>();
Map<String, List<DimensionCandidate>> dimensions = new LinkedHashMap<>();
int packCount = 0;
int dimensionCount = 0;
for (File packRoot : packRoots) {
@@ -177,6 +211,12 @@ public final class IrisDatapackCompiler {
}
IrisLogging.debug(" Compiling Dimension " + dimension.getLoadFile().getPath());
height.merge(dimension);
dimensions.computeIfAbsent(dimension.getLoadKey(), ignored -> new ArrayList<>())
.add(new DimensionCandidate(
dimension,
packRoot.toPath().toAbsolutePath().normalize(),
dimension.getDimensionType().toJson(fixer)
));
KSet<String> seenBiomes = biomes.computeIfAbsent(dimension.getLoadKey(), ignored -> new KSet<>());
dimension.installBiomes(fixer, dimension::getLoader, datapackRoots, seenBiomes);
dimension.installDimensionType(fixer, datapackRoots);
@@ -192,10 +232,80 @@ public final class IrisDatapackCompiler {
}
IrisDimension.writeShared(datapackRoots, height, adjustVanillaHeight);
validateOutputs(datapackRoots, dimensionCount);
installLevelStemBindings(normalizedBindings, dimensions, datapackRoots);
validateOutputs(datapackRoots, dimensionCount, normalizedBindings.size());
return new CompilationResult(packCount, dimensionCount, countBiomes(biomes));
}
private static void installLevelStemBindings(
List<IrisGeneratorBinding> bindings,
Map<String, List<DimensionCandidate>> dimensions,
Collection<File> datapackRoots
) throws IOException {
for (IrisGeneratorBinding binding : bindings) {
DimensionCandidate selected = resolveDimension(binding, dimensions.get(binding.dimension()));
String typeKey = "iris:" + selected.dimension().getDimensionTypeKey();
String levelStem = FLAT_VOID_LEVEL_STEM.formatted(typeKey);
for (File datapackRoot : datapackRoots) {
Path output = datapackRoot.toPath()
.toAbsolutePath()
.normalize()
.resolve("data/iris/dimension")
.resolve(binding.worldKey().key() + ".json");
Files.createDirectories(output.getParent());
Files.writeString(
output,
levelStem,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
);
}
}
}
private static DimensionCandidate resolveDimension(
IrisGeneratorBinding binding,
List<DimensionCandidate> candidates
) throws IOException {
if (candidates == null || candidates.isEmpty()) {
throw new IOException("Iris world " + binding.worldKey() + " selects missing dimension \""
+ binding.dimension() + "\".");
}
DimensionCandidate selected = candidates.getFirst();
for (int index = 1; index < candidates.size(); index++) {
DimensionCandidate candidate = candidates.get(index);
if (!selected.dimensionTypeJson().equals(candidate.dimensionTypeJson())) {
throw new IOException("Iris world " + binding.worldKey() + " selects ambiguous dimension \""
+ binding.dimension() + "\" from " + selected.packRoot() + " and "
+ candidate.packRoot() + ".");
}
}
return selected;
}
private static List<IrisGeneratorBinding> normalizeBindings(List<IrisGeneratorBinding> bindings)
throws IOException {
List<IrisGeneratorBinding> requiredBindings = List.copyOf(
Objects.requireNonNull(bindings, "bindings")
);
Map<WorldSlotKey, IrisGeneratorBinding> byWorld = new LinkedHashMap<>();
for (IrisGeneratorBinding binding : requiredBindings) {
IrisGeneratorBinding requiredBinding = Objects.requireNonNull(binding, "binding");
IrisGeneratorBinding previous = byWorld.putIfAbsent(
requiredBinding.worldKey(),
requiredBinding
);
if (previous != null) {
throw new IOException("Multiple Iris LevelStem bindings target "
+ requiredBinding.worldKey() + ".");
}
}
ArrayList<IrisGeneratorBinding> normalized = new ArrayList<>(byWorld.values());
normalized.sort(Comparator.comparing(binding -> binding.worldKey().toString()));
return List.copyOf(normalized);
}
private static void collectInstalledPackRoots(
Path packsRoot,
Map<Path, File> roots,
@@ -363,7 +473,11 @@ public final class IrisDatapackCompiler {
}
}
private static void validateOutputs(Collection<File> datapackRoots, int dimensionCount) throws IOException {
private static void validateOutputs(
Collection<File> datapackRoots,
int dimensionCount,
int levelStemCount
) throws IOException {
for (File datapackRoot : datapackRoots) {
Path root = datapackRoot.toPath();
if (!Files.isRegularFile(root.resolve("pack.mcmeta"))) {
@@ -372,6 +486,9 @@ public final class IrisDatapackCompiler {
if (dimensionCount > 0 && !Files.isDirectory(root.resolve("data/iris/dimension_type"))) {
throw new IOException("Iris dimension types were not generated at " + root);
}
if (levelStemCount > 0 && !Files.isDirectory(root.resolve("data/iris/dimension"))) {
throw new IOException("Iris LevelStem bindings were not generated at " + root);
}
}
}
@@ -389,6 +506,13 @@ public final class IrisDatapackCompiler {
private record CompilerInputEntry(Path source, String relativePath) {
}
private record DimensionCandidate(
IrisDimension dimension,
Path packRoot,
String dimensionTypeJson
) {
}
public static final class DimensionHeight {
private final IDataFixer fixer;
private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3];
@@ -109,6 +109,16 @@ public final class IrisStartupValidation {
}
}
public static void requireWorldReplacementStagingReady() {
Snapshot current = snapshot;
if (current.enforced()
&& current.datapacks() == ValidationState.RESTART_REQUIRED
&& current.packs() == ValidationState.READY) {
return;
}
requireWorldCreationReady();
}
static Snapshot snapshot() {
return snapshot;
}
@@ -10,8 +10,9 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
@@ -125,6 +126,54 @@ public final class IrisWorldStorage {
return key;
}
public static NamespacedKey replacementKeyFromName(String requestedName, String levelName) {
String requested = Objects.requireNonNull(requestedName, "requestedName").trim();
String configuredLevelName = Objects.requireNonNull(levelName, "levelName").trim();
if (requested.isEmpty()) {
throw new IllegalArgumentException("World name cannot be empty.");
}
if (configuredLevelName.isEmpty()) {
throw new IllegalArgumentException("Configured level name cannot be empty.");
}
if (requested.contains("/") || requested.contains("\\") || requested.contains("..")) {
throw new IllegalArgumentException("World name must be a safe single path segment.");
}
String normalized = requested.toLowerCase(Locale.ENGLISH);
if (normalized.contains(":")) {
int separator = normalized.indexOf(':');
if (separator == 0
|| separator == normalized.length() - 1
|| separator != normalized.lastIndexOf(':')) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
NamespacedKey explicit = NamespacedKey.fromString(normalized);
if (explicit == null) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
return explicit;
}
String configuredIrisPrefix = configuredLevelName + "_" + IRIS_NAMESPACE + "_";
if (requested.startsWith(configuredIrisPrefix)) {
return keyFromConfiguredWorldName(requested, configuredLevelName);
}
if (requested.equalsIgnoreCase(configuredLevelName)) {
return NamespacedKey.minecraft("overworld");
}
if (requested.equalsIgnoreCase(configuredLevelName + "_nether")) {
return NamespacedKey.minecraft("the_nether");
}
if (requested.equalsIgnoreCase(configuredLevelName + "_the_end")) {
return NamespacedKey.minecraft("the_end");
}
return switch (normalized) {
case "main", "overworld" -> NamespacedKey.minecraft("overworld");
case "nether", "the_nether" -> NamespacedKey.minecraft("the_nether");
case "end", "the_end" -> NamespacedKey.minecraft("the_end");
default -> new NamespacedKey(IRIS_NAMESPACE, normalized.replace(' ', '_'));
};
}
static NamespacedKey keyFromName(String worldName, String levelName) {
String name = Objects.requireNonNull(worldName, "worldName").trim();
String mainLevelName = Objects.requireNonNull(levelName, "levelName").trim();
@@ -145,6 +194,53 @@ public final class IrisWorldStorage {
return new NamespacedKey(IRIS_NAMESPACE, key);
}
public static NamespacedKey keyFromConfiguredWorldName(String configuredWorldName, String levelName) {
String name = Objects.requireNonNull(configuredWorldName, "configuredWorldName").trim();
String mainLevelName = Objects.requireNonNull(levelName, "levelName").trim();
if (name.isEmpty()) {
throw new IllegalArgumentException("Configured world name cannot be empty.");
}
if (mainLevelName.isEmpty()) {
throw new IllegalArgumentException("Level name cannot be empty.");
}
String irisPrefix = mainLevelName + "_" + IRIS_NAMESPACE + "_";
if (name.startsWith(irisPrefix)) {
return managedKeyFromName(IRIS_NAMESPACE + ":" + name.substring(irisPrefix.length()), mainLevelName);
}
return keyFromName(name, mainLevelName);
}
public static String configuredWorldName(NamespacedKey key, String levelName) {
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
String mainLevelName = Objects.requireNonNull(levelName, "levelName").trim();
if (mainLevelName.isEmpty()) {
throw new IllegalArgumentException("Level name cannot be empty.");
}
if (NamespacedKey.minecraft("overworld").equals(worldKey)) {
return mainLevelName;
}
if (NamespacedKey.minecraft("the_nether").equals(worldKey)) {
return mainLevelName + "_nether";
}
if (NamespacedKey.minecraft("the_end").equals(worldKey)) {
return mainLevelName + "_the_end";
}
if (!IRIS_NAMESPACE.equals(worldKey.getNamespace()) || !worldKey.getKey().matches("[a-z0-9_-]+")) {
throw new IllegalArgumentException("Only safe Iris-managed world keys have a Bukkit startup name.");
}
return mainLevelName + "_" + worldKey.getNamespace() + "_" + worldKey.getKey();
}
public static String configuredWorldName(WorldSlotKey key, String levelName) {
WorldSlotKey worldKey = Objects.requireNonNull(key, "key");
NamespacedKey namespacedKey = NamespacedKey.fromString(worldKey.toString());
if (namespacedKey == null) {
throw new IllegalArgumentException("World key is invalid: " + worldKey);
}
return configuredWorldName(namespacedKey, levelName);
}
public static String logicalName(WorldInfo world) {
return logicalName(WorldIdentity.key(world));
}
@@ -214,6 +310,15 @@ public final class IrisWorldStorage {
return target.toFile();
}
public static boolean isExistingManagedDimensionRoot(File levelRoot, NamespacedKey key) {
try {
Path target = requireSafeManagedDimensionRoot(levelRoot, key).toPath();
return Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS);
} catch (IllegalArgumentException exception) {
return false;
}
}
public static File dimensionRoot(File levelRoot, NamespacedKey key) {
Path dimensionsRoot = Objects.requireNonNull(levelRoot, "levelRoot")
.toPath()
@@ -14,6 +14,7 @@ import art.arcane.volmlib.util.io.IO;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
@@ -25,9 +26,11 @@ import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Stream;
@@ -35,34 +38,41 @@ public class IrisWorlds {
private static final AtomicCache<IrisWorlds> cache = new AtomicCache<>();
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Type TYPE = TypeToken.getParameterized(KMap.class, String.class, String.class).getType();
private final Path levelRoot;
private final Path registryFile;
private final KMap<String, String> worlds;
private volatile boolean dirty = false;
private IrisWorlds(KMap<String, String> worlds) {
private IrisWorlds(Path levelRoot, KMap<String, String> worlds) {
this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
registryFile = registryFile(this.levelRoot);
this.worlds = new KMap<>();
worlds.forEach((identity, type) -> this.worlds.put(WorldIdentity.parse(identity).toString(), type));
readBukkitWorlds().forEach((name, type) -> put0(IrisWorldStorage.keyFromName(name).toString(), type));
readBukkitWorlds(this.levelRoot).forEach((name, type) -> put0(
IrisWorldStorage.keyFromConfiguredWorldName(name, this.levelRoot.getFileName().toString()).toString(),
type));
save();
}
public static IrisWorlds get() {
return cache.aquire(() -> {
File file = IrisPlatforms.get().dataFile("worlds.json");
Path levelRoot = IrisWorldStorage.levelRoot().toPath().toAbsolutePath().normalize();
File file = registryFile(levelRoot).toFile();
if (!file.exists()) {
return new IrisWorlds(new KMap<>());
return new IrisWorlds(levelRoot, new KMap<>());
}
try {
String json = IO.readAll(file);
KMap<String, String> worlds = GSON.fromJson(json, TYPE);
return new IrisWorlds(Objects.requireNonNullElseGet(worlds, KMap::new));
return new IrisWorlds(levelRoot, Objects.requireNonNullElseGet(worlds, KMap::new));
} catch (Throwable e) {
IrisLogging.error("Failed to load worlds.json!");
IrisLogging.error("Failed to load worlds.json for level root " + levelRoot + "!");
e.printStackTrace();
IrisLogging.reportError(e);
}
return new IrisWorlds(new KMap<>());
return new IrisWorlds(levelRoot, new KMap<>());
});
}
@@ -114,7 +124,9 @@ public class IrisWorlds {
public synchronized KMap<String, String> getWorlds() {
clean();
KMap<String, String> result = new KMap<>();
readBukkitWorlds().forEach((name, type) -> result.put(IrisWorldStorage.keyFromName(name).toString(), type));
readBukkitWorlds(levelRoot).forEach((name, type) -> result.put(
IrisWorldStorage.keyFromConfiguredWorldName(name, levelRoot.getFileName().toString()).toString(),
type));
return result.put(worlds);
}
@@ -135,7 +147,7 @@ public class IrisWorlds {
public synchronized void clean() {
boolean removed = worlds.entrySet().removeIf(entry -> {
try {
File packRoot = IrisWorldStorage.packRoot(WorldIdentity.parse(entry.getKey()));
File packRoot = packRoot(entry.getKey());
return !new File(packRoot, "dimensions/" + entry.getValue() + ".json").exists();
} catch (IllegalArgumentException e) {
return true;
@@ -159,7 +171,7 @@ public class IrisWorlds {
return;
}
Path target = IrisPlatforms.get().dataFile("worlds.json").toPath().toAbsolutePath().normalize();
Path target = registryFile;
Path parent = target.getParent();
if (parent == null) {
throw new IOException("worlds.json target has no parent: " + target);
@@ -193,6 +205,49 @@ public class IrisWorlds {
}
public static KMap<String, String> readBukkitWorlds() {
return readBukkitWorlds(IrisWorldStorage.levelRoot().toPath());
}
static Path registryFile(Path levelRoot) {
return Objects.requireNonNull(levelRoot, "levelRoot")
.toAbsolutePath()
.normalize()
.resolve("iris")
.resolve("worlds.json");
}
static KMap<String, String> filterBukkitWorldsByStorage(
Path levelRoot,
Map<String, String> configuredWorlds
) {
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
Path levelNamePath = root.getFileName();
if (levelNamePath == null) {
throw new IllegalArgumentException("Selected level root has no name: " + root);
}
KMap<String, String> result = new KMap<>();
for (Map.Entry<String, String> entry : Objects.requireNonNull(configuredWorlds, "configuredWorlds").entrySet()) {
String configuredWorldName = entry.getKey();
NamespacedKey worldKey = IrisWorldStorage.keyFromConfiguredWorldName(
configuredWorldName,
levelNamePath.toString());
if (!IrisWorldStorage.configuredWorldName(worldKey, levelNamePath.toString())
.equals(configuredWorldName)) {
continue;
}
Path dimensionRoot = IrisWorldStorage.dimensionRoot(root.toFile(), worldKey)
.toPath()
.toAbsolutePath()
.normalize();
if (Files.isDirectory(dimensionRoot, LinkOption.NOFOLLOW_LINKS)) {
result.put(configuredWorldName, entry.getValue());
}
}
return result;
}
private static KMap<String, String> readBukkitWorlds(Path levelRoot) {
YamlConfiguration bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
ConfigurationSection worlds = bukkit.getConfigurationSection("worlds");
if (worlds == null) return new KMap<>();
@@ -212,11 +267,16 @@ public class IrisWorlds {
result.put(world, loadKey);
}
return result;
return filterBukkitWorldsByStorage(levelRoot, result);
}
private static IrisDimension loadDimension(String worldIdentity, String id) {
File pack = IrisWorldStorage.packRoot(WorldIdentity.parse(worldIdentity));
private File packRoot(String worldIdentity) {
NamespacedKey worldKey = WorldIdentity.parse(worldIdentity);
return new File(IrisWorldStorage.dimensionRoot(levelRoot.toFile(), worldKey), "iris/pack");
}
private IrisDimension loadDimension(String worldIdentity, String id) {
File pack = packRoot(worldIdentity);
IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null;
if (dimension == null) {
dimension = IrisData.loadAnyDimension(id, null);
@@ -24,6 +24,8 @@ import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackIngestService.ReapplyOutcome;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.datapack.DataVersion;
@@ -276,10 +278,12 @@ public class ServerConfigurator {
IrisLogging.debug("Checking Data Packs...");
}
List<File> packRoots;
List<IrisGeneratorBinding> bindings;
try {
packRoots = collectCompilerPackRoots();
bindings = collectConfiguredLevelStemBindings();
} catch (IOException exception) {
IrisLogging.reportError("Unable to resolve Iris datapack compiler roots.", exception);
IrisLogging.reportError("Unable to resolve Iris datapack compiler inputs.", exception);
return DatapackInstallResult.failedResult();
}
@@ -303,6 +307,7 @@ public class ServerConfigurator {
IrisDatapackCompiler.compile(
packRoots,
stagedRoots,
bindings,
fixer,
IrisSettings.get().getGeneral().adjustVanillaHeight
);
@@ -445,11 +450,25 @@ public class ServerConfigurator {
IrisWorldStorage.levelRoot().toPath());
}
private static List<IrisGeneratorBinding> collectConfiguredLevelStemBindings() throws IOException {
File levelRoot = IrisWorldStorage.levelRoot();
String levelId = levelRoot.getName();
if (levelId.isBlank()) {
throw new IOException("Configured level root has no Paper startup level id: " + levelRoot);
}
return BukkitWorldConfiguration.readIrisGeneratorBindings(
ServerProperties.BUKKIT_YML,
levelId,
levelRoot.toPath()
);
}
private static String computeCurrentDatapackCompilerInputFingerprint(IDataFixer fixer) throws IOException {
return IrisDatapackCompiler.computeInputFingerprint(
IrisDatapackCompiler.collectCompilerInputRoots(
IrisPlatforms.get().dataFolder().toPath(),
IrisWorldStorage.levelRoot().toPath()),
collectConfiguredLevelStemBindings(),
Objects.requireNonNull(fixer, "Datapack fixer"),
IrisSettings.get().getGeneral().adjustVanillaHeight);
}
@@ -1,5 +1,8 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
@@ -15,6 +18,10 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
@@ -91,6 +98,95 @@ public final class BukkitWorldConfiguration {
}
}
public static List<IrisGeneratorBinding> readIrisGeneratorBindings(
File configurationFile,
String levelName,
Path levelRoot
) throws IOException {
File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile");
String requiredLevelName = requireName(levelName, "Level name");
Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot")
.toAbsolutePath()
.normalize();
Path configurationPath = requiredConfigurationFile.toPath();
if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) {
return List.of();
}
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(requiredConfigurationFile);
Object rawWorlds = configuration.get("worlds");
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
if (rawWorlds != null && worlds == null) {
throw new IOException("bukkit.yml worlds entry is not a section.");
}
if (worlds == null) {
return List.of();
}
List<String> configuredNames = new ArrayList<>(worlds.getKeys(false));
configuredNames.sort(Comparator.naturalOrder());
Map<WorldSlotKey, IrisGeneratorBinding> bindings = new LinkedHashMap<>();
for (String configuredName : configuredNames) {
Object rawWorld = worlds.get(configuredName);
ConfigurationSection world = worlds.getConfigurationSection(configuredName);
if (rawWorld != null && world == null) {
throw new IOException("bukkit.yml world entry \"" + configuredName + "\" is not a section.");
}
if (world == null || !world.getKeys(false).contains("generator")) {
continue;
}
Object rawGenerator = world.get("generator");
if (!(rawGenerator instanceof String generator)) {
throw new IOException("bukkit.yml generator for world \"" + configuredName
+ "\" is not a string.");
}
String configuredGenerator = generator.trim();
if (!configuredGenerator.equalsIgnoreCase("Iris")
&& !configuredGenerator.regionMatches(true, 0, "Iris:", 0, 5)) {
continue;
}
NamespacedKey namespacedKey;
try {
namespacedKey = IrisWorldStorage.keyFromConfiguredWorldName(
configuredName,
requiredLevelName
);
} catch (IllegalArgumentException failure) {
throw new IOException("bukkit.yml contains an invalid Iris world name \""
+ configuredName + "\".", failure);
}
if (!"iris".equals(namespacedKey.getNamespace())) {
continue;
}
if (!configuredName.equals(IrisWorldStorage.configuredWorldName(
namespacedKey,
requiredLevelName
))) {
continue;
}
if (!IrisWorldStorage.isExistingManagedDimensionRoot(
requiredLevelRoot.toFile(),
namespacedKey
)) {
continue;
}
String dimension = selectedIrisDimension(configuredGenerator, configuredName);
WorldSlotKey worldKey = new WorldSlotKey(
namespacedKey.getNamespace(),
namespacedKey.getKey()
);
IrisGeneratorBinding binding = new IrisGeneratorBinding(configuredName, worldKey, dimension);
IrisGeneratorBinding previous = bindings.putIfAbsent(worldKey, binding);
if (previous != null) {
throw new IOException("bukkit.yml maps both \"" + previous.configuredWorldName()
+ "\" and \"" + configuredName + "\" to " + worldKey + ".");
}
}
return List.copyOf(bindings.values());
}
}
public static GeneratorReplacement replaceIfMatching(
File configurationFile,
String worldName,
@@ -396,11 +492,40 @@ public final class BukkitWorldConfiguration {
return value.trim();
}
private static String selectedIrisDimension(String configuredGenerator, String worldName) throws IOException {
if (configuredGenerator.equalsIgnoreCase("Iris")) {
throw new IOException("bukkit.yml Iris generator for custom world \"" + worldName
+ "\" must select a dimension with Iris:<dimension>.");
}
String dimension = configuredGenerator.substring(5).trim();
if (dimension.isEmpty()) {
throw new IOException("bukkit.yml Iris generator for world \"" + worldName
+ "\" does not select a dimension.");
}
return dimension;
}
public enum Registration {
CREATED,
UNCHANGED
}
public record IrisGeneratorBinding(
String configuredWorldName,
WorldSlotKey worldKey,
String dimension
) {
public IrisGeneratorBinding {
configuredWorldName = requireName(configuredWorldName, "Configured world name");
WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
if (!"iris".equals(requiredWorldKey.namespace())
|| !requiredWorldKey.key().matches("[a-z0-9_-]+")) {
throw new IllegalArgumentException("Only safe Iris-managed keys can have LevelStem bindings.");
}
dimension = requireName(dimension, "Iris dimension");
}
}
public record WorldGeneratorSnapshot(
boolean worldsSectionPresent,
boolean worldSectionPresent,
@@ -18,6 +18,7 @@ import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
@@ -695,6 +696,28 @@ public final class IrisWorldRemovalService {
}
}
static String bukkitConfigurationWorldName(WorldRemovalPathPolicy.Target target) {
WorldRemovalPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target");
Path levelName = requiredTarget.levelRoot().getFileName();
if (levelName == null || levelName.toString().isBlank()) {
throw new IllegalArgumentException("Level root must have a Bukkit startup name.");
}
return IrisWorldStorage.configuredWorldName(requiredTarget.worldKey(), levelName.toString());
}
static String bukkitGenerator(
YamlConfiguration configuration,
WorldRemovalPathPolicy.Target target
) {
ConfigurationSection worlds = Objects.requireNonNull(configuration, "configuration")
.getConfigurationSection("worlds");
if (worlds == null) {
return null;
}
ConfigurationSection world = worlds.getConfigurationSection(bukkitConfigurationWorldName(target));
return world == null ? null : world.getString("generator");
}
private static final class RemovalFailure extends CompletionException {
private final RemovalStatus status;
private final Path quarantineDirectory;
@@ -840,7 +863,7 @@ public final class IrisWorldRemovalService {
try {
boolean bukkitChanged = BukkitWorldConfiguration.remove(
ServerProperties.BUKKIT_YML,
target.logicalName()
bukkitConfigurationWorldName(target)
);
return new ConfigurationDisposition(multiverseChanged || bukkitChanged, null);
} catch (Throwable failure) {
@@ -931,7 +954,7 @@ public final class IrisWorldRemovalService {
);
boolean directoryPresent = Files.isDirectory(target.worldDirectory(), LinkOption.NOFOLLOW_LINKS);
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
String generator = configuration.getString("worlds." + target.logicalName() + ".generator");
String generator = bukkitGenerator(configuration, target);
boolean configurationManaged = generator != null
&& (generator.equalsIgnoreCase("Iris") || generator.regionMatches(true, 0, "Iris:", 0, 5));
boolean conflictingConfiguration = generator != null && !configurationManaged;
@@ -180,7 +180,6 @@ public final class LifecycleOperationCoordinator {
WORLD_UNLOAD,
WORLD_REMOVE,
WORLD_REPLACE,
WORLD_PROMOTE,
STUDIO_OPEN,
STUDIO_CLOSE,
PACK_CREATE,
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import java.io.IOException;
import java.io.InputStream;
@@ -27,6 +28,7 @@ import java.util.regex.Pattern;
import java.util.stream.Stream;
public final class WorldReplacementFilesystem {
private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace";
private static final List<Path> PAPER_WORLD_METADATA = List.of(
Path.of("data/paper/metadata.dat"),
Path.of("data/paper/level_overrides.dat"),
@@ -55,15 +57,15 @@ public final class WorldReplacementFilesystem {
ReplacementPaths requiredPaths = Objects.requireNonNull(paths, "paths");
State state = inspect(requiredPaths);
if (!state.targetPresent()) {
throw new IOException("overwrite=true requires an existing exact world slot; use ordinary create for a new world.");
throw new IOException("/iris replace requires an existing exact world slot; use /iris create for a new world.");
}
if (state.stagePresent() || state.backupPresent()) {
throw new IOException("A replacement artifact already exists for this transaction.");
}
requireMigratedRetainedWorld(requiredPaths.target());
requireCurrentPaperWorld(requiredPaths.target());
}
public static void requireMigratedRetainedWorld(Path retainedWorld) throws IOException {
public static void requireCurrentPaperWorld(Path retainedWorld) throws IOException {
try {
requireDirectory(retainedWorld, "retained world");
requireDirectory(retainedWorld.resolve("data"), "retained world data");
@@ -249,13 +251,15 @@ public final class WorldReplacementFilesystem {
try (Stream<Path> stream = Files.walk(root)) {
files = stream
.filter(path -> !path.equals(root))
.filter(path -> !containsMetadataSegment(root.relativize(path)))
.sorted(Comparator.comparing(path -> root.relativize(path).toString()))
.toList();
}
for (Path file : files) {
BasicFileAttributes attributes = requireSafeEntry(file);
Path relative = root.relativize(file);
if (isGeneratedPackMetadata(relative, attributes)) {
continue;
}
update(digest, relative.toString().replace(file.getFileSystem().getSeparator(), "/"));
digest.update((byte) (attributes.isDirectory() ? 1 : 0));
if (!attributes.isRegularFile()) {
@@ -304,13 +308,10 @@ public final class WorldReplacementFilesystem {
return fingerprint;
}
private static boolean containsMetadataSegment(Path relative) {
for (Path component : relative) {
if (".iris".equals(component.toString())) {
return true;
}
}
return false;
private static boolean isGeneratedPackMetadata(Path relative, BasicFileAttributes attributes) {
return PackDirectoryResolver.isHiddenName(relative.getName(0).toString())
|| attributes.isRegularFile()
&& relative.getFileName().toString().endsWith(CODE_WORKSPACE_SUFFIX);
}
private static BasicFileAttributes requireSafeEntry(Path path) throws IOException {
@@ -347,7 +348,7 @@ public final class WorldReplacementFilesystem {
}
private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException {
requireMigratedRetainedWorld(retainedWorld);
requireCurrentPaperWorld(retainedWorld);
requireDirectory(replacementWorld, "replacement world");
ensureDirectory(replacementWorld.resolve("data"), "replacement world data");
ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data");
@@ -1,6 +1,7 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
@@ -152,15 +153,7 @@ public final class WorldReplacementJournal {
}
String levelName = fileName.toString();
if ("iris".equals(requiredWorldKey.namespace())) {
String logicalName = requiredWorldKey.key();
if (logicalName.equals(levelName)
|| logicalName.equals(levelName + "_nether")
|| logicalName.equals(levelName + "_the_end")) {
throw new IllegalArgumentException(
"An Iris-managed world cannot use a configured vanilla world alias."
);
}
return logicalName;
return IrisWorldStorage.configuredWorldName(requiredWorldKey, levelName);
}
if (WorldSlotKey.minecraft("overworld").equals(requiredWorldKey)) {
return levelName;
@@ -0,0 +1,119 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.volmlib.util.nbt.io.NBTUtil;
import art.arcane.volmlib.util.nbt.io.NamedTag;
import art.arcane.volmlib.util.nbt.tag.CompoundTag;
import art.arcane.volmlib.util.nbt.tag.LongTag;
import art.arcane.volmlib.util.nbt.tag.Tag;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Objects;
public final class WorldReplacementSeed {
private static final Path WORLD_GEN_SETTINGS = Path.of("data/minecraft/world_gen_settings.dat");
private WorldReplacementSeed() {
}
public static long readAuthoritativeSeed(Path worldDirectory) throws IOException {
Path requiredWorldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory")
.toAbsolutePath()
.normalize();
Path settings = requiredWorldDirectory.resolve(WORLD_GEN_SETTINGS);
NamedTag namedTag = readSettings(settings);
return requireData(namedTag, settings).getLongTag("seed").asLong();
}
public static void copyWithAuthoritativeSeed(
Path sourceWorldDirectory,
Path targetWorldDirectory,
long seed
) throws IOException {
Path sourceWorld = Objects.requireNonNull(sourceWorldDirectory, "sourceWorldDirectory")
.toAbsolutePath()
.normalize();
Path targetWorld = Objects.requireNonNull(targetWorldDirectory, "targetWorldDirectory")
.toAbsolutePath()
.normalize();
Path source = sourceWorld.resolve(WORLD_GEN_SETTINGS);
Path target = targetWorld.resolve(WORLD_GEN_SETTINGS);
NamedTag namedTag = readSettings(source);
CompoundTag data = requireData(namedTag, source);
data.putLong("seed", seed);
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
throw new IOException("Staged Paper world generation settings already exist: " + target);
}
Path parent = target.getParent();
if (parent == null) {
throw new IOException("Staged Paper world generation settings have no parent: " + target);
}
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".world-gen-settings-", ".dat");
try {
NBTUtil.write(namedTag, staged.toFile(), true);
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
channel.force(true);
}
try {
Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, target);
}
} finally {
Files.deleteIfExists(staged);
}
long writtenSeed = readAuthoritativeSeed(targetWorld);
if (writtenSeed != seed) {
throw new IOException("Staged Paper world generation settings did not retain the requested seed.");
}
}
private static NamedTag readSettings(Path settings) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(
settings,
BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS
);
if (attributes.isSymbolicLink() || !attributes.isRegularFile()) {
throw new IOException("Paper world generation settings are not a regular file: " + settings);
}
try {
return NBTUtil.read(settings.toFile());
} catch (IOException failure) {
throw new IOException("Could not read Paper world generation settings: " + settings, failure);
}
}
private static CompoundTag requireData(NamedTag namedTag, Path settings) throws IOException {
Tag<?> rootTag = namedTag.getTag();
if (!(rootTag instanceof CompoundTag root)) {
throw new IOException("Paper world generation settings must have a compound root: " + settings);
}
Tag<?> dataTag = root.get("data");
if (dataTag == null) {
throw new IOException("Paper world generation settings are missing data: " + settings);
}
if (!(dataTag instanceof CompoundTag data)) {
throw new IOException("Paper world generation settings data must be a compound tag: " + settings);
}
Tag<?> seedTag = data.get("seed");
if (seedTag == null) {
throw new IOException("Paper world generation settings are missing data.seed: " + settings);
}
if (!(seedTag instanceof LongTag seed)) {
throw new IOException("Paper world generation settings data.seed must be a long tag: " + settings);
}
return data;
}
}
@@ -175,9 +175,9 @@ public final class BukkitCommandMessagesExtended {
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend",
C.YELLOW + "Try one of: overworld, vanilla, flat, theend"
);
public static final TextKey COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTART_SERVER_GENERATE_LOAD = TextKey.of(
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load",
C.GREEN + "World staging completed. Restart the server to generate/load \"" + "{worldName}" + "\"."
public static final TextKey COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTARTING_SERVER_GENERATE_LOAD = TextKey.of(
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load",
C.GREEN + "World staging completed. Iris is restarting the server to generate/load \"" + "{worldName}" + "\"."
);
public static final TextKey COMMAND_IRIS_EXCEPTION_RAISED_DURING_CREATION_SEE_CONSOLE_MORE_DETAILS = TextKey.of(
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details",
@@ -187,10 +187,6 @@ public final class BukkitCommandMessagesExtended {
"iris.bukkit.commandiris.successfully_created_your_world",
C.GREEN + "Successfully created your world!"
);
public static final TextKey COMMAND_IRIS_YOUR_WORLD_WILL_AUTOMATICALLY_BE_SET_AS_MAIN_WORLD_WHEN = TextKey.of(
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when",
C.GREEN + "Your world will automatically be set as the main world when the server restarts."
);
public static final TextKey COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA = TextKey.of(
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia",
C.YELLOW + "Runtime world creation is disabled on Folia."
@@ -203,22 +199,10 @@ public final class BukkitCommandMessagesExtended {
"iris.bukkit.commandiris.failed_stage_world_files_dimension",
C.RED + "Failed to stage world files for dimension \"" + "{value}" + "\"."
);
public static final TextKey COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME = TextKey.of(
"iris.bukkit.commandiris.updated_server_properties_level_name",
C.GREEN + "Updated server.properties level-name to \"" + "{name}" + "\"."
);
public static final TextKey COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD = TextKey.of(
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world",
C.RED + "World was staged, but failed to update server.properties main world."
);
public static final TextKey COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED = TextKey.of(
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed",
C.GREEN + "Staged Iris world \"" + "{name}" + "\" with generator Iris:" + "{value}" + " and seed " + "{seed}" + "."
);
public static final TextKey COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART = TextKey.of(
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart",
C.GREEN + "This world is now configured as main for next restart."
);
public static final TextKey COMMAND_IRIS_FAILED_UPDATE_BUKKIT_YML = TextKey.of(
"iris.bukkit.commandiris.failed_update_bukkit_yml",
C.RED + "Failed to update bukkit.yml: " + "{value}"
@@ -878,17 +862,13 @@ public final class BukkitCommandMessagesExtended {
COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD_2,
COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS,
COMMAND_IRIS_TRY_ONE_OVERWORLD_VANILLA_FLAT_THEEND,
COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTART_SERVER_GENERATE_LOAD,
COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTARTING_SERVER_GENERATE_LOAD,
COMMAND_IRIS_EXCEPTION_RAISED_DURING_CREATION_SEE_CONSOLE_MORE_DETAILS,
COMMAND_IRIS_SUCCESSFULLY_CREATED_YOUR_WORLD,
COMMAND_IRIS_YOUR_WORLD_WILL_AUTOMATICALLY_BE_SET_AS_MAIN_WORLD_WHEN,
COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA,
COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP,
COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION,
COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME,
COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD,
COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED,
COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART,
COMMAND_IRIS_FAILED_UPDATE_BUKKIT_YML,
COMMAND_IRIS_SPECIFIED_PLAYER_DOES_NOT_EXIST,
COMMAND_IRIS_IRIS_V_BY_VOLMIT_SOFTWARE,
@@ -282,10 +282,6 @@ public final class DirectorCommandMessages {
"iris.director.commandiris.param.seed_generate_world_with",
"The seed to generate the world with"
);
public static final TextKey COMMAND_IRIS_PARAM_WHETHER_NOT_AUTOMATICALLY_USE_THIS_WORLD_AS_MAIN_WORLD = TextKey.of(
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world",
"Whether or not to automatically use this world as the main world"
);
public static final TextKey COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART = TextKey.of(
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart",
"Replace the exact existing world slot on the next restart"
@@ -941,7 +937,6 @@ public final class DirectorCommandMessages {
COMMAND_IRIS_PARAM_NAME_WORLD_CREATE,
COMMAND_IRIS_PARAM_DIMENSION_PACK_CREATE_WORLD_WITH,
COMMAND_IRIS_PARAM_SEED_GENERATE_WORLD_WITH,
COMMAND_IRIS_PARAM_WHETHER_NOT_AUTOMATICALLY_USE_THIS_WORLD_AS_MAIN_WORLD,
COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART,
COMMAND_IRIS_DIRECTOR_TELEPORT_ANOTHER_WORLD,
COMMAND_IRIS_PARAM_WORLD_TELEPORT,
@@ -54,6 +54,8 @@ import org.bukkit.generator.ChunkGenerator;
import org.bukkit.inventory.ItemStack;
import java.awt.Color;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
@@ -269,6 +271,14 @@ public interface INMSBinding {
default void uninjectBukkit() {
}
default void writeCurrentPaperWorldData(
Path sourceWorldDirectory,
Path targetWorldDirectory,
long seed
) throws IOException {
throw new UnsupportedOperationException("The active NMS binding does not support current Paper world data staging.");
}
KMap<Material, List<BlockProperty>> getBlockProperties();
private void validateDimensionTypes(WorldCreator c) {
@@ -2,6 +2,7 @@ package art.arcane.iris.core.pack;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@@ -44,6 +45,22 @@ public final class AtomicDirectoryPublisher {
}
}
public static Publication publishAbsent(Path stagedDirectory, Path targetDirectory) throws IOException {
Path staged = Objects.requireNonNull(stagedDirectory, "stagedDirectory").toAbsolutePath().normalize();
Path target = Objects.requireNonNull(targetDirectory, "targetDirectory").toAbsolutePath().normalize();
if (!Files.isDirectory(staged) || Files.isSymbolicLink(staged)) {
throw new IOException("Staged directory is missing or unsafe: " + staged);
}
if (!Objects.equals(staged.getParent(), target.getParent())) {
throw new IOException("Staged and target directories must have the same parent.");
}
if (Files.exists(target) || Files.isSymbolicLink(target)) {
throw new FileAlreadyExistsException("Publication target already exists: " + target);
}
move(staged, target);
return new Publication(target, null);
}
private static void move(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
@@ -2,6 +2,8 @@ package art.arcane.iris.core.pack;
import art.arcane.iris.core.IrisDatapackCompiler;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.volmlib.util.collection.KList;
@@ -45,7 +47,7 @@ import java.util.zip.ZipInputStream;
public final class DefaultPackBootstrapProvisioner {
private static final List<PackSpec> DEFAULT_PACKS = List.of();
private static final String WORLD_DATAPACK_DIRECTORY = "iris";
private static final int MARKER_SCHEMA = 5;
private static final int MARKER_SCHEMA = 6;
private static final int MAX_ARCHIVE_ENTRIES = 100_000;
private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L;
private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L;
@@ -71,13 +73,20 @@ public final class DefaultPackBootstrapProvisioner {
) throws IOException {
Objects.requireNonNull(dataDirectory, "dataDirectory");
Objects.requireNonNull(feedback, "feedback");
Path levelRoot = Objects.requireNonNull(startupPaths, "startupPaths").levelRoot();
BukkitStartupPaths requiredStartupPaths = Objects.requireNonNull(startupPaths, "startupPaths");
Path levelRoot = requiredStartupPaths.levelRoot();
List<IrisGeneratorBinding> bindings = BukkitWorldConfiguration.readIrisGeneratorBindings(
requiredStartupPaths.bukkitConfiguration().toFile(),
levelId(levelRoot),
requiredStartupPaths.levelRoot()
);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();
ProvisionOptions options = new ProvisionOptions(
DEFAULT_PACKS,
bindings,
client,
Clock.systemUTC(),
Duration.ofMinutes(30),
@@ -95,20 +104,24 @@ public final class DefaultPackBootstrapProvisioner {
return false;
}
try {
return isProvisioned(
dataDirectory,
BukkitStartupPaths.resolveCurrent().levelRoot()
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent();
List<IrisGeneratorBinding> bindings = BukkitWorldConfiguration.readIrisGeneratorBindings(
startupPaths.bukkitConfiguration().toFile(),
levelId(startupPaths.levelRoot()),
startupPaths.levelRoot()
);
return isProvisioned(dataDirectory, startupPaths.levelRoot(), DEFAULT_PACKS, bindings);
} catch (IOException | RuntimeException exception) {
return false;
}
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot) {
return isProvisioned(dataDirectory, levelRoot, DEFAULT_PACKS);
}
static boolean isProvisioned(Path dataDirectory, Path levelRoot, List<PackSpec> requiredPacks) {
static boolean isProvisioned(
Path dataDirectory,
Path levelRoot,
List<PackSpec> requiredPacks,
List<IrisGeneratorBinding> bindings
) {
try {
Path normalizedData = dataDirectory.toAbsolutePath().normalize();
Path normalizedLevel = levelRoot.toAbsolutePath().normalize();
@@ -137,12 +150,19 @@ public final class DefaultPackBootstrapProvisioner {
return false;
}
}
List<File> packRoots = IrisDatapackCompiler.collectPackRoots(
normalizedData,
normalizedLevel
);
return directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint"))
&& datapackRoot.toString().equals(marker.getProperty("datapackPath"))
&& packRootsFingerprint(IrisDatapackCompiler.collectPackRoots(
normalizedData,
normalizedLevel
)).equals(marker.getProperty("aggregateFingerprint"));
&& packRootsFingerprint(packRoots).equals(marker.getProperty("aggregateFingerprint"))
&& IrisDatapackCompiler.computeInputFingerprint(
packRoots,
bindings,
fixer,
false
).equals(marker.getProperty("compilerInputFingerprint"));
} catch (IOException | RuntimeException exception) {
return false;
}
@@ -234,10 +254,17 @@ public final class DefaultPackBootstrapProvisioner {
}
String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer);
String aggregateFingerprint = packRootsFingerprint(packRoots);
String compilerInputFingerprint = IrisDatapackCompiler.computeInputFingerprint(
packRoots,
options.bindings(),
fixer,
false
);
boolean anyPackReplaced = !packPublications.isEmpty();
boolean rebuildDatapack = anyPackReplaced
|| !existingDatapack
|| !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint"))
|| !compilerInputFingerprint.equals(previousMarker.getProperty("compilerInputFingerprint"))
|| !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity"))
|| !datapackRoot.toString().equals(previousMarker.getProperty("datapackPath"))
|| !directoryFingerprint(datapackRoot).equals(previousMarker.getProperty("datapackFingerprint"));
@@ -245,7 +272,7 @@ public final class DefaultPackBootstrapProvisioner {
compileContainer = datapacksRoot.resolve("." + WORLD_DATAPACK_DIRECTORY + "-stage-" + UUID.randomUUID());
Files.createDirectories(compileContainer);
KList<File> outputFolders = new KList<File>().qadd(compileContainer.toFile());
IrisDatapackCompiler.compile(packRoots, outputFolders, fixer, false);
IrisDatapackCompiler.compile(packRoots, outputFolders, options.bindings(), fixer, false);
if (!isDatapackRoot(compileContainer, !packRoots.isEmpty())) {
throw new IOException("Canonical Iris datapack compiler produced incomplete output at " + compileContainer);
}
@@ -266,6 +293,7 @@ public final class DefaultPackBootstrapProvisioner {
String finalAggregateFingerprint = packRootsFingerprint(finalPackRoots);
String finalCompilerInputFingerprint = IrisDatapackCompiler.computeInputFingerprint(
finalPackRoots,
options.bindings(),
fixer,
false);
String finalDatapackFingerprint = directoryFingerprint(datapackRoot);
@@ -280,6 +308,7 @@ public final class DefaultPackBootstrapProvisioner {
}
marker.setProperty("aggregateFingerprint", finalAggregateFingerprint);
marker.setProperty("compilerIdentity", compilerIdentity);
marker.setProperty("compilerInputFingerprint", finalCompilerInputFingerprint);
marker.setProperty("datapackFingerprint", finalDatapackFingerprint);
marker.setProperty("datapackPath", datapackRoot.toString());
marker.setProperty("completedAt", Long.toString(options.clock().millis()));
@@ -765,6 +794,14 @@ public final class DefaultPackBootstrapProvisioner {
}
}
private static String levelId(Path levelRoot) throws IOException {
Path fileName = Objects.requireNonNull(levelRoot, "levelRoot").getFileName();
if (fileName == null || fileName.toString().isBlank()) {
throw new IOException("Configured level root has no Paper startup level id: " + levelRoot);
}
return fileName.toString();
}
private static void deleteQuietly(Path path, Consumer<String> feedback) {
try {
delete(path);
@@ -880,6 +917,7 @@ public final class DefaultPackBootstrapProvisioner {
record ProvisionOptions(
List<PackSpec> packs,
List<IrisGeneratorBinding> bindings,
HttpClient client,
Clock clock,
Duration refreshInterval,
@@ -891,6 +929,7 @@ public final class DefaultPackBootstrapProvisioner {
) {
ProvisionOptions {
packs = List.copyOf(Objects.requireNonNull(packs, "packs"));
bindings = List.copyOf(Objects.requireNonNull(bindings, "bindings"));
Objects.requireNonNull(client, "client");
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(refreshInterval, "refreshInterval");
@@ -134,8 +134,11 @@ public class IrisCreator {
private BiConsumer<String, Long> studioTimingConsumer;
private DatapackPreparation datapackPreparation = DatapackPreparation.INSTALL_IF_CHANGED;
public static boolean removeFromBukkitYml(String name) throws IOException {
return BukkitWorldConfiguration.remove(BUKKIT_YML, name);
public static boolean removeFromBukkitYml(NamespacedKey worldKey) throws IOException {
return BukkitWorldConfiguration.remove(
BUKKIT_YML,
IrisWorldStorage.configuredWorldName(worldKey, IrisWorldStorage.levelRoot().getName())
);
}
public static int removeTransientStudioWorldsFromBukkitYml() throws IOException {
@@ -293,7 +296,12 @@ public class IrisCreator {
reportStudioProgress(0.86D, "create_world");
if (!studio && !benchmark) {
BukkitWorldConfiguration.register(BUKKIT_YML, name, dimension, seed);
BukkitWorldConfiguration.register(
BUKKIT_YML,
IrisWorldStorage.configuredWorldName(worldKey, IrisWorldStorage.levelRoot().getName()),
dimension,
seed
);
bukkitRegistered = true;
World createdWorld = world;
CompletableFuture<Void> multiverseRegistration = J.sfut(
@@ -730,7 +738,10 @@ public class IrisCreator {
failure.addSuppressed(rollbackFailure);
}
try {
BukkitWorldConfiguration.remove(BUKKIT_YML, name);
BukkitWorldConfiguration.remove(
BUKKIT_YML,
IrisWorldStorage.configuredWorldName(worldKey, IrisWorldStorage.levelRoot().getName())
);
} catch (Throwable rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
@@ -670,7 +670,7 @@ public class IrisToolbelt {
}
public static boolean removeWorld(World world) throws IOException {
return IrisCreator.removeFromBukkitYml(IrisWorldStorage.logicalName(world));
return IrisCreator.removeFromBukkitYml(WorldIdentity.key(world));
}
record PackReference(String pack, String dimension, boolean explicitDimension) {
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cWie wäre es stattdessen mit dem Namen \"IrisWorld\"?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cDieser Ordner existiert bereits!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eVersuche eine der folgenden Optionen: overworld, vanilla, flat, theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aVorbereitung der Welt abgeschlossen. Starte den Server neu, um \"{worldName}\" zu generieren/zu laden.",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aVorbereitung der Welt abgeschlossen. Iris startet den Server jetzt neu, um \"{worldName}\" zu generieren/zu laden.",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cBei der Erstellung ist eine Ausnahme aufgetreten. Weitere Details findest du in der Konsole.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aDeine Welt wurde erfolgreich erstellt!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aIhre Welt wird automatisch als Hauptwelt festgelegt, wenn der Server neu gestartet wird.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eDie Erstellung von Welten zur Laufzeit ist unter Folia deaktiviert.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§eWeltdateien und bukkit.yml werden für den nächsten Start vorbereitet...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cDie Weltdateien für Dimension \"{value}\" konnten nicht vorbereitet werden.",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aDie Eigenschaft level-name in server.properties wurde auf \"{name}\" gesetzt.",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cDie Welt wurde vorbereitet, aber die Hauptwelt in server.properties konnte nicht aktualisiert werden.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aIris-Welt \"{name}\" mit Generator Iris:{value} und Seed {seed} vorbereitet.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aDiese Welt ist für den nächsten Neustart als Hauptwelt konfiguriert.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cbukkit.yml konnte nicht aktualisiert werden: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cDer angegebene Spieler existiert nicht.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v{value} von Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Name der zu erstellenden Welt",
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dimension oder Pack, mit der bzw. dem die Welt erstellt wird",
"iris.director.commandiris.param.seed_generate_world_with": "Seed für die Generierung der Welt",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ob man diese Welt automatisch als Hauptwelt benutzt oder nicht",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Den exakten vorhandenen Welt-Slot beim nächsten Neustart ersetzen",
"iris.director.commandiris.director.teleport_another_world": "Teleportieren in eine andere Welt",
"iris.director.commandiris.param.world_teleport": "Zielwelt der Teleportation",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§c¿Podemos sugerir el nombre \"IrisWorld\"?",
"iris.bukkit.commandiris.that_folder_already_exists": "§c¡Esa carpeta ya existe!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§ePrueba una de estas opciones: overworld, vanilla, flat, theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aPreparación del mundo completada. Reinicia el servidor para generar/cargar \"{worldName}\".",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aPreparación del mundo completada. Iris está reiniciando el servidor para generar/cargar \"{worldName}\".",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cSe produjo una excepción durante la creación. Consulta la consola para obtener más detalles.",
"iris.bukkit.commandiris.successfully_created_your_world": "§a¡Tu mundo se creó correctamente!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aTu mundo se establecerá automáticamente como mundo principal cuando se reinicie el servidor.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eLa creación de mundos en tiempo de ejecución está desactivada en Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§ePreparando los archivos del mundo y bukkit.yml para el próximo inicio...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cNo se pudieron preparar los archivos del mundo para la dimensión \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aSe actualizó level-name en server.properties a \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cEl mundo se preparó, pero no se pudo actualizar el mundo principal en server.properties.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aSe preparó el mundo de Iris \"{name}\" con el generador Iris:{value} y la semilla {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aEste mundo está configurado como principal para el próximo reinicio.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cNo se pudo actualizar bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cEl jugador especificado no existe.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v{value} por Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "El nombre del mundo que se creará",
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensión o el pack con el que se creará el mundo",
"iris.director.commandiris.param.seed_generate_world_with": "La semilla con la que se generará el mundo",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indica si este mundo debe usarse automáticamente como mundo principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Reemplazar el espacio exacto del mundo existente en el próximo reinicio",
"iris.director.commandiris.director.teleport_another_world": "Teletransportarse a otro mundo",
"iris.director.commandiris.param.world_teleport": "El mundo al que se teletransportará",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cVoimmeko ehdottaa nimeä \"IrisWorldSen sijaan?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cSe kansio on jo olemassa!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eKokeile yksi: maapallo, vanilla, litteä, theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aMaailman valmistelu valmis. Käynnistä palvelin uudelleen, jotta \"{worldName}\" luodaan/ladataan.",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aMaailman valmistelu valmis. Iris käynnistää palvelimen nyt uudelleen, jotta \"{worldName}\" luodaan/ladataan.",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cLuomisen aikana nostettu poikkeus. Katso lisätietoja konsolista.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aOnnistuneesti loin maailmasi!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aMaailmasi asetetaan automaattisesti päämaailmaksi, kun palvelin käynnistyy uudelleen.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eRuntime maailman luominen on pois käytöstä Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§eValmistellaan maailman tiedostoja ja bukkit.yml seuraavan käynnistyksen...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cMaailmatiedostoja ei voitu lavastaa ulottuvuudeksi \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aPäivitetty server.properties tason nimi \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cMaailma lavastettiin, mutta sitä ei päivitetty server.properties suurmaailma.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aIris-maailma \"{name}\" valmisteltiin generaattorilla Iris:{value} ja siemenellä {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aTämä maailma on nyt asetettu pääohjelmaksi seuraavaan uudelleenkäynnistykseen.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cPäivitys epäonnistui bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cMääriteltyä pelaajaa ei ole olemassa.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris{value} mennessä Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Luotavan maailman nimi",
"iris.director.commandiris.param.dimension_pack_create_world_with": "Ulottuvuus / paketti luoda maailma",
"iris.director.commandiris.param.seed_generate_world_with": "Siemenet tuottaa maailman kanssa",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Käytetäänkö tätä maailmaa automaattisesti päämaailmana",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Korvaa täsmällinen olemassa oleva maailmapaikka seuraavalla uudelleenkäynnistyksellä",
"iris.director.commandiris.director.teleport_another_world": "Teleporttautuminen toiseen maailmaan",
"iris.director.commandiris.param.world_teleport": "Maailman teleportata",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cNous vous suggérons plutôt le nom \"IrisWorld\".",
"iris.bukkit.commandiris.that_folder_already_exists": "§cCe dossier existe déjà !",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eEssayez l'une des options suivantes : overworld, vanilla, flat, theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aPréparation du monde terminée. Redémarrez le serveur pour générer/charger \"{worldName}\".",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aPréparation du monde terminée. Iris redémarre maintenant le serveur pour générer/charger \"{worldName}\".",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cUne exception s'est produite pendant la création. Consultez la console pour plus de détails.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aVotre monde a été créé avec succès !",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aVotre monde deviendra automatiquement le monde principal au redémarrage du serveur.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eLa création de mondes pendant l'exécution est désactivée sur Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§ePréparation des fichiers du monde et de bukkit.yml pour le prochain démarrage...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cImpossible de préparer les fichiers du monde pour la dimension \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aLa propriété level-name de server.properties a été définie sur \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cLe monde a été préparé, mais le monde principal n'a pas pu être mis à jour dans server.properties.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aLe monde Iris \"{name}\" a été préparé avec le générateur Iris:{value} et la graine {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aCe monde est maintenant configuré comme monde principal pour le prochain redémarrage.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cImpossible de mettre à jour bukkit.yml : {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cLe joueur indiqué n'existe pas.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v{value} par Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Le nom du monde à créer",
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimension ou le pack avec lequel créer le monde",
"iris.director.commandiris.param.seed_generate_world_with": "La graine avec laquelle générer le monde",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Indique si ce monde doit être utilisé automatiquement comme monde principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Remplacer lemplacement exact du monde existant au prochain redémarrage",
"iris.director.commandiris.director.teleport_another_world": "Se téléporter vers un autre monde",
"iris.director.commandiris.param.world_teleport": "Le monde vers lequel se téléporter",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cאפשר להציע את השם \"IrisWorld\"במקום?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cהתיקיה הזו כבר קיימת!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eנסה: Overworld vanilla, שטוח, TheendXTry",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aהכנת העולם הושלמה. הפעל מחדש את השרת כדי ליצור/לטעון את \"{worldName}\".",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aהכנת העולם הושלמה. Iris מפעיל כעת מחדש את השרת כדי ליצור/לטעון את \"{worldName}\".",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cלמעטים שגדלו במהלך הבריאה. ראו את הקונסולה לפרטים נוספים.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aיצרתם בהצלחה את עולמכם!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aעולמכם יקבע באופן אוטומטי כעולם הראשי כאשר השרת נפתח מחדש.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eיצירת העולם של Runtime זמינה Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§eהכנת קבצים בעולם bukkit.yml עבור הסטארט-אפ הבא...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cנכשלים בקבצי עולם הבמה לממד \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aעדכון server.properties שם ברמה \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cהעולם הוטבע, אך לא הצליח לעדכן server.properties העולם הראשי",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aעולם Iris \"{name}\" הוכן עם המחולל Iris:{value} והזרע {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aהעולם הזה מוגדר כעת כעיקרי לחידוש הבא.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cנכשל לעדכן bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cהשחקן שצוין אינו קיים.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aאיריס v{value} על ידי Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "שם העולם ליצור",
"iris.director.commandiris.param.dimension_pack_create_world_with": "המימד / החבילה ליצירת העולם עם",
"iris.director.commandiris.param.seed_generate_world_with": "הזרע ליצור את העולם עם",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "בין אם להשתמש בעולם באופן אוטומטי כעולם הראשי",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "החלפת משבצת העולם הקיימת המדויקת בהפעלה מחדש הבאה",
"iris.director.commandiris.director.teleport_another_world": "טלפורט לעולם אחר",
"iris.director.commandiris.param.world_teleport": "העולם לטלפורט",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cPossiamo suggerire il nome \"IrisWorld\" invece?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cQuella cartella esiste già!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eProva una delle seguenti opzioni: overworld, vanilla, flat, theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aPreparazione del mondo completata. Riavvia il server per generare/caricare \"{worldName}\".",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aPreparazione del mondo completata. Iris sta riavviando il server per generare/caricare \"{worldName}\".",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cSi è verificata un'eccezione durante la creazione. Consulta la console per maggiori dettagli.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aCreato con successo il tuo mondo!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aIl tuo mondo verrà impostato automaticamente come mondo principale al riavvio del server.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eLa creazione di mondi durante l'esecuzione è disattivata su Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§ePreparazione dei file del mondo e di bukkit.yml per il prossimo avvio...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cImpossibile preparare i file del mondo per la dimensione \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aLa proprietà level-name in server.properties è stata impostata su \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cIl mondo è stato preparato, ma non è stato possibile aggiornare il mondo principale in server.properties.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aMondo Iris \"{name}\" preparato con il generatore Iris:{value} e il seed {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aQuesto mondo è ora configurato come principale per il prossimo riavvio.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cImpossibile aggiornare bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cIl giocatore specificato non esiste.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v{value} di Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Il nome del mondo da creare",
"iris.director.commandiris.param.dimension_pack_create_world_with": "La dimensione o il Pack con cui creare il mondo",
"iris.director.commandiris.param.seed_generate_world_with": "Il seed con cui generare il mondo",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Che sia o meno utilizzare automaticamente questo mondo come il mondo principale",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Sostituisci lo slot esatto del mondo esistente al prossimo riavvio",
"iris.director.commandiris.director.teleport_another_world": "Teletrasporto in un altro mondo",
"iris.director.commandiris.param.world_teleport": "Il mondo verso cui teletrasportarsi",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§c代わりに \"IrisWorld\" という名前はいかがですか?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cそのフォルダーはすでに存在します!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§e次のいずれかを指定してください: overworld、vanilla、flat、theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aワールドの準備が完了しました。\"{worldName}\" を生成/読み込むにサーバーを再起動してください。",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aワールドの準備が完了しました。Iris は \"{worldName}\" を生成/読み込むためにサーバーを再起動しています。",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§c作成中に例外が発生しました。詳細はコンソールを確認してください。",
"iris.bukkit.commandiris.successfully_created_your_world": "§aワールドを作成しました!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aサーバーを再起動すると、このワールドが自動的にメインワールドに設定されます。",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eFolia では実行中のワールド作成が無効です。",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§e次回起動用のワールドファイルと bukkit.yml を準備しています...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cディメンション \"{value}\" のワールドファイルを準備できませんでした。",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aserver.properties の level-name を \"{name}\" に更新しました。",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cワールドは準備されましたが、server.properties のメインワールド設定を更新できませんでした。",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aIris ワールド \"{name}\" をジェネレーター Iris:{value}、シード {seed} で準備しました。",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aこのワールドは、次回再起動時のメインワールドとして設定されました。",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cbukkit.yml を更新できませんでした: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§c指定されたプレイヤーは存在しません。",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v{value} by Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "作成するワールドの名前",
"iris.director.commandiris.param.dimension_pack_create_world_with": "ワールドの作成に使用するディメンションまたはパック",
"iris.director.commandiris.param.seed_generate_world_with": "ワールドの生成に使用するシード",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "このワールドをメインワールドとして自動設定するかどうか",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "次回の再起動時に既存の正確なワールドスロットを置き換える",
"iris.director.commandiris.director.teleport_another_world": "別のワールドへテレポートします",
"iris.director.commandiris.param.world_teleport": "テレポート先のワールド",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§c우리는 이름 \"을 제안 할 수있다IrisWorld\" 대신?",
"iris.bukkit.commandiris.that_folder_already_exists": "§c그 폴더는 이미 존재합니다!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§e중 하나를 시도: overworld, 바닐라, 플랫, theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§a월드 준비가 완료되었습니다. \"{worldName}\"을 생성/로드하려면 서버를 재시작하세요.",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§a월드 준비가 완료되었습니다. Iris가 \"{worldName}\"을 생성/로드하기 위해 서버를 재시작하고 있습니다.",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§c창조 중 발생한 예외. 자세한 내용은 콘솔을 참조하십시오.",
"iris.bukkit.commandiris.successfully_created_your_world": "§a세상을 성공적으로 만들었습니다!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§a서버가 재시작할 때 세계로 자동 설정됩니다.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eRuntime 세계 생성은 비활성화됩니다 Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§e세계 파일 및 준비 bukkit.yml 다음 시작...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§c차원을 위한 단계 세계 파일에 실패 \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§a으로 server.properties \"에 수평 이름{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§c세계는 무대로, 하지만 업데이트 실패 server.properties 주요 세계.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aIris 월드 \"{name}\"을 생성기 Iris:{value}, 시드 {seed}로 준비했습니다.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§a다음 재시작의 메인 월드로 설정했습니다.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§c업데이트 실패 bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§c지정된 플레이어는 존재하지 않습니다.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§a아이리스 v{value} Volmit 소프트웨어",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "만들 월드 이름",
"iris.director.commandiris.param.dimension_pack_create_world_with": "월드 생성에 사용할 차원 또는 팩",
"iris.director.commandiris.param.seed_generate_world_with": "월드 생성에 사용할 시드",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "이 월드를 메인 월드로 자동 사용할지 여부",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "다음 재시작 시 기존의 정확한 월드 슬롯 교체",
"iris.director.commandiris.director.teleport_another_world": "다른 월드로 순간이동합니다",
"iris.director.commandiris.param.world_teleport": "순간이동할 월드",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cAr galime pasiūlyti pavadinimą \"IrisWorld\"vietoj to?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cTas aplankas jau egzistuoja!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§ePabandykite vieną iš: per pasaulį, vanilla, butas, pabaiga",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aPasaulio paruošimas baigtas. Paleiskite serverį iš naujo, kad sugeneruotumėte / įkeltumėte „{worldName}“.",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aPasaulio paruošimas baigtas. Iris dabar paleidžia serverį iš naujo, kad sugeneruotų / įkeltų „{worldName}“.",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cSukūrimo metu iškelta išimtis. Daugiau informacijos rasite konsolėje.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aSėkmingai sukūrė savo pasaulį!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aJūsų pasaulis bus automatiškai nustatytas kaip pagrindinis pasaulis, kai serveris vėl paleidžiamas.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eweather forecast Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§eRuošiami pasauliniai failai ir bukkit.yml kitam paleidimui...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cNepavyko sukurti pasaulinių matmenų failų \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aAtnaujinta server.properties Lyginamasis pavadinimas į \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cPasaulis buvo suskirstytas, bet nepavyko atnaujinti server.properties pagrindinis pasaulis.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aParuoštas Iris pasaulis „{name}“ su generatoriumi Iris:{value} ir pradine reikšme {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aŠis pasaulis dabar yra sukonfigūruotas kaip pagrindinis kitą startą.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cNepavyko atnaujinti bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cNurodytasis žaidėjas neegzistuoja.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris prieš{value} pagal Volmit programinė įranga",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Kuriamo pasaulio pavadinimas",
"iris.director.commandiris.param.dimension_pack_create_world_with": "dimensija / paketas sukurti pasaulį su",
"iris.director.commandiris.param.seed_generate_world_with": "Sėkla generuoti pasaulį su",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Ar automatiškai naudoti šį pasaulį kaip pagrindinį pasaulį",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Per kitą paleidimą iš naujo pakeisti tikslų esamą pasaulio lizdą",
"iris.director.commandiris.director.teleport_another_world": "Teleportas į kitą pasaulį",
"iris.director.commandiris.param.world_teleport": "Pasaulis teleportui į",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cMogen we de naam voorstellen \"IrisWorldIn plaats daarvan?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cDie map bestaat al!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eProbeer een van: overwereld, vanilla, plat, einde",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aVoorbereiding van de wereld voltooid. Herstart de server om \"{worldName}\" te genereren/laden.",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aVoorbereiding van de wereld voltooid. Iris herstart de server nu om \"{worldName}\" te genereren/laden.",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cUitzondering bij de schepping. Zie de console voor meer details.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aSuccesvol jullie wereld gecreëerd!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aUw wereld wordt automatisch ingesteld als de belangrijkste wereld wanneer de server herstart.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eRuntime wereld creatie is uitgeschakeld op Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§eVoorbereiden van wereldbestanden en bukkit.yml voor volgende opstart...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cKon wereldbestanden voor dimensie niet stagen \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aBijgewerkt server.properties niveau-naam aan \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cWereld werd opgevoerd, maar kon niet bijwerken server.properties De belangrijkste wereld.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aIris-wereld \"{name}\" voorbereid met generator Iris:{value} en seed {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aDeze wereld is nu geconfigureerd als hoofd voor volgende herstart.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cBijwerken is mislukt bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cDe opgegeven speler bestaat niet.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris /{value} door Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "De naam van de wereld te creëren",
"iris.director.commandiris.param.dimension_pack_create_world_with": "De dimensie/pack om de wereld te creëren met",
"iris.director.commandiris.param.seed_generate_world_with": "Het zaad om de wereld te genereren met",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Of deze wereld automatisch gebruikt moet worden als de belangrijkste wereld",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "De exacte bestaande wereldsleuf bij de volgende herstart vervangen",
"iris.director.commandiris.director.teleport_another_world": "Teleporteren naar een andere wereld",
"iris.director.commandiris.param.world_teleport": "Wereld te teleporteren naar",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cCzy możemy zasugerować nazwę \"IrisWorld\"zamiast?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cTen folder już istnieje!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eSpróbuj jeden z: zaświaty, vanilla, płaski, koniec",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aPrzygotowanie świata ukończone. Uruchom serwer ponownie, aby wygenerować/wczytać „{worldName}.",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aPrzygotowanie świata ukończone. Iris uruchamia teraz serwer ponownie, aby wygenerować/wczytać „{worldName}.",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cWyjątek podniesiony podczas tworzenia. Więcej szczegółów znajdziesz w konsoli.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aZ powodzeniem stworzyłeś swój świat!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aTwój świat zostanie automatycznie ustawiony jako główny świat po ponownym uruchomieniu serwera.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eRuntime world creation jest wyłączone na Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§ePrzygotowanie światowych plików i bukkit.yml dla następnego startup...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cNie udało się sscenizować plików świata dla wymiaru \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aAktualizacja server.properties level- name do \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cŚwiat został wystawiony, ale nie udało się zaktualizować server.properties Główny świat.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aPrzygotowano świat Iris „{name}” z generatorem Iris:{value} i ziarnem {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aTen świat jest teraz skonfigurowany jako główny dla następnego restartu.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cNie udało się zaktualizować bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cOkreślony gracz nie istnieje.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v.{value} przez Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Nazwa świata do stworzenia",
"iris.director.commandiris.param.dimension_pack_create_world_with": "Wymiar / pakiet do tworzenia świata z",
"iris.director.commandiris.param.seed_generate_world_with": "Nasienie do generowania świata z",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Czy automatycznie używać tego świata jako głównego świata",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Zastąp dokładny istniejący slot świata przy następnym restarcie",
"iris.director.commandiris.director.teleport_another_world": "Teleport do innego świata",
"iris.director.commandiris.param.world_teleport": "Świat teleportować do",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cPodemos sugerir o nome \"IrisWorld\" em vez disso?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cEssa pasta já existe!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eExperimente um de: overworld, vanilla, flat, theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aPreparação do mundo concluída. Reinicie o servidor para gerar/carregar \"{worldName}\".",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aPreparação do mundo concluída. O Iris está agora a reiniciar o servidor para gerar/carregar \"{worldName}\".",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cExceção levantada durante a criação. Veja o console para mais detalhes.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aCriou com sucesso o seu mundo!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aSeu mundo será automaticamente definido como o mundo principal quando o servidor reiniciar.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eA criação do mundo em execução está desactivada Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§ePreparando arquivos mundiais e bukkit.yml para a próxima inicialização...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cFalha ao encenar arquivos mundiais para a dimensão \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aAtualizado server.properties nível-nome para \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cO mundo foi encenado, mas falhou em atualizar server.properties mundo principal.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aMundo Iris \"{name}\" preparado com o gerador Iris:{value} e a seed {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aEste mundo está agora configurado como principal para o próximo reinício.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cFalha ao atualizar bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cO jogador especificado não existe.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aÍris v{value} por Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "O nome do mundo para criar",
"iris.director.commandiris.param.dimension_pack_create_world_with": "A dimensão/pack para criar o mundo com",
"iris.director.commandiris.param.seed_generate_world_with": "A semente para gerar o mundo com",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Se deve ou não usar automaticamente este mundo como o mundo principal",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Substituir o espaço exato do mundo existente no próximo reinício",
"iris.director.commandiris.director.teleport_another_world": "Teletransporte para outro mundo",
"iris.director.commandiris.param.world_teleport": "Mundo para teletransportar",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cМожно предложить название \"IrisWorldВместо этого?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cЭта папка уже существует!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eПопробуйте один из вариантов: overworld, vanilla, flat, theend",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aПодготовка мира завершена. Перезапустите сервер, чтобы сгенерировать/загрузить «{worldName}».",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aПодготовка мира завершена. Iris перезапускает сервер, чтобы сгенерировать/загрузить «{worldName}».",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cИсключение, возникшее при сотворении. Смотрите консоль для более подробной информации.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aУспешно создал свой мир!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aВаш мир будет автоматически установлен в качестве основного мира при перезагрузке сервера.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "Создание мира §eRuntime отключено на Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§eПодготовка мировых файлов и bukkit.yml Для следующего стартапа...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cНе удалось создать мировые файлы для измерения\".{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aОбновлено server.properties имя уровня \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cМир был инсценирован, но не удалось обновить server.properties Главный мир.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aМир Iris «{name}» подготовлен с генератором Iris:{value} и сидом {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aЭтот мир теперь настроен как основной для следующего перезапуска.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cНе удалось обновить bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cУказанного игрока не существует.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aИрис v{value} Разработчик Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Название мира для создания",
"iris.director.commandiris.param.dimension_pack_create_world_with": "Размер/пакет для создания мира",
"iris.director.commandiris.param.seed_generate_world_with": "Семя, чтобы создать мир с",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Использовать или не использовать этот мир как основной",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Заменить точный существующий слот мира при следующем перезапуске",
"iris.director.commandiris.director.teleport_another_world": "Телепорт в другой мир",
"iris.director.commandiris.param.world_teleport": "Телепортироваться в мир",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cAdımı önerebiliriz \"IrisWorldBunun yerine?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cBu klasör zaten var!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eBirini deneyin: Overworld, vanilla, düz, son",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aDünya hazırlığı tamamlandı. \"{worldName}\" dünyasını oluşturmak/yüklemek için sunucuyu yeniden başlatın.",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aDünya hazırlığı tamamlandı. Iris, \"{worldName}\" dünyasını oluşturmak/yüklemek için sunucuyu yeniden başlatıyor.",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cYaratılış sırasında ortaya çıktı. Konsolu daha fazla ayrıntı için görün.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aBaşarılı bir şekilde dünyanızı yarattı!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aDünyanız otomatik olarak sunucu yeniden başladığında ana dünya olarak belirlenecektir.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eRuntime dünya yaratımı engellidir Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§eDünya dosyalarını hazırlamak ve bukkit.yml Bir sonraki başlangıç için...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cBoyut için dünya dosyalarını sahnelemek için başarısız oldu \"{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aGüncelleme Tarihi server.properties Seviye adı \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cDünya sahnelendi, ancak güncellemeyi başaramadı server.properties Ana dünya.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aIris dünyası \"{name}\", Iris:{value} jeneratörü ve {seed} tohumu ile hazırlandı.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aBu dünya şimdi bir sonraki yeniden başlamak için ana olarak yapılandırılmıştır.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cgüncellemek için başarısız oldu bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cBelirtilen oyuncu mevcut değildir.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris{value} Volmit Software",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Dünyanın adı yaratmak",
"iris.director.commandiris.param.dimension_pack_create_world_with": "Dünyayı yaratmak için boyut / paket",
"iris.director.commandiris.param.seed_generate_world_with": "Dünyayı üretmek için tohum",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Bu dünyayı ana dünya olarak otomatik olarak kullanıp kullanmayalım",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Bir sonraki yeniden başlatmada mevcut tam dünya yuvasını değiştir",
"iris.director.commandiris.director.teleport_another_world": "Teleport başka bir dünyaya",
"iris.director.commandiris.param.world_teleport": "Dünya teleport'a",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§cChúng ta có thể đề nghị cái tên \"IrisWorldThay vào đó?",
"iris.bukkit.commandiris.that_folder_already_exists": "§cThư mục đó đã có!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§eHãy thử một trong những: ngoài thế giới, vanilla, bằng phẳng, kết thúc",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§aĐã chuẩn bị xong thế giới. Hãy khởi động lại máy chủ để tạo/tải \"{worldName}\".",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§aĐã chuẩn bị xong thế giới. Iris đang khởi động lại máy chủ để tạo/tải \"{worldName}\".",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§cNgoại lệ được nuôi lớn trong suốt quá trình sáng tạo. Xem bảng điều khiển để biết thêm chi tiết.",
"iris.bukkit.commandiris.successfully_created_your_world": "§aThành công trong việc tạo ra thế giới của anh!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§aThế giới của bạn sẽ tự động được thiết lập như thế giới chính khi máy chủ khởi động lại.",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eComment Folia.",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§eĐang chuẩn bị tập tin thế giới và bukkit.yml cho lần khởi chạy tiếp theo...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§cKhông thể tạo tập tin cho chiều không gian{value}\".",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§aCập nhật server.properties Cấp-tên là \"{name}\".",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§cThế giới đã được thiết lập, nhưng không cập nhật được server.properties thế giới chính.",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§aĐã chuẩn bị thế giới Iris \"{name}\" với trình tạo Iris:{value} và seed {seed}.",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§aThế giới này bây giờ được cấu hình là chính cho lần khởi động lại tiếp theo.",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§cLỗi cập nhật bukkit.yml: {value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§cNgười chơi đã ghi rõ không tồn tại.",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v.{value} by Volmit Phần mềm",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "Tên của thế giới để tạo ra",
"iris.director.commandiris.param.dimension_pack_create_world_with": "Kích thước/ lốc tạo ra thế giới với",
"iris.director.commandiris.param.seed_generate_world_with": "Hạt giống để tạo ra thế giới với",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "Có nên tự động sử dụng thế giới này làm thế giới chính hay không",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "Thay thế đúng vị trí thế giới hiện có vào lần khởi động lại tiếp theo",
"iris.director.commandiris.director.teleport_another_world": "Name",
"iris.director.commandiris.param.world_teleport": "Thế giới có thể dịch chuyển",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§c建议改用名称 \"IrisWorld\"。",
"iris.bukkit.commandiris.that_folder_already_exists": "§c该文件夹已存在!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§e可尝试:overworld、vanilla、flat 或 the_end",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§a世界暂存完成。重启服务器以生成并加载 \"{worldName}\"。",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§a世界暂存完成。Iris 正在重启服务器以生成并加载 \"{worldName}\"。",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§c创建期间发生异常。详情请查看控制台。",
"iris.bukkit.commandiris.successfully_created_your_world": "§a世界创建成功!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§a服务器重启时,此世界将自动设为主世界。",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eFolia 上已禁用运行时世界创建。",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§e正在为下次启动准备世界文件和 bukkit.yml...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§c无法为维度 \"{value}\" 暂存世界文件。",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§a已将 server.properties 中的 level-name 更新为 \"{name}\"。",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§c世界已暂存,但无法更新 server.properties 中的主世界。",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§a已暂存 Iris 世界 \"{name}\",生成器为 Iris:{value},种子为 {seed}。",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§a此世界现已设为下次重启后的主世界。",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§c无法更新 bukkit.yml{value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§c指定的玩家不存在。",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v.{value},由 Volmit Software 开发",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "要创建的世界名称",
"iris.director.commandiris.param.dimension_pack_create_world_with": "用于创建世界的维度包",
"iris.director.commandiris.param.seed_generate_world_with": "用于生成世界的种子",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自动将此世界设为主世界",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重启时替换指定的现有世界槽位",
"iris.director.commandiris.director.teleport_another_world": "传送到另一个世界",
"iris.director.commandiris.param.world_teleport": "要传送到的世界",
+1 -6
View File
@@ -149,17 +149,13 @@
"iris.bukkit.commandiris.may_we_suggest_name_irisworld_instead_2": "§c建議改用名稱 \"IrisWorld\"。",
"iris.bukkit.commandiris.that_folder_already_exists": "§c該資料夾已存在!",
"iris.bukkit.commandiris.try_one_overworld_vanilla_flat_theend": "§e可嘗試:overworld、vanilla、flat 或 the_end",
"iris.bukkit.commandiris.world_staging_completed_restart_server_generate_load": "§a世界暫存完成。重新啟動伺服器以生成並載入 \"{worldName}\"。",
"iris.bukkit.commandiris.world_staging_completed_restarting_server_generate_load": "§a世界暫存完成。Iris 正在重新啟動伺服器以生成並載入 \"{worldName}\"。",
"iris.bukkit.commandiris.exception_raised_during_creation_see_console_more_details": "§c建立期間發生例外。詳細資訊請查看主控台。",
"iris.bukkit.commandiris.successfully_created_your_world": "§a世界建立成功!",
"iris.bukkit.commandiris.your_world_will_automatically_be_set_as_main_world_when": "§a伺服器重新啟動時,此世界將自動設為主世界。",
"iris.bukkit.commandiris.runtime_world_creation_is_disabled_on_folia": "§eFolia 上已停用執行階段世界建立。",
"iris.bukkit.commandiris.preparing_world_files_bukkit_yml_next_startup": "§e正在為下次啟動準備世界檔案和 bukkit.yml...",
"iris.bukkit.commandiris.failed_stage_world_files_dimension": "§c無法為維度 \"{value}\" 暫存世界檔案。",
"iris.bukkit.commandiris.updated_server_properties_level_name": "§a已將 server.properties 中的 level-name 更新為 \"{name}\"。",
"iris.bukkit.commandiris.world_was_staged_but_failed_update_server_properties_main_world": "§c世界已暫存,但無法更新 server.properties 中的主世界。",
"iris.bukkit.commandiris.staged_iris_world_with_generator_iris_seed": "§a已暫存 Iris 世界 \"{name}\",生成器為 Iris:{value},種子為 {seed}。",
"iris.bukkit.commandiris.this_world_is_now_configured_as_main_next_restart": "§a此世界現已設為下次重新啟動後的主世界。",
"iris.bukkit.commandiris.failed_update_bukkit_yml": "§c無法更新 bukkit.yml{value}",
"iris.bukkit.commandiris.specified_player_does_not_exist": "§c指定的玩家不存在。",
"iris.bukkit.commandiris.iris_v_by_volmit_software": "§aIris v.{value},由 Volmit Software 開發",
@@ -693,7 +689,6 @@
"iris.director.commandiris.param.name_world_create": "要建立的世界名稱",
"iris.director.commandiris.param.dimension_pack_create_world_with": "用於建立世界的維度包",
"iris.director.commandiris.param.seed_generate_world_with": "用於生成世界的種子",
"iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world": "是否自動將此世界設為主世界",
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart": "在下次重新啟動時取代指定的現有世界槽位",
"iris.director.commandiris.director.teleport_another_world": "傳送到另一個世界",
"iris.director.commandiris.param.world_teleport": "要傳送到的世界",
@@ -9,6 +9,7 @@ import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
public class ExactWorldSlotPathPolicyTest {
@@ -53,6 +54,7 @@ public class ExactWorldSlotPathPolicyTest {
assertEquals(canonicalRoot, target.levelRoot());
assertEquals(canonicalRoot.resolve(expectation.relativePath()), target.worldDirectory());
}
assertFalse(Files.exists(canonicalRoot.resolve("dimensions")));
}
@Test
@@ -1,5 +1,6 @@
package art.arcane.iris.core;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding;
import org.junit.Assume;
import org.junit.Rule;
import org.junit.Test;
@@ -79,6 +80,23 @@ public class IrisDatapackCompilerInputFingerprintTest {
assertNotEquals(initial, fingerprint(pack, false, "compiler-b"));
}
@Test
public void configuredLevelStemBindingsSaltTheFingerprint() throws Exception {
Path pack = activePack("binding-inputs");
IrisGeneratorBinding moon = binding("moon", "overworld");
IrisGeneratorBinding mars = binding("mars", "overworld");
String empty = fingerprint(pack, List.of(), false, "compiler-a");
String moonOnly = fingerprint(pack, List.of(moon), false, "compiler-a");
String both = fingerprint(pack, List.of(mars, moon), false, "compiler-a");
assertNotEquals(empty, moonOnly);
assertNotEquals(moonOnly, both);
assertEquals(
both,
fingerprint(pack, List.of(moon, mars), false, "compiler-a")
);
}
@Test
public void compilerInputsRejectNestedSymbolicLinks() throws Exception {
Path pack = activePack("unsafe-input");
@@ -146,6 +164,7 @@ public class IrisDatapackCompilerInputFingerprintTest {
assertEquals(compilerRoots, compilationRoots);
assertTrue(!IrisDatapackCompiler.computeInputFingerprint(
compilerRoots,
List.of(),
false,
"compiler-a").isBlank());
}
@@ -163,9 +182,27 @@ public class IrisDatapackCompilerInputFingerprintTest {
}
private String fingerprint(Path pack, boolean adjustVanillaHeight, String compilerIdentity) throws IOException {
return fingerprint(pack, List.of(), adjustVanillaHeight, compilerIdentity);
}
private String fingerprint(
Path pack,
List<IrisGeneratorBinding> bindings,
boolean adjustVanillaHeight,
String compilerIdentity
) throws IOException {
return IrisDatapackCompiler.computeInputFingerprint(
List.of(pack.toFile()),
bindings,
adjustVanillaHeight,
compilerIdentity);
}
private IrisGeneratorBinding binding(String worldKey, String dimension) {
return new IrisGeneratorBinding(
"world_iris_" + worldKey,
new WorldSlotKey("iris", worldKey),
dimension
);
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding;
import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.v1217.DataFixerV1217;
import art.arcane.volmlib.util.collection.KList;
@@ -18,6 +19,7 @@ import java.util.List;
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 IrisDatapackCompilerTest {
@@ -42,6 +44,7 @@ public class IrisDatapackCompilerTest {
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
packRoots,
new KList<File>().qadd(datapackRoot.toFile()),
List.of(),
new DataFixerV1217(),
false
);
@@ -72,6 +75,7 @@ public class IrisDatapackCompilerTest {
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
List.of(packRoot.toFile()),
new KList<File>().qadd(datapackRoot.toFile()),
List.of(),
new DataFixerV1217(),
false
);
@@ -106,6 +110,7 @@ public class IrisDatapackCompilerTest {
IrisDatapackCompiler.compile(
List.of(),
new KList<File>().qadd(datapackRoot.toFile()),
List.of(),
new DataFixerV1217(),
false
);
@@ -136,6 +141,7 @@ public class IrisDatapackCompilerTest {
IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile(
List.of(),
new KList<File>().qadd(datapackRoot.toFile()),
List.of(),
new DataFixerV1217(),
false
);
@@ -147,7 +153,112 @@ public class IrisDatapackCompilerTest {
assertFalse(Files.exists(stale));
}
@Test
public void emitsBootNativeCustomLevelStemBinding() throws Exception {
Path packRoot = temporaryFolder.newFolder("binding-pack").toPath();
Path datapackRoot = temporaryFolder.newFolder("binding-datapack").toPath();
createPack(packRoot, "moon_pack", "moon_custom");
IrisDatapackCompiler.compile(
List.of(packRoot.toFile()),
new KList<File>().qadd(datapackRoot.toFile()),
List.of(binding("moon", "moon_pack")),
new DataFixerV1217(),
false
);
Path levelStemPath = datapackRoot.resolve("data/iris/dimension/moon.json");
JSONObject levelStem = new JSONObject(Files.readString(levelStemPath, StandardCharsets.UTF_8));
JSONObject generator = levelStem.getJSONObject("generator");
JSONObject settings = generator.getJSONObject("settings");
assertEquals("iris:moon_pack", levelStem.getString("type"));
assertEquals("minecraft:flat", generator.getString("type"));
assertEquals("minecraft:the_void", settings.getString("biome"));
assertFalse(settings.getBoolean("features"));
assertFalse(settings.getBoolean("lakes"));
assertEquals(1, settings.getJSONArray("layers").length());
assertEquals(
"minecraft:air",
settings.getJSONArray("layers").getJSONObject(0).getString("block")
);
assertEquals(1, settings.getJSONArray("layers").getJSONObject(0).getInt("height"));
assertEquals(0, settings.getJSONArray("structure_overrides").length());
assertFalse(Files.exists(datapackRoot.resolve("data/minecraft/dimension/overworld.json")));
assertFalse(Files.exists(datapackRoot.resolve("data/minecraft/dimension/the_nether.json")));
assertFalse(Files.exists(datapackRoot.resolve("data/minecraft/dimension/the_end.json")));
}
@Test
public void rejectsBindingToMissingDimension() throws Exception {
Path packRoot = temporaryFolder.newFolder("missing-binding-pack").toPath();
Path datapackRoot = temporaryFolder.newFolder("missing-binding-datapack").toPath();
createPack(packRoot, "available", "available_custom");
IOException failure = assertThrows(
IOException.class,
() -> IrisDatapackCompiler.compile(
List.of(packRoot.toFile()),
new KList<File>().qadd(datapackRoot.toFile()),
List.of(binding("moon", "missing")),
new DataFixerV1217(),
false
)
);
assertTrue(failure.getMessage().contains("selects missing dimension \"missing\""));
}
@Test
public void rejectsBindingToConflictingDuplicateDimension() throws Exception {
Path firstPack = temporaryFolder.newFolder("ambiguous-binding-first").toPath();
Path secondPack = temporaryFolder.newFolder("ambiguous-binding-second").toPath();
Path datapackRoot = temporaryFolder.newFolder("ambiguous-binding-datapack").toPath();
createPack(firstPack, "shared", "first_custom", "NORMAL");
createPack(secondPack, "shared", "second_custom", "NETHER");
IOException failure = assertThrows(
IOException.class,
() -> IrisDatapackCompiler.compile(
List.of(firstPack.toFile(), secondPack.toFile()),
new KList<File>().qadd(datapackRoot.toFile()),
List.of(binding("moon", "shared")),
new DataFixerV1217(),
false
)
);
assertTrue(failure.getMessage().contains("selects ambiguous dimension \"shared\""));
}
@Test
public void acceptsIdenticalFrozenAndInstalledDimensionDefinitions() throws Exception {
Path installedPack = temporaryFolder.newFolder("identical-binding-installed").toPath();
Path frozenPack = temporaryFolder.newFolder("identical-binding-frozen").toPath();
Path datapackRoot = temporaryFolder.newFolder("identical-binding-datapack").toPath();
createPack(installedPack, "shared", "installed_custom");
createPack(frozenPack, "shared", "frozen_custom");
IrisDatapackCompiler.compile(
List.of(installedPack.toFile(), frozenPack.toFile()),
new KList<File>().qadd(datapackRoot.toFile()),
List.of(binding("moon", "shared")),
new DataFixerV1217(),
false
);
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension/moon.json")));
}
private static void createPack(Path root, String dimensionKey, String biomeId) throws Exception {
createPack(root, dimensionKey, biomeId, "NORMAL");
}
private static void createPack(
Path root,
String dimensionKey,
String biomeId,
String environment
) throws Exception {
Files.createDirectories(root.resolve("dimensions"));
Files.createDirectories(root.resolve("biomes"));
Files.writeString(
@@ -155,14 +266,14 @@ public class IrisDatapackCompilerTest {
"""
{
"name": "Test Dimension",
"environment": "NORMAL",
"environment": "%s",
"logicalHeight": 256,
"dimensionHeight": {
"min": -64,
"max": 320
}
}
""",
""".formatted(environment),
StandardCharsets.UTF_8
);
Files.writeString(
@@ -182,4 +293,12 @@ public class IrisDatapackCompilerTest {
StandardCharsets.UTF_8
);
}
private static IrisGeneratorBinding binding(String worldKey, String dimension) {
return new IrisGeneratorBinding(
"world_iris_" + worldKey,
new WorldSlotKey("iris", worldKey),
dimension
);
}
}
@@ -7,6 +7,7 @@ import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@@ -86,6 +87,23 @@ public class IrisStartupValidationTest {
assertEquals("restart boundary", IrisStartupValidation.denialReason().orElseThrow());
}
@Test
public void restartBoundaryAllowsReplacementStagingWithoutAllowingRuntimeCreation() {
IrisStartupValidation.begin();
IrisStartupValidation.markDatapacksReady();
IrisStartupValidation.markPacksReady();
IrisStartupValidation.requireRestart("restart boundary");
assertThrows(IllegalStateException.class, IrisStartupValidation::requireWorldCreationReady);
IrisStartupValidation.requireWorldReplacementStagingReady();
IrisStartupValidation.markPacksInvalid(List.of("pack validation failed"));
assertThrows(
IllegalStateException.class,
IrisStartupValidation::requireWorldReplacementStagingReady
);
}
@Test
public void packValidationInfrastructureFailureDeniesCreation() {
IrisStartupValidation.begin();
@@ -12,7 +12,9 @@ import java.nio.file.Path;
import java.util.Properties;
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 IrisWorldStorageTest {
@Rule
@@ -45,6 +47,113 @@ public class IrisWorldStorageTest {
assertEquals(new NamespacedKey("iris", "iris_world"), IrisWorldStorage.keyFromName("Iris World", "world"));
}
@Test
public void mapsPaperStartupNamesWithoutChangingLogicalNames() {
NamespacedKey worldKey = new NamespacedKey("iris", "moon");
assertEquals("world_iris_moon", IrisWorldStorage.configuredWorldName(worldKey, "world"));
assertEquals(
"world_iris_moon",
IrisWorldStorage.configuredWorldName(new WorldSlotKey("iris", "moon"), "world")
);
assertEquals(worldKey, IrisWorldStorage.keyFromConfiguredWorldName("world_iris_moon", "world"));
assertEquals("moon", IrisWorldStorage.logicalName(worldKey, "world"));
assertEquals(NamespacedKey.minecraft("overworld"),
IrisWorldStorage.keyFromConfiguredWorldName("world", "world"));
assertEquals(NamespacedKey.minecraft("the_nether"),
IrisWorldStorage.keyFromConfiguredWorldName("world_nether", "world"));
assertEquals(NamespacedKey.minecraft("the_end"),
IrisWorldStorage.keyFromConfiguredWorldName("world_the_end", "world"));
}
@Test
public void configuredWorldNameRejectsUnsafeManagedKeys() {
assertThrows(IllegalArgumentException.class,
() -> IrisWorldStorage.keyFromConfiguredWorldName("world_iris_nested/world", "world"));
assertThrows(IllegalArgumentException.class,
() -> IrisWorldStorage.configuredWorldName(new NamespacedKey("other", "moon"), "world"));
}
@Test
public void replacementKeyAcceptsExplicitCanonicalIdentities() {
assertEquals(
NamespacedKey.minecraft("overworld"),
IrisWorldStorage.replacementKeyFromName("minecraft:overworld", "world")
);
assertEquals(
NamespacedKey.minecraft("the_nether"),
IrisWorldStorage.replacementKeyFromName("MINECRAFT:THE_NETHER", "world")
);
assertEquals(
NamespacedKey.minecraft("the_end"),
IrisWorldStorage.replacementKeyFromName("minecraft:the_end", "world")
);
assertEquals(
new NamespacedKey("iris", "moon"),
IrisWorldStorage.replacementKeyFromName("iris:moon", "world")
);
assertEquals(
new NamespacedKey("iris", "main"),
IrisWorldStorage.replacementKeyFromName("iris:main", "world")
);
assertEquals(
new NamespacedKey("iris", "survival"),
IrisWorldStorage.replacementKeyFromName("iris:survival", "survival")
);
assertEquals(
new NamespacedKey("iris", "moon"),
IrisWorldStorage.replacementKeyFromName("world_iris_moon", "world")
);
}
@Test
public void replacementKeyResolvesFriendlyVanillaAliases() {
assertEquals(NamespacedKey.minecraft("overworld"),
IrisWorldStorage.replacementKeyFromName("main", "world"));
assertEquals(NamespacedKey.minecraft("overworld"),
IrisWorldStorage.replacementKeyFromName("overworld", "world"));
assertEquals(NamespacedKey.minecraft("the_nether"),
IrisWorldStorage.replacementKeyFromName("nether", "world"));
assertEquals(NamespacedKey.minecraft("the_nether"),
IrisWorldStorage.replacementKeyFromName("the_nether", "world"));
assertEquals(NamespacedKey.minecraft("the_end"),
IrisWorldStorage.replacementKeyFromName("end", "world"));
assertEquals(NamespacedKey.minecraft("the_end"),
IrisWorldStorage.replacementKeyFromName("the_end", "world"));
}
@Test
public void replacementKeyResolvesConfiguredVanillaNamesBeforeFriendlyAliases() {
assertEquals(NamespacedKey.minecraft("overworld"),
IrisWorldStorage.replacementKeyFromName("survival", "survival"));
assertEquals(NamespacedKey.minecraft("the_nether"),
IrisWorldStorage.replacementKeyFromName("survival_nether", "survival"));
assertEquals(NamespacedKey.minecraft("the_end"),
IrisWorldStorage.replacementKeyFromName("survival_the_end", "survival"));
assertEquals(NamespacedKey.minecraft("overworld"),
IrisWorldStorage.replacementKeyFromName("nether", "nether"));
}
@Test
public void replacementKeyMapsOtherBareNamesIntoIrisNamespace() {
assertEquals(
new NamespacedKey("iris", "moon_base"),
IrisWorldStorage.replacementKeyFromName("Moon Base", "world")
);
}
@Test
public void replacementKeyRejectsUnsafePathsAndInvalidIdentifiers() {
assertThrows(IllegalArgumentException.class,
() -> IrisWorldStorage.replacementKeyFromName("../world", "world"));
assertThrows(IllegalArgumentException.class,
() -> IrisWorldStorage.replacementKeyFromName("iris:nested/world", "world"));
assertThrows(IllegalArgumentException.class,
() -> IrisWorldStorage.replacementKeyFromName("iris:", "world"));
assertThrows(IllegalArgumentException.class,
() -> IrisWorldStorage.replacementKeyFromName("world", " "));
}
@Test
public void managedKeyRejectsMainWorldsAndUnsafePaths() {
assertEquals(new NamespacedKey("iris", "iris_world"),
@@ -124,9 +233,26 @@ public class IrisWorldStorageTest {
Path dimensions = Files.createDirectories(levelRoot.toPath().resolve("dimensions"));
Path outside = temporaryFolder.newFolder("outside").toPath();
Files.createSymbolicLink(dimensions.resolve("iris"), outside);
NamespacedKey key = new NamespacedKey("iris", "probe");
assertThrows(IllegalArgumentException.class, () -> IrisWorldStorage.requireSafeManagedDimensionRoot(
levelRoot,
new NamespacedKey("iris", "probe")));
key));
assertFalse(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, key));
}
@Test
public void existingManagedDimensionRootRequiresRealDirectory() throws Exception {
File levelRoot = temporaryFolder.newFolder("managed-world-admission");
Path namespace = Files.createDirectories(levelRoot.toPath().resolve("dimensions/iris"));
NamespacedKey missing = new NamespacedKey("iris", "missing");
NamespacedKey file = new NamespacedKey("iris", "file");
NamespacedKey directory = new NamespacedKey("iris", "directory");
Files.writeString(namespace.resolve("file"), "not a directory");
Files.createDirectory(namespace.resolve("directory"));
assertFalse(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, missing));
assertFalse(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, file));
assertTrue(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, directory));
}
}
@@ -0,0 +1,55 @@
package art.arcane.iris.core;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
public class IrisWorldsTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void registryFileBelongsToSelectedLevelRoot() throws Exception {
Path levelRoot = temporaryFolder.newFolder("world").toPath();
assertEquals(
levelRoot.toAbsolutePath().normalize().resolve("iris/worlds.json"),
IrisWorlds.registryFile(levelRoot));
}
@Test
public void bukkitWorldFilteringUsesExactStorageInSelectedRoot() throws Exception {
Path selectedRoot = temporaryFolder.newFolder("world").toPath();
Path otherRoot = temporaryFolder.newFolder("archive").toPath();
Files.createDirectories(selectedRoot.resolve("dimensions/minecraft/overworld"));
Files.createDirectories(selectedRoot.resolve("dimensions/iris/moon"));
Files.createDirectories(otherRoot.resolve("dimensions/iris/foreign"));
Files.createDirectories(selectedRoot.resolve("dimensions/iris"));
Files.writeString(selectedRoot.resolve("dimensions/iris/not_a_directory"), "not storage");
Map<String, String> configuredWorlds = new LinkedHashMap<>();
configuredWorlds.put("world", "overworld");
configuredWorlds.put("world_nether", "underworld");
configuredWorlds.put("world_iris_moon", "overworld");
configuredWorlds.put("moon", "overworld");
configuredWorlds.put("world_iris_foreign", "overworld");
configuredWorlds.put("archive_iris_foreign", "overworld");
configuredWorlds.put("foreign", "overworld");
configuredWorlds.put("world_iris_not_a_directory", "overworld");
configuredWorlds.put("world_iris_missing", "overworld");
Map<String, String> selected = IrisWorlds.filterBukkitWorldsByStorage(selectedRoot, configuredWorlds);
Map<String, String> other = IrisWorlds.filterBukkitWorldsByStorage(otherRoot, configuredWorlds);
assertEquals(Set.of("world", "world_iris_moon"), selected.keySet());
assertEquals(Set.of("archive_iris_foreign"), other.keySet());
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.WorldSlotKey;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.Rule;
import org.junit.Test;
@@ -13,6 +14,7 @@ import java.nio.channels.ServerSocketChannel;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -26,6 +28,112 @@ public class BukkitWorldConfigurationTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void readsOnlyCanonicalCustomIrisGeneratorBindings() throws Exception {
File configuration = temporaryFolder.newFile("binding-bukkit.yml");
Path levelRoot = temporaryFolder.newFolder("binding-level-root").toPath();
Files.createDirectories(levelRoot.resolve("dimensions/iris/moon"));
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.world.generator", "Iris:overworld");
yaml.set("worlds.world_nether.generator", "Iris:underworld");
yaml.set("worlds.world_the_end.generator", "Iris:theend");
yaml.set("worlds.moon.generator", "Iris:noncanonical_short_name");
yaml.set("worlds.world_iris_moon.generator", "Iris:moon_pack");
yaml.set("worlds.world_iris_other.generator", "Other:generator");
yaml.save(configuration);
List<BukkitWorldConfiguration.IrisGeneratorBinding> bindings =
BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", levelRoot);
assertEquals(List.of(new BukkitWorldConfiguration.IrisGeneratorBinding(
"world_iris_moon",
new WorldSlotKey("iris", "moon"),
"moon_pack"
)), bindings);
}
@Test
public void rejectsCustomIrisBindingWithoutSelectedDimension() throws Exception {
File configuration = temporaryFolder.newFile("missing-binding-dimension.yml");
Path levelRoot = temporaryFolder.newFolder("missing-binding-level-root").toPath();
Files.createDirectories(levelRoot.resolve("dimensions/iris/moon"));
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.world_iris_moon.generator", "Iris");
yaml.save(configuration);
IOException failure = assertThrows(
IOException.class,
() -> BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", levelRoot)
);
assertTrue(failure.getMessage().contains("must select a dimension"));
}
@Test
public void ignoresNoncanonicalShortNamesThatCollapseOntoCanonicalIrisKeys() throws Exception {
File configuration = temporaryFolder.newFile("ambiguous-binding-name.yml");
Path levelRoot = temporaryFolder.newFolder("ambiguous-binding-level-root").toPath();
Files.createDirectories(levelRoot.resolve("dimensions/iris/moon"));
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.moon.generator", "Iris:moon_pack");
yaml.set("worlds.world_iris_moon.generator", "Iris:moon_pack");
yaml.save(configuration);
assertEquals(List.of(new BukkitWorldConfiguration.IrisGeneratorBinding(
"world_iris_moon",
new WorldSlotKey("iris", "moon"),
"moon_pack"
)), BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", levelRoot));
}
@Test
public void admitsOnlyExistingCustomWorldDirectoriesUnderSelectedLevelRoot() throws Exception {
File configuration = temporaryFolder.newFile("root-admission-bukkit.yml");
Path selectedRoot = temporaryFolder.newFolder("selected-level-root").toPath();
Path otherRoot = temporaryFolder.newFolder("other-level-root").toPath();
Files.createDirectories(selectedRoot.resolve("dimensions/iris/moon"));
Files.createDirectories(otherRoot.resolve("dimensions/iris/moon"));
Files.createDirectories(selectedRoot.resolve("dimensions/iris"));
Files.writeString(selectedRoot.resolve("dimensions/iris/file"), "not a world directory");
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.world_iris_moon.generator", "Iris:moon_pack");
yaml.set("worlds.other_iris_moon.generator", "Iris:other_pack");
yaml.set("worlds.world_iris_missing.generator", "Iris:missing_pack");
yaml.set("worlds.world_iris_file.generator", "Iris:file_pack");
yaml.save(configuration);
List<BukkitWorldConfiguration.IrisGeneratorBinding> bindings =
BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", selectedRoot);
assertEquals(List.of(new BukkitWorldConfiguration.IrisGeneratorBinding(
"world_iris_moon",
new WorldSlotKey("iris", "moon"),
"moon_pack"
)), bindings);
}
@Test
public void excludesSymlinkedCustomWorldTarget() throws Exception {
File configuration = temporaryFolder.newFile("symlink-admission-bukkit.yml");
Path levelRoot = temporaryFolder.newFolder("symlink-level-root").toPath();
Path namespaceRoot = Files.createDirectories(levelRoot.resolve("dimensions/iris"));
Path outside = temporaryFolder.newFolder("symlink-world-outside").toPath();
try {
Files.createSymbolicLink(namespaceRoot.resolve("moon"), outside);
} catch (IOException | UnsupportedOperationException | SecurityException exception) {
assumeNoException(exception);
}
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("worlds.world_iris_moon.generator", "Iris:moon_pack");
yaml.save(configuration);
assertTrue(BukkitWorldConfiguration.readIrisGeneratorBindings(
configuration,
"world",
levelRoot
).isEmpty());
}
@Test
public void registersAndRemovesWorldAtomically() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
@@ -1,6 +1,7 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.WorldRemovalPathPolicy;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -18,6 +19,7 @@ import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
@@ -112,6 +114,34 @@ public class IrisWorldRemovalServiceTest {
assertTrue(result.registryChanged());
}
@Test
public void bukkitUnregistrationUsesCanonicalPaperStartupName() throws Exception {
Path levelRoot = temporaryFolder.newFolder("world").toPath();
WorldRemovalPathPolicy.Target target = WorldRemovalPathPolicy.resolve("moon", "world", levelRoot);
assertEquals(
"world_iris_moon",
IrisWorldRemovalService.bukkitConfigurationWorldName(target)
);
}
@Test
public void diskInspectionReadsOnlyCanonicalPaperStartupSection() throws Exception {
Path levelRoot = temporaryFolder.newFolder("inspection-world").toPath();
WorldRemovalPathPolicy.Target target = WorldRemovalPathPolicy.resolve(
"moon",
"inspection-world",
levelRoot
);
YamlConfiguration configuration = new YamlConfiguration();
configuration.set("worlds.moon.generator", "Iris:noncanonical");
assertNull(IrisWorldRemovalService.bukkitGenerator(configuration, target));
configuration.set("worlds.inspection-world_iris_moon.generator", "Iris:overworld");
assertEquals("Iris:overworld", IrisWorldRemovalService.bukkitGenerator(configuration, target));
}
@Test
public void registryFailureStopsBeforeFilesystemDeletion() throws Exception {
LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator();
@@ -238,6 +238,57 @@ public class WorldReplacementBootstrapTest {
assertFalse(Files.exists(paths(first).backup()));
}
@Test
public void publishesTwoDistinctArmedReplacementsInOneColdReconcile() throws Exception {
Transaction nether = stagedTransaction(Phase.ARMED, true, "nether-original");
configureReplacement(nether);
WorldSlotKey endKey = WorldSlotKey.minecraft("the_end");
ExactWorldSlotPathPolicy.Target endTarget = ExactWorldSlotPathPolicy.resolve(levelRoot, endKey);
WorldGeneratorSnapshot endOriginal = BukkitWorldConfiguration.snapshot(
bukkitConfiguration.toFile(),
"world_the_end"
);
UUID endId = UUID.randomUUID();
ReplacementPaths endPaths = WorldReplacementFilesystem.paths(endTarget, endId);
writeOriginalTarget(endPaths, "end-original");
Path endDimension = endPaths.stage().resolve("iris/pack/dimensions/theend.json");
Files.createDirectories(endDimension.getParent());
Files.writeString(endDimension, "end-replacement");
String endFingerprint = WorldReplacementFilesystem.fingerprintPack(
endPaths.stage().resolve("iris/pack")
);
Transaction end = new Transaction(
endId,
endKey,
"world_the_end",
endTarget.levelRoot(),
"theend",
SEED,
endFingerprint,
endOriginal,
true,
Phase.ARMED
);
WorldReplacementJournal.write(dataDirectory, end);
configureReplacement(end);
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(2, result.transactions());
assertEquals(2, result.published());
assertEquals(0, result.rolledBack());
assertEquals(0, result.retained());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("end-replacement", Files.readString(endTarget.worldDirectory()
.resolve("iris/pack/dimensions/theend.json")));
assertEquals("nether-original", Files.readString(backup(nether).resolve("original.txt")));
assertEquals("end-original", Files.readString(endPaths.backup().resolve("original.txt")));
List<Transaction> published = WorldReplacementJournal.load(dataDirectory, levelRoot);
assertEquals(2, published.size());
assertTrue(published.stream().allMatch(transaction -> transaction.phase() == Phase.PUBLISHED));
}
@Test
public void roundTripsBlankAndWhitespaceOriginalGenerators() throws Exception {
for (String generator : List.of("", " ")) {
@@ -260,24 +311,15 @@ public class WorldReplacementBootstrapTest {
}
@Test
public void rejectsIrisWorldKeysThatCollideWithConfiguredVanillaAliases() {
WorldGeneratorSnapshot original = new WorldGeneratorSnapshot(false, false, false, null, false, null);
for (String alias : List.of("world", "world_nether", "world_the_end")) {
Transaction transaction = new Transaction(
UUID.randomUUID(),
new WorldSlotKey("iris", alias),
alias,
levelRoot,
"underworld",
SEED,
"0".repeat(64),
original,
false,
Phase.ARMED
);
assertThrows(IOException.class, () -> WorldReplacementJournal.resolveTarget(transaction, levelRoot));
}
public void usesPaperStartupAliasesForIrisReplacementJournals() {
assertEquals(
"world_iris_moon",
WorldReplacementJournal.logicalWorldName(levelRoot, new WorldSlotKey("iris", "moon"))
);
assertEquals(
"world_iris_world_nether",
WorldReplacementJournal.logicalWorldName(levelRoot, new WorldSlotKey("iris", "world_nether"))
);
}
@Test
@@ -17,6 +17,7 @@ import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -67,7 +68,10 @@ public class WorldReplacementFilesystemTest {
() -> WorldReplacementFilesystem.requireExistingTarget(paths)
);
assertTrue(failure.getMessage().contains("requires an existing exact world slot"));
assertEquals(
"/iris replace requires an existing exact world slot; use /iris create for a new world.",
failure.getMessage()
);
assertFalse(Files.exists(paths.target()));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
@@ -92,7 +96,7 @@ public class WorldReplacementFilesystemTest {
}
@Test
public void rejectsUnmigratedRetainedWorldBeforePublication() throws Exception {
public void rejectsIncompleteCurrentPaperWorldBeforePublication() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("missing-paper-metadata", TRANSACTION_ID);
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("original.txt"), "original");
@@ -109,7 +113,7 @@ public class WorldReplacementFilesystemTest {
}
@Test
public void rejectsUnmigratedRetainedWorldAtAdmission() throws Exception {
public void rejectsIncompleteCurrentPaperWorldAtAdmission() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-missing-paper-metadata", TRANSACTION_ID);
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("original.txt"), "original");
@@ -331,6 +335,38 @@ public class WorldReplacementFilesystemTest {
);
}
@Test
public void ignoresGeneratedAuthoringMetadataWithoutIgnoringNestedPackContent() throws Exception {
Path pack = temporaryFolder.newFolder("generated-metadata").toPath();
Path objects = Files.createDirectories(pack.resolve("objects"));
Files.writeString(objects.resolve("tree.iob"), "tree");
String expected = WorldReplacementFilesystem.fingerprintPack(pack);
Files.createDirectories(pack.resolve(".iris/schema"));
Files.writeString(pack.resolve(".iris/schema/dimensions-schema.json"), "schema");
Files.createDirectories(pack.resolve(".idea"));
Files.writeString(pack.resolve(".idea/jsonSchemas.xml"), "generated UUIDs");
Files.writeString(pack.resolve("pack.code-workspace"), "workspace");
Files.writeString(objects.resolve("editor.code-workspace"), "nested workspace");
assertEquals(expected, WorldReplacementFilesystem.fingerprintPack(pack));
Path nestedPackContent = Files.createDirectories(objects.resolve(".iris"));
Files.writeString(nestedPackContent.resolve("semantic.json"), "pack content");
assertNotEquals(expected, WorldReplacementFilesystem.fingerprintPack(pack));
}
@Test
public void validatesExcludedAuthoringMetadataForUnsafeEntries() throws Exception {
Path pack = temporaryFolder.newFolder("unsafe-generated-metadata").toPath();
Path metadata = Files.createDirectories(pack.resolve(".iris"));
Path outside = temporaryFolder.newFile("outside-generated-metadata.txt").toPath();
Files.createSymbolicLink(metadata.resolve("linked.json"), outside);
assertThrows(IOException.class, () -> WorldReplacementFilesystem.fingerprintPack(pack));
}
@Test
public void rejectsMalformedAndCrossTransactionPaths() throws Exception {
Path parent = temporaryFolder.newFolder("invalid-paths").toPath();
@@ -0,0 +1,143 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.volmlib.util.nbt.io.NBTUtil;
import art.arcane.volmlib.util.nbt.io.NamedTag;
import art.arcane.volmlib.util.nbt.tag.CompoundTag;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class WorldReplacementSeedTest {
private static final long SEED = -734829104958217364L;
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void readsAuthoritativeLongSeed() throws Exception {
CompoundTag data = new CompoundTag();
data.putLong("seed", SEED);
CompoundTag root = new CompoundTag();
root.put("data", data);
Path worldDirectory = writeSettings("valid", root);
assertEquals(SEED, WorldReplacementSeed.readAuthoritativeSeed(worldDirectory));
}
@Test
public void copiesSettingsAndRewritesOnlyAuthoritativeSeed() throws Exception {
CompoundTag dimensions = new CompoundTag();
dimensions.putString("marker", "preserved");
CompoundTag data = new CompoundTag();
data.putLong("seed", 1337L);
data.putString("generator", "preserved");
data.put("dimensions", dimensions);
CompoundTag root = new CompoundTag();
root.put("data", data);
root.putString("root-marker", "preserved");
Path sourceWorld = writeSettings("copy-source", root);
Path targetWorld = temporaryFolder.newFolder("copy-target").toPath();
WorldReplacementSeed.copyWithAuthoritativeSeed(sourceWorld, targetWorld, SEED);
assertEquals(1337L, WorldReplacementSeed.readAuthoritativeSeed(sourceWorld));
assertEquals(SEED, WorldReplacementSeed.readAuthoritativeSeed(targetWorld));
NamedTag copied = NBTUtil.read(settingsPath(targetWorld).toFile());
CompoundTag copiedRoot = (CompoundTag) copied.getTag();
CompoundTag copiedData = copiedRoot.getCompoundTag("data");
assertEquals("preserved", copiedRoot.getString("root-marker"));
assertEquals("preserved", copiedData.getString("generator"));
assertEquals("preserved", copiedData.getCompoundTag("dimensions").getString("marker"));
}
@Test
public void rejectsMissingDataCompound() throws Exception {
Path worldDirectory = writeSettings("missing-data", new CompoundTag());
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementSeed.readAuthoritativeSeed(worldDirectory)
);
assertTrue(failure.getMessage().contains("missing data"));
}
@Test
public void rejectsMissingSeed() throws Exception {
CompoundTag root = new CompoundTag();
root.put("data", new CompoundTag());
Path worldDirectory = writeSettings("missing-seed", root);
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementSeed.readAuthoritativeSeed(worldDirectory)
);
assertTrue(failure.getMessage().contains("missing data.seed"));
}
@Test
public void rejectsWrongDataType() throws Exception {
CompoundTag root = new CompoundTag();
root.putString("data", "invalid");
Path worldDirectory = writeSettings("wrong-data-type", root);
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementSeed.readAuthoritativeSeed(worldDirectory)
);
assertTrue(failure.getMessage().contains("data must be a compound tag"));
}
@Test
public void rejectsNonLongSeed() throws Exception {
CompoundTag data = new CompoundTag();
data.putInt("seed", 1337);
CompoundTag root = new CompoundTag();
root.put("data", data);
Path worldDirectory = writeSettings("wrong-seed-type", root);
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementSeed.readAuthoritativeSeed(worldDirectory)
);
assertTrue(failure.getMessage().contains("data.seed must be a long tag"));
}
@Test
public void rejectsCorruptSettings() throws Exception {
Path worldDirectory = temporaryFolder.newFolder("corrupt").toPath();
Path settings = settingsPath(worldDirectory);
Files.createDirectories(settings.getParent());
Files.write(settings, new byte[]{10, 0});
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementSeed.readAuthoritativeSeed(worldDirectory)
);
assertTrue(failure.getMessage().contains("Could not read Paper world generation settings"));
}
private Path writeSettings(String name, CompoundTag root) throws Exception {
Path worldDirectory = temporaryFolder.newFolder(name).toPath();
Path settings = settingsPath(worldDirectory);
Files.createDirectories(settings.getParent());
NBTUtil.write(root, settings.toFile());
return worldDirectory;
}
private Path settingsPath(Path worldDirectory) {
return worldDirectory.resolve("data/minecraft/world_gen_settings.dat");
}
}
@@ -5,11 +5,13 @@ import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.nio.file.Files;
import java.nio.file.FileAlreadyExistsException;
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 AtomicDirectoryPublisherTest {
@@ -52,4 +54,21 @@ public class AtomicDirectoryPublisherTest {
assertTrue(Files.isDirectory(target));
assertEquals("old", Files.readString(target.resolve("value.txt")));
}
@Test
public void absentPublicationRefusesAnExistingTargetWithoutMovingEitherDirectory() throws Exception {
Path root = temporaryFolder.getRoot().toPath();
Path target = Files.createDirectory(root.resolve("existing-target"));
Files.writeString(target.resolve("value.txt"), "old");
Path staged = Files.createDirectory(root.resolve("absent-stage"));
Files.writeString(staged.resolve("value.txt"), "new");
assertThrows(
FileAlreadyExistsException.class,
() -> AtomicDirectoryPublisher.publishAbsent(staged, target)
);
assertEquals("old", Files.readString(target.resolve("value.txt")));
assertEquals("new", Files.readString(staged.resolve("value.txt")));
}
}
@@ -1,5 +1,6 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.Assume;
@@ -49,6 +50,7 @@ public class DefaultPackBootstrapProvisionerTest {
try {
Path dataDirectory = root.resolve("plugins/Iris");
DefaultPackBootstrapProvisioner.ProvisionOptions options = new DefaultPackBootstrapProvisioner.ProvisionOptions(
List.of(),
List.of(),
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(),
Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC),
@@ -71,7 +73,12 @@ public class DefaultPackBootstrapProvisionerTest {
assertTrue(result.packRoots().isEmpty());
assertEquals(0, requests.get());
assertTrue(Files.isRegularFile(result.datapackRoot().resolve("pack.mcmeta")));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, List.of()));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(
dataDirectory,
root,
List.of(),
List.of()
));
} finally {
server.stop(0);
delete(root);
@@ -114,14 +121,24 @@ public class DefaultPackBootstrapProvisionerTest {
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack")));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(
dataDirectory,
root,
options.packs(),
options.bindings()
));
assertTrue(DefaultPackBootstrapProvisioner.wasProvisionedThisStartup());
Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties"));
assertEquals("true", marker.getProperty("pack.overworld.managed"));
assertEquals("true", marker.getProperty("pack.underworld.managed"));
assertEquals("underworld", marker.getProperty("pack.underworld.requiredDimension"));
delete(installed.packRoots().get("underworld"));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(
dataDirectory,
root,
options.packs(),
options.bindings()
));
} finally {
server.stop(0);
delete(root);
@@ -309,7 +326,12 @@ public class DefaultPackBootstrapProvisionerTest {
assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json")));
Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8);
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(
dataDirectory,
root,
options.packs(),
options.bindings()
));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
@@ -338,7 +360,12 @@ public class DefaultPackBootstrapProvisionerTest {
writePack(dataDirectory.resolve("packs/second"), "second", "second_biome");
writePack(root.resolve("dimensions/example/world/iris/pack"), "world_local", "world_local_biome");
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs()));
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(
dataDirectory,
root,
options.packs(),
options.bindings()
));
DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision(
dataDirectory,
ignored -> {
@@ -591,6 +618,56 @@ public class DefaultPackBootstrapProvisionerTest {
}
}
@Test
public void effectiveStartupBindingsInstallAndRefreshBootNativeLevelStem() throws Exception {
Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-stem");
try {
Path dataDirectory = serverRoot.resolve("plugins/Iris");
Path levelRoot = serverRoot.resolve("levels/primary");
Path packRoot = levelRoot.resolve("dimensions/iris/moon/iris/pack");
Files.createDirectories(levelRoot);
Files.writeString(
serverRoot.resolve("server.properties"),
"level-name=levels/primary\n",
StandardCharsets.UTF_8
);
writePack(packRoot, "overworld", "overworld_biome");
Files.writeString(
packRoot.resolve("dimensions/underworld.json"),
dimensionJson("underworld"),
StandardCharsets.UTF_8
);
Files.writeString(
serverRoot.resolve("bukkit.yml"),
"worlds:\n primary_iris_moon:\n generator: Iris:overworld\n",
StandardCharsets.UTF_8
);
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolve(serverRoot, new String[0]);
DefaultPackBootstrapProvisioner.ProvisionResult installed =
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, startupPaths);
Path levelStem = installed.datapackRoot().resolve("data/iris/dimension/moon.json");
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
assertTrue(Files.readString(levelStem).contains("\"type\": \"iris:overworld\""));
Files.writeString(
serverRoot.resolve("bukkit.yml"),
"worlds:\n primary_iris_moon:\n generator: Iris:underworld\n",
StandardCharsets.UTF_8
);
DefaultPackBootstrapProvisioner.ProvisionResult updated =
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
}, startupPaths);
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
assertTrue(Files.readString(levelStem).contains("\"type\": \"iris:underworld\""));
} finally {
delete(serverRoot);
}
}
private static DefaultPackBootstrapProvisioner.ProvisionOptions options(
HttpServer server,
Path serverRoot,
@@ -610,6 +687,7 @@ public class DefaultPackBootstrapProvisionerTest {
"underworld"
)
),
List.of(),
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(),
Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC),
refreshInterval,