Unified modded world replace

- Added /iris replace with override and overwrite aliases on Fabric, Forge, and NeoForge using the Paper target, type, and seed arguments
- Resolved main, nether, end, level-name aliases, iris: and bare names, and any loaded mod dimension as replacement targets
- Kept the saved seed when seed is omitted and stored explicit seeds per replaced dimension
- Applied replaced vanilla and mod dimensions through the forced datapack level stem with the configured seed bound at engine creation
- Applied staged Iris world replacements at startup by moving their terrain aside and repointing the dimension registry
- Refused a second staging while a replacement is pending and checked pack environments for vanilla slots
- Added the modded-only off form to cancel a staged replacement or return a replaced dimension to its own generator
- Reset the Overworld spawn and moved returning players out of unsafe positions after an Overworld replacement
- Renamed the takeover reconciler, model, config key, state file, and recovery folder to replacement names
- Removed /iris world takeover and its help entry and localized messages
This commit is contained in:
Brian Neumann-Fopiano
2026-10-15 10:10:11 -04:00
parent 0ca33068f3
commit a7cf2cac9a
46 changed files with 2663 additions and 1454 deletions
@@ -100,6 +100,7 @@ import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
@@ -477,6 +478,7 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
return existing;
}
try {
applyReplacementSeed(level);
Engine created = ModdedWorldEngines.get(
level,
activePack,
@@ -511,6 +513,16 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
}
}
private void applyReplacementSeed(NativeWorld level) {
if (seedOverride != Long.MIN_VALUE) {
return;
}
OptionalLong replacementSeed = ModdedDimensionReplacements.loadedSeed(level.name());
if (replacementSeed.isPresent()) {
seedOverride = replacementSeed.getAsLong();
}
}
private void recordBindFailure(NativeWorld level, Throwable error) {
// A runtime that is stopping says nothing about the world: fail this call, let the next bind try again.
if (GenerationFailures.isShutdownFailure(error)) {
@@ -194,6 +194,11 @@ public final class ModdedDimensionRegistryStore {
write(file, dimensions, List.of());
}
static synchronized void writeWorldRoot(Path worldRoot, List<PersistentDimension> dimensions) {
Path file = storeFile(worldRoot);
write(file, dimensions, contents(file).unparsed());
}
private static void write(Path file, List<PersistentDimension> dimensions, List<Object> unparsed) {
JSONArray entries = new JSONArray();
for (PersistentDimension dimension : dimensions) {
@@ -0,0 +1,144 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.volmlib.util.json.JSONObject;
import java.nio.file.Path;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.regex.Pattern;
public record ModdedDimensionReplacement(String levelKey, String pack, String packDimension, OptionalLong seed) {
private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+");
private static final Pattern PATH = Pattern.compile("[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*");
private static final Pattern PACK = Pattern.compile("[A-Za-z0-9_.-]+");
private static final Pattern PACK_DIMENSION = Pattern.compile("[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*");
private static final String TYPE = "type";
private static final String SEED = "seed";
public ModdedDimensionReplacement {
Objects.requireNonNull(levelKey, "levelKey");
Objects.requireNonNull(pack, "pack");
Objects.requireNonNull(packDimension, "packDimension");
Objects.requireNonNull(seed, "seed");
}
public static ModdedDimensionReplacement parse(String levelKey, String type, OptionalLong seed) {
String level = requireLevelKey(levelKey);
String reference = type == null ? "" : type.trim();
int packColon = reference.indexOf(':');
String pack = packColon >= 0 ? reference.substring(0, packColon) : reference;
String packDimension = packColon >= 0 ? reference.substring(packColon + 1) : reference;
if (!PACK.matcher(pack).matches()
|| !PACK_DIMENSION.matcher(packDimension).matches()
|| pack.contains("..")
|| packDimension.contains("..")) {
throw new IllegalArgumentException("Invalid replacement type '" + type
+ "' for " + level + ". Use pack or pack:dimensionKey.");
}
OptionalLong replacementSeed = Objects.requireNonNull(seed, "seed");
if (replacementSeed.isPresent()) {
requireUsableSeed(replacementSeed.getAsLong());
}
return new ModdedDimensionReplacement(level, pack, packDimension, replacementSeed);
}
public static long requireUsableSeed(long seed) {
if (seed == Long.MIN_VALUE) {
throw new IllegalArgumentException("Seed " + Long.MIN_VALUE
+ " is reserved on mod loaders; choose any other signed 64-bit integer");
}
return seed;
}
public static ModdedDimensionReplacement fromJson(String levelKey, Object value) {
if (!(value instanceof JSONObject entry)) {
throw new IllegalArgumentException("the replacement must be an object with a \"type\" and an optional \"seed\"");
}
Object type = entry.opt(TYPE);
if (!(type instanceof String typeRef)) {
throw new IllegalArgumentException("the replacement \"type\" must be a pack or pack:dimensionKey string");
}
if (!entry.has(SEED) || entry.isNull(SEED)) {
return parse(levelKey, typeRef, OptionalLong.empty());
}
Object seed = entry.opt(SEED);
if (!(seed instanceof Integer) && !(seed instanceof Long)) {
throw new IllegalArgumentException("the replacement \"seed\" must be a signed 64-bit integer");
}
return parse(levelKey, typeRef, OptionalLong.of(((Number) seed).longValue()));
}
public JSONObject toJson() {
JSONObject json = new JSONObject();
json.put(TYPE, packRef());
if (seed.isPresent()) {
json.put(SEED, seed.getAsLong());
}
return json;
}
public String packRef() {
return pack.equals(packDimension) ? pack : pack + ":" + packDimension;
}
public ModdedDimensionReplacement withSeed(long replacementSeed) {
return new ModdedDimensionReplacement(levelKey, pack, packDimension, OptionalLong.of(replacementSeed));
}
public ModdedDimensionReplacement withPack(String canonicalPack, String canonicalPackDimension) {
return new ModdedDimensionReplacement(levelKey, canonicalPack, canonicalPackDimension, seed);
}
public String namespace() {
return levelKey.substring(0, levelKey.indexOf(':'));
}
public String path() {
return levelKey.substring(levelKey.indexOf(':') + 1);
}
public Path storageFolder(Path worldRoot) {
return storageFolder(worldRoot, levelKey);
}
public static Path storageFolder(Path worldRoot, String levelKey) {
String level = requireLevelKey(levelKey);
int colon = level.indexOf(':');
Path folder = worldRoot.resolve("dimensions").resolve(level.substring(0, colon));
for (String segment : level.substring(colon + 1).split("/")) {
folder = folder.resolve(segment);
}
return folder;
}
public static String requireLevelKey(String levelKey) {
String level = levelKey == null ? "" : levelKey.trim();
int colon = level.indexOf(':');
if (colon <= 0
|| !NAMESPACE.matcher(level.substring(0, colon)).matches()
|| !PATH.matcher(level.substring(colon + 1)).matches()
|| level.contains("..")) {
throw new IllegalArgumentException("Invalid dimension key '" + levelKey
+ "'. Use namespace:path, for example examplemod:skylands.");
}
return level;
}
}
@@ -0,0 +1,206 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
public final class ModdedDimensionReplacements {
private static final String GENERATOR_TYPE = "irisworldgen:iris";
private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed";
private static final String PLACEHOLDER_BIOME = "minecraft:plains";
private static final String DIMENSIONS = "dimensions";
private static final String JSON = ".json";
private ModdedDimensionReplacements() {
}
public static String levelStemJson(String dimensionTypeKey, ModdedDimensionReplacement replacement) {
JSONObject biomeSource = new JSONObject();
biomeSource.put("type", PLACEHOLDER_BIOME_SOURCE);
biomeSource.put("biome", PLACEHOLDER_BIOME);
JSONObject generator = new JSONObject();
generator.put("type", GENERATOR_TYPE);
generator.put("biome_source", biomeSource);
generator.put("dimension", replacement.packRef());
JSONObject stem = new JSONObject();
stem.put("type", Objects.requireNonNull(dimensionTypeKey, "dimensionTypeKey"));
stem.put("generator", generator);
return stem.toString(2) + "\n";
}
public static Path writeLevelStem(Path datapackRoot, ModdedDimensionReplacement replacement, String dimensionTypeKey)
throws IOException {
Path output = datapackRoot.resolve("data").resolve(replacement.namespace()).resolve("dimension");
String[] segments = replacement.path().split("/");
for (int index = 0; index < segments.length - 1; index++) {
output = output.resolve(segments[index]);
}
output = output.resolve(segments[segments.length - 1] + ".json");
Files.createDirectories(output.getParent());
Files.writeString(output, levelStemJson(dimensionTypeKey, replacement), StandardCharsets.UTF_8);
return output;
}
public static void writeLevelStems(Path datapackRoot, Map<String, ModdedDimensionReplacement> configured,
StagedTypes staged, Path worldRoot) throws IOException {
for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) {
String typeRef = staged.typeRef(replacement, worldRoot);
if (typeRef == null) {
skipUnstaged(replacement, staged, worldRoot);
continue;
}
writeLevelStem(datapackRoot, replacement, typeRef);
ModdedIrisLog.info("Iris dimension replacement staged: {} generates from {} with dimension type {}",
replacement.levelKey(), replacement.packRef(), typeRef);
}
}
public static Map<String, ModdedDimensionReplacement> configured() throws IOException {
return canonical(ModdedModConfig.get().dimensionReplacements(), IrisPlatforms.get().packsFolderNoCreate().toPath());
}
public static OptionalLong loadedSeed(String levelKey) {
ModdedModConfig config = ModdedModConfig.loaded();
if (config == null) {
return OptionalLong.empty();
}
ModdedDimensionReplacement replacement = config.dimensionReplacements().get(levelKey);
return replacement == null ? OptionalLong.empty() : replacement.seed();
}
static Map<String, ModdedDimensionReplacement> canonical(Map<String, ModdedDimensionReplacement> configured,
Path packsRoot) throws IOException {
Map<String, ModdedDimensionReplacement> canonical = new TreeMap<>();
for (ModdedDimensionReplacement replacement : configured.values()) {
canonical.put(replacement.levelKey(), canonical(replacement, packsRoot));
}
return Map.copyOf(canonical);
}
public static ModdedDimensionReplacement canonical(ModdedDimensionReplacement replacement, Path packsRoot)
throws IOException {
Path pack = packsRoot.resolve(replacement.pack());
if (!Files.isDirectory(pack)) {
return replacement;
}
String packName = sameIgnoringCase(pack.toRealPath().getFileName().toString(), replacement.pack());
Path dimensions = pack.resolve(DIMENSIONS);
Path dimensionFile = dimensions.resolve(replacement.packDimension() + JSON);
String dimension = replacement.packDimension();
if (Files.isRegularFile(dimensionFile)) {
String relative = dimensions.toRealPath().relativize(dimensionFile.toRealPath()).toString()
.replace(File.separatorChar, '/');
dimension = sameIgnoringCase(relative.substring(0, relative.length() - JSON.length()), replacement.packDimension());
}
return replacement.withPack(packName, dimension);
}
public static boolean installed(ModdedDimensionReplacement replacement, Path packsRoot) {
return Files.isRegularFile(packsRoot.resolve(replacement.pack()).resolve(DIMENSIONS)
.resolve(replacement.packDimension() + JSON));
}
public static boolean matches(ModdedDimensionReplacement applied, ModdedDimensionReplacement configured) {
if (applied == null || configured == null) {
return applied == configured;
}
if (!applied.packRef().equals(configured.packRef())) {
return false;
}
return configured.seed().isEmpty() || configured.seed().equals(applied.seed());
}
public static Set<String> changedLevels(Map<String, ModdedDimensionReplacement> applied,
Map<String, ModdedDimensionReplacement> configured) {
Set<String> levels = new TreeSet<>(applied.keySet());
levels.addAll(configured.keySet());
Set<String> changed = new TreeSet<>();
for (String level : levels) {
if (!matches(applied.get(level), configured.get(level))) {
changed.add(level);
}
}
return changed;
}
private static void skipUnstaged(ModdedDimensionReplacement replacement, StagedTypes staged, Path worldRoot)
throws IOException {
String reason = "pack '" + replacement.pack() + "' dimension '" + replacement.packDimension()
+ "' is not installed or failed validation" + caseHint(replacement, staged);
if (worldRoot != null && ModdedReplacementReconciler.hasReplacementRecord(worldRoot, replacement.levelKey())) {
throw new IOException("Iris refused to stage the replacement of " + replacement.levelKey() + ": " + reason
+ ", and the level already has an applied replacement or Iris generation history. Restore the pack, or remove the "
+ replacement.levelKey() + " entry from dimensionReplacements in modded.json to hand the level back to its own generator.");
}
ModdedIrisLog.error("Iris skipped the replacement of {}: {}, so the dimension keeps its own generator and the replacement stays unapplied",
replacement.levelKey(), reason);
}
private static String caseHint(ModdedDimensionReplacement replacement, StagedTypes staged) {
String wanted = replacement.pack() + ":" + replacement.packDimension();
for (String installed : staged.installed().keySet()) {
if (installed.equalsIgnoreCase(wanted)) {
return "; pack names and dimension keys are case-sensitive, the installed dimension is " + installed;
}
}
return "";
}
private static String sameIgnoringCase(String actual, String configured) {
return actual.equalsIgnoreCase(configured) ? actual : configured;
}
public static String fingerprint(Map<String, ModdedDimensionReplacement> configured) {
StringBuilder canonical = new StringBuilder();
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(configured).entrySet()) {
canonical.append(entry.getKey()).append('=').append(entry.getValue().packRef()).append('\n');
}
return canonical.toString();
}
public record StagedTypes(Map<String, String> installed, Map<Path, String> retained) {
public static StagedTypes create() {
return new StagedTypes(new LinkedHashMap<>(), new LinkedHashMap<>());
}
String typeRef(ModdedDimensionReplacement replacement, Path worldRoot) {
if (worldRoot != null) {
String retainedType = retained.get(replacement.storageFolder(worldRoot).toAbsolutePath().normalize());
if (retainedType != null) {
return retainedType;
}
}
return installed.get(replacement.pack() + ":" + replacement.packDimension());
}
}
}
@@ -1,84 +0,0 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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;
}
}
@@ -1,197 +0,0 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
public final class ModdedDimensionTakeovers {
private static final String GENERATOR_TYPE = "irisworldgen:iris";
private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed";
private static final String PLACEHOLDER_BIOME = "minecraft:plains";
private static final String DIMENSIONS = "dimensions";
private static final String JSON = ".json";
private ModdedDimensionTakeovers() {
}
public static List<ModdedDimensionTakeover> parseAll(Map<String, String> configured) {
List<ModdedDimensionTakeover> takeovers = new ArrayList<>(configured.size());
for (Map.Entry<String, String> 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<String, String> configured, StagedTypes staged,
Path worldRoot) throws IOException {
for (ModdedDimensionTakeover takeover : parseAll(configured)) {
String typeRef = staged.typeRef(takeover, worldRoot);
if (typeRef == null) {
skipUnstaged(takeover, staged, worldRoot);
continue;
}
writeLevelStem(datapackRoot, takeover, typeRef);
ModdedIrisLog.info("Iris dimension takeover staged: {} generates from {} with dimension type {}",
takeover.levelKey(), takeover.packRef(), typeRef);
}
}
public static Map<String, String> configured() throws IOException {
return canonical(ModdedModConfig.get().dimensionTakeovers(), IrisPlatforms.get().packsFolderNoCreate().toPath());
}
static Map<String, String> canonical(Map<String, String> configured, Path packsRoot) throws IOException {
Map<String, String> canonical = new TreeMap<>();
for (ModdedDimensionTakeover takeover : parseAll(configured)) {
canonical.put(takeover.levelKey(), canonical(takeover, packsRoot).packRef());
}
return Map.copyOf(canonical);
}
public static ModdedDimensionTakeover canonical(ModdedDimensionTakeover takeover, Path packsRoot) throws IOException {
Path pack = packsRoot.resolve(takeover.pack());
if (!Files.isDirectory(pack)) {
return takeover;
}
String packName = sameIgnoringCase(pack.toRealPath().getFileName().toString(), takeover.pack());
Path dimensions = pack.resolve(DIMENSIONS);
Path dimensionFile = dimensions.resolve(takeover.packDimension() + JSON);
String dimension = takeover.packDimension();
if (Files.isRegularFile(dimensionFile)) {
String relative = dimensions.toRealPath().relativize(dimensionFile.toRealPath()).toString()
.replace(File.separatorChar, '/');
dimension = sameIgnoringCase(relative.substring(0, relative.length() - JSON.length()), takeover.packDimension());
}
return new ModdedDimensionTakeover(takeover.levelKey(), packName, dimension);
}
public static boolean installed(ModdedDimensionTakeover takeover, Path packsRoot) {
return Files.isRegularFile(packsRoot.resolve(takeover.pack()).resolve(DIMENSIONS)
.resolve(takeover.packDimension() + JSON));
}
public static Set<String> changedLevels(Map<String, String> applied, Map<String, String> configured) {
Set<String> levels = new TreeSet<>(applied.keySet());
levels.addAll(configured.keySet());
Set<String> changed = new TreeSet<>();
for (String level : levels) {
if (!Objects.equals(applied.get(level), configured.get(level))) {
changed.add(level);
}
}
return changed;
}
private static void skipUnstaged(ModdedDimensionTakeover takeover, StagedTypes staged, Path worldRoot) throws IOException {
String reason = "pack '" + takeover.pack() + "' dimension '" + takeover.packDimension()
+ "' is not installed or failed validation" + caseHint(takeover, staged);
if (worldRoot != null && ModdedTakeoverReconciler.hasTakeoverRecord(worldRoot, takeover.levelKey())) {
throw new IOException("Iris refused to stage the dimension takeover of " + takeover.levelKey() + ": " + reason
+ ", and the level already has an applied takeover or Iris generation history. Restore the pack, or run /iris world takeover "
+ takeover.levelKey() + " off to hand the level back to its own generator.");
}
ModdedIrisLog.error("Iris skipped the dimension takeover of {}: {}, so the dimension keeps its own generator and the takeover stays unapplied",
takeover.levelKey(), reason);
}
private static String caseHint(ModdedDimensionTakeover takeover, StagedTypes staged) {
String wanted = takeover.pack() + ":" + takeover.packDimension();
for (String installed : staged.installed().keySet()) {
if (installed.equalsIgnoreCase(wanted)) {
return "; pack names and dimension keys are case-sensitive, the installed dimension is " + installed;
}
}
return "";
}
private static String sameIgnoringCase(String actual, String configured) {
return actual.equalsIgnoreCase(configured) ? actual : configured;
}
public static String fingerprint(Map<String, String> configured) {
StringBuilder canonical = new StringBuilder();
for (Map.Entry<String, String> entry : new TreeMap<>(configured).entrySet()) {
canonical.append(entry.getKey()).append('=').append(entry.getValue()).append('\n');
}
return canonical.toString();
}
public record StagedTypes(Map<String, String> installed, Map<Path, String> retained) {
public static StagedTypes create() {
return new StagedTypes(new LinkedHashMap<>(), new LinkedHashMap<>());
}
String typeRef(ModdedDimensionTakeover takeover, Path worldRoot) {
if (worldRoot != null) {
String retainedType = retained.get(takeover.storageFolder(worldRoot).toAbsolutePath().normalize());
if (retainedType != null) {
return retainedType;
}
}
return installed.get(takeover.pack() + ":" + takeover.packDimension());
}
}
}
@@ -172,9 +172,11 @@ public final class ModdedEngineBootstrap {
server.refreshWorlds();
bindWorldGenerators(server);
ModdedStartup.runOnce(server);
reconcileSpawn(server);
boolean replacedOverworld = !loader().clientEnvironment() && ModdedReplacementEntry.prepare(server);
reconcileSpawn(server, replacedOverworld);
if (!loader().clientEnvironment()) {
ModdedTakeoverReconciler.recordApplied(server);
ModdedReplacementReconciler.recordApplied(server);
ModdedReplacementEntry.retireIfComplete();
}
ModdedWorldCheck.serverStarted(server);
ModdedWorldgenCatalogs.capture(server);
@@ -275,6 +277,7 @@ public final class ModdedEngineBootstrap {
Throwable failure = null;
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
failure = runStopStage(failure, "replacement entry guard", ModdedReplacementEntry::clear);
failure = runStopStage(failure, "dimension manager", ModdedDimensionManager::clear);
ModdedScheduler scheduler = schedulerOrNull();
if (scheduler != null) {
@@ -330,7 +333,7 @@ public final class ModdedEngineBootstrap {
spawnCaptureServer = server;
}
private static void reconcileSpawn(NativeModdedServer server) {
private static void reconcileSpawn(NativeModdedServer server, boolean replacedOverworld) {
NativeServerSpawn spawn = new NativeServerSpawn(server);
NativeServerSpawn.Respawn current = spawn.current();
if (current == null || current.world() == null) {
@@ -342,7 +345,8 @@ public final class ModdedEngineBootstrap {
}
String dimensionId = level.name();
boolean studio = dimensionId.startsWith("irisworldgen:studio_");
if (!shouldReconcileSpawn(initialSpawnWasDefault, studio, current.position().x(), current.position().z())) {
if (!shouldReconcileSpawn(initialSpawnWasDefault || replacedOverworld, studio,
current.position().x(), current.position().z())) {
return;
}
int surfaceY = spawn.surfaceAtOrigin(level);
@@ -373,7 +377,7 @@ public final class ModdedEngineBootstrap {
bind();
IrisLanguage.initialize();
if (!moddedLoader.clientEnvironment()) {
ModdedTakeoverReconciler.reconcileEarly();
ModdedReplacementReconciler.reconcileEarly();
}
ModdedStartup.prefetchStartupDatapack();
if (!moddedLoader.clientEnvironment()) {
@@ -324,7 +324,7 @@ public final class ModdedForcedDatapack {
false
);
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
digest.update(ModdedDimensionTakeovers.fingerprint(ModdedDimensionTakeovers.configured())
digest.update(ModdedDimensionReplacements.fingerprint(ModdedDimensionReplacements.configured())
.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest.digest());
}
@@ -335,7 +335,7 @@ public final class ModdedForcedDatapack {
int packCount = 0;
KList<String> presetIds = new KList<>();
ModdedDimensionTakeovers.StagedTypes stagedDimensionTypes = ModdedDimensionTakeovers.StagedTypes.create();
ModdedDimensionReplacements.StagedTypes stagedDimensionTypes = ModdedDimensionReplacements.StagedTypes.create();
Path worldRoot = worldRootOrNull();
Set<PackSelection> legacySelections = unadoptedSelections(worldRoot);
List<File> packRoots = compilationPackRoots(worldRoot);
@@ -346,8 +346,8 @@ public final class ModdedForcedDatapack {
}
}
if (!ModdedEngineBootstrap.loader().clientEnvironment()) {
ModdedDimensionTakeovers.writeLevelStems(stagingDirectory,
ModdedDimensionTakeovers.configured(), stagedDimensionTypes, worldRoot);
ModdedDimensionReplacements.writeLevelStems(stagingDirectory,
ModdedDimensionReplacements.configured(), stagedDimensionTypes, worldRoot);
}
IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer);
@@ -370,7 +370,7 @@ public final class ModdedForcedDatapack {
private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory,
Map<String, KSet<String>> seenBiomes,
KList<String> presetIds,
ModdedDimensionTakeovers.StagedTypes stagedDimensionTypes) throws IOException {
ModdedDimensionReplacements.StagedTypes stagedDimensionTypes) throws IOException {
File sourcePack = context.packRoot().toFile();
PackValidationResult validation;
try {
@@ -400,7 +400,7 @@ public final class ModdedForcedDatapack {
stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-");
Map<String, KSet<String>> packBiomes = new LinkedHashMap<>();
KList<String> packPresetIds = new KList<>();
ModdedDimensionTakeovers.StagedTypes packDimensionTypes = ModdedDimensionTakeovers.StagedTypes.create();
ModdedDimensionReplacements.StagedTypes packDimensionTypes = ModdedDimensionReplacements.StagedTypes.create();
KList<File> packFolders = new KList<>();
packFolders.add(packStagingDirectory.toFile());
boolean installed;
@@ -484,7 +484,7 @@ public final class ModdedForcedDatapack {
private static boolean installPack(PackStageContext context, IDataFixer fixer, KList<File> folders,
Map<String, KSet<String>> seenBiomes,
KList<String> presetIds,
ModdedDimensionTakeovers.StagedTypes dimensionTypes) throws IOException {
ModdedDimensionReplacements.StagedTypes dimensionTypes) throws IOException {
File packFolder = context.packRoot().toFile();
String packName = context.packName();
File dimensionsDirectory = new File(packFolder, "dimensions");
@@ -32,7 +32,7 @@ import java.util.TreeSet;
public final class ModdedModConfig {
private static final Object LOCK = new Object();
private static final String DIMENSION_TAKEOVERS = "dimensionTakeovers";
private static final String DIMENSION_REPLACEMENTS = "dimensionReplacements";
private static volatile ModdedModConfig instance;
private final String defaultPack;
@@ -41,20 +41,20 @@ public final class ModdedModConfig {
private final String mainWorldPack;
private final long mainWorldSeed;
private final boolean mainWorldAutoRestart;
private final Map<String, String> dimensionTakeovers;
private final List<String> takeoverProblems;
private final Map<String, ModdedDimensionReplacement> dimensionReplacements;
private final List<String> replacementProblems;
private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld,
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart,
Takeovers takeovers) {
Replacements replacements) {
this.defaultPack = defaultPack;
this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim();
this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld;
this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim();
this.mainWorldSeed = mainWorldSeed;
this.mainWorldAutoRestart = mainWorldAutoRestart;
this.dimensionTakeovers = Map.copyOf(takeovers.entries());
this.takeoverProblems = List.copyOf(takeovers.problems());
this.dimensionReplacements = Map.copyOf(replacements.entries());
this.replacementProblems = List.copyOf(replacements.problems());
}
public static ModdedModConfig get() {
@@ -71,11 +71,15 @@ public final class ModdedModConfig {
}
}
public static ModdedModConfig loaded() {
return instance;
}
public static void setPrimaryWorld(String dimensionId) {
synchronized (LOCK) {
ModdedModConfig current = writable();
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld,
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.takeovers());
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.replacements());
instance = updated;
write(configFile(), updated);
}
@@ -85,18 +89,18 @@ public final class ModdedModConfig {
synchronized (LOCK) {
ModdedModConfig current = writable();
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.takeovers());
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.replacements());
instance = updated;
write(configFile(), updated);
}
}
public static boolean setDimensionTakeover(ModdedDimensionTakeover takeover) {
return updateDimensionTakeover(takeover.levelKey(), takeover.packRef());
public static boolean setDimensionReplacement(ModdedDimensionReplacement replacement) {
return updateDimensionReplacement(replacement.levelKey(), replacement);
}
public static boolean clearDimensionTakeover(String levelKey) {
return updateDimensionTakeover(levelKey, null);
public static boolean clearDimensionReplacement(String levelKey) {
return updateDimensionReplacement(levelKey, null);
}
public String defaultPack() {
@@ -123,12 +127,12 @@ public final class ModdedModConfig {
return mainWorldAutoRestart;
}
public Map<String, String> dimensionTakeovers() {
return dimensionTakeovers;
public Map<String, ModdedDimensionReplacement> dimensionReplacements() {
return dimensionReplacements;
}
public List<String> takeoverProblems() {
return takeoverProblems;
public List<String> replacementProblems() {
return replacementProblems;
}
static ModdedModConfig read(Path file) {
@@ -140,7 +144,7 @@ public final class ModdedModConfig {
ModdedIrisLog.error("Iris modded config at {} is unreadable; using defaults", file, unreadable);
return new ModdedModConfig(defaults.defaultPack, defaults.primaryWorld, defaults.routePlayersToPrimaryWorld,
defaults.mainWorldPack, defaults.mainWorldSeed, defaults.mainWorldAutoRestart,
new Takeovers(Map.of(), List.of("Iris could not read " + file + ": " + unreadable.getMessage())));
new Replacements(Map.of(), List.of("Iris could not read " + file + ": " + unreadable.getMessage())));
}
return new ModdedModConfig(
json.optString("defaultPack", defaults.defaultPack),
@@ -149,37 +153,39 @@ public final class ModdedModConfig {
json.optString("mainWorldPack", defaults.mainWorldPack),
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
readDimensionTakeovers(json, file));
readDimensionReplacements(json, file));
}
private Takeovers takeovers() {
return new Takeovers(dimensionTakeovers, takeoverProblems);
private Replacements replacements() {
return new Replacements(dimensionReplacements, replacementProblems);
}
private static ModdedModConfig writable() {
ModdedModConfig current = get();
if (!current.takeoverProblems.isEmpty()) {
if (!current.replacementProblems.isEmpty()) {
throw new IllegalStateException("Iris will not rewrite " + configFile()
+ " while its dimension takeovers are unreadable: " + String.join("; ", current.takeoverProblems)
+ " while its dimension replacements are unreadable: " + String.join("; ", current.replacementProblems)
+ ". Fix the file and restart first.");
}
return current;
}
private static ModdedModConfig defaults() {
return new ModdedModConfig("overworld", "", true, "", 0L, false, new Takeovers(Map.of(), List.of()));
return new ModdedModConfig("overworld", "", true, "", 0L, false, new Replacements(Map.of(), List.of()));
}
private static boolean updateDimensionTakeover(String levelKey, String packRef) {
private static boolean updateDimensionReplacement(String levelKey, ModdedDimensionReplacement replacement) {
synchronized (LOCK) {
ModdedModConfig current = writable();
Map<String, String> takeovers = new TreeMap<>(current.dimensionTakeovers);
String previous = packRef == null ? takeovers.remove(levelKey) : takeovers.put(levelKey, packRef);
if (packRef == null ? previous == null : packRef.equals(previous)) {
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>(current.dimensionReplacements);
ModdedDimensionReplacement previous = replacement == null
? replacements.remove(levelKey)
: replacements.put(levelKey, replacement);
if (replacement == null ? previous == null : replacement.equals(previous)) {
return false;
}
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Takeovers(takeovers, List.of()));
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Replacements(replacements, List.of()));
instance = updated;
write(configFile(), updated);
return true;
@@ -200,33 +206,29 @@ public final class ModdedModConfig {
return read(file);
}
private static Takeovers readDimensionTakeovers(JSONObject json, Path file) {
if (!json.has(DIMENSION_TAKEOVERS) || json.isNull(DIMENSION_TAKEOVERS)) {
return new Takeovers(Map.of(), List.of());
private static Replacements readDimensionReplacements(JSONObject json, Path file) {
if (!json.has(DIMENSION_REPLACEMENTS) || json.isNull(DIMENSION_REPLACEMENTS)) {
return new Replacements(Map.of(), List.of());
}
JSONObject section = json.optJSONObject(DIMENSION_TAKEOVERS);
JSONObject section = json.optJSONObject(DIMENSION_REPLACEMENTS);
if (section == null) {
return new Takeovers(Map.of(), List.of("Iris could not read " + DIMENSION_TAKEOVERS + " in " + file
+ ": it must be an object mapping dimension keys to pack references"));
return new Replacements(Map.of(), List.of("Iris could not read " + DIMENSION_REPLACEMENTS + " in " + file
+ ": it must be an object mapping dimension keys to replacements"));
}
Map<String, String> takeovers = new TreeMap<>();
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>();
List<String> problems = new ArrayList<>();
for (String levelKey : new TreeSet<>(section.keySet())) {
Object value = section.opt(levelKey);
try {
if (!(value instanceof String packRef)) {
throw new IllegalArgumentException("the pack reference must be a string");
}
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, packRef);
takeovers.put(takeover.levelKey(), takeover.packRef());
ModdedDimensionReplacement replacement = ModdedDimensionReplacement.fromJson(levelKey, section.opt(levelKey));
replacements.put(replacement.levelKey(), replacement);
} catch (IllegalArgumentException invalid) {
problems.add("Iris could not read dimension takeover '" + levelKey + "' in " + file + ": " + invalid.getMessage());
problems.add("Iris could not read dimension replacement '" + levelKey + "' in " + file + ": " + invalid.getMessage());
}
}
for (String problem : problems) {
ModdedIrisLog.error(problem);
}
return new Takeovers(takeovers, problems);
return new Replacements(replacements, problems);
}
private static void write(Path file, ModdedModConfig config) {
@@ -237,11 +239,11 @@ public final class ModdedModConfig {
json.put("mainWorldPack", config.mainWorldPack);
json.put("mainWorldSeed", config.mainWorldSeed);
json.put("mainWorldAutoRestart", config.mainWorldAutoRestart);
JSONObject takeovers = new JSONObject();
for (Map.Entry<String, String> entry : new TreeMap<>(config.dimensionTakeovers).entrySet()) {
takeovers.put(entry.getKey(), entry.getValue());
JSONObject replacements = new JSONObject();
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(config.dimensionReplacements).entrySet()) {
replacements.put(entry.getKey(), entry.getValue().toJson());
}
json.put(DIMENSION_TAKEOVERS, takeovers);
json.put(DIMENSION_REPLACEMENTS, replacements);
try {
Files.createDirectories(file.getParent());
Files.writeString(file, json.toString(4), StandardCharsets.UTF_8);
@@ -250,6 +252,6 @@ public final class ModdedModConfig {
}
}
private record Takeovers(Map<String, String> entries, List<String> problems) {
private record Replacements(Map<String, ModdedDimensionReplacement> entries, List<String> problems) {
}
}
@@ -127,6 +127,7 @@ public final class ModdedProtocolHandler {
if (player == null || ModdedPrimaryWorldRouter.refuseIfUnavailable(player)) {
return;
}
ModdedReplacementEntry.onPlayerJoin(ModdedEngineBootstrap.currentServer(), player);
String sessionId = sessionId(player);
ModdedStartup.warnStartupFailuresTo(player);
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
@@ -0,0 +1,150 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeDimensionRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeServerSpawn;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
public final class ModdedReplacementEntry {
private static final Set<UUID> PENDING = ConcurrentHashMap.newKeySet();
private static volatile Path guardedWorld;
private static volatile UUID transaction;
private ModdedReplacementEntry() {
}
public static boolean prepare(NativeModdedServer server) {
clear();
Path overworld = ModdedDimensionReplacement.storageFolder(server.root(), ModdedReplacementReconciler.OVERWORLD);
Optional<WorldReplacementEntryGuard.Entry> entry;
try {
entry = WorldReplacementEntryGuard.load(overworld);
} catch (IOException failure) {
ModdedIrisLog.error("Iris could not read the Overworld replacement entry marker under {}", overworld, failure);
return false;
}
if (entry.isEmpty()) {
return false;
}
PENDING.addAll(entry.get().pendingPlayers());
transaction = entry.get().transactionId();
guardedWorld = overworld;
ModdedIrisLog.info("Iris replaced the Overworld; {} returning player(s) are checked for a safe position on their next join",
PENDING.size());
return true;
}
public static void retireIfComplete() {
Path world = guardedWorld;
UUID current = transaction;
if (world == null || current == null || !PENDING.isEmpty()) {
return;
}
CompletableFuture.runAsync(() -> retire(world, current));
}
public static void onPlayerJoin(NativeModdedServer server, NativeProtocolPlayer player) {
if (guardedWorld == null || server == null || player == null || !PENDING.contains(player.id())) {
return;
}
NativeWorld overworld = server.overworld();
if (overworld == null || !player.isInWorld(overworld) || standingSafely(overworld, player)) {
complete(player.id());
return;
}
NativeServerSpawn.Respawn spawn = new NativeServerSpawn(server).current();
boolean overworldSpawn = spawn != null && NativeDimensionRuntime.sameWorld(spawn.world(), overworld);
double x = overworldSpawn ? spawn.position().x() + 0.5D : 0.5D;
double z = overworldSpawn ? spawn.position().z() + 0.5D : 0.5D;
UUID playerId = player.id();
NativeWorldTeleport.teleport(player, new NativeWorldTeleport.Destination(server, overworld, x, Double.MIN_VALUE, z,
ModdedTeleportDeadline.fromNow()))
.whenComplete((Boolean moved, Throwable failure) -> {
if (failure != null || !Boolean.TRUE.equals(moved)) {
ModdedIrisLog.error("Iris could not move {} out of the replaced Overworld terrain to the spawn surface",
playerId, failure);
return;
}
ModdedIrisLog.info("Iris moved {} from an unsafe position in the replaced Overworld to the spawn surface",
playerId);
complete(playerId);
});
}
public static void clear() {
PENDING.clear();
guardedWorld = null;
transaction = null;
}
static boolean standingSafely(NativeWorld world, NativeProtocolPlayer player) {
int y = player.blockY();
if (y < world.minHeight() || y + 1 >= world.maxHeight()) {
return false;
}
return !world.getBlock(player.blockX(), y, player.blockZ()).isSolid()
&& !world.getBlock(player.blockX(), y + 1, player.blockZ()).isSolid();
}
private static void complete(UUID playerId) {
Path world = guardedWorld;
UUID current = transaction;
if (world == null || current == null || !PENDING.remove(playerId)) {
return;
}
CompletableFuture.runAsync(() -> persistCompletion(world, current, playerId));
}
private static synchronized void persistCompletion(Path world, UUID current, UUID playerId) {
try {
Optional<WorldReplacementEntryGuard.Entry> updated = WorldReplacementEntryGuard.completePlayer(world, current, playerId);
if (updated.isPresent() && updated.get().pendingPlayers().isEmpty()) {
retire(world, current);
}
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris could not record the safe Overworld replacement entry of {}", playerId, failure);
}
}
private static synchronized void retire(Path world, UUID current) {
try {
if (WorldReplacementEntryGuard.retireIfEmpty(world, current)) {
ModdedIrisLog.info("Iris retired the Overworld replacement entry marker: every known player entered safely");
if (world.equals(guardedWorld)) {
clear();
}
}
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris could not retire the Overworld replacement entry marker under {}", world, failure);
}
}
}
@@ -0,0 +1,417 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.UUID;
import java.util.function.Predicate;
public final class ModdedReplacementReconciler {
public static final String OVERWORLD = "minecraft:overworld";
private static final String STATE_FILE = "data/irisworldgen/dimension-replacements.json";
private static final String APPLIED = "replacements";
private static final String PENDING = "pending";
private static final String IRIS_WORLD_NAMESPACE = "irisworldgen:";
private ModdedReplacementReconciler() {
}
public static void reconcileEarly() {
try {
ModdedModConfig config = ModdedModConfig.get();
Map<String, ModdedDimensionReplacement> configured = ModdedDimensionReplacements.configured();
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
if (worldRoot == null) {
if (!configured.isEmpty()) {
ModdedIrisLog.info("Iris dimension replacements {} apply to a new world; nothing to move aside",
configured.keySet());
}
return;
}
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath();
Result result = reconcile(new Request(worldRoot, configured, config.replacementProblems(),
(ModdedDimensionReplacement replacement) -> ModdedDimensionReplacements.installed(replacement, packsRoot),
recoveryParent));
if (!result.adoptedLevels().isEmpty()) {
ModdedIrisLog.info("Iris dimension replacement recorded for {}: their existing Iris generation history already uses the configured pack and seed",
result.adoptedLevels());
}
if (!result.replacedWorlds().isEmpty()) {
ModdedIrisLog.warn("Iris replaced the Iris worlds {} from their staged packs", result.replacedWorlds());
}
if (result.quarantinedLevels().isEmpty() && result.replacedWorlds().isEmpty()) {
return;
}
ModdedIrisLog.warn("Iris dimension replacement changed for {}: {}", movedLevels(result),
result.recovery() == null
? "no existing chunk data to move aside"
: "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery()
+ " so they regenerate (player data kept)");
} catch (Throwable failure) {
ModdedIrisLog.error("Iris dimension replacement reconciliation failed", failure);
throw new IllegalStateException("Iris refused startup after dimension replacement reconciliation failed", failure);
}
}
public static void recordApplied(NativeModdedServer server) {
Optional<Path> worldRoot = NativeWorldPaths.root(server);
if (worldRoot.isEmpty()) {
return;
}
try {
recordApplied(worldRoot.get(), ModdedDimensionReplacements.configured(),
(String levelKey, String packRef) -> generatedSeed(server, levelKey, packRef));
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris is stopping the server because it could not record the applied dimension replacements under {}; an unrecorded replacement would be moved aside again on the next start",
worldRoot.get(), failure);
server.halt(false);
}
}
static Map<String, ModdedDimensionReplacement> recordApplied(Path worldRoot,
Map<String, ModdedDimensionReplacement> configured,
GeneratedSeed generating) throws IOException {
State state = readState(worldRoot);
Map<String, ModdedDimensionReplacement> recorded = new TreeMap<>(state.applied());
for (ModdedDimensionReplacement replacement : new TreeMap<>(configured).values()) {
OptionalLong seed = generating.seed(replacement.levelKey(), replacement.packRef());
if (seed.isPresent() && (replacement.seed().isEmpty() || replacement.seed().equals(seed))) {
recorded.put(replacement.levelKey(), replacement.withSeed(seed.getAsLong()));
continue;
}
ModdedIrisLog.warn("Iris replacement of {} is configured but the level does not generate from {}{}; it stays unapplied",
replacement.levelKey(), replacement.packRef(),
replacement.seed().isPresent() ? " with seed " + replacement.seed().getAsLong() : "");
}
if (!recorded.equals(state.applied())) {
writeState(worldRoot, new State(recorded, state.pending()));
}
return Map.copyOf(recorded);
}
static Result reconcile(Request request) throws IOException {
Path worldRoot = request.worldRoot();
requireReadableConfig(worldRoot, request.configProblems());
State state = readState(worldRoot);
Map<String, ModdedDimensionReplacement> applied = state.applied();
Set<String> refreshed = new TreeSet<>();
boolean irisWorldsPending = false;
for (String level : state.pending().keySet()) {
if (level.startsWith(IRIS_WORLD_NAMESPACE)) {
irisWorldsPending = true;
} else if (request.configured().containsKey(level)) {
refreshed.add(level);
}
}
Set<String> changed = new TreeSet<>(ModdedDimensionReplacements.changedLevels(applied, request.configured()));
changed.addAll(refreshed);
Map<String, ModdedDimensionReplacement> recorded = new TreeMap<>(applied);
Set<String> quarantined = new TreeSet<>();
Set<String> adopted = new TreeSet<>();
for (String level : changed) {
ModdedDimensionReplacement replacement = request.configured().get(level);
if (replacement == null) {
recorded.remove(level);
quarantined.add(level);
continue;
}
Path storage = replacement.storageFolder(worldRoot);
OptionalLong historySeed = refreshed.contains(level)
? OptionalLong.empty()
: matchingHistorySeed(storage, replacement);
if (historySeed.isPresent()) {
recorded.put(level, replacement.withSeed(historySeed.getAsLong()));
adopted.add(level);
} else if (request.installed().test(replacement)) {
quarantined.add(level);
} else {
refuseOrSkipUninstalled(worldRoot, applied, replacement);
}
}
List<ModdedDimensionRegistryStore.PersistentDimension> registry = irisWorldsPending
? ModdedDimensionRegistryStore.loadWorldRoot(worldRoot)
: List.of();
Map<String, ModdedDimensionReplacement> replacedWorlds = new TreeMap<>();
for (ModdedDimensionReplacement pending : state.pending().values()) {
if (!pending.levelKey().startsWith(IRIS_WORLD_NAMESPACE)) {
continue;
}
if (registered(registry, pending.levelKey())) {
replacedWorlds.put(pending.levelKey(), pending);
} else {
ModdedIrisLog.warn("Iris dropped the staged replacement of {}: the Iris world is no longer registered",
pending.levelKey());
}
}
List<Path> sources = new ArrayList<>();
for (String level : quarantined) {
addChunkData(sources, ModdedDimensionReplacement.storageFolder(worldRoot, level));
}
for (String level : replacedWorlds.keySet()) {
addChunkData(sources, ModdedDimensionReplacement.storageFolder(worldRoot, level));
}
Path recovery = request.recoveryParent().resolve("replacement-recovery-" + UUID.randomUUID());
List<Path> moved = sources.isEmpty() ? List.of() : ModdedWorldQuarantine.move(worldRoot, sources, recovery);
if (quarantined.contains(OVERWORLD)) {
WorldReplacementEntryGuard.stage(worldRoot, ModdedDimensionReplacement.storageFolder(worldRoot, OVERWORLD),
UUID.randomUUID());
}
if (!replacedWorlds.isEmpty()) {
ModdedDimensionRegistryStore.writeWorldRoot(worldRoot, repointed(registry, replacedWorlds));
}
State reconciled = new State(recorded, Map.of());
if (!reconciled.equals(state)) {
writeState(worldRoot, reconciled);
}
return new Result(Set.copyOf(quarantined), Set.copyOf(adopted), Set.copyOf(replacedWorlds.keySet()),
moved.isEmpty() ? null : recovery);
}
public static boolean pending(Path worldRoot, String levelKey, Map<String, ModdedDimensionReplacement> configured)
throws IOException {
State state = readState(worldRoot);
if (state.pending().containsKey(levelKey)) {
return true;
}
ModdedDimensionReplacement replacement = configured.get(levelKey);
return replacement != null && !ModdedDimensionReplacements.matches(state.applied().get(levelKey), replacement);
}
public static Optional<ModdedDimensionReplacement> applied(Path worldRoot, String levelKey) throws IOException {
return Optional.ofNullable(readState(worldRoot).applied().get(levelKey));
}
public static synchronized void stagePending(Path worldRoot, ModdedDimensionReplacement replacement) throws IOException {
if (replacement.seed().isEmpty()) {
throw new IllegalArgumentException("A staged replacement needs its effective seed: " + replacement.levelKey());
}
State state = readState(worldRoot);
Map<String, ModdedDimensionReplacement> pending = new TreeMap<>(state.pending());
pending.put(replacement.levelKey(), replacement);
writeState(worldRoot, new State(state.applied(), pending));
}
public static synchronized boolean cancelPending(Path worldRoot, String levelKey) throws IOException {
State state = readState(worldRoot);
Map<String, ModdedDimensionReplacement> pending = new TreeMap<>(state.pending());
if (pending.remove(levelKey) == null) {
return false;
}
writeState(worldRoot, new State(state.applied(), pending));
return true;
}
static boolean hasReplacementRecord(Path worldRoot, String levelKey) throws IOException {
return readState(worldRoot).applied().containsKey(levelKey) || hasGenerationHistory(
ModdedDimensionReplacement.storageFolder(worldRoot, levelKey));
}
private static Set<String> movedLevels(Result result) {
Set<String> levels = new TreeSet<>(result.quarantinedLevels());
levels.addAll(result.replacedWorlds());
return levels;
}
private static void addChunkData(List<Path> sources, Path storage) {
for (String folder : ModdedDimensionStorage.chunkDataFolders()) {
sources.add(storage.resolve(folder));
}
}
private static boolean registered(List<ModdedDimensionRegistryStore.PersistentDimension> registry, String levelKey) {
for (ModdedDimensionRegistryStore.PersistentDimension dimension : registry) {
if (dimension.id().equals(levelKey)) {
return true;
}
}
return false;
}
private static List<ModdedDimensionRegistryStore.PersistentDimension> repointed(
List<ModdedDimensionRegistryStore.PersistentDimension> registry,
Map<String, ModdedDimensionReplacement> replacedWorlds) {
List<ModdedDimensionRegistryStore.PersistentDimension> updated = new ArrayList<>(registry.size());
for (ModdedDimensionRegistryStore.PersistentDimension dimension : registry) {
ModdedDimensionReplacement replacement = replacedWorlds.get(dimension.id());
updated.add(replacement == null ? dimension : new ModdedDimensionRegistryStore.PersistentDimension(
dimension.id(), replacement.pack(), replacement.packDimension(), replacement.seed().getAsLong()));
}
return updated;
}
private static void refuseOrSkipUninstalled(Path worldRoot, Map<String, ModdedDimensionReplacement> applied,
ModdedDimensionReplacement replacement) throws IOException {
if (applied.containsKey(replacement.levelKey()) || hasGenerationHistory(replacement.storageFolder(worldRoot))) {
throw new IOException("Iris refused to switch the replacement of " + replacement.levelKey() + " to "
+ replacement.packRef() + ": pack '" + replacement.pack() + "' dimension '" + replacement.packDimension()
+ "' is not installed, and the level already has an applied replacement or Iris generation history. Install the pack or restore the previous replacement.");
}
ModdedIrisLog.error("Iris left {} untouched: its replacement pack '{}' dimension '{}' is not installed",
replacement.levelKey(), replacement.pack(), replacement.packDimension());
}
private static boolean hasGenerationHistory(Path storage) {
return Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS);
}
static OptionalLong matchingHistorySeed(Path storage, ModdedDimensionReplacement replacement) throws IOException {
if (!hasGenerationHistory(storage)) {
return OptionalLong.empty();
}
GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch();
if (replacement.seed().isPresent() && replacement.seed().getAsLong() != epoch.worldSeed()) {
return OptionalLong.empty();
}
GenerationEpoch.DimensionContract contract = epoch.dimensionContract();
if (!contract.dimensionKey().equals(replacement.packDimension())) {
return OptionalLong.empty();
}
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
boolean samePack = scoped.isPresent()
? scoped.get().equals(new ModdedWorldgenIds.ScopedDimension(replacement.pack(), replacement.packDimension()))
: aliasMatches(epoch, contract, replacement);
return samePack ? OptionalLong.of(epoch.worldSeed()) : OptionalLong.empty();
}
private static boolean aliasMatches(GenerationEpoch epoch, GenerationEpoch.DimensionContract contract,
ModdedDimensionReplacement replacement) {
Set<String> packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), contract.dimensionKey());
return packs.isEmpty() || packs.contains(replacement.pack());
}
private static OptionalLong generatedSeed(NativeModdedServer server, String levelKey, String packRef) {
NativeWorld world = server.world(levelKey);
if (world == null) {
return OptionalLong.empty();
}
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class);
if (generator == null || !packRef.equals(generator.dimensionKey())) {
return OptionalLong.empty();
}
return OptionalLong.of(generator.visibleBiomeSeed());
}
private static void requireReadableConfig(Path worldRoot, List<String> configProblems) throws IOException {
if (configProblems.isEmpty()) {
return;
}
Path state = worldRoot.resolve(STATE_FILE);
if (!Files.exists(state)) {
return;
}
throw new IOException("Iris refused to reconcile dimension replacements because the configured replacements are unreadable while "
+ state + " records applied replacements: " + String.join("; ", configProblems)
+ ". Fix the modded config so every replacement entry is valid; Iris never treats an unreadable replacement as removed.");
}
static State readState(Path worldRoot) throws IOException {
Path state = worldRoot.resolve(STATE_FILE);
if (!Files.isRegularFile(state)) {
return State.EMPTY;
}
JSONObject json = new JSONObject(Files.readString(state, StandardCharsets.UTF_8));
return new State(readSection(json, APPLIED, state), readSection(json, PENDING, state));
}
static void writeState(Path worldRoot, State replacements) throws IOException {
Path state = worldRoot.resolve(STATE_FILE);
Files.createDirectories(state.getParent());
JSONObject json = new JSONObject();
json.put(APPLIED, section(replacements.applied()));
json.put(PENDING, section(replacements.pending()));
Path temporary = state.resolveSibling(state.getFileName() + ".tmp-" + UUID.randomUUID());
Files.writeString(temporary, json.toString(2), StandardCharsets.UTF_8);
try {
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException atomicUnsupported) {
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING);
}
}
private static Map<String, ModdedDimensionReplacement> readSection(JSONObject json, String name, Path state)
throws IOException {
JSONObject section = json.optJSONObject(name);
if (section == null) {
return Map.of();
}
Map<String, ModdedDimensionReplacement> replacements = new TreeMap<>();
for (String level : section.keySet()) {
try {
replacements.put(level, ModdedDimensionReplacement.fromJson(level, section.opt(level)));
} catch (IllegalArgumentException invalid) {
throw new IOException("Iris could not read replacement '" + level + "' in " + state + ": "
+ invalid.getMessage(), invalid);
}
}
return Map.copyOf(replacements);
}
private static JSONObject section(Map<String, ModdedDimensionReplacement> replacements) {
JSONObject section = new JSONObject();
for (Map.Entry<String, ModdedDimensionReplacement> entry : new TreeMap<>(replacements).entrySet()) {
section.put(entry.getKey(), entry.getValue().toJson());
}
return section;
}
interface GeneratedSeed {
OptionalLong seed(String levelKey, String packRef);
}
record State(Map<String, ModdedDimensionReplacement> applied, Map<String, ModdedDimensionReplacement> pending) {
static final State EMPTY = new State(Map.of(), Map.of());
State {
applied = Map.copyOf(applied);
pending = Map.copyOf(pending);
}
}
record Request(Path worldRoot, Map<String, ModdedDimensionReplacement> configured, List<String> configProblems,
Predicate<ModdedDimensionReplacement> installed, Path recoveryParent) {
}
record Result(Set<String> quarantinedLevels, Set<String> adoptedLevels, Set<String> replacedWorlds, Path recovery) {
}
}
@@ -1,262 +0,0 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationHistoryPaths;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.UUID;
import java.util.function.BiPredicate;
import java.util.function.Predicate;
public final class ModdedTakeoverReconciler {
private static final String STATE_FILE = "data/irisworldgen/dimension-takeovers.json";
private static final String TAKEOVERS = "takeovers";
private ModdedTakeoverReconciler() {
}
public static void reconcileEarly() {
try {
ModdedModConfig config = ModdedModConfig.get();
Map<String, String> configured = ModdedDimensionTakeovers.configured();
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
if (worldRoot == null) {
if (!configured.isEmpty()) {
ModdedIrisLog.info("Iris dimension takeovers {} apply to a new world; nothing to quarantine",
configured.keySet());
}
return;
}
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath();
Result result = reconcile(new Request(worldRoot, configured, config.takeoverProblems(),
(ModdedDimensionTakeover takeover) -> ModdedDimensionTakeovers.installed(takeover, packsRoot), recoveryParent));
if (!result.adoptedLevels().isEmpty()) {
ModdedIrisLog.info("Iris dimension takeover recorded for {}: their existing Iris generation history already uses the configured pack",
result.adoptedLevels());
}
if (result.quarantinedLevels().isEmpty()) {
return;
}
ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", result.quarantinedLevels(),
result.recovery() == null
? "no existing chunk data to quarantine"
: "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery()
+ " so they regenerate (player data kept)");
} catch (Throwable failure) {
ModdedIrisLog.error("Iris dimension takeover reconciliation failed", failure);
throw new IllegalStateException("Iris refused startup after dimension takeover reconciliation failed", failure);
}
}
public static void recordApplied(NativeModdedServer server) {
Optional<Path> worldRoot = NativeWorldPaths.root(server);
if (worldRoot.isEmpty()) {
return;
}
try {
recordApplied(worldRoot.get(), ModdedDimensionTakeovers.configured(),
(String levelKey, String packRef) -> generatesFrom(server, levelKey, packRef));
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris is stopping the server because it could not record the applied dimension takeovers under {}; an unrecorded takeover would be quarantined on the next start",
worldRoot.get(), failure);
server.halt(false);
}
}
static Map<String, String> recordApplied(Path worldRoot, Map<String, String> configured,
BiPredicate<String, String> generating) throws IOException {
Map<String, String> applied = readApplied(worldRoot);
Map<String, String> recorded = new TreeMap<>(applied);
for (Map.Entry<String, String> entry : new TreeMap<>(configured).entrySet()) {
if (generating.test(entry.getKey(), entry.getValue())) {
recorded.put(entry.getKey(), entry.getValue());
continue;
}
ModdedIrisLog.warn("Iris dimension takeover of {} is configured but the level does not generate from {}; it stays unapplied",
entry.getKey(), entry.getValue());
}
if (!recorded.equals(applied)) {
writeApplied(worldRoot, recorded);
}
return Map.copyOf(recorded);
}
static Result reconcile(Request request) throws IOException {
Path worldRoot = request.worldRoot();
requireReadableConfig(worldRoot, request.configProblems());
Map<String, String> applied = readApplied(worldRoot);
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, request.configured());
Map<String, String> state = new TreeMap<>(applied);
Set<String> quarantined = new TreeSet<>();
Set<String> adopted = new TreeSet<>();
for (String level : changed) {
String packRef = request.configured().get(level);
if (packRef == null) {
state.remove(level);
quarantined.add(level);
continue;
}
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(level, packRef);
Path storage = takeover.storageFolder(worldRoot);
if (historyMatches(storage, takeover)) {
state.put(level, packRef);
adopted.add(level);
} else if (request.installed().test(takeover)) {
quarantined.add(level);
} else {
refuseOrSkipUninstalled(worldRoot, applied, takeover);
}
}
List<Path> sources = new ArrayList<>();
for (String level : quarantined) {
Path storage = ModdedDimensionTakeover.storageFolder(worldRoot, level);
for (String folder : ModdedDimensionStorage.chunkDataFolders()) {
sources.add(storage.resolve(folder));
}
}
Path recovery = request.recoveryParent().resolve("takeover-recovery-" + UUID.randomUUID());
List<Path> moved = sources.isEmpty() ? List.of() : ModdedWorldQuarantine.move(worldRoot, sources, recovery);
if (!state.equals(applied)) {
writeApplied(worldRoot, state);
}
return new Result(Set.copyOf(quarantined), Set.copyOf(adopted), moved.isEmpty() ? null : recovery);
}
static boolean hasTakeoverRecord(Path worldRoot, String levelKey) throws IOException {
return readApplied(worldRoot).containsKey(levelKey) || hasGenerationHistory(
ModdedDimensionTakeover.storageFolder(worldRoot, levelKey));
}
private static void refuseOrSkipUninstalled(Path worldRoot, Map<String, String> applied,
ModdedDimensionTakeover takeover) throws IOException {
if (applied.containsKey(takeover.levelKey()) || hasGenerationHistory(takeover.storageFolder(worldRoot))) {
throw new IOException("Iris refused to switch the dimension takeover of " + takeover.levelKey() + " to "
+ takeover.packRef() + ": pack '" + takeover.pack() + "' dimension '" + takeover.packDimension()
+ "' is not installed, and the level already has an applied takeover or Iris generation history. Install the pack or restore the previous takeover.");
}
ModdedIrisLog.error("Iris left {} untouched: its takeover pack '{}' dimension '{}' is not installed",
takeover.levelKey(), takeover.pack(), takeover.packDimension());
}
private static boolean hasGenerationHistory(Path storage) {
return Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS);
}
static boolean historyMatches(Path storage, ModdedDimensionTakeover takeover) throws IOException {
if (!hasGenerationHistory(storage)) {
return false;
}
GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch();
GenerationEpoch.DimensionContract contract = epoch.dimensionContract();
if (!contract.dimensionKey().equals(takeover.packDimension())) {
return false;
}
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
if (scoped.isPresent()) {
return scoped.get().equals(new ModdedWorldgenIds.ScopedDimension(takeover.pack(), takeover.packDimension()));
}
Set<String> packs = ModdedWorldgenIds.aliasPacks(epoch.registryContract(), contract.dimensionKey());
return packs.isEmpty() || packs.contains(takeover.pack());
}
private static boolean generatesFrom(NativeModdedServer server, String levelKey, String packRef) {
NativeWorld world = server.world(levelKey);
if (world == null) {
return false;
}
IrisModdedChunkGenerator generator = NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class);
return generator != null && packRef.equals(generator.dimensionKey());
}
private static void requireReadableConfig(Path worldRoot, List<String> configProblems) throws IOException {
if (configProblems.isEmpty()) {
return;
}
Path state = worldRoot.resolve(STATE_FILE);
if (!Files.exists(state)) {
return;
}
throw new IOException("Iris refused to reconcile dimension takeovers because the configured takeovers are unreadable while "
+ state + " records applied takeovers: " + String.join("; ", configProblems)
+ ". Fix the modded config so every takeover entry is valid; Iris never treats an unreadable takeover as removed.");
}
static Map<String, String> 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<String, String> applied = new TreeMap<>();
for (String level : takeovers.keySet()) {
applied.put(level, takeovers.getString(level));
}
return Map.copyOf(applied);
}
static void writeApplied(Path worldRoot, Map<String, String> takeovers) throws IOException {
Path state = worldRoot.resolve(STATE_FILE);
Files.createDirectories(state.getParent());
JSONObject entries = new JSONObject();
for (Map.Entry<String, String> entry : new TreeMap<>(takeovers).entrySet()) {
entries.put(entry.getKey(), entry.getValue());
}
JSONObject json = new JSONObject();
json.put(TAKEOVERS, entries);
Path temporary = state.resolveSibling(state.getFileName() + ".tmp-" + UUID.randomUUID());
Files.writeString(temporary, json.toString(2), StandardCharsets.UTF_8);
try {
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException atomicUnsupported) {
Files.move(temporary, state, StandardCopyOption.REPLACE_EXISTING);
}
}
record Request(Path worldRoot, Map<String, String> configured, List<String> configProblems,
Predicate<ModdedDimensionTakeover> installed, Path recoveryParent) {
}
record Result(Set<String> quarantinedLevels, Set<String> adoptedLevels, Path recovery) {
}
}
@@ -18,6 +18,7 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.localization.DirectorCommandMessages;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.IrisMessages;
import art.arcane.iris.modded.localization.ModdedCatalogMessages;
@@ -79,6 +80,7 @@ final class ModdedCommandHelp {
Entry.group("object", ModdedHelpMessages.GROUP_OBJECT_OBJECT_WAND_SAVE_PASTE_ANALYZE_AND_UNDO_TOOLS, "o"),
Entry.group("edit", ModdedHelpMessages.GROUP_EDIT_OPEN_PACK_BIOME_REGION_AND_DIMENSION_JSON_FILES_IN_YOUR_DESKTOP_EDITOR),
Entry.command("create", "<name> [pack|pack:dimensionKey] [seed]", ModdedHelpMessages.COMMAND_CREATE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_QUOTE_PACK_DIMENSIONKEY_TO_PICK, "c"),
Entry.command("replace", "<target> [type=<pack|pack:dimensionKey|off>] [seed=<seed>]", DirectorCommandMessages.COMMAND_IRIS_PARAM_REPLACE_EXACT_EXISTING_WORLD_SLOT_NEXT_RESTART, "override", "overwrite"),
Entry.command("height", "", ModdedHelpMessages.COMMAND_HEIGHT_PRINT_WORLD_HEIGHT),
Entry.command("worlds", "", ModdedHelpMessages.COMMAND_WORLDS_LIST_WORLD_ACCESS, "accesslist"),
Entry.group("studio", ModdedHelpMessages.GROUP_STUDIO_PACK_PROJECT_CREATION_PACKAGING_AND_REPORTS, "std", "s"),
@@ -175,7 +177,6 @@ final class ModdedCommandHelp {
Entry.command("enable", "<dimension> <pack|pack:dimensionKey> [seed|random]", ModdedHelpMessages.COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK, "create"),
Entry.command("replace-overworld", "<pack|pack:dimensionKey> [seed|random]", ModdedHelpMessages.COMMAND_REPLACE_OVERWORLD_INJECT_AN_IRIS_PRIMARY_WORLD_AND_ROUTE_PLAYERS_THERE_INSTEAD_OF_THE),
Entry.command("mainworld", "<pack|pack:dimensionKey|off> [seed|random]", ModdedHelpMessages.COMMAND_MAINWORLD_CONFIGURE_PRIMARY_WORLD_PRESET),
Entry.command("takeover", "<dimension> <pack|pack:dimensionKey|off>", ModdedHelpMessages.COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK),
Entry.command("disable", "<dimension>", ModdedHelpMessages.COMMAND_DISABLE_EVACUATE_AND_UNLOAD_AN_IRIS_DIMENSION_WORLD_DATA_ON_DISK_IS_KEPT),
Entry.command("delete", "<dimension>", 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"),
@@ -138,6 +138,9 @@ final class ModdedCommandTree {
root.then(createTree("create"));
root.then(createTree("c"));
root.then(ModdedReplaceCommands.tree("replace"));
root.then(ModdedReplaceCommands.tree("override"));
root.then(ModdedReplaceCommands.tree("overwrite"));
root.then(ModdedStudioCommands.tree("studio"));
root.then(ModdedStudioCommands.tree("std"));
@@ -0,0 +1,113 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedDimensionReplacement;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.OptionalLong;
record ModdedReplaceArguments(String target, String type, OptionalLong seed, boolean off) {
static final String DEFAULT_TYPE = "default";
static final String OFF = "off";
static final String PRESERVE_SEED = "preserve";
private static final String TARGET = "target";
private static final String TYPE = "type";
private static final String SEED = "seed";
private static final List<String> POSITIONAL = List.of(TARGET, TYPE, SEED);
private static final Map<String, String> KEYS = Map.of(
TARGET, TARGET,
"world-name", TARGET,
TYPE, TYPE,
"dimension", TYPE,
"pack", TYPE,
SEED, SEED,
"s", SEED
);
static ModdedReplaceArguments parse(String raw) {
String input = raw == null ? "" : raw.trim();
String[] values = new String[POSITIONAL.size()];
String[] tokens = input.isEmpty() ? new String[0] : input.split("\\s+");
for (String token : tokens) {
int equals = token.indexOf('=');
if (equals < 0) {
continue;
}
String key = KEYS.get(token.substring(0, equals).toLowerCase(Locale.ROOT));
if (key == null) {
throw new IllegalArgumentException("Unknown argument '" + token.substring(0, equals)
+ "'. Use target=, type=, or seed=.");
}
String value = token.substring(equals + 1);
if (value.isEmpty()) {
throw new IllegalArgumentException("Argument " + key + "= needs a value.");
}
int index = POSITIONAL.indexOf(key);
if (values[index] != null) {
throw new IllegalArgumentException("Argument " + key + " was given more than once.");
}
values[index] = value;
}
for (String token : tokens) {
if (token.indexOf('=') >= 0) {
continue;
}
int index = nextUnset(values);
if (index < 0) {
throw new IllegalArgumentException("Unexpected argument '" + token
+ "'. Usage: /iris replace <target> [type=<pack|pack:dimensionKey|off>] [seed=<seed>]");
}
values[index] = token;
}
if (values[0] == null) {
throw new IllegalArgumentException("Missing target. Usage: /iris replace <target> [type=<pack|pack:dimensionKey|off>] [seed=<seed>]");
}
String type = values[1] == null ? DEFAULT_TYPE : values[1];
boolean off = OFF.equalsIgnoreCase(type);
if (off && values[2] != null) {
throw new IllegalArgumentException("Turning a replacement off takes no seed.");
}
return new ModdedReplaceArguments(values[0], type, parseSeed(values[2]), off);
}
private static int nextUnset(String[] values) {
for (int index = 0; index < values.length; index++) {
if (values[index] == null) {
return index;
}
}
return -1;
}
private static OptionalLong parseSeed(String raw) {
if (raw == null || PRESERVE_SEED.equalsIgnoreCase(raw)) {
return OptionalLong.empty();
}
long seed;
try {
seed = Long.parseLong(raw);
} catch (NumberFormatException failure) {
throw new IllegalArgumentException("Seed must be a signed 64-bit integer");
}
return OptionalLong.of(ModdedDimensionReplacement.requireUsableSeed(seed));
}
}
@@ -0,0 +1,301 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.localization.BukkitCommandMessagesExtended;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.modded.ModdedDimensionRegistryStore;
import art.arcane.iris.modded.ModdedDimensionReplacement;
import art.arcane.iris.modded.ModdedDimensionReplacements;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.modded.ModdedModConfig;
import art.arcane.iris.modded.ModdedReplacementReconciler;
import art.arcane.iris.pack.PackDownloader;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandRegistration;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.localization.MessageArgument;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
final class ModdedReplaceCommands {
private static final Predicate<NativeCommandSource> GATE = NativeCommandRegistration.GAMEMASTERS;
private static final String ARGUMENTS = "arguments";
private static final String MISSING_TARGET = "/iris replace requires an existing exact world slot; use /iris create for a new world.";
private static final String STAGE_FAILURE = "Could not stage the world replacement: ";
private static final List<String> TARGET_NAMES = List.of("main", "nether", "end");
private static final List<String> KEYS = List.of("target=", "type=", "seed=");
private ModdedReplaceCommands() {
}
static LiteralArgumentBuilder<NativeCommandSource> tree(String name) {
return NativeCommandRegistration.literal(name).requires(GATE)
.then(NativeCommandRegistration.argument(ARGUMENTS, StringArgumentType.greedyString())
.suggests(ModdedReplaceCommands::suggest)
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> replace(
context.getSource(),
StringArgumentType.getString(context, ARGUMENTS)))));
}
private static int replace(NativeCommandSource source, String raw) {
NativeModdedServer server = source.server();
ModdedReplaceArguments arguments;
ModdedReplaceTarget target;
try {
arguments = ModdedReplaceArguments.parse(raw);
target = ModdedReplaceTarget.resolve(arguments.target(), levelName(server));
} catch (IllegalArgumentException invalid) {
IrisModdedCommands.fail(source, invalid.getMessage());
return 0;
}
if (ModdedEngineBootstrap.loader().clientEnvironment()) {
IrisModdedCommands.fail(source, "Iris replaces dimensions only on dedicated servers.");
return 0;
}
if (arguments.off()) {
return turnOff(source, server, target);
}
String resolvedType = arguments.type().equalsIgnoreCase(ModdedReplaceArguments.DEFAULT_TYPE)
? IrisSettings.get().getGenerator().getDefaultWorldType()
: arguments.type();
String pack = packOf(resolvedType);
IrisDimension dimension = installedDimension(pack, packDimensionOf(resolvedType));
if (dimension == null) {
reportDimensionNotFound(source, resolvedType);
return 0;
}
if (ModdedWorldCommands.blockIfPackBroken(source, target.levelKey(), pack)) {
return 0;
}
try {
ModdedReplaceTarget.requireCompatibleEnvironment(target.kind(), dimension.getEnvironment());
ModdedDimensionReplacement requested = ModdedDimensionReplacements.canonical(
ModdedDimensionReplacement.parse(target.levelKey(), resolvedType, arguments.seed()),
ModdedPackCommands.packsRoot().toPath());
ModdedDimensionReplacement staged = target.kind() == ModdedReplaceTarget.Kind.IRIS_WORLD
? stageIrisWorld(server, requested)
: stageLevel(server, requested);
String seedDetail = arguments.seed().isEmpty()
? " preserving seed " + staged.seed().getAsLong()
: " using seed " + staged.seed().getAsLong();
IrisModdedCommands.ok(source, "Staged Iris replacement for " + staged.levelKey() + seedDetail
+ ". Restart once to publish it. On that restart the current terrain of the dimension moves to "
+ "a replacement-recovery folder under config/irisworldgen; player data stays in the save.");
return 1;
} catch (IllegalArgumentException rejected) {
IrisModdedCommands.fail(source, STAGE_FAILURE + rejected.getMessage());
return 0;
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris failed to stage the world replacement for {}", target.levelKey(), failure);
IrisModdedCommands.fail(source, STAGE_FAILURE + IrisLanguage.errorDetail(failure));
return 0;
}
}
private static ModdedDimensionReplacement stageLevel(NativeModdedServer server, ModdedDimensionReplacement requested)
throws IOException {
NativeWorld level = server.world(requested.levelKey());
if (level == null) {
throw new IllegalArgumentException(MISSING_TARGET);
}
Path worldRoot = server.root();
requireNothingPending(worldRoot, requested.levelKey());
long seed = requested.seed().orElse(ModdedReplacementReconciler.applied(worldRoot, requested.levelKey())
.map((ModdedDimensionReplacement applied) -> applied.seed().getAsLong())
.orElse(level.seed()));
ModdedDimensionReplacement staged = requested.withSeed(seed);
ModdedReplacementReconciler.stagePending(worldRoot, staged);
try {
if (ModdedModConfig.setDimensionReplacement(staged)) {
ModdedForcedDatapack.scheduleRegeneration("dimension replacement of " + staged.levelKey());
}
} catch (RuntimeException failure) {
ModdedReplacementReconciler.cancelPending(worldRoot, staged.levelKey());
throw failure;
}
return staged;
}
private static ModdedDimensionReplacement stageIrisWorld(NativeModdedServer server, ModdedDimensionReplacement requested)
throws IOException {
ModdedDimensionRegistryStore.PersistentDimension registered =
ModdedDimensionRegistryStore.get(server, requested.levelKey());
if (registered == null) {
throw new IllegalArgumentException(MISSING_TARGET);
}
Path worldRoot = server.root();
requireNothingPending(worldRoot, requested.levelKey());
ModdedDimensionReplacement staged = requested.withSeed(requested.seed().orElse(registered.seed()));
ModdedReplacementReconciler.stagePending(worldRoot, staged);
return staged;
}
private static void requireNothingPending(Path worldRoot, String levelKey) throws IOException {
Map<String, ModdedDimensionReplacement> configured = ModdedModConfig.get().dimensionReplacements();
if (ModdedReplacementReconciler.pending(worldRoot, levelKey, configured)) {
throw new IllegalArgumentException("A replacement is already pending for " + levelKey + ".");
}
}
private static int turnOff(NativeCommandSource source, NativeModdedServer server, ModdedReplaceTarget target) {
String levelKey = target.levelKey();
try {
Path worldRoot = server.root();
boolean cancelled = ModdedReplacementReconciler.cancelPending(worldRoot, levelKey);
boolean applied = target.kind() != ModdedReplaceTarget.Kind.IRIS_WORLD
&& ModdedReplacementReconciler.applied(worldRoot, levelKey).isPresent();
boolean cleared = target.kind() != ModdedReplaceTarget.Kind.IRIS_WORLD
&& ModdedModConfig.clearDimensionReplacement(levelKey);
if (cleared) {
ModdedForcedDatapack.scheduleRegeneration("dimension replacement of " + levelKey + " turned off");
}
if (cleared && applied) {
IrisModdedCommands.ok(source, "Turned off the Iris replacement of " + levelKey
+ ". Restart once to return the dimension to its own generator; its Iris terrain moves to "
+ "a replacement-recovery folder under config/irisworldgen.");
return 1;
}
if (cleared || cancelled) {
IrisModdedCommands.ok(source, "Cancelled the staged Iris replacement of " + levelKey + ".");
return 1;
}
if (applied) {
IrisModdedCommands.ok(source, "The Iris replacement of " + levelKey
+ " is already turned off. Restart once to return the dimension to its own generator.");
return 1;
}
IrisModdedCommands.fail(source, "No Iris replacement is staged or applied for " + levelKey + ".");
return 0;
} catch (IOException | RuntimeException failure) {
ModdedIrisLog.error("Iris failed to turn off the replacement of {}", levelKey, failure);
IrisModdedCommands.fail(source, "Could not turn off the world replacement: " + IrisLanguage.errorDetail(failure));
return 0;
}
}
private static String levelName(NativeModdedServer server) {
Path levelFolder = server.root().toAbsolutePath().normalize().getFileName();
return levelFolder == null ? "" : levelFolder.toString();
}
private static IrisDimension installedDimension(String pack, String packDimension) {
if (!pack.matches("[A-Za-z0-9_.-]+") || pack.contains("..")
|| !packDimension.matches("[A-Za-z0-9_/.-]+") || packDimension.contains("..")) {
return null;
}
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
if (!packFolder.isDirectory()) {
return null;
}
return IrisData.get(packFolder).getDimensionLoader().load(packDimension);
}
private static void reportDimensionNotFound(NativeCommandSource source, String resolvedType) {
IrisModdedCommands.fail(source, IrisLanguage.plain(
BukkitCommandMessagesExtended.COMMAND_IRIS_DIMENSION_NOT_FOUND,
MessageArgument.untrusted("dimension", resolvedType)));
IrisModdedCommands.fail(source, IrisLanguage.plain(
BukkitCommandMessagesExtended.COMMAND_IRIS_TRY_ONE_OVERWORLD_VANILLA_FLAT_THEEND));
IrisModdedCommands.fail(source, IrisLanguage.plain(
BukkitCommandMessagesExtended.COMMAND_IRIS_INSTALL_PACK_AND_RESTART,
MessageArgument.untrusted("command", PackDownloader.downloadCommandFor(packOf(resolvedType)))));
}
private static String packOf(String type) {
String value = type.trim();
int colon = value.indexOf(':');
return colon >= 0 ? value.substring(0, colon) : value;
}
private static String packDimensionOf(String type) {
String value = type.trim();
int colon = value.indexOf(':');
return colon >= 0 ? value.substring(colon + 1) : value;
}
private static CompletableFuture<Suggestions> suggest(CommandContext<NativeCommandSource> context,
SuggestionsBuilder builder) throws CommandSyntaxException {
String remaining = builder.getRemaining();
int tokenStart = remaining.lastIndexOf(' ') + 1;
String token = remaining.substring(tokenStart);
int positional = 0;
for (String previous : remaining.substring(0, tokenStart).trim().split("\\s+")) {
if (!previous.isEmpty() && previous.indexOf('=') < 0) {
positional++;
}
}
int equals = token.indexOf('=');
if (equals >= 0) {
String key = token.substring(0, equals);
SuggestionsBuilder valueBuilder = builder.createOffset(builder.getStart() + tokenStart + equals + 1);
return switch (key) {
case "target", "world-name" -> NativeCommandRegistration.suggest(targetNames(context.getSource()), valueBuilder);
case "type", "dimension", "pack" -> typeSuggestions(context, valueBuilder);
default -> builder.buildFuture();
};
}
SuggestionsBuilder tokenBuilder = builder.createOffset(builder.getStart() + tokenStart);
if (positional == 0) {
List<String> targets = new ArrayList<>(targetNames(context.getSource()));
targets.addAll(KEYS);
return NativeCommandRegistration.suggest(targets, tokenBuilder);
}
if (positional == 1) {
return typeSuggestions(context, tokenBuilder);
}
return NativeCommandRegistration.suggest(List.of(ModdedReplaceArguments.PRESERVE_SEED), tokenBuilder);
}
private static CompletableFuture<Suggestions> typeSuggestions(CommandContext<NativeCommandSource> context,
SuggestionsBuilder builder)
throws CommandSyntaxException {
if (ModdedReplaceArguments.OFF.startsWith(builder.getRemainingLowerCase())) {
builder.suggest(ModdedReplaceArguments.OFF);
}
return ModdedCommandSuggestions.PACK_NAMES.getSuggestions(context, builder);
}
private static List<String> targetNames(NativeCommandSource source) {
List<String> names = new ArrayList<>(TARGET_NAMES);
for (NativeWorld level : source.server().worlds()) {
names.add(level.name());
}
return names;
}
}
@@ -0,0 +1,122 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedDimensionReplacement;
import art.arcane.iris.world.IrisEnvironment;
import java.util.Locale;
import java.util.Objects;
record ModdedReplaceTarget(String levelKey, Kind kind) {
static final String IRIS_WORLD_NAMESPACE = "irisworldgen";
private static final String PAPER_IRIS_NAMESPACE = "iris";
private static final String OVERWORLD = "minecraft:overworld";
private static final String NETHER = "minecraft:the_nether";
private static final String END = "minecraft:the_end";
ModdedReplaceTarget {
Objects.requireNonNull(levelKey, "levelKey");
Objects.requireNonNull(kind, "kind");
}
static ModdedReplaceTarget resolve(String requestedName, String levelName) {
String requested = requestedName == null ? "" : requestedName.trim();
if (requested.isEmpty()) {
throw new IllegalArgumentException("World name cannot be empty.");
}
String normalized = requested.toLowerCase(Locale.ENGLISH);
if (normalized.contains(":")) {
return exact(normalized, requestedName);
}
if (requested.contains("/") || requested.contains("\\") || requested.contains("..")) {
throw new IllegalArgumentException("World name must be a safe single path segment.");
}
String configuredLevelName = levelName == null ? "" : levelName.trim();
if (!configuredLevelName.isEmpty()) {
if (requested.equalsIgnoreCase(configuredLevelName)) {
return of(OVERWORLD);
}
if (requested.equalsIgnoreCase(configuredLevelName + "_nether")) {
return of(NETHER);
}
if (requested.equalsIgnoreCase(configuredLevelName + "_the_end")) {
return of(END);
}
}
return switch (normalized) {
case "main", "overworld" -> of(OVERWORLD);
case "nether", "the_nether" -> of(NETHER);
case "end", "the_end" -> of(END);
default -> of(IRIS_WORLD_NAMESPACE + ":" + normalized.replace(' ', '_'));
};
}
static void requireCompatibleEnvironment(Kind kind, IrisEnvironment environment) {
IrisEnvironment expected = kind.environment();
if (expected != null && environment != expected) {
throw new IllegalArgumentException("The " + kind.slotName() + " slot requires a pack environment of "
+ expected.name() + ".");
}
}
private static ModdedReplaceTarget exact(String normalized, String requestedName) {
int separator = normalized.indexOf(':');
if (separator == 0 || separator == normalized.length() - 1 || separator != normalized.lastIndexOf(':')) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
String namespace = normalized.substring(0, separator);
String path = normalized.substring(separator + 1);
String levelKey = PAPER_IRIS_NAMESPACE.equals(namespace) ? IRIS_WORLD_NAMESPACE + ":" + path : normalized;
return of(levelKey);
}
private static ModdedReplaceTarget of(String levelKey) {
String level = ModdedDimensionReplacement.requireLevelKey(levelKey);
Kind kind = switch (level) {
case OVERWORLD -> Kind.VANILLA_OVERWORLD;
case NETHER -> Kind.VANILLA_NETHER;
case END -> Kind.VANILLA_END;
default -> level.startsWith(IRIS_WORLD_NAMESPACE + ":") ? Kind.IRIS_WORLD : Kind.DIMENSION;
};
return new ModdedReplaceTarget(level, kind);
}
enum Kind {
VANILLA_OVERWORLD(IrisEnvironment.NORMAL),
VANILLA_NETHER(IrisEnvironment.NETHER),
VANILLA_END(IrisEnvironment.THE_END),
IRIS_WORLD(null),
DIMENSION(null);
private final IrisEnvironment environment;
Kind(IrisEnvironment environment) {
this.environment = environment;
}
IrisEnvironment environment() {
return environment;
}
String slotName() {
return name().toLowerCase(Locale.ENGLISH);
}
}
}
@@ -26,9 +26,6 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.iris.modded.MainWorldService;
import art.arcane.iris.modded.ModdedDimensionTakeover;
import art.arcane.iris.modded.ModdedDimensionTakeovers;
import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.ModdedModConfig;
@@ -47,7 +44,6 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -63,7 +59,6 @@ public final class ModdedWorldCommands {
private static final String DEFAULT_NAMESPACE = "irisworldgen";
private static final long DEFAULT_SEED = 1337L;
private static final SuggestionProvider<NativeCommandSource> LOADED_DIMENSIONS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(loadedIrisDimensions(context.getSource().server()), builder);
private static final SuggestionProvider<NativeCommandSource> ALL_DIMENSIONS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(allDimensions(context.getSource().server()), builder);
private ModdedWorldCommands() {
}
@@ -85,7 +80,6 @@ public final class ModdedWorldCommands {
root.then(updateTree());
root.then(replaceOverworldTree());
root.then(mainWorldTree());
root.then(takeoverTree());
root.then(disableTree());
root.then(deleteTree("delete"));
@@ -162,18 +156,6 @@ public final class ModdedWorldCommands {
StringArgumentType.getString(context, "seed"))))));
}
private static LiteralArgumentBuilder<NativeCommandSource> takeoverTree() {
return NativeCommandRegistration.literal("takeover")
.then(NativeCommandRegistration.argument("dimension", NativeCommandArguments.identifier()).suggests(ALL_DIMENSIONS)
.then(NativeCommandRegistration.literal("off")
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> clearTakeover(context.getSource(),
dimensionArgument(context)))))
.then(NativeCommandRegistration.argument("pack", StringArgumentType.greedyString()).suggests(IrisModdedCommands.PACK_NAMES)
.executes(ModdedCommandTree.localized((CommandContext<NativeCommandSource> context) -> takeover(context.getSource(),
dimensionArgument(context),
StringArgumentType.getString(context, "pack"))))));
}
public static int createWorld(NativeCommandSource source, String name, String pack, long seed) {
String[] packRef = parsePackRef(pack);
return enable(source, name, packRef[0], packRef[1], seed);
@@ -304,62 +286,6 @@ public final class ModdedWorldCommands {
return 1;
}
private static int takeover(NativeCommandSource source, String targetDimension, String packRaw) {
ModdedDimensionTakeover takeover;
try {
takeover = ModdedDimensionTakeover.parse(normalizeDimensionId(targetDimension), packRaw);
} catch (IllegalArgumentException invalid) {
IrisModdedCommands.fail(source, invalid.getMessage());
return 0;
}
String pack = takeover.pack();
if (!new File(ModdedPackCommands.packsRoot(), pack).isDirectory()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_WORLD_COMMANDS_REQUIRED_PACK_IS_NOT_INSTALLED_INSTALL_THEN_RESTART,
MessageArgument.untrusted("pack", pack)));
return 0;
}
if (blockIfPackBroken(source, takeover.levelKey(), pack) || !loadPackDimension(source, pack, takeover.packDimension())) {
return 0;
}
ModdedDimensionTakeover canonical;
try {
canonical = ModdedDimensionTakeovers.canonical(takeover, ModdedPackCommands.packsRoot().toPath());
} catch (IOException unreadable) {
ModdedIrisLog.error("Iris could not resolve the takeover pack {} for {}", takeover.packRef(), takeover.levelKey(), unreadable);
IrisModdedCommands.fail(source, IrisLanguage.errorDetail(unreadable));
return 0;
}
ModdedModConfig.setDimensionTakeover(canonical);
ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + canonical.levelKey());
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_SET,
MessageArgument.untrusted("dimensionId", canonical.levelKey()),
MessageArgument.untrusted("pack", canonical.pack()),
MessageArgument.untrusted("packDimension", canonical.packDimension())));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED));
return 1;
}
private static int clearTakeover(NativeCommandSource source, String targetDimension) {
String dimensionId;
try {
dimensionId = normalizeDimensionId(targetDimension);
} catch (IllegalArgumentException invalid) {
IrisModdedCommands.fail(source, invalid.getMessage());
return 0;
}
if (!ModdedModConfig.clearDimensionTakeover(dimensionId)) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED,
MessageArgument.untrusted("dimensionId", dimensionId)));
return 0;
}
ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + dimensionId + " turned off");
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED,
MessageArgument.untrusted("dimensionId", dimensionId)));
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED));
return 1;
}
private static int clearMainWorld(NativeCommandSource source) {
ModdedModConfig.setMainWorld("", 0L);
MainWorldService.clearOverride();
@@ -437,7 +363,7 @@ public final class ModdedWorldCommands {
return 1;
}
private static boolean blockIfPackBroken(NativeCommandSource source, String dimensionId, String pack) {
static boolean blockIfPackBroken(NativeCommandSource source, String dimensionId, String pack) {
try {
ModdedStartup.requirePackForWorldCreation(pack);
return false;
@@ -570,15 +496,6 @@ public final class ModdedWorldCommands {
return 1;
}
private static List<String> allDimensions(NativeModdedServer server) {
List<String> 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<String> loadedIrisDimensions(NativeModdedServer server) {
List<String> dimensions = new ArrayList<>();
for (NativeWorld level : server.worlds()) {
@@ -1152,22 +1152,6 @@ public final class ModdedCommandMessages {
"iris.modded.moddedworldcommands.iris_main_world_override_cleared_overworld_keeps_its_current_generator",
"Iris main world override cleared. The overworld keeps its current generator; edit server.properties level-type and restart to change it back."
);
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_SET = TextKey.of(
"iris.modded.moddedworldcommands.takeover_set",
"Iris takeover of " + "{dimensionId}" + " set to pack '" + "{pack}" + "', dimension '" + "{packDimension}" + "'."
);
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED = TextKey.of(
"iris.modded.moddedworldcommands.takeover_cleared",
"Iris takeover of " + "{dimensionId}" + " turned off; the dimension's own generator returns on the next restart."
);
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED = TextKey.of(
"iris.modded.moddedworldcommands.takeover_not_configured",
"No Iris takeover is configured for " + "{dimensionId}" + "."
);
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED = TextKey.of(
"iris.modded.moddedworldcommands.takeover_restart_required",
"Restart required: on the next start the dimension's chunk, entity, POI, and Iris Mantle data move to a recovery folder and regenerate. Player data is kept."
);
public static final TextKey MODDED_WORLD_COMMANDS_INVALID_SEED_USE_NUMBER_RANDOM = TextKey.of(
"iris.modded.moddedworldcommands.invalid_seed_use_number_random",
"Invalid seed '" + "{seedRaw}" + "'. Use a number or 'random'."
@@ -1564,10 +1548,6 @@ public final class ModdedCommandMessages {
MODDED_WORLD_COMMANDS_VANILLA_OVERWORLD_GENERATOR_CANNOT_BE_HOT_SWAPPED_SO_THIS_DOES,
MODDED_WORLD_COMMANDS_INSTEAD_IS_NOW_CONFIGURED_PRIMARY_WORLD_PLAYERS_VANILLA_OVERWORLD_ARE,
MODDED_WORLD_COMMANDS_IRIS_MAIN_WORLD_OVERRIDE_CLEARED_OVERWORLD_KEEPS_ITS_CURRENT_GENERATOR,
MODDED_WORLD_COMMANDS_TAKEOVER_SET,
MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED,
MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED,
MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED,
MODDED_WORLD_COMMANDS_INVALID_SEED_USE_NUMBER_RANDOM,
MODDED_WORLD_COMMANDS_PACK_IS_NOT_READY_YET_STILL_LOADING_VALIDATING_TRY_COMMAND,
MODDED_WORLD_COMMANDS_FAILED_WRITE_SERVER_PROPERTIES_CHECK_FILE_PERMISSIONS_SET_LEVEL_TYPE,
@@ -390,10 +390,6 @@ public final class ModdedHelpMessages {
"iris.modded.help.entry.command.mainworld",
"Configure or clear the Iris primary-world preset used on restart"
);
public static final TextKey COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK = TextKey.of(
"iris.modded.help.entry.command.takeover",
"Take over an existing dimension with an Iris pack on the next restart, or turn it off"
);
public static final TextKey COMMAND_OBJECT_WE_BUKKIT_ONLY = TextKey.of(
"iris.modded.help.entry.command.object.we",
"Explain why WorldEdit selection import requires the Bukkit plugin"
@@ -520,7 +516,6 @@ public final class ModdedHelpMessages {
COMMAND_HEIGHT_PRINT_WORLD_HEIGHT,
COMMAND_WORLDS_LIST_WORLD_ACCESS,
COMMAND_MAINWORLD_CONFIGURE_PRIMARY_WORLD_PRESET,
COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK,
COMMAND_OBJECT_WE_BUKKIT_ONLY,
COMMAND_OBJECT_STUDIO_BUKKIT_ONLY,
COMMAND_OBJECT_CONVERT_BUKKIT_ONLY,
@@ -0,0 +1,290 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedDimensionReplacementsTest {
@Test
public void parsesPackAndPackDimensionReferences() {
ModdedDimensionReplacement samePack = replacement("iristest:skylands", "skypack");
ModdedDimensionReplacement scoped = ModdedDimensionReplacement.parse("aether:the_aether", "overworld:floating/sky",
OptionalLong.of(42L));
assertEquals("skypack", samePack.pack());
assertEquals("skypack", samePack.packDimension());
assertEquals("skypack", samePack.packRef());
assertEquals(OptionalLong.empty(), samePack.seed());
assertEquals("overworld", scoped.pack());
assertEquals("floating/sky", scoped.packDimension());
assertEquals("overworld:floating/sky", scoped.packRef());
assertEquals(OptionalLong.of(42L), scoped.seed());
assertEquals("aether", scoped.namespace());
assertEquals("the_aether", scoped.path());
}
@Test
public void rejectsInvalidLevelKeysAndPackReferences() {
for (String levelKey : List.of("skylands", "Iristest:skylands", "iristest:", ":skylands", "iristest:sky/../x", "iristest:/sky")) {
assertThrows(levelKey, IllegalArgumentException.class, () -> replacement(levelKey, "overworld"));
}
for (String packRef : List.of("", " ", "../overworld", "overworld:", "over world", "overworld:a/../b")) {
assertThrows(packRef, IllegalArgumentException.class, () -> replacement("iristest:skylands", packRef));
}
}
@Test
public void jsonRoundTripsTheTypeAndOptionalSeed() {
ModdedDimensionReplacement seeded = ModdedDimensionReplacement.parse("minecraft:overworld", "overworld",
OptionalLong.of(-7L));
ModdedDimensionReplacement unseeded = replacement("examplemod:skylands", "skypack:sky");
assertEquals("overworld", seeded.toJson().getString("type"));
assertEquals(-7L, seeded.toJson().getLong("seed"));
assertFalse(unseeded.toJson().has("seed"));
assertEquals(seeded, ModdedDimensionReplacement.fromJson("minecraft:overworld", seeded.toJson()));
assertEquals(unseeded, ModdedDimensionReplacement.fromJson("examplemod:skylands", unseeded.toJson()));
assertThrows(IllegalArgumentException.class,
() -> ModdedDimensionReplacement.fromJson("examplemod:skylands", "skypack"));
assertThrows(IllegalArgumentException.class,
() -> ModdedDimensionReplacement.fromJson("examplemod:skylands", new JSONObject("{\"seed\": 1}")));
assertThrows(IllegalArgumentException.class,
() -> ModdedDimensionReplacement.fromJson("examplemod:skylands",
new JSONObject("{\"type\": \"skypack\", \"seed\": \"abc\"}")));
}
@Test
public void levelStoragePathFollowsTheDimensionsLayout() {
Path worldRoot = Path.of("/srv/world");
assertEquals(worldRoot.resolve("dimensions").resolve("iristest").resolve("skylands"),
replacement("iristest:skylands", "overworld").storageFolder(worldRoot));
assertEquals(worldRoot.resolve("dimensions").resolve("aether").resolve("realm").resolve("upper"),
replacement("aether:realm/upper", "overworld").storageFolder(worldRoot));
assertEquals(worldRoot.resolve("dimensions").resolve("minecraft").resolve("overworld"),
ModdedDimensionReplacement.storageFolder(worldRoot, "minecraft:overworld"));
}
@Test
public void levelStemUsesTheIrisGeneratorWithThePresetBiomeSourcePlaceholder() {
JSONObject stem = new JSONObject(ModdedDimensionReplacements.levelStemJson("iristest:skylands",
replacement("iristest:skylands", "overworld:sky")));
assertEquals("iristest:skylands", stem.getString("type"));
JSONObject generator = stem.getJSONObject("generator");
assertEquals("irisworldgen:iris", generator.getString("type"));
assertEquals("overworld:sky", generator.getString("dimension"));
assertEquals("minecraft:fixed", generator.getJSONObject("biome_source").getString("type"));
assertEquals("minecraft:plains", generator.getJSONObject("biome_source").getString("biome"));
}
@Test
public void levelStemIsWrittenAtTheDatapackDimensionPathOfTheLevelKey() throws IOException {
Path datapack = Files.createTempDirectory("iris-replace-stem");
try {
Path written = ModdedDimensionReplacements.writeLevelStem(datapack,
replacement("aether:the_aether", "overworld"), "aether:the_aether");
assertEquals(datapack.resolve("data").resolve("aether").resolve("dimension").resolve("the_aether.json"), written);
assertEquals("aether:the_aether", new JSONObject(Files.readString(written)).getString("type"));
} finally {
ModdedReplacementReconcilerTest.deleteTree(datapack);
}
}
@Test
public void changedLevelsCoverAddedChangedReseededAndRemovedReplacements() {
Map<String, ModdedDimensionReplacement> applied = Map.of(
"iristest:kept", seeded("iristest:kept", "overworld", 5L),
"iristest:unseeded", seeded("iristest:unseeded", "overworld", 5L),
"iristest:changed", seeded("iristest:changed", "overworld", 5L),
"iristest:reseeded", seeded("iristest:reseeded", "overworld", 5L),
"iristest:removed", seeded("iristest:removed", "overworld", 5L));
Map<String, ModdedDimensionReplacement> configured = Map.of(
"iristest:kept", seeded("iristest:kept", "overworld", 5L),
"iristest:unseeded", replacement("iristest:unseeded", "overworld"),
"iristest:changed", seeded("iristest:changed", "overworld:sky", 5L),
"iristest:reseeded", seeded("iristest:reseeded", "overworld", 6L),
"iristest:added", replacement("iristest:added", "overworld"));
assertEquals(Set.of("iristest:changed", "iristest:reseeded", "iristest:removed", "iristest:added"),
ModdedDimensionReplacements.changedLevels(applied, configured));
assertEquals(Set.of(), ModdedDimensionReplacements.changedLevels(applied, applied));
}
@Test
public void fingerprintIsOrderIndependentAndTracksEveryLevelStem() {
Map<String, ModdedDimensionReplacement> forward = new LinkedHashMap<>();
forward.put("iristest:a", replacement("iristest:a", "overworld"));
forward.put("iristest:b", seeded("iristest:b", "overworld:sky", 9L));
Map<String, ModdedDimensionReplacement> reversed = new LinkedHashMap<>();
reversed.put("iristest:b", seeded("iristest:b", "overworld:sky", 9L));
reversed.put("iristest:a", replacement("iristest:a", "overworld"));
assertEquals(ModdedDimensionReplacements.fingerprint(forward), ModdedDimensionReplacements.fingerprint(reversed));
assertNotEquals(ModdedDimensionReplacements.fingerprint(forward),
ModdedDimensionReplacements.fingerprint(Map.of("iristest:a", replacement("iristest:a", "overworld"))));
assertEquals("", ModdedDimensionReplacements.fingerprint(Map.of()));
assertTrue(ModdedDimensionReplacements.fingerprint(forward).contains("iristest:b=overworld:sky"));
}
@Test
public void installedPackTypeIsStagedForTheReplacement() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-stage-installed");
try {
Path datapack = temporary.resolve("datapack");
ModdedDimensionReplacements.StagedTypes staged = ModdedDimensionReplacements.StagedTypes.create();
staged.installed().put("skypack:sky", "irisworldgen:packs/x/dimensions/y/dimension_type");
ModdedDimensionReplacements.writeLevelStems(datapack,
Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), staged,
temporary.resolve("world"));
JSONObject stem = new JSONObject(Files.readString(datapack.resolve("data/aether/dimension/the_aether.json")));
assertEquals("irisworldgen:packs/x/dimensions/y/dimension_type", stem.getString("type"));
assertEquals("skypack:sky", stem.getJSONObject("generator").getString("dimension"));
} finally {
ModdedReplacementReconcilerTest.deleteTree(temporary);
}
}
@Test
public void retainedHistoryOfTheLevelStagesTheReplacementWithoutTheInstalledPack() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-stage-retained");
try {
Path datapack = temporary.resolve("datapack");
Path worldRoot = temporary.resolve("world");
ModdedDimensionReplacements.StagedTypes staged = ModdedDimensionReplacements.StagedTypes.create();
staged.retained().put(worldRoot.resolve("dimensions/aether/the_aether").toAbsolutePath().normalize(),
"aether:the_aether");
ModdedDimensionReplacements.writeLevelStems(datapack,
Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), staged, worldRoot);
JSONObject stem = new JSONObject(Files.readString(datapack.resolve("data/aether/dimension/the_aether.json")));
assertEquals("aether:the_aether", stem.getString("type"));
} finally {
ModdedReplacementReconcilerTest.deleteTree(temporary);
}
}
@Test
public void unstageableReplacementWithoutAnyRecordIsSkipped() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-stage-skip");
try {
Path datapack = temporary.resolve("datapack");
Path worldRoot = temporary.resolve("world");
Files.createDirectories(worldRoot);
Map<String, ModdedDimensionReplacement> configured =
Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky"));
ModdedDimensionReplacements.writeLevelStems(datapack, configured,
ModdedDimensionReplacements.StagedTypes.create(), worldRoot);
ModdedDimensionReplacements.writeLevelStems(datapack, configured,
ModdedDimensionReplacements.StagedTypes.create(), null);
assertFalse(Files.exists(datapack.resolve("data/aether/dimension/the_aether.json")));
} finally {
ModdedReplacementReconcilerTest.deleteTree(temporary);
}
}
@Test
public void unstageableReplacementOfAnAppliedLevelRefusesStaging() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-stage-applied");
try {
Path worldRoot = temporary.resolve("world");
ModdedReplacementReconciler.writeState(worldRoot, new ModdedReplacementReconciler.State(
Map.of("aether:the_aether", seeded("aether:the_aether", "skypack:sky", 5L)), Map.of()));
IOException refused = assertThrows(IOException.class, () -> ModdedDimensionReplacements.writeLevelStems(
temporary.resolve("datapack"),
Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")),
ModdedDimensionReplacements.StagedTypes.create(), worldRoot));
assertTrue(refused.getMessage(), refused.getMessage().contains("aether:the_aether"));
assertTrue(refused.getMessage(), refused.getMessage().contains("skypack"));
assertTrue(refused.getMessage(), refused.getMessage().contains("dimensionReplacements"));
} finally {
ModdedReplacementReconcilerTest.deleteTree(temporary);
}
}
@Test
public void unstageableReplacementOfALevelWithIrisHistoryRefusesStaging() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-stage-history");
try {
Path worldRoot = temporary.resolve("world");
Files.createDirectories(worldRoot.resolve("dimensions/aether/the_aether/iris/generation"));
assertThrows(IOException.class, () -> ModdedDimensionReplacements.writeLevelStems(
temporary.resolve("datapack"),
Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")),
ModdedDimensionReplacements.StagedTypes.create(), worldRoot));
} finally {
ModdedReplacementReconcilerTest.deleteTree(temporary);
}
}
@Test
public void canonicalReplacementFollowsTheInstalledPackFolderCaseAndKeepsTheSeed() throws IOException {
Path packsRoot = Files.createTempDirectory("iris-replace-canonical");
try {
Files.createDirectories(packsRoot.resolve("skypack/dimensions"));
Files.writeString(packsRoot.resolve("skypack/dimensions/sky.json"), "{}");
boolean caseInsensitive = Files.isDirectory(packsRoot.resolve("SKYPACK"));
Map<String, ModdedDimensionReplacement> canonical = ModdedDimensionReplacements.canonical(Map.of(
"aether:the_aether", seeded("aether:the_aether", "SkyPack:Sky", 3L),
"aether:other", replacement("aether:other", "missing:sky")), packsRoot);
assertEquals(caseInsensitive ? "skypack:sky" : "SkyPack:Sky", canonical.get("aether:the_aether").packRef());
assertEquals(OptionalLong.of(3L), canonical.get("aether:the_aether").seed());
assertEquals("missing:sky", canonical.get("aether:other").packRef());
assertTrue(ModdedDimensionReplacements.installed(replacement("aether:the_aether", "skypack:sky"), packsRoot));
assertFalse(ModdedDimensionReplacements.installed(replacement("aether:the_aether", "skypack:void"), packsRoot));
} finally {
ModdedReplacementReconcilerTest.deleteTree(packsRoot);
}
}
static ModdedDimensionReplacement replacement(String levelKey, String type) {
return ModdedDimensionReplacement.parse(levelKey, type, OptionalLong.empty());
}
static ModdedDimensionReplacement seeded(String levelKey, String type, long seed) {
return ModdedDimensionReplacement.parse(levelKey, type, OptionalLong.of(seed));
}
}
@@ -1,246 +0,0 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedDimensionTakeoversTest {
@Test
public void parsesPackAndPackDimensionReferences() {
ModdedDimensionTakeover samePack = ModdedDimensionTakeover.parse("iristest:skylands", "skypack");
ModdedDimensionTakeover scoped = ModdedDimensionTakeover.parse("aether:the_aether", "overworld:floating/sky");
assertEquals("skypack", samePack.pack());
assertEquals("skypack", samePack.packDimension());
assertEquals("skypack", samePack.packRef());
assertEquals("overworld", scoped.pack());
assertEquals("floating/sky", scoped.packDimension());
assertEquals("overworld:floating/sky", scoped.packRef());
assertEquals("aether", scoped.namespace());
assertEquals("the_aether", scoped.path());
}
@Test
public void rejectsInvalidLevelKeysAndPackReferences() {
for (String levelKey : List.of("skylands", "Iristest:skylands", "iristest:", ":skylands", "iristest:sky/../x", "iristest:/sky")) {
assertThrows(levelKey, IllegalArgumentException.class, () -> ModdedDimensionTakeover.parse(levelKey, "overworld"));
}
for (String packRef : List.of("", " ", "../overworld", "overworld:", "over world", "overworld:a/../b")) {
assertThrows(packRef, IllegalArgumentException.class, () -> ModdedDimensionTakeover.parse("iristest:skylands", packRef));
}
}
@Test
public void levelStoragePathFollowsTheDimensionsLayout() {
Path worldRoot = Path.of("/srv/world");
assertEquals(worldRoot.resolve("dimensions").resolve("iristest").resolve("skylands"),
ModdedDimensionTakeover.parse("iristest:skylands", "overworld").storageFolder(worldRoot));
assertEquals(worldRoot.resolve("dimensions").resolve("aether").resolve("realm").resolve("upper"),
ModdedDimensionTakeover.parse("aether:realm/upper", "overworld").storageFolder(worldRoot));
}
@Test
public void levelStemUsesTheIrisGeneratorWithThePresetBiomeSourcePlaceholder() {
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse("iristest:skylands", "overworld:sky");
JSONObject stem = new JSONObject(ModdedDimensionTakeovers.levelStemJson("iristest:skylands", takeover));
assertEquals("iristest:skylands", stem.getString("type"));
JSONObject generator = stem.getJSONObject("generator");
assertEquals("irisworldgen:iris", generator.getString("type"));
assertEquals("overworld:sky", generator.getString("dimension"));
assertEquals("minecraft:fixed", generator.getJSONObject("biome_source").getString("type"));
assertEquals("minecraft:plains", generator.getJSONObject("biome_source").getString("biome"));
}
@Test
public void levelStemIsWrittenAtTheDatapackDimensionPathOfTheLevelKey() throws IOException {
Path datapack = Files.createTempDirectory("iris-takeover-stem");
try {
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse("aether:the_aether", "overworld");
Path written = ModdedDimensionTakeovers.writeLevelStem(datapack, takeover, "aether:the_aether");
assertEquals(datapack.resolve("data").resolve("aether").resolve("dimension").resolve("the_aether.json"), written);
assertEquals("aether:the_aether", new JSONObject(Files.readString(written)).getString("type"));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(datapack);
}
}
@Test
public void changedLevelsCoverAddedChangedAndRemovedTakeovers() {
Map<String, String> applied = Map.of(
"iristest:kept", "overworld",
"iristest:changed", "overworld",
"iristest:removed", "overworld");
Map<String, String> 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<String, String> forward = new LinkedHashMap<>();
forward.put("iristest:a", "overworld");
forward.put("iristest:b", "overworld:sky");
Map<String, String> reversed = new LinkedHashMap<>();
reversed.put("iristest:b", "overworld:sky");
reversed.put("iristest:a", "overworld");
assertEquals(ModdedDimensionTakeovers.fingerprint(forward), ModdedDimensionTakeovers.fingerprint(reversed));
assertNotEquals(ModdedDimensionTakeovers.fingerprint(forward),
ModdedDimensionTakeovers.fingerprint(Map.of("iristest:a", "overworld")));
assertEquals("", ModdedDimensionTakeovers.fingerprint(Map.of()));
assertTrue(ModdedDimensionTakeovers.fingerprint(forward).contains("iristest:b=overworld:sky"));
}
@Test
public void installedPackTypeIsStagedForTheTakeover() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-stage-installed");
try {
Path datapack = temporary.resolve("datapack");
ModdedDimensionTakeovers.StagedTypes staged = ModdedDimensionTakeovers.StagedTypes.create();
staged.installed().put("skypack:sky", "irisworldgen:packs/x/dimensions/y/dimension_type");
ModdedDimensionTakeovers.writeLevelStems(datapack, Map.of("aether:the_aether", "skypack:sky"), staged,
temporary.resolve("world"));
JSONObject stem = new JSONObject(Files.readString(datapack.resolve("data/aether/dimension/the_aether.json")));
assertEquals("irisworldgen:packs/x/dimensions/y/dimension_type", stem.getString("type"));
assertEquals("skypack:sky", stem.getJSONObject("generator").getString("dimension"));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(temporary);
}
}
@Test
public void retainedHistoryOfTheLevelStagesTheTakeoverWithoutTheInstalledPack() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-stage-retained");
try {
Path datapack = temporary.resolve("datapack");
Path worldRoot = temporary.resolve("world");
ModdedDimensionTakeovers.StagedTypes staged = ModdedDimensionTakeovers.StagedTypes.create();
staged.retained().put(worldRoot.resolve("dimensions/aether/the_aether").toAbsolutePath().normalize(),
"aether:the_aether");
ModdedDimensionTakeovers.writeLevelStems(datapack, Map.of("aether:the_aether", "skypack:sky"), staged, worldRoot);
JSONObject stem = new JSONObject(Files.readString(datapack.resolve("data/aether/dimension/the_aether.json")));
assertEquals("aether:the_aether", stem.getString("type"));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(temporary);
}
}
@Test
public void unstageableTakeoverWithoutAnyRecordIsSkipped() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-stage-skip");
try {
Path datapack = temporary.resolve("datapack");
Path worldRoot = temporary.resolve("world");
Files.createDirectories(worldRoot);
ModdedDimensionTakeovers.writeLevelStems(datapack, Map.of("aether:the_aether", "skypack:sky"),
ModdedDimensionTakeovers.StagedTypes.create(), worldRoot);
ModdedDimensionTakeovers.writeLevelStems(datapack, Map.of("aether:the_aether", "skypack:sky"),
ModdedDimensionTakeovers.StagedTypes.create(), null);
assertFalse(Files.exists(datapack.resolve("data/aether/dimension/the_aether.json")));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(temporary);
}
}
@Test
public void unstageableTakeoverOfAnAppliedLevelRefusesStaging() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-stage-applied");
try {
Path worldRoot = temporary.resolve("world");
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("aether:the_aether", "skypack:sky"));
IOException refused = assertThrows(IOException.class, () -> ModdedDimensionTakeovers.writeLevelStems(
temporary.resolve("datapack"), Map.of("aether:the_aether", "skypack:sky"),
ModdedDimensionTakeovers.StagedTypes.create(), worldRoot));
assertTrue(refused.getMessage(), refused.getMessage().contains("aether:the_aether"));
assertTrue(refused.getMessage(), refused.getMessage().contains("skypack"));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(temporary);
}
}
@Test
public void unstageableTakeoverOfALevelWithIrisHistoryRefusesStaging() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-stage-history");
try {
Path worldRoot = temporary.resolve("world");
Files.createDirectories(worldRoot.resolve("dimensions/aether/the_aether/iris/generation"));
assertThrows(IOException.class, () -> ModdedDimensionTakeovers.writeLevelStems(
temporary.resolve("datapack"), Map.of("aether:the_aether", "skypack:sky"),
ModdedDimensionTakeovers.StagedTypes.create(), worldRoot));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(temporary);
}
}
@Test
public void canonicalTakeoverFollowsTheInstalledPackFolderCase() throws IOException {
Path packsRoot = Files.createTempDirectory("iris-takeover-canonical");
try {
Files.createDirectories(packsRoot.resolve("skypack/dimensions"));
Files.writeString(packsRoot.resolve("skypack/dimensions/sky.json"), "{}");
boolean caseInsensitive = Files.isDirectory(packsRoot.resolve("SKYPACK"));
Map<String, String> canonical = ModdedDimensionTakeovers.canonical(
Map.of("aether:the_aether", "SkyPack:Sky", "aether:other", "missing:sky"), packsRoot);
assertEquals(caseInsensitive ? "skypack:sky" : "SkyPack:Sky", canonical.get("aether:the_aether"));
assertEquals("missing:sky", canonical.get("aether:other"));
assertEquals("skypack:sky", ModdedDimensionTakeovers.canonical(
Map.of("aether:the_aether", "skypack:sky"), packsRoot).get("aether:the_aether"));
assertTrue(ModdedDimensionTakeovers.installed(ModdedDimensionTakeover.parse("aether:the_aether", "skypack:sky"), packsRoot));
assertFalse(ModdedDimensionTakeovers.installed(ModdedDimensionTakeover.parse("aether:the_aether", "skypack:void"), packsRoot));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(packsRoot);
}
}
}
@@ -26,64 +26,72 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class ModdedModConfigTest {
@Test
public void validTakeoversLoadWithoutProblems() throws IOException {
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld:sky\"}}");
public void validReplacementsLoadWithoutProblems() throws IOException {
Path file = config("{\"dimensionReplacements\": {\"aether:the_aether\": {\"type\": \"overworld:sky\"},"
+ " \"minecraft:overworld\": {\"type\": \"overworld\", \"seed\": -42}}}");
try {
ModdedModConfig config = ModdedModConfig.read(file);
assertEquals(Map.of("aether:the_aether", "overworld:sky"), config.dimensionTakeovers());
assertEquals(List.of(), config.takeoverProblems());
assertEquals(Map.of(
"aether:the_aether", ModdedDimensionReplacement.parse("aether:the_aether", "overworld:sky", OptionalLong.empty()),
"minecraft:overworld", ModdedDimensionReplacement.parse("minecraft:overworld", "overworld", OptionalLong.of(-42L))),
config.dimensionReplacements());
assertEquals(List.of(), config.replacementProblems());
} finally {
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
ModdedReplacementReconcilerTest.deleteTree(file.getParent());
}
}
@Test
public void unparseableConfigReportsAProblemNamingTheFile() throws IOException {
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": ");
Path file = config("{\"dimensionReplacements\": {\"aether:the_aether\": ");
try {
ModdedModConfig config = ModdedModConfig.read(file);
assertEquals(Map.of(), config.dimensionTakeovers());
assertEquals(1, config.takeoverProblems().size());
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains(file.toString()));
assertEquals(Map.of(), config.dimensionReplacements());
assertEquals(1, config.replacementProblems().size());
assertTrue(config.replacementProblems().getFirst(), config.replacementProblems().getFirst().contains(file.toString()));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
ModdedReplacementReconcilerTest.deleteTree(file.getParent());
}
}
@Test
public void invalidTakeoverEntryIsReportedInsteadOfSilentlyDropped() throws IOException {
Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld\", \"Broken Key\": \"overworld\"}}");
public void invalidReplacementEntriesAreReportedInsteadOfSilentlyDropped() throws IOException {
Path file = config("{\"dimensionReplacements\": {\"aether:the_aether\": {\"type\": \"overworld\"},"
+ " \"Broken Key\": {\"type\": \"overworld\"}, \"aether:plain\": \"overworld\"}}");
try {
ModdedModConfig config = ModdedModConfig.read(file);
assertEquals(Map.of("aether:the_aether", "overworld"), config.dimensionTakeovers());
assertEquals(1, config.takeoverProblems().size());
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains("Broken Key"));
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains(file.toString()));
assertEquals(Map.of("aether:the_aether", ModdedDimensionReplacement.parse("aether:the_aether", "overworld",
OptionalLong.empty())), config.dimensionReplacements());
assertEquals(2, config.replacementProblems().size());
assertTrue(config.replacementProblems().toString(), config.replacementProblems().get(0).contains("Broken Key"));
assertTrue(config.replacementProblems().toString(), config.replacementProblems().get(1).contains("aether:plain"));
assertTrue(config.replacementProblems().getFirst(), config.replacementProblems().getFirst().contains(file.toString()));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
ModdedReplacementReconcilerTest.deleteTree(file.getParent());
}
}
@Test
public void takeoverSectionThatIsNotAnObjectIsReported() throws IOException {
Path file = config("{\"dimensionTakeovers\": [\"aether:the_aether\"]}");
public void replacementSectionThatIsNotAnObjectIsReported() throws IOException {
Path file = config("{\"dimensionReplacements\": [\"aether:the_aether\"]}");
try {
ModdedModConfig config = ModdedModConfig.read(file);
assertEquals(Map.of(), config.dimensionTakeovers());
assertEquals(1, config.takeoverProblems().size());
assertTrue(config.takeoverProblems().getFirst(), config.takeoverProblems().getFirst().contains("dimensionTakeovers"));
assertEquals(Map.of(), config.dimensionReplacements());
assertEquals(1, config.replacementProblems().size());
assertTrue(config.replacementProblems().getFirst(), config.replacementProblems().getFirst().contains("dimensionReplacements"));
} finally {
ModdedTakeoverReconcilerTest.deleteTree(file.getParent());
ModdedReplacementReconcilerTest.deleteTree(file.getParent());
}
}
@@ -0,0 +1,584 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationPackFingerprint;
import art.arcane.iris.world.lifecycle.WorldReplacementEntryGuard;
import org.junit.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import static art.arcane.iris.modded.ModdedDimensionReplacementsTest.replacement;
import static art.arcane.iris.modded.ModdedDimensionReplacementsTest.seeded;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedReplacementReconcilerTest {
@Test
public void stateRoundTripsAppliedAndPendingEntriesAndDefaultsToEmpty() throws IOException {
Path worldRoot = Files.createTempDirectory("iris-replace-state");
try {
assertEquals(ModdedReplacementReconciler.State.EMPTY, ModdedReplacementReconciler.readState(worldRoot));
ModdedReplacementReconciler.State state = new ModdedReplacementReconciler.State(
Map.of("iristest:skylands", seeded("iristest:skylands", "overworld:sky", 5L)),
Map.of("irisworldgen:myworld", seeded("irisworldgen:myworld", "underworld", 9L)));
ModdedReplacementReconciler.writeState(worldRoot, state);
assertEquals(state, ModdedReplacementReconciler.readState(worldRoot));
assertTrue(Files.isRegularFile(worldRoot.resolve("data/irisworldgen/dimension-replacements.json")));
} finally {
deleteTree(worldRoot);
}
}
@Test
public void newReplacementQuarantinesOnlyThatLevelsChunkDataAndKeepsPlayerData() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-reconcile");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris", "data")) {
touch(skylands.resolve(folder).resolve("payload.bin"));
}
touch(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca"));
touch(worldRoot.resolve("players/data/player.dat"));
ModdedReplacementReconciler.Result result = reconcile(worldRoot,
Map.of("iristest:skylands", replacement("iristest:skylands", "overworld")), List.of(), recoveryParent);
assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels());
assertTrue(result.recovery().getFileName().toString().startsWith("replacement-recovery-"));
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) {
assertFalse(folder, Files.exists(skylands.resolve(folder)));
assertTrue(folder, Files.isRegularFile(result.recovery()
.resolve("dimensions/iristest/skylands").resolve(folder).resolve("payload.bin")));
}
assertTrue(Files.isRegularFile(skylands.resolve("data/payload.bin")));
assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertTrue(Files.isRegularFile(worldRoot.resolve("players/data/player.dat")));
assertTrue(WorldReplacementEntryGuard.load(skylands).isEmpty());
assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void overworldReplacementGuardsEveryKnownPlayerUntilTheyEnterSafely() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-overworld");
Path worldRoot = temporary.resolve("world");
try {
UUID player = UUID.randomUUID();
Path overworld = worldRoot.resolve("dimensions/minecraft/overworld");
touch(overworld.resolve("region/r.0.0.mca"));
touch(worldRoot.resolve("players/data/" + player + ".dat"));
ModdedReplacementReconciler.Result result = reconcile(worldRoot,
Map.of("minecraft:overworld", seeded("minecraft:overworld", "overworld", 123L)), List.of(),
temporary.resolve("config"));
assertEquals(Set.of("minecraft:overworld"), result.quarantinedLevels());
assertFalse(Files.exists(overworld.resolve("region")));
Optional<WorldReplacementEntryGuard.Entry> guard = WorldReplacementEntryGuard.load(overworld);
assertTrue(guard.isPresent());
assertEquals(Set.of(player), guard.get().pendingPlayers());
} finally {
deleteTree(temporary);
}
}
@Test
public void unchangedReplacementIsANoOpAndRemovalQuarantinesTheIrisChunks() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-cycle");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
writeApplied(worldRoot, seeded("iristest:skylands", "overworld", 5L));
touch(region);
ModdedReplacementReconciler.Result unchanged = reconcile(worldRoot,
Map.of("iristest:skylands", replacement("iristest:skylands", "overworld")), List.of(), recoveryParent);
assertEquals(Set.of(), unchanged.quarantinedLevels());
assertNull(unchanged.recovery());
assertTrue(Files.isRegularFile(region));
ModdedReplacementReconciler.Result removed = reconcile(worldRoot, Map.of(), List.of(), recoveryParent);
assertEquals(Set.of("iristest:skylands"), removed.quarantinedLevels());
assertFalse(Files.exists(region));
assertTrue(Files.isRegularFile(removed.recovery().resolve("dimensions/iristest/skylands/region/r.0.0.mca")));
assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void newReplacementWithoutChunkDataStaysPendingWithoutARecoveryFolder() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-empty");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Files.createDirectories(worldRoot);
ModdedReplacementReconciler.Result result = reconcile(worldRoot,
Map.of("aether:the_aether", replacement("aether:the_aether", "overworld")), List.of(), recoveryParent);
assertEquals(Set.of("aether:the_aether"), result.quarantinedLevels());
assertNull(result.recovery());
assertFalse(Files.exists(recoveryParent));
assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void unreadableConfigWithRecordedReplacementsRefusesWithoutMovingAnything() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-unreadable");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
writeApplied(worldRoot, seeded("iristest:skylands", "overworld", 5L));
touch(region);
List<String> problems = List.of("Iris could not read /srv/config/irisworldgen/modded.json: Unterminated object");
IOException refused = assertThrows(IOException.class, () -> reconcile(
worldRoot, Map.of(), problems, recoveryParent));
assertTrue(refused.getMessage(), refused.getMessage().contains("modded.json"));
assertTrue(Files.isRegularFile(region));
assertFalse(Files.exists(recoveryParent));
assertEquals(Set.of("iristest:skylands"), ModdedReplacementReconciler.readState(worldRoot).applied().keySet());
} finally {
deleteTree(temporary);
}
}
@Test
public void unreadableConfigWithAnEmptyStateFileStillRefuses() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-unreadable-empty");
Path worldRoot = temporary.resolve("world");
try {
ModdedReplacementReconciler.writeState(worldRoot, ModdedReplacementReconciler.State.EMPTY);
assertThrows(IOException.class, () -> reconcile(
worldRoot, Map.of(), List.of("invalid replacement entry"), temporary.resolve("config")));
} finally {
deleteTree(temporary);
}
}
@Test
public void unreadableConfigWithoutRecordedStateProceeds() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-unreadable-fresh");
Path worldRoot = temporary.resolve("world");
try {
Files.createDirectories(worldRoot);
ModdedReplacementReconciler.Result result = reconcile(
worldRoot, Map.of(), List.of("invalid replacement entry"), temporary.resolve("config"));
assertEquals(Set.of(), result.quarantinedLevels());
} finally {
deleteTree(temporary);
}
}
@Test
public void changedReplacementKeepsThePreviousStateUntilTheNewPackGenerates() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-change");
Path worldRoot = temporary.resolve("world");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
ModdedDimensionReplacement previous = seeded("iristest:skylands", "overworld", 5L);
writeApplied(worldRoot, previous);
touch(region);
ModdedReplacementReconciler.Result result = reconcile(worldRoot,
Map.of("iristest:skylands", replacement("iristest:skylands", "skypack")), List.of(),
temporary.resolve("config"));
assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels());
assertFalse(Files.exists(region));
assertEquals(Map.of("iristest:skylands", previous), ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void existingHistoryOfTheConfiguredPackAndSeedIsAdoptedWithoutQuarantine() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-history-match");
Path worldRoot = temporary.resolve("world");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of());
touch(skylands.resolve("region/r.0.0.mca"));
ModdedReplacementReconciler.Result result = reconcile(worldRoot,
Map.of("iristest:skylands", seeded("iristest:skylands", "skypack:sky", 5L)), List.of(),
temporary.resolve("config"));
assertEquals(Set.of("iristest:skylands"), result.adoptedLevels());
assertNull(result.recovery());
assertTrue(Files.isRegularFile(skylands.resolve("region/r.0.0.mca")));
assertEquals(Map.of("iristest:skylands", seeded("iristest:skylands", "skypack:sky", 5L)),
ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void restagingTheAppliedReplacementRegeneratesItAndKeepsItApplied() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-refresh");
Path worldRoot = temporary.resolve("world");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of());
touch(skylands.resolve("region/r.0.0.mca"));
ModdedDimensionReplacement applied = seeded("iristest:skylands", "skypack:sky", 5L);
writeApplied(worldRoot, applied);
ModdedReplacementReconciler.stagePending(worldRoot, applied);
ModdedReplacementReconciler.stagePending(worldRoot, seeded("iristest:unconfigured", "skypack:sky", 5L));
touch(worldRoot.resolve("dimensions/iristest/unconfigured/region/r.0.0.mca"));
ModdedReplacementReconciler.Result result = reconcile(worldRoot,
Map.of("iristest:skylands", applied), List.of(), temporary.resolve("config"));
assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels());
assertEquals(Set.of(), result.adoptedLevels());
assertFalse(Files.exists(skylands.resolve("region")));
assertFalse(Files.exists(skylands.resolve("iris/generation")));
assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/iristest/unconfigured/region/r.0.0.mca")));
assertEquals(ModdedReplacementReconciler.State.EMPTY.pending(),
ModdedReplacementReconciler.readState(worldRoot).pending());
assertEquals(Map.of("iristest:skylands", applied), ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void historyOfAnotherSeedIsQuarantined() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-history-seed");
Path worldRoot = temporary.resolve("world");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of());
touch(skylands.resolve("region/r.0.0.mca"));
ModdedReplacementReconciler.Result result = reconcile(worldRoot,
Map.of("iristest:skylands", seeded("iristest:skylands", "skypack:sky", 6L)), List.of(),
temporary.resolve("config"));
assertEquals(Set.of(), result.adoptedLevels());
assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels());
assertFalse(Files.exists(skylands.resolve("region")));
} finally {
deleteTree(temporary);
}
}
@Test
public void historyOfAnotherPackIsQuarantined() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-history-other");
Path worldRoot = temporary.resolve("world");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("otherpack", "sky"), "sky", List.of());
touch(skylands.resolve("region/r.0.0.mca"));
ModdedReplacementReconciler.Result result = reconcile(worldRoot,
Map.of("iristest:skylands", replacement("iristest:skylands", "skypack:sky")), List.of(),
temporary.resolve("config"));
assertEquals(Set.of(), result.adoptedLevels());
assertFalse(Files.exists(skylands.resolve("region")));
assertFalse(Files.exists(skylands.resolve("iris")));
assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void borrowedHistoryMatchesOnItsRecordedAliasPack() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-history-borrowed");
Path worldRoot = temporary.resolve("world");
try {
Path aether = worldRoot.resolve("dimensions/aether/the_aether");
createHistory(temporary, aether, "aether:the_aether", "sky",
List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow")));
touch(aether.resolve("region/r.0.0.mca"));
ModdedReplacementReconciler.Result other = reconcile(worldRoot,
Map.of("aether:the_aether", replacement("aether:the_aether", "otherpack:sky")), List.of(),
temporary.resolve("config"));
assertEquals(Set.of(), other.adoptedLevels());
assertFalse(Files.exists(aether.resolve("region")));
createHistory(temporary, aether, "aether:the_aether", "sky",
List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow")));
ModdedReplacementReconciler.Result matching = reconcile(worldRoot,
Map.of("aether:the_aether", replacement("aether:the_aether", "skypack:sky")), List.of(),
temporary.resolve("config"));
assertEquals(Set.of("aether:the_aether"), matching.adoptedLevels());
assertTrue(Files.isDirectory(aether.resolve("iris")));
} finally {
deleteTree(temporary);
}
}
@Test
public void pendingIrisWorldReplacementMovesItsTerrainAndRepointsItsRegistryEntry() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-iris-world");
Path worldRoot = temporary.resolve("world");
try {
Path myworld = worldRoot.resolve("dimensions/irisworldgen/myworld");
touch(myworld.resolve("region/r.0.0.mca"));
touch(myworld.resolve("iris/generation/manifest.json"));
ModdedDimensionRegistryStore.write(registryFile(worldRoot), List.of(
new ModdedDimensionRegistryStore.PersistentDimension("irisworldgen:myworld", "overworld", "overworld", 1L),
new ModdedDimensionRegistryStore.PersistentDimension("irisworldgen:other", "overworld", "overworld", 2L)));
ModdedReplacementReconciler.stagePending(worldRoot, seeded("irisworldgen:myworld", "underworld", 9L));
ModdedReplacementReconciler.Result result = reconcile(worldRoot, Map.of(), List.of(), temporary.resolve("config"));
assertEquals(Set.of("irisworldgen:myworld"), result.replacedWorlds());
assertFalse(Files.exists(myworld.resolve("region")));
assertFalse(Files.exists(myworld.resolve("iris")));
assertTrue(Files.isRegularFile(result.recovery().resolve("dimensions/irisworldgen/myworld/region/r.0.0.mca")));
assertEquals(List.of(
new ModdedDimensionRegistryStore.PersistentDimension("irisworldgen:myworld", "underworld", "underworld", 9L),
new ModdedDimensionRegistryStore.PersistentDimension("irisworldgen:other", "overworld", "overworld", 2L)),
ModdedDimensionRegistryStore.loadWorldRoot(worldRoot));
assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).pending());
} finally {
deleteTree(temporary);
}
}
@Test
public void pendingReplacementOfARemovedIrisWorldIsDroppedWithoutMovingAnything() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-iris-world-gone");
Path worldRoot = temporary.resolve("world");
try {
Path region = worldRoot.resolve("dimensions/irisworldgen/myworld/region/r.0.0.mca");
touch(region);
ModdedReplacementReconciler.stagePending(worldRoot, seeded("irisworldgen:myworld", "underworld", 9L));
ModdedReplacementReconciler.Result result = reconcile(worldRoot, Map.of(), List.of(), temporary.resolve("config"));
assertEquals(Set.of(), result.replacedWorlds());
assertTrue(Files.isRegularFile(region));
assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).pending());
} finally {
deleteTree(temporary);
}
}
@Test
public void pendingIrisWorldReplacementsCanBeCancelledBeforeTheRestart() throws IOException {
Path worldRoot = Files.createTempDirectory("iris-replace-cancel");
try {
ModdedReplacementReconciler.stagePending(worldRoot, seeded("irisworldgen:myworld", "underworld", 9L));
assertTrue(ModdedReplacementReconciler.cancelPending(worldRoot, "irisworldgen:myworld"));
assertFalse(ModdedReplacementReconciler.cancelPending(worldRoot, "irisworldgen:myworld"));
assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).pending());
} finally {
deleteTree(worldRoot);
}
}
@Test
public void recordAppliedStoresOnlyReplacementsThatGenerateFromTheirPackAndSeed() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-record");
Path worldRoot = temporary.resolve("world");
try {
writeApplied(worldRoot, seeded("iristest:kept", "overworld", 5L));
Map<String, ModdedDimensionReplacement> recorded = ModdedReplacementReconciler.recordApplied(worldRoot,
Map.of("iristest:kept", replacement("iristest:kept", "overworld"),
"iristest:live", replacement("iristest:live", "skypack"),
"iristest:seeded", seeded("iristest:seeded", "skypack", 8L),
"iristest:wrongseed", seeded("iristest:wrongseed", "skypack", 8L),
"iristest:skipped", replacement("iristest:skipped", "skypack")),
(String level, String packRef) -> switch (level) {
case "iristest:skipped" -> OptionalLong.empty();
case "iristest:wrongseed" -> OptionalLong.of(9L);
case "iristest:seeded" -> OptionalLong.of(8L);
default -> OptionalLong.of(5L);
});
Map<String, ModdedDimensionReplacement> expected = Map.of(
"iristest:kept", seeded("iristest:kept", "overworld", 5L),
"iristest:live", seeded("iristest:live", "skypack", 5L),
"iristest:seeded", seeded("iristest:seeded", "skypack", 8L));
assertEquals(expected, recorded);
assertEquals(expected, ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void recordAppliedFailsWhenTheStateCannotBeWritten() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-record-fail");
Path worldRoot = temporary.resolve("world");
try {
touch(worldRoot.resolve("data/irisworldgen/dimension-replacements.json/blocker"));
assertThrows(IOException.class, () -> ModdedReplacementReconciler.recordApplied(worldRoot,
Map.of("iristest:live", replacement("iristest:live", "skypack")),
(String level, String packRef) -> OptionalLong.of(1L)));
} finally {
deleteTree(temporary);
}
}
@Test
public void switchingAnAppliedLevelToAnUninstalledPackRefusesBeforeMovingAnything() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-uninstalled-applied");
Path worldRoot = temporary.resolve("world");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
writeApplied(worldRoot, seeded("iristest:skylands", "overworld", 5L));
touch(region);
IOException refused = assertThrows(IOException.class, () -> ModdedReplacementReconciler.reconcile(
new ModdedReplacementReconciler.Request(worldRoot,
Map.of("iristest:skylands", replacement("iristest:skylands", "missing")), List.of(),
(ModdedDimensionReplacement replacement) -> false, temporary.resolve("config"))));
assertTrue(refused.getMessage(), refused.getMessage().contains("missing"));
assertTrue(Files.isRegularFile(region));
assertEquals(Set.of("iristest:skylands"), ModdedReplacementReconciler.readState(worldRoot).applied().keySet());
} finally {
deleteTree(temporary);
}
}
@Test
public void newReplacementOfAnUninstalledPackLeavesTheLevelAlone() throws IOException {
Path temporary = Files.createTempDirectory("iris-replace-uninstalled-new");
Path worldRoot = temporary.resolve("world");
try {
Path region = worldRoot.resolve("dimensions/aether/the_aether/region/r.0.0.mca");
touch(region);
ModdedReplacementReconciler.Result result = ModdedReplacementReconciler.reconcile(
new ModdedReplacementReconciler.Request(worldRoot,
Map.of("aether:the_aether", replacement("aether:the_aether", "missing")), List.of(),
(ModdedDimensionReplacement replacement) -> false, temporary.resolve("config")));
assertEquals(Set.of(), result.quarantinedLevels());
assertTrue(Files.isRegularFile(region));
assertEquals(Map.of(), ModdedReplacementReconciler.readState(worldRoot).applied());
} finally {
deleteTree(temporary);
}
}
@Test
public void pendingCoversStagedLevelStemsAndIrisWorlds() throws IOException {
Path worldRoot = Files.createTempDirectory("iris-replace-pending");
try {
writeApplied(worldRoot, seeded("minecraft:overworld", "overworld", 5L));
ModdedReplacementReconciler.stagePending(worldRoot, seeded("irisworldgen:myworld", "underworld", 9L));
Map<String, ModdedDimensionReplacement> configured = Map.of(
"minecraft:overworld", seeded("minecraft:overworld", "overworld", 5L),
"minecraft:the_nether", seeded("minecraft:the_nether", "underworld", 5L));
assertFalse(ModdedReplacementReconciler.pending(worldRoot, "minecraft:overworld", configured));
assertTrue(ModdedReplacementReconciler.pending(worldRoot, "minecraft:the_nether", configured));
assertTrue(ModdedReplacementReconciler.pending(worldRoot, "irisworldgen:myworld", configured));
assertFalse(ModdedReplacementReconciler.pending(worldRoot, "minecraft:the_end", configured));
} finally {
deleteTree(worldRoot);
}
}
private static ModdedReplacementReconciler.Result reconcile(Path worldRoot, Map<String, ModdedDimensionReplacement> configured,
List<String> problems, Path recoveryParent) throws IOException {
return ModdedReplacementReconciler.reconcile(new ModdedReplacementReconciler.Request(
worldRoot, configured, problems, (ModdedDimensionReplacement replacement) -> true, recoveryParent));
}
private static void writeApplied(Path worldRoot, ModdedDimensionReplacement applied) throws IOException {
ModdedReplacementReconciler.writeState(worldRoot, new ModdedReplacementReconciler.State(
Map.of(applied.levelKey(), applied), Map.of()));
}
private static Path registryFile(Path worldRoot) {
return worldRoot.resolve("iris").resolve("iris-dimensions.json");
}
private static void createHistory(Path temporary, Path dimensionRoot, String dimensionTypeKey, String dimensionKey,
List<String> biomeKeys) throws IOException {
Path source = Files.createTempDirectory(temporary, "pack");
touch(source.resolve("dimensions/" + dimensionKey + ".json"));
GenerationEpoch epoch = ModdedRetainedPackNameTest.epoch(dimensionTypeKey, dimensionKey, biomeKeys);
Files.createDirectories(dimensionRoot);
GenerationHistory.create(dimensionRoot, source,
GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION), 5L,
epoch.dimensionContract(), epoch.registryContract());
}
private static void touch(Path file) throws IOException {
Files.createDirectories(file.getParent());
Files.writeString(file, "x", StandardCharsets.UTF_8);
}
static void deleteTree(Path root) throws IOException {
if (!Files.exists(root)) {
return;
}
try (Stream<Path> walk = Files.walk(root)) {
for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
@@ -1,386 +0,0 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationPackFingerprint;
import org.junit.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedTakeoverReconcilerTest {
@Test
public void appliedStateRoundTripsAndDefaultsToEmpty() throws IOException {
Path worldRoot = Files.createTempDirectory("iris-takeover-state");
try {
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld:sky"));
assertEquals(Map.of("iristest:skylands", "overworld:sky"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(worldRoot);
}
}
@Test
public void newTakeoverQuarantinesOnlyThatLevelsChunkDataAndKeepsPlayerData() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-reconcile");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris", "data")) {
touch(skylands.resolve(folder).resolve("payload.bin"));
}
touch(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca"));
touch(worldRoot.resolve("players/data/player.dat"));
ModdedTakeoverReconciler.Result result = reconcile(
worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent);
assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels());
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) {
assertFalse(folder, Files.exists(skylands.resolve(folder)));
assertTrue(folder, Files.isRegularFile(result.recovery()
.resolve("dimensions/iristest/skylands").resolve(folder).resolve("payload.bin")));
}
assertTrue(Files.isRegularFile(skylands.resolve("data/payload.bin")));
assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertTrue(Files.isRegularFile(worldRoot.resolve("players/data/player.dat")));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void unchangedTakeoverIsANoOpAndRemovalQuarantinesTheIrisChunks() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-cycle");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld"));
touch(region);
ModdedTakeoverReconciler.Result unchanged = reconcile(
worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent);
assertEquals(Set.of(), unchanged.quarantinedLevels());
assertNull(unchanged.recovery());
assertTrue(Files.isRegularFile(region));
ModdedTakeoverReconciler.Result removed = reconcile(
worldRoot, Map.of(), List.of(), recoveryParent);
assertEquals(Set.of("iristest:skylands"), removed.quarantinedLevels());
assertFalse(Files.exists(region));
assertTrue(Files.isRegularFile(removed.recovery().resolve("dimensions/iristest/skylands/region/r.0.0.mca")));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void newTakeoverWithoutChunkDataStaysPendingWithoutARecoveryFolder() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-empty");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Files.createDirectories(worldRoot);
ModdedTakeoverReconciler.Result result = reconcile(
worldRoot, Map.of("aether:the_aether", "overworld"), List.of(), recoveryParent);
assertEquals(Set.of("aether:the_aether"), result.quarantinedLevels());
assertNull(result.recovery());
assertFalse(Files.exists(recoveryParent));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void unreadableConfigWithRecordedTakeoversRefusesWithoutMovingAnything() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-unreadable");
Path worldRoot = temporary.resolve("world");
Path recoveryParent = temporary.resolve("config");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld"));
touch(region);
List<String> problems = List.of("Iris could not read /srv/config/irisworldgen/modded.json: Unterminated object");
IOException refused = assertThrows(IOException.class, () -> reconcile(
worldRoot, Map.of(), problems, recoveryParent));
assertTrue(refused.getMessage(), refused.getMessage().contains("modded.json"));
assertTrue(Files.isRegularFile(region));
assertFalse(Files.exists(recoveryParent));
assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void unreadableConfigWithAnEmptyStateFileStillRefuses() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-unreadable-empty");
Path worldRoot = temporary.resolve("world");
try {
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of());
assertThrows(IOException.class, () -> reconcile(
worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config")));
} finally {
deleteTree(temporary);
}
}
@Test
public void unreadableConfigWithoutRecordedStateProceeds() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-unreadable-fresh");
Path worldRoot = temporary.resolve("world");
try {
Files.createDirectories(worldRoot);
ModdedTakeoverReconciler.Result result = reconcile(
worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config"));
assertEquals(Set.of(), result.quarantinedLevels());
} finally {
deleteTree(temporary);
}
}
@Test
public void changedTakeoverKeepsThePreviousStateUntilTheNewPackGenerates() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-change");
Path worldRoot = temporary.resolve("world");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld"));
touch(region);
ModdedTakeoverReconciler.Result result = reconcile(
worldRoot, Map.of("iristest:skylands", "skypack"), List.of(), temporary.resolve("config"));
assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels());
assertFalse(Files.exists(region));
assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void existingHistoryOfTheConfiguredPackIsAdoptedWithoutQuarantine() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-history-match");
Path worldRoot = temporary.resolve("world");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of());
touch(skylands.resolve("region/r.0.0.mca"));
ModdedTakeoverReconciler.Result result = reconcile(
worldRoot, Map.of("iristest:skylands", "skypack:sky"), List.of(), temporary.resolve("config"));
assertEquals(Set.of("iristest:skylands"), result.adoptedLevels());
assertNull(result.recovery());
assertTrue(Files.isRegularFile(skylands.resolve("region/r.0.0.mca")));
assertEquals(Map.of("iristest:skylands", "skypack:sky"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void historyOfAnotherPackIsQuarantined() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-history-other");
Path worldRoot = temporary.resolve("world");
try {
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("otherpack", "sky"), "sky", List.of());
touch(skylands.resolve("region/r.0.0.mca"));
ModdedTakeoverReconciler.Result result = reconcile(
worldRoot, Map.of("iristest:skylands", "skypack:sky"), List.of(), temporary.resolve("config"));
assertEquals(Set.of(), result.adoptedLevels());
assertFalse(Files.exists(skylands.resolve("region")));
assertFalse(Files.exists(skylands.resolve("iris")));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void borrowedHistoryMatchesOnItsRecordedAliasPack() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-history-borrowed");
Path worldRoot = temporary.resolve("world");
try {
Path aether = worldRoot.resolve("dimensions/aether/the_aether");
createHistory(temporary, aether, "aether:the_aether", "sky",
List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow")));
touch(aether.resolve("region/r.0.0.mca"));
ModdedTakeoverReconciler.Result other = reconcile(
worldRoot, Map.of("aether:the_aether", "otherpack:sky"), List.of(), temporary.resolve("config"));
assertEquals(Set.of(), other.adoptedLevels());
assertFalse(Files.exists(aether.resolve("region")));
createHistory(temporary, aether, "aether:the_aether", "sky",
List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow")));
ModdedTakeoverReconciler.Result matching = reconcile(
worldRoot, Map.of("aether:the_aether", "skypack:sky"), List.of(), temporary.resolve("config"));
assertEquals(Set.of("aether:the_aether"), matching.adoptedLevels());
assertTrue(Files.isDirectory(aether.resolve("iris")));
} finally {
deleteTree(temporary);
}
}
@Test
public void recordAppliedStoresOnlyTakeoversThatGenerateFromTheirPack() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-record");
Path worldRoot = temporary.resolve("world");
try {
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:kept", "overworld"));
Map<String, String> recorded = ModdedTakeoverReconciler.recordApplied(worldRoot,
Map.of("iristest:kept", "overworld", "iristest:live", "skypack", "iristest:skipped", "skypack"),
(String level, String packRef) -> !level.equals("iristest:skipped"));
Map<String, String> expected = Map.of("iristest:kept", "overworld", "iristest:live", "skypack");
assertEquals(expected, recorded);
assertEquals(expected, ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void recordAppliedFailsWhenTheStateCannotBeWritten() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-record-fail");
Path worldRoot = temporary.resolve("world");
try {
touch(worldRoot.resolve("data/irisworldgen/dimension-takeovers.json/blocker"));
assertThrows(IOException.class, () -> ModdedTakeoverReconciler.recordApplied(worldRoot,
Map.of("iristest:live", "skypack"), (String level, String packRef) -> true));
} finally {
deleteTree(temporary);
}
}
@Test
public void switchingAnAppliedLevelToAnUninstalledPackRefusesBeforeMovingAnything() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-uninstalled-applied");
Path worldRoot = temporary.resolve("world");
try {
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld"));
touch(region);
IOException refused = assertThrows(IOException.class, () -> ModdedTakeoverReconciler.reconcile(
new ModdedTakeoverReconciler.Request(worldRoot, Map.of("iristest:skylands", "missing"), List.of(),
(ModdedDimensionTakeover takeover) -> false, temporary.resolve("config"))));
assertTrue(refused.getMessage(), refused.getMessage().contains("missing"));
assertTrue(Files.isRegularFile(region));
assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
@Test
public void newTakeoverOfAnUninstalledPackLeavesTheLevelAlone() throws IOException {
Path temporary = Files.createTempDirectory("iris-takeover-uninstalled-new");
Path worldRoot = temporary.resolve("world");
try {
Path region = worldRoot.resolve("dimensions/aether/the_aether/region/r.0.0.mca");
touch(region);
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
new ModdedTakeoverReconciler.Request(worldRoot, Map.of("aether:the_aether", "missing"), List.of(),
(ModdedDimensionTakeover takeover) -> false, temporary.resolve("config")));
assertEquals(Set.of(), result.quarantinedLevels());
assertTrue(Files.isRegularFile(region));
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
} finally {
deleteTree(temporary);
}
}
private static ModdedTakeoverReconciler.Result reconcile(Path worldRoot, Map<String, String> configured,
List<String> problems, Path recoveryParent) throws IOException {
return ModdedTakeoverReconciler.reconcile(new ModdedTakeoverReconciler.Request(
worldRoot, configured, problems, (ModdedDimensionTakeover takeover) -> true, recoveryParent));
}
private static void createHistory(Path temporary, Path dimensionRoot, String dimensionTypeKey, String dimensionKey,
List<String> biomeKeys) throws IOException {
Path source = Files.createTempDirectory(temporary, "pack");
touch(source.resolve("dimensions/" + dimensionKey + ".json"));
GenerationEpoch epoch = ModdedRetainedPackNameTest.epoch(dimensionTypeKey, dimensionKey, biomeKeys);
Files.createDirectories(dimensionRoot);
GenerationHistory.create(dimensionRoot, source,
GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION), 5L,
epoch.dimensionContract(), epoch.registryContract());
}
private static void touch(Path file) throws IOException {
Files.createDirectories(file.getParent());
Files.writeString(file, "x", StandardCharsets.UTF_8);
}
static void deleteTree(Path root) throws IOException {
if (!Files.exists(root)) {
return;
}
try (Stream<Path> walk = Files.walk(root)) {
for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
@@ -52,6 +52,9 @@ public class IrisModdedCommandParityTest {
child(iris, "worlds");
child(iris, "accesslist");
child(child(iris, "goto"), "unregistered");
for (String replace : new String[]{"replace", "override", "overwrite"}) {
assertTrue(replace, child(child(iris, replace), "arguments").getChildren().isEmpty());
}
CommandNode<CommandSourceStack> edit = child(iris, "edit");
child(edit, "b");
@@ -173,7 +176,9 @@ public class IrisModdedCommandParityTest {
assertTrue(ModdedCommandHelp.documents("studio", "pkg"));
assertTrue(ModdedCommandHelp.documents("object", "we"));
assertTrue(ModdedCommandHelp.documents("world", "mainworld"));
assertTrue(ModdedCommandHelp.documents("world", "takeover"));
assertTrue(ModdedCommandHelp.documents("", "replace"));
assertTrue(ModdedCommandHelp.documents("", "override"));
assertTrue(ModdedCommandHelp.documents("", "overwrite"));
assertTrue(ModdedCommandHelp.documents("goto", "unregistered"));
assertTrue(ModdedCommandHelp.documents("pack", "compat"));
assertTrue(ModdedCommandHelp.documents("pack", "cp"));
@@ -0,0 +1,112 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded.command;
import org.junit.Test;
import java.util.List;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class ModdedReplaceArgumentsTest {
@Test
public void targetAloneUsesTheDefaultTypeAndPreservesTheSeed() {
ModdedReplaceArguments arguments = ModdedReplaceArguments.parse("minecraft:overworld");
assertEquals("minecraft:overworld", arguments.target());
assertEquals("default", arguments.type());
assertEquals(OptionalLong.empty(), arguments.seed());
assertFalse(arguments.off());
}
@Test
public void positionalArgumentsFillTargetTypeAndSeedInOrder() {
ModdedReplaceArguments arguments = ModdedReplaceArguments.parse("main overworld 123456789");
assertEquals("main", arguments.target());
assertEquals("overworld", arguments.type());
assertEquals(OptionalLong.of(123456789L), arguments.seed());
}
@Test
public void keyedArgumentsAcceptThePaperNamesAndAliases() {
ModdedReplaceArguments named = ModdedReplaceArguments.parse("target=examplemod:skylands type=skypack:sky seed=-5");
ModdedReplaceArguments aliased = ModdedReplaceArguments.parse("world-name=myworld dimension=underworld s=7");
ModdedReplaceArguments pack = ModdedReplaceArguments.parse("seed=1 pack=overworld:floating/sky target=nether");
assertEquals("examplemod:skylands", named.target());
assertEquals("skypack:sky", named.type());
assertEquals(OptionalLong.of(-5L), named.seed());
assertEquals("myworld", aliased.target());
assertEquals("underworld", aliased.type());
assertEquals(OptionalLong.of(7L), aliased.seed());
assertEquals("nether", pack.target());
assertEquals("overworld:floating/sky", pack.type());
assertEquals(OptionalLong.of(1L), pack.seed());
}
@Test
public void positionalTargetMixesWithKeyedTypeAndSeedLikeThePaperCommand() {
ModdedReplaceArguments arguments = ModdedReplaceArguments.parse("minecraft:the_nether type=underworld seed=-987654321");
assertEquals("minecraft:the_nether", arguments.target());
assertEquals("underworld", arguments.type());
assertEquals(OptionalLong.of(-987654321L), arguments.seed());
}
@Test
public void preserveKeepsTheSavedSeedAndTheFullSignedRangeIsAccepted() {
assertEquals(OptionalLong.empty(), ModdedReplaceArguments.parse("main seed=preserve").seed());
assertEquals(OptionalLong.empty(), ModdedReplaceArguments.parse("main overworld PRESERVE").seed());
assertEquals(OptionalLong.of(Long.MAX_VALUE),
ModdedReplaceArguments.parse("main seed=" + Long.MAX_VALUE).seed());
assertEquals(OptionalLong.of(Long.MIN_VALUE + 1L),
ModdedReplaceArguments.parse("main seed=" + (Long.MIN_VALUE + 1L)).seed());
}
@Test
public void invalidSeedsAreRejected() {
IllegalArgumentException text = assertThrows(IllegalArgumentException.class,
() -> ModdedReplaceArguments.parse("main seed=random"));
IllegalArgumentException reserved = assertThrows(IllegalArgumentException.class,
() -> ModdedReplaceArguments.parse("main seed=" + Long.MIN_VALUE));
assertEquals("Seed must be a signed 64-bit integer", text.getMessage());
assertTrue(reserved.getMessage(), reserved.getMessage().contains(Long.toString(Long.MIN_VALUE)));
}
@Test
public void offTurnsAReplacementOffAndTakesNoSeed() {
assertTrue(ModdedReplaceArguments.parse("examplemod:skylands off").off());
assertTrue(ModdedReplaceArguments.parse("target=examplemod:skylands type=OFF").off());
assertThrows(IllegalArgumentException.class, () -> ModdedReplaceArguments.parse("examplemod:skylands off seed=1"));
}
@Test
public void malformedArgumentListsAreRejected() {
for (String raw : List.of("", " ", "type=overworld", "main foo=1", "main type=a pack=b",
"main overworld 1 extra", "main type=", "target=main world-name=other")) {
assertThrows(raw, IllegalArgumentException.class, () -> ModdedReplaceArguments.parse(raw));
}
}
}
@@ -0,0 +1,97 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.modded.command;
import art.arcane.iris.world.IrisEnvironment;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
public class ModdedReplaceTargetTest {
private static final String LEVEL_NAME = "world";
@Test
public void friendlyNamesResolveToTheVanillaSlots() {
for (String overworld : List.of("main", "overworld", "MAIN", " Overworld ")) {
assertTarget("minecraft:overworld", ModdedReplaceTarget.Kind.VANILLA_OVERWORLD, overworld);
}
assertTarget("minecraft:the_nether", ModdedReplaceTarget.Kind.VANILLA_NETHER, "nether");
assertTarget("minecraft:the_nether", ModdedReplaceTarget.Kind.VANILLA_NETHER, "the_nether");
assertTarget("minecraft:the_end", ModdedReplaceTarget.Kind.VANILLA_END, "end");
assertTarget("minecraft:the_end", ModdedReplaceTarget.Kind.VANILLA_END, "the_end");
}
@Test
public void configuredLevelNamesResolveToTheVanillaSlotsFirst() {
assertTarget("minecraft:overworld", ModdedReplaceTarget.Kind.VANILLA_OVERWORLD, "world");
assertTarget("minecraft:the_nether", ModdedReplaceTarget.Kind.VANILLA_NETHER, "world_nether");
assertTarget("minecraft:the_end", ModdedReplaceTarget.Kind.VANILLA_END, "WORLD_the_end");
assertEquals("minecraft:overworld", ModdedReplaceTarget.resolve("nether", "nether").levelKey());
}
@Test
public void exactKeysKeepTheirNamespace() {
assertTarget("minecraft:overworld", ModdedReplaceTarget.Kind.VANILLA_OVERWORLD, "minecraft:overworld");
assertTarget("minecraft:the_end", ModdedReplaceTarget.Kind.VANILLA_END, "MINECRAFT:THE_END");
assertTarget("examplemod:skylands", ModdedReplaceTarget.Kind.DIMENSION, "examplemod:skylands");
assertTarget("examplemod:realm/upper", ModdedReplaceTarget.Kind.DIMENSION, "examplemod:realm/upper");
}
@Test
public void otherBareNamesResolveToIrisWorlds() {
assertTarget("irisworldgen:myworld", ModdedReplaceTarget.Kind.IRIS_WORLD, "myworld");
assertTarget("irisworldgen:my_world", ModdedReplaceTarget.Kind.IRIS_WORLD, "My World");
assertTarget("irisworldgen:myworld", ModdedReplaceTarget.Kind.IRIS_WORLD, "iris:myworld");
assertTarget("irisworldgen:myworld", ModdedReplaceTarget.Kind.IRIS_WORLD, "irisworldgen:myworld");
}
@Test
public void vanillaSlotsRequireTheirEnvironment() {
assertEquals(IrisEnvironment.NORMAL, ModdedReplaceTarget.Kind.VANILLA_OVERWORLD.environment());
assertEquals(IrisEnvironment.NETHER, ModdedReplaceTarget.Kind.VANILLA_NETHER.environment());
assertEquals(IrisEnvironment.THE_END, ModdedReplaceTarget.Kind.VANILLA_END.environment());
assertNull(ModdedReplaceTarget.Kind.IRIS_WORLD.environment());
assertNull(ModdedReplaceTarget.Kind.DIMENSION.environment());
ModdedReplaceTarget.requireCompatibleEnvironment(ModdedReplaceTarget.Kind.VANILLA_NETHER, IrisEnvironment.NETHER);
ModdedReplaceTarget.requireCompatibleEnvironment(ModdedReplaceTarget.Kind.DIMENSION, IrisEnvironment.THE_END);
IllegalArgumentException mismatch = assertThrows(IllegalArgumentException.class,
() -> ModdedReplaceTarget.requireCompatibleEnvironment(
ModdedReplaceTarget.Kind.VANILLA_NETHER, IrisEnvironment.NORMAL));
assertEquals("The vanilla_nether slot requires a pack environment of NETHER.", mismatch.getMessage());
}
@Test
public void unsafeOrMalformedTargetsAreRejected() {
for (String target : List.of("", " ", "../world", "a/b", "a\\b", "ns:", ":x", "a:b:c", "bad key:x",
"examplemod:sky/../x", "examplemod:/sky")) {
assertThrows(target, IllegalArgumentException.class, () -> ModdedReplaceTarget.resolve(target, LEVEL_NAME));
}
}
private static void assertTarget(String levelKey, ModdedReplaceTarget.Kind kind, String requested) {
ModdedReplaceTarget target = ModdedReplaceTarget.resolve(requested, LEVEL_NAME);
assertEquals(requested, levelKey, target.levelKey());
assertEquals(requested, kind, target.kind());
}
}
@@ -449,7 +449,6 @@ operator_instruction = "Führe {command} in der Konsole aus oder aktiviere Cheat
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Eine bestehende Dimension beim nächsten Neustart mit einem Iris-Pack übernehmen oder die Übernahme ausschalten"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1112,10 +1111,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Derzeit sind kei
loaded_iris_dimensions = "Geladene Iris-Dimensionen: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Verwende /iris world create <name> <pack>, 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"
@@ -449,7 +449,6 @@ operator_instruction = "Ejecuta {command} desde la consola (o activa los trucos
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Tomar el control de una dimensión existente con un pack de Iris en el próximo reinicio, o desactivarlo"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1112,10 +1111,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "No hay dimension
loaded_iris_dimensions = "Dimensiones de Iris cargadas: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Usa /iris world create <name> <pack> 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)"
@@ -454,7 +454,6 @@ operator_instruction = "Juokse {command} konsolilta (tai mahdollista huijaukset
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Ota olemassa oleva ulottuvuus haltuun Iris-paketilla seuraavassa uudelleenkäynnistyksessä tai poista haltuunotto käytöstä"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1117,10 +1116,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Ei Iris mitat ov
loaded_iris_dimensions = "Ladattu Iris mitat: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Käyttö /iris world create <name> <pack> 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)"
@@ -449,7 +449,6 @@ operator_instruction = "Exécutez {command} depuis la console (ou activez les co
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Prendre le contrôle d'une dimension existante avec un pack Iris au prochain redémarrage, ou le désactiver"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1112,10 +1111,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Aucune dimension
loaded_iris_dimensions = "Dimensions Iris chargées : {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Utilisez /iris world create <name> <pack> 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)"
@@ -454,7 +454,6 @@ operator_instruction = "לרוץ {command} מן הקונסולה (או לאפש
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "השתלטות על ממד קיים עם חבילת Iris בהפעלה מחדש הבאה, או כיבוי ההשתלטות"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1117,10 +1116,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "לא Iris המי
loaded_iris_dimensions = "טעון Iris ממדים: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "שימוש בשימוש /iris world create <name> <pack> להזריק אחד מבלי להתחיל מחדש."
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)"
@@ -449,7 +449,6 @@ operator_instruction = "Esegui {command} dalla console oppure abilita i trucchi
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Acquisisci una dimensione esistente con un pack Iris al prossimo riavvio, oppure disattiva l'acquisizione"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1112,10 +1111,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Nessuna dimensio
loaded_iris_dimensions = "Dimensioni Iris caricate: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Usa /iris world create <name> <pack> 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"
@@ -452,7 +452,6 @@ operator_instruction = "コンソールから {command} を実行してくださ
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "次回の再起動時に既存のディメンションを Iris パックで引き継ぐか、引き継ぎを無効にします"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1115,10 +1114,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "現在読み込
loaded_iris_dimensions = "読み込み済み Iris ディメンション: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "再起動せずに注入するには /iris world create <name> <pack> を使用してください。"
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)"
@@ -452,7 +452,6 @@ operator_instruction = "콘솔에서 {command}을(를) 실행하세요(싱글
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "다음 재시작 시 기존 차원을 Iris 팩으로 인수하거나 인수를 해제합니다"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1115,10 +1114,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "현재 로드된
loaded_iris_dimensions = "로드 중 ... Iris 크기: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "재시작하지 않고 주입하려면 /iris world create <name> <pack>을(를) 사용하세요."
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)"
@@ -454,7 +454,6 @@ operator_instruction = "Vykdyti {command} iš konsolės (arba leidžia cheats į
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Kito paleidimo iš naujo metu perimti esamą dimensiją Iris paketu arba išjungti perėmimą"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1117,10 +1116,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Ne Iris šiuo me
loaded_iris_dimensions = "Įkelta Iris matmenys: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Naudojimas /iris world create <name> <pack> š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)"
@@ -449,7 +449,6 @@ operator_instruction = "Start {command} vanaf de console (of schakel bedrog in s
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Neem bij de volgende herstart een bestaande dimensie over met een Iris-pack, of schakel de overname uit"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1112,10 +1111,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Nee Iris De afme
loaded_iris_dimensions = "Geladen Iris afmetingen: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Gebruik /iris world create <name> <pack> 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)"
@@ -454,7 +454,6 @@ operator_instruction = "Biegnij {command} z konsoli (lub włączyć cheats w sin
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Przejmij istniejący wymiar pakietem Iris przy następnym restarcie lub wyłącz przejęcie"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1117,10 +1116,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Nie. Iris wymiar
loaded_iris_dimensions = "Załadowane Iris wymiary: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Stosowanie /iris world create <name> <pack> 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)"
@@ -449,7 +449,6 @@ operator_instruction = "Executar {command} a partir do console (ou habilitar fra
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Assumir o controlo de uma dimensão existente com um pack Iris no próximo reinício, ou desativá-lo"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1112,10 +1111,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Não Iris as dim
loaded_iris_dimensions = "Carregado Iris dimensões: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Utilização /iris world create <name> <pack> 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)"
@@ -454,7 +454,6 @@ operator_instruction = "Беги. {command} от консоли (или вклю
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Перехватить существующее измерение пакетом Iris при следующем перезапуске или отключить перехват"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1117,10 +1116,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Нет. Iris В
loaded_iris_dimensions = "загруженный Iris размеры: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Использовать /iris world create <name> <pack> Вводить один без перезапуска."
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)"
@@ -454,7 +454,6 @@ operator_instruction = "{command} komutunu konsoldan çalıştırın (veya tek o
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Bir sonraki yeniden başlatmada mevcut bir boyutu bir Iris paketiyle devral veya devralmayı kapat"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1117,10 +1116,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Hayır Iris Boyu
loaded_iris_dimensions = "Yüklendi Iris Boyut: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Kullanım Kullanımı /iris world create <name> <pack> 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)"
@@ -452,7 +452,6 @@ operator_instruction = "Chạy {command} từ bàn giao tiếp (hoặc kích ho
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "Tiếp quản một chiều không gian hiện có bằng gói Iris ở lần khởi động lại tiếp theo, hoặc tắt việc tiếp quản"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1115,10 +1114,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "Không Iris Kíc
loaded_iris_dimensions = "Đã nạp Iris Kích thước: {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "Dùng /iris world create <name> <pack> 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)"
@@ -452,7 +452,6 @@ operator_instruction = "请从控制台执行 {command}(或在单人游戏中
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "在下次重启时用 Iris 资源包接管现有维度,或关闭接管"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1115,10 +1114,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "没有 Iris 维
loaded_iris_dimensions = "已加载 Iris 维度 : {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "使用 /iris world create <name> <pack> 在不重新启动的情况下注入一个。"
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)"
@@ -452,7 +452,6 @@ operator_instruction = "請從主控台執行 {command}(或在單人遊戲中
"entry.command.height" = "Print the current dimension height range"
"entry.command.worlds" = "List loaded dimensions and identify the Iris-generated dimensions"
"entry.command.mainworld" = "Configure or clear the Iris primary-world preset used on restart"
"entry.command.takeover" = "在下次重新啟動時用 Iris 資源包接管現有維度,或關閉接管"
"entry.command.object.we" = "Explain why WorldEdit selection import requires the Bukkit plugin"
"entry.command.object.studio" = "Explain why the object studio world requires the Bukkit toolchain"
"entry.command.object.convert" = "Explain why schematic conversion requires the Bukkit plugin"
@@ -1115,10 +1114,6 @@ no_iris_dimensions_are_currently_loaded_create_one_with_iris = "沒有 Iris 維
loaded_iris_dimensions = "已載入 Iris 維度 : {value}"
message_2 = " - {dimension}"
use_iris_world_create_name_pack_inject_one_without_restarting = "使用 /iris world create <name> <pack> 在不重新啟動的情況下注入一個。"
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)"