From 57072d5bcd126abbc63cbd6808e68f5e194a7453 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sat, 15 Aug 2026 11:27:46 -0400 Subject: [PATCH] Correct Java 26.2 Iris world lifecycle --- .../iris/core/nms/v26_2_R1/NMSBinding.java | 115 +++++ ...dingCurrentPaperWorldDataContractTest.java | 92 ++++ .../iris/core/BukkitWorldReconciler.java | 35 +- .../iris/core/IrisWorldGeneratorResolver.java | 9 +- .../core/PendingWorldReplacementManager.java | 96 ++--- .../iris/core/commands/CommandIris.java | 399 +++++------------- .../iris/core/BukkitWorldReconcilerTest.java | 10 + .../core/IrisWorldGeneratorResolverTest.java | 13 + ...dingWorldReplacementManagerPolicyTest.java | 126 ++++++ ...ommandIrisCreateOverwriteContractTest.java | 35 +- .../CommandIrisFoliaCreateContractTest.java | 48 +++ .../CommandIrisMainWorldPromotionTest.java | 93 ---- build.gradle | 2 + .../iris/core/IrisDatapackCompiler.java | 130 +++++- .../iris/core/IrisStartupValidation.java | 10 + .../arcane/iris/core/IrisWorldStorage.java | 107 ++++- .../java/art/arcane/iris/core/IrisWorlds.java | 86 +++- .../arcane/iris/core/ServerConfigurator.java | 21 +- .../lifecycle/BukkitWorldConfiguration.java | 125 ++++++ .../lifecycle/IrisWorldRemovalService.java | 27 +- .../LifecycleOperationCoordinator.java | 1 - .../lifecycle/WorldReplacementFilesystem.java | 25 +- .../lifecycle/WorldReplacementJournal.java | 11 +- .../core/lifecycle/WorldReplacementSeed.java | 119 ++++++ .../BukkitCommandMessagesExtended.java | 28 +- .../localization/DirectorCommandMessages.java | 5 - .../art/arcane/iris/core/nms/INMSBinding.java | 10 + .../core/pack/AtomicDirectoryPublisher.java | 17 + .../pack/DefaultPackBootstrapProvisioner.java | 69 ++- .../arcane/iris/core/tools/IrisCreator.java | 19 +- .../arcane/iris/core/tools/IrisToolbelt.java | 2 +- core/src/main/resources/languages/de_DE.json | 7 +- core/src/main/resources/languages/es_ES.json | 7 +- core/src/main/resources/languages/fi_FI.json | 7 +- core/src/main/resources/languages/fr_FR.json | 7 +- core/src/main/resources/languages/he_IL.json | 7 +- core/src/main/resources/languages/it_IT.json | 7 +- core/src/main/resources/languages/ja-JP.json | 7 +- core/src/main/resources/languages/ko_KR.json | 7 +- core/src/main/resources/languages/lt_LT.json | 7 +- core/src/main/resources/languages/nl_NL.json | 7 +- core/src/main/resources/languages/pl_PL.json | 7 +- core/src/main/resources/languages/pt_PT.json | 7 +- core/src/main/resources/languages/ru_RU.json | 7 +- core/src/main/resources/languages/tr_TR.json | 7 +- core/src/main/resources/languages/vi_VI.json | 7 +- core/src/main/resources/languages/zh_CN.json | 7 +- core/src/main/resources/languages/zh_TW.json | 7 +- .../core/ExactWorldSlotPathPolicyTest.java | 2 + ...sDatapackCompilerInputFingerprintTest.java | 37 ++ .../iris/core/IrisDatapackCompilerTest.java | 123 +++++- .../iris/core/IrisStartupValidationTest.java | 18 + .../iris/core/IrisWorldStorageTest.java | 128 +++++- .../art/arcane/iris/core/IrisWorldsTest.java | 55 +++ .../BukkitWorldConfigurationTest.java | 108 +++++ .../IrisWorldRemovalServiceTest.java | 30 ++ .../WorldReplacementBootstrapTest.java | 78 +++- .../WorldReplacementFilesystemTest.java | 42 +- .../lifecycle/WorldReplacementSeedTest.java | 143 +++++++ .../pack/AtomicDirectoryPublisherTest.java | 19 + .../DefaultPackBootstrapProvisionerTest.java | 88 +++- 61 files changed, 2180 insertions(+), 695 deletions(-) create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisFoliaCreateContractTest.java delete mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisMainWorldPromotionTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java create mode 100644 core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementSeedTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index f6656c240..021381f75 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -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 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 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 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> getBlockProperties() { KMap> states = new KMap<>(); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java new file mode 100644 index 000000000..ed7347627 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java @@ -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> 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> 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); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java index 8de56d35b..9013c541e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java @@ -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 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 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 loadedWorld(NamespacedKey worldKey); CompletableFuture 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 loadedWorld(NamespacedKey worldKey) { return WorldIdentity.resolve(worldKey); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index ff4dba3f8..8e402db67 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -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(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index 62552ef1a..54fe16707 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -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 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 { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index e342c2508..eed8b7ea8 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -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 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 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); - } - } - } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/BukkitWorldReconcilerTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/BukkitWorldReconcilerTest.java index 1ed287472..9ab263a4f 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/BukkitWorldReconcilerTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/BukkitWorldReconcilerTest.java @@ -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 loadedWorld(NamespacedKey requestedWorldKey) { return worldKey.equals(requestedWorldKey) ? loaded : Optional.empty(); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java index fb0c66263..2d62f92d8 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java @@ -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")); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java new file mode 100644 index 000000000..ce7965151 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementManagerPolicyTest.java @@ -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 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 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) { + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisCreateOverwriteContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisCreateOverwriteContractTest.java index 06c5f38c8..039afdbb4 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisCreateOverwriteContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisCreateOverwriteContractTest.java @@ -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)); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisFoliaCreateContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisFoliaCreateContractTest.java new file mode 100644 index 000000000..adf249d9c --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisFoliaCreateContractTest.java @@ -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(")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisMainWorldPromotionTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisMainWorldPromotionTest.java deleted file mode 100644 index 2db60ff04..000000000 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisMainWorldPromotionTest.java +++ /dev/null @@ -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 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) { - } -} diff --git a/build.gradle b/build.gradle index 7f82c85ad..38cbcc792 100644 --- a/build.gradle +++ b/build.gradle @@ -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', diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java index e1783ac12..1faf71e0e 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -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 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 packRoots, + List bindings, IDataFixer fixer, boolean adjustVanillaHeight ) throws IOException { Objects.requireNonNull(fixer, "fixer"); return computeInputFingerprint( packRoots, + bindings, adjustVanillaHeight, compilerIdentity(fixer)); } static String computeInputFingerprint( List packRoots, + List bindings, boolean adjustVanillaHeight, String compilerIdentity ) throws IOException { Objects.requireNonNull(packRoots, "packRoots"); Objects.requireNonNull(compilerIdentity, "compilerIdentity"); + List 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 packRoots, KList datapackRoots, + List bindings, IDataFixer fixer, boolean adjustVanillaHeight ) throws IOException { Objects.requireNonNull(packRoots, "packRoots"); Objects.requireNonNull(datapackRoots, "datapackRoots"); Objects.requireNonNull(fixer, "fixer"); + List 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> biomes = new LinkedHashMap<>(); + Map> 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 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 bindings, + Map> dimensions, + Collection 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 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 normalizeBindings(List bindings) + throws IOException { + List requiredBindings = List.copyOf( + Objects.requireNonNull(bindings, "bindings") + ); + Map 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 normalized = new ArrayList<>(byWorld.values()); + normalized.sort(Comparator.comparing(binding -> binding.worldKey().toString())); + return List.copyOf(normalized); + } + private static void collectInstalledPackRoots( Path packsRoot, Map roots, @@ -363,7 +473,11 @@ public final class IrisDatapackCompiler { } } - private static void validateOutputs(Collection datapackRoots, int dimensionCount) throws IOException { + private static void validateOutputs( + Collection 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]; diff --git a/core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java b/core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java index 24d033636..cfd8536ae 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java +++ b/core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java @@ -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; } diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java index b27b52bd8..25d9bf36d 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java @@ -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() diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java index 4a69e0a42..0f1ab8cde 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java @@ -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 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 worlds; private volatile boolean dirty = false; - private IrisWorlds(KMap worlds) { + private IrisWorlds(Path levelRoot, KMap 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 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 getWorlds() { clean(); KMap 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 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 filterBukkitWorldsByStorage( + Path levelRoot, + Map 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 result = new KMap<>(); + for (Map.Entry 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 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); diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index 1faf9c2be..02191f2af 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -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 packRoots; + List 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 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); } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java index 920512422..38cd39be1 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java @@ -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 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 configuredNames = new ArrayList<>(worlds.getKeys(false)); + configuredNames.sort(Comparator.naturalOrder()); + Map 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:."); + } + 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, diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java index d43082720..eb76aaf8d 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinator.java b/core/src/main/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinator.java index 3cb00e7ab..406a45581 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinator.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinator.java @@ -180,7 +180,6 @@ public final class LifecycleOperationCoordinator { WORLD_UNLOAD, WORLD_REMOVE, WORLD_REPLACE, - WORLD_PROMOTE, STUDIO_OPEN, STUDIO_CLOSE, PACK_CREATE, diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java index 09b8bee80..1f84f0394 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystem.java @@ -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 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 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"); diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java index af94b2d17..4541b20cb 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementJournal.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java new file mode 100644 index 000000000..eaea13f6f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementSeed.java @@ -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; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java b/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java index 34426e99c..8d96c99e8 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java +++ b/core/src/main/java/art/arcane/iris/core/localization/BukkitCommandMessagesExtended.java @@ -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, diff --git a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java index 094c3894b..f2f4986df 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java @@ -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, diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index 0f028833e..3a167889c 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -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> getBlockProperties(); private void validateDimensionTypes(WorldCreator c) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/AtomicDirectoryPublisher.java b/core/src/main/java/art/arcane/iris/core/pack/AtomicDirectoryPublisher.java index 55fc81784..f9237e78e 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/AtomicDirectoryPublisher.java +++ b/core/src/main/java/art/arcane/iris/core/pack/AtomicDirectoryPublisher.java @@ -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); diff --git a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java index 1ca366118..6251b1bbd 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java +++ b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java @@ -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 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 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 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 requiredPacks) { + static boolean isProvisioned( + Path dataDirectory, + Path levelRoot, + List requiredPacks, + List bindings + ) { try { Path normalizedData = dataDirectory.toAbsolutePath().normalize(); Path normalizedLevel = levelRoot.toAbsolutePath().normalize(); @@ -137,12 +150,19 @@ public final class DefaultPackBootstrapProvisioner { return false; } } + List 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 outputFolders = new KList().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 feedback) { try { delete(path); @@ -880,6 +917,7 @@ public final class DefaultPackBootstrapProvisioner { record ProvisionOptions( List packs, + List 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"); diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 988511c19..7fb78cc47 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -134,8 +134,11 @@ public class IrisCreator { private BiConsumer 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 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); } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index 4c8661f1d..0145aa097 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -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) { diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 295d0d8e5..45f52d605 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -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", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 947c8d029..657dcdb09 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -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á", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index ab2c6b5cf..d57c2020d 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -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", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 8cee22d12..3cc4750b2 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -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 l’emplacement 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", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 80ab030d4..aaa93d60b 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -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": "העולם לטלפורט", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 8049bad99..4b10b61af 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -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", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index 091c61168..fc8c8eaa4 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -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": "テレポート先のワールド", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index f382a8a70..6f76bd639 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -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": "순간이동할 월드", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index f3ab71aa3..c3a1746a1 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -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 į", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 29849fad0..de7acdabc 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -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", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index b2955795c..5c81c902a 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -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", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index de6a89328..f326331ae 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -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", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 3199d0d49..48325edd5 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -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": "Телепортироваться в мир", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 19d8d4cb7..37ba514c0 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -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", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index c5f92f57f..26f4e3876 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -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", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index d1ad6eca1..e7ecdb918 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -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": "要传送到的世界", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index bd6e18b18..515283d95 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -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": "要傳送到的世界", diff --git a/core/src/test/java/art/arcane/iris/core/ExactWorldSlotPathPolicyTest.java b/core/src/test/java/art/arcane/iris/core/ExactWorldSlotPathPolicyTest.java index ab4358dee..90630ea7f 100644 --- a/core/src/test/java/art/arcane/iris/core/ExactWorldSlotPathPolicyTest.java +++ b/core/src/test/java/art/arcane/iris/core/ExactWorldSlotPathPolicyTest.java @@ -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 diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java index bc4e0ca49..5c2a50662 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java @@ -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 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 + ); + } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java index 6cf5c92e5..7a8b5dd10 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java @@ -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().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().qadd(datapackRoot.toFile()), + List.of(), new DataFixerV1217(), false ); @@ -106,6 +110,7 @@ public class IrisDatapackCompilerTest { IrisDatapackCompiler.compile( List.of(), new KList().qadd(datapackRoot.toFile()), + List.of(), new DataFixerV1217(), false ); @@ -136,6 +141,7 @@ public class IrisDatapackCompilerTest { IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile( List.of(), new KList().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().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().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().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().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 + ); + } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisStartupValidationTest.java b/core/src/test/java/art/arcane/iris/core/IrisStartupValidationTest.java index 6877335fa..2edd2a351 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisStartupValidationTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisStartupValidationTest.java @@ -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(); diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java index 8d3295324..74a0a5fb2 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java @@ -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)); } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java new file mode 100644 index 000000000..64673f36a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java @@ -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 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 selected = IrisWorlds.filterBukkitWorldsByStorage(selectedRoot, configuredWorlds); + Map other = IrisWorlds.filterBukkitWorldsByStorage(otherRoot, configuredWorlds); + + assertEquals(Set.of("world", "world_iris_moon"), selected.keySet()); + assertEquals(Set.of("archive_iris_foreign"), other.keySet()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java index b61cad9bc..975779712 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java @@ -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 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 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"); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalServiceTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalServiceTest.java index f61689501..d4c74d759 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalServiceTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalServiceTest.java @@ -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(); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java index 0bd6b454b..96df8a7a3 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -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 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 diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java index e6cac9e62..671020f90 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementFilesystemTest.java @@ -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(); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementSeedTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementSeedTest.java new file mode 100644 index 000000000..9e168acc8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementSeedTest.java @@ -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"); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/AtomicDirectoryPublisherTest.java b/core/src/test/java/art/arcane/iris/core/pack/AtomicDirectoryPublisherTest.java index 7f9a6eedf..2b5bf8fff 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/AtomicDirectoryPublisherTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/AtomicDirectoryPublisherTest.java @@ -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"))); + } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java index b6d7f471f..a96c6ac92 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java @@ -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,