From a7cf2cac9a64ba3fe3bd17cef09014f76a23fba2 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 15 Oct 2026 10:10:11 -0400 Subject: [PATCH] Unified modded world replace - Added /iris replace with override and overwrite aliases on Fabric, Forge, and NeoForge using the Paper target, type, and seed arguments - Resolved main, nether, end, level-name aliases, iris: and bare names, and any loaded mod dimension as replacement targets - Kept the saved seed when seed is omitted and stored explicit seeds per replaced dimension - Applied replaced vanilla and mod dimensions through the forced datapack level stem with the configured seed bound at engine creation - Applied staged Iris world replacements at startup by moving their terrain aside and repointing the dimension registry - Refused a second staging while a replacement is pending and checked pack environments for vanilla slots - Added the modded-only off form to cancel a staged replacement or return a replaced dimension to its own generator - Reset the Overworld spawn and moved returning players out of unsafe positions after an Overworld replacement - Renamed the takeover reconciler, model, config key, state file, and recovery folder to replacement names - Removed /iris world takeover and its help entry and localized messages --- .../iris/modded/IrisModdedChunkGenerator.java | 12 + .../modded/ModdedDimensionRegistryStore.java | 5 + .../modded/ModdedDimensionReplacement.java | 144 +++++ .../modded/ModdedDimensionReplacements.java | 206 ++++++ .../iris/modded/ModdedDimensionTakeover.java | 84 --- .../iris/modded/ModdedDimensionTakeovers.java | 197 ------ .../iris/modded/ModdedEngineBootstrap.java | 14 +- .../iris/modded/ModdedForcedDatapack.java | 14 +- .../arcane/iris/modded/ModdedModConfig.java | 98 +-- .../iris/modded/ModdedProtocolHandler.java | 1 + .../iris/modded/ModdedReplacementEntry.java | 150 +++++ .../modded/ModdedReplacementReconciler.java | 417 +++++++++++++ .../iris/modded/ModdedTakeoverReconciler.java | 262 -------- .../modded/command/ModdedCommandHelp.java | 3 +- .../modded/command/ModdedCommandTree.java | 3 + .../command/ModdedReplaceArguments.java | 113 ++++ .../modded/command/ModdedReplaceCommands.java | 301 +++++++++ .../modded/command/ModdedReplaceTarget.java | 122 ++++ .../modded/command/ModdedWorldCommands.java | 85 +-- .../localization/ModdedCommandMessages.java | 20 - .../localization/ModdedHelpMessages.java | 5 - .../ModdedDimensionReplacementsTest.java | 290 +++++++++ .../modded/ModdedDimensionTakeoversTest.java | 246 -------- .../iris/modded/ModdedModConfigTest.java | 54 +- .../ModdedReplacementReconcilerTest.java | 584 ++++++++++++++++++ .../modded/ModdedTakeoverReconcilerTest.java | 386 ------------ .../command/IrisModdedCommandParityTest.java | 7 +- .../command/ModdedReplaceArgumentsTest.java | 112 ++++ .../command/ModdedReplaceTargetTest.java | 97 +++ core/src/main/resources/languages/de_DE.toml | 5 - core/src/main/resources/languages/es_ES.toml | 5 - core/src/main/resources/languages/fi_FI.toml | 5 - core/src/main/resources/languages/fr_FR.toml | 5 - core/src/main/resources/languages/he_IL.toml | 5 - core/src/main/resources/languages/it_IT.toml | 5 - core/src/main/resources/languages/ja-JP.toml | 5 - core/src/main/resources/languages/ko_KR.toml | 5 - core/src/main/resources/languages/lt_LT.toml | 5 - core/src/main/resources/languages/nl_NL.toml | 5 - core/src/main/resources/languages/pl_PL.toml | 5 - core/src/main/resources/languages/pt_PT.toml | 5 - core/src/main/resources/languages/ru_RU.toml | 5 - core/src/main/resources/languages/tr_TR.toml | 5 - core/src/main/resources/languages/vi_VI.toml | 5 - core/src/main/resources/languages/zh_CN.toml | 5 - core/src/main/resources/languages/zh_TW.toml | 5 - 46 files changed, 2663 insertions(+), 1454 deletions(-) create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacement.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacements.java delete mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeover.java delete mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementEntry.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementReconciler.java delete mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceArguments.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceCommands.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceTarget.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionReplacementsTest.java delete mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedReplacementReconcilerTest.java delete mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceArgumentsTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceTargetTest.java diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index 165ee583a..67aa2bc60 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -100,6 +100,7 @@ import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; +import java.util.OptionalLong; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.TimeUnit; @@ -477,6 +478,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat return existing; } try { + applyReplacementSeed(level); Engine created = ModdedWorldEngines.get( level, activePack, @@ -511,6 +513,16 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat } } + private void applyReplacementSeed(NativeWorld level) { + if (seedOverride != Long.MIN_VALUE) { + return; + } + OptionalLong replacementSeed = ModdedDimensionReplacements.loadedSeed(level.name()); + if (replacementSeed.isPresent()) { + seedOverride = replacementSeed.getAsLong(); + } + } + private void recordBindFailure(NativeWorld level, Throwable error) { // A runtime that is stopping says nothing about the world: fail this call, let the next bind try again. if (GenerationFailures.isShutdownFailure(error)) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java index 698006f83..610f02508 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java @@ -194,6 +194,11 @@ public final class ModdedDimensionRegistryStore { write(file, dimensions, List.of()); } + static synchronized void writeWorldRoot(Path worldRoot, List dimensions) { + Path file = storeFile(worldRoot); + write(file, dimensions, contents(file).unparsed()); + } + private static void write(Path file, List dimensions, List unparsed) { JSONArray entries = new JSONArray(); for (PersistentDimension dimension : dimensions) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacement.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacement.java new file mode 100644 index 000000000..1afe2fd53 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacement.java @@ -0,0 +1,144 @@ +/* + * 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.volmlib.util.json.JSONObject; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.regex.Pattern; + +public record ModdedDimensionReplacement(String levelKey, String pack, String packDimension, OptionalLong seed) { + private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+"); + private static final Pattern PATH = Pattern.compile("[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*"); + private static final Pattern PACK = Pattern.compile("[A-Za-z0-9_.-]+"); + private static final Pattern PACK_DIMENSION = Pattern.compile("[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*"); + private static final String TYPE = "type"; + private static final String SEED = "seed"; + + public ModdedDimensionReplacement { + Objects.requireNonNull(levelKey, "levelKey"); + Objects.requireNonNull(pack, "pack"); + Objects.requireNonNull(packDimension, "packDimension"); + Objects.requireNonNull(seed, "seed"); + } + + public static ModdedDimensionReplacement parse(String levelKey, String type, OptionalLong seed) { + String level = requireLevelKey(levelKey); + String reference = type == null ? "" : type.trim(); + int packColon = reference.indexOf(':'); + String pack = packColon >= 0 ? reference.substring(0, packColon) : reference; + String packDimension = packColon >= 0 ? reference.substring(packColon + 1) : reference; + if (!PACK.matcher(pack).matches() + || !PACK_DIMENSION.matcher(packDimension).matches() + || pack.contains("..") + || packDimension.contains("..")) { + throw new IllegalArgumentException("Invalid replacement type '" + type + + "' for " + level + ". Use pack or pack:dimensionKey."); + } + OptionalLong replacementSeed = Objects.requireNonNull(seed, "seed"); + if (replacementSeed.isPresent()) { + requireUsableSeed(replacementSeed.getAsLong()); + } + return new ModdedDimensionReplacement(level, pack, packDimension, replacementSeed); + } + + public static long requireUsableSeed(long seed) { + if (seed == Long.MIN_VALUE) { + throw new IllegalArgumentException("Seed " + Long.MIN_VALUE + + " is reserved on mod loaders; choose any other signed 64-bit integer"); + } + return seed; + } + + public static ModdedDimensionReplacement fromJson(String levelKey, Object value) { + if (!(value instanceof JSONObject entry)) { + throw new IllegalArgumentException("the replacement must be an object with a \"type\" and an optional \"seed\""); + } + Object type = entry.opt(TYPE); + if (!(type instanceof String typeRef)) { + throw new IllegalArgumentException("the replacement \"type\" must be a pack or pack:dimensionKey string"); + } + if (!entry.has(SEED) || entry.isNull(SEED)) { + return parse(levelKey, typeRef, OptionalLong.empty()); + } + Object seed = entry.opt(SEED); + if (!(seed instanceof Integer) && !(seed instanceof Long)) { + throw new IllegalArgumentException("the replacement \"seed\" must be a signed 64-bit integer"); + } + return parse(levelKey, typeRef, OptionalLong.of(((Number) seed).longValue())); + } + + public JSONObject toJson() { + JSONObject json = new JSONObject(); + json.put(TYPE, packRef()); + if (seed.isPresent()) { + json.put(SEED, seed.getAsLong()); + } + return json; + } + + public String packRef() { + return pack.equals(packDimension) ? pack : pack + ":" + packDimension; + } + + public ModdedDimensionReplacement withSeed(long replacementSeed) { + return new ModdedDimensionReplacement(levelKey, pack, packDimension, OptionalLong.of(replacementSeed)); + } + + public ModdedDimensionReplacement withPack(String canonicalPack, String canonicalPackDimension) { + return new ModdedDimensionReplacement(levelKey, canonicalPack, canonicalPackDimension, seed); + } + + public String namespace() { + return levelKey.substring(0, levelKey.indexOf(':')); + } + + public String path() { + return levelKey.substring(levelKey.indexOf(':') + 1); + } + + public Path storageFolder(Path worldRoot) { + return storageFolder(worldRoot, levelKey); + } + + public static Path storageFolder(Path worldRoot, String levelKey) { + String level = requireLevelKey(levelKey); + int colon = level.indexOf(':'); + Path folder = worldRoot.resolve("dimensions").resolve(level.substring(0, colon)); + for (String segment : level.substring(colon + 1).split("/")) { + folder = folder.resolve(segment); + } + return folder; + } + + public static String requireLevelKey(String levelKey) { + String level = levelKey == null ? "" : levelKey.trim(); + int colon = level.indexOf(':'); + if (colon <= 0 + || !NAMESPACE.matcher(level.substring(0, colon)).matches() + || !PATH.matcher(level.substring(colon + 1)).matches() + || level.contains("..")) { + throw new IllegalArgumentException("Invalid dimension key '" + levelKey + + "'. Use namespace:path, for example examplemod:skylands."); + } + return level; + } +} 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 new file mode 100644 index 000000000..3985ceb5c --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionReplacements.java @@ -0,0 +1,206 @@ +/* + * 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.spi.IrisPlatforms; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +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"; + private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed"; + private static final String PLACEHOLDER_BIOME = "minecraft:plains"; + private static final String DIMENSIONS = "dimensions"; + private static final String JSON = ".json"; + + private ModdedDimensionReplacements() { + } + + public static String levelStemJson(String dimensionTypeKey, ModdedDimensionReplacement replacement) { + JSONObject biomeSource = new JSONObject(); + biomeSource.put("type", PLACEHOLDER_BIOME_SOURCE); + biomeSource.put("biome", PLACEHOLDER_BIOME); + JSONObject generator = new JSONObject(); + generator.put("type", GENERATOR_TYPE); + generator.put("biome_source", biomeSource); + generator.put("dimension", replacement.packRef()); + JSONObject stem = new JSONObject(); + stem.put("type", Objects.requireNonNull(dimensionTypeKey, "dimensionTypeKey")); + stem.put("generator", generator); + return stem.toString(2) + "\n"; + } + + public static Path writeLevelStem(Path datapackRoot, ModdedDimensionReplacement replacement, String dimensionTypeKey) + throws IOException { + Path output = datapackRoot.resolve("data").resolve(replacement.namespace()).resolve("dimension"); + String[] segments = replacement.path().split("/"); + for (int index = 0; index < segments.length - 1; index++) { + output = output.resolve(segments[index]); + } + output = output.resolve(segments[segments.length - 1] + ".json"); + Files.createDirectories(output.getParent()); + Files.writeString(output, levelStemJson(dimensionTypeKey, replacement), StandardCharsets.UTF_8); + return output; + } + + public static void writeLevelStems(Path datapackRoot, Map configured, + StagedTypes staged, Path worldRoot) throws IOException { + for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) { + String typeRef = staged.typeRef(replacement, worldRoot); + if (typeRef == null) { + skipUnstaged(replacement, staged, worldRoot); + continue; + } + writeLevelStem(datapackRoot, replacement, typeRef); + ModdedIrisLog.info("Iris dimension replacement staged: {} generates from {} with dimension type {}", + replacement.levelKey(), replacement.packRef(), typeRef); + } + } + + public static Map configured() throws IOException { + return canonical(ModdedModConfig.get().dimensionReplacements(), IrisPlatforms.get().packsFolderNoCreate().toPath()); + } + + public static OptionalLong loadedSeed(String levelKey) { + ModdedModConfig config = ModdedModConfig.loaded(); + if (config == null) { + return OptionalLong.empty(); + } + ModdedDimensionReplacement replacement = config.dimensionReplacements().get(levelKey); + return replacement == null ? OptionalLong.empty() : replacement.seed(); + } + + static Map canonical(Map configured, + Path packsRoot) throws IOException { + Map canonical = new TreeMap<>(); + for (ModdedDimensionReplacement replacement : configured.values()) { + canonical.put(replacement.levelKey(), canonical(replacement, packsRoot)); + } + return Map.copyOf(canonical); + } + + public static ModdedDimensionReplacement canonical(ModdedDimensionReplacement replacement, Path packsRoot) + throws IOException { + Path pack = packsRoot.resolve(replacement.pack()); + if (!Files.isDirectory(pack)) { + return replacement; + } + String packName = sameIgnoringCase(pack.toRealPath().getFileName().toString(), replacement.pack()); + Path dimensions = pack.resolve(DIMENSIONS); + Path dimensionFile = dimensions.resolve(replacement.packDimension() + JSON); + String dimension = replacement.packDimension(); + if (Files.isRegularFile(dimensionFile)) { + String relative = dimensions.toRealPath().relativize(dimensionFile.toRealPath()).toString() + .replace(File.separatorChar, '/'); + dimension = sameIgnoringCase(relative.substring(0, relative.length() - JSON.length()), replacement.packDimension()); + } + return replacement.withPack(packName, dimension); + } + + public static boolean installed(ModdedDimensionReplacement replacement, Path packsRoot) { + return Files.isRegularFile(packsRoot.resolve(replacement.pack()).resolve(DIMENSIONS) + .resolve(replacement.packDimension() + JSON)); + } + + public static boolean matches(ModdedDimensionReplacement applied, ModdedDimensionReplacement configured) { + if (applied == null || configured == null) { + return applied == configured; + } + if (!applied.packRef().equals(configured.packRef())) { + return false; + } + return configured.seed().isEmpty() || configured.seed().equals(applied.seed()); + } + + public static Set changedLevels(Map applied, + Map configured) { + Set levels = new TreeSet<>(applied.keySet()); + levels.addAll(configured.keySet()); + Set changed = new TreeSet<>(); + for (String level : levels) { + if (!matches(applied.get(level), configured.get(level))) { + changed.add(level); + } + } + return changed; + } + + private static void skipUnstaged(ModdedDimensionReplacement replacement, StagedTypes staged, Path worldRoot) + throws IOException { + String reason = "pack '" + replacement.pack() + "' dimension '" + replacement.packDimension() + + "' is not installed or failed validation" + caseHint(replacement, staged); + if (worldRoot != null && ModdedReplacementReconciler.hasReplacementRecord(worldRoot, replacement.levelKey())) { + throw new IOException("Iris refused to stage the replacement of " + replacement.levelKey() + ": " + reason + + ", and the level already has an applied replacement or Iris generation history. Restore the pack, or remove the " + + replacement.levelKey() + " entry from dimensionReplacements in modded.json to hand the level back to its own generator."); + } + ModdedIrisLog.error("Iris skipped the replacement of {}: {}, so the dimension keeps its own generator and the replacement stays unapplied", + replacement.levelKey(), reason); + } + + private static String caseHint(ModdedDimensionReplacement replacement, StagedTypes staged) { + String wanted = replacement.pack() + ":" + replacement.packDimension(); + for (String installed : staged.installed().keySet()) { + if (installed.equalsIgnoreCase(wanted)) { + return "; pack names and dimension keys are case-sensitive, the installed dimension is " + installed; + } + } + return ""; + } + + private static String sameIgnoringCase(String actual, String configured) { + return actual.equalsIgnoreCase(configured) ? actual : configured; + } + + public static String fingerprint(Map configured) { + StringBuilder canonical = new StringBuilder(); + for (Map.Entry entry : new TreeMap<>(configured).entrySet()) { + canonical.append(entry.getKey()).append('=').append(entry.getValue().packRef()).append('\n'); + } + return canonical.toString(); + } + + public record StagedTypes(Map installed, Map retained) { + public static StagedTypes create() { + return new StagedTypes(new LinkedHashMap<>(), new LinkedHashMap<>()); + } + + String typeRef(ModdedDimensionReplacement replacement, Path worldRoot) { + if (worldRoot != null) { + String retainedType = retained.get(replacement.storageFolder(worldRoot).toAbsolutePath().normalize()); + if (retainedType != null) { + return retainedType; + } + } + return installed.get(replacement.pack() + ":" + replacement.packDimension()); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeover.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeover.java deleted file mode 100644 index 3034d6974..000000000 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeover.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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 java.nio.file.Path; -import java.util.regex.Pattern; - -public record ModdedDimensionTakeover(String levelKey, String pack, String packDimension) { - private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+"); - private static final Pattern PATH = Pattern.compile("[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*"); - private static final Pattern PACK = Pattern.compile("[A-Za-z0-9_.-]+"); - private static final Pattern PACK_DIMENSION = Pattern.compile("[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*"); - - public static ModdedDimensionTakeover parse(String levelKey, String packRef) { - String level = requireLevelKey(levelKey); - String reference = packRef == null ? "" : packRef.trim(); - int packColon = reference.indexOf(':'); - String pack = packColon >= 0 ? reference.substring(0, packColon) : reference; - String packDimension = packColon >= 0 ? reference.substring(packColon + 1) : reference; - if (!PACK.matcher(pack).matches() - || !PACK_DIMENSION.matcher(packDimension).matches() - || pack.contains("..") - || packDimension.contains("..")) { - throw new IllegalArgumentException("Invalid takeover pack reference '" + packRef - + "' for " + level + ". Use pack or pack:dimensionKey."); - } - return new ModdedDimensionTakeover(level, pack, packDimension); - } - - public String packRef() { - return pack.equals(packDimension) ? pack : pack + ":" + packDimension; - } - - public String namespace() { - return levelKey.substring(0, levelKey.indexOf(':')); - } - - public String path() { - return levelKey.substring(levelKey.indexOf(':') + 1); - } - - public Path storageFolder(Path worldRoot) { - return storageFolder(worldRoot, levelKey); - } - - public static Path storageFolder(Path worldRoot, String levelKey) { - String level = requireLevelKey(levelKey); - int colon = level.indexOf(':'); - Path folder = worldRoot.resolve("dimensions").resolve(level.substring(0, colon)); - for (String segment : level.substring(colon + 1).split("/")) { - folder = folder.resolve(segment); - } - return folder; - } - - private static String requireLevelKey(String levelKey) { - String level = levelKey == null ? "" : levelKey.trim(); - int colon = level.indexOf(':'); - if (colon <= 0 - || !NAMESPACE.matcher(level.substring(0, colon)).matches() - || !PATH.matcher(level.substring(colon + 1)).matches() - || level.contains("..")) { - throw new IllegalArgumentException("Invalid takeover dimension key '" + levelKey - + "'. Use namespace:path, for example aether:the_aether."); - } - return level; - } -} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java deleted file mode 100644 index 0c2ccf5d8..000000000 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java +++ /dev/null @@ -1,197 +0,0 @@ -/* - * 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.spi.IrisPlatforms; -import art.arcane.volmlib.util.json.JSONObject; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; - -public final class ModdedDimensionTakeovers { - private static final String GENERATOR_TYPE = "irisworldgen:iris"; - private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed"; - private static final String PLACEHOLDER_BIOME = "minecraft:plains"; - private static final String DIMENSIONS = "dimensions"; - private static final String JSON = ".json"; - - private ModdedDimensionTakeovers() { - } - - public static List parseAll(Map configured) { - List takeovers = new ArrayList<>(configured.size()); - for (Map.Entry entry : new TreeMap<>(configured).entrySet()) { - try { - takeovers.add(ModdedDimensionTakeover.parse(entry.getKey(), entry.getValue())); - } catch (IllegalArgumentException invalid) { - ModdedIrisLog.error("Iris ignored dimension takeover {} -> {}: {}", - entry.getKey(), entry.getValue(), invalid.getMessage()); - } - } - return List.copyOf(takeovers); - } - - public static String levelStemJson(String dimensionTypeKey, ModdedDimensionTakeover takeover) { - JSONObject biomeSource = new JSONObject(); - biomeSource.put("type", PLACEHOLDER_BIOME_SOURCE); - biomeSource.put("biome", PLACEHOLDER_BIOME); - JSONObject generator = new JSONObject(); - generator.put("type", GENERATOR_TYPE); - generator.put("biome_source", biomeSource); - generator.put("dimension", takeover.packRef()); - JSONObject stem = new JSONObject(); - stem.put("type", Objects.requireNonNull(dimensionTypeKey, "dimensionTypeKey")); - stem.put("generator", generator); - return stem.toString(2) + "\n"; - } - - public static Path writeLevelStem(Path datapackRoot, ModdedDimensionTakeover takeover, String dimensionTypeKey) - throws IOException { - Path output = datapackRoot.resolve("data").resolve(takeover.namespace()).resolve("dimension"); - String[] segments = takeover.path().split("/"); - for (int index = 0; index < segments.length - 1; index++) { - output = output.resolve(segments[index]); - } - output = output.resolve(segments[segments.length - 1] + ".json"); - Files.createDirectories(output.getParent()); - Files.writeString(output, levelStemJson(dimensionTypeKey, takeover), StandardCharsets.UTF_8); - return output; - } - - public static void writeLevelStems(Path datapackRoot, Map configured, StagedTypes staged, - Path worldRoot) throws IOException { - for (ModdedDimensionTakeover takeover : parseAll(configured)) { - String typeRef = staged.typeRef(takeover, worldRoot); - if (typeRef == null) { - skipUnstaged(takeover, staged, worldRoot); - continue; - } - writeLevelStem(datapackRoot, takeover, typeRef); - ModdedIrisLog.info("Iris dimension takeover staged: {} generates from {} with dimension type {}", - takeover.levelKey(), takeover.packRef(), typeRef); - } - } - - public static Map configured() throws IOException { - return canonical(ModdedModConfig.get().dimensionTakeovers(), IrisPlatforms.get().packsFolderNoCreate().toPath()); - } - - static Map canonical(Map configured, Path packsRoot) throws IOException { - Map canonical = new TreeMap<>(); - for (ModdedDimensionTakeover takeover : parseAll(configured)) { - canonical.put(takeover.levelKey(), canonical(takeover, packsRoot).packRef()); - } - return Map.copyOf(canonical); - } - - public static ModdedDimensionTakeover canonical(ModdedDimensionTakeover takeover, Path packsRoot) throws IOException { - Path pack = packsRoot.resolve(takeover.pack()); - if (!Files.isDirectory(pack)) { - return takeover; - } - String packName = sameIgnoringCase(pack.toRealPath().getFileName().toString(), takeover.pack()); - Path dimensions = pack.resolve(DIMENSIONS); - Path dimensionFile = dimensions.resolve(takeover.packDimension() + JSON); - String dimension = takeover.packDimension(); - if (Files.isRegularFile(dimensionFile)) { - String relative = dimensions.toRealPath().relativize(dimensionFile.toRealPath()).toString() - .replace(File.separatorChar, '/'); - dimension = sameIgnoringCase(relative.substring(0, relative.length() - JSON.length()), takeover.packDimension()); - } - return new ModdedDimensionTakeover(takeover.levelKey(), packName, dimension); - } - - public static boolean installed(ModdedDimensionTakeover takeover, Path packsRoot) { - return Files.isRegularFile(packsRoot.resolve(takeover.pack()).resolve(DIMENSIONS) - .resolve(takeover.packDimension() + JSON)); - } - - public static Set changedLevels(Map applied, Map configured) { - Set levels = new TreeSet<>(applied.keySet()); - levels.addAll(configured.keySet()); - Set changed = new TreeSet<>(); - for (String level : levels) { - if (!Objects.equals(applied.get(level), configured.get(level))) { - changed.add(level); - } - } - return changed; - } - - private static void skipUnstaged(ModdedDimensionTakeover takeover, StagedTypes staged, Path worldRoot) throws IOException { - String reason = "pack '" + takeover.pack() + "' dimension '" + takeover.packDimension() - + "' is not installed or failed validation" + caseHint(takeover, staged); - if (worldRoot != null && ModdedTakeoverReconciler.hasTakeoverRecord(worldRoot, takeover.levelKey())) { - throw new IOException("Iris refused to stage the dimension takeover of " + takeover.levelKey() + ": " + reason - + ", and the level already has an applied takeover or Iris generation history. Restore the pack, or run /iris world takeover " - + takeover.levelKey() + " off to hand the level back to its own generator."); - } - ModdedIrisLog.error("Iris skipped the dimension takeover of {}: {}, so the dimension keeps its own generator and the takeover stays unapplied", - takeover.levelKey(), reason); - } - - private static String caseHint(ModdedDimensionTakeover takeover, StagedTypes staged) { - String wanted = takeover.pack() + ":" + takeover.packDimension(); - for (String installed : staged.installed().keySet()) { - if (installed.equalsIgnoreCase(wanted)) { - return "; pack names and dimension keys are case-sensitive, the installed dimension is " + installed; - } - } - return ""; - } - - private static String sameIgnoringCase(String actual, String configured) { - return actual.equalsIgnoreCase(configured) ? actual : configured; - } - - public static String fingerprint(Map configured) { - StringBuilder canonical = new StringBuilder(); - for (Map.Entry entry : new TreeMap<>(configured).entrySet()) { - canonical.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); - } - return canonical.toString(); - } - - public record StagedTypes(Map installed, Map retained) { - public static StagedTypes create() { - return new StagedTypes(new LinkedHashMap<>(), new LinkedHashMap<>()); - } - - String typeRef(ModdedDimensionTakeover takeover, Path worldRoot) { - if (worldRoot != null) { - String retainedType = retained.get(takeover.storageFolder(worldRoot).toAbsolutePath().normalize()); - if (retainedType != null) { - return retainedType; - } - } - return installed.get(takeover.pack() + ":" + takeover.packDimension()); - } - } -} 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 5818308ff..4af8a6971 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 @@ -172,9 +172,11 @@ public final class ModdedEngineBootstrap { server.refreshWorlds(); bindWorldGenerators(server); ModdedStartup.runOnce(server); - reconcileSpawn(server); + boolean replacedOverworld = !loader().clientEnvironment() && ModdedReplacementEntry.prepare(server); + reconcileSpawn(server, replacedOverworld); if (!loader().clientEnvironment()) { - ModdedTakeoverReconciler.recordApplied(server); + ModdedReplacementReconciler.recordApplied(server); + ModdedReplacementEntry.retireIfComplete(); } ModdedWorldCheck.serverStarted(server); ModdedWorldgenCatalogs.capture(server); @@ -275,6 +277,7 @@ public final class ModdedEngineBootstrap { Throwable failure = null; failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown); failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear); + failure = runStopStage(failure, "replacement entry guard", ModdedReplacementEntry::clear); failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear); ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { @@ -330,7 +333,7 @@ public final class ModdedEngineBootstrap { spawnCaptureServer = server; } - private static void reconcileSpawn(NativeModdedServer server) { + private static void reconcileSpawn(NativeModdedServer server, boolean replacedOverworld) { NativeServerSpawn spawn = new NativeServerSpawn(server); NativeServerSpawn.Respawn current = spawn.current(); if (current == null || current.world() == null) { @@ -342,7 +345,8 @@ public final class ModdedEngineBootstrap { } String dimensionId = level.name(); boolean studio = dimensionId.startsWith("irisworldgen:studio_"); - if (!shouldReconcileSpawn(initialSpawnWasDefault, studio, current.position().x(), current.position().z())) { + if (!shouldReconcileSpawn(initialSpawnWasDefault || replacedOverworld, studio, + current.position().x(), current.position().z())) { return; } int surfaceY = spawn.surfaceAtOrigin(level); @@ -373,7 +377,7 @@ public final class ModdedEngineBootstrap { bind(); IrisLanguage.initialize(); if (!moddedLoader.clientEnvironment()) { - ModdedTakeoverReconciler.reconcileEarly(); + ModdedReplacementReconciler.reconcileEarly(); } ModdedStartup.prefetchStartupDatapack(); if (!moddedLoader.clientEnvironment()) { 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 0cd6b280f..84b3128f2 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,7 @@ public final class ModdedForcedDatapack { false ); digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8)); - digest.update(ModdedDimensionTakeovers.fingerprint(ModdedDimensionTakeovers.configured()) + digest.update(ModdedDimensionReplacements.fingerprint(ModdedDimensionReplacements.configured()) .getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } @@ -335,7 +335,7 @@ public final class ModdedForcedDatapack { int packCount = 0; KList presetIds = new KList<>(); - ModdedDimensionTakeovers.StagedTypes stagedDimensionTypes = ModdedDimensionTakeovers.StagedTypes.create(); + ModdedDimensionReplacements.StagedTypes stagedDimensionTypes = ModdedDimensionReplacements.StagedTypes.create(); Path worldRoot = worldRootOrNull(); Set legacySelections = unadoptedSelections(worldRoot); List packRoots = compilationPackRoots(worldRoot); @@ -346,8 +346,8 @@ public final class ModdedForcedDatapack { } } if (!ModdedEngineBootstrap.loader().clientEnvironment()) { - ModdedDimensionTakeovers.writeLevelStems(stagingDirectory, - ModdedDimensionTakeovers.configured(), stagedDimensionTypes, worldRoot); + ModdedDimensionReplacements.writeLevelStems(stagingDirectory, + ModdedDimensionReplacements.configured(), stagedDimensionTypes, worldRoot); } IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer); @@ -370,7 +370,7 @@ public final class ModdedForcedDatapack { private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory, Map> seenBiomes, KList presetIds, - ModdedDimensionTakeovers.StagedTypes stagedDimensionTypes) throws IOException { + ModdedDimensionReplacements.StagedTypes stagedDimensionTypes) throws IOException { File sourcePack = context.packRoot().toFile(); PackValidationResult validation; try { @@ -400,7 +400,7 @@ public final class ModdedForcedDatapack { stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-"); Map> packBiomes = new LinkedHashMap<>(); KList packPresetIds = new KList<>(); - ModdedDimensionTakeovers.StagedTypes packDimensionTypes = ModdedDimensionTakeovers.StagedTypes.create(); + ModdedDimensionReplacements.StagedTypes packDimensionTypes = ModdedDimensionReplacements.StagedTypes.create(); KList packFolders = new KList<>(); packFolders.add(packStagingDirectory.toFile()); boolean installed; @@ -484,7 +484,7 @@ public final class ModdedForcedDatapack { private static boolean installPack(PackStageContext context, IDataFixer fixer, KList folders, Map> seenBiomes, KList presetIds, - ModdedDimensionTakeovers.StagedTypes dimensionTypes) throws IOException { + ModdedDimensionReplacements.StagedTypes dimensionTypes) throws IOException { File packFolder = context.packRoot().toFile(); String packName = context.packName(); File dimensionsDirectory = new File(packFolder, "dimensions"); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java index 2bc1f4608..ba9ead672 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java @@ -32,7 +32,7 @@ import java.util.TreeSet; public final class ModdedModConfig { private static final Object LOCK = new Object(); - private static final String DIMENSION_TAKEOVERS = "dimensionTakeovers"; + private static final String DIMENSION_REPLACEMENTS = "dimensionReplacements"; private static volatile ModdedModConfig instance; private final String defaultPack; @@ -41,20 +41,20 @@ public final class ModdedModConfig { private final String mainWorldPack; private final long mainWorldSeed; private final boolean mainWorldAutoRestart; - private final Map dimensionTakeovers; - private final List takeoverProblems; + private final Map dimensionReplacements; + private final List replacementProblems; private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld, String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart, - Takeovers takeovers) { + Replacements replacements) { this.defaultPack = defaultPack; this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim(); this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld; this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim(); this.mainWorldSeed = mainWorldSeed; this.mainWorldAutoRestart = mainWorldAutoRestart; - this.dimensionTakeovers = Map.copyOf(takeovers.entries()); - this.takeoverProblems = List.copyOf(takeovers.problems()); + this.dimensionReplacements = Map.copyOf(replacements.entries()); + this.replacementProblems = List.copyOf(replacements.problems()); } public static ModdedModConfig get() { @@ -71,11 +71,15 @@ public final class ModdedModConfig { } } + public static ModdedModConfig loaded() { + return instance; + } + public static void setPrimaryWorld(String dimensionId) { synchronized (LOCK) { ModdedModConfig current = writable(); ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld, - current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.takeovers()); + current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.replacements()); instance = updated; write(configFile(), updated); } @@ -85,18 +89,18 @@ public final class ModdedModConfig { synchronized (LOCK) { ModdedModConfig current = writable(); ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld, - packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.takeovers()); + packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.replacements()); instance = updated; write(configFile(), updated); } } - public static boolean setDimensionTakeover(ModdedDimensionTakeover takeover) { - return updateDimensionTakeover(takeover.levelKey(), takeover.packRef()); + public static boolean setDimensionReplacement(ModdedDimensionReplacement replacement) { + return updateDimensionReplacement(replacement.levelKey(), replacement); } - public static boolean clearDimensionTakeover(String levelKey) { - return updateDimensionTakeover(levelKey, null); + public static boolean clearDimensionReplacement(String levelKey) { + return updateDimensionReplacement(levelKey, null); } public String defaultPack() { @@ -123,12 +127,12 @@ public final class ModdedModConfig { return mainWorldAutoRestart; } - public Map dimensionTakeovers() { - return dimensionTakeovers; + public Map dimensionReplacements() { + return dimensionReplacements; } - public List takeoverProblems() { - return takeoverProblems; + public List replacementProblems() { + return replacementProblems; } static ModdedModConfig read(Path file) { @@ -140,7 +144,7 @@ public final class ModdedModConfig { ModdedIrisLog.error("Iris modded config at {} is unreadable; using defaults", file, unreadable); return new ModdedModConfig(defaults.defaultPack, defaults.primaryWorld, defaults.routePlayersToPrimaryWorld, defaults.mainWorldPack, defaults.mainWorldSeed, defaults.mainWorldAutoRestart, - new Takeovers(Map.of(), List.of("Iris could not read " + file + ": " + unreadable.getMessage()))); + new Replacements(Map.of(), List.of("Iris could not read " + file + ": " + unreadable.getMessage()))); } return new ModdedModConfig( json.optString("defaultPack", defaults.defaultPack), @@ -149,37 +153,39 @@ public final class ModdedModConfig { json.optString("mainWorldPack", defaults.mainWorldPack), json.optLong("mainWorldSeed", defaults.mainWorldSeed), json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart), - readDimensionTakeovers(json, file)); + readDimensionReplacements(json, file)); } - private Takeovers takeovers() { - return new Takeovers(dimensionTakeovers, takeoverProblems); + private Replacements replacements() { + return new Replacements(dimensionReplacements, replacementProblems); } private static ModdedModConfig writable() { ModdedModConfig current = get(); - if (!current.takeoverProblems.isEmpty()) { + if (!current.replacementProblems.isEmpty()) { throw new IllegalStateException("Iris will not rewrite " + configFile() - + " while its dimension takeovers are unreadable: " + String.join("; ", current.takeoverProblems) + + " while its dimension replacements are unreadable: " + String.join("; ", current.replacementProblems) + ". Fix the file and restart first."); } return current; } private static ModdedModConfig defaults() { - return new ModdedModConfig("overworld", "", true, "", 0L, false, new Takeovers(Map.of(), List.of())); + return new ModdedModConfig("overworld", "", true, "", 0L, false, new Replacements(Map.of(), List.of())); } - private static boolean updateDimensionTakeover(String levelKey, String packRef) { + private static boolean updateDimensionReplacement(String levelKey, ModdedDimensionReplacement replacement) { synchronized (LOCK) { ModdedModConfig current = writable(); - Map takeovers = new TreeMap<>(current.dimensionTakeovers); - String previous = packRef == null ? takeovers.remove(levelKey) : takeovers.put(levelKey, packRef); - if (packRef == null ? previous == null : packRef.equals(previous)) { + Map replacements = new TreeMap<>(current.dimensionReplacements); + ModdedDimensionReplacement previous = replacement == null + ? replacements.remove(levelKey) + : replacements.put(levelKey, replacement); + if (replacement == null ? previous == null : replacement.equals(previous)) { return false; } ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld, - current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Takeovers(takeovers, List.of())); + current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Replacements(replacements, List.of())); instance = updated; write(configFile(), updated); return true; @@ -200,33 +206,29 @@ public final class ModdedModConfig { return read(file); } - private static Takeovers readDimensionTakeovers(JSONObject json, Path file) { - if (!json.has(DIMENSION_TAKEOVERS) || json.isNull(DIMENSION_TAKEOVERS)) { - return new Takeovers(Map.of(), List.of()); + private static Replacements readDimensionReplacements(JSONObject json, Path file) { + if (!json.has(DIMENSION_REPLACEMENTS) || json.isNull(DIMENSION_REPLACEMENTS)) { + return new Replacements(Map.of(), List.of()); } - JSONObject section = json.optJSONObject(DIMENSION_TAKEOVERS); + JSONObject section = json.optJSONObject(DIMENSION_REPLACEMENTS); if (section == null) { - return new Takeovers(Map.of(), List.of("Iris could not read " + DIMENSION_TAKEOVERS + " in " + file - + ": it must be an object mapping dimension keys to pack references")); + return new Replacements(Map.of(), List.of("Iris could not read " + DIMENSION_REPLACEMENTS + " in " + file + + ": it must be an object mapping dimension keys to replacements")); } - Map takeovers = new TreeMap<>(); + Map replacements = new TreeMap<>(); List problems = new ArrayList<>(); for (String levelKey : new TreeSet<>(section.keySet())) { - Object value = section.opt(levelKey); try { - if (!(value instanceof String packRef)) { - throw new IllegalArgumentException("the pack reference must be a string"); - } - ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, packRef); - takeovers.put(takeover.levelKey(), takeover.packRef()); + ModdedDimensionReplacement replacement = ModdedDimensionReplacement.fromJson(levelKey, section.opt(levelKey)); + replacements.put(replacement.levelKey(), replacement); } catch (IllegalArgumentException invalid) { - problems.add("Iris could not read dimension takeover '" + levelKey + "' in " + file + ": " + invalid.getMessage()); + problems.add("Iris could not read dimension replacement '" + levelKey + "' in " + file + ": " + invalid.getMessage()); } } for (String problem : problems) { ModdedIrisLog.error(problem); } - return new Takeovers(takeovers, problems); + return new Replacements(replacements, problems); } private static void write(Path file, ModdedModConfig config) { @@ -237,11 +239,11 @@ public final class ModdedModConfig { json.put("mainWorldPack", config.mainWorldPack); json.put("mainWorldSeed", config.mainWorldSeed); json.put("mainWorldAutoRestart", config.mainWorldAutoRestart); - JSONObject takeovers = new JSONObject(); - for (Map.Entry entry : new TreeMap<>(config.dimensionTakeovers).entrySet()) { - takeovers.put(entry.getKey(), entry.getValue()); + JSONObject replacements = new JSONObject(); + for (Map.Entry entry : new TreeMap<>(config.dimensionReplacements).entrySet()) { + replacements.put(entry.getKey(), entry.getValue().toJson()); } - json.put(DIMENSION_TAKEOVERS, takeovers); + json.put(DIMENSION_REPLACEMENTS, replacements); try { Files.createDirectories(file.getParent()); Files.writeString(file, json.toString(4), StandardCharsets.UTF_8); @@ -250,6 +252,6 @@ public final class ModdedModConfig { } } - private record Takeovers(Map entries, List problems) { + private record Replacements(Map entries, List problems) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java index 52655ea74..d551e3ef4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java @@ -127,6 +127,7 @@ public final class ModdedProtocolHandler { if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) { return; } + ModdedReplacementEntry.onPlayerJoin(ModdedEngineBootstrap.currentServer(), player); String sessionId = sessionId(player); ModdedStartup.warnStartupFailuresTo(player); ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementEntry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementEntry.java new file mode 100644 index 000000000..f48b2e7e3 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementEntry.java @@ -0,0 +1,150 @@ +/* + * 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.world.lifecycle.WorldReplacementEntryGuard; +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.NativeProtocolPlayer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeServerSpawn; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +public final class ModdedReplacementEntry { + private static final Set PENDING = ConcurrentHashMap.newKeySet(); + private static volatile Path guardedWorld; + private static volatile UUID transaction; + + private ModdedReplacementEntry() { + } + + public static boolean prepare(NativeModdedServer server) { + clear(); + Path overworld = ModdedDimensionReplacement.storageFolder(server.root(), ModdedReplacementReconciler.OVERWORLD); + Optional entry; + try { + entry = WorldReplacementEntryGuard.load(overworld); + } catch (IOException failure) { + ModdedIrisLog.error("Iris could not read the Overworld replacement entry marker under {}", overworld, failure); + return false; + } + if (entry.isEmpty()) { + return false; + } + PENDING.addAll(entry.get().pendingPlayers()); + transaction = entry.get().transactionId(); + guardedWorld = overworld; + ModdedIrisLog.info("Iris replaced the Overworld; {} returning player(s) are checked for a safe position on their next join", + PENDING.size()); + return true; + } + + public static void retireIfComplete() { + Path world = guardedWorld; + UUID current = transaction; + if (world == null || current == null || !PENDING.isEmpty()) { + return; + } + CompletableFuture.runAsync(() -> retire(world, current)); + } + + public static void onPlayerJoin(NativeModdedServer server, NativeProtocolPlayer player) { + if (guardedWorld == null || server == null || player == null || !PENDING.contains(player.id())) { + return; + } + NativeWorld overworld = server.overworld(); + if (overworld == null || !player.isInWorld(overworld) || standingSafely(overworld, player)) { + complete(player.id()); + return; + } + NativeServerSpawn.Respawn spawn = new NativeServerSpawn(server).current(); + boolean overworldSpawn = spawn != null && NativeDimensionRuntime.sameWorld(spawn.world(), overworld); + double x = overworldSpawn ? spawn.position().x() + 0.5D : 0.5D; + double z = overworldSpawn ? spawn.position().z() + 0.5D : 0.5D; + UUID playerId = player.id(); + NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination(server, overworld, x, Double.MIN_VALUE, z, + ModdedTeleportDeadline.fromNow())) + .whenComplete((Boolean moved, Throwable failure) -> { + if (failure != null || !Boolean.TRUE.equals(moved)) { + ModdedIrisLog.error("Iris could not move {} out of the replaced Overworld terrain to the spawn surface", + playerId, failure); + return; + } + ModdedIrisLog.info("Iris moved {} from an unsafe position in the replaced Overworld to the spawn surface", + playerId); + complete(playerId); + }); + } + + public static void clear() { + PENDING.clear(); + guardedWorld = null; + transaction = null; + } + + static boolean standingSafely(NativeWorld world, NativeProtocolPlayer player) { + int y = player.blockY(); + if (y < world.minHeight() || y + 1 >= world.maxHeight()) { + return false; + } + return !world.getBlock(player.blockX(), y, player.blockZ()).isSolid() + && !world.getBlock(player.blockX(), y + 1, player.blockZ()).isSolid(); + } + + private static void complete(UUID playerId) { + Path world = guardedWorld; + UUID current = transaction; + if (world == null || current == null || !PENDING.remove(playerId)) { + return; + } + CompletableFuture.runAsync(() -> persistCompletion(world, current, playerId)); + } + + private static synchronized void persistCompletion(Path world, UUID current, UUID playerId) { + try { + Optional updated = WorldReplacementEntryGuard.completePlayer(world, current, playerId); + if (updated.isPresent() && updated.get().pendingPlayers().isEmpty()) { + retire(world, current); + } + } catch (IOException | RuntimeException failure) { + ModdedIrisLog.error("Iris could not record the safe Overworld replacement entry of {}", playerId, failure); + } + } + + private static synchronized void retire(Path world, UUID current) { + try { + if (WorldReplacementEntryGuard.retireIfEmpty(world, current)) { + ModdedIrisLog.info("Iris retired the Overworld replacement entry marker: every known player entered safely"); + if (world.equals(guardedWorld)) { + clear(); + } + } + } catch (IOException | RuntimeException failure) { + ModdedIrisLog.error("Iris could not retire the Overworld replacement entry marker under {}", world, failure); + } + } +} 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 new file mode 100644 index 000000000..47b7ea40b --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedReplacementReconciler.java @@ -0,0 +1,417 @@ +/* + * 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.spi.IrisPlatforms; +import art.arcane.iris.world.history.GenerationEpoch; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationHistoryPaths; +import art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +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.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.UUID; +import java.util.function.Predicate; + +public final class ModdedReplacementReconciler { + public static final String OVERWORLD = "minecraft:overworld"; + private static final String STATE_FILE = "data/irisworldgen/dimension-replacements.json"; + private static final String APPLIED = "replacements"; + private static final String PENDING = "pending"; + private static final String IRIS_WORLD_NAMESPACE = "irisworldgen:"; + + private ModdedReplacementReconciler() { + } + + public static void reconcileEarly() { + try { + ModdedModConfig config = ModdedModConfig.get(); + Map configured = ModdedDimensionReplacements.configured(); + Path worldRoot = MainWorldService.configuredWorldRootIfPresent(); + if (worldRoot == null) { + if (!configured.isEmpty()) { + ModdedIrisLog.info("Iris dimension replacements {} apply to a new world; nothing to move aside", + configured.keySet()); + } + return; + } + Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen"); + Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath(); + Result result = reconcile(new Request(worldRoot, configured, config.replacementProblems(), + (ModdedDimensionReplacement replacement) -> ModdedDimensionReplacements.installed(replacement, packsRoot), + recoveryParent)); + if (!result.adoptedLevels().isEmpty()) { + ModdedIrisLog.info("Iris dimension replacement recorded for {}: their existing Iris generation history already uses the configured pack and seed", + result.adoptedLevels()); + } + if (!result.replacedWorlds().isEmpty()) { + ModdedIrisLog.warn("Iris replaced the Iris worlds {} from their staged packs", result.replacedWorlds()); + } + if (result.quarantinedLevels().isEmpty() && result.replacedWorlds().isEmpty()) { + return; + } + ModdedIrisLog.warn("Iris dimension replacement changed for {}: {}", movedLevels(result), + result.recovery() == null + ? "no existing chunk data to move aside" + : "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery() + + " so they regenerate (player data kept)"); + } catch (Throwable failure) { + ModdedIrisLog.error("Iris dimension replacement reconciliation failed", failure); + throw new IllegalStateException("Iris refused startup after dimension replacement reconciliation failed", failure); + } + } + + public static void recordApplied(NativeModdedServer server) { + Optional worldRoot = NativeWorldPaths.root(server); + if (worldRoot.isEmpty()) { + return; + } + try { + recordApplied(worldRoot.get(), ModdedDimensionReplacements.configured(), + (String levelKey, String packRef) -> generatedSeed(server, levelKey, packRef)); + } 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); + server.halt(false); + } + } + + static Map recordApplied(Path worldRoot, + Map configured, + GeneratedSeed generating) 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())); + continue; + } + ModdedIrisLog.warn("Iris replacement of {} is configured but the level does not generate from {}{}; it stays unapplied", + replacement.levelKey(), replacement.packRef(), + replacement.seed().isPresent() ? " with seed " + replacement.seed().getAsLong() : ""); + } + if (!recorded.equals(state.applied())) { + writeState(worldRoot, new State(recorded, state.pending())); + } + return Map.copyOf(recorded); + } + + static Result reconcile(Request request) throws IOException { + Path worldRoot = request.worldRoot(); + requireReadableConfig(worldRoot, request.configProblems()); + State state = readState(worldRoot); + Map applied = state.applied(); + Set refreshed = new TreeSet<>(); + boolean irisWorldsPending = false; + for (String level : state.pending().keySet()) { + if (level.startsWith(IRIS_WORLD_NAMESPACE)) { + irisWorldsPending = true; + } else if (request.configured().containsKey(level)) { + refreshed.add(level); + } + } + Set changed = new TreeSet<>(ModdedDimensionReplacements.changedLevels(applied, request.configured())); + changed.addAll(refreshed); + Map recorded = new TreeMap<>(applied); + Set quarantined = new TreeSet<>(); + Set adopted = new TreeSet<>(); + for (String level : changed) { + ModdedDimensionReplacement replacement = request.configured().get(level); + if (replacement == null) { + recorded.remove(level); + quarantined.add(level); + 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())); + adopted.add(level); + } else if (request.installed().test(replacement)) { + quarantined.add(level); + } else { + refuseOrSkipUninstalled(worldRoot, applied, replacement); + } + } + List registry = irisWorldsPending + ? ModdedDimensionRegistryStore.loadWorldRoot(worldRoot) + : List.of(); + Map replacedWorlds = new TreeMap<>(); + for (ModdedDimensionReplacement pending : state.pending().values()) { + if (!pending.levelKey().startsWith(IRIS_WORLD_NAMESPACE)) { + continue; + } + if (registered(registry, pending.levelKey())) { + replacedWorlds.put(pending.levelKey(), pending); + } else { + ModdedIrisLog.warn("Iris dropped the staged replacement of {}: the Iris world is no longer registered", + pending.levelKey()); + } + } + List sources = new ArrayList<>(); + for (String level : quarantined) { + addChunkData(sources, ModdedDimensionReplacement.storageFolder(worldRoot, level)); + } + for (String level : replacedWorlds.keySet()) { + addChunkData(sources, ModdedDimensionReplacement.storageFolder(worldRoot, level)); + } + Path recovery = request.recoveryParent().resolve("replacement-recovery-" + UUID.randomUUID()); + List moved = sources.isEmpty() ? List.of() : ModdedWorldQuarantine.move(worldRoot, sources, recovery); + if (quarantined.contains(OVERWORLD)) { + WorldReplacementEntryGuard.stage(worldRoot, ModdedDimensionReplacement.storageFolder(worldRoot, OVERWORLD), + UUID.randomUUID()); + } + if (!replacedWorlds.isEmpty()) { + ModdedDimensionRegistryStore.writeWorldRoot(worldRoot, repointed(registry, replacedWorlds)); + } + State reconciled = new State(recorded, Map.of()); + if (!reconciled.equals(state)) { + writeState(worldRoot, reconciled); + } + return new Result(Set.copyOf(quarantined), Set.copyOf(adopted), Set.copyOf(replacedWorlds.keySet()), + moved.isEmpty() ? null : recovery); + } + + public static boolean pending(Path worldRoot, String levelKey, Map configured) + throws IOException { + State state = readState(worldRoot); + if (state.pending().containsKey(levelKey)) { + return true; + } + ModdedDimensionReplacement replacement = configured.get(levelKey); + return replacement != null && !ModdedDimensionReplacements.matches(state.applied().get(levelKey), replacement); + } + + public static Optional applied(Path worldRoot, String levelKey) throws IOException { + return Optional.ofNullable(readState(worldRoot).applied().get(levelKey)); + } + + public static synchronized void stagePending(Path worldRoot, ModdedDimensionReplacement replacement) throws IOException { + if (replacement.seed().isEmpty()) { + throw new IllegalArgumentException("A staged replacement needs its effective seed: " + replacement.levelKey()); + } + State state = readState(worldRoot); + Map pending = new TreeMap<>(state.pending()); + pending.put(replacement.levelKey(), replacement); + writeState(worldRoot, new State(state.applied(), pending)); + } + + public static synchronized boolean cancelPending(Path worldRoot, String levelKey) throws IOException { + State state = readState(worldRoot); + Map pending = new TreeMap<>(state.pending()); + if (pending.remove(levelKey) == null) { + return false; + } + writeState(worldRoot, new State(state.applied(), pending)); + return true; + } + + static boolean hasReplacementRecord(Path worldRoot, String levelKey) throws IOException { + return readState(worldRoot).applied().containsKey(levelKey) || hasGenerationHistory( + ModdedDimensionReplacement.storageFolder(worldRoot, levelKey)); + } + + private static Set movedLevels(Result result) { + Set levels = new TreeSet<>(result.quarantinedLevels()); + levels.addAll(result.replacedWorlds()); + return levels; + } + + private static void addChunkData(List sources, Path storage) { + for (String folder : ModdedDimensionStorage.chunkDataFolders()) { + sources.add(storage.resolve(folder)); + } + } + + private static boolean registered(List registry, String levelKey) { + for (ModdedDimensionRegistryStore.PersistentDimension dimension : registry) { + if (dimension.id().equals(levelKey)) { + return true; + } + } + return false; + } + + private static List repointed( + List registry, + Map replacedWorlds) { + List updated = new ArrayList<>(registry.size()); + for (ModdedDimensionRegistryStore.PersistentDimension dimension : registry) { + ModdedDimensionReplacement replacement = replacedWorlds.get(dimension.id()); + updated.add(replacement == null ? dimension : new ModdedDimensionRegistryStore.PersistentDimension( + dimension.id(), replacement.pack(), replacement.packDimension(), replacement.seed().getAsLong())); + } + return updated; + } + + private static void refuseOrSkipUninstalled(Path worldRoot, Map applied, + ModdedDimensionReplacement replacement) throws IOException { + if (applied.containsKey(replacement.levelKey()) || hasGenerationHistory(replacement.storageFolder(worldRoot))) { + throw new IOException("Iris refused to switch the replacement of " + replacement.levelKey() + " to " + + replacement.packRef() + ": pack '" + replacement.pack() + "' dimension '" + replacement.packDimension() + + "' is not installed, and the level already has an applied replacement or Iris generation history. Install the pack or restore the previous replacement."); + } + ModdedIrisLog.error("Iris left {} untouched: its replacement pack '{}' dimension '{}' is not installed", + replacement.levelKey(), replacement.pack(), replacement.packDimension()); + } + + private static boolean hasGenerationHistory(Path storage) { + return Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS); + } + + static OptionalLong matchingHistorySeed(Path storage, ModdedDimensionReplacement replacement) throws IOException { + if (!hasGenerationHistory(storage)) { + return OptionalLong.empty(); + } + GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch(); + if (replacement.seed().isPresent() && replacement.seed().getAsLong() != epoch.worldSeed()) { + return OptionalLong.empty(); + } + GenerationEpoch.DimensionContract contract = epoch.dimensionContract(); + if (!contract.dimensionKey().equals(replacement.packDimension())) { + return OptionalLong.empty(); + } + Optional scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey()); + boolean samePack = scoped.isPresent() + ? scoped.get().equals(new ModdedWorldgenIds.ScopedDimension(replacement.pack(), replacement.packDimension())) + : aliasMatches(epoch, contract, replacement); + return samePack ? OptionalLong.of(epoch.worldSeed()) : OptionalLong.empty(); + } + + private static boolean aliasMatches(GenerationEpoch epoch, GenerationEpoch.DimensionContract contract, + ModdedDimensionReplacement replacement) { + Set packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), contract.dimensionKey()); + return packs.isEmpty() || packs.contains(replacement.pack()); + } + + private static OptionalLong generatedSeed(NativeModdedServer server, String levelKey, String packRef) { + NativeWorld world = server.world(levelKey); + if (world == null) { + return OptionalLong.empty(); + } + IrisModdedChunkGenerator generator = NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class); + if (generator == null || !packRef.equals(generator.dimensionKey())) { + return OptionalLong.empty(); + } + return OptionalLong.of(generator.visibleBiomeSeed()); + } + + private static void requireReadableConfig(Path worldRoot, List configProblems) throws IOException { + if (configProblems.isEmpty()) { + return; + } + Path state = worldRoot.resolve(STATE_FILE); + if (!Files.exists(state)) { + return; + } + throw new IOException("Iris refused to reconcile dimension replacements because the configured replacements are unreadable while " + + state + " records applied replacements: " + String.join("; ", configProblems) + + ". Fix the modded config so every replacement entry is valid; Iris never treats an unreadable replacement as removed."); + } + + static State readState(Path worldRoot) throws IOException { + Path state = worldRoot.resolve(STATE_FILE); + if (!Files.isRegularFile(state)) { + return State.EMPTY; + } + JSONObject json = new JSONObject(Files.readString(state, StandardCharsets.UTF_8)); + return new State(readSection(json, APPLIED, state), readSection(json, PENDING, state)); + } + + static void writeState(Path worldRoot, State replacements) throws IOException { + Path state = worldRoot.resolve(STATE_FILE); + Files.createDirectories(state.getParent()); + JSONObject json = new JSONObject(); + json.put(APPLIED, section(replacements.applied())); + json.put(PENDING, section(replacements.pending())); + Path temporary = state.resolveSibling(state.getFileName() + ".tmp-" + UUID.randomUUID()); + Files.writeString(temporary, json.toString(2), StandardCharsets.UTF_8); + try { + Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicUnsupported) { + Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static Map readSection(JSONObject json, String name, Path state) + throws IOException { + JSONObject section = json.optJSONObject(name); + if (section == null) { + return Map.of(); + } + Map replacements = new TreeMap<>(); + for (String level : section.keySet()) { + try { + replacements.put(level, ModdedDimensionReplacement.fromJson(level, section.opt(level))); + } catch (IllegalArgumentException invalid) { + throw new IOException("Iris could not read replacement '" + level + "' in " + state + ": " + + invalid.getMessage(), invalid); + } + } + return Map.copyOf(replacements); + } + + private static JSONObject section(Map replacements) { + JSONObject section = new JSONObject(); + for (Map.Entry entry : new TreeMap<>(replacements).entrySet()) { + section.put(entry.getKey(), entry.getValue().toJson()); + } + return section; + } + + interface GeneratedSeed { + OptionalLong seed(String levelKey, String packRef); + } + + record State(Map applied, Map pending) { + static final State EMPTY = new State(Map.of(), Map.of()); + + State { + applied = Map.copyOf(applied); + pending = Map.copyOf(pending); + } + } + + record Request(Path worldRoot, Map configured, List configProblems, + Predicate installed, Path recoveryParent) { + } + + record Result(Set quarantinedLevels, Set adoptedLevels, Set replacedWorlds, Path recovery) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java deleted file mode 100644 index a37922f51..000000000 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java +++ /dev/null @@ -1,262 +0,0 @@ -/* - * 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.spi.IrisPlatforms; -import art.arcane.iris.world.history.GenerationEpoch; -import art.arcane.iris.world.history.GenerationHistory; -import art.arcane.iris.world.history.GenerationHistoryPaths; -import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; -import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; -import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths; -import art.arcane.volmlib.nativelib.terrain.NativeWorld; -import art.arcane.volmlib.util.json.JSONObject; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -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.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.UUID; -import java.util.function.BiPredicate; -import java.util.function.Predicate; - -public final class ModdedTakeoverReconciler { - private static final String STATE_FILE = "data/irisworldgen/dimension-takeovers.json"; - private static final String TAKEOVERS = "takeovers"; - - private ModdedTakeoverReconciler() { - } - - public static void reconcileEarly() { - try { - ModdedModConfig config = ModdedModConfig.get(); - Map configured = ModdedDimensionTakeovers.configured(); - Path worldRoot = MainWorldService.configuredWorldRootIfPresent(); - if (worldRoot == null) { - if (!configured.isEmpty()) { - ModdedIrisLog.info("Iris dimension takeovers {} apply to a new world; nothing to quarantine", - configured.keySet()); - } - return; - } - Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen"); - Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath(); - Result result = reconcile(new Request(worldRoot, configured, config.takeoverProblems(), - (ModdedDimensionTakeover takeover) -> ModdedDimensionTakeovers.installed(takeover, packsRoot), recoveryParent)); - if (!result.adoptedLevels().isEmpty()) { - ModdedIrisLog.info("Iris dimension takeover recorded for {}: their existing Iris generation history already uses the configured pack", - result.adoptedLevels()); - } - if (result.quarantinedLevels().isEmpty()) { - return; - } - ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", result.quarantinedLevels(), - result.recovery() == null - ? "no existing chunk data to quarantine" - : "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery() - + " so they regenerate (player data kept)"); - } catch (Throwable failure) { - ModdedIrisLog.error("Iris dimension takeover reconciliation failed", failure); - throw new IllegalStateException("Iris refused startup after dimension takeover reconciliation failed", failure); - } - } - - public static void recordApplied(NativeModdedServer server) { - Optional worldRoot = NativeWorldPaths.root(server); - if (worldRoot.isEmpty()) { - return; - } - try { - recordApplied(worldRoot.get(), ModdedDimensionTakeovers.configured(), - (String levelKey, String packRef) -> generatesFrom(server, levelKey, packRef)); - } catch (IOException | RuntimeException failure) { - ModdedIrisLog.error("Iris is stopping the server because it could not record the applied dimension takeovers under {}; an unrecorded takeover would be quarantined on the next start", - worldRoot.get(), failure); - server.halt(false); - } - } - - static Map recordApplied(Path worldRoot, Map configured, - BiPredicate generating) throws IOException { - Map applied = readApplied(worldRoot); - Map recorded = new TreeMap<>(applied); - for (Map.Entry entry : new TreeMap<>(configured).entrySet()) { - if (generating.test(entry.getKey(), entry.getValue())) { - recorded.put(entry.getKey(), entry.getValue()); - continue; - } - ModdedIrisLog.warn("Iris dimension takeover of {} is configured but the level does not generate from {}; it stays unapplied", - entry.getKey(), entry.getValue()); - } - if (!recorded.equals(applied)) { - writeApplied(worldRoot, recorded); - } - return Map.copyOf(recorded); - } - - static Result reconcile(Request request) throws IOException { - Path worldRoot = request.worldRoot(); - requireReadableConfig(worldRoot, request.configProblems()); - Map applied = readApplied(worldRoot); - Set changed = ModdedDimensionTakeovers.changedLevels(applied, request.configured()); - Map state = new TreeMap<>(applied); - Set quarantined = new TreeSet<>(); - Set adopted = new TreeSet<>(); - for (String level : changed) { - String packRef = request.configured().get(level); - if (packRef == null) { - state.remove(level); - quarantined.add(level); - continue; - } - ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(level, packRef); - Path storage = takeover.storageFolder(worldRoot); - if (historyMatches(storage, takeover)) { - state.put(level, packRef); - adopted.add(level); - } else if (request.installed().test(takeover)) { - quarantined.add(level); - } else { - refuseOrSkipUninstalled(worldRoot, applied, takeover); - } - } - List sources = new ArrayList<>(); - for (String level : quarantined) { - Path storage = ModdedDimensionTakeover.storageFolder(worldRoot, level); - for (String folder : ModdedDimensionStorage.chunkDataFolders()) { - sources.add(storage.resolve(folder)); - } - } - Path recovery = request.recoveryParent().resolve("takeover-recovery-" + UUID.randomUUID()); - List moved = sources.isEmpty() ? List.of() : ModdedWorldQuarantine.move(worldRoot, sources, recovery); - if (!state.equals(applied)) { - writeApplied(worldRoot, state); - } - return new Result(Set.copyOf(quarantined), Set.copyOf(adopted), moved.isEmpty() ? null : recovery); - } - - static boolean hasTakeoverRecord(Path worldRoot, String levelKey) throws IOException { - return readApplied(worldRoot).containsKey(levelKey) || hasGenerationHistory( - ModdedDimensionTakeover.storageFolder(worldRoot, levelKey)); - } - - private static void refuseOrSkipUninstalled(Path worldRoot, Map applied, - ModdedDimensionTakeover takeover) throws IOException { - if (applied.containsKey(takeover.levelKey()) || hasGenerationHistory(takeover.storageFolder(worldRoot))) { - throw new IOException("Iris refused to switch the dimension takeover of " + takeover.levelKey() + " to " - + takeover.packRef() + ": pack '" + takeover.pack() + "' dimension '" + takeover.packDimension() - + "' is not installed, and the level already has an applied takeover or Iris generation history. Install the pack or restore the previous takeover."); - } - ModdedIrisLog.error("Iris left {} untouched: its takeover pack '{}' dimension '{}' is not installed", - takeover.levelKey(), takeover.pack(), takeover.packDimension()); - } - - private static boolean hasGenerationHistory(Path storage) { - return Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS); - } - - static boolean historyMatches(Path storage, ModdedDimensionTakeover takeover) throws IOException { - if (!hasGenerationHistory(storage)) { - return false; - } - GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch(); - GenerationEpoch.DimensionContract contract = epoch.dimensionContract(); - if (!contract.dimensionKey().equals(takeover.packDimension())) { - return false; - } - Optional scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey()); - if (scoped.isPresent()) { - return scoped.get().equals(new ModdedWorldgenIds.ScopedDimension(takeover.pack(), takeover.packDimension())); - } - Set packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), contract.dimensionKey()); - return packs.isEmpty() || packs.contains(takeover.pack()); - } - - private static boolean generatesFrom(NativeModdedServer server, String levelKey, String packRef) { - NativeWorld world = server.world(levelKey); - if (world == null) { - return false; - } - IrisModdedChunkGenerator generator = NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class); - return generator != null && packRef.equals(generator.dimensionKey()); - } - - private static void requireReadableConfig(Path worldRoot, List configProblems) throws IOException { - if (configProblems.isEmpty()) { - return; - } - Path state = worldRoot.resolve(STATE_FILE); - if (!Files.exists(state)) { - return; - } - throw new IOException("Iris refused to reconcile dimension takeovers because the configured takeovers are unreadable while " - + state + " records applied takeovers: " + String.join("; ", configProblems) - + ". Fix the modded config so every takeover entry is valid; Iris never treats an unreadable takeover as removed."); - } - - static Map readApplied(Path worldRoot) throws IOException { - Path state = worldRoot.resolve(STATE_FILE); - if (!Files.isRegularFile(state)) { - return Map.of(); - } - JSONObject takeovers = new JSONObject(Files.readString(state, StandardCharsets.UTF_8)).optJSONObject(TAKEOVERS); - if (takeovers == null) { - return Map.of(); - } - Map applied = new TreeMap<>(); - for (String level : takeovers.keySet()) { - applied.put(level, takeovers.getString(level)); - } - return Map.copyOf(applied); - } - - static void writeApplied(Path worldRoot, Map takeovers) throws IOException { - Path state = worldRoot.resolve(STATE_FILE); - Files.createDirectories(state.getParent()); - JSONObject entries = new JSONObject(); - for (Map.Entry entry : new TreeMap<>(takeovers).entrySet()) { - entries.put(entry.getKey(), entry.getValue()); - } - JSONObject json = new JSONObject(); - json.put(TAKEOVERS, entries); - Path temporary = state.resolveSibling(state.getFileName() + ".tmp-" + UUID.randomUUID()); - Files.writeString(temporary, json.toString(2), StandardCharsets.UTF_8); - try { - Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); - } catch (IOException atomicUnsupported) { - Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING); - } - } - - record Request(Path worldRoot, Map configured, List configProblems, - Predicate installed, Path recoveryParent) { - } - - record Result(Set quarantinedLevels, Set adoptedLevels, Path recovery) { - } -} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java index 7051db563..e74212d58 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.localization.DirectorCommandMessages; import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.localization.IrisMessages; import art.arcane.iris.modded.localization.ModdedCatalogMessages; @@ -79,6 +80,7 @@ final class ModdedCommandHelp { Entry.group("object", ModdedHelpMessages.GROUP_OBJECT_OBJECT_WAND_SAVE_PASTE_ANALYZE_AND_UNDO_TOOLS, "o"), Entry.group("edit", ModdedHelpMessages.GROUP_EDIT_OPEN_PACK_BIOME_REGION_AND_DIMENSION_JSON_FILES_IN_YOUR_DESKTOP_EDITOR), Entry.command("create", " [pack|pack:dimensionKey] [seed]", ModdedHelpMessages.COMMAND_CREATE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_QUOTE_PACK_DIMENSIONKEY_TO_PICK, "c"), + Entry.command("replace", " [type=] [seed=]", DirectorCommandMessages.COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART, "override", "overwrite"), Entry.command("height", "", ModdedHelpMessages.COMMAND_HEIGHT_PRINT_WORLD_HEIGHT), Entry.command("worlds", "", ModdedHelpMessages.COMMAND_WORLDS_LIST_WORLD_ACCESS, "accesslist"), Entry.group("studio", ModdedHelpMessages.GROUP_STUDIO_PACK_PROJECT_CREATION_PACKAGING_AND_REPORTS, "std", "s"), @@ -175,7 +177,6 @@ final class ModdedCommandHelp { Entry.command("enable", " [seed|random]", ModdedHelpMessages.COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK, "create"), Entry.command("replace-overworld", " [seed|random]", ModdedHelpMessages.COMMAND_REPLACE_OVERWORLD_INJECT_AN_IRIS_PRIMARY_WORLD_AND_ROUTE_PLAYERS_THERE_INSTEAD_OF_THE), Entry.command("mainworld", " [seed|random]", ModdedHelpMessages.COMMAND_MAINWORLD_CONFIGURE_PRIMARY_WORLD_PRESET), - Entry.command("takeover", " ", ModdedHelpMessages.COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK), Entry.command("disable", "", ModdedHelpMessages.COMMAND_DISABLE_EVACUATE_AND_UNLOAD_AN_IRIS_DIMENSION_WORLD_DATA_ON_DISK_IS_KEPT), Entry.command("delete", "", ModdedHelpMessages.COMMAND_DELETE_DISABLE_AN_IRIS_DIMENSION_AND_WIPE_ITS_CHUNK_AND_MANTLE_DATA_FROM, "remove", "rm"), Entry.command("list", "", ModdedHelpMessages.COMMAND_LIST_LIST_LOADED_IRIS_DIMENSIONS, "ls"), diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java index 389baf91c..6cb25508e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java @@ -138,6 +138,9 @@ final class ModdedCommandTree { root.then(createTree("create")); root.then(createTree("c")); + root.then(ModdedReplaceCommands.tree("replace")); + root.then(ModdedReplaceCommands.tree("override")); + root.then(ModdedReplaceCommands.tree("overwrite")); root.then(ModdedStudioCommands.tree("studio")); root.then(ModdedStudioCommands.tree("std")); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceArguments.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceArguments.java new file mode 100644 index 000000000..df73affca --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceArguments.java @@ -0,0 +1,113 @@ +/* + * 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 art.arcane.iris.modded.ModdedDimensionReplacement; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.OptionalLong; + +record ModdedReplaceArguments(String target, String type, OptionalLong seed, boolean off) { + static final String DEFAULT_TYPE = "default"; + static final String OFF = "off"; + static final String PRESERVE_SEED = "preserve"; + private static final String TARGET = "target"; + private static final String TYPE = "type"; + private static final String SEED = "seed"; + private static final List POSITIONAL = List.of(TARGET, TYPE, SEED); + private static final Map KEYS = Map.of( + TARGET, TARGET, + "world-name", TARGET, + TYPE, TYPE, + "dimension", TYPE, + "pack", TYPE, + SEED, SEED, + "s", SEED + ); + + static ModdedReplaceArguments parse(String raw) { + String input = raw == null ? "" : raw.trim(); + String[] values = new String[POSITIONAL.size()]; + String[] tokens = input.isEmpty() ? new String[0] : input.split("\\s+"); + for (String token : tokens) { + int equals = token.indexOf('='); + if (equals < 0) { + continue; + } + String key = KEYS.get(token.substring(0, equals).toLowerCase(Locale.ROOT)); + if (key == null) { + throw new IllegalArgumentException("Unknown argument '" + token.substring(0, equals) + + "'. Use target=, type=, or seed=."); + } + String value = token.substring(equals + 1); + if (value.isEmpty()) { + throw new IllegalArgumentException("Argument " + key + "= needs a value."); + } + int index = POSITIONAL.indexOf(key); + if (values[index] != null) { + throw new IllegalArgumentException("Argument " + key + " was given more than once."); + } + values[index] = value; + } + for (String token : tokens) { + if (token.indexOf('=') >= 0) { + continue; + } + int index = nextUnset(values); + if (index < 0) { + throw new IllegalArgumentException("Unexpected argument '" + token + + "'. Usage: /iris replace [type=] [seed=]"); + } + values[index] = token; + } + if (values[0] == null) { + throw new IllegalArgumentException("Missing target. Usage: /iris replace [type=] [seed=]"); + } + String type = values[1] == null ? DEFAULT_TYPE : values[1]; + boolean off = OFF.equalsIgnoreCase(type); + if (off && values[2] != null) { + throw new IllegalArgumentException("Turning a replacement off takes no seed."); + } + return new ModdedReplaceArguments(values[0], type, parseSeed(values[2]), off); + } + + private static int nextUnset(String[] values) { + for (int index = 0; index < values.length; index++) { + if (values[index] == null) { + return index; + } + } + return -1; + } + + private static OptionalLong parseSeed(String raw) { + if (raw == null || PRESERVE_SEED.equalsIgnoreCase(raw)) { + return OptionalLong.empty(); + } + long seed; + try { + seed = Long.parseLong(raw); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("Seed must be a signed 64-bit integer"); + } + return OptionalLong.of(ModdedDimensionReplacement.requireUsableSeed(seed)); + } +} 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 new file mode 100644 index 000000000..d15e30331 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceCommands.java @@ -0,0 +1,301 @@ +/* + * 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 art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.localization.BukkitCommandMessagesExtended; +import art.arcane.iris.localization.IrisLanguage; +import art.arcane.iris.modded.ModdedDimensionRegistryStore; +import art.arcane.iris.modded.ModdedDimensionReplacement; +import art.arcane.iris.modded.ModdedDimensionReplacements; +import art.arcane.iris.modded.ModdedEngineBootstrap; +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.pack.PackDownloader; +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; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.localization.MessageArgument; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.suggestion.Suggestions; +import com.mojang.brigadier.suggestion.SuggestionsBuilder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; + +final class ModdedReplaceCommands { + private static final Predicate GATE = NativeCommandRegistration.GAMEMASTERS; + private static final String ARGUMENTS = "arguments"; + private static final String MISSING_TARGET = "/iris replace requires an existing exact world slot; use /iris create for a new world."; + private static final String STAGE_FAILURE = "Could not stage the world replacement: "; + private static final List TARGET_NAMES = List.of("main", "nether", "end"); + private static final List KEYS = List.of("target=", "type=", "seed="); + + private ModdedReplaceCommands() { + } + + static LiteralArgumentBuilder tree(String name) { + return NativeCommandRegistration.literal(name).requires(GATE) + .then(NativeCommandRegistration.argument(ARGUMENTS, StringArgumentType.greedyString()) + .suggests(ModdedReplaceCommands::suggest) + .executes(ModdedCommandTree.localized((CommandContext context) -> replace( + context.getSource(), + StringArgumentType.getString(context, ARGUMENTS))))); + } + + private static int replace(NativeCommandSource source, String raw) { + NativeModdedServer server = source.server(); + ModdedReplaceArguments arguments; + ModdedReplaceTarget target; + try { + arguments = ModdedReplaceArguments.parse(raw); + target = ModdedReplaceTarget.resolve(arguments.target(), levelName(server)); + } catch (IllegalArgumentException invalid) { + IrisModdedCommands.fail(source, invalid.getMessage()); + return 0; + } + if (ModdedEngineBootstrap.loader().clientEnvironment()) { + IrisModdedCommands.fail(source, "Iris replaces dimensions only on dedicated servers."); + return 0; + } + if (arguments.off()) { + return turnOff(source, server, target); + } + String resolvedType = arguments.type().equalsIgnoreCase(ModdedReplaceArguments.DEFAULT_TYPE) + ? IrisSettings.get().getGenerator().getDefaultWorldType() + : arguments.type(); + String pack = packOf(resolvedType); + IrisDimension dimension = installedDimension(pack, packDimensionOf(resolvedType)); + if (dimension == null) { + reportDimensionNotFound(source, resolvedType); + return 0; + } + if (ModdedWorldCommands.blockIfPackBroken(source, target.levelKey(), pack)) { + return 0; + } + try { + ModdedReplaceTarget.requireCompatibleEnvironment(target.kind(), dimension.getEnvironment()); + ModdedDimensionReplacement requested = ModdedDimensionReplacements.canonical( + ModdedDimensionReplacement.parse(target.levelKey(), resolvedType, arguments.seed()), + ModdedPackCommands.packsRoot().toPath()); + ModdedDimensionReplacement staged = target.kind() == ModdedReplaceTarget.Kind.IRIS_WORLD + ? stageIrisWorld(server, requested) + : stageLevel(server, requested); + String seedDetail = arguments.seed().isEmpty() + ? " preserving seed " + staged.seed().getAsLong() + : " using seed " + staged.seed().getAsLong(); + IrisModdedCommands.ok(source, "Staged Iris replacement for " + staged.levelKey() + seedDetail + + ". Restart once to publish it. On that restart the current terrain of the dimension moves to " + + "a replacement-recovery folder under config/irisworldgen; player data stays in the save."); + return 1; + } catch (IllegalArgumentException rejected) { + IrisModdedCommands.fail(source, STAGE_FAILURE + rejected.getMessage()); + return 0; + } catch (IOException | RuntimeException failure) { + ModdedIrisLog.error("Iris failed to stage the world replacement for {}", target.levelKey(), failure); + IrisModdedCommands.fail(source, STAGE_FAILURE + IrisLanguage.errorDetail(failure)); + return 0; + } + } + + private static ModdedDimensionReplacement stageLevel(NativeModdedServer server, ModdedDimensionReplacement requested) + throws IOException { + NativeWorld level = server.world(requested.levelKey()); + if (level == null) { + throw new IllegalArgumentException(MISSING_TARGET); + } + Path worldRoot = server.root(); + requireNothingPending(worldRoot, requested.levelKey()); + long seed = requested.seed().orElse(ModdedReplacementReconciler.applied(worldRoot, requested.levelKey()) + .map((ModdedDimensionReplacement applied) -> applied.seed().getAsLong()) + .orElse(level.seed())); + ModdedDimensionReplacement staged = requested.withSeed(seed); + ModdedReplacementReconciler.stagePending(worldRoot, staged); + try { + if (ModdedModConfig.setDimensionReplacement(staged)) { + ModdedForcedDatapack.scheduleRegeneration("dimension replacement of " + staged.levelKey()); + } + } catch (RuntimeException failure) { + ModdedReplacementReconciler.cancelPending(worldRoot, staged.levelKey()); + throw failure; + } + return staged; + } + + private static ModdedDimensionReplacement stageIrisWorld(NativeModdedServer server, ModdedDimensionReplacement requested) + throws IOException { + ModdedDimensionRegistryStore.PersistentDimension registered = + ModdedDimensionRegistryStore.get(server, requested.levelKey()); + if (registered == null) { + throw new IllegalArgumentException(MISSING_TARGET); + } + Path worldRoot = server.root(); + requireNothingPending(worldRoot, requested.levelKey()); + ModdedDimensionReplacement staged = requested.withSeed(requested.seed().orElse(registered.seed())); + ModdedReplacementReconciler.stagePending(worldRoot, staged); + return staged; + } + + private static void requireNothingPending(Path worldRoot, String levelKey) throws IOException { + Map configured = ModdedModConfig.get().dimensionReplacements(); + if (ModdedReplacementReconciler.pending(worldRoot, levelKey, configured)) { + throw new IllegalArgumentException("A replacement is already pending for " + levelKey + "."); + } + } + + private static int turnOff(NativeCommandSource source, NativeModdedServer server, ModdedReplaceTarget target) { + String levelKey = target.levelKey(); + try { + Path worldRoot = server.root(); + boolean cancelled = ModdedReplacementReconciler.cancelPending(worldRoot, levelKey); + boolean applied = target.kind() != ModdedReplaceTarget.Kind.IRIS_WORLD + && ModdedReplacementReconciler.applied(worldRoot, levelKey).isPresent(); + boolean cleared = target.kind() != ModdedReplaceTarget.Kind.IRIS_WORLD + && ModdedModConfig.clearDimensionReplacement(levelKey); + if (cleared) { + ModdedForcedDatapack.scheduleRegeneration("dimension replacement of " + levelKey + " turned off"); + } + if (cleared && applied) { + IrisModdedCommands.ok(source, "Turned off the Iris replacement of " + levelKey + + ". Restart once to return the dimension to its own generator; its Iris terrain moves to " + + "a replacement-recovery folder under config/irisworldgen."); + return 1; + } + if (cleared || cancelled) { + IrisModdedCommands.ok(source, "Cancelled the staged Iris replacement of " + levelKey + "."); + return 1; + } + if (applied) { + IrisModdedCommands.ok(source, "The Iris replacement of " + levelKey + + " is already turned off. Restart once to return the dimension to its own generator."); + return 1; + } + IrisModdedCommands.fail(source, "No Iris replacement is staged or applied for " + levelKey + "."); + return 0; + } catch (IOException | RuntimeException failure) { + ModdedIrisLog.error("Iris failed to turn off the replacement of {}", levelKey, failure); + IrisModdedCommands.fail(source, "Could not turn off the world replacement: " + IrisLanguage.errorDetail(failure)); + return 0; + } + } + + private static String levelName(NativeModdedServer server) { + Path levelFolder = server.root().toAbsolutePath().normalize().getFileName(); + return levelFolder == null ? "" : levelFolder.toString(); + } + + private static IrisDimension installedDimension(String pack, String packDimension) { + if (!pack.matches("[A-Za-z0-9_.-]+") || pack.contains("..") + || !packDimension.matches("[A-Za-z0-9_/.-]+") || packDimension.contains("..")) { + return null; + } + File packFolder = new File(ModdedPackCommands.packsRoot(), pack); + if (!packFolder.isDirectory()) { + return null; + } + return IrisData.get(packFolder).getDimensionLoader().load(packDimension); + } + + private static void reportDimensionNotFound(NativeCommandSource source, String resolvedType) { + 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)); + IrisModdedCommands.fail(source, IrisLanguage.plain( + BukkitCommandMessagesExtended.COMMAND_IRIS_INSTALL_PACK_AND_RESTART, + MessageArgument.untrusted("command", PackDownloader.downloadCommandFor(packOf(resolvedType))))); + } + + private static String packOf(String type) { + String value = type.trim(); + int colon = value.indexOf(':'); + return colon >= 0 ? value.substring(0, colon) : value; + } + + private static String packDimensionOf(String type) { + String value = type.trim(); + int colon = value.indexOf(':'); + return colon >= 0 ? value.substring(colon + 1) : value; + } + + private static CompletableFuture suggest(CommandContext context, + SuggestionsBuilder builder) throws CommandSyntaxException { + String remaining = builder.getRemaining(); + int tokenStart = remaining.lastIndexOf(' ') + 1; + String token = remaining.substring(tokenStart); + int positional = 0; + for (String previous : remaining.substring(0, tokenStart).trim().split("\\s+")) { + if (!previous.isEmpty() && previous.indexOf('=') < 0) { + positional++; + } + } + int equals = token.indexOf('='); + if (equals >= 0) { + String key = token.substring(0, equals); + SuggestionsBuilder valueBuilder = builder.createOffset(builder.getStart() + tokenStart + equals + 1); + return switch (key) { + case "target", "world-name" -> NativeCommandRegistration.suggest(targetNames(context.getSource()), valueBuilder); + case "type", "dimension", "pack" -> typeSuggestions(context, valueBuilder); + default -> builder.buildFuture(); + }; + } + SuggestionsBuilder tokenBuilder = builder.createOffset(builder.getStart() + tokenStart); + if (positional == 0) { + List targets = new ArrayList<>(targetNames(context.getSource())); + targets.addAll(KEYS); + return NativeCommandRegistration.suggest(targets, tokenBuilder); + } + if (positional == 1) { + return typeSuggestions(context, tokenBuilder); + } + return NativeCommandRegistration.suggest(List.of(ModdedReplaceArguments.PRESERVE_SEED), tokenBuilder); + } + + private static CompletableFuture typeSuggestions(CommandContext context, + SuggestionsBuilder builder) + throws CommandSyntaxException { + if (ModdedReplaceArguments.OFF.startsWith(builder.getRemainingLowerCase())) { + builder.suggest(ModdedReplaceArguments.OFF); + } + return ModdedCommandSuggestions.PACK_NAMES.getSuggestions(context, builder); + } + + private static List targetNames(NativeCommandSource source) { + List names = new ArrayList<>(TARGET_NAMES); + for (NativeWorld level : source.server().worlds()) { + names.add(level.name()); + } + return names; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceTarget.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceTarget.java new file mode 100644 index 000000000..7cbe7a72f --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedReplaceTarget.java @@ -0,0 +1,122 @@ +/* + * 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 art.arcane.iris.modded.ModdedDimensionReplacement; +import art.arcane.iris.world.IrisEnvironment; + +import java.util.Locale; +import java.util.Objects; + +record ModdedReplaceTarget(String levelKey, Kind kind) { + static final String IRIS_WORLD_NAMESPACE = "irisworldgen"; + private static final String PAPER_IRIS_NAMESPACE = "iris"; + private static final String OVERWORLD = "minecraft:overworld"; + private static final String NETHER = "minecraft:the_nether"; + private static final String END = "minecraft:the_end"; + + ModdedReplaceTarget { + Objects.requireNonNull(levelKey, "levelKey"); + Objects.requireNonNull(kind, "kind"); + } + + static ModdedReplaceTarget resolve(String requestedName, String levelName) { + String requested = requestedName == null ? "" : requestedName.trim(); + if (requested.isEmpty()) { + throw new IllegalArgumentException("World name cannot be empty."); + } + String normalized = requested.toLowerCase(Locale.ENGLISH); + if (normalized.contains(":")) { + return exact(normalized, requestedName); + } + if (requested.contains("/") || requested.contains("\\") || requested.contains("..")) { + throw new IllegalArgumentException("World name must be a safe single path segment."); + } + String configuredLevelName = levelName == null ? "" : levelName.trim(); + if (!configuredLevelName.isEmpty()) { + if (requested.equalsIgnoreCase(configuredLevelName)) { + return of(OVERWORLD); + } + if (requested.equalsIgnoreCase(configuredLevelName + "_nether")) { + return of(NETHER); + } + if (requested.equalsIgnoreCase(configuredLevelName + "_the_end")) { + return of(END); + } + } + return switch (normalized) { + case "main", "overworld" -> of(OVERWORLD); + case "nether", "the_nether" -> of(NETHER); + case "end", "the_end" -> of(END); + default -> of(IRIS_WORLD_NAMESPACE + ":" + normalized.replace(' ', '_')); + }; + } + + static void requireCompatibleEnvironment(Kind kind, IrisEnvironment environment) { + IrisEnvironment expected = kind.environment(); + if (expected != null && environment != expected) { + throw new IllegalArgumentException("The " + kind.slotName() + " slot requires a pack environment of " + + expected.name() + "."); + } + } + + private static ModdedReplaceTarget exact(String normalized, String requestedName) { + int separator = normalized.indexOf(':'); + if (separator == 0 || separator == normalized.length() - 1 || separator != normalized.lastIndexOf(':')) { + throw new IllegalArgumentException("World identifier is invalid: " + requestedName); + } + String namespace = normalized.substring(0, separator); + String path = normalized.substring(separator + 1); + String levelKey = PAPER_IRIS_NAMESPACE.equals(namespace) ? IRIS_WORLD_NAMESPACE + ":" + path : normalized; + return of(levelKey); + } + + private static ModdedReplaceTarget of(String levelKey) { + String level = ModdedDimensionReplacement.requireLevelKey(levelKey); + Kind kind = switch (level) { + case OVERWORLD -> Kind.VANILLA_OVERWORLD; + case NETHER -> Kind.VANILLA_NETHER; + case END -> Kind.VANILLA_END; + default -> level.startsWith(IRIS_WORLD_NAMESPACE + ":") ? Kind.IRIS_WORLD : Kind.DIMENSION; + }; + return new ModdedReplaceTarget(level, kind); + } + + enum Kind { + VANILLA_OVERWORLD(IrisEnvironment.NORMAL), + VANILLA_NETHER(IrisEnvironment.NETHER), + VANILLA_END(IrisEnvironment.THE_END), + IRIS_WORLD(null), + DIMENSION(null); + + private final IrisEnvironment environment; + + Kind(IrisEnvironment environment) { + this.environment = environment; + } + + IrisEnvironment environment() { + return environment; + } + + String slotName() { + return name().toLowerCase(Locale.ENGLISH); + } + } +} 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 7718929bb..e1d9f758f 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 @@ -26,9 +26,6 @@ import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators; import art.arcane.iris.modded.MainWorldService; -import art.arcane.iris.modded.ModdedDimensionTakeover; -import art.arcane.iris.modded.ModdedDimensionTakeovers; -import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedModConfig; @@ -47,7 +44,6 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.terrain.NativeWorld; import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -63,7 +59,6 @@ public final class ModdedWorldCommands { private static final String DEFAULT_NAMESPACE = "irisworldgen"; private static final long DEFAULT_SEED = 1337L; private static final SuggestionProvider LOADED_DIMENSIONS = (CommandContext context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(loadedIrisDimensions(context.getSource().server()), builder); - private static final SuggestionProvider ALL_DIMENSIONS = (CommandContext context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(allDimensions(context.getSource().server()), builder); private ModdedWorldCommands() { } @@ -85,7 +80,6 @@ public final class ModdedWorldCommands { root.then(updateTree()); root.then(replaceOverworldTree()); root.then(mainWorldTree()); - root.then(takeoverTree()); root.then(disableTree()); root.then(deleteTree("delete")); @@ -162,18 +156,6 @@ public final class ModdedWorldCommands { StringArgumentType.getString(context, "seed")))))); } - private static LiteralArgumentBuilder takeoverTree() { - return NativeCommandRegistration.literal("takeover") - .then(NativeCommandRegistration.argument("dimension", NativeCommandArguments.identifier()).suggests(ALL_DIMENSIONS) - .then(NativeCommandRegistration.literal("off") - .executes(ModdedCommandTree.localized((CommandContext context) -> clearTakeover(context.getSource(), - dimensionArgument(context))))) - .then(NativeCommandRegistration.argument("pack", StringArgumentType.greedyString()).suggests(IrisModdedCommands.PACK_NAMES) - .executes(ModdedCommandTree.localized((CommandContext context) -> takeover(context.getSource(), - dimensionArgument(context), - StringArgumentType.getString(context, "pack")))))); - } - public static int createWorld(NativeCommandSource source, String name, String pack, long seed) { String[] packRef = parsePackRef(pack); return enable(source, name, packRef[0], packRef[1], seed); @@ -304,62 +286,6 @@ public final class ModdedWorldCommands { return 1; } - private static int takeover(NativeCommandSource source, String targetDimension, String packRaw) { - ModdedDimensionTakeover takeover; - try { - takeover = ModdedDimensionTakeover.parse(normalizeDimensionId(targetDimension), packRaw); - } catch (IllegalArgumentException invalid) { - IrisModdedCommands.fail(source, invalid.getMessage()); - return 0; - } - String pack = takeover.pack(); - if (!new File(ModdedPackCommands.packsRoot(), pack).isDirectory()) { - IrisModdedCommands.fail(source, IrisLanguage.plain( - ModdedCommandMessages.MODDED_WORLD_COMMANDS_REQUIRED_PACK_IS_NOT_INSTALLED_INSTALL_THEN_RESTART, - MessageArgument.untrusted("pack", pack))); - return 0; - } - if (blockIfPackBroken(source, takeover.levelKey(), pack) || !loadPackDimension(source, pack, takeover.packDimension())) { - return 0; - } - ModdedDimensionTakeover canonical; - try { - canonical = ModdedDimensionTakeovers.canonical(takeover, ModdedPackCommands.packsRoot().toPath()); - } catch (IOException unreadable) { - ModdedIrisLog.error("Iris could not resolve the takeover pack {} for {}", takeover.packRef(), takeover.levelKey(), unreadable); - IrisModdedCommands.fail(source, IrisLanguage.errorDetail(unreadable)); - return 0; - } - ModdedModConfig.setDimensionTakeover(canonical); - ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + canonical.levelKey()); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_SET, - MessageArgument.untrusted("dimensionId", canonical.levelKey()), - MessageArgument.untrusted("pack", canonical.pack()), - MessageArgument.untrusted("packDimension", canonical.packDimension()))); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED)); - return 1; - } - - private static int clearTakeover(NativeCommandSource source, String targetDimension) { - String dimensionId; - try { - dimensionId = normalizeDimensionId(targetDimension); - } catch (IllegalArgumentException invalid) { - IrisModdedCommands.fail(source, invalid.getMessage()); - return 0; - } - if (!ModdedModConfig.clearDimensionTakeover(dimensionId)) { - IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED, - MessageArgument.untrusted("dimensionId", dimensionId))); - return 0; - } - ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + dimensionId + " turned off"); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED, - MessageArgument.untrusted("dimensionId", dimensionId))); - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED)); - return 1; - } - private static int clearMainWorld(NativeCommandSource source) { ModdedModConfig.setMainWorld("", 0L); MainWorldService.clearOverride(); @@ -437,7 +363,7 @@ public final class ModdedWorldCommands { return 1; } - private static boolean blockIfPackBroken(NativeCommandSource source, String dimensionId, String pack) { + static boolean blockIfPackBroken(NativeCommandSource source, String dimensionId, String pack) { try { ModdedStartup.requirePackForWorldCreation(pack); return false; @@ -570,15 +496,6 @@ public final class ModdedWorldCommands { return 1; } - private static List allDimensions(NativeModdedServer server) { - List dimensions = new ArrayList<>(); - for (NativeWorld level : server.worlds()) { - dimensions.add(level.name()); - } - dimensions.addAll(ModdedModConfig.get().dimensionTakeovers().keySet()); - return dimensions.stream().distinct().sorted().toList(); - } - private static List loadedIrisDimensions(NativeModdedServer server) { List dimensions = new ArrayList<>(); for (NativeWorld level : server.worlds()) { 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 df565d8fb..f6b6779cb 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 @@ -1152,22 +1152,6 @@ public final class ModdedCommandMessages { "iris.modded.moddedworldcommands.iris_main_world_override_cleared_overworld_keeps_its_current_generator", "Iris main world override cleared. The overworld keeps its current generator; edit server.properties level-type and restart to change it back." ); - public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_SET = TextKey.of( - "iris.modded.moddedworldcommands.takeover_set", - "Iris takeover of " + "{dimensionId}" + " set to pack '" + "{pack}" + "', dimension '" + "{packDimension}" + "'." - ); - public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED = TextKey.of( - "iris.modded.moddedworldcommands.takeover_cleared", - "Iris takeover of " + "{dimensionId}" + " turned off; the dimension's own generator returns on the next restart." - ); - public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED = TextKey.of( - "iris.modded.moddedworldcommands.takeover_not_configured", - "No Iris takeover is configured for " + "{dimensionId}" + "." - ); - public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED = TextKey.of( - "iris.modded.moddedworldcommands.takeover_restart_required", - "Restart required: on the next start the dimension's chunk, entity, POI, and Iris Mantle data move to a recovery folder and regenerate. Player data is kept." - ); public static final TextKey MODDED_WORLD_COMMANDS_INVALID_SEED_USE_NUMBER_RANDOM = TextKey.of( "iris.modded.moddedworldcommands.invalid_seed_use_number_random", "Invalid seed '" + "{seedRaw}" + "'. Use a number or 'random'." @@ -1564,10 +1548,6 @@ public final class ModdedCommandMessages { MODDED_WORLD_COMMANDS_VANILLA_OVERWORLD_GENERATOR_CANNOT_BE_HOT_SWAPPED_SO_THIS_DOES, MODDED_WORLD_COMMANDS_INSTEAD_IS_NOW_CONFIGURED_PRIMARY_WORLD_PLAYERS_VANILLA_OVERWORLD_ARE, MODDED_WORLD_COMMANDS_IRIS_MAIN_WORLD_OVERRIDE_CLEARED_OVERWORLD_KEEPS_ITS_CURRENT_GENERATOR, - MODDED_WORLD_COMMANDS_TAKEOVER_SET, - MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED, - MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED, - MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED, MODDED_WORLD_COMMANDS_INVALID_SEED_USE_NUMBER_RANDOM, MODDED_WORLD_COMMANDS_PACK_IS_NOT_READY_YET_STILL_LOADING_VALIDATING_TRY_COMMAND, MODDED_WORLD_COMMANDS_FAILED_WRITE_SERVER_PROPERTIES_CHECK_FILE_PERMISSIONS_SET_LEVEL_TYPE, diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedHelpMessages.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedHelpMessages.java index ba28c3e91..219e33e73 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedHelpMessages.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/localization/ModdedHelpMessages.java @@ -390,10 +390,6 @@ public final class ModdedHelpMessages { "iris.modded.help.entry.command.mainworld", "Configure or clear the Iris primary-world preset used on restart" ); - public static final TextKey COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK = TextKey.of( - "iris.modded.help.entry.command.takeover", - "Take over an existing dimension with an Iris pack on the next restart, or turn it off" - ); public static final TextKey COMMAND_OBJECT_WE_BUKKIT_ONLY = TextKey.of( "iris.modded.help.entry.command.object.we", "Explain why WorldEdit selection import requires the Bukkit plugin" @@ -520,7 +516,6 @@ public final class ModdedHelpMessages { COMMAND_HEIGHT_PRINT_WORLD_HEIGHT, COMMAND_WORLDS_LIST_WORLD_ACCESS, COMMAND_MAINWORLD_CONFIGURE_PRIMARY_WORLD_PRESET, - COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK, COMMAND_OBJECT_WE_BUKKIT_ONLY, COMMAND_OBJECT_STUDIO_BUKKIT_ONLY, COMMAND_OBJECT_CONVERT_BUKKIT_ONLY, 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 new file mode 100644 index 000000000..d786e1e39 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionReplacementsTest.java @@ -0,0 +1,290 @@ +/* + * 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.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.io.IOException; +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.OptionalLong; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ModdedDimensionReplacementsTest { + @Test + public void parsesPackAndPackDimensionReferences() { + ModdedDimensionReplacement samePack = replacement("iristest:skylands", "skypack"); + ModdedDimensionReplacement scoped = ModdedDimensionReplacement.parse("aether:the_aether", "overworld:floating/sky", + OptionalLong.of(42L)); + + assertEquals("skypack", samePack.pack()); + assertEquals("skypack", samePack.packDimension()); + assertEquals("skypack", samePack.packRef()); + assertEquals(OptionalLong.empty(), samePack.seed()); + assertEquals("overworld", scoped.pack()); + assertEquals("floating/sky", scoped.packDimension()); + assertEquals("overworld:floating/sky", scoped.packRef()); + assertEquals(OptionalLong.of(42L), scoped.seed()); + assertEquals("aether", scoped.namespace()); + assertEquals("the_aether", scoped.path()); + } + + @Test + public void rejectsInvalidLevelKeysAndPackReferences() { + for (String levelKey : List.of("skylands", "Iristest:skylands", "iristest:", ":skylands", "iristest:sky/../x", "iristest:/sky")) { + assertThrows(levelKey, IllegalArgumentException.class, () -> replacement(levelKey, "overworld")); + } + for (String packRef : List.of("", " ", "../overworld", "overworld:", "over world", "overworld:a/../b")) { + assertThrows(packRef, IllegalArgumentException.class, () -> replacement("iristest:skylands", packRef)); + } + } + + @Test + public void jsonRoundTripsTheTypeAndOptionalSeed() { + ModdedDimensionReplacement seeded = ModdedDimensionReplacement.parse("minecraft:overworld", "overworld", + OptionalLong.of(-7L)); + ModdedDimensionReplacement unseeded = replacement("examplemod:skylands", "skypack:sky"); + + assertEquals("overworld", seeded.toJson().getString("type")); + assertEquals(-7L, seeded.toJson().getLong("seed")); + assertFalse(unseeded.toJson().has("seed")); + assertEquals(seeded, ModdedDimensionReplacement.fromJson("minecraft:overworld", seeded.toJson())); + assertEquals(unseeded, ModdedDimensionReplacement.fromJson("examplemod:skylands", unseeded.toJson())); + assertThrows(IllegalArgumentException.class, + () -> ModdedDimensionReplacement.fromJson("examplemod:skylands", "skypack")); + assertThrows(IllegalArgumentException.class, + () -> ModdedDimensionReplacement.fromJson("examplemod:skylands", new JSONObject("{\"seed\": 1}"))); + assertThrows(IllegalArgumentException.class, + () -> ModdedDimensionReplacement.fromJson("examplemod:skylands", + new JSONObject("{\"type\": \"skypack\", \"seed\": \"abc\"}"))); + } + + @Test + public void levelStoragePathFollowsTheDimensionsLayout() { + Path worldRoot = Path.of("/srv/world"); + + assertEquals(worldRoot.resolve("dimensions").resolve("iristest").resolve("skylands"), + replacement("iristest:skylands", "overworld").storageFolder(worldRoot)); + assertEquals(worldRoot.resolve("dimensions").resolve("aether").resolve("realm").resolve("upper"), + replacement("aether:realm/upper", "overworld").storageFolder(worldRoot)); + assertEquals(worldRoot.resolve("dimensions").resolve("minecraft").resolve("overworld"), + ModdedDimensionReplacement.storageFolder(worldRoot, "minecraft:overworld")); + } + + @Test + public void levelStemUsesTheIrisGeneratorWithThePresetBiomeSourcePlaceholder() { + JSONObject stem = new JSONObject(ModdedDimensionReplacements.levelStemJson("iristest:skylands", + replacement("iristest:skylands", "overworld:sky"))); + + assertEquals("iristest:skylands", stem.getString("type")); + JSONObject generator = stem.getJSONObject("generator"); + assertEquals("irisworldgen:iris", generator.getString("type")); + assertEquals("overworld:sky", generator.getString("dimension")); + assertEquals("minecraft:fixed", generator.getJSONObject("biome_source").getString("type")); + assertEquals("minecraft:plains", generator.getJSONObject("biome_source").getString("biome")); + } + + @Test + public void levelStemIsWrittenAtTheDatapackDimensionPathOfTheLevelKey() throws IOException { + Path datapack = Files.createTempDirectory("iris-replace-stem"); + try { + Path written = ModdedDimensionReplacements.writeLevelStem(datapack, + replacement("aether:the_aether", "overworld"), "aether:the_aether"); + + assertEquals(datapack.resolve("data").resolve("aether").resolve("dimension").resolve("the_aether.json"), written); + assertEquals("aether:the_aether", new JSONObject(Files.readString(written)).getString("type")); + } finally { + ModdedReplacementReconcilerTest.deleteTree(datapack); + } + } + + @Test + public void changedLevelsCoverAddedChangedReseededAndRemovedReplacements() { + Map applied = Map.of( + "iristest:kept", seeded("iristest:kept", "overworld", 5L), + "iristest:unseeded", seeded("iristest:unseeded", "overworld", 5L), + "iristest:changed", seeded("iristest:changed", "overworld", 5L), + "iristest:reseeded", seeded("iristest:reseeded", "overworld", 5L), + "iristest:removed", seeded("iristest:removed", "overworld", 5L)); + Map configured = Map.of( + "iristest:kept", seeded("iristest:kept", "overworld", 5L), + "iristest:unseeded", replacement("iristest:unseeded", "overworld"), + "iristest:changed", seeded("iristest:changed", "overworld:sky", 5L), + "iristest:reseeded", seeded("iristest:reseeded", "overworld", 6L), + "iristest:added", replacement("iristest:added", "overworld")); + + assertEquals(Set.of("iristest:changed", "iristest:reseeded", "iristest:removed", "iristest:added"), + ModdedDimensionReplacements.changedLevels(applied, configured)); + assertEquals(Set.of(), ModdedDimensionReplacements.changedLevels(applied, applied)); + } + + @Test + public void fingerprintIsOrderIndependentAndTracksEveryLevelStem() { + Map forward = new LinkedHashMap<>(); + forward.put("iristest:a", replacement("iristest:a", "overworld")); + forward.put("iristest:b", seeded("iristest:b", "overworld:sky", 9L)); + Map reversed = new LinkedHashMap<>(); + reversed.put("iristest:b", seeded("iristest:b", "overworld:sky", 9L)); + reversed.put("iristest:a", replacement("iristest:a", "overworld")); + + assertEquals(ModdedDimensionReplacements.fingerprint(forward), ModdedDimensionReplacements.fingerprint(reversed)); + assertNotEquals(ModdedDimensionReplacements.fingerprint(forward), + ModdedDimensionReplacements.fingerprint(Map.of("iristest:a", replacement("iristest:a", "overworld")))); + assertEquals("", ModdedDimensionReplacements.fingerprint(Map.of())); + assertTrue(ModdedDimensionReplacements.fingerprint(forward).contains("iristest:b=overworld:sky")); + } + + @Test + public void installedPackTypeIsStagedForTheReplacement() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-stage-installed"); + try { + Path datapack = temporary.resolve("datapack"); + ModdedDimensionReplacements.StagedTypes staged = ModdedDimensionReplacements.StagedTypes.create(); + staged.installed().put("skypack:sky", "irisworldgen:packs/x/dimensions/y/dimension_type"); + + ModdedDimensionReplacements.writeLevelStems(datapack, + Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), staged, + temporary.resolve("world")); + + 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")); + assertEquals("skypack:sky", stem.getJSONObject("generator").getString("dimension")); + } finally { + ModdedReplacementReconcilerTest.deleteTree(temporary); + } + } + + @Test + public void retainedHistoryOfTheLevelStagesTheReplacementWithoutTheInstalledPack() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-stage-retained"); + try { + Path datapack = temporary.resolve("datapack"); + Path worldRoot = temporary.resolve("world"); + ModdedDimensionReplacements.StagedTypes staged = ModdedDimensionReplacements.StagedTypes.create(); + staged.retained().put(worldRoot.resolve("dimensions/aether/the_aether").toAbsolutePath().normalize(), + "aether:the_aether"); + + ModdedDimensionReplacements.writeLevelStems(datapack, + Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), staged, worldRoot); + + JSONObject stem = new JSONObject(Files.readString(datapack.resolve("data/aether/dimension/the_aether.json"))); + assertEquals("aether:the_aether", stem.getString("type")); + } finally { + ModdedReplacementReconcilerTest.deleteTree(temporary); + } + } + + @Test + public void unstageableReplacementWithoutAnyRecordIsSkipped() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-stage-skip"); + try { + Path datapack = temporary.resolve("datapack"); + Path worldRoot = temporary.resolve("world"); + Files.createDirectories(worldRoot); + Map configured = + Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")); + + ModdedDimensionReplacements.writeLevelStems(datapack, configured, + ModdedDimensionReplacements.StagedTypes.create(), worldRoot); + ModdedDimensionReplacements.writeLevelStems(datapack, configured, + ModdedDimensionReplacements.StagedTypes.create(), null); + + assertFalse(Files.exists(datapack.resolve("data/aether/dimension/the_aether.json"))); + } finally { + ModdedReplacementReconcilerTest.deleteTree(temporary); + } + } + + @Test + public void unstageableReplacementOfAnAppliedLevelRefusesStaging() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-stage-applied"); + try { + Path worldRoot = temporary.resolve("world"); + ModdedReplacementReconciler.writeState(worldRoot, new ModdedReplacementReconciler.State( + Map.of("aether:the_aether", seeded("aether:the_aether", "skypack:sky", 5L)), Map.of())); + + 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)); + + assertTrue(refused.getMessage(), refused.getMessage().contains("aether:the_aether")); + assertTrue(refused.getMessage(), refused.getMessage().contains("skypack")); + assertTrue(refused.getMessage(), refused.getMessage().contains("dimensionReplacements")); + } finally { + ModdedReplacementReconcilerTest.deleteTree(temporary); + } + } + + @Test + public void unstageableReplacementOfALevelWithIrisHistoryRefusesStaging() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-stage-history"); + try { + Path worldRoot = temporary.resolve("world"); + Files.createDirectories(worldRoot.resolve("dimensions/aether/the_aether/iris/generation")); + + assertThrows(IOException.class, () -> ModdedDimensionReplacements.writeLevelStems( + temporary.resolve("datapack"), + Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), + ModdedDimensionReplacements.StagedTypes.create(), worldRoot)); + } finally { + ModdedReplacementReconcilerTest.deleteTree(temporary); + } + } + + @Test + public void canonicalReplacementFollowsTheInstalledPackFolderCaseAndKeepsTheSeed() throws IOException { + Path packsRoot = Files.createTempDirectory("iris-replace-canonical"); + try { + Files.createDirectories(packsRoot.resolve("skypack/dimensions")); + Files.writeString(packsRoot.resolve("skypack/dimensions/sky.json"), "{}"); + boolean caseInsensitive = Files.isDirectory(packsRoot.resolve("SKYPACK")); + + Map canonical = ModdedDimensionReplacements.canonical(Map.of( + "aether:the_aether", seeded("aether:the_aether", "SkyPack:Sky", 3L), + "aether:other", replacement("aether:other", "missing:sky")), packsRoot); + + assertEquals(caseInsensitive ? "skypack:sky" : "SkyPack:Sky", canonical.get("aether:the_aether").packRef()); + assertEquals(OptionalLong.of(3L), canonical.get("aether:the_aether").seed()); + assertEquals("missing:sky", canonical.get("aether:other").packRef()); + assertTrue(ModdedDimensionReplacements.installed(replacement("aether:the_aether", "skypack:sky"), packsRoot)); + assertFalse(ModdedDimensionReplacements.installed(replacement("aether:the_aether", "skypack:void"), packsRoot)); + } finally { + ModdedReplacementReconcilerTest.deleteTree(packsRoot); + } + } + + static ModdedDimensionReplacement replacement(String levelKey, String type) { + return ModdedDimensionReplacement.parse(levelKey, type, OptionalLong.empty()); + } + + static ModdedDimensionReplacement seeded(String levelKey, String type, long seed) { + return ModdedDimensionReplacement.parse(levelKey, type, OptionalLong.of(seed)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java deleted file mode 100644 index b2df3247c..000000000 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java +++ /dev/null @@ -1,246 +0,0 @@ -/* - * 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.volmlib.util.json.JSONObject; -import org.junit.Test; - -import java.io.IOException; -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.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -public class ModdedDimensionTakeoversTest { - @Test - public void parsesPackAndPackDimensionReferences() { - ModdedDimensionTakeover samePack = ModdedDimensionTakeover.parse("iristest:skylands", "skypack"); - ModdedDimensionTakeover scoped = ModdedDimensionTakeover.parse("aether:the_aether", "overworld:floating/sky"); - - assertEquals("skypack", samePack.pack()); - assertEquals("skypack", samePack.packDimension()); - assertEquals("skypack", samePack.packRef()); - assertEquals("overworld", scoped.pack()); - assertEquals("floating/sky", scoped.packDimension()); - assertEquals("overworld:floating/sky", scoped.packRef()); - assertEquals("aether", scoped.namespace()); - assertEquals("the_aether", scoped.path()); - } - - @Test - public void rejectsInvalidLevelKeysAndPackReferences() { - for (String levelKey : List.of("skylands", "Iristest:skylands", "iristest:", ":skylands", "iristest:sky/../x", "iristest:/sky")) { - assertThrows(levelKey, IllegalArgumentException.class, () -> ModdedDimensionTakeover.parse(levelKey, "overworld")); - } - for (String packRef : List.of("", " ", "../overworld", "overworld:", "over world", "overworld:a/../b")) { - assertThrows(packRef, IllegalArgumentException.class, () -> ModdedDimensionTakeover.parse("iristest:skylands", packRef)); - } - } - - @Test - public void levelStoragePathFollowsTheDimensionsLayout() { - Path worldRoot = Path.of("/srv/world"); - - assertEquals(worldRoot.resolve("dimensions").resolve("iristest").resolve("skylands"), - ModdedDimensionTakeover.parse("iristest:skylands", "overworld").storageFolder(worldRoot)); - assertEquals(worldRoot.resolve("dimensions").resolve("aether").resolve("realm").resolve("upper"), - ModdedDimensionTakeover.parse("aether:realm/upper", "overworld").storageFolder(worldRoot)); - } - - @Test - public void levelStemUsesTheIrisGeneratorWithThePresetBiomeSourcePlaceholder() { - ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse("iristest:skylands", "overworld:sky"); - - JSONObject stem = new JSONObject(ModdedDimensionTakeovers.levelStemJson("iristest:skylands", takeover)); - - assertEquals("iristest:skylands", stem.getString("type")); - JSONObject generator = stem.getJSONObject("generator"); - assertEquals("irisworldgen:iris", generator.getString("type")); - assertEquals("overworld:sky", generator.getString("dimension")); - assertEquals("minecraft:fixed", generator.getJSONObject("biome_source").getString("type")); - assertEquals("minecraft:plains", generator.getJSONObject("biome_source").getString("biome")); - } - - @Test - public void levelStemIsWrittenAtTheDatapackDimensionPathOfTheLevelKey() throws IOException { - Path datapack = Files.createTempDirectory("iris-takeover-stem"); - try { - ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse("aether:the_aether", "overworld"); - - Path written = ModdedDimensionTakeovers.writeLevelStem(datapack, takeover, "aether:the_aether"); - - assertEquals(datapack.resolve("data").resolve("aether").resolve("dimension").resolve("the_aether.json"), written); - assertEquals("aether:the_aether", new JSONObject(Files.readString(written)).getString("type")); - } finally { - ModdedTakeoverReconcilerTest.deleteTree(datapack); - } - } - - @Test - public void changedLevelsCoverAddedChangedAndRemovedTakeovers() { - Map applied = Map.of( - "iristest:kept", "overworld", - "iristest:changed", "overworld", - "iristest:removed", "overworld"); - Map configured = Map.of( - "iristest:kept", "overworld", - "iristest:changed", "overworld:sky", - "iristest:added", "overworld"); - - assertEquals(Set.of("iristest:changed", "iristest:removed", "iristest:added"), - ModdedDimensionTakeovers.changedLevels(applied, configured)); - assertEquals(Set.of(), ModdedDimensionTakeovers.changedLevels(configured, configured)); - } - - @Test - public void fingerprintIsOrderIndependentAndTracksEveryEntry() { - Map forward = new LinkedHashMap<>(); - forward.put("iristest:a", "overworld"); - forward.put("iristest:b", "overworld:sky"); - Map reversed = new LinkedHashMap<>(); - reversed.put("iristest:b", "overworld:sky"); - reversed.put("iristest:a", "overworld"); - - assertEquals(ModdedDimensionTakeovers.fingerprint(forward), ModdedDimensionTakeovers.fingerprint(reversed)); - assertNotEquals(ModdedDimensionTakeovers.fingerprint(forward), - ModdedDimensionTakeovers.fingerprint(Map.of("iristest:a", "overworld"))); - assertEquals("", ModdedDimensionTakeovers.fingerprint(Map.of())); - assertTrue(ModdedDimensionTakeovers.fingerprint(forward).contains("iristest:b=overworld:sky")); - } - - @Test - public void installedPackTypeIsStagedForTheTakeover() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-stage-installed"); - try { - Path datapack = temporary.resolve("datapack"); - ModdedDimensionTakeovers.StagedTypes staged = ModdedDimensionTakeovers.StagedTypes.create(); - staged.installed().put("skypack:sky", "irisworldgen:packs/x/dimensions/y/dimension_type"); - - ModdedDimensionTakeovers.writeLevelStems(datapack, Map.of("aether:the_aether", "skypack:sky"), staged, - temporary.resolve("world")); - - 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")); - assertEquals("skypack:sky", stem.getJSONObject("generator").getString("dimension")); - } finally { - ModdedTakeoverReconcilerTest.deleteTree(temporary); - } - } - - @Test - public void retainedHistoryOfTheLevelStagesTheTakeoverWithoutTheInstalledPack() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-stage-retained"); - try { - Path datapack = temporary.resolve("datapack"); - Path worldRoot = temporary.resolve("world"); - ModdedDimensionTakeovers.StagedTypes staged = ModdedDimensionTakeovers.StagedTypes.create(); - staged.retained().put(worldRoot.resolve("dimensions/aether/the_aether").toAbsolutePath().normalize(), - "aether:the_aether"); - - ModdedDimensionTakeovers.writeLevelStems(datapack, Map.of("aether:the_aether", "skypack:sky"), staged, worldRoot); - - JSONObject stem = new JSONObject(Files.readString(datapack.resolve("data/aether/dimension/the_aether.json"))); - assertEquals("aether:the_aether", stem.getString("type")); - } finally { - ModdedTakeoverReconcilerTest.deleteTree(temporary); - } - } - - @Test - public void unstageableTakeoverWithoutAnyRecordIsSkipped() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-stage-skip"); - try { - Path datapack = temporary.resolve("datapack"); - Path worldRoot = temporary.resolve("world"); - Files.createDirectories(worldRoot); - - ModdedDimensionTakeovers.writeLevelStems(datapack, Map.of("aether:the_aether", "skypack:sky"), - ModdedDimensionTakeovers.StagedTypes.create(), worldRoot); - ModdedDimensionTakeovers.writeLevelStems(datapack, Map.of("aether:the_aether", "skypack:sky"), - ModdedDimensionTakeovers.StagedTypes.create(), null); - - assertFalse(Files.exists(datapack.resolve("data/aether/dimension/the_aether.json"))); - } finally { - ModdedTakeoverReconcilerTest.deleteTree(temporary); - } - } - - @Test - public void unstageableTakeoverOfAnAppliedLevelRefusesStaging() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-stage-applied"); - try { - Path worldRoot = temporary.resolve("world"); - ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("aether:the_aether", "skypack:sky")); - - IOException refused = assertThrows(IOException.class, () -> ModdedDimensionTakeovers.writeLevelStems( - temporary.resolve("datapack"), Map.of("aether:the_aether", "skypack:sky"), - ModdedDimensionTakeovers.StagedTypes.create(), worldRoot)); - - assertTrue(refused.getMessage(), refused.getMessage().contains("aether:the_aether")); - assertTrue(refused.getMessage(), refused.getMessage().contains("skypack")); - } finally { - ModdedTakeoverReconcilerTest.deleteTree(temporary); - } - } - - @Test - public void unstageableTakeoverOfALevelWithIrisHistoryRefusesStaging() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-stage-history"); - try { - Path worldRoot = temporary.resolve("world"); - Files.createDirectories(worldRoot.resolve("dimensions/aether/the_aether/iris/generation")); - - assertThrows(IOException.class, () -> ModdedDimensionTakeovers.writeLevelStems( - temporary.resolve("datapack"), Map.of("aether:the_aether", "skypack:sky"), - ModdedDimensionTakeovers.StagedTypes.create(), worldRoot)); - } finally { - ModdedTakeoverReconcilerTest.deleteTree(temporary); - } - } - - @Test - public void canonicalTakeoverFollowsTheInstalledPackFolderCase() throws IOException { - Path packsRoot = Files.createTempDirectory("iris-takeover-canonical"); - try { - Files.createDirectories(packsRoot.resolve("skypack/dimensions")); - Files.writeString(packsRoot.resolve("skypack/dimensions/sky.json"), "{}"); - boolean caseInsensitive = Files.isDirectory(packsRoot.resolve("SKYPACK")); - - Map canonical = ModdedDimensionTakeovers.canonical( - Map.of("aether:the_aether", "SkyPack:Sky", "aether:other", "missing:sky"), packsRoot); - - assertEquals(caseInsensitive ? "skypack:sky" : "SkyPack:Sky", canonical.get("aether:the_aether")); - assertEquals("missing:sky", canonical.get("aether:other")); - assertEquals("skypack:sky", ModdedDimensionTakeovers.canonical( - Map.of("aether:the_aether", "skypack:sky"), packsRoot).get("aether:the_aether")); - assertTrue(ModdedDimensionTakeovers.installed(ModdedDimensionTakeover.parse("aether:the_aether", "skypack:sky"), packsRoot)); - assertFalse(ModdedDimensionTakeovers.installed(ModdedDimensionTakeover.parse("aether:the_aether", "skypack:void"), packsRoot)); - } finally { - ModdedTakeoverReconcilerTest.deleteTree(packsRoot); - } - } -} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedModConfigTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedModConfigTest.java index cf8a31569..3ce2ebc6e 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedModConfigTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedModConfigTest.java @@ -26,64 +26,72 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.OptionalLong; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class ModdedModConfigTest { @Test - public void validTakeoversLoadWithoutProblems() throws IOException { - Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld:sky\"}}"); + public void validReplacementsLoadWithoutProblems() throws IOException { + Path file = config("{\"dimensionReplacements\": {\"aether:the_aether\": {\"type\": \"overworld:sky\"}," + + " \"minecraft:overworld\": {\"type\": \"overworld\", \"seed\": -42}}}"); try { ModdedModConfig config = ModdedModConfig.read(file); - assertEquals(Map.of("aether:the_aether", "overworld:sky"), config.dimensionTakeovers()); - assertEquals(List.of(), config.takeoverProblems()); + assertEquals(Map.of( + "aether:the_aether", ModdedDimensionReplacement.parse("aether:the_aether", "overworld:sky", OptionalLong.empty()), + "minecraft:overworld", ModdedDimensionReplacement.parse("minecraft:overworld", "overworld", OptionalLong.of(-42L))), + config.dimensionReplacements()); + assertEquals(List.of(), config.replacementProblems()); } finally { - ModdedTakeoverReconcilerTest.deleteTree(file.getParent()); + ModdedReplacementReconcilerTest.deleteTree(file.getParent()); } } @Test public void unparseableConfigReportsAProblemNamingTheFile() throws IOException { - Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": "); + Path file = config("{\"dimensionReplacements\": {\"aether:the_aether\": "); try { ModdedModConfig config = ModdedModConfig.read(file); - assertEquals(Map.of(), config.dimensionTakeovers()); - assertEquals(1, config.takeoverProblems().size()); - assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains(file.toString())); + assertEquals(Map.of(), config.dimensionReplacements()); + assertEquals(1, config.replacementProblems().size()); + assertTrue(config.replacementProblems().getFirst(), config.replacementProblems().getFirst().contains(file.toString())); } finally { - ModdedTakeoverReconcilerTest.deleteTree(file.getParent()); + ModdedReplacementReconcilerTest.deleteTree(file.getParent()); } } @Test - public void invalidTakeoverEntryIsReportedInsteadOfSilentlyDropped() throws IOException { - Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld\", \"Broken Key\": \"overworld\"}}"); + public void invalidReplacementEntriesAreReportedInsteadOfSilentlyDropped() throws IOException { + Path file = config("{\"dimensionReplacements\": {\"aether:the_aether\": {\"type\": \"overworld\"}," + + " \"Broken Key\": {\"type\": \"overworld\"}, \"aether:plain\": \"overworld\"}}"); try { ModdedModConfig config = ModdedModConfig.read(file); - assertEquals(Map.of("aether:the_aether", "overworld"), config.dimensionTakeovers()); - assertEquals(1, config.takeoverProblems().size()); - assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains("Broken Key")); - assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains(file.toString())); + assertEquals(Map.of("aether:the_aether", ModdedDimensionReplacement.parse("aether:the_aether", "overworld", + OptionalLong.empty())), config.dimensionReplacements()); + assertEquals(2, config.replacementProblems().size()); + assertTrue(config.replacementProblems().toString(), config.replacementProblems().get(0).contains("Broken Key")); + assertTrue(config.replacementProblems().toString(), config.replacementProblems().get(1).contains("aether:plain")); + assertTrue(config.replacementProblems().getFirst(), config.replacementProblems().getFirst().contains(file.toString())); } finally { - ModdedTakeoverReconcilerTest.deleteTree(file.getParent()); + ModdedReplacementReconcilerTest.deleteTree(file.getParent()); } } @Test - public void takeoverSectionThatIsNotAnObjectIsReported() throws IOException { - Path file = config("{\"dimensionTakeovers\": [\"aether:the_aether\"]}"); + public void replacementSectionThatIsNotAnObjectIsReported() throws IOException { + Path file = config("{\"dimensionReplacements\": [\"aether:the_aether\"]}"); try { ModdedModConfig config = ModdedModConfig.read(file); - assertEquals(Map.of(), config.dimensionTakeovers()); - assertEquals(1, config.takeoverProblems().size()); - assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains("dimensionTakeovers")); + assertEquals(Map.of(), config.dimensionReplacements()); + assertEquals(1, config.replacementProblems().size()); + assertTrue(config.replacementProblems().getFirst(), config.replacementProblems().getFirst().contains("dimensionReplacements")); } finally { - ModdedTakeoverReconcilerTest.deleteTree(file.getParent()); + ModdedReplacementReconcilerTest.deleteTree(file.getParent()); } } 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 new file mode 100644 index 000000000..9f62e6f8d --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedReplacementReconcilerTest.java @@ -0,0 +1,584 @@ +/* + * 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.world.history.GenerationEpoch; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationPackFingerprint; +import art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard; +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.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import static art.arcane.iris.modded.ModdedDimensionReplacementsTest.replacement; +import static art.arcane.iris.modded.ModdedDimensionReplacementsTest.seeded; +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; + +public class ModdedReplacementReconcilerTest { + @Test + public void stateRoundTripsAppliedAndPendingEntriesAndDefaultsToEmpty() throws IOException { + Path worldRoot = Files.createTempDirectory("iris-replace-state"); + try { + assertEquals(ModdedReplacementReconciler.State.EMPTY, ModdedReplacementReconciler.readState(worldRoot)); + + ModdedReplacementReconciler.State state = new ModdedReplacementReconciler.State( + Map.of("iristest:skylands", seeded("iristest:skylands", "overworld:sky", 5L)), + Map.of("irisworldgen:myworld", seeded("irisworldgen:myworld", "underworld", 9L))); + ModdedReplacementReconciler.writeState(worldRoot, state); + + assertEquals(state, ModdedReplacementReconciler.readState(worldRoot)); + assertTrue(Files.isRegularFile(worldRoot.resolve("data/irisworldgen/dimension-replacements.json"))); + } finally { + deleteTree(worldRoot); + } + } + + @Test + public void newReplacementQuarantinesOnlyThatLevelsChunkDataAndKeepsPlayerData() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-reconcile"); + Path worldRoot = temporary.resolve("world"); + Path recoveryParent = temporary.resolve("config"); + try { + Path skylands = worldRoot.resolve("dimensions/iristest/skylands"); + for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris", "data")) { + touch(skylands.resolve(folder).resolve("payload.bin")); + } + touch(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca")); + touch(worldRoot.resolve("players/data/player.dat")); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("iristest:skylands", replacement("iristest:skylands", "overworld")), List.of(), recoveryParent); + + assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels()); + assertTrue(result.recovery().getFileName().toString().startsWith("replacement-recovery-")); + for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) { + assertFalse(folder, Files.exists(skylands.resolve(folder))); + assertTrue(folder, Files.isRegularFile(result.recovery() + .resolve("dimensions/iristest/skylands").resolve(folder).resolve("payload.bin"))); + } + assertTrue(Files.isRegularFile(skylands.resolve("data/payload.bin"))); + assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca"))); + assertTrue(Files.isRegularFile(worldRoot.resolve("players/data/player.dat"))); + assertTrue(WorldReplacementEntryGuard.load(skylands).isEmpty()); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void overworldReplacementGuardsEveryKnownPlayerUntilTheyEnterSafely() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-overworld"); + Path worldRoot = temporary.resolve("world"); + try { + UUID player = UUID.randomUUID(); + Path overworld = worldRoot.resolve("dimensions/minecraft/overworld"); + touch(overworld.resolve("region/r.0.0.mca")); + touch(worldRoot.resolve("players/data/" + player + ".dat")); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("minecraft:overworld", seeded("minecraft:overworld", "overworld", 123L)), List.of(), + temporary.resolve("config")); + + assertEquals(Set.of("minecraft:overworld"), result.quarantinedLevels()); + assertFalse(Files.exists(overworld.resolve("region"))); + Optional guard = WorldReplacementEntryGuard.load(overworld); + assertTrue(guard.isPresent()); + assertEquals(Set.of(player), guard.get().pendingPlayers()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void unchangedReplacementIsANoOpAndRemovalQuarantinesTheIrisChunks() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-cycle"); + Path worldRoot = temporary.resolve("world"); + Path recoveryParent = temporary.resolve("config"); + try { + Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca"); + writeApplied(worldRoot, seeded("iristest:skylands", "overworld", 5L)); + touch(region); + + ModdedReplacementReconciler.Result unchanged = reconcile(worldRoot, + Map.of("iristest:skylands", replacement("iristest:skylands", "overworld")), List.of(), recoveryParent); + + assertEquals(Set.of(), unchanged.quarantinedLevels()); + assertNull(unchanged.recovery()); + assertTrue(Files.isRegularFile(region)); + + ModdedReplacementReconciler.Result removed = reconcile(worldRoot, Map.of(), List.of(), recoveryParent); + + assertEquals(Set.of("iristest:skylands"), removed.quarantinedLevels()); + assertFalse(Files.exists(region)); + assertTrue(Files.isRegularFile(removed.recovery().resolve("dimensions/iristest/skylands/region/r.0.0.mca"))); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void newReplacementWithoutChunkDataStaysPendingWithoutARecoveryFolder() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-empty"); + Path worldRoot = temporary.resolve("world"); + Path recoveryParent = temporary.resolve("config"); + try { + Files.createDirectories(worldRoot); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("aether:the_aether", replacement("aether:the_aether", "overworld")), List.of(), recoveryParent); + + assertEquals(Set.of("aether:the_aether"), result.quarantinedLevels()); + assertNull(result.recovery()); + assertFalse(Files.exists(recoveryParent)); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void unreadableConfigWithRecordedReplacementsRefusesWithoutMovingAnything() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-unreadable"); + Path worldRoot = temporary.resolve("world"); + Path recoveryParent = temporary.resolve("config"); + try { + Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca"); + writeApplied(worldRoot, seeded("iristest:skylands", "overworld", 5L)); + touch(region); + List problems = List.of("Iris could not read /srv/config/irisworldgen/modded.json: Unterminated object"); + + IOException refused = assertThrows(IOException.class, () -> reconcile( + worldRoot, Map.of(), problems, recoveryParent)); + + assertTrue(refused.getMessage(), refused.getMessage().contains("modded.json")); + assertTrue(Files.isRegularFile(region)); + assertFalse(Files.exists(recoveryParent)); + assertEquals(Set.of("iristest:skylands"), ModdedReplacementReconciler.readState(worldRoot).applied().keySet()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void unreadableConfigWithAnEmptyStateFileStillRefuses() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-unreadable-empty"); + Path worldRoot = temporary.resolve("world"); + try { + ModdedReplacementReconciler.writeState(worldRoot, ModdedReplacementReconciler.State.EMPTY); + + assertThrows(IOException.class, () -> reconcile( + worldRoot, Map.of(), List.of("invalid replacement entry"), temporary.resolve("config"))); + } finally { + deleteTree(temporary); + } + } + + @Test + public void unreadableConfigWithoutRecordedStateProceeds() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-unreadable-fresh"); + Path worldRoot = temporary.resolve("world"); + try { + Files.createDirectories(worldRoot); + + ModdedReplacementReconciler.Result result = reconcile( + worldRoot, Map.of(), List.of("invalid replacement entry"), temporary.resolve("config")); + + assertEquals(Set.of(), result.quarantinedLevels()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void changedReplacementKeepsThePreviousStateUntilTheNewPackGenerates() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-change"); + Path worldRoot = temporary.resolve("world"); + try { + Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca"); + ModdedDimensionReplacement previous = seeded("iristest:skylands", "overworld", 5L); + writeApplied(worldRoot, previous); + touch(region); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("iristest:skylands", replacement("iristest:skylands", "skypack")), List.of(), + temporary.resolve("config")); + + assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels()); + assertFalse(Files.exists(region)); + assertEquals(Map.of("iristest:skylands", previous), ModdedReplacementReconciler.readState(worldRoot).applied()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void existingHistoryOfTheConfiguredPackAndSeedIsAdoptedWithoutQuarantine() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-history-match"); + Path worldRoot = temporary.resolve("world"); + try { + Path skylands = worldRoot.resolve("dimensions/iristest/skylands"); + createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of()); + touch(skylands.resolve("region/r.0.0.mca")); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("iristest:skylands", seeded("iristest:skylands", "skypack:sky", 5L)), List.of(), + temporary.resolve("config")); + + 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()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void restagingTheAppliedReplacementRegeneratesItAndKeepsItApplied() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-refresh"); + Path worldRoot = temporary.resolve("world"); + try { + Path skylands = worldRoot.resolve("dimensions/iristest/skylands"); + createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of()); + touch(skylands.resolve("region/r.0.0.mca")); + ModdedDimensionReplacement applied = seeded("iristest:skylands", "skypack:sky", 5L); + writeApplied(worldRoot, applied); + ModdedReplacementReconciler.stagePending(worldRoot, applied); + ModdedReplacementReconciler.stagePending(worldRoot, seeded("iristest:unconfigured", "skypack:sky", 5L)); + touch(worldRoot.resolve("dimensions/iristest/unconfigured/region/r.0.0.mca")); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("iristest:skylands", applied), List.of(), temporary.resolve("config")); + + assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels()); + assertEquals(Set.of(), result.adoptedLevels()); + assertFalse(Files.exists(skylands.resolve("region"))); + assertFalse(Files.exists(skylands.resolve("iris/generation"))); + assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/iristest/unconfigured/region/r.0.0.mca"))); + assertEquals(ModdedReplacementReconciler.State.EMPTY.pending(), + ModdedReplacementReconciler.readState(worldRoot).pending()); + assertEquals(Map.of("iristest:skylands", applied), ModdedReplacementReconciler.readState(worldRoot).applied()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void historyOfAnotherSeedIsQuarantined() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-history-seed"); + Path worldRoot = temporary.resolve("world"); + try { + Path skylands = worldRoot.resolve("dimensions/iristest/skylands"); + createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of()); + touch(skylands.resolve("region/r.0.0.mca")); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("iristest:skylands", seeded("iristest:skylands", "skypack:sky", 6L)), List.of(), + temporary.resolve("config")); + + assertEquals(Set.of(), result.adoptedLevels()); + assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels()); + assertFalse(Files.exists(skylands.resolve("region"))); + } finally { + deleteTree(temporary); + } + } + + @Test + public void historyOfAnotherPackIsQuarantined() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-history-other"); + Path worldRoot = temporary.resolve("world"); + try { + Path skylands = worldRoot.resolve("dimensions/iristest/skylands"); + createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("otherpack", "sky"), "sky", List.of()); + touch(skylands.resolve("region/r.0.0.mca")); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, + Map.of("iristest:skylands", replacement("iristest:skylands", "skypack:sky")), List.of(), + temporary.resolve("config")); + + assertEquals(Set.of(), result.adoptedLevels()); + assertFalse(Files.exists(skylands.resolve("region"))); + assertFalse(Files.exists(skylands.resolve("iris"))); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void borrowedHistoryMatchesOnItsRecordedAliasPack() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-history-borrowed"); + Path worldRoot = temporary.resolve("world"); + try { + Path aether = worldRoot.resolve("dimensions/aether/the_aether"); + createHistory(temporary, aether, "aether:the_aether", "sky", + List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow"))); + touch(aether.resolve("region/r.0.0.mca")); + + ModdedReplacementReconciler.Result other = reconcile(worldRoot, + Map.of("aether:the_aether", replacement("aether:the_aether", "otherpack:sky")), List.of(), + temporary.resolve("config")); + + assertEquals(Set.of(), other.adoptedLevels()); + assertFalse(Files.exists(aether.resolve("region"))); + + createHistory(temporary, aether, "aether:the_aether", "sky", + List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow"))); + ModdedReplacementReconciler.Result matching = reconcile(worldRoot, + Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), List.of(), + temporary.resolve("config")); + + assertEquals(Set.of("aether:the_aether"), matching.adoptedLevels()); + assertTrue(Files.isDirectory(aether.resolve("iris"))); + } finally { + deleteTree(temporary); + } + } + + @Test + public void pendingIrisWorldReplacementMovesItsTerrainAndRepointsItsRegistryEntry() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-iris-world"); + Path worldRoot = temporary.resolve("world"); + try { + Path myworld = worldRoot.resolve("dimensions/irisworldgen/myworld"); + touch(myworld.resolve("region/r.0.0.mca")); + touch(myworld.resolve("iris/generation/manifest.json")); + ModdedDimensionRegistryStore.write(registryFile(worldRoot), List.of( + new ModdedDimensionRegistryStore.PersistentDimension("irisworldgen:myworld", "overworld", "overworld", 1L), + new ModdedDimensionRegistryStore.PersistentDimension("irisworldgen:other", "overworld", "overworld", 2L))); + ModdedReplacementReconciler.stagePending(worldRoot, seeded("irisworldgen:myworld", "underworld", 9L)); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, Map.of(), List.of(), temporary.resolve("config")); + + assertEquals(Set.of("irisworldgen:myworld"), result.replacedWorlds()); + assertFalse(Files.exists(myworld.resolve("region"))); + assertFalse(Files.exists(myworld.resolve("iris"))); + assertTrue(Files.isRegularFile(result.recovery().resolve("dimensions/irisworldgen/myworld/region/r.0.0.mca"))); + assertEquals(List.of( + new ModdedDimensionRegistryStore.PersistentDimension("irisworldgen:myworld", "underworld", "underworld", 9L), + new ModdedDimensionRegistryStore.PersistentDimension("irisworldgen:other", "overworld", "overworld", 2L)), + ModdedDimensionRegistryStore.loadWorldRoot(worldRoot)); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).pending()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void pendingReplacementOfARemovedIrisWorldIsDroppedWithoutMovingAnything() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-iris-world-gone"); + Path worldRoot = temporary.resolve("world"); + try { + Path region = worldRoot.resolve("dimensions/irisworldgen/myworld/region/r.0.0.mca"); + touch(region); + ModdedReplacementReconciler.stagePending(worldRoot, seeded("irisworldgen:myworld", "underworld", 9L)); + + ModdedReplacementReconciler.Result result = reconcile(worldRoot, Map.of(), List.of(), temporary.resolve("config")); + + assertEquals(Set.of(), result.replacedWorlds()); + assertTrue(Files.isRegularFile(region)); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).pending()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void pendingIrisWorldReplacementsCanBeCancelledBeforeTheRestart() throws IOException { + Path worldRoot = Files.createTempDirectory("iris-replace-cancel"); + try { + ModdedReplacementReconciler.stagePending(worldRoot, seeded("irisworldgen:myworld", "underworld", 9L)); + + assertTrue(ModdedReplacementReconciler.cancelPending(worldRoot, "irisworldgen:myworld")); + assertFalse(ModdedReplacementReconciler.cancelPending(worldRoot, "irisworldgen:myworld")); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).pending()); + } finally { + deleteTree(worldRoot); + } + } + + @Test + public void recordAppliedStoresOnlyReplacementsThatGenerateFromTheirPackAndSeed() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-record"); + Path worldRoot = temporary.resolve("world"); + try { + writeApplied(worldRoot, seeded("iristest:kept", "overworld", 5L)); + + 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); + }); + + 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)); + assertEquals(expected, recorded); + assertEquals(expected, ModdedReplacementReconciler.readState(worldRoot).applied()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void recordAppliedFailsWhenTheStateCannotBeWritten() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-record-fail"); + Path worldRoot = temporary.resolve("world"); + try { + touch(worldRoot.resolve("data/irisworldgen/dimension-replacements.json/blocker")); + + assertThrows(IOException.class, () -> ModdedReplacementReconciler.recordApplied(worldRoot, + Map.of("iristest:live", replacement("iristest:live", "skypack")), + (String level, String packRef) -> OptionalLong.of(1L))); + } finally { + deleteTree(temporary); + } + } + + @Test + public void switchingAnAppliedLevelToAnUninstalledPackRefusesBeforeMovingAnything() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-uninstalled-applied"); + Path worldRoot = temporary.resolve("world"); + try { + Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca"); + writeApplied(worldRoot, seeded("iristest:skylands", "overworld", 5L)); + touch(region); + + 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")))); + + assertTrue(refused.getMessage(), refused.getMessage().contains("missing")); + assertTrue(Files.isRegularFile(region)); + assertEquals(Set.of("iristest:skylands"), ModdedReplacementReconciler.readState(worldRoot).applied().keySet()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void newReplacementOfAnUninstalledPackLeavesTheLevelAlone() throws IOException { + Path temporary = Files.createTempDirectory("iris-replace-uninstalled-new"); + Path worldRoot = temporary.resolve("world"); + try { + Path region = worldRoot.resolve("dimensions/aether/the_aether/region/r.0.0.mca"); + touch(region); + + 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"))); + + assertEquals(Set.of(), result.quarantinedLevels()); + assertTrue(Files.isRegularFile(region)); + assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied()); + } finally { + deleteTree(temporary); + } + } + + @Test + public void pendingCoversStagedLevelStemsAndIrisWorlds() throws IOException { + Path worldRoot = Files.createTempDirectory("iris-replace-pending"); + try { + writeApplied(worldRoot, seeded("minecraft:overworld", "overworld", 5L)); + ModdedReplacementReconciler.stagePending(worldRoot, seeded("irisworldgen:myworld", "underworld", 9L)); + Map configured = Map.of( + "minecraft:overworld", seeded("minecraft:overworld", "overworld", 5L), + "minecraft:the_nether", seeded("minecraft:the_nether", "underworld", 5L)); + + assertFalse(ModdedReplacementReconciler.pending(worldRoot, "minecraft:overworld", configured)); + assertTrue(ModdedReplacementReconciler.pending(worldRoot, "minecraft:the_nether", configured)); + assertTrue(ModdedReplacementReconciler.pending(worldRoot, "irisworldgen:myworld", configured)); + assertFalse(ModdedReplacementReconciler.pending(worldRoot, "minecraft:the_end", configured)); + } finally { + deleteTree(worldRoot); + } + } + + 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)); + } + + private static void writeApplied(Path worldRoot, ModdedDimensionReplacement applied) throws IOException { + ModdedReplacementReconciler.writeState(worldRoot, new ModdedReplacementReconciler.State( + Map.of(applied.levelKey(), applied), Map.of())); + } + + private static Path registryFile(Path worldRoot) { + return worldRoot.resolve("iris").resolve("iris-dimensions.json"); + } + + private static void createHistory(Path temporary, Path dimensionRoot, String dimensionTypeKey, String dimensionKey, + List biomeKeys) throws IOException { + Path source = Files.createTempDirectory(temporary, "pack"); + touch(source.resolve("dimensions/" + dimensionKey + ".json")); + GenerationEpoch epoch = ModdedRetainedPackNameTest.epoch(dimensionTypeKey, dimensionKey, biomeKeys); + Files.createDirectories(dimensionRoot); + GenerationHistory.create(dimensionRoot, source, + GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION), 5L, + epoch.dimensionContract(), epoch.registryContract()); + } + + private static void touch(Path file) throws IOException { + Files.createDirectories(file.getParent()); + Files.writeString(file, "x", StandardCharsets.UTF_8); + } + + static void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream walk = Files.walk(root)) { + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java deleted file mode 100644 index e6bc58250..000000000 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java +++ /dev/null @@ -1,386 +0,0 @@ -/* - * 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.world.history.GenerationEpoch; -import art.arcane.iris.world.history.GenerationHistory; -import art.arcane.iris.world.history.GenerationPackFingerprint; -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.Map; -import java.util.Set; -import java.util.stream.Stream; - -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; - -public class ModdedTakeoverReconcilerTest { - @Test - public void appliedStateRoundTripsAndDefaultsToEmpty() throws IOException { - Path worldRoot = Files.createTempDirectory("iris-takeover-state"); - try { - assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); - - ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld:sky")); - - assertEquals(Map.of("iristest:skylands", "overworld:sky"), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(worldRoot); - } - } - - @Test - public void newTakeoverQuarantinesOnlyThatLevelsChunkDataAndKeepsPlayerData() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-reconcile"); - Path worldRoot = temporary.resolve("world"); - Path recoveryParent = temporary.resolve("config"); - try { - Path skylands = worldRoot.resolve("dimensions/iristest/skylands"); - for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris", "data")) { - touch(skylands.resolve(folder).resolve("payload.bin")); - } - touch(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca")); - touch(worldRoot.resolve("players/data/player.dat")); - - ModdedTakeoverReconciler.Result result = reconcile( - worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent); - - assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels()); - for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) { - assertFalse(folder, Files.exists(skylands.resolve(folder))); - assertTrue(folder, Files.isRegularFile(result.recovery() - .resolve("dimensions/iristest/skylands").resolve(folder).resolve("payload.bin"))); - } - assertTrue(Files.isRegularFile(skylands.resolve("data/payload.bin"))); - assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca"))); - assertTrue(Files.isRegularFile(worldRoot.resolve("players/data/player.dat"))); - assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void unchangedTakeoverIsANoOpAndRemovalQuarantinesTheIrisChunks() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-cycle"); - Path worldRoot = temporary.resolve("world"); - Path recoveryParent = temporary.resolve("config"); - try { - Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca"); - ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld")); - touch(region); - - ModdedTakeoverReconciler.Result unchanged = reconcile( - worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent); - - assertEquals(Set.of(), unchanged.quarantinedLevels()); - assertNull(unchanged.recovery()); - assertTrue(Files.isRegularFile(region)); - - ModdedTakeoverReconciler.Result removed = reconcile( - worldRoot, Map.of(), List.of(), recoveryParent); - - assertEquals(Set.of("iristest:skylands"), removed.quarantinedLevels()); - assertFalse(Files.exists(region)); - assertTrue(Files.isRegularFile(removed.recovery().resolve("dimensions/iristest/skylands/region/r.0.0.mca"))); - assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void newTakeoverWithoutChunkDataStaysPendingWithoutARecoveryFolder() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-empty"); - Path worldRoot = temporary.resolve("world"); - Path recoveryParent = temporary.resolve("config"); - try { - Files.createDirectories(worldRoot); - - ModdedTakeoverReconciler.Result result = reconcile( - worldRoot, Map.of("aether:the_aether", "overworld"), List.of(), recoveryParent); - - assertEquals(Set.of("aether:the_aether"), result.quarantinedLevels()); - assertNull(result.recovery()); - assertFalse(Files.exists(recoveryParent)); - assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void unreadableConfigWithRecordedTakeoversRefusesWithoutMovingAnything() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-unreadable"); - Path worldRoot = temporary.resolve("world"); - Path recoveryParent = temporary.resolve("config"); - try { - Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca"); - ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld")); - touch(region); - List problems = List.of("Iris could not read /srv/config/irisworldgen/modded.json: Unterminated object"); - - IOException refused = assertThrows(IOException.class, () -> reconcile( - worldRoot, Map.of(), problems, recoveryParent)); - - assertTrue(refused.getMessage(), refused.getMessage().contains("modded.json")); - assertTrue(Files.isRegularFile(region)); - assertFalse(Files.exists(recoveryParent)); - assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void unreadableConfigWithAnEmptyStateFileStillRefuses() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-unreadable-empty"); - Path worldRoot = temporary.resolve("world"); - try { - ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of()); - - assertThrows(IOException.class, () -> reconcile( - worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config"))); - } finally { - deleteTree(temporary); - } - } - - @Test - public void unreadableConfigWithoutRecordedStateProceeds() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-unreadable-fresh"); - Path worldRoot = temporary.resolve("world"); - try { - Files.createDirectories(worldRoot); - - ModdedTakeoverReconciler.Result result = reconcile( - worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config")); - - assertEquals(Set.of(), result.quarantinedLevels()); - } finally { - deleteTree(temporary); - } - } - - @Test - public void changedTakeoverKeepsThePreviousStateUntilTheNewPackGenerates() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-change"); - Path worldRoot = temporary.resolve("world"); - try { - Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca"); - ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld")); - touch(region); - - ModdedTakeoverReconciler.Result result = reconcile( - worldRoot, Map.of("iristest:skylands", "skypack"), List.of(), temporary.resolve("config")); - - assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels()); - assertFalse(Files.exists(region)); - assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void existingHistoryOfTheConfiguredPackIsAdoptedWithoutQuarantine() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-history-match"); - Path worldRoot = temporary.resolve("world"); - try { - Path skylands = worldRoot.resolve("dimensions/iristest/skylands"); - createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of()); - touch(skylands.resolve("region/r.0.0.mca")); - - ModdedTakeoverReconciler.Result result = reconcile( - worldRoot, Map.of("iristest:skylands", "skypack:sky"), List.of(), temporary.resolve("config")); - - 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", "skypack:sky"), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void historyOfAnotherPackIsQuarantined() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-history-other"); - Path worldRoot = temporary.resolve("world"); - try { - Path skylands = worldRoot.resolve("dimensions/iristest/skylands"); - createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("otherpack", "sky"), "sky", List.of()); - touch(skylands.resolve("region/r.0.0.mca")); - - ModdedTakeoverReconciler.Result result = reconcile( - worldRoot, Map.of("iristest:skylands", "skypack:sky"), List.of(), temporary.resolve("config")); - - assertEquals(Set.of(), result.adoptedLevels()); - assertFalse(Files.exists(skylands.resolve("region"))); - assertFalse(Files.exists(skylands.resolve("iris"))); - assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void borrowedHistoryMatchesOnItsRecordedAliasPack() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-history-borrowed"); - Path worldRoot = temporary.resolve("world"); - try { - Path aether = worldRoot.resolve("dimensions/aether/the_aether"); - createHistory(temporary, aether, "aether:the_aether", "sky", - List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow"))); - touch(aether.resolve("region/r.0.0.mca")); - - ModdedTakeoverReconciler.Result other = reconcile( - worldRoot, Map.of("aether:the_aether", "otherpack:sky"), List.of(), temporary.resolve("config")); - - assertEquals(Set.of(), other.adoptedLevels()); - assertFalse(Files.exists(aether.resolve("region"))); - - createHistory(temporary, aether, "aether:the_aether", "sky", - List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow"))); - ModdedTakeoverReconciler.Result matching = reconcile( - worldRoot, Map.of("aether:the_aether", "skypack:sky"), List.of(), temporary.resolve("config")); - - assertEquals(Set.of("aether:the_aether"), matching.adoptedLevels()); - assertTrue(Files.isDirectory(aether.resolve("iris"))); - } finally { - deleteTree(temporary); - } - } - - @Test - public void recordAppliedStoresOnlyTakeoversThatGenerateFromTheirPack() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-record"); - Path worldRoot = temporary.resolve("world"); - try { - ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:kept", "overworld")); - - Map recorded = ModdedTakeoverReconciler.recordApplied(worldRoot, - Map.of("iristest:kept", "overworld", "iristest:live", "skypack", "iristest:skipped", "skypack"), - (String level, String packRef) -> !level.equals("iristest:skipped")); - - Map expected = Map.of("iristest:kept", "overworld", "iristest:live", "skypack"); - assertEquals(expected, recorded); - assertEquals(expected, ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void recordAppliedFailsWhenTheStateCannotBeWritten() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-record-fail"); - Path worldRoot = temporary.resolve("world"); - try { - touch(worldRoot.resolve("data/irisworldgen/dimension-takeovers.json/blocker")); - - assertThrows(IOException.class, () -> ModdedTakeoverReconciler.recordApplied(worldRoot, - Map.of("iristest:live", "skypack"), (String level, String packRef) -> true)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void switchingAnAppliedLevelToAnUninstalledPackRefusesBeforeMovingAnything() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-uninstalled-applied"); - Path worldRoot = temporary.resolve("world"); - try { - Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca"); - ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld")); - touch(region); - - IOException refused = assertThrows(IOException.class, () -> ModdedTakeoverReconciler.reconcile( - new ModdedTakeoverReconciler.Request(worldRoot, Map.of("iristest:skylands", "missing"), List.of(), - (ModdedDimensionTakeover takeover) -> false, temporary.resolve("config")))); - - assertTrue(refused.getMessage(), refused.getMessage().contains("missing")); - assertTrue(Files.isRegularFile(region)); - assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - @Test - public void newTakeoverOfAnUninstalledPackLeavesTheLevelAlone() throws IOException { - Path temporary = Files.createTempDirectory("iris-takeover-uninstalled-new"); - Path worldRoot = temporary.resolve("world"); - try { - Path region = worldRoot.resolve("dimensions/aether/the_aether/region/r.0.0.mca"); - touch(region); - - ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( - new ModdedTakeoverReconciler.Request(worldRoot, Map.of("aether:the_aether", "missing"), List.of(), - (ModdedDimensionTakeover takeover) -> false, temporary.resolve("config"))); - - assertEquals(Set.of(), result.quarantinedLevels()); - assertTrue(Files.isRegularFile(region)); - assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); - } finally { - deleteTree(temporary); - } - } - - private static ModdedTakeoverReconciler.Result reconcile(Path worldRoot, Map configured, - List problems, Path recoveryParent) throws IOException { - return ModdedTakeoverReconciler.reconcile(new ModdedTakeoverReconciler.Request( - worldRoot, configured, problems, (ModdedDimensionTakeover takeover) -> true, recoveryParent)); - } - - private static void createHistory(Path temporary, Path dimensionRoot, String dimensionTypeKey, String dimensionKey, - List biomeKeys) throws IOException { - Path source = Files.createTempDirectory(temporary, "pack"); - touch(source.resolve("dimensions/" + dimensionKey + ".json")); - GenerationEpoch epoch = ModdedRetainedPackNameTest.epoch(dimensionTypeKey, dimensionKey, biomeKeys); - Files.createDirectories(dimensionRoot); - GenerationHistory.create(dimensionRoot, source, - GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION), 5L, - epoch.dimensionContract(), epoch.registryContract()); - } - - private static void touch(Path file) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, "x", StandardCharsets.UTF_8); - } - - static void deleteTree(Path root) throws IOException { - if (!Files.exists(root)) { - return; - } - try (Stream walk = Files.walk(root)) { - for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { - Files.deleteIfExists(path); - } - } - } -} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java index 471a55504..f4aac6334 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java @@ -52,6 +52,9 @@ public class IrisModdedCommandParityTest { child(iris, "worlds"); child(iris, "accesslist"); child(child(iris, "goto"), "unregistered"); + for (String replace : new String[]{"replace", "override", "overwrite"}) { + assertTrue(replace, child(child(iris, replace), "arguments").getChildren().isEmpty()); + } CommandNode edit = child(iris, "edit"); child(edit, "b"); @@ -173,7 +176,9 @@ public class IrisModdedCommandParityTest { assertTrue(ModdedCommandHelp.documents("studio", "pkg")); assertTrue(ModdedCommandHelp.documents("object", "we")); assertTrue(ModdedCommandHelp.documents("world", "mainworld")); - assertTrue(ModdedCommandHelp.documents("world", "takeover")); + assertTrue(ModdedCommandHelp.documents("", "replace")); + assertTrue(ModdedCommandHelp.documents("", "override")); + assertTrue(ModdedCommandHelp.documents("", "overwrite")); assertTrue(ModdedCommandHelp.documents("goto", "unregistered")); assertTrue(ModdedCommandHelp.documents("pack", "compat")); assertTrue(ModdedCommandHelp.documents("pack", "cp")); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceArgumentsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceArgumentsTest.java new file mode 100644 index 000000000..9fc158b06 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceArgumentsTest.java @@ -0,0 +1,112 @@ +/* + * 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.util.List; +import java.util.OptionalLong; + +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 ModdedReplaceArgumentsTest { + @Test + public void targetAloneUsesTheDefaultTypeAndPreservesTheSeed() { + ModdedReplaceArguments arguments = ModdedReplaceArguments.parse("minecraft:overworld"); + + assertEquals("minecraft:overworld", arguments.target()); + assertEquals("default", arguments.type()); + assertEquals(OptionalLong.empty(), arguments.seed()); + assertFalse(arguments.off()); + } + + @Test + public void positionalArgumentsFillTargetTypeAndSeedInOrder() { + ModdedReplaceArguments arguments = ModdedReplaceArguments.parse("main overworld 123456789"); + + assertEquals("main", arguments.target()); + assertEquals("overworld", arguments.type()); + assertEquals(OptionalLong.of(123456789L), arguments.seed()); + } + + @Test + public void keyedArgumentsAcceptThePaperNamesAndAliases() { + ModdedReplaceArguments named = ModdedReplaceArguments.parse("target=examplemod:skylands type=skypack:sky seed=-5"); + ModdedReplaceArguments aliased = ModdedReplaceArguments.parse("world-name=myworld dimension=underworld s=7"); + ModdedReplaceArguments pack = ModdedReplaceArguments.parse("seed=1 pack=overworld:floating/sky target=nether"); + + assertEquals("examplemod:skylands", named.target()); + assertEquals("skypack:sky", named.type()); + assertEquals(OptionalLong.of(-5L), named.seed()); + assertEquals("myworld", aliased.target()); + assertEquals("underworld", aliased.type()); + assertEquals(OptionalLong.of(7L), aliased.seed()); + assertEquals("nether", pack.target()); + assertEquals("overworld:floating/sky", pack.type()); + assertEquals(OptionalLong.of(1L), pack.seed()); + } + + @Test + public void positionalTargetMixesWithKeyedTypeAndSeedLikeThePaperCommand() { + ModdedReplaceArguments arguments = ModdedReplaceArguments.parse("minecraft:the_nether type=underworld seed=-987654321"); + + assertEquals("minecraft:the_nether", arguments.target()); + assertEquals("underworld", arguments.type()); + assertEquals(OptionalLong.of(-987654321L), arguments.seed()); + } + + @Test + public void preserveKeepsTheSavedSeedAndTheFullSignedRangeIsAccepted() { + assertEquals(OptionalLong.empty(), ModdedReplaceArguments.parse("main seed=preserve").seed()); + assertEquals(OptionalLong.empty(), ModdedReplaceArguments.parse("main overworld PRESERVE").seed()); + assertEquals(OptionalLong.of(Long.MAX_VALUE), + ModdedReplaceArguments.parse("main seed=" + Long.MAX_VALUE).seed()); + assertEquals(OptionalLong.of(Long.MIN_VALUE + 1L), + ModdedReplaceArguments.parse("main seed=" + (Long.MIN_VALUE + 1L)).seed()); + } + + @Test + public void invalidSeedsAreRejected() { + IllegalArgumentException text = assertThrows(IllegalArgumentException.class, + () -> ModdedReplaceArguments.parse("main seed=random")); + IllegalArgumentException reserved = assertThrows(IllegalArgumentException.class, + () -> ModdedReplaceArguments.parse("main seed=" + Long.MIN_VALUE)); + + assertEquals("Seed must be a signed 64-bit integer", text.getMessage()); + assertTrue(reserved.getMessage(), reserved.getMessage().contains(Long.toString(Long.MIN_VALUE))); + } + + @Test + public void offTurnsAReplacementOffAndTakesNoSeed() { + assertTrue(ModdedReplaceArguments.parse("examplemod:skylands off").off()); + assertTrue(ModdedReplaceArguments.parse("target=examplemod:skylands type=OFF").off()); + assertThrows(IllegalArgumentException.class, () -> ModdedReplaceArguments.parse("examplemod:skylands off seed=1")); + } + + @Test + public void malformedArgumentListsAreRejected() { + for (String raw : List.of("", " ", "type=overworld", "main foo=1", "main type=a pack=b", + "main overworld 1 extra", "main type=", "target=main world-name=other")) { + assertThrows(raw, IllegalArgumentException.class, () -> ModdedReplaceArguments.parse(raw)); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceTargetTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceTargetTest.java new file mode 100644 index 000000000..c551c50e0 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/ModdedReplaceTargetTest.java @@ -0,0 +1,97 @@ +/* + * 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 art.arcane.iris.world.IrisEnvironment; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +public class ModdedReplaceTargetTest { + private static final String LEVEL_NAME = "world"; + + @Test + public void friendlyNamesResolveToTheVanillaSlots() { + for (String overworld : List.of("main", "overworld", "MAIN", " Overworld ")) { + assertTarget("minecraft:overworld", ModdedReplaceTarget.Kind.VANILLA_OVERWORLD, overworld); + } + assertTarget("minecraft:the_nether", ModdedReplaceTarget.Kind.VANILLA_NETHER, "nether"); + assertTarget("minecraft:the_nether", ModdedReplaceTarget.Kind.VANILLA_NETHER, "the_nether"); + assertTarget("minecraft:the_end", ModdedReplaceTarget.Kind.VANILLA_END, "end"); + assertTarget("minecraft:the_end", ModdedReplaceTarget.Kind.VANILLA_END, "the_end"); + } + + @Test + public void configuredLevelNamesResolveToTheVanillaSlotsFirst() { + assertTarget("minecraft:overworld", ModdedReplaceTarget.Kind.VANILLA_OVERWORLD, "world"); + assertTarget("minecraft:the_nether", ModdedReplaceTarget.Kind.VANILLA_NETHER, "world_nether"); + assertTarget("minecraft:the_end", ModdedReplaceTarget.Kind.VANILLA_END, "WORLD_the_end"); + assertEquals("minecraft:overworld", ModdedReplaceTarget.resolve("nether", "nether").levelKey()); + } + + @Test + public void exactKeysKeepTheirNamespace() { + assertTarget("minecraft:overworld", ModdedReplaceTarget.Kind.VANILLA_OVERWORLD, "minecraft:overworld"); + assertTarget("minecraft:the_end", ModdedReplaceTarget.Kind.VANILLA_END, "MINECRAFT:THE_END"); + assertTarget("examplemod:skylands", ModdedReplaceTarget.Kind.DIMENSION, "examplemod:skylands"); + assertTarget("examplemod:realm/upper", ModdedReplaceTarget.Kind.DIMENSION, "examplemod:realm/upper"); + } + + @Test + public void otherBareNamesResolveToIrisWorlds() { + assertTarget("irisworldgen:myworld", ModdedReplaceTarget.Kind.IRIS_WORLD, "myworld"); + assertTarget("irisworldgen:my_world", ModdedReplaceTarget.Kind.IRIS_WORLD, "My World"); + assertTarget("irisworldgen:myworld", ModdedReplaceTarget.Kind.IRIS_WORLD, "iris:myworld"); + assertTarget("irisworldgen:myworld", ModdedReplaceTarget.Kind.IRIS_WORLD, "irisworldgen:myworld"); + } + + @Test + public void vanillaSlotsRequireTheirEnvironment() { + assertEquals(IrisEnvironment.NORMAL, ModdedReplaceTarget.Kind.VANILLA_OVERWORLD.environment()); + assertEquals(IrisEnvironment.NETHER, ModdedReplaceTarget.Kind.VANILLA_NETHER.environment()); + assertEquals(IrisEnvironment.THE_END, ModdedReplaceTarget.Kind.VANILLA_END.environment()); + assertNull(ModdedReplaceTarget.Kind.IRIS_WORLD.environment()); + assertNull(ModdedReplaceTarget.Kind.DIMENSION.environment()); + + ModdedReplaceTarget.requireCompatibleEnvironment(ModdedReplaceTarget.Kind.VANILLA_NETHER, IrisEnvironment.NETHER); + ModdedReplaceTarget.requireCompatibleEnvironment(ModdedReplaceTarget.Kind.DIMENSION, IrisEnvironment.THE_END); + IllegalArgumentException mismatch = assertThrows(IllegalArgumentException.class, + () -> ModdedReplaceTarget.requireCompatibleEnvironment( + ModdedReplaceTarget.Kind.VANILLA_NETHER, IrisEnvironment.NORMAL)); + assertEquals("The vanilla_nether slot requires a pack environment of NETHER.", mismatch.getMessage()); + } + + @Test + public void unsafeOrMalformedTargetsAreRejected() { + for (String target : List.of("", " ", "../world", "a/b", "a\\b", "ns:", ":x", "a:b:c", "bad key:x", + "examplemod:sky/../x", "examplemod:/sky")) { + assertThrows(target, IllegalArgumentException.class, () -> ModdedReplaceTarget.resolve(target, LEVEL_NAME)); + } + } + + private static void assertTarget(String levelKey, ModdedReplaceTarget.Kind kind, String requested) { + ModdedReplaceTarget target = ModdedReplaceTarget.resolve(requested, LEVEL_NAME); + assertEquals(requested, levelKey, target.levelKey()); + assertEquals(requested, kind, target.kind()); + } +} diff --git a/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index 2ea59df22..f59feba8e 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -449,7 +449,6 @@ operator_instruction = "Führe {command} in der Konsole aus oder aktiviere Cheat "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Eine bestehende Dimension beim nächsten Neustart mit einem Iris-Pack übernehmen oder die Übernahme ausschalten" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1112,10 +1111,6 @@ 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." -takeover_set = "Iris-Übernahme von {dimensionId} auf Pack '{pack}', Dimension '{packDimension}' gesetzt." -takeover_cleared = "Iris-Übernahme von {dimensionId} ausgeschaltet; der eigene Generator der Dimension kehrt beim nächsten Neustart zurück." -takeover_not_configured = "Für {dimensionId} ist keine Iris-Übernahme konfiguriert." -takeover_restart_required = "Neustart erforderlich: Beim nächsten Start werden die Chunk-, Entity-, POI- und Iris Mantle-Daten der Dimension in einen Wiederherstellungsordner verschoben und neu generiert. Spielerdaten bleiben erhalten." [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 a18aac02f..49d86cb38 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -449,7 +449,6 @@ operator_instruction = "Ejecuta {command} desde la consola (o activa los trucos "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Tomar el control de una dimensión existente con un pack de Iris en el próximo reinicio, o desactivarlo" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1112,10 +1111,6 @@ 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." -takeover_set = "Toma de control de Iris de {dimensionId} asignada al pack '{pack}', dimensión '{packDimension}'." -takeover_cleared = "Toma de control de Iris de {dimensionId} desactivada; el generador propio de la dimensión vuelve en el próximo reinicio." -takeover_not_configured = "No hay ninguna toma de control de Iris configurada para {dimensionId}." -takeover_restart_required = "Se requiere reiniciar: en el próximo inicio, los datos de chunks, entidades, POI e Iris Mantle de la dimensión se mueven a una carpeta de recuperación y se regeneran. Los datos de los jugadores se conservan." [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 9f911f8ba..7331c9545 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -454,7 +454,6 @@ operator_instruction = "Juokse {command} konsolilta (tai mahdollista huijaukset "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Ota olemassa oleva ulottuvuus haltuun Iris-paketilla seuraavassa uudelleenkäynnistyksessä tai poista haltuunotto käytöstä" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1117,10 +1116,6 @@ 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." -takeover_set = "Iris-haltuunotto kohteelle {dimensionId} asetettu: paketti '{pack}', ulottuvuus '{packDimension}'." -takeover_cleared = "Iris-haltuunotto kohteelle {dimensionId} poistettu käytöstä; ulottuvuuden oma generaattori palaa seuraavan uudelleenkäynnistyksen yhteydessä." -takeover_not_configured = "Kohteelle {dimensionId} ei ole määritetty Iris-haltuunottoa." -takeover_restart_required = "Uudelleenkäynnistys vaaditaan: seuraavassa käynnistyksessä ulottuvuuden chunk-, entiteetti-, POI- ja Iris Mantle -tiedot siirretään palautuskansioon ja luodaan uudelleen. Pelaajatiedot säilytetään." [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 6ebebe844..3a9d49a4c 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -449,7 +449,6 @@ operator_instruction = "Exécutez {command} depuis la console (ou activez les co "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Prendre le contrôle d'une dimension existante avec un pack Iris au prochain redémarrage, ou le désactiver" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1112,10 +1111,6 @@ 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." -takeover_set = "Prise de contrôle Iris de {dimensionId} définie sur le pack '{pack}', dimension '{packDimension}'." -takeover_cleared = "Prise de contrôle Iris de {dimensionId} désactivée ; le générateur propre à la dimension revient au prochain redémarrage." -takeover_not_configured = "Aucune prise de contrôle Iris n'est configurée pour {dimensionId}." -takeover_restart_required = "Redémarrage requis : au prochain démarrage, les données de chunks, d'entités, de POI et d'Iris Mantle de la dimension sont déplacées vers un dossier de récupération puis régénérées. Les données des joueurs sont conservées." [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 b637973b4..fa8a790f2 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.toml @@ -454,7 +454,6 @@ operator_instruction = "לרוץ {command} מן הקונסולה (או לאפש "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "השתלטות על ממד קיים עם חבילת Iris בהפעלה מחדש הבאה, או כיבוי ההשתלטות" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1117,10 +1116,6 @@ 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 להזריק אחד מבלי להתחיל מחדש." -takeover_set = "השתלטות Iris על {dimensionId} הוגדרה לחבילה '{pack}', ממד '{packDimension}'." -takeover_cleared = "השתלטות Iris על {dimensionId} כובתה; המחולל המקורי של הממד יחזור בהפעלה מחדש הבאה." -takeover_not_configured = "לא מוגדרת השתלטות Iris עבור {dimensionId}." -takeover_restart_required = "נדרשת הפעלה מחדש: בהפעלה הבאה נתוני ה-chunk, הישויות, ה-POI וה-Iris Mantle של הממד יועברו לתיקיית שחזור וייווצרו מחדש. נתוני השחקנים נשמרים." [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 9b4ed9ede..8b8a2ea36 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -449,7 +449,6 @@ operator_instruction = "Esegui {command} dalla console oppure abilita i trucchi "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Acquisisci una dimensione esistente con un pack Iris al prossimo riavvio, oppure disattiva l'acquisizione" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1112,10 +1111,6 @@ 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." -takeover_set = "Acquisizione Iris di {dimensionId} impostata sul pack '{pack}', dimensione '{packDimension}'." -takeover_cleared = "Acquisizione Iris di {dimensionId} disattivata; il generatore originale della dimensione torna al prossimo riavvio." -takeover_not_configured = "Nessuna acquisizione Iris configurata per {dimensionId}." -takeover_restart_required = "Riavvio necessario: al prossimo avvio i dati di chunk, entità, POI e Iris Mantle della dimensione vengono spostati in una cartella di recupero e rigenerati. I dati dei giocatori vengono mantenuti." [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 beda621a2..cc14b6a4d 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.toml @@ -452,7 +452,6 @@ operator_instruction = "コンソールから {command} を実行してくださ "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "次回の再起動時に既存のディメンションを Iris パックで引き継ぐか、引き継ぎを無効にします" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1115,10 +1114,6 @@ 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 を使用してください。" -takeover_set = "{dimensionId} の Iris テイクオーバーをパック '{pack}'、ディメンション '{packDimension}' に設定しました。" -takeover_cleared = "{dimensionId} の Iris テイクオーバーを無効にしました。次回の再起動でディメンション本来のジェネレーターに戻ります。" -takeover_not_configured = "{dimensionId} には Iris テイクオーバーが設定されていません。" -takeover_restart_required = "再起動が必要です: 次回の起動時に、このディメンションのチャンク、エンティティ、POI、Iris Mantle データを復旧フォルダーへ移動して再生成します。プレイヤーデータは保持されます。" [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 9885ebd03..a67aeddd8 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -452,7 +452,6 @@ operator_instruction = "콘솔에서 {command}을(를) 실행하세요(싱글 "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "다음 재시작 시 기존 차원을 Iris 팩으로 인수하거나 인수를 해제합니다" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1115,10 +1114,6 @@ 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 을(를) 사용하세요." -takeover_set = "{dimensionId}의 Iris 인수를 팩 '{pack}', 차원 '{packDimension}'(으)로 설정했습니다." -takeover_cleared = "{dimensionId}의 Iris 인수를 해제했습니다. 다음 재시작 시 차원 고유의 생성기로 돌아갑니다." -takeover_not_configured = "{dimensionId}에 설정된 Iris 인수가 없습니다." -takeover_restart_required = "재시작 필요: 다음 시작 시 이 차원의 청크, 엔티티, POI, Iris Mantle 데이터가 복구 폴더로 이동되고 다시 생성됩니다. 플레이어 데이터는 유지됩니다." [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 8e9a25ee7..2d1ce337a 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -454,7 +454,6 @@ operator_instruction = "Vykdyti {command} iš konsolės (arba leidžia cheats į "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Kito paleidimo iš naujo metu perimti esamą dimensiją Iris paketu arba išjungti perėmimą" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1117,10 +1116,6 @@ 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." -takeover_set = "Iris perėmimas dimensijai {dimensionId} nustatytas į paketą '{pack}', dimensiją '{packDimension}'." -takeover_cleared = "Iris perėmimas dimensijai {dimensionId} išjungtas; dimensijos nuosavas generatorius grįš kito paleidimo iš naujo metu." -takeover_not_configured = "Dimensijai {dimensionId} nesukonfigūruotas joks Iris perėmimas." -takeover_restart_required = "Reikia paleisti iš naujo: kito paleidimo metu dimensijos chunk, esybių, POI ir Iris Mantle duomenys perkeliami į atkūrimo aplanką ir sugeneruojami iš naujo. Žaidėjų duomenys išsaugomi." [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 e73084b6c..22e589492 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -449,7 +449,6 @@ operator_instruction = "Start {command} vanaf de console (of schakel bedrog in s "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Neem bij de volgende herstart een bestaande dimensie over met een Iris-pack, of schakel de overname uit" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1112,10 +1111,6 @@ 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." -takeover_set = "Iris-overname van {dimensionId} ingesteld op pack '{pack}', dimensie '{packDimension}'." -takeover_cleared = "Iris-overname van {dimensionId} uitgeschakeld; de eigen generator van de dimensie keert terug bij de volgende herstart." -takeover_not_configured = "Er is geen Iris-overname ingesteld voor {dimensionId}." -takeover_restart_required = "Herstart vereist: bij de volgende start worden de chunk-, entiteit-, POI- en Iris Mantle-gegevens van de dimensie naar een herstelmap verplaatst en opnieuw gegenereerd. Spelergegevens blijven behouden." [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 5b1b6658e..99a973149 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -454,7 +454,6 @@ operator_instruction = "Biegnij {command} z konsoli (lub włączyć cheats w sin "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Przejmij istniejący wymiar pakietem Iris przy następnym restarcie lub wyłącz przejęcie" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1117,10 +1116,6 @@ 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." -takeover_set = "Przejęcie Iris wymiaru {dimensionId} ustawiono na pakiet '{pack}', wymiar '{packDimension}'." -takeover_cleared = "Przejęcie Iris wymiaru {dimensionId} wyłączone; własny generator wymiaru wróci przy następnym restarcie." -takeover_not_configured = "Dla {dimensionId} nie skonfigurowano przejęcia Iris." -takeover_restart_required = "Wymagany restart: przy następnym uruchomieniu dane chunków, bytów, POI i Iris Mantle tego wymiaru zostaną przeniesione do folderu odzyskiwania i wygenerowane od nowa. Dane graczy zostają zachowane." [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 a658ef6f1..28f9263cc 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -449,7 +449,6 @@ operator_instruction = "Executar {command} a partir do console (ou habilitar fra "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Assumir o controlo de uma dimensão existente com um pack Iris no próximo reinício, ou desativá-lo" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1112,10 +1111,6 @@ 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." -takeover_set = "Controlo Iris de {dimensionId} definido para o pack '{pack}', dimensão '{packDimension}'." -takeover_cleared = "Controlo Iris de {dimensionId} desativado; o gerador próprio da dimensão regressa no próximo reinício." -takeover_not_configured = "Não há nenhum controlo Iris configurado para {dimensionId}." -takeover_restart_required = "Reinício necessário: no próximo arranque, os dados de chunks, entidades, POI e Iris Mantle da dimensão são movidos para uma pasta de recuperação e regenerados. Os dados dos jogadores são mantidos." [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 92eed6c5c..2d1be8afc 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -454,7 +454,6 @@ operator_instruction = "Беги. {command} от консоли (или вклю "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Перехватить существующее измерение пакетом Iris при следующем перезапуске или отключить перехват" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1117,10 +1116,6 @@ 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 Вводить один без перезапуска." -takeover_set = "Перехват Iris для {dimensionId} настроен на пакет '{pack}', измерение '{packDimension}'." -takeover_cleared = "Перехват Iris для {dimensionId} отключён; собственный генератор измерения вернётся при следующем перезапуске." -takeover_not_configured = "Для {dimensionId} перехват Iris не настроен." -takeover_restart_required = "Требуется перезапуск: при следующем запуске данные чанков, сущностей, POI и Iris Mantle этого измерения будут перемещены в папку восстановления и сгенерированы заново. Данные игроков сохраняются." [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 fb0432b89..d95fae2e3 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -454,7 +454,6 @@ operator_instruction = "{command} komutunu konsoldan çalıştırın (veya tek o "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Bir sonraki yeniden başlatmada mevcut bir boyutu bir Iris paketiyle devral veya devralmayı kapat" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1117,10 +1116,6 @@ 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." -takeover_set = "{dimensionId} için Iris devralması '{pack}' paketine, '{packDimension}' boyutuna ayarlandı." -takeover_cleared = "{dimensionId} için Iris devralması kapatıldı; boyutun kendi üreticisi bir sonraki yeniden başlatmada geri gelir." -takeover_not_configured = "{dimensionId} için yapılandırılmış bir Iris devralması yok." -takeover_restart_required = "Yeniden başlatma gerekli: bir sonraki başlatmada boyutun chunk, varlık, POI ve Iris Mantle verileri bir kurtarma klasörüne taşınır ve yeniden oluşturulur. Oyuncu verileri korunur." [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 46c1e9c0f..86a373f1b 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -452,7 +452,6 @@ operator_instruction = "Chạy {command} từ bàn giao tiếp (hoặc kích ho "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "Tiếp quản một chiều không gian hiện có bằng gói Iris ở lần khởi động lại tiếp theo, hoặc tắt việc tiếp quản" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1115,10 +1114,6 @@ 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." -takeover_set = "Đã đặt Iris tiếp quản {dimensionId} bằng gói '{pack}', chiều không gian '{packDimension}'." -takeover_cleared = "Đã tắt Iris tiếp quản {dimensionId}; bộ tạo riêng của chiều không gian sẽ trở lại ở lần khởi động lại tiếp theo." -takeover_not_configured = "Không có Iris tiếp quản nào được cấu hình cho {dimensionId}." -takeover_restart_required = "Cần khởi động lại: ở lần khởi động tiếp theo, dữ liệu chunk, thực thể, POI và Iris Mantle của chiều không gian sẽ được chuyển vào thư mục khôi phục và tạo lại. Dữ liệu người chơi được giữ nguyên." [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 824f0e453..99070694e 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -452,7 +452,6 @@ operator_instruction = "请从控制台执行 {command}(或在单人游戏中 "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "在下次重启时用 Iris 资源包接管现有维度,或关闭接管" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1115,10 +1114,6 @@ 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 在不重新启动的情况下注入一个。" -takeover_set = "已将 {dimensionId} 的 Iris 接管设置为资源包 '{pack}',维度 '{packDimension}'。" -takeover_cleared = "已关闭 {dimensionId} 的 Iris 接管;下次重启后该维度将恢复使用其自身的生成器。" -takeover_not_configured = "{dimensionId} 未配置 Iris 接管。" -takeover_restart_required = "需要重启:下次启动时,该维度的区块、实体、POI 和 Iris Mantle 数据将移至恢复文件夹并重新生成。玩家数据将被保留。" [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 994c4e83b..edb721831 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -452,7 +452,6 @@ operator_instruction = "請從主控台執行 {command}(或在單人遊戲中 "entry.command.height" = "Print the current dimension height range" "entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions" "entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart" -"entry.command.takeover" = "在下次重新啟動時用 Iris 資源包接管現有維度,或關閉接管" "entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin" "entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain" "entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin" @@ -1115,10 +1114,6 @@ 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 在不重新啟動的情況下注入一個。" -takeover_set = "已將 {dimensionId} 的 Iris 接管設定為資源包 '{pack}',維度 '{packDimension}'。" -takeover_cleared = "已關閉 {dimensionId} 的 Iris 接管;下次重新啟動後該維度將恢復使用其自身的生成器。" -takeover_not_configured = "{dimensionId} 未設定 Iris 接管。" -takeover_restart_required = "需要重新啟動:下次啟動時,該維度的區塊、實體、POI 與 Iris Mantle 資料將移至復原資料夾並重新生成。玩家資料將會保留。" [iris.director.commanddatapack] "director.download_manage_external_datapack_imports_modrinth" = "下載 & 管理外部資料包進口(Modrinth)"