From 12b97b7994d4737bc1e274c9f3f1d1264b147d3e Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 12 Aug 2026 00:42:33 -0400 Subject: [PATCH] Fixes --- README.md | 6 +- .../java/art/arcane/iris/IrisBootstrap.java | 75 ++- .../core/PendingWorldReplacementManager.java | 366 ++++----------- .../iris/core/commands/CommandIris.java | 4 +- .../IrisBootstrapFailureContractTest.java | 65 +++ .../iris/modded/ModdedPackInstaller.java | 24 +- .../art/arcane/iris/modded/ModdedStartup.java | 44 +- .../modded/command/IrisModdedCommands.java | 6 +- .../modded/command/ModdedStudioCommands.java | 4 +- .../modded/command/ModdedWorldCommands.java | 4 +- .../iris/modded/ModdedPackInstallerTest.java | 52 +++ .../ModdedStartupPackSelectionTest.java | 41 ++ core/build.gradle | 2 +- .../iris/core/ExactWorldSlotPathPolicy.java | 28 +- .../art/arcane/iris/core/WorldSlotKey.java | 41 ++ .../core/lifecycle/BukkitStartupPaths.java | 370 +++++++++++++++ .../lifecycle/BukkitWorldConfiguration.java | 61 ++- .../lifecycle/WorldReplacementBootstrap.java | 35 +- .../WorldReplacementBootstrapMarker.java | 16 + .../lifecycle/WorldReplacementFilesystem.java | 126 ++++- .../lifecycle/WorldReplacementJournal.java | 82 +++- .../pack/DefaultPackBootstrapProvisioner.java | 437 +++++++++++------- .../arcane/iris/core/pack/PackDownloader.java | 103 ++++- .../arcane/iris/core/service/StudioSVC.java | 80 +++- .../util/common/misc/ServerProperties.java | 54 +-- core/src/main/resources/languages/de_DE.json | 1 + core/src/main/resources/languages/es_ES.json | 1 + core/src/main/resources/languages/fi_FI.json | 1 + core/src/main/resources/languages/fr_FR.json | 1 + core/src/main/resources/languages/he_IL.json | 1 + core/src/main/resources/languages/it_IT.json | 1 + core/src/main/resources/languages/ja-JP.json | 1 + core/src/main/resources/languages/ko_KR.json | 1 + core/src/main/resources/languages/lt_LT.json | 1 + core/src/main/resources/languages/nl_NL.json | 1 + core/src/main/resources/languages/pl_PL.json | 1 + core/src/main/resources/languages/pt_PT.json | 1 + core/src/main/resources/languages/ru_RU.json | 1 + core/src/main/resources/languages/tr_TR.json | 1 + core/src/main/resources/languages/vi_VI.json | 1 + core/src/main/resources/languages/zh_CN.json | 1 + core/src/main/resources/languages/zh_TW.json | 1 + .../core/ExactWorldSlotPathPolicyTest.java | 45 +- .../lifecycle/BukkitStartupPathsTest.java | 260 +++++++++++ .../BukkitWorldConfigurationTest.java | 51 ++ .../WorldReplacementBootstrapTest.java | 386 ++++++++++++++++ .../WorldReplacementFilesystemTest.java | 104 +++++ .../DefaultPackBootstrapProvisionerTest.java | 298 ++++++++++-- .../iris/core/pack/PackDownloaderTest.java | 139 +++++- .../service/StudioSVCManagedBetaPackTest.java | 78 ++++ docs/01 - Installation & Platforms.md | 24 +- docs/02 - Getting Started.md | 4 +- docs/03 - Configuration.md | 4 +- docs/04 - Commands & Permissions.md | 4 +- docs/05 - Concepts & Pack Layout.md | 2 +- docs/06 - Worlds & Lifecycle.md | 6 +- docs/25 - Pack Management.md | 10 +- docs/27 - Example - Configuring Overworld.md | 2 +- docs/30 - Platform Differences.md | 6 +- docs/31 - Operator Runbooks & Smoke Tests.md | 14 +- docs/87 - Maintainer - Release Readiness.md | 2 +- docs/94 - API - Modded.md | 2 +- 62 files changed, 2878 insertions(+), 706 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackInstallerTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupPackSelectionTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/WorldSlotKey.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/BukkitStartupPaths.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapMarker.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/BukkitStartupPathsTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/StudioSVCManagedBetaPackTest.java diff --git a/README.md b/README.md index c614de600..80556e07d 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,12 @@ content selecting on `#minecraft:is_overworld` and friends. ## Install **Plugin (Paper/Purpur/Leaf/Canvas/Folia/Spigot):** drop the plugin jar into `plugins/` and start -the server. On first boot Iris downloads the default `overworld` pack automatically. +the server. On first boot Iris downloads the managed `overworld` and `underworld` beta packs automatically. **Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is self-contained (core, SPI, and required Fabric API modules are bundled). On first boot Iris -downloads the default `overworld` pack before the worldgen datapack is written, so the default -pack is fully active immediately. Packs installed later register their custom dimension types +prefetches the managed `overworld` and `underworld` beta packs and rebuilds the worldgen datapack; +restart once if startup reports that registry-visible pack data was installed too late for that boot. Packs installed later register their custom dimension types (height ranges) and custom biomes through the forced datapack at server start - restart once after adding a pack so worlds get its full heights and biomes; worlds created before that restart run with fallback heights. diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java index 4e370f922..135675c0e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java @@ -1,9 +1,15 @@ package art.arcane.iris; +import art.arcane.iris.core.lifecycle.BukkitStartupPaths; +import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap; +import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult; import io.papermc.paper.plugin.bootstrap.BootstrapContext; import io.papermc.paper.plugin.bootstrap.PluginBootstrap; +import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent; +import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType; +import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; import java.io.IOException; import java.nio.file.Path; @@ -12,16 +18,75 @@ import java.nio.file.Path; public final class IrisBootstrap implements PluginBootstrap { @Override public void bootstrap(BootstrapContext context) { - ProvisionResult provisioned = provision(context); - Path datapackRoot = provisioned.datapackRoot(); - context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot); + WorldReplacementBootstrapMarker.markBootstrapped(); + try { + BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent(); + reconcilePendingWorldReplacements(context, startupPaths); + ProvisionResult provisioned = provision(context, startupPaths); + Path datapackRoot = provisioned.datapackRoot(); + context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot); + } catch (Throwable failure) { + armStartupFailure(context, failure); + } } - private static ProvisionResult provision(BootstrapContext context) { + private static void reconcilePendingWorldReplacements( + BootstrapContext context, + BukkitStartupPaths startupPaths + ) { + try { + WorldReplacementBootstrap.ReconcileResult result = WorldReplacementBootstrap.reconcile( + context.getDataDirectory(), + startupPaths.levelRoot(), + startupPaths.bukkitConfiguration(), + message -> context.getLogger().info(message) + ); + if (result.transactions() > 0) { + context.getLogger().info( + "Reconciled {} pending Iris world replacement(s): {} published, {} rolled back, {} retained", + result.transactions(), + result.published(), + result.rolledBack(), + result.retained() + ); + } + } catch (IOException failure) { + throw new IllegalStateException( + "Unable to reconcile pending Iris world replacements before registry bootstrap", + failure + ); + } + } + + static void armStartupFailure(BootstrapContext context, Throwable failure) { + armStartupFailure(context, failure, LifecycleEvents.DATAPACK_DISCOVERY); + } + + static void armStartupFailure( + BootstrapContext context, + Throwable failure, + LifecycleEventType eventType + ) { + context.getLifecycleManager().registerEventHandler(eventType, event -> { + throw new IllegalStateException("Iris bootstrap did not establish safe world-generation state.", failure); + }); + try { + context.getLogger().error( + "Iris bootstrap failed; registry and world startup will be stopped at datapack discovery.", + failure + ); + } catch (Throwable loggingFailure) { + failure.addSuppressed(loggingFailure); + failure.printStackTrace(System.err); + } + } + + private static ProvisionResult provision(BootstrapContext context, BukkitStartupPaths startupPaths) { try { return DefaultPackBootstrapProvisioner.provision( context.getDataDirectory(), - message -> context.getLogger().info(message) + message -> context.getLogger().info(message), + startupPaths ); } catch (IOException e) { throw new IllegalStateException("Unable to provision the Iris startup datapack", e); 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 0a0b8541f..dcfc8b5d1 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 @@ -7,6 +7,7 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacem import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap; +import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths; import art.arcane.iris.core.lifecycle.WorldReplacementJournal; @@ -68,7 +69,7 @@ public final class PendingWorldReplacementManager implements Listener { if (worldKey == null) { throw new IllegalArgumentException("World identifier is invalid: " + requestedName); } - ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey); + ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), toWorldSlotKey(worldKey)); return worldKey; } @@ -80,8 +81,12 @@ public final class PendingWorldReplacementManager implements Listener { ) 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(); + if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) { + throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap."); + } PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName()); LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get(); try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire( @@ -89,13 +94,13 @@ public final class PendingWorldReplacementManager implements Listener { LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE, requiredWorldKey.toString() )) { - if (findTransaction(requiredWorldKey) != null) { + if (findTransaction(requiredWorldSlotKey) != null) { throw new IOException("A replacement is already pending for " + requiredWorldKey + "."); } - ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey); + ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldSlotKey); requireCompatibleEnvironment(target.slotKind(), requiredDimension.getEnvironment()); long effectiveSeed = resolveEffectiveSeed(target.slotKind(), seed); - String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldKey); + 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."); @@ -103,7 +108,8 @@ public final class PendingWorldReplacementManager implements Listener { UUID transactionId = UUID.randomUUID(); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); - boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS); + WorldReplacementFilesystem.requireExistingTarget(paths); + boolean targetPresent = true; WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot( ServerProperties.BUKKIT_YML, worldName @@ -127,7 +133,7 @@ public final class PendingWorldReplacementManager implements Listener { String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); transaction = new Transaction( transactionId, - requiredWorldKey, + requiredWorldSlotKey, worldName, target.levelRoot(), installed.getLoadKey(), @@ -163,7 +169,7 @@ public final class PendingWorldReplacementManager implements Listener { } catch (Throwable failure) { if (configurationApplied && transaction != null) { try { - WorldGeneratorSnapshot replacement = replacementSnapshot(transaction); + WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction); if (!BukkitWorldConfiguration.restoreIfMatching( ServerProperties.BUKKIT_YML, worldName, @@ -236,7 +242,7 @@ public final class PendingWorldReplacementManager implements Listener { if (transaction.phase() == Phase.CLEANUP_PENDING) { scheduleCommittedCleanup(transaction); } else if (transaction.phase() == Phase.PUBLISHED) { - WorldIdentity.resolve(transaction.worldKey()) + WorldIdentity.resolve(toNamespacedKey(transaction.worldKey())) .ifPresent(world -> verifyPublishedWorld(world, transaction)); } } @@ -266,7 +272,7 @@ public final class PendingWorldReplacementManager implements Listener { private void verifyPublishedWorld(World world, Transaction transaction) { try { - if (!transaction.worldKey().equals(WorldIdentity.key(world))) { + if (!transaction.worldKey().equals(toWorldSlotKey(WorldIdentity.key(world)))) { throw new IOException("Loaded world identity does not match the replacement journal."); } if (!IrisToolbelt.isIrisWorld(world)) { @@ -295,6 +301,13 @@ public final class PendingWorldReplacementManager implements Listener { if (!transaction.packFingerprint().equals(fingerprint)) { throw new IOException("The replacement pack changed before runtime verification."); } + WorldGeneratorSnapshot configured = BukkitWorldConfiguration.snapshot( + ServerProperties.BUKKIT_YML, + transaction.worldName() + ); + if (!configured.matchesGeneratorAndSeed(WorldReplacementBootstrap.replacementSnapshot(transaction))) { + throw new IOException("bukkit.yml changed before the replacement could be committed."); + } } catch (Throwable failure) { initiateRollback(transaction, failure); return; @@ -317,13 +330,14 @@ public final class PendingWorldReplacementManager implements Listener { try { Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING); writeTransaction(rollback); - ServerConfigurator.restart("An Iris world replacement failed verification and will be rolled back."); } catch (Throwable rollbackFailure) { failure.addSuppressed(rollbackFailure); IrisStartupValidation.markPacksInvalid(List.of( "Iris could not arm rollback for " + transaction.worldKey() + ": " + detail(rollbackFailure))); Iris.reportError("Failed to arm Iris world replacement rollback for " + transaction.worldKey() + ". Stop the server and preserve the replacement artifacts.", rollbackFailure); + } finally { + ServerConfigurator.restart("An Iris world replacement failed verification and requires a cold restart."); } } @@ -348,14 +362,14 @@ public final class PendingWorldReplacementManager implements Listener { throw new RestartBoundaryRequired("The world-storage transaction has not reached its cold bootstrap."); } if (transaction.phase() == Phase.PREPARED) { - if (current.matchesGeneratorAndSeed(replacement)) { - writeTransaction(transaction.withPhase(Phase.ARMED)); - throw new RestartBoundaryRequired("The replacement was armed before its journal phase was durable."); - } else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) { + if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) { WorldReplacementFilesystem.discardStage(paths); deleteJournal(transaction.id()); Iris.warn("Cancelled incomplete Iris world replacement for " + transaction.worldKey() + "."); return; + } else if (current.matchesGeneratorAndSeed(replacement)) { + writeTransaction(transaction.withPhase(Phase.ARMED)); + throw new RestartBoundaryRequired("The replacement was armed before its journal phase was durable."); } else { throw new IOException("bukkit.yml does not match the prepared replacement or its original state."); } @@ -376,16 +390,38 @@ public final class PendingWorldReplacementManager implements Listener { if (!cleanupInFlight.add(transaction.id())) { return; } - J.a(() -> cleanupCommittedReplacement(transaction)); + try { + J.a(() -> cleanupCommittedReplacement(transaction)); + } catch (Throwable failure) { + cleanupInFlight.remove(transaction.id()); + Iris.reportError("Could not schedule retained-backup cleanup for " + transaction.worldKey() + + "; cleanup will retry without rolling back the verified world.", failure); + } } private void cleanupCommittedReplacement(Transaction transaction) { try { - ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction); - ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id()); - WorldReplacementFilesystem.cleanupBackup(paths); - deleteJournal(transaction.id()); - Iris.success("Removed the retained backup for " + transaction.worldKey() + "."); + synchronized (this) { + Transaction current = findTransaction(transaction.worldKey()); + if (current == null + || !current.id().equals(transaction.id()) + || current.phase() != Phase.CLEANUP_PENDING) { + return; + } + WorldGeneratorSnapshot configured = BukkitWorldConfiguration.snapshot( + ServerProperties.BUKKIT_YML, + current.worldName() + ); + if (!configured.matchesGeneratorAndSeed(WorldReplacementBootstrap.replacementSnapshot(current))) { + throw new IOException("bukkit.yml changed before the retained backup could be removed."); + } + ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(current); + ReplacementPaths paths = WorldReplacementFilesystem.paths(target, current.id()); + WorldReplacementFilesystem.validateCommittedTarget(paths, current.packFingerprint()); + WorldReplacementFilesystem.cleanupBackup(paths); + deleteJournal(current.id()); + Iris.success("Removed the retained backup for " + current.worldKey() + "."); + } } catch (Throwable failure) { Iris.reportError("Could not clean the retained backup for " + transaction.worldKey() + "; cleanup will retry without rolling back the verified world.", failure); @@ -396,7 +432,7 @@ public final class PendingWorldReplacementManager implements Listener { } } - private ExactWorldSlotPathPolicy.Target prepareTarget(NamespacedKey worldKey) throws IOException { + 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"); @@ -416,6 +452,10 @@ public final class PendingWorldReplacementManager implements Listener { } private Transaction findTransaction(NamespacedKey worldKey) throws IOException { + return findTransaction(toWorldSlotKey(worldKey)); + } + + private Transaction findTransaction(WorldSlotKey worldKey) throws IOException { for (Transaction transaction : loadTransactions()) { if (transaction.worldKey().equals(worldKey)) { return transaction; @@ -425,159 +465,23 @@ public final class PendingWorldReplacementManager implements Listener { } private List loadTransactions() throws IOException { - Path directory = journalDirectory(false); - if (directory == null) { - return List.of(); - } - ArrayList transactions = new ArrayList<>(); - try (DirectoryStream files = Files.newDirectoryStream(directory, "*" + JOURNAL_SUFFIX)) { - for (Path file : files) { - if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("Replacement journal entry is unsafe: " + file); - } - transactions.add(readTransaction(file)); - } - } - transactions.sort(Comparator.comparing(transaction -> transaction.id().toString())); - return List.copyOf(transactions); - } - - private Transaction readTransaction(Path file) throws IOException { - Properties properties = new Properties(); - try (InputStream input = Files.newInputStream(file)) { - properties.load(input); - } - UUID id = UUID.fromString(required(properties, "id")); - if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) { - throw new IOException("Replacement journal filename does not match its transaction id."); - } - NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey")); - if (worldKey == null) { - throw new IOException("Replacement journal contains an invalid world key."); - } - ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey); - String dimension = required(properties, "dimension"); - if (!safeDimension(dimension)) { - throw new IOException("Replacement journal contains an invalid dimension key."); - } - long seed = parseLong(properties, "seed"); - String packFingerprint = required(properties, "packFingerprint"); - if (!packFingerprint.matches("[0-9a-f]{64}")) { - throw new IOException("Replacement journal contains an invalid pack fingerprint."); - } - WorldGeneratorSnapshot original = readSnapshot(properties, "original."); - boolean originalTargetPresent = parseBoolean(properties, "originalTargetPresent"); - Phase phase; - try { - phase = Phase.valueOf(required(properties, "phase")); - } catch (IllegalArgumentException failure) { - throw new IOException("Replacement journal contains an invalid phase.", failure); - } - return new Transaction( - id, - worldKey, - dimension, - seed, - packFingerprint, - original, - originalTargetPresent, - phase - ); + return WorldReplacementJournal.load(dataDirectory(), IrisWorldStorage.levelRoot().toPath()); } private void writeTransaction(Transaction transaction) throws IOException { - Path directory = Objects.requireNonNull(journalDirectory(true)); - Path target = directory.resolve(transaction.id() + JOURNAL_SUFFIX); - Properties properties = new Properties(); - properties.setProperty("id", transaction.id().toString()); - properties.setProperty("worldKey", transaction.worldKey().toString()); - properties.setProperty("dimension", transaction.dimension()); - properties.setProperty("seed", Long.toString(transaction.seed())); - properties.setProperty("packFingerprint", transaction.packFingerprint()); - properties.setProperty("originalTargetPresent", Boolean.toString(transaction.originalTargetPresent())); - properties.setProperty("phase", transaction.phase().name()); - writeSnapshot(properties, "original.", transaction.originalConfiguration()); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - properties.store(output, null); - writeAtomic(target, output.toByteArray()); + WorldReplacementJournal.write(dataDirectory(), transaction); } private void deleteJournal(UUID id) throws IOException { - Path directory = journalDirectory(false); - if (directory == null) { - return; - } - Files.deleteIfExists(directory.resolve(id + JOURNAL_SUFFIX)); - forceDirectory(directory); + WorldReplacementJournal.delete(dataDirectory(), id); } - private Path journalDirectory(boolean create) throws IOException { - Path directory = plugin.getDataFile(JOURNAL_DIRECTORY).toPath().toAbsolutePath().normalize(); - if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { - if (!create) { - return null; - } - Files.createDirectories(directory); - } - if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("Replacement journal storage is unsafe: " + directory); - } - return directory; + private Path dataDirectory() { + return plugin.getDataFolder().toPath().toAbsolutePath().normalize(); } - private static void writeAtomic(Path target, byte[] content) throws IOException { - Path parent = Objects.requireNonNull(target.getParent(), "journal parent"); - Path temporary = parent.resolve("." + target.getFileName() + ".tmp-" + UUID.randomUUID()); - try { - try (FileChannel channel = FileChannel.open( - temporary, - StandardOpenOption.CREATE_NEW, - StandardOpenOption.WRITE - )) { - ByteBuffer buffer = ByteBuffer.wrap(content); - while (buffer.hasRemaining()) { - channel.write(buffer); - } - channel.force(true); - } - try { - Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException failure) { - Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); - } - forceDirectory(parent); - } finally { - Files.deleteIfExists(temporary); - } - } - - private static void forceDirectory(Path directory) throws IOException { - try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); - } - } - - private static ReplacementPaths replacementPaths( - ExactWorldSlotPathPolicy.Target target, - UUID id - ) { - String artifactBase = ".iris-replace-" + target.worldKey().getKey() + "-" + id; - return new ReplacementPaths( - target.worldDirectory(), - target.namespaceRoot().resolve(artifactBase + ".stage"), - target.namespaceRoot().resolve(artifactBase + ".backup") - ); - } - - private static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) { - return new WorldGeneratorSnapshot( - true, - true, - true, - "Iris:" + transaction.dimension(), - true, - transaction.seed() - ); + private ExactWorldSlotPathPolicy.Target resolveTransactionTarget(Transaction transaction) throws IOException { + return WorldReplacementJournal.resolveTarget(transaction, IrisWorldStorage.levelRoot().toPath()); } private static boolean configurationMatches(WorldGeneratorSnapshot expected, String worldName) { @@ -633,91 +537,27 @@ public final class PendingWorldReplacementManager implements Listener { } } - private static World.Environment expectedEnvironment(NamespacedKey worldKey) { - if (NamespacedKey.minecraft("overworld").equals(worldKey)) { + private static World.Environment expectedEnvironment(WorldSlotKey worldKey) { + if (WorldSlotKey.minecraft("overworld").equals(worldKey)) { return World.Environment.NORMAL; } - if (NamespacedKey.minecraft("the_nether").equals(worldKey)) { + if (WorldSlotKey.minecraft("the_nether").equals(worldKey)) { return World.Environment.NETHER; } - if (NamespacedKey.minecraft("the_end").equals(worldKey)) { + if (WorldSlotKey.minecraft("the_end").equals(worldKey)) { return World.Environment.THE_END; } return null; } - private static void writeSnapshot(Properties properties, String prefix, WorldGeneratorSnapshot snapshot) { - properties.setProperty(prefix + "worldsSectionPresent", Boolean.toString(snapshot.worldsSectionPresent())); - properties.setProperty(prefix + "worldSectionPresent", Boolean.toString(snapshot.worldSectionPresent())); - properties.setProperty(prefix + "generatorPresent", Boolean.toString(snapshot.generatorPresent())); - if (snapshot.generatorPresent()) { - properties.setProperty(prefix + "generator", snapshot.generator()); - } - properties.setProperty(prefix + "seedPresent", Boolean.toString(snapshot.seedPresent())); - if (snapshot.seedPresent()) { - properties.setProperty(prefix + "seed", Long.toString(snapshot.seed())); - } + private static WorldSlotKey toWorldSlotKey(NamespacedKey worldKey) { + NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); + return new WorldSlotKey(requiredWorldKey.getNamespace(), requiredWorldKey.getKey()); } - private static WorldGeneratorSnapshot readSnapshot(Properties properties, String prefix) throws IOException { - boolean worldsPresent = parseBoolean(properties, prefix + "worldsSectionPresent"); - boolean worldPresent = parseBoolean(properties, prefix + "worldSectionPresent"); - boolean generatorPresent = parseBoolean(properties, prefix + "generatorPresent"); - String generator = generatorPresent ? required(properties, prefix + "generator") : null; - boolean seedPresent = parseBoolean(properties, prefix + "seedPresent"); - Long seed = seedPresent ? parseLong(properties, prefix + "seed") : null; - try { - return new WorldGeneratorSnapshot( - worldsPresent, - worldPresent, - generatorPresent, - generator, - seedPresent, - seed - ); - } catch (IllegalArgumentException failure) { - throw new IOException("Replacement journal contains an invalid configuration snapshot.", failure); - } - } - - private static boolean safeDimension(String value) { - if (value.isEmpty() || value.length() > 256 || value.startsWith(".") || value.contains("..")) { - return false; - } - String[] segments = value.split("/", -1); - if (segments.length > 16) { - return false; - } - for (String segment : segments) { - if (segment.isEmpty() || !segment.matches("[A-Za-z0-9_-]+")) { - return false; - } - } - return true; - } - - private static String required(Properties properties, String key) throws IOException { - String value = properties.getProperty(key); - if (value == null || value.isBlank()) { - throw new IOException("Replacement journal is missing " + key + "."); - } - return value.trim(); - } - - private static boolean parseBoolean(Properties properties, String key) throws IOException { - String value = required(properties, key); - if (!"true".equals(value) && !"false".equals(value)) { - throw new IOException("Replacement journal contains an invalid boolean for " + key + "."); - } - return Boolean.parseBoolean(value); - } - - private static long parseLong(Properties properties, String key) throws IOException { - try { - return Long.parseLong(required(properties, key)); - } catch (NumberFormatException failure) { - throw new IOException("Replacement journal contains an invalid integer for " + key + ".", failure); - } + private static NamespacedKey toNamespacedKey(WorldSlotKey worldKey) { + WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); + return new NamespacedKey(requiredWorldKey.namespace(), requiredWorldKey.key()); } private static String detail(Throwable failure) { @@ -740,50 +580,12 @@ public final class PendingWorldReplacementManager implements Listener { } } - private record Transaction( - UUID id, - NamespacedKey worldKey, - String dimension, - long seed, - String packFingerprint, - WorldGeneratorSnapshot originalConfiguration, - boolean originalTargetPresent, - Phase phase - ) { - private Transaction { - Objects.requireNonNull(id, "id"); - Objects.requireNonNull(worldKey, "worldKey"); - Objects.requireNonNull(dimension, "dimension"); - Objects.requireNonNull(packFingerprint, "packFingerprint"); - Objects.requireNonNull(originalConfiguration, "originalConfiguration"); - Objects.requireNonNull(phase, "phase"); - } - - private String worldName() { - return IrisWorldStorage.logicalName(worldKey); - } - - private Transaction withPhase(Phase nextPhase) { - return new Transaction( - id, - worldKey, - dimension, - seed, - packFingerprint, - originalConfiguration, - originalTargetPresent, - nextPhase - ); - } - } - - private enum Phase { - PREPARED, - ARMED, - PUBLISHED, - ROLLBACK_PENDING - } - private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) { } + + private static final class RestartBoundaryRequired extends IOException { + private RestartBoundaryRequired(String message) { + super(message); + } + } } 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 dd662cb6e..c9ea114dc 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 @@ -710,9 +710,9 @@ public class CommandIris implements DirectorExecutor { @Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", descriptionKey = "iris.director.commandiris.param.whether_not_overwrite_pack_with_downloaded_one", aliases = "force", defaultValue = "false") boolean overwrite ) { - if (PackDownloader.isDefaultOverworld(pack)) { + if (PackDownloader.isManagedBetaPack(pack)) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK_BETA_RELEASE, MessageArgument.untrusted("pack", pack), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : ""))); - Iris.service(StudioSVC.class).downloadDefaultOverworld(sender(), overwrite); + Iris.service(StudioSVC.class).downloadManagedBeta(sender(), pack, overwrite); } else { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("branch", branch), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : ""))); Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java new file mode 100644 index 000000000..ea0e6a038 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapFailureContractTest.java @@ -0,0 +1,65 @@ +package art.arcane.iris; + +import io.papermc.paper.plugin.bootstrap.BootstrapContext; +import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent; +import io.papermc.paper.plugin.lifecycle.event.LifecycleEventManager; +import io.papermc.paper.plugin.lifecycle.event.handler.LifecycleEventHandler; +import io.papermc.paper.plugin.lifecycle.event.handler.configuration.LifecycleEventHandlerConfiguration; +import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEventType; +import net.kyori.adventure.text.logger.slf4j.ComponentLogger; +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBootstrapFailureContractTest { + @Test + public void bootstrapFailureAbortsDatapackDiscoveryInsteadOfDisablingOnlyIris() { + BootstrapContext context = mock(BootstrapContext.class); + ComponentLogger logger = mock(ComponentLogger.class); + CapturingLifecycleManager manager = new CapturingLifecycleManager(); + IllegalStateException cause = new IllegalStateException("unsafe transaction"); + LifecycleEventType eventType = lifecycleEventType(); + when(context.getLogger()).thenReturn(logger); + when(context.getLifecycleManager()).thenReturn(manager); + + IrisBootstrap.armStartupFailure(context, cause, eventType); + + assertSame(eventType, manager.eventType); + IllegalStateException failure = assertThrows(IllegalStateException.class, manager::runCapturedHandler); + assertSame(cause, failure.getCause()); + } + + @SuppressWarnings("unchecked") + private static LifecycleEventType lifecycleEventType() { + return (LifecycleEventType) mock(LifecycleEventType.class); + } + + private static final class CapturingLifecycleManager implements LifecycleEventManager { + private LifecycleEventType eventType; + private LifecycleEventHandler handler; + + @Override + public void registerEventHandler( + LifecycleEventType eventType, + LifecycleEventHandler eventHandler + ) { + this.eventType = eventType; + this.handler = eventHandler; + } + + @Override + public void registerEventHandler( + LifecycleEventHandlerConfiguration handlerConfiguration + ) { + throw new AssertionError("The direct handler overload must be used."); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void runCapturedHandler() { + ((LifecycleEventHandler) handler).run(null); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java index 567462c3c..541245152 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java @@ -42,12 +42,14 @@ public final class ModdedPackInstaller { } public static boolean install(Path configDir, String pack, String branch, - boolean forceOverwrite, Consumer feedback) { + boolean forceOverwrite, boolean refreshDatapack, + Consumer feedback) { if (pack == null || !PACK_NAME.matcher(pack).matches()) { feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_PACK_NAME, MessageArgument.untrusted("pack", String.valueOf(pack)))); return false; } - if (branch == null || !BRANCH_NAME.matcher(branch).matches()) { + boolean managedBeta = PackDownloader.isManagedBetaPack(pack); + if (!acceptsBranch(pack, branch)) { feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_BRANCH_NAME, MessageArgument.untrusted("branch", String.valueOf(branch)))); return false; } @@ -56,8 +58,8 @@ public final class ModdedPackInstaller { synchronized (installLock) { File packs = configDir.resolve("irisworldgen").resolve("packs").toFile(); try { - PackDownloader.PackInstallResult result = PackDownloader.isDefaultOverworld(pack) - ? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) + PackDownloader.PackInstallResult result = managedBeta + ? PackDownloader.downloadManagedBeta(packs, pack, forceOverwrite, feedback) : PackDownloader.download( packs, "IrisDimensions/" + pack, @@ -67,7 +69,7 @@ public final class ModdedPackInstaller { pack, feedback); boolean installed = result != null; - if (result != null && result.changed()) { + if (shouldRefreshDatapack(result, refreshDatapack)) { // Pack-install completion is one of the four forced-datapack regeneration triggers; every // install call site already runs off the server thread, so regenerate inline here. A // regeneration failure must never turn a successful install into a failed one. @@ -82,7 +84,8 @@ public final class ModdedPackInstaller { } return installed; } catch (IOException error) { - LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error); + String source = managedBeta ? "beta release" : "branch " + branch; + LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, source, error); feedback.accept(IrisLanguage.plain( PackDownloadMessages.DOWNLOAD_FAILED, MessageArgument.untrusted("type", error.getClass().getSimpleName()), @@ -92,4 +95,13 @@ public final class ModdedPackInstaller { } } } + + static boolean acceptsBranch(String pack, String branch) { + return PackDownloader.isManagedBetaPack(pack) + || (branch != null && BRANCH_NAME.matcher(branch).matches()); + } + + static boolean shouldRefreshDatapack(PackDownloader.PackInstallResult result, boolean refreshDatapack) { + return result != null && result.changed() && refreshDatapack; + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 25f4520e9..1209b7680 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -37,6 +37,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -253,20 +254,39 @@ public final class ModdedStartup { if (!config.autoDownloadDefaultPack()) { return; } - String pack = config.defaultPack(); Path configDir = ModdedEngineBootstrap.loader().configDir(); - File packFolder = new File(ModdedPackCommands.packsRoot(), pack); - if (new File(packFolder, "dimensions/" + pack + ".json").isFile()) { - return; - } - String source = "master branch"; - LOGGER.info("Iris default pack '{}' missing; downloading IrisDimensions/{} ({})", pack, pack, source); - boolean installed = ModdedPackInstaller.install( - configDir, pack, "master", false, - (String line) -> LOGGER.info("Iris: {}", line)); - if (!installed) { - LOGGER.warn("Iris default pack '{}' could not be downloaded; install it with /iris download {}", pack, pack); + File packsRoot = ModdedPackCommands.packsRoot(); + for (String pack : startupPacks(config.defaultPack())) { + ensureStartupPack(configDir, packsRoot, pack); } } } + + static List startupPacks(String configuredDefault) { + LinkedHashSet packs = new LinkedHashSet<>(PackDownloader.managedBetaPacks()); + if (configuredDefault != null && !configuredDefault.isBlank()) { + packs.add(configuredDefault); + } + return List.copyOf(packs); + } + + private static void ensureStartupPack(Path configDir, File packsRoot, String pack) { + boolean present = PackDownloader.isManagedBetaPack(pack) + ? PackDownloader.isManagedBetaPackPresent(packsRoot, pack) + : PackDownloader.isPackPresent(packsRoot, pack); + if (present) { + return; + } + boolean managedBeta = PackDownloader.isManagedBetaPack(pack); + String branch = managedBeta ? "beta" : "master"; + String source = managedBeta ? "beta release" : "master branch"; + String role = managedBeta ? "managed beta pack" : "default pack"; + LOGGER.info("Iris {} '{}' missing; downloading IrisDimensions/{} ({})", role, pack, pack, source); + boolean installed = ModdedPackInstaller.install( + configDir, pack, branch, false, false, + (String line) -> LOGGER.info("Iris: {}", line)); + if (!installed) { + LOGGER.warn("Iris {} '{}' could not be downloaded; install it with /iris download {}", role, pack, pack); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 35a5a6a69..f497ce7ec 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -258,8 +258,8 @@ public final class IrisModdedCommands { static int download(CommandSourceStack source, String pack, String branch, boolean forceOverwrite) { - boolean defaultOverworld = PackDownloader.isDefaultOverworld(pack); - String baseDownloadSource = defaultOverworld ? "beta release" : "branch " + branch; + boolean managedBeta = PackDownloader.isManagedBetaPack(pack); + String baseDownloadSource = managedBeta ? "beta release" : "branch " + branch; String downloadSource = forceOverwrite ? baseDownloadSource + IrisLanguage.plain(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : baseDownloadSource; @@ -274,7 +274,7 @@ public final class IrisModdedCommands { } scheduler.async(() -> { boolean installed = ModdedPackInstaller.install( - ModdedEngineBootstrap.loader().configDir(), pack, branch, forceOverwrite, + ModdedEngineBootstrap.loader().configDir(), pack, branch, forceOverwrite, true, (String message) -> scheduler.global(() -> ok(source, message))); if (installed) { scheduler.global(() -> ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_INSTALLED_ITS_EXACT_DIMENSION_TYPES_CUSTOM_BIOMES_JOIN_FORCED, MessageArgument.untrusted("pack", pack)))); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index d57a69e29..8f9bccf8f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -383,7 +383,7 @@ public final class ModdedStudioCommands { File packFolder = new File(ModdedPackCommands.packsRoot(), pack); if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) { server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_MISSING_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)))); - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true, (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); if (!installed || !new File(packFolder, "dimensions/" + pack + ".json").isFile()) { server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_COULD_NOT_BE_DOWNLOADED_CHECK_NAME_TRY_IRIS_DOWNLOAD, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)))); @@ -588,7 +588,7 @@ public final class ModdedStudioCommands { File templateFolder = new File(packsRoot, template); if (!new File(templateFolder, "dimensions/" + template + ".json").isFile()) { server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template)))); - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false, true, (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); if (!installed || !new File(templateFolder, "dimensions/" + template + ".json").isFile()) { server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_COULD_NOT_BE_DOWNLOADED_INSTALL_PACK_WITH_DIMENSIONS_JSON, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template)))); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java index 4be4d8d5d..95ca1ee20 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java @@ -177,7 +177,7 @@ public final class ModdedWorldCommands { } IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))); Thread thread = new Thread(() -> { - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true, (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); server.execute(() -> { if (!installed || !packFolder.isDirectory()) { @@ -280,7 +280,7 @@ public final class ModdedWorldCommands { } IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS_2, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))); Thread thread = new Thread(() -> { - boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, + boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true, (String line) -> server.execute(() -> IrisModdedCommands.ok(source, line))); server.execute(() -> { if (!installed || !packFolder.isDirectory()) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackInstallerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackInstallerTest.java new file mode 100644 index 000000000..42bd09fd6 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackInstallerTest.java @@ -0,0 +1,52 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import art.arcane.iris.core.pack.PackDownloader; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedPackInstallerTest { + @Test + public void managedBetaPacksIgnoreTheRequestedBranch() { + assertTrue(ModdedPackInstaller.acceptsBranch("overworld", null)); + assertTrue(ModdedPackInstaller.acceptsBranch("underworld", "feature/arbitrary")); + } + + @Test + public void nonManagedPacksStillRequireAValidBranch() { + assertTrue(ModdedPackInstaller.acceptsBranch("custom", "stable")); + assertFalse(ModdedPackInstaller.acceptsBranch("custom", null)); + assertFalse(ModdedPackInstaller.acceptsBranch("custom", "feature/arbitrary")); + } + + @Test + public void startupBatchDefersDatapackRefresh() { + PackDownloader.PackInstallResult changed = new PackDownloader.PackInstallResult("underworld", true, true); + + assertFalse(ModdedPackInstaller.shouldRefreshDatapack(changed, false)); + assertTrue(ModdedPackInstaller.shouldRefreshDatapack(changed, true)); + assertFalse(ModdedPackInstaller.shouldRefreshDatapack( + new PackDownloader.PackInstallResult("underworld", false, false), + true + )); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupPackSelectionTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupPackSelectionTest.java new file mode 100644 index 000000000..867cae830 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupPackSelectionTest.java @@ -0,0 +1,41 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class ModdedStartupPackSelectionTest { + @Test + public void managedBetaPacksAreAlwaysInstalledInStableOrder() { + assertEquals(List.of("overworld", "underworld"), ModdedStartup.startupPacks("overworld")); + assertEquals(List.of("overworld", "underworld"), ModdedStartup.startupPacks("underworld")); + } + + @Test + public void configuredNonManagedDefaultIsInstalledAfterManagedBetaPacks() { + assertEquals( + List.of("overworld", "underworld", "custom"), + ModdedStartup.startupPacks("custom") + ); + } +} diff --git a/core/build.gradle b/core/build.gradle index 8fc13222c..ec73dcbb6 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -228,7 +228,7 @@ tasks.named('compileJava', JavaCompile).configure { tasks.named('test', Test).configure { jvmArgs('--add-modules', 'jdk.incubator.vector') - classpath += files('src/main/resources') + classpath = files('src/main/resources') + classpath } configurations.matching { it.name.startsWith('slim') }.all { } diff --git a/core/src/main/java/art/arcane/iris/core/ExactWorldSlotPathPolicy.java b/core/src/main/java/art/arcane/iris/core/ExactWorldSlotPathPolicy.java index 609beb10f..bf1c7a374 100644 --- a/core/src/main/java/art/arcane/iris/core/ExactWorldSlotPathPolicy.java +++ b/core/src/main/java/art/arcane/iris/core/ExactWorldSlotPathPolicy.java @@ -1,7 +1,5 @@ package art.arcane.iris.core; -import org.bukkit.NamespacedKey; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.LinkOption; @@ -17,13 +15,13 @@ public final class ExactWorldSlotPathPolicy { private ExactWorldSlotPathPolicy() { } - public static Target resolve(Path levelRoot, NamespacedKey worldKey) { - NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); + public static Target resolve(Path levelRoot, WorldSlotKey worldKey) { + WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); Path canonicalLevelRoot = canonicalLevelRoot(levelRoot); SlotKind slotKind = classify(requiredWorldKey); Path dimensionsRoot = canonicalLevelRoot.resolve("dimensions"); - Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.getNamespace()); - Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.getKey()).normalize(); + Path namespaceRoot = dimensionsRoot.resolve(requiredWorldKey.namespace()); + Path worldDirectory = namespaceRoot.resolve(requiredWorldKey.key()).normalize(); if (!Objects.equals(worldDirectory.getParent(), namespaceRoot)) { throw new Rejection( RejectionReason.PATH_TRAVERSAL, @@ -37,7 +35,7 @@ public final class ExactWorldSlotPathPolicy { return new Target(requiredWorldKey, slotKind, canonicalLevelRoot, namespaceRoot, worldDirectory); } - public static Target validate(Path levelRoot, NamespacedKey worldKey, Path candidate) { + public static Target validate(Path levelRoot, WorldSlotKey worldKey, Path candidate) { Path requiredCandidate = Objects.requireNonNull(candidate, "candidate"); rejectTraversal(requiredCandidate, "World candidate"); Target target = resolve(levelRoot, worldKey); @@ -71,9 +69,9 @@ public final class ExactWorldSlotPathPolicy { } } - private static SlotKind classify(NamespacedKey worldKey) { - if ("iris".equals(worldKey.getNamespace())) { - if (!SAFE_IRIS_KEY.matcher(worldKey.getKey()).matches()) { + private static SlotKind classify(WorldSlotKey worldKey) { + if ("iris".equals(worldKey.namespace())) { + if (!SAFE_IRIS_KEY.matcher(worldKey.key()).matches()) { throw new Rejection( RejectionReason.INVALID_IRIS_KEY, "Iris world keys must be safe single path segments." @@ -81,13 +79,13 @@ public final class ExactWorldSlotPathPolicy { } return SlotKind.IRIS_MANAGED; } - if (!NamespacedKey.MINECRAFT.equals(worldKey.getNamespace())) { + if (!"minecraft".equals(worldKey.namespace())) { throw new Rejection( RejectionReason.FOREIGN_NAMESPACE, "Only Iris-managed and exact vanilla dimension slots can be replaced." ); } - return switch (worldKey.getKey()) { + return switch (worldKey.key()) { case "overworld" -> SlotKind.VANILLA_OVERWORLD; case "the_nether" -> SlotKind.VANILLA_NETHER; case "the_end" -> SlotKind.VANILLA_END; @@ -154,7 +152,7 @@ public final class ExactWorldSlotPathPolicy { } public record Target( - NamespacedKey worldKey, + WorldSlotKey worldKey, SlotKind slotKind, Path levelRoot, Path namespaceRoot, @@ -167,8 +165,8 @@ public final class ExactWorldSlotPathPolicy { namespaceRoot = Objects.requireNonNull(namespaceRoot, "namespaceRoot").toAbsolutePath().normalize(); worldDirectory = Objects.requireNonNull(worldDirectory, "worldDirectory").toAbsolutePath().normalize(); SlotKind expectedSlotKind = classify(worldKey); - Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.getNamespace()); - Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.getKey()); + Path expectedNamespaceRoot = levelRoot.resolve("dimensions").resolve(worldKey.namespace()); + Path expectedWorldDirectory = expectedNamespaceRoot.resolve(worldKey.key()); if (slotKind != expectedSlotKind || !namespaceRoot.equals(expectedNamespaceRoot) || !worldDirectory.equals(expectedWorldDirectory)) { diff --git a/core/src/main/java/art/arcane/iris/core/WorldSlotKey.java b/core/src/main/java/art/arcane/iris/core/WorldSlotKey.java new file mode 100644 index 000000000..37f194afb --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/WorldSlotKey.java @@ -0,0 +1,41 @@ +package art.arcane.iris.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +public record WorldSlotKey(String namespace, String key) { + private static final Pattern KEY_PATTERN = Pattern.compile("^[a-z0-9/._-]+$"); + private static final Pattern NAMESPACE_PATTERN = Pattern.compile("^[a-z0-9._-]+$"); + + public WorldSlotKey { + namespace = Objects.requireNonNull(namespace, "namespace"); + key = Objects.requireNonNull(key, "key"); + if (!NAMESPACE_PATTERN.matcher(namespace).matches()) { + throw new IllegalArgumentException("World slot namespace is invalid: " + namespace); + } + if (!KEY_PATTERN.matcher(key).matches()) { + throw new IllegalArgumentException("World slot key is invalid: " + key); + } + } + + public static WorldSlotKey parse(String value) { + String requiredValue = Objects.requireNonNull(value, "value"); + int separator = requiredValue.indexOf(':'); + if (separator <= 0 || separator != requiredValue.lastIndexOf(':') || separator == requiredValue.length() - 1) { + throw new IllegalArgumentException("World slot key must use namespace:key syntax."); + } + return new WorldSlotKey( + requiredValue.substring(0, separator), + requiredValue.substring(separator + 1) + ); + } + + public static WorldSlotKey minecraft(String key) { + return new WorldSlotKey("minecraft", key); + } + + @Override + public String toString() { + return namespace + ":" + key; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitStartupPaths.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitStartupPaths.java new file mode 100644 index 000000000..462c3d870 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitStartupPaths.java @@ -0,0 +1,370 @@ +package art.arcane.iris.core.lifecycle; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; + +public final class BukkitStartupPaths { + private static final String DEFAULT_BUKKIT_CONFIGURATION = "bukkit.yml"; + private static final String DEFAULT_LEVEL_NAME = "world"; + private static final String DEFAULT_SERVER_PROPERTIES = "server.properties"; + private static final String DEFAULT_WORLD_CONTAINER = "."; + + private final Path bukkitConfiguration; + private final Path levelRoot; + private final String levelName; + private final Path serverProperties; + private final Path serverRoot; + private final Path worldContainer; + + private BukkitStartupPaths( + Path serverRoot, + Path serverProperties, + Path bukkitConfiguration, + Path worldContainer, + String levelName, + Path levelRoot + ) { + this.serverRoot = Objects.requireNonNull(serverRoot, "serverRoot"); + this.serverProperties = Objects.requireNonNull(serverProperties, "serverProperties"); + this.bukkitConfiguration = Objects.requireNonNull(bukkitConfiguration, "bukkitConfiguration"); + this.worldContainer = Objects.requireNonNull(worldContainer, "worldContainer"); + this.levelName = Objects.requireNonNull(levelName, "levelName"); + this.levelRoot = Objects.requireNonNull(levelRoot, "levelRoot"); + } + + public static BukkitStartupPaths resolveCurrent() throws IOException { + return resolve(Path.of(""), currentArguments()); + } + + public static BukkitStartupPaths resolve(Path serverWorkingDirectory) throws IOException { + return resolve(serverWorkingDirectory, currentArguments()); + } + + public static BukkitStartupPaths resolve(Path serverWorkingDirectory, String[] processArguments) + throws IOException { + Path serverRoot = canonicalDirectory( + Objects.requireNonNull(serverWorkingDirectory, "serverWorkingDirectory").toAbsolutePath().normalize(), + "Server working directory" + ); + StartupArguments arguments = parseArguments(processArguments); + Path serverProperties = resolveAgainstServerRoot( + serverRoot, + arguments.serverProperties(), + DEFAULT_SERVER_PROPERTIES, + "server properties" + ); + Path bukkitConfiguration = resolveAgainstServerRoot( + serverRoot, + arguments.bukkitConfiguration(), + DEFAULT_BUKKIT_CONFIGURATION, + "Bukkit configuration" + ); + String levelName = effectiveLevelName(serverProperties, arguments.levelName()); + Path worldContainer = canonicalPotentialDirectory( + resolveAgainstServerRoot( + serverRoot, + arguments.worldContainer() == null + ? BukkitWorldConfiguration.readWorldContainer(bukkitConfiguration.toFile()) + : arguments.worldContainer(), + DEFAULT_WORLD_CONTAINER, + "world container" + ), + "Configured world container" + ); + Path configuredLevel = parsePath(levelName, "level name"); + Path levelRoot = canonicalPotentialDirectory( + configuredLevel.isAbsolute() + ? configuredLevel + : worldContainer.resolve(configuredLevel), + "Configured level root" + ); + return new BukkitStartupPaths( + serverRoot, + serverProperties, + bukkitConfiguration, + worldContainer, + levelName, + levelRoot + ); + } + + public Path bukkitConfiguration() { + return bukkitConfiguration; + } + + public Path levelRoot() { + return levelRoot; + } + + public String levelName() { + return levelName; + } + + public Path serverProperties() { + return serverProperties; + } + + public Path serverRoot() { + return serverRoot; + } + + public Path worldContainer() { + return worldContainer; + } + + private static String[] currentArguments() { + return applicationArguments(ProcessHandle.current().info().arguments().orElse(new String[0])); + } + + static String[] applicationArguments(String[] processArguments) { + String[] arguments = Objects.requireNonNull(processArguments, "processArguments"); + for (int index = 0; index < arguments.length; index++) { + if (arguments[index].equals("-jar") + || arguments[index].equals("-m") + || arguments[index].equals("--module")) { + int applicationStart = Math.min(arguments.length, index + 2); + return Arrays.copyOfRange(arguments, applicationStart, arguments.length); + } + } + Set optionsWithValues = Set.of( + "-cp", + "-classpath", + "--class-path", + "-p", + "--module-path", + "--upgrade-module-path", + "--add-modules", + "--enable-native-access", + "--limit-modules", + "--add-exports", + "--add-opens", + "--add-reads", + "--patch-module", + "--source" + ); + for (int index = 0; index < arguments.length; index++) { + String argument = arguments[index]; + if (optionsWithValues.contains(argument)) { + index++; + continue; + } + if (argument.startsWith("-") || argument.startsWith("@")) { + continue; + } + return Arrays.copyOfRange(arguments, index + 1, arguments.length); + } + return new String[0]; + } + + private static StartupArguments parseArguments(String[] processArguments) throws IOException { + String[] arguments = Objects.requireNonNull(processArguments, "processArguments"); + String serverProperties = null; + String bukkitConfiguration = null; + String levelName = null; + String worldContainer = null; + for (int index = 0; index < arguments.length; index++) { + String argument = Objects.requireNonNull(arguments[index], "process argument"); + if (argument.equals("--")) { + break; + } + ParsedArgument parsed = parseArgument( + argument, + index + 1 < arguments.length ? arguments[index + 1] : null + ); + if (parsed == null) { + continue; + } + switch (parsed.kind()) { + case SERVER_PROPERTIES -> serverProperties = parsed.value(); + case BUKKIT_CONFIGURATION -> bukkitConfiguration = parsed.value(); + case LEVEL_NAME -> levelName = parsed.value(); + case WORLD_CONTAINER -> worldContainer = parsed.value(); + } + if (parsed.consumedFollowing()) { + index++; + } + } + return new StartupArguments(serverProperties, bukkitConfiguration, levelName, worldContainer); + } + + private static ParsedArgument parseArgument(String argument, String following) throws IOException { + ParsedArgument parsed = parseArgument( + argument, + following, + ArgumentKind.SERVER_PROPERTIES, + "-c", + "--config" + ); + if (parsed != null) { + return parsed; + } + parsed = parseArgument( + argument, + following, + ArgumentKind.BUKKIT_CONFIGURATION, + "-b", + "--bukkit-settings" + ); + if (parsed != null) { + return parsed; + } + parsed = parseArgument( + argument, + following, + ArgumentKind.WORLD_CONTAINER, + "-W", + "--world-dir", + "--universe", + "--world-container" + ); + if (parsed != null) { + return parsed; + } + return parseArgument( + argument, + following, + ArgumentKind.LEVEL_NAME, + "-w", + "--world", + "--level-name" + ); + } + + private static ParsedArgument parseArgument( + String argument, + String following, + ArgumentKind kind, + String... keys + ) throws IOException { + for (String key : keys) { + if (argument.equals(key)) { + if (following == null || following.isBlank()) { + throw new IOException("Startup argument " + key + " requires a value"); + } + return new ParsedArgument(kind, following, true); + } + if (key.length() == 2 && argument.startsWith(key) && argument.length() > key.length()) { + String value = argument.substring(key.length()); + if (value.startsWith("=")) { + value = value.substring(1); + } + if (value.isBlank()) { + throw new IOException("Startup argument " + key + " requires a value"); + } + return new ParsedArgument(kind, value, false); + } + String prefix = key + "="; + if (argument.startsWith(prefix)) { + String value = argument.substring(prefix.length()); + if (value.isBlank()) { + throw new IOException("Startup argument " + key + " requires a value"); + } + return new ParsedArgument(kind, value, false); + } + } + return null; + } + + private static Path resolveAgainstServerRoot( + Path serverRoot, + String configuredValue, + String defaultValue, + String label + ) throws IOException { + String value = configuredValue == null ? defaultValue : configuredValue; + Path configured = parsePath(value, label); + return configured.isAbsolute() + ? configured.normalize() + : serverRoot.resolve(configured).normalize(); + } + + private static Path parsePath(String value, String label) throws IOException { + if (value == null || value.isBlank()) { + throw new IOException("Configured " + label + " is empty"); + } + try { + return Path.of(value); + } catch (InvalidPathException exception) { + throw new IOException("Configured " + label + " is invalid", exception); + } + } + + private static String effectiveLevelName(Path serverProperties, String argumentOverride) throws IOException { + if (argumentOverride != null) { + return requireValue(argumentOverride, "level name"); + } + if (!Files.exists(serverProperties, LinkOption.NOFOLLOW_LINKS)) { + return DEFAULT_LEVEL_NAME; + } + if (!Files.isRegularFile(serverProperties)) { + throw new IOException("Configured server properties is not a regular file: " + serverProperties); + } + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(serverProperties)) { + properties.load(input); + } + return requireValue(properties.getProperty("level-name", DEFAULT_LEVEL_NAME), "level name"); + } + + private static String requireValue(String value, String label) throws IOException { + if (value == null || value.isBlank()) { + throw new IOException("Configured " + label + " is empty"); + } + return value; + } + + private static Path canonicalDirectory(Path path, String label) throws IOException { + Path canonical = path.toRealPath(); + if (!Files.isDirectory(canonical)) { + throw new IOException(label + " is not a directory: " + path); + } + return canonical; + } + + private static Path canonicalPotentialDirectory(Path path, String label) throws IOException { + Path normalized = path.toAbsolutePath().normalize(); + ArrayList missing = new ArrayList<>(); + Path existing = normalized; + while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) { + Path fileName = existing.getFileName(); + Path parent = existing.getParent(); + if (fileName == null || parent == null) { + throw new IOException(label + " has no existing filesystem ancestor: " + normalized); + } + missing.add(fileName); + existing = parent; + } + Path canonical = canonicalDirectory(existing, label); + for (int index = missing.size() - 1; index >= 0; index--) { + canonical = canonical.resolve(missing.get(index)); + } + return canonical.normalize(); + } + + private enum ArgumentKind { + SERVER_PROPERTIES, + BUKKIT_CONFIGURATION, + LEVEL_NAME, + WORLD_CONTAINER + } + + private record ParsedArgument(ArgumentKind kind, String value, boolean consumedFollowing) { + } + + private record StartupArguments( + String serverProperties, + String bukkitConfiguration, + String levelName, + String worldContainer + ) { + } +} 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 e166b22c2..421a84b43 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 @@ -9,14 +9,17 @@ 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.ArrayList; import java.util.Objects; import java.util.function.Predicate; public final class BukkitWorldConfiguration { + private static final String DEFAULT_WORLD_CONTAINER = "."; private static final Object MUTATION_LOCK = new Object(); private BukkitWorldConfiguration() { @@ -63,6 +66,31 @@ public final class BukkitWorldConfiguration { } } + public static String readWorldContainer(File configurationFile) throws IOException { + File requiredConfigurationFile = Objects.requireNonNull(configurationFile, "configurationFile"); + Path configurationPath = requiredConfigurationFile.toPath(); + if (!Files.exists(configurationPath, LinkOption.NOFOLLOW_LINKS)) { + return DEFAULT_WORLD_CONTAINER; + } + if (!Files.isRegularFile(configurationPath)) { + throw new IOException("Configured Bukkit configuration is not a regular file: " + configurationPath); + } + synchronized (MUTATION_LOCK) { + YamlConfiguration configuration = load(requiredConfigurationFile); + Object configured = configuration.get("settings.world-container"); + if (configured == null) { + return DEFAULT_WORLD_CONTAINER; + } + if (!(configured instanceof String value)) { + throw new IOException("Bukkit settings.world-container must be a path string"); + } + if (value.isBlank()) { + throw new IOException("Configured world container is empty"); + } + return value; + } + } + public static GeneratorReplacement replaceIfMatching( File configurationFile, String worldName, @@ -82,7 +110,7 @@ public final class BukkitWorldConfiguration { return new GeneratorReplacement(false, current, replacement); } apply(configuration, requiredWorldName, replacement); - saveAtomic(configurationFile.toPath(), configuration); + saveAtomic(configurationFile.toPath(), configuration, true); return new GeneratorReplacement(true, current, replacement); } } @@ -104,7 +132,7 @@ public final class BukkitWorldConfiguration { return false; } apply(configuration, requiredWorldName, requiredRestoration); - saveAtomic(configurationFile.toPath(), configuration); + saveAtomic(configurationFile.toPath(), configuration, true); return true; } } @@ -194,7 +222,16 @@ public final class BukkitWorldConfiguration { } static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException { + saveAtomic(target, configuration, false); + } + + private static void saveAtomic( + Path target, + YamlConfiguration configuration, + boolean requireAtomicReplacement + ) throws IOException { Path absoluteTarget = target.toAbsolutePath().normalize(); + requireRegularConfigurationFile(absoluteTarget); Path parent = absoluteTarget.getParent(); if (parent == null) { throw new IOException("bukkit.yml target has no parent: " + absoluteTarget); @@ -206,9 +243,16 @@ public final class BukkitWorldConfiguration { try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) { channel.force(true); } + requireRegularConfigurationFile(absoluteTarget); try { Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (AtomicMoveNotSupportedException exception) { + if (requireAtomicReplacement) { + throw new IOException( + "Exact world replacement requires atomic bukkit.yml publication on this filesystem.", + exception + ); + } Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING); } } finally { @@ -217,6 +261,7 @@ public final class BukkitWorldConfiguration { } private static YamlConfiguration load(File configurationFile) throws IOException { + requireRegularConfigurationFile(configurationFile.toPath()); YamlConfiguration configuration = new YamlConfiguration(); try { configuration.load(configurationFile); @@ -226,6 +271,18 @@ public final class BukkitWorldConfiguration { } } + private static void requireRegularConfigurationFile(Path configurationFile) throws IOException { + Path path = configurationFile.toAbsolutePath().normalize(); + BasicFileAttributes attributes = Files.readAttributes( + path, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (attributes.isSymbolicLink() || !attributes.isRegularFile()) { + throw new IOException("bukkit.yml must be an existing regular file and was not changed: " + path); + } + } + private static WorldGeneratorSnapshot snapshot( YamlConfiguration configuration, String worldName diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java index e68378d04..9d2d44690 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java @@ -102,21 +102,28 @@ public final class WorldReplacementBootstrap { feedback.accept("Restored the retained world for " + transaction.worldKey() + "."); return ReconcileAction.ROLLED_BACK; } - if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) { - rollback(dataDirectory, bukkitConfiguration, transaction, paths, current, replacement); - feedback.accept("Cancelled or rolled back Iris world replacement for " + transaction.worldKey() + "."); - return ReconcileAction.ROLLED_BACK; - } - if (!current.matchesGeneratorAndSeed(replacement)) { - throw conflict(transaction, "bukkit.yml matches neither the replacement nor its retained original state."); - } - Transaction active = transaction; if (active.phase() == Phase.PREPARED) { + if (current.matchesGeneratorAndSeed(active.originalConfiguration())) { + rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement); + feedback.accept("Cancelled incomplete Iris world replacement for " + active.worldKey() + "."); + return ReconcileAction.ROLLED_BACK; + } + if (!current.matchesGeneratorAndSeed(replacement)) { + throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state."); + } active = active.withPhase(Phase.ARMED); WorldReplacementJournal.write(dataDirectory, active); } if (active.phase() == Phase.ARMED) { + if (!current.matchesGeneratorAndSeed(replacement)) { + if (current.matchesGeneratorAndSeed(active.originalConfiguration())) { + rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement); + feedback.accept("Cancelled Iris world replacement for " + active.worldKey() + "."); + return ReconcileAction.ROLLED_BACK; + } + throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state."); + } WorldReplacementFilesystem.publish( paths, active.originalTargetPresent(), @@ -129,6 +136,14 @@ public final class WorldReplacementBootstrap { return ReconcileAction.PUBLISHED; } if (active.phase() == Phase.PUBLISHED) { + if (!current.matchesGeneratorAndSeed(replacement)) { + if (current.matchesGeneratorAndSeed(active.originalConfiguration())) { + rollback(dataDirectory, bukkitConfiguration, active, paths, current, replacement); + feedback.accept("Rolled back Iris world replacement for " + active.worldKey() + "."); + return ReconcileAction.ROLLED_BACK; + } + throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state."); + } WorldReplacementFilesystem.publish( paths, active.originalTargetPresent(), @@ -154,7 +169,7 @@ public final class WorldReplacementBootstrap { Transaction rollback = transaction.phase() == Phase.ROLLBACK_PENDING ? transaction : transaction.withPhase(Phase.ROLLBACK_PENDING); - if (rollback != transaction) { + if (transaction.phase() != Phase.ROLLBACK_PENDING) { WorldReplacementJournal.write(dataDirectory, rollback); } WorldReplacementFilesystem.prepareRollback(paths, rollback.originalTargetPresent()); diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapMarker.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapMarker.java new file mode 100644 index 000000000..543c8e637 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapMarker.java @@ -0,0 +1,16 @@ +package art.arcane.iris.core.lifecycle; + +public final class WorldReplacementBootstrapMarker { + private static volatile boolean bootstrappedThisProcess; + + private WorldReplacementBootstrapMarker() { + } + + public static boolean wasBootstrappedThisProcess() { + return bootstrappedThisProcess; + } + + public static void markBootstrapped() { + bootstrappedThisProcess = true; + } +} 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 42039569f..7a2734fb9 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 @@ -1,8 +1,10 @@ package art.arcane.iris.core.lifecycle; -import art.arcane.iris.core.SnapshotDirectoryTreeDeleter; import art.arcane.iris.core.ExactWorldSlotPathPolicy; +import art.arcane.iris.core.SnapshotDirectoryTreeDeleter; +import art.arcane.iris.spi.IrisLogging; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -26,6 +28,11 @@ import java.util.regex.Pattern; import java.util.stream.Stream; public final class WorldReplacementFilesystem { + private static final List PAPER_WORLD_METADATA = List.of( + Path.of("data/paper/metadata.dat"), + Path.of("data/paper/level_overrides.dat"), + Path.of("data/minecraft/world_gen_settings.dat") + ); private static final Pattern STAGE_NAME = Pattern.compile( "^\\.iris-replace-[a-z0-9_-]+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.stage$"); private static final Pattern BACKUP_NAME = Pattern.compile( @@ -37,7 +44,7 @@ public final class WorldReplacementFilesystem { public static ReplacementPaths paths(ExactWorldSlotPathPolicy.Target target, UUID id) { ExactWorldSlotPathPolicy.Target requiredTarget = Objects.requireNonNull(target, "target"); UUID requiredId = Objects.requireNonNull(id, "id"); - String artifactBase = ".iris-replace-" + requiredTarget.worldKey().getKey() + "-" + requiredId; + String artifactBase = ".iris-replace-" + requiredTarget.worldKey().key() + "-" + requiredId; return new ReplacementPaths( requiredTarget.worldDirectory(), requiredTarget.namespaceRoot().resolve(artifactBase + ".stage"), @@ -45,6 +52,17 @@ public final class WorldReplacementFilesystem { ); } + public static void requireExistingTarget(ReplacementPaths paths) throws IOException { + 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."); + } + if (state.stagePresent() || state.backupPresent()) { + throw new IOException("A replacement artifact already exists for this transaction."); + } + } + public static void publish( ReplacementPaths paths, boolean originalTargetPresent, @@ -56,6 +74,12 @@ public final class WorldReplacementFilesystem { if (state.stagePresent()) { requireSafeTree(requiredPaths.stage(), "replacement stage"); requireFingerprint(requiredPaths.stage().resolve("iris/pack"), expectedFingerprint); + if (originalTargetPresent) { + Path retainedWorld = state.targetPresent() + ? requiredPaths.target() + : requiredPaths.backup(); + preservePaperWorldMetadata(retainedWorld, requiredPaths.stage()); + } if (state.targetPresent()) { if (state.backupPresent()) { throw new IOException("Replacement target, stage, and backup are all present."); @@ -86,6 +110,11 @@ public final class WorldReplacementFilesystem { } requireSafeTree(requiredPaths.target(), "replacement target"); requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint); + if (originalTargetPresent) { + preservePaperWorldMetadata(requiredPaths.backup(), requiredPaths.target()); + requireSafeTree(requiredPaths.target(), "replacement target"); + requireFingerprint(requiredPaths.target().resolve("iris/pack"), expectedFingerprint); + } } public static void rollback(ReplacementPaths paths, boolean originalTargetPresent) throws IOException { @@ -298,6 +327,71 @@ public final class WorldReplacementFilesystem { } } + private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException { + requireDirectory(retainedWorld, "retained world"); + requireDirectory(replacementWorld, "replacement world"); + requireDirectory(retainedWorld.resolve("data"), "retained world data"); + requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data"); + requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data"); + for (Path relative : PAPER_WORLD_METADATA) { + BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative)); + if (!sourceAttributes.isRegularFile()) { + throw new IOException("Retained Paper world metadata is not a regular file: " + relative); + } + } + ensureDirectory(replacementWorld.resolve("data"), "replacement world data"); + ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data"); + ensureDirectory(replacementWorld.resolve("data/minecraft"), "replacement Minecraft data"); + for (Path relative : PAPER_WORLD_METADATA) { + Path destination = replacementWorld.resolve(relative); + if (Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + BasicFileAttributes destinationAttributes = requireSafeEntry(destination); + if (!destinationAttributes.isRegularFile()) { + throw new IOException("Replacement Paper world metadata is not a regular file: " + relative); + } + continue; + } + copyMetadataFile(retainedWorld.resolve(relative), destination); + } + } + + private static void ensureDirectory(Path directory, String label) throws IOException { + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + requireDirectory(directory, label); + return; + } + Files.createDirectory(directory); + forceDirectoryRequired(directory.getParent()); + } + + private static void copyMetadataFile(Path source, Path destination) throws IOException { + Path temporary = destination.resolveSibling("." + destination.getFileName() + ".iris-replace.tmp"); + if (Files.exists(temporary, LinkOption.NOFOLLOW_LINKS)) { + BasicFileAttributes attributes = requireSafeEntry(temporary); + if (!attributes.isRegularFile()) { + throw new IOException("Replacement metadata staging path is unsafe: " + temporary); + } + Files.delete(temporary); + } + try { + Files.copy(source, temporary, StandardCopyOption.COPY_ATTRIBUTES); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + channel.force(true); + } + try { + Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + throw new IOException( + "Exact world replacement requires atomic Paper metadata publication on this filesystem.", + exception + ); + } + forceDirectoryRequired(destination.getParent()); + } finally { + Files.deleteIfExists(temporary); + } + } + private static void update(MessageDigest digest, String value) { byte[] bytes = value.getBytes(StandardCharsets.UTF_8); digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); @@ -313,13 +407,37 @@ public final class WorldReplacementFilesystem { } private static void move(Path source, Path target) throws IOException { + forceDirectoryRequired(source.getParent()); try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException exception) { - Files.move(source, target); + throw new IOException( + "Exact world replacement requires atomic directory moves on this filesystem.", + exception + ); } - try (FileChannel channel = FileChannel.open(source.getParent(), StandardOpenOption.READ)) { + forceDirectoryAfterCommit(source.getParent()); + } + + private static void forceDirectoryRequired(Path directory) throws IOException { + if (File.separatorChar == '\\') { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); + } catch (UnsupportedOperationException failure) { + throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure); + } + } + + private static void forceDirectoryAfterCommit(Path directory) { + try { + forceDirectoryRequired(directory); + } catch (IOException failure) { + IrisLogging.reportError( + "A world-replacement move completed, but its parent directory could not be durability-synced.", + failure + ); } } 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 b58ded276..edb5fe8d7 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,10 +1,12 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.ExactWorldSlotPathPolicy; +import art.arcane.iris.core.WorldSlotKey; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; -import org.bukkit.NamespacedKey; +import art.arcane.iris.spi.IrisLogging; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -18,9 +20,11 @@ import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Properties; +import java.util.Set; import java.util.UUID; public final class WorldReplacementJournal { @@ -46,6 +50,12 @@ public final class WorldReplacementJournal { } } transactions.sort(Comparator.comparing(transaction -> transaction.id().toString())); + Set worldKeys = new HashSet<>(); + for (Transaction transaction : transactions) { + if (!worldKeys.add(transaction.worldKey())) { + throw new IOException("Multiple replacement journals target " + transaction.worldKey() + "."); + } + } return List.copyOf(transactions); } @@ -77,8 +87,9 @@ public final class WorldReplacementJournal { if (directory == null) { return; } + forceDirectoryRequired(directory); Files.deleteIfExists(directory.resolve(Objects.requireNonNull(id, "id") + JOURNAL_SUFFIX)); - forceDirectory(directory); + forceDirectoryAfterCommit(directory); } public static ExactWorldSlotPathPolicy.Target resolveTarget(Transaction transaction, Path currentLevelRoot) @@ -91,31 +102,44 @@ public final class WorldReplacementJournal { if (!target.levelRoot().equals(requiredTransaction.levelRoot())) { throw new IOException("The configured level root changed after the world replacement was staged."); } - String expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey()); + String expectedWorldName; + try { + expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey()); + } catch (IllegalArgumentException failure) { + throw new IOException("The replacement journal targets an ambiguous logical world name.", failure); + } if (!expectedWorldName.equals(requiredTransaction.worldName())) { throw new IOException("The logical world name changed after the world replacement was staged."); } return target; } - public static String logicalWorldName(Path levelRoot, NamespacedKey worldKey) { + public static String logicalWorldName(Path levelRoot, WorldSlotKey worldKey) { Path requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize(); - NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); - if ("iris".equals(requiredWorldKey.getNamespace())) { - return requiredWorldKey.getKey(); - } + WorldSlotKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); Path fileName = requiredLevelRoot.getFileName(); if (fileName == null || fileName.toString().isBlank()) { throw new IllegalArgumentException("Level root must have a logical world name."); } String levelName = fileName.toString(); - if (NamespacedKey.minecraft("overworld").equals(requiredWorldKey)) { + 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; + } + if (WorldSlotKey.minecraft("overworld").equals(requiredWorldKey)) { return levelName; } - if (NamespacedKey.minecraft("the_nether").equals(requiredWorldKey)) { + if (WorldSlotKey.minecraft("the_nether").equals(requiredWorldKey)) { return levelName + "_nether"; } - if (NamespacedKey.minecraft("the_end").equals(requiredWorldKey)) { + if (WorldSlotKey.minecraft("the_end").equals(requiredWorldKey)) { return levelName + "_the_end"; } throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey); @@ -135,9 +159,11 @@ public final class WorldReplacementJournal { if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) { throw new IOException("Replacement journal filename does not match its transaction id."); } - NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey")); - if (worldKey == null) { - throw new IOException("Replacement journal contains an invalid world key."); + WorldSlotKey worldKey; + try { + worldKey = WorldSlotKey.parse(required(properties, "worldKey")); + } catch (IllegalArgumentException failure) { + throw new IOException("Replacement journal contains an invalid world key.", failure); } String worldName = exact(properties, "worldName"); Path recordedLevelRoot = recordedLevelRoot(properties); @@ -300,26 +326,46 @@ public final class WorldReplacementJournal { } channel.force(true); } + forceDirectoryRequired(parent); try { Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (AtomicMoveNotSupportedException failure) { - Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + throw new IOException( + "Exact world replacement requires atomic journal publication on this filesystem.", + failure + ); } - forceDirectory(parent); + forceDirectoryAfterCommit(parent); } finally { Files.deleteIfExists(temporary); } } - private static void forceDirectory(Path directory) throws IOException { + private static void forceDirectoryRequired(Path directory) throws IOException { + if (File.separatorChar == '\\') { + return; + } try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); + } catch (UnsupportedOperationException failure) { + throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure); + } + } + + private static void forceDirectoryAfterCommit(Path directory) { + try { + forceDirectoryRequired(directory); + } catch (IOException failure) { + IrisLogging.reportError( + "A world-replacement journal change completed, but its parent directory could not be durability-synced.", + failure + ); } } public record Transaction( UUID id, - NamespacedKey worldKey, + WorldSlotKey worldKey, String worldName, Path levelRoot, String dimension, 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 e5f6074fd..973be2cab 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 @@ -1,6 +1,7 @@ package art.arcane.iris.core.pack; import art.arcane.iris.core.IrisDatapackCompiler; +import art.arcane.iris.core.lifecycle.BukkitStartupPaths; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.volmlib.util.collection.KList; @@ -27,7 +28,9 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.HexFormat; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Properties; import java.util.Set; @@ -40,9 +43,20 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; public final class DefaultPackBootstrapProvisioner { - private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"); + private static final List DEFAULT_PACKS = List.of( + new PackSpec( + "overworld", + URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"), + "overworld" + ), + new PackSpec( + "underworld", + URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"), + "underworld" + ) + ); private static final String WORLD_DATAPACK_DIRECTORY = "iris"; - private static final int MARKER_SCHEMA = 3; + private static final int MARKER_SCHEMA = 4; 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; @@ -53,17 +67,28 @@ public final class DefaultPackBootstrapProvisioner { private DefaultPackBootstrapProvisioner() { } + static List defaultPacks() { + return DEFAULT_PACKS; + } + public static ProvisionResult provision(Path dataDirectory, Consumer feedback) throws IOException { + return provision(dataDirectory, feedback, BukkitStartupPaths.resolveCurrent()); + } + + public static ProvisionResult provision( + Path dataDirectory, + Consumer feedback, + BukkitStartupPaths startupPaths + ) throws IOException { Objects.requireNonNull(dataDirectory, "dataDirectory"); Objects.requireNonNull(feedback, "feedback"); - Path serverRoot = Path.of("").toAbsolutePath().normalize(); - Path levelRoot = resolveLevelRoot(serverRoot); + Path levelRoot = Objects.requireNonNull(startupPaths, "startupPaths").levelRoot(); HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(15)) .followRedirects(HttpClient.Redirect.ALWAYS) .build(); ProvisionOptions options = new ProvisionOptions( - DEFAULT_SOURCE, + DEFAULT_PACKS, client, Clock.systemUTC(), Duration.ofMinutes(30), @@ -83,7 +108,7 @@ public final class DefaultPackBootstrapProvisioner { try { return isProvisioned( dataDirectory, - resolveLevelRoot(Path.of("").toAbsolutePath().normalize()) + BukkitStartupPaths.resolveCurrent().levelRoot() ); } catch (IOException | RuntimeException exception) { return false; @@ -91,14 +116,17 @@ public final class DefaultPackBootstrapProvisioner { } static boolean isProvisioned(Path dataDirectory, Path levelRoot) { + return isProvisioned(dataDirectory, levelRoot, DEFAULT_PACKS); + } + + static boolean isProvisioned(Path dataDirectory, Path levelRoot, List requiredPacks) { try { Path normalizedData = dataDirectory.toAbsolutePath().normalize(); Path normalizedLevel = levelRoot.toAbsolutePath().normalize(); Path bootstrapRoot = normalizedData.resolve("bootstrap"); Path datapackRoot = worldDatapackRoot(normalizedLevel); - Path packRoot = normalizedData.resolve("packs/overworld"); Path markerFile = bootstrapRoot.resolve("provisioned.properties"); - if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) { + if (!Files.isRegularFile(markerFile) || !isDatapackRoot(datapackRoot)) { return false; } Properties marker = loadProperties(markerFile); @@ -111,8 +139,16 @@ public final class DefaultPackBootstrapProvisioner { .equals(marker.getProperty("compilerIdentity"))) { return false; } - return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint")) - && directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint")) + for (PackSpec spec : requiredPacks) { + Path packRoot = normalizedData.resolve("packs").resolve(spec.key()); + if (!isPackRoot(packRoot, spec) + || !spec.source().toString().equals(marker.getProperty(markerKey(spec, "source"))) + || !spec.requiredDimension().equals(marker.getProperty(markerKey(spec, "requiredDimension"))) + || !directoryFingerprint(packRoot).equals(marker.getProperty(markerKey(spec, "fingerprint")))) { + return false; + } + } + return directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint")) && datapackRoot.toString().equals(marker.getProperty("datapackPath")) && packRootsFingerprint(IrisDatapackCompiler.collectPackRoots( normalizedData, @@ -136,7 +172,6 @@ public final class DefaultPackBootstrapProvisioner { PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(""); Path normalizedData = dataDirectory.toAbsolutePath().normalize(); Path packsRoot = normalizedData.resolve("packs"); - Path packRoot = packsRoot.resolve("overworld"); Path bootstrapRoot = normalizedData.resolve("bootstrap"); Path legacyDatapackRoot = bootstrapRoot.resolve("datapack"); Path datapacksRoot = options.levelRoot().toAbsolutePath().normalize().resolve("datapacks"); @@ -149,40 +184,58 @@ public final class DefaultPackBootstrapProvisioner { Files.createDirectories(datapacksRoot); Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties(); - boolean existingPack = isPackRoot(packRoot); boolean existingDatapack = isDatapackRoot(datapackRoot); - String currentPackFingerprint = existingPack ? directoryFingerprint(packRoot) : ""; - boolean markerOwned = "true".equals(previousMarker.getProperty("managedDefault")); - boolean unchangedManagedPack = markerOwned - && currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint")); - boolean managedDefault = !existingPack || unchangedManagedPack; - if (Files.isSymbolicLink(packRoot)) { - managedDefault = false; + List plans = new ArrayList<>(options.packs().size()); + for (PackSpec spec : options.packs()) { + Path packRoot = packsRoot.resolve(spec.key()).normalize(); + if (!Objects.equals(packRoot.getParent(), packsRoot)) { + throw new IOException("Bootstrap pack target escapes the packs directory: " + packRoot); + } + boolean existingPack = isPackRoot(packRoot, spec); + String currentFingerprint = existingPack ? directoryFingerprint(packRoot) : ""; + boolean markerOwned = "true".equals(markerProperty(previousMarker, spec, "managed")); + boolean unchangedManagedPack = markerOwned + && currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint")); + boolean managed = !existingPack || unchangedManagedPack; + if (Files.isSymbolicLink(packRoot)) { + managed = false; + } + String retainedSourceSha = markerProperty(previousMarker, spec, "sourceSha256"); + Archive archive = managed + ? acquireArchive(cacheRoot, previousMarker, spec, feedback, options) + : new Archive(null, retainedSourceSha == null || retainedSourceSha.isBlank() + ? currentFingerprint + : retainedSourceSha); + boolean replacePack = !existingPack || managed + && (!archive.sha256().equals(markerProperty(previousMarker, spec, "sourceSha256")) + || !currentFingerprint.equals(markerProperty(previousMarker, spec, "fingerprint"))); + plans.add(new PackPlan(spec, packRoot, existingPack, managed, archive, replacePack)); } - Archive archive = managedDefault - ? acquireArchive(cacheRoot, previousMarker, feedback, options) - : new Archive(null, currentPackFingerprint); - boolean replacePack = !existingPack || managedDefault - && (!archive.sha256().equals(previousMarker.getProperty("sourceSha256")) - || !currentPackFingerprint.equals(previousMarker.getProperty("defaultPackFingerprint"))); - Path stagedPack = null; - Path extractionRoot = null; + Map stagedPacks = new LinkedHashMap<>(); + List extractionRoots = new ArrayList<>(); Path compileContainer = null; - Path packBackup = null; Path datapackBackup = null; - boolean packReplaced = false; boolean datapackReplaced = false; + List packPublications = new ArrayList<>(); + boolean committed = false; try { - if (replacePack) { - extractionRoot = cacheRoot.resolve(".extract-" + UUID.randomUUID()); + for (PackPlan plan : plans) { + if (!plan.replace()) { + continue; + } + Path extractionRoot = cacheRoot.resolve(".extract-" + plan.spec().key() + "-" + UUID.randomUUID()); + extractionRoots.add(extractionRoot); Files.createDirectories(extractionRoot); - Path extractedPack = extractArchive(archive.path(), extractionRoot); - stagedPack = packsRoot.resolve(".overworld-stage-" + UUID.randomUUID()); + Path extractedPack = extractArchive(plan.archive().path(), extractionRoot, plan.spec()); + Path stagedPack = packsRoot.resolve("." + plan.spec().key() + "-stage-" + UUID.randomUUID()); copyDirectory(extractedPack, stagedPack); - validatePackRoot(stagedPack); - packBackup = replaceWithBackup(stagedPack, packRoot); - packReplaced = true; + validatePackRoot(stagedPack, plan.spec()); + stagedPacks.put(plan, stagedPack); + } + for (Map.Entry entry : stagedPacks.entrySet()) { + Path backup = replaceWithBackup(entry.getValue(), entry.getKey().root()); + packPublications.add(new PackPublication(entry.getKey().root(), backup)); } List packRoots = IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot()); @@ -195,7 +248,8 @@ public final class DefaultPackBootstrapProvisioner { } String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer); String aggregateFingerprint = packRootsFingerprint(packRoots); - boolean rebuildDatapack = replacePack + boolean anyPackReplaced = !packPublications.isEmpty(); + boolean rebuildDatapack = anyPackReplaced || !existingDatapack || !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint")) || !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity")) @@ -214,11 +268,12 @@ public final class DefaultPackBootstrapProvisioner { datapackReplaced = true; } - validatePackRoot(packRoot); + for (PackPlan plan : plans) { + validatePackRoot(plan.root(), plan.spec()); + } if (!isDatapackRoot(datapackRoot)) { throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot); } - String finalPackFingerprint = directoryFingerprint(packRoot); List finalPackRoots = IrisDatapackCompiler.collectPackRoots( normalizedData, options.levelRoot()); @@ -230,47 +285,76 @@ public final class DefaultPackBootstrapProvisioner { String finalDatapackFingerprint = directoryFingerprint(datapackRoot); Properties marker = new Properties(); marker.setProperty("schema", Integer.toString(MARKER_SCHEMA)); - marker.setProperty("source", options.source().toString()); - marker.setProperty("sourceSha256", archive.sha256()); - marker.setProperty("managedDefault", Boolean.toString(managedDefault)); - marker.setProperty("defaultPackFingerprint", finalPackFingerprint); + for (PackPlan plan : plans) { + marker.setProperty(markerKey(plan.spec(), "source"), plan.spec().source().toString()); + marker.setProperty(markerKey(plan.spec(), "sourceSha256"), plan.archive().sha256()); + marker.setProperty(markerKey(plan.spec(), "managed"), Boolean.toString(plan.managed())); + marker.setProperty(markerKey(plan.spec(), "fingerprint"), directoryFingerprint(plan.root())); + marker.setProperty(markerKey(plan.spec(), "requiredDimension"), plan.spec().requiredDimension()); + } marker.setProperty("aggregateFingerprint", finalAggregateFingerprint); marker.setProperty("compilerIdentity", compilerIdentity); marker.setProperty("datapackFingerprint", finalDatapackFingerprint); marker.setProperty("datapackPath", datapackRoot.toString()); marker.setProperty("completedAt", Long.toString(options.clock().millis())); storePropertiesAtomic(markerFile, marker); + committed = true; PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint); PROVISIONED_THIS_STARTUP.set(true); - deleteQuietly(packBackup, feedback); + for (PackPublication publication : packPublications) { + deleteQuietly(publication.backup(), feedback); + } deleteQuietly(datapackBackup, feedback); deleteQuietly(legacyDatapackRoot, feedback); + boolean everyPackMissing = plans.stream().noneMatch(PackPlan::existed); ProvisionStatus status; - if (!existingPack && !existingDatapack) { + if (everyPackMissing && !existingDatapack) { status = ProvisionStatus.INSTALLED; - } else if (replacePack || rebuildDatapack) { + } else if (anyPackReplaced || rebuildDatapack) { status = ProvisionStatus.UPDATED; } else { status = ProvisionStatus.UNCHANGED; } - feedback.accept("Iris bootstrap pack is " + status.name().toLowerCase() + "."); - return new ProvisionResult(packRoot, datapackRoot, status); + feedback.accept("Iris bootstrap packs are " + status.name().toLowerCase() + "."); + Map provisionedPacks = new LinkedHashMap<>(); + for (PackPlan plan : plans) { + provisionedPacks.put(plan.spec().key(), plan.root()); + } + return new ProvisionResult(provisionedPacks, datapackRoot, status); } catch (IOException failure) { - IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced); - if (rollbackFailure != null) { - failure.addSuppressed(rollbackFailure); + if (!committed) { + IOException rollbackFailure = rollback( + packPublications, + datapackRoot, + datapackBackup, + datapackReplaced + ); + if (rollbackFailure != null) { + failure.addSuppressed(rollbackFailure); + } } throw failure; } catch (RuntimeException | LinkageError failure) { - IOException rollbackFailure = rollback(packRoot, packBackup, packReplaced, datapackRoot, datapackBackup, datapackReplaced); - if (rollbackFailure != null) { - failure.addSuppressed(rollbackFailure); + if (!committed) { + IOException rollbackFailure = rollback( + packPublications, + datapackRoot, + datapackBackup, + datapackReplaced + ); + if (rollbackFailure != null) { + failure.addSuppressed(rollbackFailure); + } } throw new IOException("Iris bootstrap provisioning failed", failure); } finally { - deleteQuietly(stagedPack, feedback); - deleteQuietly(extractionRoot, feedback); + for (Path stagedPack : stagedPacks.values()) { + deleteQuietly(stagedPack, feedback); + } + for (Path extractionRoot : extractionRoots) { + deleteQuietly(extractionRoot, feedback); + } deleteQuietly(compileContainer, feedback); } } @@ -278,35 +362,44 @@ public final class DefaultPackBootstrapProvisioner { private static Archive acquireArchive( Path cacheRoot, Properties marker, + PackSpec spec, Consumer feedback, ProvisionOptions options ) throws IOException { - Path archivePath = cacheRoot.resolve("default-overworld.zip"); - Path metadataPath = cacheRoot.resolve("default-overworld.properties"); + Path archivePath = cacheRoot.resolve("default-" + spec.key() + ".zip"); + Path metadataPath = cacheRoot.resolve("default-" + spec.key() + ".properties"); Properties metadata = Files.isRegularFile(metadataPath) ? loadProperties(metadataPath) : new Properties(); boolean validCache = false; if (Files.isRegularFile(archivePath)) { try { - validCache = validateArchive(archivePath); + validCache = validateArchive(archivePath, spec); } catch (IOException exception) { - feedback.accept("Cached default overworld archive is invalid; downloading a replacement."); + feedback.accept("Cached Iris " + spec.key() + " beta archive is invalid; downloading a replacement."); } } + String cachedSource = metadata.getProperty("source", ""); + boolean cacheMatchesSource = validCache && (spec.source().toString().equals(cachedSource) + || cachedSource.isBlank() && "overworld".equals(spec.key())); long fetchedAt = parseLong(metadata.getProperty("fetchedAt"), 0L); - boolean fresh = validCache && options.clock().millis() - fetchedAt < options.refreshInterval().toMillis(); + boolean fresh = cacheMatchesSource + && options.clock().millis() - fetchedAt < options.refreshInterval().toMillis(); if (fresh) { + if (cachedSource.isBlank()) { + metadata.setProperty("source", spec.source().toString()); + storePropertiesAtomic(metadataPath, metadata); + } return new Archive(archivePath, sha256(archivePath)); } IOException networkFailure = null; for (int attempt = 1; attempt <= options.attempts(); attempt++) { try { - HttpRequest.Builder request = HttpRequest.newBuilder(options.source()) + HttpRequest.Builder request = HttpRequest.newBuilder(spec.source()) .timeout(options.requestTimeout()) .header("Accept", "application/octet-stream") .header("User-Agent", "Iris-Bootstrap") .GET(); - if (validCache) { + if (cacheMatchesSource) { String etag = metadata.getProperty("etag"); String lastModified = metadata.getProperty("lastModified"); if (etag != null && !etag.isBlank()) { @@ -318,8 +411,9 @@ public final class DefaultPackBootstrapProvisioner { } HttpResponse response = options.client().send(request.build(), HttpResponse.BodyHandlers.ofInputStream()); int status = response.statusCode(); - if (status == 304 && validCache) { + if (status == 304 && cacheMatchesSource) { close(response.body()); + metadata.setProperty("source", spec.source().toString()); metadata.setProperty("fetchedAt", Long.toString(options.clock().millis())); storePropertiesAtomic(metadataPath, metadata); return new Archive(archivePath, sha256(archivePath)); @@ -328,10 +422,10 @@ public final class DefaultPackBootstrapProvisioner { Path temporary = cacheRoot.resolve(".download-" + UUID.randomUUID() + ".zip"); try { try (InputStream input = response.body(); OutputStream output = Files.newOutputStream(temporary)) { - copyLimited(input, output, options.maxArchiveBytes()); + copyLimited(input, output, options.maxArchiveBytes(), spec); } - if (!validateArchive(temporary)) { - throw new IOException("Downloaded default overworld archive is invalid"); + if (!validateArchive(temporary, spec)) { + throw new IOException("Downloaded Iris " + spec.key() + " beta archive is invalid"); } move(temporary, archivePath, true); } finally { @@ -340,14 +434,15 @@ public final class DefaultPackBootstrapProvisioner { Properties updated = new Properties(); response.headers().firstValue("etag").ifPresent(value -> updated.setProperty("etag", value)); response.headers().firstValue("last-modified").ifPresent(value -> updated.setProperty("lastModified", value)); + updated.setProperty("source", spec.source().toString()); updated.setProperty("fetchedAt", Long.toString(options.clock().millis())); updated.setProperty("sha256", sha256(archivePath)); storePropertiesAtomic(metadataPath, updated); - feedback.accept("Downloaded the Iris default overworld beta archive."); + feedback.accept("Downloaded the Iris " + spec.key() + " beta archive."); return new Archive(archivePath, updated.getProperty("sha256")); } close(response.body()); - IOException statusFailure = new IOException("Default overworld download returned HTTP " + status); + IOException statusFailure = new IOException("Iris " + spec.key() + " beta download returned HTTP " + status); if (!retryableStatus(status)) { networkFailure = statusFailure; break; @@ -355,7 +450,7 @@ public final class DefaultPackBootstrapProvisioner { networkFailure = statusFailure; } catch (InterruptedException exception) { Thread.currentThread().interrupt(); - throw new IOException("Default overworld download was interrupted", exception); + throw new IOException("Iris " + spec.key() + " beta download was interrupted", exception); } catch (IOException exception) { networkFailure = exception; } @@ -364,79 +459,38 @@ public final class DefaultPackBootstrapProvisioner { } } - if (validCache) { - feedback.accept("Default overworld download failed; using the validated cached archive."); + if (cacheMatchesSource) { + feedback.accept("Iris " + spec.key() + " beta download failed; using the validated cached archive."); return new Archive(archivePath, sha256(archivePath)); } - if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent())) { - String sourceSha = marker.getProperty("sourceSha256"); + if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) { + String sourceSha = markerProperty(marker, spec, "sourceSha256"); return new Archive(null, sourceSha); } throw networkFailure == null - ? new IOException("Default overworld archive is unavailable and no valid cache exists") - : new IOException("Default overworld archive is unavailable and no valid cache exists", networkFailure); + ? new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists") + : new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists", networkFailure); } - private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory) { - String sourceSha = marker.getProperty("sourceSha256"); - String expectedFingerprint = marker.getProperty("defaultPackFingerprint"); + private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) { + String sourceSha = markerProperty(marker, spec, "sourceSha256"); + String expectedFingerprint = markerProperty(marker, spec, "fingerprint"); if (sourceSha == null || sourceSha.isBlank() || expectedFingerprint == null || expectedFingerprint.isBlank()) { return false; } - Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld"); + Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs").resolve(spec.key()); try { - return isPackRoot(packRoot) && directoryFingerprint(packRoot).equals(expectedFingerprint); + return isPackRoot(packRoot, spec) && directoryFingerprint(packRoot).equals(expectedFingerprint); } catch (IOException | RuntimeException exception) { return false; } } - static Path resolveLevelRoot(Path serverRoot) throws IOException { - Path normalizedServerRoot = serverRoot.toAbsolutePath().normalize(); - String levelName = readConfiguredLevelName(normalizedServerRoot); - Path configured = Path.of(levelName); - return configured.isAbsolute() - ? configured.normalize() - : normalizedServerRoot.resolve(configured).normalize(); + public static Path resolveLevelRoot(Path serverRoot) throws IOException { + return BukkitStartupPaths.resolve(serverRoot).levelRoot(); } - private static String readConfiguredLevelName(Path serverRoot) throws IOException { - String levelName = "world"; - Path propertiesFile = serverRoot.resolve("server.properties"); - if (Files.isRegularFile(propertiesFile)) { - Properties properties = loadProperties(propertiesFile); - levelName = properties.getProperty("level-name", levelName); - } - - String[] arguments = ProcessHandle.current().info().arguments().orElse(new String[0]); - for (int index = 0; index < arguments.length; index++) { - String argument = arguments[index]; - String following = index + 1 < arguments.length ? arguments[index + 1] : null; - String parsed = parseLevelArgument(argument, following); - if (parsed != null) { - levelName = parsed; - } - } - if (levelName.isBlank()) { - throw new IOException("Configured level name is empty"); - } - return levelName; - } - - private static String parseLevelArgument(String argument, String following) { - for (String key : List.of("-w", "--level-name", "--world")) { - if (argument.equals(key) && following != null && !following.isBlank()) { - return following; - } - String prefix = key + "="; - if (argument.startsWith(prefix) && argument.length() > prefix.length()) { - return argument.substring(prefix.length()); - } - } - return null; - } - - private static boolean validateArchive(Path archive) throws IOException { + private static boolean validateArchive(Path archive, PackSpec spec) throws IOException { int entries = 0; long expanded = 0L; boolean dimensionFound = false; @@ -446,13 +500,14 @@ public final class DefaultPackBootstrapProvisioner { while ((entry = zip.getNextEntry()) != null) { entries++; if (entries > MAX_ARCHIVE_ENTRIES) { - throw new IOException("Default overworld archive contains too many entries"); + throw new IOException("Iris " + spec.key() + " beta archive contains too many entries"); } String name = normalizedZipEntry(entry.getName()); if (!paths.add(name)) { - throw new IOException("Default overworld archive contains duplicate path " + name); + throw new IOException("Iris " + spec.key() + " beta archive contains duplicate path " + name); } - if (name.equals("dimensions/overworld.json") || name.endsWith("/dimensions/overworld.json")) { + String requiredDimension = "dimensions/" + spec.requiredDimension() + ".json"; + if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) { dimensionFound = true; } if (!entry.isDirectory()) { @@ -461,7 +516,7 @@ public final class DefaultPackBootstrapProvisioner { while ((read = zip.read(buffer)) >= 0) { expanded += read; if (expanded > MAX_EXPANDED_BYTES) { - throw new IOException("Default overworld archive expands beyond the safety limit"); + throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit"); } } } @@ -471,9 +526,9 @@ public final class DefaultPackBootstrapProvisioner { return entries > 0 && dimensionFound; } - private static Path extractArchive(Path archive, Path extractionRoot) throws IOException { + private static Path extractArchive(Path archive, Path extractionRoot, PackSpec spec) throws IOException { if (archive == null) { - throw new IOException("Cached default pack archive is unavailable for required pack rebuild"); + throw new IOException("Cached Iris " + spec.key() + " beta archive is unavailable for required pack rebuild"); } long expanded = 0L; int entries = 0; @@ -482,12 +537,12 @@ public final class DefaultPackBootstrapProvisioner { while ((entry = zip.getNextEntry()) != null) { entries++; if (entries > MAX_ARCHIVE_ENTRIES) { - throw new IOException("Default overworld archive contains too many entries"); + throw new IOException("Iris " + spec.key() + " beta archive contains too many entries"); } String name = normalizedZipEntry(entry.getName()); Path output = extractionRoot.resolve(name).normalize(); if (!output.startsWith(extractionRoot)) { - throw new IOException("Unsafe path in default overworld archive: " + entry.getName()); + throw new IOException("Unsafe path in Iris " + spec.key() + " beta archive: " + entry.getName()); } if (entry.isDirectory()) { Files.createDirectories(output); @@ -499,7 +554,7 @@ public final class DefaultPackBootstrapProvisioner { while ((read = zip.read(buffer)) >= 0) { expanded += read; if (expanded > MAX_EXPANDED_BYTES) { - throw new IOException("Default overworld archive expands beyond the safety limit"); + throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit"); } file.write(buffer, 0, read); } @@ -508,42 +563,44 @@ public final class DefaultPackBootstrapProvisioner { zip.closeEntry(); } } - if (isPackRoot(extractionRoot)) { + if (isPackRoot(extractionRoot, spec)) { return extractionRoot; } List candidates = new ArrayList<>(); try (DirectoryStream children = Files.newDirectoryStream(extractionRoot)) { for (Path child : children) { - if (Files.isDirectory(child) && isPackRoot(child)) { + if (Files.isDirectory(child) && isPackRoot(child, spec)) { candidates.add(child); } } } if (candidates.size() != 1) { - throw new IOException("Default overworld archive has an invalid root layout"); + throw new IOException("Iris " + spec.key() + " beta archive has an invalid root layout"); } return candidates.getFirst(); } private static String normalizedZipEntry(String raw) throws IOException { if (raw == null || raw.isBlank() || raw.indexOf('\0') >= 0 || raw.startsWith("/") || raw.startsWith("\\")) { - throw new IOException("Invalid path in default overworld archive"); + throw new IOException("Invalid path in Iris bootstrap pack archive"); } String normalized = raw.replace('\\', '/'); Path path = Path.of(normalized).normalize(); if (path.isAbsolute() || path.startsWith("..") || normalized.matches("^[A-Za-z]:.*")) { - throw new IOException("Unsafe path in default overworld archive: " + raw); + throw new IOException("Unsafe path in Iris bootstrap pack archive: " + raw); } return path.toString().replace('\\', '/'); } - private static boolean isPackRoot(Path path) { - return path != null && Files.isRegularFile(path.resolve("dimensions/overworld.json")); + private static boolean isPackRoot(Path path, PackSpec spec) { + return path != null + && Files.isRegularFile(path.resolve("dimensions").resolve(spec.requiredDimension() + ".json")); } - private static void validatePackRoot(Path path) throws IOException { - if (!isPackRoot(path)) { - throw new IOException("Default overworld pack is missing dimensions/overworld.json at " + path); + private static void validatePackRoot(Path path, PackSpec spec) throws IOException { + if (!isPackRoot(path, spec)) { + throw new IOException("Iris " + spec.key() + " beta pack is missing dimensions/" + + spec.requiredDimension() + ".json at " + path); } } @@ -636,26 +693,27 @@ public final class DefaultPackBootstrapProvisioner { } private static IOException rollback( - Path packRoot, - Path packBackup, - boolean packReplaced, + List packPublications, Path datapackRoot, Path datapackBackup, boolean datapackReplaced ) { IOException failure = null; try { - restore(packRoot, packBackup, packReplaced); + restore(datapackRoot, datapackBackup, datapackReplaced); } catch (IOException exception) { failure = exception; } - try { - restore(datapackRoot, datapackBackup, datapackReplaced); - } catch (IOException exception) { - if (failure == null) { - failure = exception; - } else { - failure.addSuppressed(exception); + for (int index = packPublications.size() - 1; index >= 0; index--) { + PackPublication publication = packPublications.get(index); + try { + restore(publication.root(), publication.backup(), true); + } catch (IOException exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } } } return failure; @@ -726,14 +784,19 @@ public final class DefaultPackBootstrapProvisioner { } } - private static void copyLimited(InputStream input, OutputStream output, long limit) throws IOException { + private static void copyLimited( + InputStream input, + OutputStream output, + long limit, + PackSpec spec + ) throws IOException { byte[] buffer = new byte[8192]; long total = 0L; int read; while ((read = input.read(buffer)) >= 0) { total += read; if (total > limit) { - throw new IOException("Default overworld archive exceeds the download size limit"); + throw new IOException("Iris " + spec.key() + " beta archive exceeds the download size limit"); } output.write(buffer, 0, read); } @@ -772,7 +835,7 @@ public final class DefaultPackBootstrapProvisioner { Thread.sleep(duration.toMillis()); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); - throw new IOException("Default overworld retry wait was interrupted", exception); + throw new IOException("Iris bootstrap pack retry wait was interrupted", exception); } } @@ -787,6 +850,24 @@ public final class DefaultPackBootstrapProvisioner { } } + private static String markerKey(PackSpec spec, String property) { + return "pack." + spec.key() + "." + property; + } + + private static String markerProperty(Properties marker, PackSpec spec, String property) { + String current = marker.getProperty(markerKey(spec, property)); + if (current != null || !"overworld".equals(spec.key())) { + return current; + } + return switch (property) { + case "source" -> marker.getProperty("source"); + case "sourceSha256" -> marker.getProperty("sourceSha256"); + case "managed" -> marker.getProperty("managedDefault"); + case "fingerprint" -> marker.getProperty("defaultPackFingerprint"); + default -> null; + }; + } + private static void close(InputStream input) { try { input.close(); @@ -800,16 +881,16 @@ public final class DefaultPackBootstrapProvisioner { UNCHANGED } - public record ProvisionResult(Path packRoot, Path datapackRoot, ProvisionStatus status) { + public record ProvisionResult(Map packRoots, Path datapackRoot, ProvisionStatus status) { public ProvisionResult { - Objects.requireNonNull(packRoot, "packRoot"); + packRoots = Map.copyOf(Objects.requireNonNull(packRoots, "packRoots")); Objects.requireNonNull(datapackRoot, "datapackRoot"); Objects.requireNonNull(status, "status"); } } record ProvisionOptions( - URI source, + List packs, HttpClient client, Clock clock, Duration refreshInterval, @@ -820,19 +901,49 @@ public final class DefaultPackBootstrapProvisioner { Path levelRoot ) { ProvisionOptions { - Objects.requireNonNull(source, "source"); + packs = List.copyOf(Objects.requireNonNull(packs, "packs")); Objects.requireNonNull(client, "client"); Objects.requireNonNull(clock, "clock"); Objects.requireNonNull(refreshInterval, "refreshInterval"); Objects.requireNonNull(requestTimeout, "requestTimeout"); Objects.requireNonNull(retryDelay, "retryDelay"); Objects.requireNonNull(levelRoot, "levelRoot"); - if (attempts < 1 || maxArchiveBytes < 1L) { + if (packs.isEmpty() || attempts < 1 || maxArchiveBytes < 1L) { throw new IllegalArgumentException("Invalid bootstrap provisioning options"); } + Set keys = new HashSet<>(); + for (PackSpec pack : packs) { + if (!keys.add(pack.key())) { + throw new IllegalArgumentException("Duplicate bootstrap pack key '" + pack.key() + "'"); + } + } } } + record PackSpec(String key, URI source, String requiredDimension) { + PackSpec { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(requiredDimension, "requiredDimension"); + if (!key.matches("[a-z0-9_-]+") || !requiredDimension.matches("[a-z0-9_/-]+")) { + throw new IllegalArgumentException("Invalid bootstrap pack specification for '" + key + "'"); + } + } + } + + private record PackPlan( + PackSpec spec, + Path root, + boolean existed, + boolean managed, + Archive archive, + boolean replace + ) { + } + + private record PackPublication(Path root, Path backup) { + } + private record Archive(Path path, String sha256) { private Archive { Objects.requireNonNull(sha256, "sha256"); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java index b636ebfa1..fca8fe114 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java @@ -36,10 +36,12 @@ import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.Arrays; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -55,6 +57,14 @@ public final class PackDownloader { private static final String DEFAULT_OVERWORLD_PACK = "overworld"; private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld"; private static final String DEFAULT_OVERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"; + private static final String UNDERWORLD_PACK = "underworld"; + private static final String UNDERWORLD_REPOSITORY = "IrisDimensions/underworld"; + private static final String UNDERWORLD_RELEASE_URL = "https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"; + private static final List MANAGED_BETA_PACK_KEYS = List.of(DEFAULT_OVERWORLD_PACK, UNDERWORLD_PACK); + private static final Map MANAGED_BETA_PACKS = Map.of( + DEFAULT_OVERWORLD_PACK, new ManagedBetaPack(DEFAULT_OVERWORLD_REPOSITORY, DEFAULT_OVERWORLD_RELEASE_URL), + UNDERWORLD_PACK, new ManagedBetaPack(UNDERWORLD_REPOSITORY, UNDERWORLD_RELEASE_URL) + ); private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+"); private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*"); private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}"); @@ -74,6 +84,14 @@ public final class PackDownloader { return DEFAULT_OVERWORLD_PACK.equals(pack); } + public static boolean isManagedBetaPack(String pack) { + return pack != null && MANAGED_BETA_PACKS.containsKey(pack); + } + + public static List managedBetaPacks() { + return MANAGED_BETA_PACK_KEYS; + } + public static String defaultOverworldPack() { return DEFAULT_OVERWORLD_PACK; } @@ -115,8 +133,40 @@ public final class PackDownloader { } } + public static boolean isManagedBetaPackPresent(File packsFolder, String key) { + if (!isManagedBetaPack(key) || !isPackPresent(packsFolder, key)) { + return false; + } + File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key); + if (resolvedPack == null) { + return false; + } + Path primaryDimension = resolvedPack.toPath().toAbsolutePath().normalize() + .resolve("dimensions") + .resolve(key + ".json"); + return !Files.isSymbolicLink(primaryDimension) + && Files.isRegularFile(primaryDimension, LinkOption.NOFOLLOW_LINKS); + } + public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer feedback) throws IOException { - return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback); + return downloadManagedBeta(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback); + } + + public static PackInstallResult downloadManagedBeta(File packsFolder, String pack, boolean forceOverwrite, + Consumer feedback) throws IOException { + ManagedBetaPack managed = pack == null ? null : MANAGED_BETA_PACKS.get(pack); + if (managed == null) { + throw new IllegalArgumentException("Pack '" + pack + "' has no managed beta release"); + } + return download( + packsFolder, + managed.repository(), + managed.releaseUrl(), + forceOverwrite, + true, + pack, + feedback + ); } /** @@ -135,7 +185,10 @@ public final class PackDownloader { } String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref; return withDownloadLock(lockKey, () -> { - if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) { + boolean present = isManagedBetaPack(expectedKey) + ? isManagedBetaPackPresent(packsFolder, expectedKey) + : isPackPresent(packsFolder, expectedKey); + if (!forceOverwrite && present) { sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); return new PackInstallResult(expectedKey, false, false); } @@ -235,11 +288,11 @@ public final class PackDownloader { sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB)); return null; } - if (dimensions.length != 1) { - sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED)); + String selectedDimension = selectDimensionKey(dimensions, expectedKey, feedback); + if (selectedDimension == null) { return null; } - IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]); + IrisDimension dimension = data.getDimensionLoader().load(selectedDimension); if (dimension == null) { sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION)); return null; @@ -280,6 +333,29 @@ public final class PackDownloader { return new PreparedPack(key, name, validation); } + private static String selectDimensionKey(String[] dimensions, String expectedKey, + Consumer feedback) throws IOException { + if (expectedKey == null || expectedKey.isBlank()) { + if (dimensions.length != 1) { + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED)); + return null; + } + return dimensions[0]; + } + + int matches = 0; + for (String dimension : dimensions) { + if (expectedKey.equals(dimension)) { + matches++; + } + } + if (matches != 1) { + throw new IOException("Downloaded pack dimensions " + Arrays.toString(dimensions) + + " do not contain exactly one requested key '" + expectedKey + "'"); + } + return expectedKey; + } + private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared, boolean forceOverwrite, Consumer feedback) throws IOException { Path target = packsRoot.resolve(prepared.key()).normalize(); @@ -299,15 +375,19 @@ public final class PackDownloader { )); return null; } - if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) { + boolean present = isManagedBetaPack(prepared.key()) + ? isManagedBetaPackPresent(packsRoot.toFile(), prepared.key()) + : isPackPresent(packsRoot.toFile(), prepared.key()); + if (!forceOverwrite && present) { sendFeedback(feedback, IrisLanguage.plain( PackDownloadMessages.PACK_KEY_CONFLICT, MessageArgument.untrusted("key", prepared.key()) )); return null; } - if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) { - IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found)."); + if (!forceOverwrite && Files.exists(target) && !present) { + IrisLogging.warn("Replacing partial pack folder " + target + + " (required primary dimension is missing)."); } Optional loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key())); @@ -572,6 +652,10 @@ public final class PackDownloader { return DEFAULT_OVERWORLD_RELEASE_URL; } + static String underworldReleaseUrl() { + return UNDERWORLD_RELEASE_URL; + } + private static void validateGithubRef(String qualifiedRef) { String refPath = qualifiedRef.startsWith("refs/heads/") ? qualifiedRef.substring("refs/heads/".length()) @@ -597,6 +681,9 @@ public final class PackDownloader { private record PreparedPack(String key, String name, PackValidationResult validation) { } + private record ManagedBetaPack(String repository, String releaseUrl) { + } + public record PackInstallResult(String key, boolean changed, boolean restartRequired) { } diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index 81ec82249..ccd23b0c7 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -68,12 +68,13 @@ import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; -import java.util.Optional; import java.util.Locale; import java.util.Objects; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -97,20 +98,17 @@ public class StudioSVC implements IrisService { @Override public void onEnable() { - J.a(() -> { - String pack = IrisSettings.get().getGenerator().getDefaultWorldType(); + VolmitSender console = BukkitPlatform.console(); + runPackMutation(console, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, + "managed-beta-packs", () -> installMissingManagedBetaPacks(console), + "Failed to install Iris managed beta packs at startup."); - // Presence means a non-empty pack folder: an empty leftover folder must still - // trigger the install instead of shadowing it forever. - if (!PackDownloader.isPackPresent(getWorkspaceFolder(), pack)) { - if (PackDownloader.isDefaultOverworld(pack)) { - IrisLogging.info("Downloading Default Pack " + pack + " (beta release)"); - IrisServices.get(StudioSVC.class).downloadDefaultOverworld(BukkitPlatform.console(), false); - } else { - IrisLogging.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack); - } - } - }); + String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType(); + if (!PackDownloader.isManagedBetaPack(configuredPack) + && !PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) { + IrisLogging.warn("Default pack '" + configuredPack + + "' is not installed. Please download it manually with /iris download " + configuredPack); + } } @Override @@ -348,12 +346,15 @@ public class StudioSVC implements IrisService { }, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD, MessageArgument.untrusted("key", String.valueOf(key)))); } - public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) { - String key = PackDownloader.defaultOverworldPack(); + public void downloadManagedBeta(VolmitSender sender, String key, boolean forceOverwrite) { + if (!PackDownloader.isManagedBetaPack(key)) { + sender.sendMessage("Iris pack '" + key + "' does not have a managed beta release."); + return; + } runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> { - DownloadOutcome outcome = downloadDefaultOverworldLocked(sender, forceOverwrite); + DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, forceOverwrite); return finishStandalonePackMutation(sender, outcome); - }, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_IRISDIMENSIONS_OVERWORLD_BETA_RELEASE)); + }, "Failed to download IrisDimensions/" + key + " beta release."); } public void downloadBranch(VolmitSender sender, String repo, String branch, boolean forceOverwrite) { @@ -380,8 +381,8 @@ public class StudioSVC implements IrisService { } private DownloadOutcome downloadSearchLocked(VolmitSender sender, String key, boolean forceOverwrite) throws IOException { - if (PackDownloader.isDefaultOverworld(key)) { - return downloadDefaultOverworldLocked(sender, forceOverwrite); + if (PackDownloader.isManagedBetaPack(key)) { + return downloadManagedBetaLocked(sender, key, forceOverwrite); } String descriptor = key.contains("/") ? key : getListing(false).get(key); @@ -411,21 +412,52 @@ public class StudioSVC implements IrisService { return new PackListingReference(repository, ref, expectedKey); } - private DownloadOutcome downloadDefaultOverworldLocked(VolmitSender sender, boolean forceOverwrite) throws IOException { - String expectedKey = PackDownloader.defaultOverworldPack(); - if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), expectedKey)) { + private DownloadOutcome downloadManagedBetaLocked( + VolmitSender sender, + String expectedKey, + boolean forceOverwrite + ) throws IOException { + if (!forceOverwrite && PackDownloader.isManagedBetaPackPresent(getWorkspaceFolder(), expectedKey)) { sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); return DownloadOutcome.notChanged(); } - PackDownloader.PackInstallResult result = PackDownloader.downloadDefaultOverworld( + PackDownloader.PackInstallResult result = PackDownloader.downloadManagedBeta( getWorkspaceFolder(), + expectedKey, forceOverwrite, sender::sendMessage ); return DownloadOutcome.from(result); } + private boolean installMissingManagedBetaPacks(VolmitSender sender) { + boolean changed = false; + boolean restartRequired = false; + for (String key : missingManagedBetaPacks(getWorkspaceFolder())) { + IrisLogging.info("Downloading managed Iris pack " + key + " (beta release)"); + try { + DownloadOutcome outcome = downloadManagedBetaLocked(sender, key, false); + changed |= outcome.changed(); + restartRequired |= outcome.restartRequired(); + } catch (Throwable failure) { + IrisLogging.reportError("Failed to download IrisDimensions/" + key + " beta release.", failure); + sender.sendMessage("Failed to download IrisDimensions/" + key + " beta release. " + errorDetail(failure)); + } + } + return finishStandalonePackMutation(sender, new DownloadOutcome(changed, restartRequired)); + } + + static List missingManagedBetaPacks(File workspaceFolder) { + List missing = new ArrayList<>(); + for (String key : PackDownloader.managedBetaPacks()) { + if (!PackDownloader.isManagedBetaPackPresent(workspaceFolder, key)) { + missing.add(key); + } + } + return List.copyOf(missing); + } + private DownloadOutcome downloadLocked( VolmitSender sender, String repo, diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/ServerProperties.java b/core/src/main/java/art/arcane/iris/util/common/misc/ServerProperties.java index 60f32d19c..6f499eea8 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/ServerProperties.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/ServerProperties.java @@ -1,7 +1,6 @@ package art.arcane.iris.util.common.misc; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; +import art.arcane.iris.core.lifecycle.BukkitStartupPaths; import java.io.File; import java.io.FileInputStream; @@ -16,48 +15,19 @@ public class ServerProperties { public static final String LEVEL_NAME; static { - String[] args = ProcessHandle.current() - .info() - .arguments() - .orElse(new String[0]); - - String propertiesPath = "server.properties"; - String bukkitYml = "bukkit.yml"; - String levelName = null; - - for (int i = 0; i < args.length; i++) { - String arg = args[i]; - String next = i < args.length - 1 ? args[i + 1] : null; - - propertiesPath = parse(arg, next, propertiesPath, "-c", "--config"); - bukkitYml = parse(arg, next, bukkitYml, "-b", "--bukkit-settings"); - levelName = parse(arg, next, levelName, "-w", "--level-name", "--world"); + BukkitStartupPaths startupPaths; + try { + startupPaths = BukkitStartupPaths.resolveCurrent(); + } catch (IOException exception) { + throw new RuntimeException(exception); } - - SERVER_PROPERTIES = new File(propertiesPath); - BUKKIT_YML = new File(bukkitYml); - try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)){ + SERVER_PROPERTIES = startupPaths.serverProperties().toFile(); + BUKKIT_YML = startupPaths.bukkitConfiguration().toFile(); + try (FileInputStream in = new FileInputStream(SERVER_PROPERTIES)) { DATA.load(in); - } catch (IOException e) { - throw new RuntimeException(e); + } catch (IOException exception) { + throw new RuntimeException(exception); } - - if (levelName != null) LEVEL_NAME = levelName; - else LEVEL_NAME = DATA.getProperty("level-name", "world"); - } - - private static String parse( - @NotNull String current, - @Nullable String next, - String fallback, - @NotNull String @NotNull ... keys - ) { - for (String k : keys) { - if (current.equals(k) && next != null) - return next; - if (current.startsWith(k + "=") && current.length() > k.length() + 1) - return current.substring(k.length() + 1); - } - return fallback; + LEVEL_NAME = startupPaths.levelName(); } } diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 749f2f2e6..1bdc537a8 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Zu teleportierender Spieler", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index f73f91e8b..ea30b3829 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -697,6 +697,7 @@ "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á", "iris.director.commandiris.param.player_teleport": "El jugador que se teletransportará", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index 104efa52e..2365bac12 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Pelaaja teleportata", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 6188a900b..5ce24e026 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Le joueur à téléporter", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index c2ea4fe69..d3543d033 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -697,6 +697,7 @@ "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": "העולם לטלפורט", "iris.director.commandiris.param.player_teleport": "שחקן לטלפורט", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 898659028..f89faca55 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Il giocatore da teletrasportare", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index 4bc881eb1..20aabd259 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -697,6 +697,7 @@ "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": "テレポート先のワールド", "iris.director.commandiris.param.player_teleport": "テレポートさせるプレイヤー", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index 181fdb160..3714cc87f 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -697,6 +697,7 @@ "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": "순간이동할 월드", "iris.director.commandiris.param.player_teleport": "순간이동시킬 플레이어", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 31b00439f..69d7a4809 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -697,6 +697,7 @@ "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 į", "iris.director.commandiris.param.player_teleport": "Žaidėjas į teleportą", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index c9787f529..99aea4c38 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Speler naar teleporteren", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index b22d33d7d..243fb818f 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Gracz do teleportowania", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index 12c4d40a8..ce1539b76 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Jogador para teletransportar", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 6929c4232..4ae099cc6 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -697,6 +697,7 @@ "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": "Телепортироваться в мир", "iris.director.commandiris.param.player_teleport": "Игрок телепортируется", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index c5925bde1..cd1dccdc6 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Oyuncuya teleport", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 65bd009cf..2cc7d1d87 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -697,6 +697,7 @@ "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", "iris.director.commandiris.param.player_teleport": "Người chơi cần 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 d86c8e1ed..44728ccca 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -697,6 +697,7 @@ "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": "要传送到的世界", "iris.director.commandiris.param.player_teleport": "要传送的玩家", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index 04583309c..f635ab704 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -697,6 +697,7 @@ "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": "要傳送到的世界", "iris.director.commandiris.param.player_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 7b826162d..ab4358dee 100644 --- a/core/src/test/java/art/arcane/iris/core/ExactWorldSlotPathPolicyTest.java +++ b/core/src/test/java/art/arcane/iris/core/ExactWorldSlotPathPolicyTest.java @@ -1,6 +1,5 @@ package art.arcane.iris.core; -import org.bukkit.NamespacedKey; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -22,22 +21,22 @@ public class ExactWorldSlotPathPolicyTest { Path canonicalRoot = levelRoot.toRealPath(); List expectations = List.of( new SlotExpectation( - new NamespacedKey("iris", "underworld"), + new WorldSlotKey("iris", "underworld"), ExactWorldSlotPathPolicy.SlotKind.IRIS_MANAGED, "dimensions/iris/underworld" ), new SlotExpectation( - NamespacedKey.minecraft("overworld"), + WorldSlotKey.minecraft("overworld"), ExactWorldSlotPathPolicy.SlotKind.VANILLA_OVERWORLD, "dimensions/minecraft/overworld" ), new SlotExpectation( - NamespacedKey.minecraft("the_nether"), + WorldSlotKey.minecraft("the_nether"), ExactWorldSlotPathPolicy.SlotKind.VANILLA_NETHER, "dimensions/minecraft/the_nether" ), new SlotExpectation( - NamespacedKey.minecraft("the_end"), + WorldSlotKey.minecraft("the_end"), ExactWorldSlotPathPolicy.SlotKind.VANILLA_END, "dimensions/minecraft/the_end" ) @@ -63,7 +62,7 @@ public class ExactWorldSlotPathPolicyTest { ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve( levelRoot, - NamespacedKey.minecraft("the_nether") + WorldSlotKey.minecraft("the_nether") ); assertEquals(worldDirectory.toRealPath(), target.worldDirectory()); @@ -75,15 +74,15 @@ public class ExactWorldSlotPathPolicyTest { ExactWorldSlotPathPolicy.Rejection foreign = assertThrows( ExactWorldSlotPathPolicy.Rejection.class, - () -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("foreign", "world")) + () -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("foreign", "world")) ); ExactWorldSlotPathPolicy.Rejection nestedIris = assertThrows( ExactWorldSlotPathPolicy.Rejection.class, - () -> ExactWorldSlotPathPolicy.resolve(levelRoot, new NamespacedKey("iris", "nested/world")) + () -> ExactWorldSlotPathPolicy.resolve(levelRoot, new WorldSlotKey("iris", "nested/world")) ); ExactWorldSlotPathPolicy.Rejection unsupportedMinecraft = assertThrows( ExactWorldSlotPathPolicy.Rejection.class, - () -> ExactWorldSlotPathPolicy.resolve(levelRoot, NamespacedKey.minecraft("custom")) + () -> ExactWorldSlotPathPolicy.resolve(levelRoot, WorldSlotKey.minecraft("custom")) ); assertEquals(ExactWorldSlotPathPolicy.RejectionReason.FOREIGN_NAMESPACE, foreign.reason()); @@ -97,7 +96,7 @@ public class ExactWorldSlotPathPolicyTest { @Test public void validatesOnlyTheExactExpectedCandidate() throws Exception { Path levelRoot = temporaryFolder.newFolder("candidate-policy").toPath(); - NamespacedKey worldKey = NamespacedKey.minecraft("the_nether"); + WorldSlotKey worldKey = WorldSlotKey.minecraft("the_nether"); Path expected = levelRoot.toRealPath().resolve("dimensions/minecraft/the_nether"); ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.validate( @@ -136,7 +135,7 @@ public class ExactWorldSlotPathPolicyTest { ExactWorldSlotPathPolicy.Rejection.class, () -> ExactWorldSlotPathPolicy.resolve( levelRoot.resolve("child/.."), - new NamespacedKey("iris", "underworld") + new WorldSlotKey("iris", "underworld") ) ); @@ -148,41 +147,41 @@ public class ExactWorldSlotPathPolicyTest { Path linkedLevelTarget = temporaryFolder.newFolder("linked-level-target").toPath(); Path levelLink = temporaryFolder.getRoot().toPath().resolve("linked-level"); Files.createSymbolicLink(levelLink, linkedLevelTarget); - assertSymbolicLinkRejected(levelLink, new NamespacedKey("iris", "underworld")); + assertSymbolicLinkRejected(levelLink, new WorldSlotKey("iris", "underworld")); Path dimensionsLevel = temporaryFolder.newFolder("linked-dimensions").toPath(); Path externalDimensions = temporaryFolder.newFolder("external-dimensions").toPath(); Files.createSymbolicLink(dimensionsLevel.resolve("dimensions"), externalDimensions); - assertSymbolicLinkRejected(dimensionsLevel, new NamespacedKey("iris", "underworld")); + assertSymbolicLinkRejected(dimensionsLevel, new WorldSlotKey("iris", "underworld")); Path namespaceLevel = temporaryFolder.newFolder("linked-namespace").toPath(); Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions")); Path externalNamespace = temporaryFolder.newFolder("external-namespace").toPath(); Files.createSymbolicLink(dimensions.resolve("minecraft"), externalNamespace); - assertSymbolicLinkRejected(namespaceLevel, NamespacedKey.minecraft("the_nether")); + assertSymbolicLinkRejected(namespaceLevel, WorldSlotKey.minecraft("the_nether")); Path targetLevel = temporaryFolder.newFolder("linked-target").toPath(); Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/minecraft")); Path externalTarget = temporaryFolder.newFolder("external-target").toPath(); Files.createSymbolicLink(namespace.resolve("the_nether"), externalTarget); - assertSymbolicLinkRejected(targetLevel, NamespacedKey.minecraft("the_nether")); + assertSymbolicLinkRejected(targetLevel, WorldSlotKey.minecraft("the_nether")); } @Test public void rejectsNonDirectoryStorageEntries() throws Exception { Path dimensionsLevel = temporaryFolder.newFolder("file-dimensions").toPath(); Files.writeString(dimensionsLevel.resolve("dimensions"), "not a directory"); - assertUnsafeEntryRejected(dimensionsLevel, new NamespacedKey("iris", "underworld")); + assertUnsafeEntryRejected(dimensionsLevel, new WorldSlotKey("iris", "underworld")); Path namespaceLevel = temporaryFolder.newFolder("file-namespace").toPath(); Path dimensions = Files.createDirectories(namespaceLevel.resolve("dimensions")); Files.writeString(dimensions.resolve("iris"), "not a directory"); - assertUnsafeEntryRejected(namespaceLevel, new NamespacedKey("iris", "underworld")); + assertUnsafeEntryRejected(namespaceLevel, new WorldSlotKey("iris", "underworld")); Path targetLevel = temporaryFolder.newFolder("file-target").toPath(); Path namespace = Files.createDirectories(targetLevel.resolve("dimensions/iris")); Files.writeString(namespace.resolve("underworld"), "not a directory"); - assertUnsafeEntryRejected(targetLevel, new NamespacedKey("iris", "underworld")); + assertUnsafeEntryRejected(targetLevel, new WorldSlotKey("iris", "underworld")); } @Test @@ -191,13 +190,13 @@ public class ExactWorldSlotPathPolicyTest { ExactWorldSlotPathPolicy.Rejection missingFailure = assertThrows( ExactWorldSlotPathPolicy.Rejection.class, - () -> ExactWorldSlotPathPolicy.resolve(missing, new NamespacedKey("iris", "underworld")) + () -> ExactWorldSlotPathPolicy.resolve(missing, new WorldSlotKey("iris", "underworld")) ); ExactWorldSlotPathPolicy.Rejection filesystemFailure = assertThrows( ExactWorldSlotPathPolicy.Rejection.class, () -> ExactWorldSlotPathPolicy.resolve( missing.toAbsolutePath().getRoot(), - new NamespacedKey("iris", "underworld") + new WorldSlotKey("iris", "underworld") ) ); @@ -205,7 +204,7 @@ public class ExactWorldSlotPathPolicyTest { assertEquals(ExactWorldSlotPathPolicy.RejectionReason.UNSAFE_ENTRY, filesystemFailure.reason()); } - private void assertSymbolicLinkRejected(Path levelRoot, NamespacedKey worldKey) { + private void assertSymbolicLinkRejected(Path levelRoot, WorldSlotKey worldKey) { ExactWorldSlotPathPolicy.Rejection failure = assertThrows( ExactWorldSlotPathPolicy.Rejection.class, () -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey) @@ -214,7 +213,7 @@ public class ExactWorldSlotPathPolicyTest { assertEquals(ExactWorldSlotPathPolicy.RejectionReason.SYMBOLIC_LINK, failure.reason()); } - private void assertUnsafeEntryRejected(Path levelRoot, NamespacedKey worldKey) { + private void assertUnsafeEntryRejected(Path levelRoot, WorldSlotKey worldKey) { ExactWorldSlotPathPolicy.Rejection failure = assertThrows( ExactWorldSlotPathPolicy.Rejection.class, () -> ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey) @@ -224,7 +223,7 @@ public class ExactWorldSlotPathPolicyTest { } private record SlotExpectation( - NamespacedKey worldKey, + WorldSlotKey worldKey, ExactWorldSlotPathPolicy.SlotKind slotKind, String relativePath ) { diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitStartupPathsTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitStartupPathsTest.java new file mode 100644 index 000000000..109f3908b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitStartupPathsTest.java @@ -0,0 +1,260 @@ +package art.arcane.iris.core.lifecycle; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class BukkitStartupPathsTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void defaultsResolveAgainstServerWorkingDirectory() throws Exception { + Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath(); + + BukkitStartupPaths paths = BukkitStartupPaths.resolve(serverRoot, new String[0]); + + assertEquals(serverRoot, paths.serverRoot()); + assertEquals(serverRoot.resolve("server.properties"), paths.serverProperties()); + assertEquals(serverRoot.resolve("bukkit.yml"), paths.bukkitConfiguration()); + assertEquals(serverRoot, paths.worldContainer()); + assertEquals("world", paths.levelName()); + assertEquals(serverRoot.resolve("world"), paths.levelRoot()); + } + + @Test + public void shortSeparatedArgumentsOverrideRelativeDefaults() throws Exception { + Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath(); + Path configurationRoot = Files.createDirectories(serverRoot.resolve("short-config")); + Path properties = configurationRoot.resolve("custom.properties"); + Path bukkit = configurationRoot.resolve("custom-bukkit.yml"); + Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8); + Files.writeString( + bukkit, + "settings:\n world-container: relative-worlds\n", + StandardCharsets.UTF_8 + ); + + BukkitStartupPaths paths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{ + "-c", "short-config/custom.properties", + "-b", "short-config/custom-bukkit.yml", + "-w", "argument-world" + } + ); + + assertEquals(properties, paths.serverProperties()); + assertEquals(bukkit, paths.bukkitConfiguration()); + assertEquals(serverRoot.resolve("relative-worlds"), paths.worldContainer()); + assertEquals("argument-world", paths.levelName()); + assertEquals(serverRoot.resolve("relative-worlds/argument-world"), paths.levelRoot()); + } + + @Test + public void longSeparatedArgumentsUseCustomPropertiesAndWorldContainer() throws Exception { + Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath(); + Path configurationRoot = Files.createDirectories(serverRoot.resolve("long-config")); + Path properties = configurationRoot.resolve("server.properties"); + Path bukkit = configurationRoot.resolve("bukkit.yml"); + Files.writeString(properties, "level-name=ignored-property-world\n", StandardCharsets.UTF_8); + Files.writeString( + bukkit, + "settings:\n world-container: long-worlds\n", + StandardCharsets.UTF_8 + ); + + BukkitStartupPaths paths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{ + "--config", "long-config/server.properties", + "--bukkit-settings", "long-config/bukkit.yml", + "--world", "long-world" + } + ); + + assertEquals(properties, paths.serverProperties()); + assertEquals(bukkit, paths.bukkitConfiguration()); + assertEquals(serverRoot.resolve("long-worlds"), paths.worldContainer()); + assertEquals("long-world", paths.levelName()); + assertEquals(serverRoot.resolve("long-worlds/long-world"), paths.levelRoot()); + } + + @Test + public void equalsArgumentsResolveRelativePathsAndLastWorldOverride() throws Exception { + Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath(); + Path configurationRoot = Files.createDirectories(serverRoot.resolve("equals-config")); + Path properties = configurationRoot.resolve("server.properties"); + Path bukkit = configurationRoot.resolve("bukkit.yml"); + Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8); + Files.writeString( + bukkit, + "settings:\n world-container: equals-worlds\n", + StandardCharsets.UTF_8 + ); + + BukkitStartupPaths paths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{ + "--config=equals-config/server.properties", + "--bukkit-settings=equals-config/bukkit.yml", + "--world=first-world", + "-w=last-world" + } + ); + + assertEquals(properties, paths.serverProperties()); + assertEquals(bukkit, paths.bukkitConfiguration()); + assertEquals(serverRoot.resolve("equals-worlds"), paths.worldContainer()); + assertEquals("last-world", paths.levelName()); + assertEquals(serverRoot.resolve("equals-worlds/last-world"), paths.levelRoot()); + } + + @Test + public void absoluteConfigurationContainerAndLevelPathsRemainAbsolute() throws Exception { + Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath(); + Path configurationRoot = Files.createDirectories(serverRoot.resolve("absolute-config")); + Path worldContainer = Files.createDirectories(serverRoot.resolve("absolute-worlds")); + Path absoluteLevel = serverRoot.resolve("absolute-level"); + Path properties = configurationRoot.resolve("server.properties"); + Path bukkit = configurationRoot.resolve("bukkit.yml"); + Files.writeString(properties, "level-name=property-world\n", StandardCharsets.UTF_8); + Files.writeString( + bukkit, + "settings:\n world-container: '" + worldContainer + "'\n", + StandardCharsets.UTF_8 + ); + + BukkitStartupPaths propertiesPaths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{ + "--config=" + properties, + "--bukkit-settings=" + bukkit + } + ); + BukkitStartupPaths argumentPaths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{ + "-c", properties.toString(), + "-b", bukkit.toString(), + "-w", absoluteLevel.toString() + } + ); + + assertEquals(properties, propertiesPaths.serverProperties()); + assertEquals(bukkit, propertiesPaths.bukkitConfiguration()); + assertEquals(worldContainer, propertiesPaths.worldContainer()); + assertEquals(worldContainer.resolve("property-world"), propertiesPaths.levelRoot()); + assertEquals(absoluteLevel.toString(), argumentPaths.levelName()); + assertEquals(absoluteLevel, argumentPaths.levelRoot()); + } + + @Test + public void worldContainerArgumentsOverrideBukkitConfiguration() throws Exception { + Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath(); + Path bukkit = serverRoot.resolve("bukkit.yml"); + Files.writeString( + bukkit, + "settings:\n world-container: ignored-worlds\n", + StandardCharsets.UTF_8 + ); + + BukkitStartupPaths shortPaths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{"-W", "short-worlds", "--world=short-level"} + ); + BukkitStartupPaths longPaths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{"--world-dir=first-worlds", "--universe", "second-worlds"} + ); + BukkitStartupPaths explicitPaths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{"--world-container=explicit-worlds"} + ); + + assertEquals(serverRoot.resolve("short-worlds"), shortPaths.worldContainer()); + assertEquals(serverRoot.resolve("short-worlds/short-level"), shortPaths.levelRoot()); + assertEquals(serverRoot.resolve("second-worlds"), longPaths.worldContainer()); + assertEquals(serverRoot.resolve("second-worlds/world"), longPaths.levelRoot()); + assertEquals(serverRoot.resolve("explicit-worlds"), explicitPaths.worldContainer()); + assertEquals(serverRoot.resolve("explicit-worlds/world"), explicitPaths.levelRoot()); + } + + @Test + public void compactShortArgumentsMatchServerOptionParsing() throws Exception { + Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath(); + Path configurationRoot = Files.createDirectories(serverRoot.resolve("compact-config")); + Path properties = configurationRoot.resolve("server.properties"); + Path bukkit = configurationRoot.resolve("bukkit.yml"); + Files.writeString(properties, "level-name=ignored-world\n", StandardCharsets.UTF_8); + Files.writeString( + bukkit, + "settings:\n world-container: ignored-worlds\n", + StandardCharsets.UTF_8 + ); + + BukkitStartupPaths paths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{ + "-ccompact-config/server.properties", + "-bcompact-config/bukkit.yml", + "-Wcompact-worlds", + "-wcompact-level" + } + ); + + assertEquals(properties, paths.serverProperties()); + assertEquals(bukkit, paths.bukkitConfiguration()); + assertEquals(serverRoot.resolve("compact-worlds"), paths.worldContainer()); + assertEquals("compact-level", paths.levelName()); + assertEquals(serverRoot.resolve("compact-worlds/compact-level"), paths.levelRoot()); + } + + @Test + public void applicationArgumentsExcludeJvmAndLauncherOptions() { + assertArrayEquals( + new String[]{"-bconfig/bukkit.yml", "-Wworlds", "-wlevel", "-cserver.properties"}, + BukkitStartupPaths.applicationArguments(new String[]{ + "-Xmx4G", + "-jar", + "paper.jar", + "-bconfig/bukkit.yml", + "-Wworlds", + "-wlevel", + "-cserver.properties" + }) + ); + assertArrayEquals( + new String[]{"-bconfig/bukkit.yml"}, + BukkitStartupPaths.applicationArguments(new String[]{ + "-Xmx4G", + "-cp", + "paper.jar", + "org.bukkit.craftbukkit.Main", + "-bconfig/bukkit.yml" + }) + ); + } + + @Test + public void endOfOptionsStopsStartupOptionParsing() throws Exception { + Path serverRoot = temporaryFolder.getRoot().toPath().toRealPath(); + + BukkitStartupPaths paths = BukkitStartupPaths.resolve( + serverRoot, + new String[]{"--", "-bcustom-bukkit.yml", "-Wcustom-worlds", "-wcustom-level"} + ); + + assertEquals(serverRoot.resolve("bukkit.yml"), paths.bukkitConfiguration()); + assertEquals(serverRoot, paths.worldContainer()); + assertEquals("world", paths.levelName()); + assertEquals(serverRoot.resolve("world"), paths.levelRoot()); + } +} 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 6e8b1332b..b61cad9bc 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 @@ -7,13 +7,20 @@ import org.junit.rules.TemporaryFolder; import java.io.File; import java.io.IOException; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.channels.ServerSocketChannel; +import java.nio.file.FileSystems; import java.nio.file.Files; +import java.nio.file.Path; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeNoException; +import static org.junit.Assume.assumeTrue; public class BukkitWorldConfigurationTest { @Rule @@ -317,4 +324,48 @@ public class BukkitWorldConfigurationTest { assertTrue(failure.getMessage().contains("generator")); assertEquals(malformed, Files.readString(configuration.toPath())); } + + @Test + public void replacementRejectsSymbolicConfigurationWithoutChangingLinkOrTarget() throws Exception { + Path target = temporaryFolder.newFile("shared-bukkit.yml").toPath(); + String original = "settings:\n allow-end: true\n"; + Files.writeString(target, original); + Path link = temporaryFolder.getRoot().toPath().resolve("bukkit.yml"); + try { + Files.createSymbolicLink(link, target.getFileName()); + } catch (IOException | UnsupportedOperationException failure) { + assumeNoException(failure); + } + BukkitWorldConfiguration.WorldGeneratorSnapshot expected = + new BukkitWorldConfiguration.WorldGeneratorSnapshot(false, false, false, null, false, null); + + assertThrows(IOException.class, () -> BukkitWorldConfiguration.replaceIfMatching( + link.toFile(), + "world_nether", + expected, + "underworld", + 1337L + )); + + assertTrue(Files.isSymbolicLink(link)); + assertEquals(original, Files.readString(target)); + } + + @Test + public void snapshotRejectsSpecialConfigurationWithoutOpeningIt() throws Exception { + assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("unix")); + Path socket = temporaryFolder.getRoot().toPath().resolve("bukkit.yml"); + try (ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) { + try { + channel.bind(UnixDomainSocketAddress.of(socket)); + } catch (IOException | UnsupportedOperationException failure) { + assumeNoException(failure); + } + + assertThrows(IOException.class, () -> BukkitWorldConfiguration.snapshot( + socket.toFile(), + "world_nether" + )); + } + } } 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 new file mode 100644 index 000000000..12e69fa9e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -0,0 +1,386 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.core.ExactWorldSlotPathPolicy; +import art.arcane.iris.core.WorldSlotKey; +import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; +import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths; +import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase; +import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction; +import org.junit.Before; +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 java.util.List; +import java.util.UUID; + +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 WorldReplacementBootstrapTest { + private static final WorldSlotKey WORLD_KEY = WorldSlotKey.minecraft("the_nether"); + private static final long SEED = 4242424242L; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private Path serverRoot; + private Path dataDirectory; + private Path levelRoot; + private Path bukkitConfiguration; + private ExactWorldSlotPathPolicy.Target target; + + @Before + public void setUp() throws Exception { + serverRoot = temporaryFolder.newFolder("server").toPath(); + dataDirectory = Files.createDirectories(serverRoot.resolve("plugins/Iris")); + levelRoot = Files.createDirectories(serverRoot.resolve("world")); + bukkitConfiguration = Files.createFile(serverRoot.resolve("bukkit.yml")); + target = ExactWorldSlotPathPolicy.resolve(levelRoot, WORLD_KEY); + Files.createDirectories(target.namespaceRoot()); + } + + @Test + public void publishesArmedReplacementBeforeRegistryCompilation() throws Exception { + Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); + configureReplacement(transaction); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.published()); + assertEquals("replacement", replacementContent(target.worldDirectory())); + assertEquals("original", Files.readString(backup(transaction).resolve("original.txt"))); + assertEquals(Phase.PUBLISHED, loadSingle().phase()); + } + + @Test + public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception { + configureExistingReplacement(); + Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.published()); + assertEquals("replacement", replacementContent(target.worldDirectory())); + assertEquals("original", Files.readString(backup(transaction).resolve("original.txt"))); + assertEquals(Phase.PUBLISHED, loadSingle().phase()); + } + + @Test + public void retainsPublishedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception { + configureExistingReplacement(); + Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original"); + WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint()); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.retained()); + assertEquals("replacement", replacementContent(target.worldDirectory())); + assertEquals("original", Files.readString(backup(transaction).resolve("original.txt"))); + assertEquals(Phase.PUBLISHED, loadSingle().phase()); + } + + @Test + public void cancelsPreparedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception { + configureExistingReplacement(); + Transaction transaction = stagedTransaction(Phase.PREPARED, true, "original"); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.rolledBack()); + assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); + assertFalse(Files.exists(paths(transaction).stage())); + assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty()); + } + + @Test + public void resumesPublicationAfterOriginalMoveCrash() throws Exception { + Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); + configureReplacement(transaction); + ReplacementPaths paths = paths(transaction); + Files.move(paths.target(), paths.backup()); + + reconcile(); + + assertEquals("replacement", replacementContent(paths.target())); + assertEquals("original", Files.readString(paths.backup().resolve("original.txt"))); + assertEquals(Phase.PUBLISHED, loadSingle().phase()); + } + + @Test + public void cancelsPreparedTransactionWhenConfigurationWasNotApplied() throws Exception { + Transaction transaction = stagedTransaction(Phase.PREPARED, true, "original"); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.rolledBack()); + assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); + assertFalse(Files.exists(paths(transaction).stage())); + assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty()); + } + + @Test + public void restoresPublishedWorldWhenConfigurationWasReverted() throws Exception { + Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original"); + configureReplacement(transaction); + WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint()); + restoreOriginalConfiguration(transaction); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.rolledBack()); + assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); + assertFalse(Files.exists(paths(transaction).stage())); + assertFalse(Files.exists(paths(transaction).backup())); + assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty()); + } + + @Test + public void rejectsThirdPartyConfigurationAfterPublicationWithoutMovingStorage() throws Exception { + Transaction transaction = stagedTransaction(Phase.PUBLISHED, true, "original"); + configureReplacement(transaction); + WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint()); + WorldGeneratorSnapshot replacement = WorldReplacementBootstrap.replacementSnapshot(transaction); + BukkitWorldConfiguration.replaceIfMatching( + bukkitConfiguration.toFile(), + transaction.worldName(), + replacement, + "other", + SEED + ); + + assertThrows(IOException.class, this::reconcile); + + assertEquals("replacement", replacementContent(target.worldDirectory())); + assertEquals("original", Files.readString(backup(transaction).resolve("original.txt"))); + assertEquals(Phase.PUBLISHED, loadSingle().phase()); + } + + @Test + public void completesRollbackAcrossPreparedStorageCrashBoundary() throws Exception { + Transaction transaction = stagedTransaction(Phase.ROLLBACK_PENDING, true, "original"); + configureReplacement(transaction); + WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint()); + WorldReplacementFilesystem.prepareRollback(paths(transaction), true); + restoreOriginalConfiguration(transaction); + WorldReplacementJournal.write(dataDirectory, transaction.withPhase(Phase.ROLLBACK_CLEANUP)); + + reconcile(); + + assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); + assertFalse(Files.exists(paths(transaction).stage())); + assertTrue(WorldReplacementJournal.load(dataDirectory, levelRoot).isEmpty()); + } + + @Test + public void retainsVerifiedTargetWhenBackupWasAlreadyCleaned() throws Exception { + Transaction transaction = stagedTransaction(Phase.CLEANUP_PENDING, true, "original"); + configureReplacement(transaction); + WorldReplacementFilesystem.publish(paths(transaction), true, transaction.packFingerprint()); + WorldReplacementFilesystem.cleanupBackup(paths(transaction)); + + WorldReplacementBootstrap.ReconcileResult result = reconcile(); + + assertEquals(1, result.retained()); + assertEquals("replacement", replacementContent(target.worldDirectory())); + assertEquals(Phase.CLEANUP_PENDING, loadSingle().phase()); + } + + @Test + public void rejectsChangedLevelRootBeforeTouchingStagedStorage() throws Exception { + Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); + configureReplacement(transaction); + Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world")); + + assertThrows( + IOException.class, + () -> WorldReplacementBootstrap.reconcile( + dataDirectory, + otherLevelRoot, + bukkitConfiguration, + ignored -> { + } + ) + ); + + assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); + assertTrue(Files.isDirectory(paths(transaction).stage())); + assertFalse(Files.exists(paths(transaction).backup())); + } + + @Test + public void rejectsDuplicateWorldJournalsBeforePublishingEither() throws Exception { + Transaction first = stagedTransaction(Phase.ARMED, true, "original"); + configureReplacement(first); + Transaction second = new Transaction( + UUID.randomUUID(), + first.worldKey(), + first.worldName(), + first.levelRoot(), + first.dimension(), + first.seed(), + first.packFingerprint(), + first.originalConfiguration(), + first.originalTargetPresent(), + first.phase() + ); + WorldReplacementJournal.write(dataDirectory, second); + + assertThrows(IOException.class, this::reconcile); + + assertTrue(Files.isDirectory(paths(first).stage())); + assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); + assertFalse(Files.exists(paths(first).backup())); + } + + @Test + public void roundTripsBlankAndWhitespaceOriginalGenerators() throws Exception { + for (String generator : List.of("", " ")) { + WorldGeneratorSnapshot original = new WorldGeneratorSnapshot( + true, + true, + true, + generator, + false, + null + ); + Transaction transaction = transaction(UUID.randomUUID(), original, Phase.PREPARED, false, "replacement"); + WorldReplacementJournal.write(dataDirectory, transaction); + + Transaction loaded = loadSingle(); + + assertEquals(generator, loaded.originalConfiguration().generator()); + WorldReplacementJournal.delete(dataDirectory, transaction.id()); + } + } + + @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)); + } + } + + private WorldReplacementBootstrap.ReconcileResult reconcile() throws Exception { + return WorldReplacementBootstrap.reconcile( + dataDirectory, + levelRoot, + bukkitConfiguration, + ignored -> { + } + ); + } + + private Transaction stagedTransaction(Phase phase, boolean originalPresent, String originalContent) + throws Exception { + WorldGeneratorSnapshot original = BukkitWorldConfiguration.snapshot( + bukkitConfiguration.toFile(), + "world_nether" + ); + return transaction(UUID.randomUUID(), original, phase, originalPresent, originalContent); + } + + private Transaction transaction( + UUID id, + WorldGeneratorSnapshot original, + Phase phase, + boolean originalPresent, + String originalContent + ) throws Exception { + ReplacementPaths paths = WorldReplacementFilesystem.paths(target, id); + if (originalPresent) { + writeOriginalTarget(paths, originalContent); + } + Path dimension = paths.stage().resolve("iris/pack/dimensions/underworld.json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, "replacement"); + String fingerprint = WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack")); + Transaction transaction = new Transaction( + id, + WORLD_KEY, + "world_nether", + target.levelRoot(), + "underworld", + SEED, + fingerprint, + original, + originalPresent, + phase + ); + WorldReplacementJournal.write(dataDirectory, transaction); + return transaction; + } + + private void configureReplacement(Transaction transaction) throws Exception { + BukkitWorldConfiguration.GeneratorReplacement result = BukkitWorldConfiguration.replaceIfMatching( + bukkitConfiguration.toFile(), + transaction.worldName(), + transaction.originalConfiguration(), + transaction.dimension(), + transaction.seed() + ); + assertTrue(result.applied()); + } + + private void configureExistingReplacement() throws Exception { + BukkitWorldConfiguration.register( + bukkitConfiguration.toFile(), + "world_nether", + "underworld", + SEED + ); + } + + private void restoreOriginalConfiguration(Transaction transaction) throws Exception { + assertTrue(BukkitWorldConfiguration.restoreIfMatching( + bukkitConfiguration.toFile(), + transaction.worldName(), + WorldReplacementBootstrap.replacementSnapshot(transaction), + transaction.originalConfiguration() + )); + } + + private Transaction loadSingle() throws Exception { + return WorldReplacementJournal.load(dataDirectory, levelRoot).getFirst(); + } + + private ReplacementPaths paths(Transaction transaction) { + return WorldReplacementFilesystem.paths(target, transaction.id()); + } + + private Path backup(Transaction transaction) { + return paths(transaction).backup(); + } + + private void writeOriginalTarget(ReplacementPaths paths, String originalContent) throws Exception { + Files.createDirectories(paths.target().resolve("data/paper")); + Files.createDirectories(paths.target().resolve("data/minecraft")); + Files.writeString(paths.target().resolve("original.txt"), originalContent); + Files.writeString(paths.target().resolve("data/paper/metadata.dat"), "metadata"); + Files.writeString(paths.target().resolve("data/paper/level_overrides.dat"), "overrides"); + Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation"); + } + + private String replacementContent(Path worldDirectory) throws Exception { + return Files.readString(worldDirectory.resolve("iris/pack/dimensions/underworld.json")); + } +} 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 929bdde35..75e21580d 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 @@ -31,15 +31,83 @@ public class WorldReplacementFilesystemTest { public void publishesReplacementAndRetainsOriginalBackup() throws Exception { WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-existing", TRANSACTION_ID); writeOriginalTarget(paths, "original"); + Files.createDirectories(paths.target().resolve("region")); + Files.createDirectories(paths.target().resolve("entities")); + Files.createDirectories(paths.target().resolve("poi")); + Files.writeString(paths.target().resolve("region/r.0.0.mca"), "old-region"); + Files.writeString(paths.target().resolve("entities/r.0.0.mca"), "old-entities"); + Files.writeString(paths.target().resolve("poi/r.0.0.mca"), "old-poi"); String fingerprint = writeStage(paths, "replacement"); WorldReplacementFilesystem.publish(paths, true, fingerprint); assertEquals("replacement", readPackContent(paths.target())); assertEquals("original", Files.readString(paths.backup().resolve("original.txt"))); + assertEquals("metadata", Files.readString(paths.target().resolve("data/paper/metadata.dat"))); + assertEquals("overrides", Files.readString(paths.target().resolve("data/paper/level_overrides.dat"))); + assertEquals( + "generation", + Files.readString(paths.target().resolve("data/minecraft/world_gen_settings.dat")) + ); + assertFalse(Files.exists(paths.target().resolve("region/r.0.0.mca"))); + assertFalse(Files.exists(paths.target().resolve("entities/r.0.0.mca"))); + assertFalse(Files.exists(paths.target().resolve("poi/r.0.0.mca"))); + assertTrue(Files.exists(paths.backup().resolve("region/r.0.0.mca"))); + assertTrue(Files.exists(paths.backup().resolve("entities/r.0.0.mca"))); + assertTrue(Files.exists(paths.backup().resolve("poi/r.0.0.mca"))); assertFalse(Files.exists(paths.stage())); } + @Test + public void rejectsAbsentTargetForReplacementAdmission() throws Exception { + WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID); + + IOException failure = assertThrows( + IOException.class, + () -> WorldReplacementFilesystem.requireExistingTarget(paths) + ); + + assertTrue(failure.getMessage().contains("requires an existing exact world slot")); + assertFalse(Files.exists(paths.target())); + assertFalse(Files.exists(paths.stage())); + assertFalse(Files.exists(paths.backup())); + } + + @Test + public void completesMetadataForAlreadyPublishedReplacement() throws Exception { + WorldReplacementFilesystem.ReplacementPaths paths = paths("published-metadata", TRANSACTION_ID); + writeOriginalTarget(paths, "original"); + String fingerprint = writeStage(paths, "replacement"); + Files.move(paths.target(), paths.backup()); + Files.move(paths.stage(), paths.target()); + + WorldReplacementFilesystem.publish(paths, true, fingerprint); + + assertEquals("metadata", Files.readString(paths.target().resolve("data/paper/metadata.dat"))); + assertEquals("overrides", Files.readString(paths.target().resolve("data/paper/level_overrides.dat"))); + assertEquals( + "generation", + Files.readString(paths.target().resolve("data/minecraft/world_gen_settings.dat")) + ); + } + + @Test + public void rejectsUnmigratedRetainedWorldBeforePublication() throws Exception { + WorldReplacementFilesystem.ReplacementPaths paths = paths("missing-paper-metadata", TRANSACTION_ID); + Files.createDirectories(paths.target()); + Files.writeString(paths.target().resolve("original.txt"), "original"); + String fingerprint = writeStage(paths, "replacement"); + + assertThrows( + IOException.class, + () -> WorldReplacementFilesystem.publish(paths, true, fingerprint) + ); + + assertTrue(Files.isDirectory(paths.target())); + assertTrue(Files.isDirectory(paths.stage())); + assertFalse(Files.exists(paths.backup())); + } + @Test public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception { WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID); @@ -136,6 +204,37 @@ public class WorldReplacementFilesystemTest { assertFalse(Files.exists(paths.backup())); } + @Test + public void preparedRollbackCanRepublishWhenConfigurationRestoreFails() throws Exception { + WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-republish", TRANSACTION_ID); + writeOriginalTarget(paths, "original"); + String fingerprint = writeStage(paths, "replacement"); + WorldReplacementFilesystem.publish(paths, true, fingerprint); + + WorldReplacementFilesystem.prepareRollback(paths, true); + WorldReplacementFilesystem.publish(paths, true, fingerprint); + + assertEquals("replacement", readPackContent(paths.target())); + assertEquals("original", Files.readString(paths.backup().resolve("original.txt"))); + assertFalse(Files.exists(paths.stage())); + } + + @Test + public void preparedRollbackCleanupIsRetryableAfterStageDeletion() throws Exception { + WorldReplacementFilesystem.ReplacementPaths paths = paths("rollback-cleanup-retry", TRANSACTION_ID); + writeOriginalTarget(paths, "original"); + String fingerprint = writeStage(paths, "replacement"); + WorldReplacementFilesystem.publish(paths, true, fingerprint); + WorldReplacementFilesystem.prepareRollback(paths, true); + + WorldReplacementFilesystem.finishPreparedRollback(paths, true); + WorldReplacementFilesystem.finishPreparedRollback(paths, true); + + assertEquals("original", Files.readString(paths.target().resolve("original.txt"))); + assertFalse(Files.exists(paths.stage())); + assertFalse(Files.exists(paths.backup())); + } + @Test public void rejectsPackMutationBeforePublication() throws Exception { WorldReplacementFilesystem.ReplacementPaths paths = paths("fingerprint-mutation", TRANSACTION_ID); @@ -289,6 +388,11 @@ public class WorldReplacementFilesystemTest { private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception { Files.createDirectories(paths.target()); Files.writeString(paths.target().resolve("original.txt"), content); + Files.createDirectories(paths.target().resolve("data/paper")); + Files.createDirectories(paths.target().resolve("data/minecraft")); + Files.writeString(paths.target().resolve("data/paper/metadata.dat"), "metadata"); + Files.writeString(paths.target().resolve("data/paper/level_overrides.dat"), "overrides"); + Files.writeString(paths.target().resolve("data/minecraft/world_gen_settings.dat"), "generation"); } private String readPackContent(Path worldDirectory) throws Exception { 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 39d0ff76c..03b079758 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 @@ -7,6 +7,7 @@ import org.junit.Test; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.net.InetSocketAddress; import java.net.URI; import java.net.http.HttpClient; @@ -17,10 +18,14 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; @@ -31,6 +36,26 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class DefaultPackBootstrapProvisionerTest { + @Test + public void defaultBetaSourcesArePinnedPerRequiredPack() { + Map packs = new LinkedHashMap<>(); + for (DefaultPackBootstrapProvisioner.PackSpec pack : DefaultPackBootstrapProvisioner.defaultPacks()) { + packs.put(pack.key(), pack); + } + + assertEquals(2, packs.size()); + assertEquals( + URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"), + packs.get("overworld").source() + ); + assertEquals("overworld", packs.get("overworld").requiredDimension()); + assertEquals( + URI.create("https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"), + packs.get("underworld").source() + ); + assertEquals("underworld", packs.get("underworld").requiredDimension()); + } + @Test public void coldInstallUsesFreshCacheWithoutSecondRequest() throws Exception { byte[] archive = packArchive("overworld", "bootstrap_biome"); @@ -58,14 +83,23 @@ public class DefaultPackBootstrapProvisionerTest { assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status()); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status()); - assertEquals(1, requests.get()); - assertTrue(Files.isRegularFile(installed.packRoot().resolve("dimensions/overworld.json"))); + assertEquals(2, requests.get()); + assertTrue(Files.isRegularFile(installed.packRoots().get("overworld").resolve("dimensions/overworld.json"))); + assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld.json"))); + assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld_roof.json"))); assertEquals(root.resolve("datapacks/iris"), installed.datapackRoot()); assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta"))); 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)); + assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs())); 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())); } finally { server.stop(0); delete(root); @@ -101,9 +135,10 @@ public class DefaultPackBootstrapProvisionerTest { ); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, rebuilt.status()); - assertEquals(1, requests.get()); + assertEquals(2, requests.get()); assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("pack.mcmeta"))); assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json"))); + assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json"))); } finally { if (!serverStopped) { server.stop(0); @@ -138,9 +173,10 @@ public class DefaultPackBootstrapProvisionerTest { ); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status()); - assertEquals(1, requests.get()); + assertEquals(2, requests.get()); assertTrue(Files.isSymbolicLink(dataDirectory.resolve("packs"))); assertTrue(Files.isRegularFile(sharedPacks.resolve("overworld/dimensions/overworld.json"))); + assertTrue(Files.isRegularFile(sharedPacks.resolve("underworld/dimensions/underworld.json"))); assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta"))); } finally { server.stop(0); @@ -165,7 +201,7 @@ public class DefaultPackBootstrapProvisionerTest { DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> { }, options); - assertEquals(2, requests.get()); + assertEquals(3, requests.get()); try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(cache))) { assertTrue(zip.getNextEntry() != null); } @@ -245,13 +281,13 @@ public class DefaultPackBootstrapProvisionerTest { options ); - assertEquals(0, requests.get()); + assertEquals(1, requests.get()); assertTrue(Files.isSymbolicLink(link)); assertEquals(target.toRealPath(), link.toRealPath()); 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)); + assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs())); DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision( dataDirectory, ignored -> { @@ -259,7 +295,7 @@ public class DefaultPackBootstrapProvisionerTest { options ); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status()); - assertEquals(0, requests.get()); + assertEquals(2, requests.get()); assertTrue(Files.isSymbolicLink(link)); } finally { server.stop(0); @@ -280,7 +316,7 @@ 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)); + assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root, options.packs())); DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision( dataDirectory, ignored -> { @@ -289,7 +325,7 @@ public class DefaultPackBootstrapProvisionerTest { ); assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status()); - assertEquals(1, requests.get()); + assertEquals(2, requests.get()); assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json"))); assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json"))); } finally { @@ -319,14 +355,15 @@ public class DefaultPackBootstrapProvisionerTest { refreshOptions ); - assertEquals(1, requests.get()); + assertEquals(3, requests.get()); assertTrue(Files.readString(editedBiome).contains("locally_edited_biome")); assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json"))); Properties marker = new Properties(); - try (java.io.InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) { + try (InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) { marker.load(input); } - assertEquals("false", marker.getProperty("managedDefault")); + assertEquals("false", marker.getProperty("pack.overworld.managed")); + assertEquals("true", marker.getProperty("pack.underworld.managed")); } finally { server.stop(0); delete(root); @@ -334,39 +371,185 @@ public class DefaultPackBootstrapProvisionerTest { } @Test - public void failedAggregateCompilationPreservesPreviousOutputAndMarker() throws Exception { + public void underworldLocalEditRelinquishesOnlyUnderworldOwnership() throws Exception { AtomicInteger requests = new AtomicInteger(); - HttpServer server = server(packArchive("overworld", "first_biome"), requests); - Path root = Files.createTempDirectory("iris-bootstrap-rollback"); + HttpServer server = server(packArchive("overworld", "overworld_managed"), requests); + Path root = Files.createTempDirectory("iris-bootstrap-underworld-edit"); try { Path dataDirectory = root.resolve("plugins/Iris"); - DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1)); - DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision( + DefaultPackBootstrapProvisioner.ProvisionOptions initialOptions = options( + server, + root, + Duration.ofHours(1) + ); + DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> { + }, initialOptions); + Path editedBiome = dataDirectory.resolve("packs/underworld/biomes/local.json"); + Files.writeString(editedBiome, biomeJson("underworld_local_edit"), StandardCharsets.UTF_8); + DefaultPackBootstrapProvisioner.ProvisionOptions refreshOptions = options(server, root, Duration.ZERO); + + DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision( dataDirectory, ignored -> { }, - options + refreshOptions ); - byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties")); - byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta")); - Path invalidPack = dataDirectory.resolve("packs/invalid"); - Files.createDirectories(invalidPack.resolve("dimensions")); - Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8); + + assertEquals(3, requests.get()); + assertTrue(Files.readString(editedBiome).contains("underworld_local_edit")); + assertTrue(Files.isRegularFile(updated.datapackRoot() + .resolve("data/underworld/worldgen/biome/underworld_local_edit.json"))); + Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties")); + assertEquals("true", marker.getProperty("pack.overworld.managed")); + assertEquals("false", marker.getProperty("pack.underworld.managed")); + } finally { + server.stop(0); + delete(root); + } + } + + @Test + public void betaPacksUpdateIndependentlyAndRecompileOneAggregateDatapack() throws Exception { + byte[] overworld = packArchive("overworld", "overworld_first"); + byte[] underworldFirst = underworldArchive("underworld_first"); + AtomicInteger requests = new AtomicInteger(); + HttpServer initialServer = server(overworld, underworldFirst, requests); + HttpServer updateServer = null; + Path root = Files.createTempDirectory("iris-bootstrap-independent-update"); + try { + Path dataDirectory = root.resolve("plugins/Iris"); + DefaultPackBootstrapProvisioner.provision( + dataDirectory, + ignored -> { + }, + options(initialServer, root, Duration.ofHours(1)) + ); + initialServer.stop(0); + initialServer = null; + + byte[] underworldSecond = underworldArchive("underworld_second"); + updateServer = server(overworld, underworldSecond, requests); + DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision( + dataDirectory, + ignored -> { + }, + options(updateServer, root, Duration.ZERO) + ); + + assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status()); + assertEquals(4, requests.get()); + assertTrue(Files.readString(dataDirectory.resolve("packs/overworld/biomes/local.json")) + .contains("overworld_first")); + assertTrue(Files.readString(dataDirectory.resolve("packs/underworld/biomes/local.json")) + .contains("underworld_second")); + assertTrue(Files.isRegularFile(updated.datapackRoot() + .resolve("data/overworld/worldgen/biome/overworld_first.json"))); + assertTrue(Files.isRegularFile(updated.datapackRoot() + .resolve("data/underworld/worldgen/biome/underworld_second.json"))); + assertFalse(Files.exists(updated.datapackRoot() + .resolve("data/underworld/worldgen/biome/underworld_first.json"))); + } finally { + if (initialServer != null) { + initialServer.stop(0); + } + if (updateServer != null) { + updateServer.stop(0); + } + delete(root); + } + } + + @Test + public void invalidUnderworldArchivePublishesNeitherRequiredPack() throws Exception { + byte[] overworld = packArchive("overworld", "overworld_valid"); + byte[] invalidUnderworld = packArchive("underworld_roof", "roof_only"); + AtomicInteger requests = new AtomicInteger(); + HttpServer server = server(overworld, invalidUnderworld, requests); + Path root = Files.createTempDirectory("iris-bootstrap-underworld-invalid"); + try { + Path dataDirectory = root.resolve("plugins/Iris"); assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision( dataDirectory, ignored -> { }, - options + options(server, root, Duration.ZERO) )); - assertTrue(java.util.Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties")))); - assertTrue(java.util.Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta")))); + + assertEquals(2, requests.get()); + assertFalse(Files.exists(dataDirectory.resolve("packs/overworld"))); + assertFalse(Files.exists(dataDirectory.resolve("packs/underworld"))); + assertFalse(Files.exists(dataDirectory.resolve("bootstrap/provisioned.properties"))); + assertFalse(Files.exists(root.resolve("datapacks/iris"))); } finally { server.stop(0); delete(root); } } + @Test + public void failedAggregateCompilationRollsBackBothPackUpdatesAndDatapack() throws Exception { + AtomicInteger requests = new AtomicInteger(); + HttpServer initialServer = server( + packArchive("overworld", "overworld_first"), + underworldArchive("underworld_first"), + requests + ); + HttpServer updateServer = null; + Path root = Files.createTempDirectory("iris-bootstrap-rollback"); + try { + Path dataDirectory = root.resolve("plugins/Iris"); + DefaultPackBootstrapProvisioner.ProvisionResult first = DefaultPackBootstrapProvisioner.provision( + dataDirectory, + ignored -> { + }, + options(initialServer, root, Duration.ofHours(1)) + ); + initialServer.stop(0); + initialServer = null; + byte[] marker = Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties")); + byte[] metadata = Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta")); + byte[] originalOverworld = Files.readAllBytes(dataDirectory.resolve("packs/overworld/biomes/local.json")); + byte[] originalUnderworld = Files.readAllBytes(dataDirectory.resolve("packs/underworld/biomes/local.json")); + Path invalidPack = dataDirectory.resolve("packs/invalid"); + Files.createDirectories(invalidPack.resolve("dimensions")); + Files.writeString(invalidPack.resolve("dimensions/broken.json"), "{", StandardCharsets.UTF_8); + updateServer = server( + packArchive("overworld", "overworld_second"), + underworldArchive("underworld_second"), + requests + ); + DefaultPackBootstrapProvisioner.ProvisionOptions updateOptions = options( + updateServer, + root, + Duration.ZERO + ); + + assertThrows(IOException.class, () -> DefaultPackBootstrapProvisioner.provision( + dataDirectory, + ignored -> { + }, + updateOptions + )); + assertTrue(Arrays.equals(marker, Files.readAllBytes(dataDirectory.resolve("bootstrap/provisioned.properties")))); + assertTrue(Arrays.equals(metadata, Files.readAllBytes(first.datapackRoot().resolve("pack.mcmeta")))); + assertTrue(Arrays.equals(originalOverworld, + Files.readAllBytes(dataDirectory.resolve("packs/overworld/biomes/local.json")))); + assertTrue(Arrays.equals(originalUnderworld, + Files.readAllBytes(dataDirectory.resolve("packs/underworld/biomes/local.json")))); + assertNoBootstrapTransactionPaths(dataDirectory.resolve("packs")); + assertNoBootstrapTransactionPaths(root.resolve("datapacks")); + } finally { + if (initialServer != null) { + initialServer.stop(0); + } + if (updateServer != null) { + updateServer.stop(0); + } + delete(root); + } + } + @Test public void resolvesConfiguredLevelRootFromServerProperties() throws Exception { Path serverRoot = Files.createTempDirectory("iris-bootstrap-level-root"); @@ -378,7 +561,7 @@ public class DefaultPackBootstrapProvisionerTest { ); assertEquals( - serverRoot.resolve("levels/primary").normalize(), + serverRoot.toRealPath().resolve("levels/primary").normalize(), DefaultPackBootstrapProvisioner.resolveLevelRoot(serverRoot) ); } finally { @@ -391,9 +574,20 @@ public class DefaultPackBootstrapProvisionerTest { Path serverRoot, Duration refreshInterval ) { - URI source = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/overworld.zip"); + String sourceRoot = "http://127.0.0.1:" + server.getAddress().getPort(); return new DefaultPackBootstrapProvisioner.ProvisionOptions( - source, + List.of( + new DefaultPackBootstrapProvisioner.PackSpec( + "overworld", + URI.create(sourceRoot + "/overworld.zip"), + "overworld" + ), + new DefaultPackBootstrapProvisioner.PackSpec( + "underworld", + URI.create(sourceRoot + "/underworld.zip"), + "underworld" + ) + ), HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(), Clock.fixed(Instant.parse("2026-07-12T12:00:00Z"), ZoneOffset.UTC), refreshInterval, @@ -406,8 +600,17 @@ public class DefaultPackBootstrapProvisionerTest { } private static HttpServer server(byte[] response, AtomicInteger requests) throws IOException { + return server(response, underworldArchive("underworld_biome"), requests); + } + + private static HttpServer server( + byte[] overworldResponse, + byte[] underworldResponse, + AtomicInteger requests + ) throws IOException { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.createContext("/overworld.zip", exchange -> respond(exchange, response, requests)); + server.createContext("/overworld.zip", exchange -> respond(exchange, overworldResponse, requests)); + server.createContext("/underworld.zip", exchange -> respond(exchange, underworldResponse, requests)); server.start(); return server; } @@ -428,6 +631,33 @@ public class DefaultPackBootstrapProvisionerTest { return zip(files); } + private static byte[] underworldArchive(String biomeId) throws IOException { + LinkedHashMap files = new LinkedHashMap<>(); + files.put("dimensions/underworld.json", dimensionJson("underworld")); + files.put("dimensions/underworld_roof.json", dimensionJson("underworld_roof")); + files.put("regions/local.json", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}"); + files.put("biomes/local.json", biomeJson(biomeId)); + return zip(files); + } + + private static Properties loadProperties(Path path) throws IOException { + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(path)) { + properties.load(input); + } + return properties; + } + + private static void assertNoBootstrapTransactionPaths(Path root) throws IOException { + if (!Files.isDirectory(root)) { + return; + } + try (Stream stream = Files.list(root)) { + assertFalse(stream.anyMatch(path -> path.getFileName().toString().contains("-stage-") + || path.getFileName().toString().contains("-backup-"))); + } + } + private static void writePack(Path root, String dimensionKey, String biomeId) throws IOException { Files.createDirectories(root.resolve("dimensions")); Files.createDirectories(root.resolve("regions")); @@ -462,8 +692,8 @@ public class DefaultPackBootstrapProvisionerTest { if (!Files.exists(root)) { return; } - try (java.util.stream.Stream stream = Files.walk(root)) { - for (Path path : stream.sorted(java.util.Comparator.reverseOrder()).toList()) { + try (Stream stream = Files.walk(root)) { + for (Path path : stream.sorted(Comparator.reverseOrder()).toList()) { Files.deleteIfExists(path); } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index eb4c8e213..4b43e3e55 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -49,6 +49,7 @@ import java.util.zip.ZipOutputStream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -95,6 +96,60 @@ public class PackDownloaderTest { PackDownloader.defaultOverworldReleaseUrl() ); assertTrue(PackDownloader.isDefaultOverworld("overworld")); + assertTrue(PackDownloader.isManagedBetaPack("overworld")); + assertEquals(List.of("overworld", "underworld"), PackDownloader.managedBetaPacks()); + } + + @Test + public void resolvesUnderworldBetaRelease() { + assertEquals( + "https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip", + PackDownloader.underworldReleaseUrl() + ); + assertTrue(PackDownloader.isManagedBetaPack("underworld")); + } + + @Test + public void managedPackPresenceRequiresItsPrimaryDimension() throws Exception { + File packsFolder = temp.newFolder("managed-presence"); + Path dimensions = Files.createDirectories( + packsFolder.toPath().resolve("underworld/dimensions") + ); + writeDimension(packsFolder.toPath().resolve("underworld"), "underworld_roof"); + + assertTrue(PackDownloader.isPackPresent(packsFolder, "underworld")); + assertFalse(PackDownloader.isManagedBetaPackPresent(packsFolder, "underworld")); + + writeDimension(packsFolder.toPath().resolve("underworld"), "underworld"); + assertTrue(Files.isDirectory(dimensions)); + assertTrue(PackDownloader.isManagedBetaPackPresent(packsFolder, "underworld")); + } + + @Test + public void repairsManagedFolderMissingItsPrimaryDimension() throws Exception { + File packsFolder = temp.newFolder("managed-repair-packs"); + Path target = packsFolder.toPath().resolve("underworld"); + Files.createDirectories(target.resolve("dimensions")); + writeDimension(target, "underworld_roof"); + Files.writeString(target.resolve("partial.txt"), "partial", StandardCharsets.UTF_8); + File extracted = writePack(temp.newFolder("managed-repair-source").toPath(), "underworld", "new"); + writeDimension(extracted.toPath(), "underworld_roof"); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + false, + "underworld", + ignored -> { + } + ); + + assertNotNull(result); + assertTrue(result.changed()); + assertTrue(Files.isRegularFile(target.resolve("dimensions/underworld.json"))); + assertTrue(Files.isRegularFile(target.resolve("dimensions/underworld_roof.json"))); + assertFalse(Files.exists(target.resolve("partial.txt"))); + assertTransactionStateClean(packsFolder); } @Test @@ -102,6 +157,9 @@ public class PackDownloaderTest { assertFalse(PackDownloader.isDefaultOverworld("theend")); assertFalse(PackDownloader.isDefaultOverworld("")); assertFalse(PackDownloader.isDefaultOverworld(null)); + assertFalse(PackDownloader.isManagedBetaPack("theend")); + assertFalse(PackDownloader.isManagedBetaPack("")); + assertFalse(PackDownloader.isManagedBetaPack(null)); } @Test @@ -345,6 +403,71 @@ public class PackDownloaderTest { assertEquals(0, PackDownloader.downloadLockCount()); } + @Test + public void importsExpectedDimensionFromMultiDimensionPack() throws Exception { + File packsFolder = temp.newFolder("multi-dimension-packs"); + File extracted = writePack(temp.newFolder("multi-dimension-source").toPath(), "underworld", "new"); + writeDimension(extracted.toPath(), "underworld_roof"); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + false, + "underworld", + ignored -> { + } + ); + + assertEquals("underworld", result.key()); + assertTrue(result.changed()); + assertTrue(Files.isRegularFile( + packsFolder.toPath().resolve("underworld/dimensions/underworld_roof.json") + )); + assertTransactionStateClean(packsFolder); + } + + @Test + public void rejectsMultiDimensionPackWithoutExpectedDimension() throws Exception { + File packsFolder = temp.newFolder("missing-dimension-packs"); + File extracted = writePack(temp.newFolder("missing-dimension-source").toPath(), "underworld", "new"); + writeDimension(extracted.toPath(), "underworld_roof"); + + IOException failure = assertThrows(IOException.class, () -> PackDownloader.installExtractedPack( + packsFolder, + extracted, + false, + "missing", + ignored -> { + } + )); + + assertTrue(failure.getMessage().contains("missing")); + assertTrue(failure.getMessage().contains("underworld")); + assertFalse(new File(packsFolder, "missing").exists()); + assertTransactionStateClean(packsFolder); + } + + @Test + public void rejectsAmbiguousMultiDimensionPackWithoutExpectedKey() throws Exception { + File packsFolder = temp.newFolder("ambiguous-dimension-packs"); + File extracted = writePack(temp.newFolder("ambiguous-dimension-source").toPath(), "underworld", "new"); + writeDimension(extracted.toPath(), "underworld_roof"); + List feedback = new ArrayList<>(); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + false, + null, + feedback::add + ); + + assertNull(result); + assertFalse(feedback.isEmpty()); + assertFalse(new File(packsFolder, "underworld").exists()); + assertTransactionStateClean(packsFolder); + } + @Test public void concurrentImportsForSameKeyPublishOnlyOnePack() throws Exception { File packsFolder = temp.newFolder("concurrent-packs"); @@ -465,12 +588,7 @@ public class PackDownloaderTest { Files.createDirectories(root.resolve("dimensions")); Files.createDirectories(root.resolve("regions")); Files.createDirectories(root.resolve("biomes")); - Files.writeString( - root.resolve("dimensions/" + key + ".json"), - "{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256," - + "\"dimensionHeight\":{\"min\":-64,\"max\":320}}", - StandardCharsets.UTF_8 - ); + writeDimension(root, key); Files.writeString( root.resolve("regions/local.json"), "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}", @@ -485,6 +603,15 @@ public class PackDownloaderTest { return root.toFile(); } + private static void writeDimension(Path root, String key) throws IOException { + Files.writeString( + root.resolve("dimensions/" + key + ".json"), + "{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256," + + "\"dimensionHeight\":{\"min\":-64,\"max\":320}}", + StandardCharsets.UTF_8 + ); + } + private static void writeArchive(Path archive, Map entries) throws IOException { try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) { for (Map.Entry entry : entries.entrySet()) { diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCManagedBetaPackTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCManagedBetaPackTest.java new file mode 100644 index 000000000..044a9d2e3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCManagedBetaPackTest.java @@ -0,0 +1,78 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.service; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; + +public class StudioSVCManagedBetaPackTest { + @Test + public void startupSelectsOnlyMissingManagedBetaPacks() throws IOException { + Path workspace = Files.createTempDirectory("iris-managed-beta-startup"); + try { + assertEquals( + List.of("overworld", "underworld"), + StudioSVC.missingManagedBetaPacks(workspace.toFile()) + ); + + createPack(workspace, "overworld"); + assertEquals( + List.of("underworld"), + StudioSVC.missingManagedBetaPacks(workspace.toFile()) + ); + + createDimension(workspace, "underworld", "underworld_roof"); + assertEquals( + List.of("underworld"), + StudioSVC.missingManagedBetaPacks(workspace.toFile()) + ); + + createPack(workspace, "underworld"); + assertEquals(List.of(), StudioSVC.missingManagedBetaPacks(workspace.toFile())); + } finally { + deleteTree(workspace); + } + } + + private static void createPack(Path workspace, String key) throws IOException { + createDimension(workspace, key, key); + } + + private static void createDimension(Path workspace, String folder, String key) throws IOException { + Path dimensions = Files.createDirectories(workspace.resolve(folder).resolve("dimensions")); + Files.writeString(dimensions.resolve(key + ".json"), "{}", StandardCharsets.UTF_8); + } + + private static void deleteTree(Path root) throws IOException { + try (Stream paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.comparingInt(Path::getNameCount).reversed()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/docs/01 - Installation & Platforms.md b/docs/01 - Installation & Platforms.md index b86abbb19..de81f70a2 100644 --- a/docs/01 - Installation & Platforms.md +++ b/docs/01 - Installation & Platforms.md @@ -1,13 +1,13 @@ # 01 - Installation & Platforms -Iris installs as either a Bukkit-family plugin jar or a self-contained Fabric, Forge, or NeoForge mod jar. Java 25 is required on every platform. On first boot the default `overworld` pack is downloaded when missing; packs live under each platform’s data directory. +Iris installs as either a Bukkit-family plugin jar or a self-contained Fabric, Forge, or NeoForge mod jar. Java 25 is required on every platform. On first boot the managed `overworld` and `underworld` beta packs are downloaded when missing; packs live under each platform’s data directory. ## Installation outcome Complete one platform path below. A successful install has all three results: 1. Iris reaches its enabled/ready state without an exception. -2. The platform data directory contains `settings.json` and a loadable `packs/overworld/` directory. +2. The platform data directory contains `settings.json` and loadable `packs/overworld/` and `packs/underworld/` directories. 3. `/iris` prints help from the server console. On a modded client, the Iris keybind category is an additional client-side check, not a substitute for the server check. Keep the previous jar/mod and the entire Iris data directory until the new build passes these checks. Replacing the binary does not update pack snapshots already stored inside worlds. @@ -22,7 +22,7 @@ Keep the previous jar/mod and the entire Iris data directory until the new build | Fabric Loader | 0.19.3+ | | Forge | 65.0.4+ | | NeoForge | 26.2.0.12-beta+ | -| Network | Outbound HTTPS on first boot for default pack download (GitHub IrisDimensions overworld release / pack install) | +| Network | Outbound HTTPS on first boot for the GitHub IrisDimensions Overworld and Underworld beta release assets | Before replacing an existing installation: @@ -37,7 +37,7 @@ Do not copy multiple Iris platform jars into the same `plugins/` or `mods/` dire 1. Place the CraftBukkit-labelled plugin jar into `plugins/`. 2. Start the server. Iris loads at `STARTUP` (`plugin.yml` / `paper-plugin.yml`). -3. On first boot Iris provisions the default `overworld` pack into `plugins/Iris/packs/overworld` when missing (source: IrisDimensions overworld `beta` release zip). +3. On first boot Iris provisions `overworld` and `underworld` into `plugins/Iris/packs/` when missing from their IrisDimensions `beta` release ZIPs. 4. Settings are written at `plugins/Iris/settings.json` if absent (`IrisSettings.read()`). Validate the plugin install from the server console: @@ -45,6 +45,7 @@ Validate the plugin install from the server console: ```text /iris version /iris pack validate pack=overworld +/iris pack validate pack=underworld ``` The first command must report the running Iris, platform, and Minecraft versions. The second must resolve the downloaded pack and finish without blocking validation errors. Then complete the disposable-world workflow in `02 - Getting Started.md`; a command response alone does not prove that the generator can create chunks. @@ -66,16 +67,17 @@ Before creating a real world, run the Bukkit fresh-install smoke in `31 - Operat 1. Place the matching mod jar into `mods/`. 2. Start the dedicated server (or a client for singleplayer; see below). 3. The jar is self-contained: core, SPI, and required Fabric API modules are bundled where applicable. Mod id: `irisworldgen`. -4. On first boot, if `config/irisworldgen/modded.json` has `autoDownloadDefaultPack` true (default) and `defaultPack` (default `overworld`) is missing, Iris downloads `IrisDimensions/` (branch `master` for the auto-prefetch path) into the packs folder before the forced worldgen datapack is written. +4. On first boot, if `config/irisworldgen/modded.json` has `autoDownloadDefaultPack` true (default), Iris installs the managed `overworld` and `underworld` beta releases when missing, followed by a configured non-managed `defaultPack` when applicable, before the forced worldgen datapack is written. Validate the server-side mod install: ```text /iris version /iris pack validate overworld +/iris pack validate underworld ``` -The install passes when Iris reports the expected loader/version, `config/irisworldgen/packs/overworld/dimensions/` contains a dimension JSON file, and validation has no blocking errors. Restart once before creating a world if the pack or its generated dimension-type datapack was installed during this boot. +The install passes when Iris reports the expected loader/version, both managed pack directories contain their primary dimension JSON, and validation has no blocking errors. Restart once before creating a world if a pack or its generated dimension-type datapack was installed during this boot. Packs installed later register custom dimension types (height ranges) and custom biomes through the forced datapack at server start. **Restart once after adding a pack** so worlds get full heights and biomes. Worlds created before that restart run with fallback heights. @@ -118,7 +120,7 @@ Pack resolution for engines, commands, and the forced datapack uses `config/iris | Key | Default | Effect | |---|---|---| | `defaultPack` | `overworld` | Pack auto-download and default create pack name | -| `autoDownloadDefaultPack` | `true` | Async prefetch at boot when pack missing | +| `autoDownloadDefaultPack` | `true` | Async prefetch of both managed beta packs and any configured non-managed default when missing | | `primaryWorld` | `""` | Primary-world router target dimension id | | `routePlayersToPrimaryWorld` | `true` | Route players from vanilla overworld when primary is set | | `mainWorldPack` | `""` | Main-world generator override pack ref | @@ -142,17 +144,17 @@ Full key list: `03 - Configuration.md`. | Platform | Behavior | |---|---| -| Plugin | `DefaultPackBootstrapProvisioner` downloads `https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip` into `packs/overworld` when not already provisioned | -| Mod | If `autoDownloadDefaultPack` and pack missing, async install of configured `defaultPack` into `config/irisworldgen/packs` | +| Plugin | `DefaultPackBootstrapProvisioner` independently manages the Overworld and Underworld beta assets under `packs/overworld` and `packs/underworld`, then compiles the aggregate datapack once | +| Mod | If `autoDownloadDefaultPack` is enabled, async install of both managed beta packs plus any distinct configured default into `config/irisworldgen/packs` | -Manual install: `/iris download ` (alias `dl`). Default overworld uses the beta-release path; other packs use `IrisDimensions//` (plugin default branch `stable` for non-default; mod download defaults branch `stable` unless auto-prefetch uses `master` — see `25 - Pack Management.md`). +Manual install: `/iris download ` (alias `dl`). `overworld` and `underworld` use their beta-release assets and ignore the branch argument; other packs use `IrisDimensions//` (default branch `stable` — see `25 - Pack Management.md`). ## Installation recovery | Symptom | Check | Recovery | |---|---|---| | Iris does not appear in `/iris version` | Wrong directory, wrong platform jar, duplicate jar, Java mismatch, or an enable exception | Stop the server, keep only the matching artifact, confirm Java 25, and fix the first Iris exception in the startup log | -| `settings.json` exists but `packs/overworld` does not | Default-pack download failed or is still incomplete | Restore outbound HTTPS or install a complete pack, then restart; do not create an empty `overworld` folder | +| `settings.json` exists but a managed pack is absent | Managed beta download failed or is still incomplete | Restore outbound HTTPS or install the complete release pack, then restart; do not create an empty pack folder | | Pack validates but modded height/biomes use fallbacks | Forced datapack was generated after registries loaded | Restart once with the pack already installed, then create a new disposable world | | Bukkit command is denied for a non-op | `iris.all` is missing | Grant `iris.all`; `iris.treefeller` controls only survival tree felling | | Client HUD is absent but server commands work | Client mod missing, disabled keybind, or server capability not negotiated | Install the matching client mod, reconnect, and verify the Iris keybind category; server generation does not require the client HUD | diff --git a/docs/02 - Getting Started.md b/docs/02 - Getting Started.md index ec542e945..71f636f93 100644 --- a/docs/02 - Getting Started.md +++ b/docs/02 - Getting Started.md @@ -15,7 +15,7 @@ Treat each numbered section as a gate. Confirm the world is loaded before telepo - Iris installed per `01 - Installation & Platforms.md` - Java 25 server or mod instance running - Operator / gamemaster access (`iris` commands; modded mutating commands require permission level 2 / gamemasters) -- Default pack present (auto-downloaded on first boot) or an installed pack under the platform packs directory +- Managed Overworld and Underworld packs present (auto-downloaded on first boot) or the required project pack installed under the platform packs directory ## Argument style @@ -263,7 +263,7 @@ The session passes when the production world loads again after a clean restart a | Bukkit optional args without `key=` | Parse error | Use `seed=1337`, not a bare second number for optional params | | Mod pregen while another job runs | Start fails | `/iris pregen stop` then start again | | Studio closed mid-edit | World discarded | Edits on disk in `packs/` remain; reopen studio | -| Default pack download blocked | Create/open fails missing pack | Allow HTTPS or `/iris download overworld` offline install of a pack tree | +| Managed pack download blocked | Startup or create/open fails with a missing pack | Allow HTTPS or install with `/iris download overworld` and `/iris download underworld`; an offline install must contain each complete pack tree | | `type=default` vs pack key | Resolves via `generator.defaultWorldType` | Prefer explicit `type=overworld` or your pack key | ## Quick reference diff --git a/docs/03 - Configuration.md b/docs/03 - Configuration.md index 9bc75a76c..4f222997e 100644 --- a/docs/03 - Configuration.md +++ b/docs/03 - Configuration.md @@ -244,8 +244,8 @@ Path: `/irisworldgen/modded.json`. Written with defaults on first loa | Key | Type | Default | Notes | |-----|------|---------|-------| -| `defaultPack` | string | `"overworld"` | Default pack for bootstrap download/install | -| `autoDownloadDefaultPack` | boolean | `true` | Download default pack when missing | +| `defaultPack` | string | `"overworld"` | Default create pack; a distinct non-managed value is also prefetched when enabled | +| `autoDownloadDefaultPack` | boolean | `true` | Download missing managed Overworld/Underworld beta packs and any distinct configured default | | `primaryWorld` | string | `""` | Primary Iris dimension id for player routing | | `routePlayersToPrimaryWorld` | boolean | `true` | Route players to primary when set | | `mainWorldPack` | string | `""` | Pack (or `pack:dimensionKey`) for main-world preset | diff --git a/docs/04 - Commands & Permissions.md b/docs/04 - Commands & Permissions.md index d66f86636..98fec2f6a 100644 --- a/docs/04 - Commands & Permissions.md +++ b/docs/04 - Commands & Permissions.md @@ -79,7 +79,7 @@ Tree feller on mod loaders uses platform permission APIs (`irisworldgen:treefell | `load` | `import` | **Bukkit** | `` | Load managed Iris world | | `unload` | | **Bukkit** | `` | Unload Iris world | | `debug` | | Both | — | Toggle `general.debug` and save settings | -| `download` | `dl` | Both | ` [branch=stable] [overwrite=false]` (`overwrite` alias `force`) | Download pack project | +| `download` | `dl` | Both | ` [branch=stable] [overwrite=false]` (`overwrite` alias `force`) | Download a pack; `overworld` and `underworld` resolve to managed beta release ZIPs | | `metrics` | `measure` | Both | — | Generation metrics (player / current Iris level) | | `reload` | | Both | — | Reload `settings.json` and locale; modded also schedules forced datapack regeneration | | `seed` | | **Modded** | — | Print world/engine seeds (gamemaster) | @@ -102,7 +102,7 @@ Tree feller on mod loaders uses platform permission APIs (`irisworldgen:treefell --- -On Bukkit, `overwrite=true` is deliberately restart-only. The name may resolve to a safe `iris:*` world or exactly the configured main, `_nether`, or `_the_end` alias; arbitrary `minecraft:*` and foreign namespaces are rejected. Iris stages and validates a fresh pack snapshot, compare-and-swaps only that world's `bukkit.yml` generator and seed, and retains the existing dimension folder as a rollback backup until the restarted world proves its Iris identity, pack, dimension, environment, and seed. Multiple distinct slots may be staged before one restart. `main=true` is valid with overwrite only when the name is the configured main-world name. Exact vanilla slots preserve the authoritative seed shared by the existing level, regardless of the supplied `seed`; this keeps Overworld/Nether/End coordinate generation aligned. Use ordinary new-main promotion when a new level seed is required. +On Paper-family servers, `overwrite=true` is deliberately restart-only; Spigot rejects it because it has no pre-registry plugin bootstrap. The exact target dimension folder must already exist; use ordinary `/iris create` for a new world. The name may resolve to a safe `iris:*` world or exactly the configured main, `_nether`, or `_the_end` alias; arbitrary `minecraft:*` and foreign namespaces are rejected. Iris stages and validates a fresh pack snapshot, compare-and-swaps only that world's `bukkit.yml` generator and seed, and retains the existing dimension folder as a rollback backup until the restarted world proves its Iris identity, pack, dimension, environment, and seed. Multiple distinct slots may be staged before one restart. `main=true` is valid with overwrite only when the name is the configured main-world name. Exact vanilla slots preserve the authoritative seed shared by the existing level, regardless of the supplied `seed`; this keeps Overworld/Nether/End coordinate generation aligned. Use ordinary new-main promotion when a new level seed is required. --- diff --git a/docs/05 - Concepts & Pack Layout.md b/docs/05 - Concepts & Pack Layout.md index 01711a3e7..b9b379a87 100644 --- a/docs/05 - Concepts & Pack Layout.md +++ b/docs/05 - Concepts & Pack Layout.md @@ -103,7 +103,7 @@ Live overworld also contains authoring-only or empty trees that loaders do not r 2. `dimensions/` is missing. 3. `dimensions/` has no `*.json` files. -A downloaded archive is also rejected unless it contains exactly one loadable dimension (install key becomes that dimension’s load key). Presence of a pack on disk is defined as a safe pack directory with at least one non-symlink `dimensions/*.json` file. +A downloaded archive without an expected key is rejected unless it contains exactly one loadable dimension, whose load key becomes the install key. Managed-release and listing downloads carry an exact expected primary key, so their archive may retain additional dimension resources; the expected dimension selects the folder key and the entire pack still validates before publication. Presence of a pack on disk is defined as a safe pack directory with at least one non-symlink `dimensions/*.json` file. ## Snippets diff --git a/docs/06 - Worlds & Lifecycle.md b/docs/06 - Worlds & Lifecycle.md index 7e45631e4..4ab78bcbe 100644 --- a/docs/06 - Worlds & Lifecycle.md +++ b/docs/06 - Worlds & Lifecycle.md @@ -126,11 +126,11 @@ Runtime world creation is disabled on Folia. `/iris create` instead: ## Exact world-slot replacement -`overwrite=true` uses lifecycle kind `WORLD_REPLACE` and always stages for restart on Bukkit-family servers, including Paper and Folia. It accepts safe `iris:*` keys and only the three exact vanilla slots resolved from the configured level name: `minecraft:overworld`, `minecraft:the_nether`, and `minecraft:the_end`. A vanilla slot requires a matching pack environment (`NORMAL`, `NETHER`, or `THE_END`), and Nether/End replacement requires the server's matching allow setting to be enabled; foreign namespaces, other `minecraft:*` keys, path traversal, links, and special filesystem entries fail closed. `main=true` may accompany overwrite only for the configured main-world name. Minecraft stores one authoritative seed for the existing level, so all three exact vanilla slots preserve that loaded primary-world seed and report when it differs from the command's `seed`; changing the level seed remains the ordinary new-main promotion workflow. +`overwrite=true` uses lifecycle kind `WORLD_REPLACE` and always stages for a complete Paper-family restart, including Paper, Purpur, Leaf, and Folia; Spigot has no early registry bootstrap and rejects this mode. The exact target dimension folder must already exist; ordinary create remains the path for a new world. It accepts safe `iris:*` keys and only the three exact vanilla slots resolved from the configured level name: `minecraft:overworld`, `minecraft:the_nether`, and `minecraft:the_end`. A vanilla slot requires a matching pack environment (`NORMAL`, `NETHER`, or `THE_END`), and Nether/End replacement requires the server's matching allow setting to be enabled; foreign namespaces, other `minecraft:*` keys, path traversal, links, and special filesystem entries fail closed. `main=true` may accompany overwrite only for the configured main-world name. Minecraft stores one authoritative seed for the existing level, so all three exact vanilla slots preserve that loaded primary-world seed and report when it differs from the command's `seed`; changing the level seed remains the ordinary new-main promotion workflow. -The transaction copies and validates a fresh frozen pack under a same-filesystem sibling stage, fingerprints it, journals the original target state and `bukkit.yml` generator/seed, then compare-and-swaps that one configuration entry. Distinct slots can be queued before one restart. During Iris `STARTUP`, before Bukkit loads worlds, each authorized transaction atomically moves the old exact dimension directory to a retained sibling backup and publishes its stage. There is no chunk merge: old region, entity, POI, and Iris data remain only in the backup, while the target starts with the staged pack snapshot. +The transaction copies and validates a fresh frozen pack under a same-filesystem sibling stage, fingerprints it, binds its journal to the canonical level root and logical world name, records the original target and `bukkit.yml` generator/seed, then compare-and-swaps that one configuration entry. Distinct slots can be queued before one restart. Paper bootstrap reconciles each authorized transaction before Iris compiles its aggregate datapack or Minecraft builds registries: it atomically moves the old exact dimension directory to a retained sibling backup and publishes the stage. The filesystem must support atomic replacement for the world directories, journal, and `bukkit.yml`; Iris refuses the operation without falling back to a non-atomic destructive move. Publication retains Paper's per-world `data/paper/metadata.dat`, `data/paper/level_overrides.dat`, and `data/minecraft/world_gen_settings.dat` so the replacement keeps the exact slot metadata and authoritative seed. Old `region`, `entities`, `poi`, and Iris runtime data are never merged; they remain only in the backup while the target starts with the staged pack snapshot. -The backup is deleted only after `WorldLoad` proves the exact namespaced identity, Iris generator, selected dimension, seed, vanilla-slot environment, and unchanged pack fingerprint. A failed runtime check journals rollback, restores the prior `bukkit.yml` generator/seed with compare-and-swap semantics, requests another restart, and restores the retained directory before that restart loads worlds. A crash between either atomic move or journal write is retried idempotently. Conflicting manual configuration, changed staged bytes, unsafe storage, or corrupt journals block Iris world admission and preserve the stage/backup for operator recovery instead of guessing or deleting. +The backup is eligible for asynchronous deletion only after `WorldLoad` proves the exact namespaced identity, Iris generator, selected dimension, seed, vanilla-slot environment, and unchanged pack fingerprint; cleanup failure retains its committed journal and retries without rolling back a verified world. A failed runtime check journals rollback and requests another restart, then cold bootstrap restores the retained directory and prior `bukkit.yml` generator/seed before registry or world loading. A crash between any move, configuration write, or journal phase is retried idempotently. Conflicting manual configuration, changed roots or logical names, changed staged bytes, unsafe storage, duplicate/corrupt journals, or irreconcilable transaction state abort the early bootstrap and preserve recoverable artifacts instead of guessing or deleting. ## Studio create diff --git a/docs/25 - Pack Management.md b/docs/25 - Pack Management.md index 4c8050061..c70e812a4 100644 --- a/docs/25 - Pack Management.md +++ b/docs/25 - Pack Management.md @@ -34,13 +34,13 @@ If validation reports a missing edge, restore or repair that resource before pac | Command | Behavior | |---------|----------| | Bukkit: `/iris download [branch=stable] [overwrite=false]`; modded: `/iris download [branch] [force]` | Download into packs root | -| Default overworld special case | Pack name `overworld` uses IrisDimensions overworld **beta release zip** (`…/releases/download/beta/overworld.zip`), not an arbitrary branch zip | +| Managed beta packs | Pack names `overworld` and `underworld` use their IrisDimensions **beta release ZIPs**, not arbitrary branch archives | | Other packs | `IrisDimensions//` GitHub archive search via `StudioSVC.downloadSearch` | | Param | Default | Notes | |-------|---------|-------| | `pack` | required | Folder/key or repo short name | -| `branch` | `stable` | GitHub ref when not default overworld | +| `branch` | `stable` | GitHub ref when the pack is not a managed beta | | `overwrite` | `false` | Force replace existing present pack | ### Install pipeline (`PackDownloader`) @@ -48,12 +48,12 @@ If validation reports a missing edge, restore or repair that resource before pac 1. Per-key/ref download lock (concurrent startup and commands do not double-fetch). 2. If pack present and not force → skip network. 3. Download zip (size/entry limits: archive ≤512MiB, ≤100k entries, total uncompressed budget, per-file cap). -4. Unpack to temp; require single pack home directory. -5. Open as datapack-compiler `IrisData`; require **exactly one** dimension; key = that dimension load key. +4. Unpack to temp; require a single pack home directory. +5. Open as datapack-compiler `IrisData`. A normal download without an expected key requires exactly one dimension; a managed or listing download selects its exact expected dimension while retaining and validating any additional dimension resources in the same pack. 6. Run `PackValidator.validate`; blocking errors abort install. 7. Publish into `packs//` with conflict checks (refuses symlink targets; detects dimension-key conflicts with other folders). -Default overworld repository constant: `IrisDimensions/overworld`. +Managed beta sources are `IrisDimensions/overworld` (`overworld.zip`) and `IrisDimensions/underworld` (`underworld.zip`). Startup treats their ownership independently: an operator-edited or linked pack is preserved without preventing the other managed pack from updating. ## Validate diff --git a/docs/27 - Example - Configuring Overworld.md b/docs/27 - Example - Configuring Overworld.md index 7b1b81435..707e6b3b4 100644 --- a/docs/27 - Example - Configuring Overworld.md +++ b/docs/27 - Example - Configuring Overworld.md @@ -109,7 +109,7 @@ Worlds created from a pack store a **copy** at: `StudioSVC.installIntoWorld` and `replaceIntoWorld` copy the source pack tree into that directory. Runtime generation for a normal world reads the world copy, not the global `packs/` tree. Studio worlds hotload the pack under `packs/` directly. -First install often downloads the default overworld release into `packs/` (`downloadDefaultOverworld` / `/iris download` flows — see `02 - Getting Started.md`, `25 - Pack Management.md`). +First install downloads the managed Overworld and Underworld beta releases into `packs/`; `/iris download overworld` uses the same Overworld asset (see `02 - Getting Started.md`, `25 - Pack Management.md`). ## High-level layout (shipping overworld) diff --git a/docs/30 - Platform Differences.md b/docs/30 - Platform Differences.md index a96b41c3d..8f9fe1d65 100644 --- a/docs/30 - Platform Differences.md +++ b/docs/30 - Platform Differences.md @@ -41,7 +41,7 @@ Hotload: Bukkit file-watch engine; modded 3s poll. Same invalidate/reload/locale | Concern | Bukkit | Modded | |---------|--------|--------| -| Create | `/iris create` → managed world name, generator Iris, optional main-world; `overwrite=true` stages exact Iris/vanilla-slot replacement for restart | `/iris create` or `/iris world enable` → dimension id + pack injection | +| Create | `/iris create` → managed world name, generator Iris, optional main-world; on Paper-family servers `overwrite=true` stages replacement of an existing exact Iris/vanilla slot for restart | `/iris create` or `/iris world enable` → dimension id + pack injection | | Load / unload | `/iris load` (`import`), `/iris unload` | `/iris world disable` unloads; no separate load command | | Remove / delete | `/iris remove` optional folder delete | `/iris world delete` wipes chunk/mantle data | | Primary / main world | create `main=true` for a new level root, or name the configured main with `overwrite=true` for journaled in-place dimension replacement | `modded.json` primary + `routePlayersToPrimaryWorld`; `/iris world mainworld`, `replace-overworld` | @@ -49,7 +49,7 @@ Hotload: Bukkit file-watch engine; modded 3s poll. Same invalidate/reload/locale | Studio world | Transient studio world via StudioSVC; `/iris jigsaw` can select the Jigsaw Studio generator for one activation | Studio dimension under `irisworldgen:studio_*`; no Jigsaw Studio authoring command tree | | Folia | Regionized schedulers; pregen `runtimeSchedulerMode` forces `FOLIA` when regionized | N/A (not Bukkit Folia) | -Default pack bootstrap still downloads the IrisDimensions overworld release into `packs/overworld` when missing (shared provisioner). +Startup installs the IrisDimensions Overworld and Underworld beta releases into `packs/overworld` and `packs/underworld` when missing. Paper bootstrap publishes both in one rollback scope before compiling the aggregate datapack; legacy Bukkit and modded startup use the same managed release sources. Modded startup quarantines a corrupt persistent-dimension registry as `iris-dimensions.json.broken-` and continues without those dynamic worlds. Recovery details are in `06 - Worlds & Lifecycle.md`. @@ -76,7 +76,7 @@ Jigsaw pack resources are shared runtime data, but in-game Jigsaw Studio is not | Jigsaw Studio create/grid/marker capture/rules/export | yes | no | no | no | | Saved planar/spatial Iris jigsaw runtime | yes | yes | yes | yes | | Pack validate / cleanup / download | yes | yes | yes | yes | -| Exact restart replacement of configured Overworld/Nether/End slots | yes | no | no | no | +| Exact restart replacement of configured Overworld/Nether/End slots | Paper/Purpur/Leaf/Folia | no | no | no | | Pregen | yes (Paper-like / Folia modes) | yes (`moddedPregenInFlight`) | yes | yes | | Studio open/close/vscode/package | yes | yes | yes | yes | | Object wand / paste / save / undo | yes | yes | yes | yes | diff --git a/docs/31 - Operator Runbooks & Smoke Tests.md b/docs/31 - Operator Runbooks & Smoke Tests.md index 2295690f7..a40d3adc9 100644 --- a/docs/31 - Operator Runbooks & Smoke Tests.md +++ b/docs/31 - Operator Runbooks & Smoke Tests.md @@ -25,7 +25,7 @@ GoldenHash details and file layout: `32 - Determinism & Goldenhash.md`. ## A. Fresh install and first world (Bukkit-family) 1. Install the CraftBukkit-family jar into `plugins/` (Paper, Purpur, Folia, Spigot, Leaf, Canvas as advertised). Require Java 25. See `01 - Installation & Platforms.md`. -2. Start the server once. Confirm Iris enables, default pack download completes when no pack is present, and `settings.json` is written under the Iris data directory. +2. Start the server once. Confirm Iris enables, managed `overworld` and `underworld` beta downloads complete when absent, and `settings.json` is written under the Iris data directory. 3. Create a world with a fixed seed and teleport into it: ``` @@ -36,21 +36,21 @@ GoldenHash details and file layout: `32 - Determinism & Goldenhash.md`. 4. Join or teleport into the world. Confirm non-empty terrain, surface biomes, and no repeating console stack traces on first chunks. 5. Gate: world is loaded as an Iris world; chunks generate without enable-time crash; console shows no fatal engine init failure. -## A.1 Exact vanilla-slot replacement (Bukkit-family) +## A.1 Exact vanilla-slot replacement (Paper-family) -Use a disposable server whose configured level name is `world`, with a valid `NETHER` Iris pack and a generated vanilla Nether containing a unique marker chunk. Record hashes of the old Nether `region`, `entities`, and `poi` files before staging. +Use a disposable server whose configured level name is `world`, with a valid `NETHER` Iris pack and a generated vanilla Nether containing a unique marker chunk. Record hashes of the old Nether `region`, `entities`, and `poi` files, and retain copies of `data/paper/metadata.dat`, `data/paper/level_overrides.dat`, and `data/minecraft/world_gen_settings.dat` for comparison before staging. 1. Run `/iris create world_nether type= seed=1337 overwrite=true`. Gate: the command says the replacement is staged, the loaded Nether and its files remain unchanged, `bukkit.yml` now names `Iris:`, and one pending replacement journal plus one sibling stage exists. 2. Optionally stage the configured main name with a `NORMAL` pack and the End alias with a `THE_END` pack. Gate: each distinct slot gets its own transaction and no live dimension folder is moved. -3. Restart normally. Gate: Iris publishes before Bukkit world loading; `minecraft:the_nether` loads with the Iris generator, requested dimension and seed; its frozen `iris/pack` exists; no old `region`, `entities`, or `poi` file was merged into the target; and the marker chunk is absent. -4. Gate after `WorldLoad`: the retained sibling backup and journal disappear only after identity, environment, seed, dimension, and pack-fingerprint verification succeeds. +3. Restart normally. Gate: Iris publishes before aggregate-datapack compilation and Bukkit world loading; `minecraft:the_nether` loads with the selected Iris dimension, the prior Paper per-world metadata files, and the authoritative shared level seed; its frozen `iris/pack` exists; no old `region`, `entities`, or `poi` file was merged into the target; and the marker chunk is absent. +4. Gate after `WorldLoad`: the journal advances to committed cleanup only after identity, environment, seed, dimension, and pack-fingerprint verification succeeds; asynchronous cleanup then removes the retained sibling backup and journal without stalling the world thread. 5. Restart again and generate fresh Nether chunks. Gate: the exact vanilla identity and Iris generator persist, ordinary Nether portals still target `minecraft:the_nether`, and no pending stage/backup/journal returns. -6. Repeat once with a deliberately changed staged pack or conflicting `bukkit.yml` value before restart. Gate: Iris refuses publication or world admission, preserves recoverable artifacts, and never guesses a target. For a post-publication verification failure, gate that Iris restores the prior configuration, requests the controlled rollback restart, and restores the retained original directory before world load. +6. Repeat once with a deliberately changed staged pack or conflicting `bukkit.yml` value before restart. Gate: early Paper bootstrap aborts before registry/world loading, preserves recoverable artifacts, and never guesses a target. For a post-publication verification failure, gate that Iris journals rollback and requests the controlled restart, then restores the retained original directory and prior configuration before datapack compilation or world loading. ## B. Fresh install and first world (Fabric / Forge / NeoForge) 1. Install the matching mod jar into `mods/`. Fabric requires Loader ≥ declared floor; Forge/NeoForge require their declared floors. See `01 - Installation & Platforms.md` and `30 - Platform Differences.md`. -2. Start dedicated server (or integrated singleplayer for client-mod smoke). Confirm Iris boots, default pack installs, and datapack/biome registration completes. +2. Start dedicated server (or integrated singleplayer for client-mod smoke). Confirm Iris boots, both managed beta packs install, and datapack/biome registration completes. 3. Create a world with fixed seed (positional mod syntax): ``` diff --git a/docs/87 - Maintainer - Release Readiness.md b/docs/87 - Maintainer - Release Readiness.md index 09fc755aa..a3d37b151 100644 --- a/docs/87 - Maintainer - Release Readiness.md +++ b/docs/87 - Maintainer - Release Readiness.md @@ -270,7 +270,7 @@ Gate: every advertised server, loader, client, and content path completes the sa ### Confirmed release blockers and follow-ups -- [ ] Freeze the default overworld pack to an immutable release input. The runtime downloader currently follows the mutable `master` branch, so any recorded tree checksum remains reproducible only while that upstream content is unchanged. Immutable branch/tag/commit URL resolution is implemented, but published commit `8e32852ee6ecd039fae27a36f701f57cdc02e83f` predates the five local slime-category and biome-tag corrections, the dormant standard entity resource restoration, and removal of the legacy default ambient-spawner attachments; publish those pack edits under a new commit/tag before pinning automatic installs. +- [ ] Publish and retain anonymously downloadable `beta` assets for both managed pack repositories before shipping a build that requires dual bootstrap. Runtime uses the mutable Overworld and Underworld beta release URLs; record each downloaded asset checksum for a release baseline, and move production installs to immutable release inputs when the beta streams are promoted. - [x] Make modded GoldenHash metadata use the active Iris engine seed. Fabric, Forge, and NeoForge generated identical output from Iris seed `1337`, but filenames and headers recorded each vanilla level seed, preventing one captured baseline file from being reused directly across loaders. - [x] Correct the default overworld pack's slime spawn category from implicit `MISC` to explicit `MONSTER` in `biomes/vanilla/mangrove_swamp.json`, `biomes/swamp/cambian-drift.json`, `biomes/swamp/cambian-drift-extended.json`, `biomes/swamp/marsh.json`, and `biomes/swamp/marsh-rotten.json`. NeoForge exposes the bad category at startup; all loaders generate the same bad datapack entry, which can affect natural slime spawning and mob-cap accounting. - [x] Extend `PackValidator` to reject authored custom-biome spawn categories that disagree with the live entity category instead of allowing the bad datapack to reach loader validation. diff --git a/docs/94 - API - Modded.md b/docs/94 - API - Modded.md index f54eae3f9..2efcb086c 100644 --- a/docs/94 - API - Modded.md +++ b/docs/94 - API - Modded.md @@ -248,7 +248,7 @@ Paths relative to the loader config directory (`config/`): Pack install root is `config/irisworldgen/packs`, not `config/iris`. Missing pack at world open is a hard failure with the expected absolute path (no silent vanilla terrain). -Async default-pack prefetch at boot when `autoDownloadDefaultPack` is set and `defaultPack` is missing (`IrisDimensions/` from `master`). Failures log a pointer to `/iris download `. +When `autoDownloadDefaultPack` is enabled, startup asynchronously installs missing `overworld` and `underworld` managed beta releases, plus a distinct configured non-managed `defaultPack`. Failures log a pointer to `/iris download `; managed beta names always resolve to their release assets rather than the requested branch. Forced datapack contributes presets, dimension types, and biomes under `irisworldgen` (ids from pack/dimension names). Regenerated on pack change / studio hotload. Failure to inject (mixin/event not applied) logs once: