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 committed 2026-10-15 10:10:11 -04:00
1 parent 0ca33068f3
commit a7cf2cac9a
46 files changed
+2663 -1454

No files matched your search

@@ -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());
}
}