From 31ec5a83c222916eebdce0d87c7271e42418faf8 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 15 Oct 2026 12:33:47 -0400 Subject: [PATCH] Refused runtime replace targets - Refused /iris replace for dimensions that no level stem defines, including mod levels created while the server starts - Skipped hand-edited dimensionReplacements entries for such dimensions at startup without moving their data or writing a level stem override - Derived replaceable dimensions from the recorded worldgen catalog and the applied replacement state - Recorded a replacement as applied only when the loaded level generates from Iris with the configured pack and seed - Kept matching Iris generation history without recording it as an applied replacement - Dropped applied records whose loaded level uses another chunk generator - Stopped Iris engines bound to replaced levels that a mod swapped out during startup - Listed installed packs and pack:dimensionKey forms when the replace type is unknown - Added the refusal and installed-pack messages to every locale --- .../modded/ModdedDimensionReplacements.java | 115 +++++++++++++++++- .../iris/modded/ModdedEngineBootstrap.java | 1 + .../iris/modded/ModdedForcedDatapack.java | 9 +- .../modded/ModdedReplacementReconciler.java | 80 ++++++++---- .../iris/modded/ModdedWorldEngines.java | 16 +++ .../modded/command/ModdedReplaceCommands.java | 73 ++++++++++- .../localization/ModdedCommandMessages.java | 17 ++- .../ModdedDimensionReplacementsTest.java | 72 ++++++++++- .../ModdedReplacementReconcilerTest.java | 94 +++++++++++--- .../command/ModdedReplaceCommandsTest.java | 69 +++++++++++ core/src/main/resources/languages/de_DE.toml | 3 + core/src/main/resources/languages/es_ES.toml | 3 + core/src/main/resources/languages/fi_FI.toml | 3 + core/src/main/resources/languages/fr_FR.toml | 3 + core/src/main/resources/languages/he_IL.toml | 3 + core/src/main/resources/languages/it_IT.toml | 3 + core/src/main/resources/languages/ja-JP.toml | 3 + core/src/main/resources/languages/ko_KR.toml | 3 + core/src/main/resources/languages/lt_LT.toml | 3 + core/src/main/resources/languages/nl_NL.toml | 3 + core/src/main/resources/languages/pl_PL.toml | 3 + core/src/main/resources/languages/pt_PT.toml | 3 + core/src/main/resources/languages/ru_RU.toml | 3 + core/src/main/resources/languages/tr_TR.toml | 3 + core/src/main/resources/languages/vi_VI.toml | 3 + core/src/main/resources/languages/zh_CN.toml | 3 + core/src/main/resources/languages/zh_TW.toml | 3 + 27 files changed, 542 insertions(+), 55 deletions(-) create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceCommandsTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacements.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacements.java index 3985ceb5c..b8e1f9218 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacements.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacements.java @@ -18,6 +18,10 @@ package art.arcane.iris.modded; +import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs; +import art.arcane.iris.pack.ingest.catalog.CatalogLevelStem; +import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog; +import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogStore; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.json.JSONObject; @@ -27,15 +31,20 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.OptionalLong; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; public final class ModdedDimensionReplacements { - private static final String GENERATOR_TYPE = "irisworldgen:iris"; + public static final String IRIS_GENERATOR = "irisworldgen:iris"; + private static final Set VANILLA_LEVELS = Set.of("minecraft:overworld", "minecraft:the_nether", + "minecraft:the_end"); + private static volatile RecordedCatalog recordedCatalog; private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed"; private static final String PLACEHOLDER_BIOME = "minecraft:plains"; private static final String DIMENSIONS = "dimensions"; @@ -49,7 +58,7 @@ public final class ModdedDimensionReplacements { biomeSource.put("type", PLACEHOLDER_BIOME_SOURCE); biomeSource.put("biome", PLACEHOLDER_BIOME); JSONObject generator = new JSONObject(); - generator.put("type", GENERATOR_TYPE); + generator.put("type", IRIS_GENERATOR); generator.put("biome_source", biomeSource); generator.put("dimension", replacement.packRef()); JSONObject stem = new JSONObject(); @@ -72,8 +81,13 @@ public final class ModdedDimensionReplacements { } public static void writeLevelStems(Path datapackRoot, Map configured, - StagedTypes staged, Path worldRoot) throws IOException { + StagedTypes staged, Path worldRoot, ReplaceableLevels levels) throws IOException { for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) { + if (!levels.replaceable(replacement.levelKey())) { + ModdedIrisLog.error(unreplaceableReason(replacement.levelKey(), levels) + + " Iris writes no dimension override for it."); + continue; + } String typeRef = staged.typeRef(replacement, worldRoot); if (typeRef == null) { skipUnstaged(replacement, staged, worldRoot); @@ -89,6 +103,68 @@ public final class ModdedDimensionReplacements { return canonical(ModdedModConfig.get().dimensionReplacements(), IrisPlatforms.get().packsFolderNoCreate().toPath()); } + public static Map stageable(Map configured, + ReplaceableLevels levels) { + Map stageable = new TreeMap<>(); + for (ModdedDimensionReplacement replacement : configured.values()) { + if (levels.replaceable(replacement.levelKey())) { + stageable.put(replacement.levelKey(), replacement); + } + } + return Map.copyOf(stageable); + } + + public static ReplaceableLevels replaceableLevels(Path worldRoot, Map configured) + throws IOException { + if (configured.isEmpty()) { + return ReplaceableLevels.unknown(); + } + return replaceableLevels(worldRoot); + } + + public static ReplaceableLevels replaceableLevels(Path worldRoot) throws IOException { + Set applied = worldRoot == null + ? Set.of() + : ModdedReplacementReconciler.readState(worldRoot).applied().keySet(); + Optional catalog = ModdedWorldgenCatalogs.latest(); + if (catalog.isEmpty()) { + catalog = recordedCatalog(); + } + return catalog.isEmpty() + ? ReplaceableLevels.unknown() + : ReplaceableLevels.of(catalog.get().levelStems(), applied); + } + + public static String unreplaceableReason(String levelKey, ReplaceableLevels levels) { + if (!levels.known()) { + return "Iris refuses to replace " + levelKey + ": it has no record of which dimensions this server loads" + + " from level stems yet. Start the server once so Iris can read the dimension registry."; + } + return "Iris refuses to replace " + levelKey + ": it is not a dimension the server loads from a level stem;" + + " a mod creates it while the server runs, so its data stays untouched."; + } + + private static Optional recordedCatalog() { + Path file = ModdedWorldgenCatalogs.catalogFile(); + if (!Files.isRegularFile(file)) { + return Optional.empty(); + } + try { + long modified = Files.getLastModifiedTime(file).toMillis(); + RecordedCatalog cached = recordedCatalog; + if (cached != null && cached.file().equals(file) && cached.modified() == modified) { + return Optional.of(cached.catalog()); + } + WorldgenCatalog catalog = WorldgenCatalogStore.read(file); + recordedCatalog = new RecordedCatalog(file, modified, catalog); + return Optional.of(catalog); + } catch (IOException | RuntimeException unreadable) { + ModdedIrisLog.error("Iris could not read the recorded worldgen catalog at {}; only the vanilla dimensions are replaceable until the next start records it", + file, unreadable); + return Optional.empty(); + } + } + public static OptionalLong loadedSeed(String levelKey) { ModdedModConfig config = ModdedModConfig.loaded(); if (config == null) { @@ -188,6 +264,39 @@ public final class ModdedDimensionReplacements { return canonical.toString(); } + private record RecordedCatalog(Path file, long modified, WorldgenCatalog catalog) { + } + + public record ReplaceableLevels(boolean known, Map stemGenerators, Set applied) { + public ReplaceableLevels { + stemGenerators = Map.copyOf(stemGenerators); + applied = Set.copyOf(applied); + } + + public static ReplaceableLevels unknown() { + return new ReplaceableLevels(false, Map.of(), Set.of()); + } + + public static ReplaceableLevels of(List stems, Set applied) { + Map generators = new TreeMap<>(); + for (CatalogLevelStem stem : stems) { + generators.put(stem.key(), stem.generatorType()); + } + return new ReplaceableLevels(true, generators, applied); + } + + public boolean replaceable(String levelKey) { + if (VANILLA_LEVELS.contains(levelKey)) { + return true; + } + String generator = stemGenerators.get(levelKey); + if (generator == null) { + return false; + } + return !IRIS_GENERATOR.equals(generator) || applied.contains(levelKey); + } + } + public record StagedTypes(Map installed, Map retained) { public static StagedTypes create() { return new StagedTypes(new LinkedHashMap<>(), new LinkedHashMap<>()); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index a559615af..527f437e5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -178,6 +178,7 @@ public final class ModdedEngineBootstrap { reconcileSpawn(server, replacedOverworld); if (!loader().clientEnvironment()) { ModdedReplacementReconciler.recordApplied(server); + ModdedReplacementReconciler.stopUnpublishedEngines(server); ModdedReplacementEntry.retireIfComplete(); } ModdedWorldCheck.serverStarted(server); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index 84b3128f2..f65f73376 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -324,7 +324,9 @@ public final class ModdedForcedDatapack { false ); digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8)); - digest.update(ModdedDimensionReplacements.fingerprint(ModdedDimensionReplacements.configured()) + Map replacements = ModdedDimensionReplacements.configured(); + digest.update(ModdedDimensionReplacements.fingerprint(ModdedDimensionReplacements.stageable(replacements, + ModdedDimensionReplacements.replaceableLevels(worldRootOrNull(), replacements))) .getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } @@ -346,8 +348,9 @@ public final class ModdedForcedDatapack { } } if (!ModdedEngineBootstrap.loader().clientEnvironment()) { - ModdedDimensionReplacements.writeLevelStems(stagingDirectory, - ModdedDimensionReplacements.configured(), stagedDimensionTypes, worldRoot); + Map replacements = ModdedDimensionReplacements.configured(); + ModdedDimensionReplacements.writeLevelStems(stagingDirectory, replacements, stagedDimensionTypes, worldRoot, + ModdedDimensionReplacements.replaceableLevels(worldRoot, replacements)); } IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementReconciler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementReconciler.java index 47b7ea40b..49fb0c9ad 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementReconciler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementReconciler.java @@ -38,6 +38,7 @@ import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.OptionalLong; import java.util.Set; @@ -72,9 +73,9 @@ public final class ModdedReplacementReconciler { Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath(); Result result = reconcile(new Request(worldRoot, configured, config.replacementProblems(), (ModdedDimensionReplacement replacement) -> ModdedDimensionReplacements.installed(replacement, packsRoot), - recoveryParent)); + recoveryParent, ModdedDimensionReplacements.replaceableLevels(worldRoot, configured))); if (!result.adoptedLevels().isEmpty()) { - ModdedIrisLog.info("Iris dimension replacement recorded for {}: their existing Iris generation history already uses the configured pack and seed", + ModdedIrisLog.info("Iris kept the existing terrain of {}: its Iris generation history already uses the configured pack and seed; the replacement is recorded once the level generates from it", result.adoptedLevels()); } if (!result.replacedWorlds().isEmpty()) { @@ -101,7 +102,7 @@ public final class ModdedReplacementReconciler { } try { recordApplied(worldRoot.get(), ModdedDimensionReplacements.configured(), - (String levelKey, String packRef) -> generatedSeed(server, levelKey, packRef)); + (String levelKey) -> liveLevel(server, levelKey)); } catch (IOException | RuntimeException failure) { ModdedIrisLog.error("Iris is stopping the server because it could not record the applied dimension replacements under {}; an unrecorded replacement would be moved aside again on the next start", worldRoot.get(), failure); @@ -109,15 +110,29 @@ public final class ModdedReplacementReconciler { } } - static Map recordApplied(Path worldRoot, + public static void stopUnpublishedEngines(NativeModdedServer server) { + for (String levelKey : ModdedWorldEngines.evictUnpublished(server, + ModdedModConfig.get().dimensionReplacements().keySet())) { + ModdedIrisLog.error("Iris stopped the engine it started for {}: a mod replaced that level with its own chunk generator while the server started", + levelKey); + } + } + + static synchronized Map recordApplied(Path worldRoot, Map configured, - GeneratedSeed generating) throws IOException { + LiveLevels levels) throws IOException { State state = readState(worldRoot); Map recorded = new TreeMap<>(state.applied()); for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) { - OptionalLong seed = generating.seed(replacement.levelKey(), replacement.packRef()); - if (seed.isPresent() && (replacement.seed().isEmpty() || replacement.seed().equals(seed))) { - recorded.put(replacement.levelKey(), replacement.withSeed(seed.getAsLong())); + Optional live = levels.level(replacement.levelKey()); + if (live.isPresent() && live.get().generates(replacement)) { + recorded.put(replacement.levelKey(), replacement.withSeed(live.get().seed())); + continue; + } + if (live.isPresent() && !live.get().iris()) { + recorded.remove(replacement.levelKey()); + ModdedIrisLog.error("Iris replacement of {} is configured but the loaded level uses another chunk generator; a mod creates that level while the server runs, so Iris cannot replace it. Remove its dimensionReplacements entry from modded.json", + replacement.levelKey()); continue; } ModdedIrisLog.warn("Iris replacement of {} is configured but the level does not generate from {}{}; it stays unapplied", @@ -130,7 +145,7 @@ public final class ModdedReplacementReconciler { return Map.copyOf(recorded); } - static Result reconcile(Request request) throws IOException { + static synchronized Result reconcile(Request request) throws IOException { Path worldRoot = request.worldRoot(); requireReadableConfig(worldRoot, request.configProblems()); State state = readState(worldRoot); @@ -149,6 +164,7 @@ public final class ModdedReplacementReconciler { Map recorded = new TreeMap<>(applied); Set quarantined = new TreeSet<>(); Set adopted = new TreeSet<>(); + Set unreplaceable = new TreeSet<>(); for (String level : changed) { ModdedDimensionReplacement replacement = request.configured().get(level); if (replacement == null) { @@ -156,12 +172,15 @@ public final class ModdedReplacementReconciler { quarantined.add(level); continue; } + if (!request.levels().replaceable(level)) { + unreplaceable.add(level); + ModdedIrisLog.error(ModdedDimensionReplacements.unreplaceableReason(level, request.levels()) + + " Remove its dimensionReplacements entry from modded.json."); + continue; + } Path storage = replacement.storageFolder(worldRoot); - OptionalLong historySeed = refreshed.contains(level) - ? OptionalLong.empty() - : matchingHistorySeed(storage, replacement); - if (historySeed.isPresent()) { - recorded.put(level, replacement.withSeed(historySeed.getAsLong())); + boolean keepHistory = !refreshed.contains(level) && matchingHistorySeed(storage, replacement).isPresent(); + if (keepHistory) { adopted.add(level); } else if (request.installed().test(replacement)) { quarantined.add(level); @@ -205,7 +224,7 @@ public final class ModdedReplacementReconciler { writeState(worldRoot, reconciled); } return new Result(Set.copyOf(quarantined), Set.copyOf(adopted), Set.copyOf(replacedWorlds.keySet()), - moved.isEmpty() ? null : recovery); + Set.copyOf(unreplaceable), moved.isEmpty() ? null : recovery); } public static boolean pending(Path worldRoot, String levelKey, Map configured) @@ -320,16 +339,16 @@ public final class ModdedReplacementReconciler { return packs.isEmpty() || packs.contains(replacement.pack()); } - private static OptionalLong generatedSeed(NativeModdedServer server, String levelKey, String packRef) { + private static Optional liveLevel(NativeModdedServer server, String levelKey) { NativeWorld world = server.world(levelKey); if (world == null) { - return OptionalLong.empty(); + return Optional.empty(); } IrisModdedChunkGenerator generator = NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class); - if (generator == null || !packRef.equals(generator.dimensionKey())) { - return OptionalLong.empty(); + if (generator == null) { + return Optional.of(new LiveLevel(false, "", 0L)); } - return OptionalLong.of(generator.visibleBiomeSeed()); + return Optional.of(new LiveLevel(true, generator.dimensionKey(), generator.visibleBiomeSeed())); } private static void requireReadableConfig(Path worldRoot, List configProblems) throws IOException { @@ -395,8 +414,19 @@ public final class ModdedReplacementReconciler { return section; } - interface GeneratedSeed { - OptionalLong seed(String levelKey, String packRef); + interface LiveLevels { + Optional level(String levelKey); + } + + record LiveLevel(boolean iris, String packRef, long seed) { + LiveLevel { + Objects.requireNonNull(packRef, "packRef"); + } + + boolean generates(ModdedDimensionReplacement replacement) { + return iris && packRef.equals(replacement.packRef()) + && (replacement.seed().isEmpty() || replacement.seed().getAsLong() == seed); + } } record State(Map applied, Map pending) { @@ -409,9 +439,11 @@ public final class ModdedReplacementReconciler { } record Request(Path worldRoot, Map configured, List configProblems, - Predicate installed, Path recoveryParent) { + Predicate installed, Path recoveryParent, + ModdedDimensionReplacements.ReplaceableLevels levels) { } - record Result(Set quarantinedLevels, Set adoptedLevels, Set replacedWorlds, Path recovery) { + record Result(Set quarantinedLevels, Set adoptedLevels, Set replacedWorlds, + Set unreplaceableLevels, Path recovery) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 4b2a7e375..018a89be3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded; import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldDimensions; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; @@ -49,8 +50,10 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public final class ModdedWorldEngines { @@ -92,6 +95,19 @@ public final class ModdedWorldEngines { } } + static List evictUnpublished(NativeModdedServer server, Set levelKeys) { + List evicted = new ArrayList<>(); + for (NativeWorld level : new ArrayList<>(ENGINES.keySet())) { + if (!levelKeys.contains(level.name()) + || NativeDimensionRuntime.sameWorld(server.world(level.name()), level)) { + continue; + } + evict(level); + evicted.add(level.name()); + } + return evicted; + } + static void evictOrThrow(NativeWorld level) { Engine[] removed = new Engine[1]; ENGINES.computeIfPresent(level, (NativeWorld ignored, Engine current) -> { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceCommands.java index d15e30331..00cdf60c5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceCommands.java @@ -30,7 +30,11 @@ import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.modded.ModdedModConfig; import art.arcane.iris.modded.ModdedReplacementReconciler; +import art.arcane.iris.modded.catalog.ModdedWorldgenCatalogs; +import art.arcane.iris.modded.localization.ModdedCommandMessages; +import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDownloader; +import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog; import art.arcane.iris.pack.loading.IrisData; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandRegistration; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource; @@ -46,10 +50,15 @@ import com.mojang.brigadier.suggestion.SuggestionsBuilder; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; import java.util.concurrent.CompletableFuture; import java.util.function.Predicate; @@ -135,6 +144,7 @@ final class ModdedReplaceCommands { throw new IllegalArgumentException(MISSING_TARGET); } Path worldRoot = server.root(); + requireReplaceableLevel(worldRoot, requested.levelKey()); requireNothingPending(worldRoot, requested.levelKey()); long seed = requested.seed().orElse(ModdedReplacementReconciler.applied(worldRoot, requested.levelKey()) .map((ModdedDimensionReplacement applied) -> applied.seed().getAsLong()) @@ -166,6 +176,26 @@ final class ModdedReplaceCommands { return staged; } + private static void requireReplaceableLevel(Path worldRoot, String levelKey) throws IOException { + Optional catalog = ModdedWorldgenCatalogs.latest(); + Set applied = ModdedReplacementReconciler.applied(worldRoot, levelKey).isPresent() + ? Set.of(levelKey) + : Set.of(); + ModdedDimensionReplacements.ReplaceableLevels levels = catalog.isEmpty() + ? ModdedDimensionReplacements.ReplaceableLevels.unknown() + : ModdedDimensionReplacements.ReplaceableLevels.of(catalog.get().levelStems(), applied); + if (levels.replaceable(levelKey)) { + return; + } + if (!levels.known()) { + throw new IllegalArgumentException(IrisLanguage.plain( + ModdedCommandMessages.MODDED_WORLD_COMMANDS_REPLACE_REGISTRY_NOT_READY)); + } + throw new IllegalArgumentException(IrisLanguage.plain( + ModdedCommandMessages.MODDED_WORLD_COMMANDS_REPLACE_RUNTIME_LEVEL, + MessageArgument.untrusted("dimensionId", levelKey))); + } + private static void requireNothingPending(Path worldRoot, String levelKey) throws IOException { Map configured = ModdedModConfig.get().dimensionReplacements(); if (ModdedReplacementReconciler.pending(worldRoot, levelKey, configured)) { @@ -230,13 +260,52 @@ final class ModdedReplaceCommands { IrisModdedCommands.fail(source, IrisLanguage.plain( BukkitCommandMessagesExtended.COMMAND_IRIS_DIMENSION_NOT_FOUND, MessageArgument.untrusted("dimension", resolvedType))); - IrisModdedCommands.fail(source, IrisLanguage.plain( - BukkitCommandMessagesExtended.COMMAND_IRIS_TRY_ONE_OVERWORLD_VANILLA_FLAT_THEEND)); + List installed = installedTypes(ModdedPackCommands.packsRoot()); + if (!installed.isEmpty()) { + IrisModdedCommands.fail(source, IrisLanguage.plain( + ModdedCommandMessages.MODDED_WORLD_COMMANDS_REPLACE_INSTALLED_TYPES, + MessageArgument.untrusted("value", String.join(", ", installed)))); + } IrisModdedCommands.fail(source, IrisLanguage.plain( BukkitCommandMessagesExtended.COMMAND_IRIS_INSTALL_PACK_AND_RESTART, MessageArgument.untrusted("command", PackDownloader.downloadCommandFor(packOf(resolvedType))))); } + static List installedTypes(File packsRoot) { + Set types = new TreeSet<>(); + for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsRoot)) { + List dimensionKeys = dimensionKeys(pack.toPath().resolve("dimensions")); + if (dimensionKeys.isEmpty()) { + continue; + } + types.add(pack.getName()); + for (String dimensionKey : dimensionKeys) { + if (!dimensionKey.equals(pack.getName())) { + types.add(pack.getName() + ":" + dimensionKey); + } + } + } + return List.copyOf(types); + } + + private static List dimensionKeys(Path dimensions) { + if (!Files.isDirectory(dimensions)) { + return List.of(); + } + List keys = new ArrayList<>(); + try (Stream files = Files.walk(dimensions)) { + for (Path file : files.filter(Files::isRegularFile).toList()) { + String relative = dimensions.relativize(file).toString().replace(File.separatorChar, '/'); + if (relative.endsWith(".json")) { + keys.add(relative.substring(0, relative.length() - ".json".length())); + } + } + } catch (IOException | RuntimeException unreadable) { + ModdedIrisLog.warn("Iris could not list the dimensions of pack folder {}", dimensions.getParent(), unreadable); + } + return keys; + } + private static String packOf(String type) { String value = type.trim(); int colon = value.indexOf(':'); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java index f6b6779cb..b35476c1e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedCommandMessages.java @@ -1260,6 +1260,18 @@ public final class ModdedCommandMessages { "iris.modded.moddedworldcommands.use_iris_world_create_name_pack_inject_one_without_restarting", "Use /iris world create to inject one without restarting." ); + public static final TextKey MODDED_WORLD_COMMANDS_REPLACE_RUNTIME_LEVEL = TextKey.of( + "iris.modded.moddedworldcommands.replace_runtime_level", + "{dimensionId}" + " is created by a mod while the server runs and is not defined as a dimension, so Iris cannot replace it." + ); + public static final TextKey MODDED_WORLD_COMMANDS_REPLACE_REGISTRY_NOT_READY = TextKey.of( + "iris.modded.moddedworldcommands.replace_registry_not_ready", + "Iris is still reading this server's dimensions. Try again in a moment." + ); + public static final TextKey MODDED_WORLD_COMMANDS_REPLACE_INSTALLED_TYPES = TextKey.of( + "iris.modded.moddedworldcommands.replace_installed_types", + "Installed packs: " + "{value}" + ); private static final List KEYS = List.of( IRIS_MODDED_COMMANDS_THIS_DIMENSION_IS_NOT_GENERATED_BY_IRIS, @@ -1574,7 +1586,10 @@ public final class ModdedCommandMessages { MODDED_WORLD_COMMANDS_NO_IRIS_DIMENSIONS_ARE_CURRENTLY_LOADED_CREATE_ONE_WITH_IRIS, MODDED_WORLD_COMMANDS_LOADED_IRIS_DIMENSIONS, MODDED_WORLD_COMMANDS_MESSAGE_2, - MODDED_WORLD_COMMANDS_USE_IRIS_WORLD_CREATE_NAME_PACK_INJECT_ONE_WITHOUT_RESTARTING + MODDED_WORLD_COMMANDS_USE_IRIS_WORLD_CREATE_NAME_PACK_INJECT_ONE_WITHOUT_RESTARTING, + MODDED_WORLD_COMMANDS_REPLACE_RUNTIME_LEVEL, + MODDED_WORLD_COMMANDS_REPLACE_REGISTRY_NOT_READY, + MODDED_WORLD_COMMANDS_REPLACE_INSTALLED_TYPES ); private ModdedCommandMessages() { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionReplacementsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionReplacementsTest.java index d786e1e39..7986ca119 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionReplacementsTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionReplacementsTest.java @@ -37,6 +37,58 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class ModdedDimensionReplacementsTest { + private static final ModdedDimensionReplacements.ReplaceableLevels STEMS = stems("aether:the_aether"); + + @Test + public void onlyLevelStemsTheServerCreatesItselfAreReplaceable() { + ModdedDimensionReplacements.ReplaceableLevels levels = new ModdedDimensionReplacements.ReplaceableLevels(true, + Map.of("minecraft:overworld", "minecraft:noise", + "examplemod:skylands", "examplemod:sky_generator", + "examplemod:applied", ModdedDimensionReplacements.IRIS_GENERATOR, + "ae2:spatial_storage", ModdedDimensionReplacements.IRIS_GENERATOR), + Set.of("examplemod:applied")); + + assertTrue(levels.replaceable("minecraft:overworld")); + assertTrue(levels.replaceable("minecraft:the_end")); + assertTrue(levels.replaceable("examplemod:skylands")); + assertTrue(levels.replaceable("examplemod:applied")); + assertFalse(levels.replaceable("ae2:spatial_storage")); + assertFalse(levels.replaceable("ae2:runtime_only")); + } + + @Test + public void withoutARecordedRegistryOnlyTheVanillaDimensionsAreReplaceable() { + ModdedDimensionReplacements.ReplaceableLevels unknown = ModdedDimensionReplacements.ReplaceableLevels.unknown(); + + assertFalse(unknown.known()); + assertTrue(unknown.replaceable("minecraft:overworld")); + assertTrue(unknown.replaceable("minecraft:the_nether")); + assertFalse(unknown.replaceable("examplemod:skylands")); + } + + @Test + public void unreplaceableLevelsGetNoLevelStemOverride() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-stage-runtime"); + try { + Path datapack = temporary.resolve("datapack"); + ModdedDimensionReplacements.StagedTypes staged = ModdedDimensionReplacements.StagedTypes.create(); + staged.installed().put("overworld:overworld", "irisworldgen:packs/x/dimensions/y/dimension_type"); + + ModdedDimensionReplacements.writeLevelStems(datapack, Map.of( + "ae2:spatial_storage", replacement("ae2:spatial_storage", "overworld"), + "minecraft:the_end", replacement("minecraft:the_end", "overworld")), staged, + temporary.resolve("world"), STEMS); + + assertFalse(Files.exists(datapack.resolve("data/ae2/dimension/spatial_storage.json"))); + assertTrue(Files.isRegularFile(datapack.resolve("data/minecraft/dimension/the_end.json"))); + assertEquals(Set.of("minecraft:the_end"), ModdedDimensionReplacements.stageable(Map.of( + "ae2:spatial_storage", replacement("ae2:spatial_storage", "overworld"), + "minecraft:the_end", replacement("minecraft:the_end", "overworld")), STEMS).keySet()); + } finally { + ModdedReplacementReconcilerTest.deleteTree(temporary); + } + } + @Test public void parsesPackAndPackDimensionReferences() { ModdedDimensionReplacement samePack = replacement("iristest:skylands", "skypack"); @@ -170,7 +222,7 @@ public class ModdedDimensionReplacementsTest { ModdedDimensionReplacements.writeLevelStems(datapack, Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), staged, - temporary.resolve("world")); + temporary.resolve("world"), STEMS); JSONObject stem = new JSONObject(Files.readString(datapack.resolve("data/aether/dimension/the_aether.json"))); assertEquals("irisworldgen:packs/x/dimensions/y/dimension_type", stem.getString("type")); @@ -191,7 +243,7 @@ public class ModdedDimensionReplacementsTest { "aether:the_aether"); ModdedDimensionReplacements.writeLevelStems(datapack, - Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), staged, worldRoot); + Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), staged, worldRoot, STEMS); JSONObject stem = new JSONObject(Files.readString(datapack.resolve("data/aether/dimension/the_aether.json"))); assertEquals("aether:the_aether", stem.getString("type")); @@ -211,9 +263,9 @@ public class ModdedDimensionReplacementsTest { Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")); ModdedDimensionReplacements.writeLevelStems(datapack, configured, - ModdedDimensionReplacements.StagedTypes.create(), worldRoot); + ModdedDimensionReplacements.StagedTypes.create(), worldRoot, STEMS); ModdedDimensionReplacements.writeLevelStems(datapack, configured, - ModdedDimensionReplacements.StagedTypes.create(), null); + ModdedDimensionReplacements.StagedTypes.create(), null, STEMS); assertFalse(Files.exists(datapack.resolve("data/aether/dimension/the_aether.json"))); } finally { @@ -232,7 +284,7 @@ public class ModdedDimensionReplacementsTest { IOException refused = assertThrows(IOException.class, () -> ModdedDimensionReplacements.writeLevelStems( temporary.resolve("datapack"), Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), - ModdedDimensionReplacements.StagedTypes.create(), worldRoot)); + ModdedDimensionReplacements.StagedTypes.create(), worldRoot, STEMS)); assertTrue(refused.getMessage(), refused.getMessage().contains("aether:the_aether")); assertTrue(refused.getMessage(), refused.getMessage().contains("skypack")); @@ -252,7 +304,7 @@ public class ModdedDimensionReplacementsTest { assertThrows(IOException.class, () -> ModdedDimensionReplacements.writeLevelStems( temporary.resolve("datapack"), Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), - ModdedDimensionReplacements.StagedTypes.create(), worldRoot)); + ModdedDimensionReplacements.StagedTypes.create(), worldRoot, STEMS)); } finally { ModdedReplacementReconcilerTest.deleteTree(temporary); } @@ -280,6 +332,14 @@ public class ModdedDimensionReplacementsTest { } } + static ModdedDimensionReplacements.ReplaceableLevels stems(String... levelKeys) { + Map generators = new LinkedHashMap<>(); + for (String levelKey : levelKeys) { + generators.put(levelKey, "minecraft:noise"); + } + return new ModdedDimensionReplacements.ReplaceableLevels(true, generators, Set.of()); + } + static ModdedDimensionReplacement replacement(String levelKey, String type) { return ModdedDimensionReplacement.parse(levelKey, type, OptionalLong.empty()); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedReplacementReconcilerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedReplacementReconcilerTest.java index 9f62e6f8d..276977142 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedReplacementReconcilerTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedReplacementReconcilerTest.java @@ -32,7 +32,6 @@ import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.OptionalLong; import java.util.Set; import java.util.UUID; import java.util.stream.Stream; @@ -46,6 +45,9 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class ModdedReplacementReconcilerTest { + private static final ModdedDimensionReplacements.ReplaceableLevels STEMS = ModdedDimensionReplacementsTest.stems( + "iristest:skylands", "iristest:unconfigured", "aether:the_aether"); + @Test public void stateRoundTripsAppliedAndPendingEntriesAndDefaultsToEmpty() throws IOException { Path worldRoot = Files.createTempDirectory("iris-replace-state"); @@ -260,8 +262,7 @@ public class ModdedReplacementReconcilerTest { assertEquals(Set.of("iristest:skylands"), result.adoptedLevels()); assertNull(result.recovery()); assertTrue(Files.isRegularFile(skylands.resolve("region/r.0.0.mca"))); - assertEquals(Map.of("iristest:skylands", seeded("iristest:skylands", "skypack:sky", 5L)), - ModdedReplacementReconciler.readState(worldRoot).applied()); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied()); } finally { deleteTree(temporary); } @@ -433,29 +434,38 @@ public class ModdedReplacementReconcilerTest { } @Test - public void recordAppliedStoresOnlyReplacementsThatGenerateFromTheirPackAndSeed() throws IOException { + public void recordAppliedStoresOnlyLevelsThatLiveGenerateFromTheirPackAndSeed() throws IOException { Path temporary = Files.createTempDirectory("iris-replace-record"); Path worldRoot = temporary.resolve("world"); try { - writeApplied(worldRoot, seeded("iristest:kept", "overworld", 5L)); + ModdedReplacementReconciler.writeState(worldRoot, new ModdedReplacementReconciler.State(Map.of( + "iristest:kept", seeded("iristest:kept", "overworld", 5L), + "ae2:spatial_storage", seeded("ae2:spatial_storage", "overworld", 5L), + "iristest:removed", seeded("iristest:removed", "overworld", 5L)), Map.of())); Map recorded = ModdedReplacementReconciler.recordApplied(worldRoot, Map.of("iristest:kept", replacement("iristest:kept", "overworld"), "iristest:live", replacement("iristest:live", "skypack"), "iristest:seeded", seeded("iristest:seeded", "skypack", 8L), "iristest:wrongseed", seeded("iristest:wrongseed", "skypack", 8L), - "iristest:skipped", replacement("iristest:skipped", "skypack")), - (String level, String packRef) -> switch (level) { - case "iristest:skipped" -> OptionalLong.empty(); - case "iristest:wrongseed" -> OptionalLong.of(9L); - case "iristest:seeded" -> OptionalLong.of(8L); - default -> OptionalLong.of(5L); + "iristest:wrongpack", replacement("iristest:wrongpack", "skypack"), + "iristest:absent", replacement("iristest:absent", "skypack"), + "ae2:spatial_storage", replacement("ae2:spatial_storage", "overworld")), + (String level) -> switch (level) { + case "iristest:kept" -> Optional.of(iris("overworld", 5L)); + case "iristest:live" -> Optional.of(iris("skypack", 5L)); + case "iristest:seeded" -> Optional.of(iris("skypack", 8L)); + case "iristest:wrongseed" -> Optional.of(iris("skypack", 9L)); + case "iristest:wrongpack" -> Optional.of(iris("otherpack", 5L)); + case "ae2:spatial_storage" -> Optional.of(new ModdedReplacementReconciler.LiveLevel(false, "", 0L)); + default -> Optional.empty(); }); Map expected = Map.of( "iristest:kept", seeded("iristest:kept", "overworld", 5L), "iristest:live", seeded("iristest:live", "skypack", 5L), - "iristest:seeded", seeded("iristest:seeded", "skypack", 8L)); + "iristest:seeded", seeded("iristest:seeded", "skypack", 8L), + "iristest:removed", seeded("iristest:removed", "overworld", 5L)); assertEquals(expected, recorded); assertEquals(expected, ModdedReplacementReconciler.readState(worldRoot).applied()); } finally { @@ -472,7 +482,55 @@ public class ModdedReplacementReconcilerTest { assertThrows(IOException.class, () -> ModdedReplacementReconciler.recordApplied(worldRoot, Map.of("iristest:live", replacement("iristest:live", "skypack")), - (String level, String packRef) -> OptionalLong.of(1L))); + (String level) -> Optional.of(iris("skypack", 1L)))); + } finally { + deleteTree(temporary); + } + } + + @Test + public void runtimeCreatedLevelIsNeverMovedRecordedOrAdopted() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-runtime-level"); + Path worldRoot = temporary.resolve("world"); + try { + Path spatial = worldRoot.resolve("dimensions/ae2/spatial_storage"); + touch(spatial.resolve("region/r.0.0.mca")); + createHistory(temporary, spatial, ModdedWorldgenIds.dimensionTypeRef("overworld", "overworld"), "overworld", + List.of()); + ModdedDimensionReplacement staged = seeded("ae2:spatial_storage", "overworld", 5L); + ModdedReplacementReconciler.stagePending(worldRoot, staged); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("ae2:spatial_storage", staged), List.of(), temporary.resolve("config")); + + assertEquals(Set.of("ae2:spatial_storage"), result.unreplaceableLevels()); + assertEquals(Set.of(), result.quarantinedLevels()); + assertEquals(Set.of(), result.adoptedLevels()); + assertNull(result.recovery()); + assertTrue(Files.isRegularFile(spatial.resolve("region/r.0.0.mca"))); + assertEquals(ModdedReplacementReconciler.State.EMPTY, ModdedReplacementReconciler.readState(worldRoot)); + } finally { + deleteTree(temporary); + } + } + + @Test + public void levelStemOnlyDefinedByAnUnappliedIrisOverrideIsNotReplaceable() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-stale-override"); + Path worldRoot = temporary.resolve("world"); + try { + Path spatial = worldRoot.resolve("dimensions/ae2/spatial_storage"); + touch(spatial.resolve("region/r.0.0.mca")); + ModdedDimensionReplacements.ReplaceableLevels levels = new ModdedDimensionReplacements.ReplaceableLevels( + true, Map.of("ae2:spatial_storage", ModdedDimensionReplacements.IRIS_GENERATOR), Set.of()); + + ModdedReplacementReconciler.Result result = ModdedReplacementReconciler.reconcile( + new ModdedReplacementReconciler.Request(worldRoot, + Map.of("ae2:spatial_storage", replacement("ae2:spatial_storage", "overworld")), List.of(), + (ModdedDimensionReplacement replacement) -> true, temporary.resolve("config"), levels)); + + assertEquals(Set.of("ae2:spatial_storage"), result.unreplaceableLevels()); + assertTrue(Files.isRegularFile(spatial.resolve("region/r.0.0.mca"))); } finally { deleteTree(temporary); } @@ -490,7 +548,7 @@ public class ModdedReplacementReconcilerTest { IOException refused = assertThrows(IOException.class, () -> ModdedReplacementReconciler.reconcile( new ModdedReplacementReconciler.Request(worldRoot, Map.of("iristest:skylands", replacement("iristest:skylands", "missing")), List.of(), - (ModdedDimensionReplacement replacement) -> false, temporary.resolve("config")))); + (ModdedDimensionReplacement replacement) -> false, temporary.resolve("config"), STEMS))); assertTrue(refused.getMessage(), refused.getMessage().contains("missing")); assertTrue(Files.isRegularFile(region)); @@ -511,7 +569,7 @@ public class ModdedReplacementReconcilerTest { ModdedReplacementReconciler.Result result = ModdedReplacementReconciler.reconcile( new ModdedReplacementReconciler.Request(worldRoot, Map.of("aether:the_aether", replacement("aether:the_aether", "missing")), List.of(), - (ModdedDimensionReplacement replacement) -> false, temporary.resolve("config"))); + (ModdedDimensionReplacement replacement) -> false, temporary.resolve("config"), STEMS)); assertEquals(Set.of(), result.quarantinedLevels()); assertTrue(Files.isRegularFile(region)); @@ -543,7 +601,11 @@ public class ModdedReplacementReconcilerTest { private static ModdedReplacementReconciler.Result reconcile(Path worldRoot, Map configured, List problems, Path recoveryParent) throws IOException { return ModdedReplacementReconciler.reconcile(new ModdedReplacementReconciler.Request( - worldRoot, configured, problems, (ModdedDimensionReplacement replacement) -> true, recoveryParent)); + worldRoot, configured, problems, (ModdedDimensionReplacement replacement) -> true, recoveryParent, STEMS)); + } + + private static ModdedReplacementReconciler.LiveLevel iris(String packRef, long seed) { + return new ModdedReplacementReconciler.LiveLevel(true, packRef, seed); } private static void writeApplied(Path worldRoot, ModdedDimensionReplacement applied) throws IOException { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceCommandsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceCommandsTest.java new file mode 100644 index 000000000..deffe272c --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceCommandsTest.java @@ -0,0 +1,69 @@ +/* + * 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.command; + +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 ModdedReplaceCommandsTest { + @Test + public void installedTypesListEveryPackAndItsOtherDimensionKeys() throws IOException { + Path packs = Files.createTempDirectory("iris-replace-types"); + try { + touch(packs.resolve("overworld/dimensions/overworld.json")); + touch(packs.resolve("skypack/dimensions/sky.json")); + touch(packs.resolve("skypack/dimensions/floating/upper.json")); + touch(packs.resolve("empty/readme.txt")); + + assertEquals(List.of("overworld", "skypack", "skypack:floating/upper", "skypack:sky"), + ModdedReplaceCommands.installedTypes(packs.toFile())); + } finally { + try (Stream walk = Files.walk(packs)) { + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } + } + + @Test + public void noInstalledPacksListNothing() throws IOException { + Path packs = Files.createTempDirectory("iris-replace-types-empty"); + try { + assertEquals(List.of(), ModdedReplaceCommands.installedTypes(packs.toFile())); + assertEquals(List.of(), ModdedReplaceCommands.installedTypes(packs.resolve("missing").toFile())); + } finally { + Files.deleteIfExists(packs); + } + } + + private static void touch(Path file) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, "{}", StandardCharsets.UTF_8); + } +} diff --git a/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index f59feba8e..22792dbb1 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -1111,6 +1111,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Derzeit sind kei loaded_iris_dimensions = "Geladene Iris-Dimensionen: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Verwende /iris world create , um eine Dimension ohne Neustart einzubinden." +replace_runtime_level = "{dimensionId} wird während der Laufzeit von einer Mod erstellt und ist nicht als Dimension definiert, daher kann Iris sie nicht ersetzen." +replace_registry_not_ready = "Iris liest noch die Dimensionen dieses Servers. Versuche es gleich noch einmal." +replace_installed_types = "Installierte Packs: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Externe Datapack-Importe von Modrinth herunterladen und verwalten" diff --git a/core/src/main/resources/languages/es_ES.toml b/core/src/main/resources/languages/es_ES.toml index 49d86cb38..1ae7c4934 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -1111,6 +1111,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "No hay dimension loaded_iris_dimensions = "Dimensiones de Iris cargadas: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Usa /iris world create para inyectar una sin reiniciar." +replace_runtime_level = "{dimensionId} la crea un mod mientras el servidor está en marcha y no está definida como dimensión, por lo que Iris no puede reemplazarla." +replace_registry_not_ready = "Iris todavía está leyendo las dimensiones de este servidor. Inténtalo de nuevo en un momento." +replace_installed_types = "Packs instalados: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Descargar y gestionar importaciones externas de datapacks (Modrinth)" diff --git a/core/src/main/resources/languages/fi_FI.toml b/core/src/main/resources/languages/fi_FI.toml index 7331c9545..352a54756 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -1116,6 +1116,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Ei Iris mitat ov loaded_iris_dimensions = "Ladattu Iris mitat: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Käyttö /iris world create pistääksesi yhden ilman, että aloitat alusta." +replace_runtime_level = "{dimensionId} on modin palvelimen ajon aikana luoma eikä sitä ole määritelty ulottuvuudeksi, joten Iris ei voi korvata sitä." +replace_registry_not_ready = "Iris lukee vielä tämän palvelimen ulottuvuuksia. Yritä hetken päästä uudelleen." +replace_installed_types = "Asennetut paketit: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Lataa & Hallinnoidaan ulkoisen datapaketin tuontia (Modrinth)" diff --git a/core/src/main/resources/languages/fr_FR.toml b/core/src/main/resources/languages/fr_FR.toml index 3a9d49a4c..e7c4c55f4 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -1111,6 +1111,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Aucune dimension loaded_iris_dimensions = "Dimensions Iris chargées : {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Utilisez /iris world create pour en injecter une sans redémarrer." +replace_runtime_level = "{dimensionId} est créée par un mod pendant que le serveur tourne et n'est pas définie comme dimension ; Iris ne peut donc pas la remplacer." +replace_registry_not_ready = "Iris lit encore les dimensions de ce serveur. Réessayez dans un instant." +replace_installed_types = "Packs installés : {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Télécharger et gérer les importations externes de datapacks (Modrinth)" diff --git a/core/src/main/resources/languages/he_IL.toml b/core/src/main/resources/languages/he_IL.toml index fa8a790f2..f5fc97242 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.toml @@ -1116,6 +1116,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "לא Iris המי loaded_iris_dimensions = "טעון Iris ממדים: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "שימוש בשימוש /iris world create להזריק אחד מבלי להתחיל מחדש." +replace_runtime_level = "{dimensionId} נוצר על ידי מוד בזמן שהשרת פועל ואינו מוגדר כממד, ולכן Iris אינו יכול להחליף אותו." +replace_registry_not_ready = "Iris עדיין קורא את הממדים של השרת הזה. נסה שוב בעוד רגע." +replace_installed_types = "חבילות מותקנות: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "הורד הורדה & ניהול יבוא נתונים חיצוני (Modrinth)" diff --git a/core/src/main/resources/languages/it_IT.toml b/core/src/main/resources/languages/it_IT.toml index 8b8a2ea36..7e895197b 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -1111,6 +1111,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Nessuna dimensio loaded_iris_dimensions = "Dimensioni Iris caricate: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Usa /iris world create per iniettarne una senza riavviare." +replace_runtime_level = "{dimensionId} viene creata da una mod mentre il server è in esecuzione e non è definita come dimensione, quindi Iris non può sostituirla." +replace_registry_not_ready = "Iris sta ancora leggendo le dimensioni di questo server. Riprova tra un momento." +replace_installed_types = "Pack installati: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Scarica e gestisci le importazioni di datapack esterni da Modrinth" diff --git a/core/src/main/resources/languages/ja-JP.toml b/core/src/main/resources/languages/ja-JP.toml index cc14b6a4d..e9fc942fa 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.toml @@ -1114,6 +1114,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "現在読み込 loaded_iris_dimensions = "読み込み済み Iris ディメンション: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "再起動せずに注入するには /iris world create を使用してください。" +replace_runtime_level = "{dimensionId} はサーバー実行中に Mod が作成するもので、ディメンションとして定義されていないため、Iris は置き換えられません。" +replace_registry_not_ready = "Iris はまだこのサーバーのディメンションを読み込んでいます。しばらくしてから再試行してください。" +replace_installed_types = "インストール済みのパック: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "外部データパックのインポートをダウンロードして管理します(Modrinth)" diff --git a/core/src/main/resources/languages/ko_KR.toml b/core/src/main/resources/languages/ko_KR.toml index a67aeddd8..8adb8c480 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -1114,6 +1114,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "현재 로드된 loaded_iris_dimensions = "로드 중 ... Iris 크기: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "재시작하지 않고 주입하려면 /iris world create 을(를) 사용하세요." +replace_runtime_level = "{dimensionId}은(는) 서버 실행 중에 모드가 만드는 것이며 차원으로 정의되어 있지 않으므로 Iris가 교체할 수 없습니다." +replace_registry_not_ready = "Iris가 아직 이 서버의 차원을 읽는 중입니다. 잠시 후 다시 시도하세요." +replace_installed_types = "설치된 팩: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "외부 데이터 팩 가져오기를 다운로드하고 관리합니다(Modrinth)" diff --git a/core/src/main/resources/languages/lt_LT.toml b/core/src/main/resources/languages/lt_LT.toml index 2d1ce337a..7b58093e6 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -1116,6 +1116,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Ne Iris šiuo me loaded_iris_dimensions = "Įkelta Iris matmenys: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Naudojimas /iris world create švirkšti vieną nepradedant iš naujo." +replace_runtime_level = "{dimensionId} kuria modas serveriui veikiant, ir ji neapibrėžta kaip dimensija, todėl Iris negali jos pakeisti." +replace_registry_not_ready = "Iris vis dar skaito šio serverio dimensijas. Bandykite dar kartą po akimirkos." +replace_installed_types = "Įdiegti paketai: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Parsisiųsti & valdyti išorės importą (Modrinth)" diff --git a/core/src/main/resources/languages/nl_NL.toml b/core/src/main/resources/languages/nl_NL.toml index 22e589492..c7b9be125 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -1111,6 +1111,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Nee Iris De afme loaded_iris_dimensions = "Geladen Iris afmetingen: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Gebruik /iris world create om er één te injecteren zonder opnieuw te starten." +replace_runtime_level = "{dimensionId} wordt door een mod gemaakt terwijl de server draait en is niet als dimensie gedefinieerd, dus Iris kan haar niet vervangen." +replace_registry_not_ready = "Iris leest de dimensies van deze server nog in. Probeer het zo opnieuw." +replace_installed_types = "Geïnstalleerde packs: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Downloaden & de invoer van externe datapacks beheren (Modrinth)" diff --git a/core/src/main/resources/languages/pl_PL.toml b/core/src/main/resources/languages/pl_PL.toml index 99a973149..04dec7dab 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -1116,6 +1116,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Nie. Iris wymiar loaded_iris_dimensions = "Załadowane Iris wymiary: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Stosowanie /iris world create wykonać wstrzyknięcie bez ponownego uruchamiania." +replace_runtime_level = "{dimensionId} jest tworzony przez moda podczas działania serwera i nie jest zdefiniowany jako wymiar, więc Iris nie może go zastąpić." +replace_registry_not_ready = "Iris wciąż odczytuje wymiary tego serwera. Spróbuj ponownie za chwilę." +replace_installed_types = "Zainstalowane paczki: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Pobierz & zarządzanie importem zewnętrznych danych (Modrinth)" diff --git a/core/src/main/resources/languages/pt_PT.toml b/core/src/main/resources/languages/pt_PT.toml index 28f9263cc..0b5de85af 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -1111,6 +1111,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Não Iris as dim loaded_iris_dimensions = "Carregado Iris dimensões: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Utilização /iris world create injectar um sem reiniciar." +replace_runtime_level = "{dimensionId} é criada por um mod enquanto o servidor está em execução e não está definida como dimensão, por isso o Iris não a pode substituir." +replace_registry_not_ready = "O Iris ainda está a ler as dimensões deste servidor. Tente novamente daqui a pouco." +replace_installed_types = "Packs instalados: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Baixar & gerir as importações de pacotes de dados externos (Modrinth)" diff --git a/core/src/main/resources/languages/ru_RU.toml b/core/src/main/resources/languages/ru_RU.toml index 2d1be8afc..6ef425884 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -1116,6 +1116,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Нет. Iris В loaded_iris_dimensions = "загруженный Iris размеры: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Использовать /iris world create Вводить один без перезапуска." +replace_runtime_level = "{dimensionId} создаётся модом во время работы сервера и не определено как измерение, поэтому Iris не может его заменить." +replace_registry_not_ready = "Iris ещё считывает измерения этого сервера. Повторите попытку чуть позже." +replace_installed_types = "Установленные паки: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "& управляет внешним импортом пакетов данных (Modrinth)" diff --git a/core/src/main/resources/languages/tr_TR.toml b/core/src/main/resources/languages/tr_TR.toml index d95fae2e3..599c275b2 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -1116,6 +1116,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Hayır Iris Boyu loaded_iris_dimensions = "Yüklendi Iris Boyut: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Kullanım Kullanımı /iris world create Yeniden başlatmadan birini enjekte etmek." +replace_runtime_level = "{dimensionId} sunucu çalışırken bir mod tarafından oluşturuluyor ve boyut olarak tanımlı değil, bu yüzden Iris onu değiştiremez." +replace_registry_not_ready = "Iris bu sunucunun boyutlarını hâlâ okuyor. Birazdan tekrar deneyin." +replace_installed_types = "Yüklü paketler: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Download indir & Dış veri paketi ithalatını yönetin (Modrinth)" diff --git a/core/src/main/resources/languages/vi_VI.toml b/core/src/main/resources/languages/vi_VI.toml index 86a373f1b..e7610ef80 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -1114,6 +1114,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Không Iris Kíc loaded_iris_dimensions = "Đã nạp Iris Kích thước: {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "Dùng /iris world create tiêm một mũi mà không khởi động lại." +replace_runtime_level = "{dimensionId} được một mod tạo ra khi máy chủ đang chạy và không được định nghĩa là chiều không gian, nên Iris không thể thay thế nó." +replace_registry_not_ready = "Iris vẫn đang đọc các chiều không gian của máy chủ này. Hãy thử lại sau giây lát." +replace_installed_types = "Các gói đã cài đặt: {value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "Tải về & Quản lý các tập dữ liệu bên ngoài (Modrinth)" diff --git a/core/src/main/resources/languages/zh_CN.toml b/core/src/main/resources/languages/zh_CN.toml index 99070694e..e36ddf878 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -1114,6 +1114,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "没有 Iris 维 loaded_iris_dimensions = "已加载 Iris 维度 : {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "使用 /iris world create 在不重新启动的情况下注入一个。" +replace_runtime_level = "{dimensionId} 由模组在服务器运行时创建,并未定义为维度,因此 Iris 无法替换它。" +replace_registry_not_ready = "Iris 仍在读取此服务器的维度。请稍后再试。" +replace_installed_types = "已安装的包:{value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "下载 & 管理外部数据包进口(Modrinth)" diff --git a/core/src/main/resources/languages/zh_TW.toml b/core/src/main/resources/languages/zh_TW.toml index edb721831..07a0bc867 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -1114,6 +1114,9 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "沒有 Iris 維 loaded_iris_dimensions = "已載入 Iris 維度 : {value}" message_2 = " - {dimension}" use_iris_world_create_name_pack_inject_one_without_restarting = "使用 /iris world create 在不重新啟動的情況下注入一個。" +replace_runtime_level = "{dimensionId} 由模組在伺服器執行時建立,並未定義為維度,因此 Iris 無法取代它。" +replace_registry_not_ready = "Iris 仍在讀取此伺服器的維度。請稍後再試。" +replace_installed_types = "已安裝的包:{value}" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "下載 & 管理外部資料包進口(Modrinth)"