diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java index 1081dfbb2..b366d05f3 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java @@ -896,14 +896,10 @@ public final class IrisWorldGeneratorResolver { } private static String dimensionTypeKey(IrisData data, IrisDimension dimension) { - return IrisPlatforms.get() - .registries() - .generationRegistry() - .dimensionTypeResourceKey( - data.getDataFolder().getName(), - dimension.getLoadKey(), - dimension.getDimensionTypeKey() - ); + return dimension.dimensionTypeResourceKey( + IrisPlatforms.get().registries().generationRegistry(), + data.getDataFolder().getName() + ); } private record LoadedHistory(GenerationHistory history, File activePack) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java index 663a0e6fc..d07941657 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java @@ -239,50 +239,21 @@ public final class MainWorldService { private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException { Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID()); - List moved = new ArrayList<>(); + List sources = new ArrayList<>(); for (String file : NativeServerWorldLayout.metadataFiles()) { - moveToRecovery(worldRoot, worldRoot.resolve(file), recovery, moved); + sources.add(worldRoot.resolve(file)); } for (String folder : NativeServerWorldLayout.primaryFolders()) { - moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved); + sources.add(worldRoot.resolve(folder)); } - moveToRecovery(worldRoot, worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME), recovery, moved); + sources.add(worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME)); for (String folder : NativeServerWorldLayout.dimensionFolders()) { - moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved); - } - if (moved.isEmpty()) { - Files.deleteIfExists(recovery); + sources.add(worldRoot.resolve(folder)); } + ModdedWorldQuarantine.move(worldRoot, sources, recovery); return recovery; } - private static void moveToRecovery(Path worldRoot, Path source, Path recovery, - List moved) throws IOException { - if (!Files.exists(source)) { - return; - } - Path relative = worldRoot.relativize(source); - Path target = recovery.resolve(relative); - Files.createDirectories(target.getParent()); - try { - Files.move(source, target); - moved.add(relative); - } catch (IOException failure) { - for (int index = moved.size() - 1; index >= 0; index--) { - Path rollbackRelative = moved.get(index); - Path rollbackSource = recovery.resolve(rollbackRelative); - Path rollbackTarget = worldRoot.resolve(rollbackRelative); - try { - Files.createDirectories(rollbackTarget.getParent()); - Files.move(rollbackSource, rollbackTarget); - } catch (IOException rollbackFailure) { - failure.addSuppressed(rollbackFailure); - } - } - throw failure; - } - } - private static String escape(String value) { return value.replace(":", "\\:"); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index 2780a9511..b2d0a475f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -511,19 +511,21 @@ public final class ModdedDimensionManager { .normalize(); PackValidationRegistry.requireLoadable(packRoot); IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); + String dimensionTypeRef; try { IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); if (dimension == null) { throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '" + dimensionKey + "'."); } + dimensionTypeRef = ModdedWorldgenIds.dimensionTypeRef(pack, dimension); } finally { data.close(); } return new RuntimePack( packRoot, dimensionKey, - ModdedWorldgenIds.dimensionTypeRef(pack, dimensionKey), + dimensionTypeRef, ModdedGenerationMode.TRANSIENT_STUDIO ); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java index 0bc83bf78..b54eef49c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java @@ -43,6 +43,10 @@ public final class ModdedDimensionStorage { private ModdedDimensionStorage() { } + static List chunkDataFolders() { + return CHUNK_DATA_FOLDERS; + } + public static File storageFolder(NativeModdedServer server, String dimension) { return server.dimensionFolder(dimension).toFile(); } 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 new file mode 100644 index 000000000..3034d6974 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeover.java @@ -0,0 +1,84 @@ +/* + * 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 new file mode 100644 index 000000000..ef7f53a56 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java @@ -0,0 +1,117 @@ +/* + * 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.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.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 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, + Map stagedDimensionTypes) throws IOException { + for (ModdedDimensionTakeover takeover : parseAll(configured)) { + String typeRef = stagedDimensionTypes.get(takeover.pack() + ":" + takeover.packDimension()); + if (typeRef == null) { + ModdedIrisLog.error("Iris skipped the dimension takeover of {}: pack '{}' dimension '{}' is not installed or failed validation, so the dimension keeps its own generator", + takeover.levelKey(), takeover.pack(), takeover.packDimension()); + continue; + } + writeLevelStem(datapackRoot, takeover, typeRef); + ModdedIrisLog.info("Iris dimension takeover staged: {} generates from {} with dimension type {}", + takeover.levelKey(), takeover.packRef(), typeRef); + } + } + + 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; + } + + 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(); + } +} 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 5ccf22f50..e99ac0c19 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,6 +172,9 @@ public final class ModdedEngineBootstrap { bindWorldGenerators(server); ModdedStartup.runOnce(server); reconcileSpawn(server); + if (!loader().clientEnvironment()) { + ModdedTakeoverReconciler.recordApplied(server); + } ModdedWorldCheck.serverStarted(server); } @@ -367,6 +370,9 @@ public final class ModdedEngineBootstrap { selfTest(moddedLoader.getClass().getClassLoader()); bind(); IrisLanguage.initialize(); + if (!moddedLoader.clientEnvironment()) { + ModdedTakeoverReconciler.reconcileEarly(); + } ModdedStartup.prefetchStartupDatapack(); if (!moddedLoader.clientEnvironment()) { MainWorldService.reconcileEarly(); 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 93f887393..194cc2b8d 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,6 +324,8 @@ public final class ModdedForcedDatapack { false ); digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8)); + digest.update(ModdedDimensionTakeovers.fingerprint(ModdedModConfig.get().dimensionTakeovers()) + .getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } @@ -333,15 +335,20 @@ public final class ModdedForcedDatapack { int packCount = 0; KList presetIds = new KList<>(); + Map stagedDimensionTypes = new LinkedHashMap<>(); Path worldRoot = worldRootOrNull(); Set legacySelections = unadoptedSelections(worldRoot); List packRoots = compilationPackRoots(worldRoot); for (File pack : packRoots) { PackStageContext context = packStageContext(pack.toPath(), legacySelections); - if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds)) { + if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds, stagedDimensionTypes)) { packCount++; } } + if (!ModdedEngineBootstrap.loader().clientEnvironment()) { + ModdedDimensionTakeovers.writeLevelStems(stagingDirectory, + ModdedModConfig.get().dimensionTakeovers(), stagedDimensionTypes); + } IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer); writePackMeta(stagingDirectory); @@ -362,7 +369,8 @@ public final class ModdedForcedDatapack { private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory, Map> seenBiomes, - KList presetIds) throws IOException { + KList presetIds, + Map stagedDimensionTypes) throws IOException { File sourcePack = context.packRoot().toFile(); PackValidationResult validation; try { @@ -392,11 +400,12 @@ public final class ModdedForcedDatapack { stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-"); Map> packBiomes = new LinkedHashMap<>(); KList packPresetIds = new KList<>(); + Map packDimensionTypes = new LinkedHashMap<>(); KList packFolders = new KList<>(); packFolders.add(packStagingDirectory.toFile()); boolean installed; try { - installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds); + installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds, packDimensionTypes); } catch (Throwable installationFailure) { if (context.required()) { throw requiredPackFailure(context, "datapack serialization", installationFailure); @@ -414,6 +423,7 @@ public final class ModdedForcedDatapack { mergeDirectory(packStagingDirectory, stagingDirectory); mergeBiomes(seenBiomes, packBiomes); presetIds.addAll(packPresetIds); + stagedDimensionTypes.putAll(packDimensionTypes); return true; } finally { try { @@ -470,7 +480,8 @@ public final class ModdedForcedDatapack { private static boolean installPack(PackStageContext context, IDataFixer fixer, KList folders, Map> seenBiomes, - KList presetIds) throws IOException { + KList presetIds, + Map dimensionTypes) throws IOException { File packFolder = context.packRoot().toFile(); String packName = context.packName(); File dimensionsDirectory = new File(packFolder, "dimensions"); @@ -504,18 +515,27 @@ public final class ModdedForcedDatapack { biomesForNamespace(seenBiomes, "iris"), context ); - writeDimensionType( - folders, - fixer, - dimension, - context.dimensionTypeRef(dimensionKey), - context.emitLegacyDimensionTypeAlias(), - context - ); + String typeRef = context.dimensionTypeRef(dimensionKey); + if (dimension.hasBorrowedDimensionType()) { + typeRef = requireBorrowedType(context, dimension, typeRef); + } else { + requireScopedType(context, typeRef); + writeDimensionType( + folders, + fixer, + dimension, + typeRef, + context.emitLegacyDimensionTypeAlias(), + context + ); + } if (context.emitPresets()) { - String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey); - writeWorldPreset(folders, packName, dimensionKey, presetRef); - presetIds.add(presetRef); + dimensionTypes.put(packName + ":" + dimensionKey, typeRef); + if (!dimension.hasBorrowedDimensionType()) { + String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey); + writeWorldPreset(folders, packName, dimensionKey, presetRef); + presetIds.add(presetRef); + } } installedDimensions++; } @@ -542,6 +562,22 @@ public final class ModdedForcedDatapack { + " (datapack source injection failed; see the Iris boot ERROR), so a restart alone will not register it."))); } + private static void requireScopedType(PackStageContext context, String stagedTypeRef) throws IOException { + if (context.required() && ModdedWorldgenIds.scopedDimensionType(stagedTypeRef).isEmpty()) { + throw new IOException("Retained Iris dimension type key is not scoped: " + stagedTypeRef); + } + } + + private static String requireBorrowedType(PackStageContext context, IrisDimension dimension, String stagedTypeRef) + throws IOException { + String borrowed = dimension.getBorrowedDimensionType().trim(); + if (context.required() && !borrowed.equals(stagedTypeRef)) { + throw new IOException("Retained Iris generation pack at " + context.packRoot() + " borrows dimension type " + + borrowed + ", but its generation history records " + stagedTypeRef + "."); + } + return borrowed; + } + private static void writeWorldPreset(KList folders, String packName, String dimensionKey, String presetRef) throws IOException { String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey; @@ -789,11 +825,13 @@ public final class ModdedForcedDatapack { Path normalizedRoot = packRoot.toAbsolutePath().normalize(); RetainedPack retained = retainedPack(normalizedRoot); if (retained != null) { - ModdedWorldgenIds.ScopedDimension identity = ModdedWorldgenIds - .scopedDimensionType(retained.epoch().dimensionContract().dimensionTypeKey()) - .orElseThrow(() -> new IOException("Retained Iris dimension type key is not scoped: " - + retained.epoch().dimensionContract().dimensionTypeKey())); - if (!identity.dimension().equals(retained.epoch().dimensionContract().dimensionKey())) { + GenerationEpoch.DimensionContract contract = retained.epoch().dimensionContract(); + Optional scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey()); + if (scoped.isEmpty()) { + return PackStageContext.retained(normalizedRoot, contract.dimensionKey(), retained.epoch()); + } + ModdedWorldgenIds.ScopedDimension identity = scoped.get(); + if (!identity.dimension().equals(contract.dimensionKey())) { throw new IOException("Retained Iris dimension type identity does not match its epoch contract."); } return PackStageContext.retained(normalizedRoot, identity.pack(), retained.epoch()); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java index bb8f2f906..c4cd571e5 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java @@ -28,6 +28,7 @@ import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.stream.Stream; final class ModdedGenerationHistoryStorage { private ModdedGenerationHistoryStorage() { @@ -75,7 +76,7 @@ final class ModdedGenerationHistoryStorage { return new AdoptionSource(source, captureCandidate( source, selectedDimensionKey, - GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES + adoptionAliasPolicy(dimensionRoot) )); }, ModdedGenerationHistoryStorage::requireRegistryDefinitions @@ -170,6 +171,19 @@ final class ModdedGenerationHistoryStorage { ); } + static GenerationRegistryContractFactory.CustomBiomeAliasPolicy adoptionAliasPolicy(Path dimensionRoot) + throws IOException { + Path regions = dimensionRoot.resolve("region"); + if (!Files.isDirectory(regions)) { + return GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY; + } + try (Stream files = Files.list(regions)) { + return files.anyMatch((Path file) -> file.getFileName().toString().endsWith(".mca")) + ? GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES + : GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY; + } + } + static ActivePack resolveActive(GenerationHistory history) throws IOException { GenerationEpoch epoch = history.activeEpoch(); Path packRoot = normalize(history.activePackRoot()); @@ -246,10 +260,9 @@ final class ModdedGenerationHistoryStorage { PlatformGenerationRegistry generationRegistry = IrisPlatforms.get() .registries() .generationRegistry(); - String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey( - source.getFileName().toString(), - dimension.getLoadKey(), - dimension.getDimensionTypeKey() + String dimensionTypeKey = dimension.dimensionTypeResourceKey( + generationRegistry, + source.getFileName().toString() ); GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create( dimension, 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 dabbf3d7f..4a076904c 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 @@ -24,9 +24,12 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; +import java.util.TreeMap; public final class ModdedModConfig { private static final Object LOCK = new Object(); + private static final String DIMENSION_TAKEOVERS = "dimensionTakeovers"; private static volatile ModdedModConfig instance; private final String defaultPack; @@ -35,15 +38,18 @@ public final class ModdedModConfig { private final String mainWorldPack; private final long mainWorldSeed; private final boolean mainWorldAutoRestart; + private final Map dimensionTakeovers; private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld, - String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart) { + String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart, + Map dimensionTakeovers) { 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(dimensionTakeovers); } public static ModdedModConfig get() { @@ -64,7 +70,7 @@ public final class ModdedModConfig { synchronized (LOCK) { ModdedModConfig current = get(); ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld, - current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart); + current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.dimensionTakeovers); instance = updated; write(configFile(), updated); } @@ -74,12 +80,20 @@ public final class ModdedModConfig { synchronized (LOCK) { ModdedModConfig current = get(); ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld, - packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart); + packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.dimensionTakeovers); instance = updated; write(configFile(), updated); } } + public static boolean setDimensionTakeover(ModdedDimensionTakeover takeover) { + return updateDimensionTakeover(takeover.levelKey(), takeover.packRef()); + } + + public static boolean clearDimensionTakeover(String levelKey) { + return updateDimensionTakeover(levelKey, null); + } + public String defaultPack() { return defaultPack; } @@ -104,13 +118,33 @@ public final class ModdedModConfig { return mainWorldAutoRestart; } + public Map dimensionTakeovers() { + return dimensionTakeovers; + } + + private static boolean updateDimensionTakeover(String levelKey, String packRef) { + synchronized (LOCK) { + ModdedModConfig current = get(); + 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)) { + return false; + } + ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld, + current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, takeovers); + instance = updated; + write(configFile(), updated); + return true; + } + } + private static Path configFile() { return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("modded.json"); } private static ModdedModConfig load() { Path file = configFile(); - ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false); + ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false, Map.of()); if (!Files.isRegularFile(file)) { write(file, defaults); return defaults; @@ -123,13 +157,30 @@ public final class ModdedModConfig { json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld), json.optString("mainWorldPack", defaults.mainWorldPack), json.optLong("mainWorldSeed", defaults.mainWorldSeed), - json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart)); + json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart), + readDimensionTakeovers(json.optJSONObject(DIMENSION_TAKEOVERS), file)); } catch (RuntimeException | IOException e) { ModdedIrisLog.error("Iris modded config at {} is invalid; using defaults", file, e); return defaults; } } + private static Map readDimensionTakeovers(JSONObject json, Path file) { + if (json == null) { + return Map.of(); + } + Map takeovers = new TreeMap<>(); + for (String levelKey : json.keySet()) { + try { + ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, json.optString(levelKey, "")); + takeovers.put(takeover.levelKey(), takeover.packRef()); + } catch (IllegalArgumentException invalid) { + ModdedIrisLog.error("Iris ignored dimension takeover '{}' in {}: {}", levelKey, file, invalid.getMessage()); + } + } + return takeovers; + } + private static void write(Path file, ModdedModConfig config) { JSONObject json = new JSONObject(); json.put("defaultPack", config.defaultPack); @@ -138,6 +189,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()); + } + json.put(DIMENSION_TAKEOVERS, takeovers); try { Files.createDirectories(file.getParent()); Files.writeString(file, json.toString(4), StandardCharsets.UTF_8); 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 new file mode 100644 index 000000000..7329e60d5 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java @@ -0,0 +1,143 @@ +/* + * 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.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths; +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.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.UUID; + +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 { + Map configured = ModdedModConfig.get().dimensionTakeovers(); + 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"); + Result result = reconcile(worldRoot, configured, recoveryParent); + if (result.changedLevels().isEmpty()) { + return; + } + ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", result.changedLevels(), + 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; + } + Map configured = ModdedModConfig.get().dimensionTakeovers(); + try { + if (readApplied(worldRoot.get()).equals(configured)) { + return; + } + writeApplied(worldRoot.get(), configured); + } catch (IOException | RuntimeException failure) { + ModdedIrisLog.error("Iris could not record the applied dimension takeovers under {}", worldRoot.get(), failure); + } + } + + static Result reconcile(Path worldRoot, Map configured, Path recoveryParent) throws IOException { + Map applied = readApplied(worldRoot); + Set changed = ModdedDimensionTakeovers.changedLevels(applied, configured); + if (changed.isEmpty()) { + return new Result(changed, null); + } + List sources = new ArrayList<>(); + for (String level : changed) { + Path storage = ModdedDimensionTakeover.storageFolder(worldRoot, level); + for (String folder : ModdedDimensionStorage.chunkDataFolders()) { + sources.add(storage.resolve(folder)); + } + } + Path recovery = recoveryParent.resolve("takeover-recovery-" + UUID.randomUUID()); + List moved = ModdedWorldQuarantine.move(worldRoot, sources, recovery); + writeApplied(worldRoot, configured); + return new Result(changed, moved.isEmpty() ? null : recovery); + } + + 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 Result(Set changedLevels, Path recovery) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index dbc613a2e..9bf710cf1 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -391,6 +391,15 @@ public final class ModdedWorldEngines { (actualType.maximumHeight() - actualType.minimumHeight()), actualType.logicalHeight()); String runtimeName = "Modded level '" + level.name() + "'"; + if (dimension.hasBorrowedDimensionType()) { + if (!recorded.dimensionTypeKey().equals(dimension.getBorrowedDimensionType().trim())) { + throw new IllegalStateException("Immutable Iris dimension contract for '" + level.name() + + "' records dimension type " + recorded.dimensionTypeKey() + ", but its pack borrows " + + dimension.getBorrowedDimensionType().trim() + "."); + } + expected.requireBorrowedFit(runtimeName, actual); + return; + } expected.requireExact(runtimeName, actual); expected.requireHeight(runtimeName, level.minHeight(), (level.maxHeight() - level.minHeight())); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldQuarantine.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldQuarantine.java new file mode 100644 index 000000000..075eda0bd --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldQuarantine.java @@ -0,0 +1,73 @@ +/* + * 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.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +final class ModdedWorldQuarantine { + private ModdedWorldQuarantine() { + } + + static List move(Path worldRoot, List sources, Path recovery) throws IOException { + List moved = new ArrayList<>(sources.size()); + try { + for (Path source : sources) { + moveOne(worldRoot, source, recovery, moved); + } + } catch (IOException failure) { + rollback(worldRoot, recovery, moved, failure); + throw failure; + } + if (moved.isEmpty()) { + Files.deleteIfExists(recovery); + } + return List.copyOf(moved); + } + + private static void moveOne(Path worldRoot, Path source, Path recovery, List moved) throws IOException { + if (!Files.exists(source)) { + return; + } + Path relative = worldRoot.relativize(source); + if (relative.isAbsolute() || relative.startsWith("..")) { + throw new IOException("Iris refuses to quarantine " + source + " outside the world " + worldRoot); + } + Path target = recovery.resolve(relative); + Files.createDirectories(target.getParent()); + Files.move(source, target); + moved.add(relative); + } + + private static void rollback(Path worldRoot, Path recovery, List moved, IOException failure) { + for (int index = moved.size() - 1; index >= 0; index--) { + Path relative = moved.get(index); + Path rollbackTarget = worldRoot.resolve(relative); + try { + Files.createDirectories(rollbackTarget.getParent()); + Files.move(recovery.resolve(relative), rollbackTarget); + } catch (IOException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java index d305dff4b..86d7d2043 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldgenIds.java @@ -1,6 +1,7 @@ package art.arcane.iris.modded; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; import java.nio.charset.StandardCharsets; import java.util.HexFormat; @@ -22,6 +23,12 @@ public final class ModdedWorldgenIds { return NAMESPACE + ":" + scopedPath(pack, dimension) + "/dimension_type"; } + public static String dimensionTypeRef(String pack, IrisDimension dimension) { + return dimension.hasBorrowedDimensionType() + ? dimension.getBorrowedDimensionType().trim() + : dimensionTypeRef(pack, dimension.getLoadKey()); + } + public static String biomePathPrefix(String pack, String dimension) { return scopedPath(pack, dimension) + "/biomes"; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java index 22a70e57d..21d68236b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckDimensionContract.java @@ -22,6 +22,7 @@ package art.arcane.iris.modded; import art.arcane.iris.pack.datapack.DataVersion; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.volmlib.util.json.JSONObject; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldDimensions; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection.Dimension; import art.arcane.volmlib.nativelib.terrain.NativeWorld; @@ -34,6 +35,9 @@ final class WorldCheckDimensionContract { static boolean checkDimensionType(NativeWorld level, IrisModdedChunkGenerator generator) { try { IrisDimension dimension = generator.commandEngine().getDimension(); + if (dimension.hasBorrowedDimensionType()) { + return checkBorrowedDimensionType(level, dimension); + } Dimension expected = expectedDimensionContract(dimension); Dimension actual = new NativeWorldInspection(level).dimension(); boolean pass = matchesDimensionContract(level.minHeight(), (level.maxHeight() - level.minHeight()), expected, actual); @@ -54,6 +58,24 @@ final class WorldCheckDimensionContract { } } + private static boolean checkBorrowedDimensionType(NativeWorld level, IrisDimension dimension) { + String borrowed = dimension.getBorrowedDimensionType().trim(); + String actualKey = NativeWorldDimensions.settings(level).key(); + boolean pass = borrowed.equals(actualKey) + && dimension.getMinHeight() == level.minHeight() + && dimension.getMaxHeight() <= level.maxHeight(); + String detail = "borrowed=" + borrowed + ",actual=" + actualKey + + ",packRange=" + dimension.getMinHeight() + ".." + dimension.getMaxHeight() + + ",levelRange=" + level.minHeight() + ".." + level.maxHeight(); + WorldCheckPredicates.qaEvent("dimension_type", dimension.getLoadKey(), pass, detail); + if (!pass) { + ModdedIrisLog.error("[worldcheck] borrowed dimension type mismatch for {}: {}", dimension.getLoadKey(), detail); + } else { + ModdedIrisLog.info("[worldcheck] borrowed dimension type contract: {}", detail); + } + return pass; + } + static Dimension expectedDimensionContract(IrisDimension dimension) { JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get())); return new Dimension( 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 36474a93f..fba2bd218 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 @@ -169,6 +169,7 @@ 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/ModdedDatapackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java index 0d9df4119..b20ce6749 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java @@ -163,6 +163,9 @@ public final class ModdedDatapackCommands { continue; } IrisDimension dimension = engine.getDimension(); + if (dimension.hasBorrowedDimensionType()) { + continue; + } String json; try { json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get()); 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 2c2f15d28..1380870bb 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,6 +26,8 @@ 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.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedModConfig; @@ -59,6 +61,7 @@ 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() { } @@ -80,6 +83,7 @@ public final class ModdedWorldCommands { root.then(updateTree()); root.then(replaceOverworldTree()); root.then(mainWorldTree()); + root.then(takeoverTree()); root.then(disableTree()); root.then(deleteTree("delete")); @@ -156,6 +160,18 @@ 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); @@ -286,6 +302,54 @@ 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; + } + ModdedModConfig.setDimensionTakeover(takeover); + ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + takeover.levelKey()); + IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_SET, + MessageArgument.untrusted("dimensionId", takeover.levelKey()), + MessageArgument.untrusted("pack", pack), + MessageArgument.untrusted("packDimension", takeover.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(); @@ -496,6 +560,15 @@ 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 f6b6779cb..df565d8fb 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,6 +1152,22 @@ 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'." @@ -1548,6 +1564,10 @@ 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 219e33e73..ba28c3e91 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,6 +390,10 @@ 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" @@ -516,6 +520,7 @@ 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/ModdedDimensionTakeoversTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java new file mode 100644 index 000000000..d90889747 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java @@ -0,0 +1,133 @@ +/* + * 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.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")); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryStorageTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryStorageTest.java index 977587ffa..cf1dfb556 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryStorageTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationHistoryStorageTest.java @@ -5,6 +5,7 @@ import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationHistory; import art.arcane.iris.world.history.GenerationPackFingerprint; import art.arcane.iris.world.history.GenerationRegistryContract; +import art.arcane.iris.world.history.GenerationRegistryContractFactory; import org.junit.Test; import java.io.IOException; @@ -28,6 +29,28 @@ public class ModdedGenerationHistoryStorageTest { private static final String DEFINITION_FINGERPRINT = "22".repeat(32); private static final int TRANSITION_WIDTH_BLOCKS = 256; + @Test + public void adoptionRetainsLegacyBiomeAliasesOnlyWhenTheLevelAlreadyHasChunks() throws IOException { + Path root = Files.createTempDirectory("iris-modded-history-alias-policy"); + try { + Path fresh = root.resolve("fresh"); + Path emptyRegion = root.resolve("empty-region"); + Files.createDirectories(emptyRegion.resolve("region")); + Path legacy = root.resolve("legacy"); + Files.createDirectories(legacy.resolve("region")); + Files.writeString(legacy.resolve("region").resolve("r.0.0.mca"), "chunk", StandardCharsets.UTF_8); + + assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY, + ModdedGenerationHistoryStorage.adoptionAliasPolicy(fresh)); + assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY, + ModdedGenerationHistoryStorage.adoptionAliasPolicy(emptyRegion)); + assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES, + ModdedGenerationHistoryStorage.adoptionAliasPolicy(legacy)); + } finally { + deleteTree(root); + } + } + @Test public void newDimensionPublishesAnImmutableWorldLocalPack() throws IOException { Path root = Files.createTempDirectory("iris-modded-history-create"); 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 new file mode 100644 index 000000000..51bbc8246 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java @@ -0,0 +1,148 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.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.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 = ModdedTakeoverReconciler.reconcile( + worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent); + + assertEquals(Set.of("iristest:skylands"), result.changedLevels()); + 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("iristest:skylands", "overworld"), 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 = ModdedTakeoverReconciler.reconcile( + worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent); + + assertEquals(Set.of(), unchanged.changedLevels()); + assertNull(unchanged.recovery()); + assertTrue(Files.isRegularFile(region)); + + ModdedTakeoverReconciler.Result removed = ModdedTakeoverReconciler.reconcile( + worldRoot, Map.of(), recoveryParent); + + assertEquals(Set.of("iristest:skylands"), removed.changedLevels()); + 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 changeWithoutChunkDataRecordsStateWithoutARecoveryFolder() 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 = ModdedTakeoverReconciler.reconcile( + worldRoot, Map.of("aether:the_aether", "overworld"), recoveryParent); + + assertEquals(Set.of("aether:the_aether"), result.changedLevels()); + assertNull(result.recovery()); + assertFalse(Files.exists(recoveryParent)); + assertEquals(Map.of("aether:the_aether", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot)); + } finally { + deleteTree(temporary); + } + } + + 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/ModdedWorldgenIdsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldgenIdsTest.java index 516799013..377ee13e1 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldgenIdsTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldgenIdsTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.modded; +import art.arcane.iris.generation.terrain.IrisDimension; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -19,6 +20,19 @@ public class ModdedWorldgenIdsTest { ModdedWorldgenIds.dimensionTypeRef("Pack/A-_", "É山")).orElseThrow()); } + @Test + public void borrowedDimensionTypeReplacesTheScopedGeneratedType() { + IrisDimension generated = new IrisDimension(); + generated.setLoadKey("sky"); + IrisDimension borrowed = new IrisDimension().setBorrowedDimensionType("iristest:skylands"); + borrowed.setLoadKey("sky"); + + assertEquals(ModdedWorldgenIds.dimensionTypeRef("overworld", "sky"), + ModdedWorldgenIds.dimensionTypeRef("overworld", generated)); + assertEquals("iristest:skylands", ModdedWorldgenIds.dimensionTypeRef("overworld", borrowed)); + assertTrue(ModdedWorldgenIds.scopedDimensionType("iristest:skylands").isEmpty()); + } + @Test public void malformedIdentifiersRetainRejectionBehavior() { assertTrue(ModdedWorldgenIds.scopedDimensionType( 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 ab6061c93..471a55504 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 @@ -173,6 +173,7 @@ 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("goto", "unregistered")); assertTrue(ModdedCommandHelp.documents("pack", "compat")); assertTrue(ModdedCommandHelp.documents("pack", "cp")); diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java index e98bb5880..040fc1caf 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java @@ -63,6 +63,7 @@ import art.arcane.iris.pack.validation.CompatStatus; import art.arcane.iris.pack.validation.ContentGate; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformGenerationRegistry; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.pack.loading.IrisRegistrant; import art.arcane.iris.pack.datapack.DataVersion; @@ -263,6 +264,8 @@ public class IrisDimension extends IrisRegistrant { private IrisRange dimensionHeight = new IrisRange(-64, 320); @Description("Define options for this dimension") private IrisDimensionTypeOptions dimensionOptions = new IrisDimensionTypeOptions(); + @Description("Borrow an existing registered dimension type, such as minecraft:the_end or a mod's aether:the_aether, instead of generating one from dimensionOptions. Sky, fog, lighting, and client effects then come from that type, and dimensionOptions and fullbright are ignored. dimensionHeight.min must equal the type's min_y and dimensionHeight.max must not exceed its top; a mismatch is refused when the world loads. Leave empty to generate a dimension type. Modded servers only.") + private String borrowedDimensionType = ""; @Description("Configure and enforce the native Minecraft world border. When omitted, Iris leaves the native border unchanged.") private IrisWorldBoundary worldBoundary = null; @ArrayType(type = IrisImageMapBinding.class) @@ -1105,6 +1108,17 @@ public class IrisDimension extends IrisRegistrant { return sanitizeDimensionTypeKeyValue(getLoadKey()); } + public boolean hasBorrowedDimensionType() { + return borrowedDimensionType != null && !borrowedDimensionType.isBlank(); + } + + public String dimensionTypeResourceKey(PlatformGenerationRegistry registry, String packName) { + if (hasBorrowedDimensionType()) { + return borrowedDimensionType.trim(); + } + return registry.dimensionTypeResourceKey(packName, getLoadKey(), getDimensionTypeKey()); + } + public String getCustomBiomeKey(String customBiomeId) { return customBiomeKey(getLoadKey(), customBiomeId); } diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionRuntimeContract.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionRuntimeContract.java index 412dfa311..a3d4818c9 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionRuntimeContract.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionRuntimeContract.java @@ -46,7 +46,9 @@ public record IrisDimensionRuntimeContract( Objects.requireNonNull(dimension, "dimension"); Objects.requireNonNull(namespace, "namespace"); return new IrisDimensionRuntimeContract( - namespace + ":" + dimension.getDimensionTypeKey(), + dimension.hasBorrowedDimensionType() + ? dimension.getBorrowedDimensionType().trim() + : namespace + ":" + dimension.getDimensionTypeKey(), dimension.getMinHeight(), dimension.getMaxHeight() - dimension.getMinHeight(), dimension.getLogicalHeight()); @@ -83,6 +85,24 @@ public record IrisDimensionRuntimeContract( throw mismatch(runtimeName, "unknown", actualMinHeight, actualHeight, -1); } + public void requireBorrowedFit(String runtimeName, IrisDimensionRuntimeContract actual) { + Objects.requireNonNull(actual, "actual"); + String packRange = minHeight + ".." + maxHeight(); + String borrowedRange = actual.minHeight() + ".." + actual.maxHeight(); + if (!typeKey.equals(actual.typeKey())) { + throw new IrisDimensionContractException(runtimeName + " requires borrowed dimension type " + typeKey + + ", but the loaded runtime uses " + actual.typeKey() + " with range " + borrowedRange + + ". Generation was refused before any chunk writes. Restart after the level uses " + typeKey + "."); + } + if (minHeight == actual.minHeight() && maxHeight() <= actual.maxHeight()) { + return; + } + throw new IrisDimensionContractException(runtimeName + " borrows dimension type " + typeKey + + " with range " + borrowedRange + ", but the Iris pack range is " + packRange + + ". Generation was refused before any chunk writes. Set the pack dimensionHeight.min to " + + actual.minHeight() + " and keep dimensionHeight.max at or below " + actual.maxHeight() + "."); + } + private IrisDimensionContractException mismatch( String runtimeName, String actualTypeKey, diff --git a/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java b/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java index 573761c00..fd0faca69 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java +++ b/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java @@ -30,8 +30,12 @@ import java.nio.file.Files; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.regex.Pattern; final class PackDimensionValidator { + private static final String BORROWED_DIMENSION_TYPE = "borrowedDimensionType"; + private static final Pattern RESOURCE_KEY = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); + private PackDimensionValidator() { } @@ -56,6 +60,7 @@ final class PackDimensionValidator { validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings); validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors); + validateBorrowedDimensionType(dimensionKey, dimJson, blockingErrors); validateSamplingStep(dimensionKey, dimJson, "terrainSamplingStep", 1, blockingErrors); validateSamplingStep(dimensionKey, dimJson, "caveDensitySamplingStep", 1, blockingErrors); validateSamplingStep(dimensionKey, dimJson, "biomeBoundsSamplingStep", 4, blockingErrors); @@ -123,6 +128,18 @@ final class PackDimensionValidator { } } + static void validateBorrowedDimensionType(String dimensionKey, JSONObject dimension, List errors) { + if (!dimension.has(BORROWED_DIMENSION_TYPE) || dimension.isNull(BORROWED_DIMENSION_TYPE)) { + return; + } + Object value = dimension.opt(BORROWED_DIMENSION_TYPE); + if (value instanceof String key && (key.isEmpty() || RESOURCE_KEY.matcher(key).matches())) { + return; + } + errors.add("Dimension '" + dimensionKey + "' " + BORROWED_DIMENSION_TYPE + + " must be a namespaced dimension type key such as aether:the_aether, but was '" + value + "'."); + } + static void validateObjectScaleFactor(String dimensionKey, JSONObject dimension, List errors) { validateFiniteNumber(dimension, "allObjectScaleFactor", IrisObjectScale.MINIMUM_FACTOR, IrisObjectScale.MAXIMUM_FACTOR, "Dimension '" + dimensionKey + "'", errors); diff --git a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java index 22a3ce07d..06d8df5da 100644 --- a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java @@ -373,14 +373,10 @@ public class StudioSVC implements IrisService { throw new IOException("Generation pack does not contain a loadable dimension '" + dimensionKey + "'."); } - String dimensionTypeKey = IrisPlatforms.get() - .registries() - .generationRegistry() - .dimensionTypeResourceKey( - data.getDataFolder().getName(), - dimension.getLoadKey(), - dimension.getDimensionTypeKey() - ); + String dimensionTypeKey = dimension.dimensionTypeResourceKey( + IrisPlatforms.get().registries().generationRegistry(), + data.getDataFolder().getName() + ); GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create( dimension, dimension.getLoadKey(), diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java index 2eaea8c63..fff744dbf 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationRegistryContractFactory.java @@ -175,15 +175,24 @@ public final class GenerationRegistryContractFactory { List reachableBiomes = new ArrayList<>(requiredDimension.getReachableBiomes(() -> requiredData)); for (IrisDimension registryDimension : registryDimensions(requiredData, requiredDimension)) { String dimensionKey = requireText(registryDimension.getLoadKey(), "Dimension load key"); - addGeneratedDimensionType( - definitions, - generatedSources, - registryDimension, - packName, - dimensionKey, - requiredFixer, - requiredGenerationRegistry - ); + if (!registryDimension.hasBorrowedDimensionType()) { + addGeneratedDimensionType( + definitions, + generatedSources, + registryDimension, + packName, + dimensionKey, + requiredFixer, + requiredGenerationRegistry + ); + } else if (registryDimension == requiredDimension) { + addPlatformDefinitions( + definitions, + DIMENSION_TYPE_REGISTRY, + List.of(registryDimension.dimensionTypeResourceKey(requiredGenerationRegistry, packName)), + requiredGenerationRegistry + ); + } List referencedBiomes = registryDimension == requiredDimension ? reachableBiomes : List.of(); List registryBiomes = new ArrayList<>(registryDimension.getAllBiomes(() -> requiredData)); registryBiomes.addAll(referencedBiomes); diff --git a/core/src/main/resources/languages/de_DE.toml b/core/src/main/resources/languages/de_DE.toml index 55472401e..3c905f191 100644 --- a/core/src/main/resources/languages/de_DE.toml +++ b/core/src/main/resources/languages/de_DE.toml @@ -446,6 +446,7 @@ 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" @@ -1087,6 +1088,10 @@ 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 70b48a67d..de4c4f8cb 100644 --- a/core/src/main/resources/languages/es_ES.toml +++ b/core/src/main/resources/languages/es_ES.toml @@ -446,6 +446,7 @@ 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" @@ -1087,6 +1088,10 @@ 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 5616b83eb..130d004c7 100644 --- a/core/src/main/resources/languages/fi_FI.toml +++ b/core/src/main/resources/languages/fi_FI.toml @@ -451,6 +451,7 @@ 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" @@ -1092,6 +1093,10 @@ 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 20e4834fd..6de1e6bc6 100644 --- a/core/src/main/resources/languages/fr_FR.toml +++ b/core/src/main/resources/languages/fr_FR.toml @@ -446,6 +446,7 @@ 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" @@ -1087,6 +1088,10 @@ 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 92ce65eb7..3c73e2c0b 100644 --- a/core/src/main/resources/languages/he_IL.toml +++ b/core/src/main/resources/languages/he_IL.toml @@ -451,6 +451,7 @@ 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" @@ -1092,6 +1093,10 @@ 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 898f5b296..00b18b911 100644 --- a/core/src/main/resources/languages/it_IT.toml +++ b/core/src/main/resources/languages/it_IT.toml @@ -446,6 +446,7 @@ 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" @@ -1087,6 +1088,10 @@ 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 2f3d4fc21..b379f9ca1 100644 --- a/core/src/main/resources/languages/ja-JP.toml +++ b/core/src/main/resources/languages/ja-JP.toml @@ -449,6 +449,7 @@ 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" @@ -1090,6 +1091,10 @@ 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 967099f9d..251864ff6 100644 --- a/core/src/main/resources/languages/ko_KR.toml +++ b/core/src/main/resources/languages/ko_KR.toml @@ -449,6 +449,7 @@ 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" @@ -1090,6 +1091,10 @@ 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 9fdbf193e..a3b93f432 100644 --- a/core/src/main/resources/languages/lt_LT.toml +++ b/core/src/main/resources/languages/lt_LT.toml @@ -451,6 +451,7 @@ 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" @@ -1092,6 +1093,10 @@ 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 3817f6169..a47157f31 100644 --- a/core/src/main/resources/languages/nl_NL.toml +++ b/core/src/main/resources/languages/nl_NL.toml @@ -446,6 +446,7 @@ 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" @@ -1087,6 +1088,10 @@ 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 32922cf6e..70918a17a 100644 --- a/core/src/main/resources/languages/pl_PL.toml +++ b/core/src/main/resources/languages/pl_PL.toml @@ -451,6 +451,7 @@ 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" @@ -1092,6 +1093,10 @@ 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 d2a4a1335..159e8e458 100644 --- a/core/src/main/resources/languages/pt_PT.toml +++ b/core/src/main/resources/languages/pt_PT.toml @@ -446,6 +446,7 @@ 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" @@ -1087,6 +1088,10 @@ 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 bb948faf5..9a675385e 100644 --- a/core/src/main/resources/languages/ru_RU.toml +++ b/core/src/main/resources/languages/ru_RU.toml @@ -451,6 +451,7 @@ 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" @@ -1092,6 +1093,10 @@ 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 f53c5c664..494e06198 100644 --- a/core/src/main/resources/languages/tr_TR.toml +++ b/core/src/main/resources/languages/tr_TR.toml @@ -451,6 +451,7 @@ 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" @@ -1092,6 +1093,10 @@ 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 c8ace9fa5..b9a29612a 100644 --- a/core/src/main/resources/languages/vi_VI.toml +++ b/core/src/main/resources/languages/vi_VI.toml @@ -449,6 +449,7 @@ 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" @@ -1090,6 +1091,10 @@ 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 4156dd8d7..7e546ccfe 100644 --- a/core/src/main/resources/languages/zh_CN.toml +++ b/core/src/main/resources/languages/zh_CN.toml @@ -449,6 +449,7 @@ 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" @@ -1090,6 +1091,10 @@ 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 55cdb61cc..ceff669e0 100644 --- a/core/src/main/resources/languages/zh_TW.toml +++ b/core/src/main/resources/languages/zh_TW.toml @@ -449,6 +449,7 @@ 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" @@ -1090,6 +1091,10 @@ 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/test/java/art/arcane/iris/generation/terrain/IrisDimensionRuntimeContractTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionRuntimeContractTest.java index 0578f36a5..ef5fc5772 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionRuntimeContractTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionRuntimeContractTest.java @@ -6,6 +6,7 @@ import art.arcane.iris.world.IrisEnvironment; import org.junit.Test; import static art.arcane.iris.generation.terrain.IrisDimensionTypeOptions.TriState.TRUE; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -137,6 +138,84 @@ public class IrisDimensionRuntimeContractTest { IrisDimensionRuntimeContract.requireHotloadCompatible("Studio world", active, replacement, "iris"); } + @Test + public void expectedContractUsesTheBorrowedTypeKey() { + IrisDimension dimension = borrowedDimension("aether:the_aether", 0, 256); + + assertEquals("aether:the_aether", IrisDimensionRuntimeContract.expected(dimension, "iris").typeKey()); + } + + @Test + public void borrowedTypeAcceptsAPackRangeInsideTheBorrowedRange() { + IrisDimensionRuntimeContract expected = IrisDimensionRuntimeContract.expected( + borrowedDimension("aether:the_aether", 0, 192), "iris"); + + expected.requireBorrowedFit("Modded level 'aether:the_aether'", + new IrisDimensionRuntimeContract("aether:the_aether", 0, 256, 256)); + expected.requireBorrowedFit("Modded level 'aether:the_aether'", + new IrisDimensionRuntimeContract("aether:the_aether", 0, 192, 64)); + } + + @Test + public void borrowedTypeRejectsAPackTopAboveTheBorrowedTop() { + IrisDimensionRuntimeContract expected = IrisDimensionRuntimeContract.expected( + borrowedDimension("aether:the_aether", 0, 320), "iris"); + + assertBorrowedFitRejected(expected, new IrisDimensionRuntimeContract("aether:the_aether", 0, 256, 256), + "0..320", "0..256"); + } + + @Test + public void borrowedTypeRejectsAPackMinimumThatDiffersFromTheBorrowedMinimum() { + IrisDimensionRuntimeContract expected = IrisDimensionRuntimeContract.expected( + borrowedDimension("aether:the_aether", 16, 256), "iris"); + + assertBorrowedFitRejected(expected, new IrisDimensionRuntimeContract("aether:the_aether", 0, 256, 256), + "16..256", "0..256"); + } + + @Test + public void borrowedTypeRejectsALevelUsingAnotherType() { + IrisDimensionRuntimeContract expected = IrisDimensionRuntimeContract.expected( + borrowedDimension("aether:the_aether", 0, 256), "iris"); + + assertBorrowedFitRejected(expected, new IrisDimensionRuntimeContract("minecraft:overworld", 0, 256, 256), + "aether:the_aether", "minecraft:overworld"); + } + + @Test + public void hotloadRejectsBorrowedTypeChanges() { + IrisDimension active = hotloadDimension(); + IrisDimension replacement = hotloadDimension(); + replacement.setBorrowedDimensionType("aether:the_aether"); + + assertHotloadRejected(active, replacement, "aether:the_aether"); + } + + private IrisDimension borrowedDimension(String typeKey, int minHeight, int maxHeight) { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("skylands"); + dimension.setBorrowedDimensionType(typeKey); + dimension.setDimensionHeight(new IrisRange(minHeight, maxHeight)); + dimension.setLogicalHeight(maxHeight - minHeight); + return dimension; + } + + private void assertBorrowedFitRejected(IrisDimensionRuntimeContract expected, + IrisDimensionRuntimeContract actual, + String expectedFragment, + String actualFragment) { + try { + expected.requireBorrowedFit("Modded level 'aether:the_aether'", actual); + fail("A borrowed dimension type that cannot hold the pack range must be rejected"); + } catch (IrisDimensionContractException e) { + assertTrue(e.getMessage(), e.getMessage().contains("Modded level 'aether:the_aether'")); + assertTrue(e.getMessage(), e.getMessage().contains(expectedFragment)); + assertTrue(e.getMessage(), e.getMessage().contains(actualFragment)); + assertTrue(e.getMessage(), e.getMessage().contains("Generation was refused")); + } + } + private IrisDimensionRuntimeContract expectedContract() { IrisDimension dimension = new IrisDimension(); dimension.setLoadKey("overworld"); diff --git a/core/src/test/java/art/arcane/iris/pack/PackValidatorBorrowedDimensionTypeTest.java b/core/src/test/java/art/arcane/iris/pack/PackValidatorBorrowedDimensionTypeTest.java new file mode 100644 index 000000000..4da05d4db --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/PackValidatorBorrowedDimensionTypeTest.java @@ -0,0 +1,54 @@ +/* + * 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.pack; + +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackValidatorBorrowedDimensionTypeTest { + @Test + public void acceptsAbsentEmptyAndRegistryKeys() { + assertTrue(validate("{}").isEmpty()); + assertTrue(validate("{\"borrowedDimensionType\":\"\"}").isEmpty()); + assertTrue(validate("{\"borrowedDimensionType\":\"aether:the_aether\"}").isEmpty()); + assertTrue(validate("{\"borrowedDimensionType\":\"minecraft:the_end\"}").isEmpty()); + } + + @Test + public void rejectsValuesThatAreNotNamespacedRegistryKeys() { + assertEquals(List.of("Dimension 'main' borrowedDimensionType must be a namespaced dimension type key such as aether:the_aether, but was 'the_aether'."), + validate("{\"borrowedDimensionType\":\"the_aether\"}")); + assertEquals(List.of("Dimension 'main' borrowedDimensionType must be a namespaced dimension type key such as aether:the_aether, but was 'Aether:The Aether'."), + validate("{\"borrowedDimensionType\":\"Aether:The Aether\"}")); + assertEquals(List.of("Dimension 'main' borrowedDimensionType must be a namespaced dimension type key such as aether:the_aether, but was '42'."), + validate("{\"borrowedDimensionType\":42}")); + } + + private static List validate(String json) { + List errors = new ArrayList<>(); + PackDimensionValidator.validateBorrowedDimensionType("main", new JSONObject(json), errors); + return errors; + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 3cdb8e8c8..763041586 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "1720dcd78ce7d4ef6fc601cb8d8703d42a35625e8038ab04e98db03514df5959"; + private static final String EXPECTED_SCHEMA_DIGEST = "2db2ba19e3466b7c686adca580943c1cc832bf0b44a0568e8db86a920116cfc0"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java index 46ff8934c..60700f117 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationRegistryContractFactoryTest.java @@ -153,6 +153,40 @@ public class GenerationRegistryContractFactoryTest { ))); } + @Test + public void borrowedDimensionTypeIsRecordedAsAnExternalDefinitionInsteadOfAGeneratedType() throws Exception { + File pack = temporary.newFolder("borrowed-pack"); + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(pack); + when(data.getContentGate()).thenReturn(mock(ContentGate.class)); + IrisDimension dimension = spy(new IrisDimension().setBorrowedDimensionType("aether:the_aether")); + dimension.setLoadKey("skylands"); + stubDimensions(data, dimension); + doReturn(new KList<>()).when(dimension).getReachableBiomes(any()); + doReturn(new KList<>()).when(dimension).getAllRegions(any()); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockTypeKeys()).thenReturn(new KList<>()); + when(registries.entityKeys()).thenReturn(new KList<>()); + FakeGenerationRegistry generationRegistry = new FakeGenerationRegistry("test-runtime"); + + GenerationRegistryContract contract = GenerationRegistryContractFactory.create( + data, + dimension, + GenerationPackFingerprint.compute(pack.toPath(), GenerationPackFingerprint.CURRENT_VERSION), + DataVersion.getLatest().get(), + registries, + generationRegistry + ); + + GenerationRegistryContract.PhysicalResourceKey borrowed = key( + GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, "aether:the_aether"); + assertTrue(contract.definitions().containsKey(borrowed)); + assertFalse(contract.generatedSources().containsKey(borrowed)); + assertFalse(contract.definitions().containsKey(key( + GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, "test:skylands_type"))); + assertEquals("aether:the_aether", dimension.dimensionTypeResourceKey(generationRegistry, "borrowed-pack")); + } + @Test public void duplicateLogicalBiomeIdsKeepExactDefinitionsAndChooseStableLegacyAlias() throws Exception { File pack = temporary.newFolder("duplicate-biomes"); diff --git a/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java b/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java index 7c1f4722f..edd60f050 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java +++ b/probe/src/main/java/art/arcane/iris/probe/HeadlessGenerationHistorySession.java @@ -41,8 +41,8 @@ final class HeadlessGenerationHistorySession { String fingerprint = GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION); GenerationRegistryContract registryContract = GenerationRegistryContractFactory.create( data, dimension, fingerprint, fixer, registries, generationRegistry); - String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey( - data.getDataFolder().getName(), dimension.getLoadKey(), dimension.getDimensionTypeKey()); + String dimensionTypeKey = dimension.dimensionTypeResourceKey( + generationRegistry, data.getDataFolder().getName()); GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create( dimension, dimension.getLoadKey(), dimensionTypeKey); return new GenerationHistory.FreshCreation(request.worldRoot(), pack, fingerprint, request.seed(),