mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 13:30:08 +00:00
Merged dimension takeover
This commit is contained in:
commit
79e220298f
54 files changed
+1505
-95
No files matched your search
@@ -239,50 +239,21 @@ public final class MainWorldService {
|
||||
|
||||
private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException {
|
||||
Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID());
|
||||
List<Path> moved = new ArrayList<>();
|
||||
List<Path> sources = new ArrayList<>();
|
||||
for (String file : NativeServerWorldLayout.metadataFiles()) {
|
||||
moveToRecovery(worldRoot, worldRoot.resolve(file), recovery, moved);
|
||||
sources.add(worldRoot.resolve(file));
|
||||
}
|
||||
for (String folder : NativeServerWorldLayout.primaryFolders()) {
|
||||
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved);
|
||||
sources.add(worldRoot.resolve(folder));
|
||||
}
|
||||
moveToRecovery(worldRoot, worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME), recovery, moved);
|
||||
sources.add(worldRoot.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME));
|
||||
for (String folder : NativeServerWorldLayout.dimensionFolders()) {
|
||||
moveToRecovery(worldRoot, worldRoot.resolve(folder), recovery, moved);
|
||||
}
|
||||
if (moved.isEmpty()) {
|
||||
Files.deleteIfExists(recovery);
|
||||
sources.add(worldRoot.resolve(folder));
|
||||
}
|
||||
ModdedWorldQuarantine.move(worldRoot, sources, recovery);
|
||||
return recovery;
|
||||
}
|
||||
|
||||
private static void moveToRecovery(Path worldRoot, Path source, Path recovery,
|
||||
List<Path> moved) throws IOException {
|
||||
if (!Files.exists(source)) {
|
||||
return;
|
||||
}
|
||||
Path relative = worldRoot.relativize(source);
|
||||
Path target = recovery.resolve(relative);
|
||||
Files.createDirectories(target.getParent());
|
||||
try {
|
||||
Files.move(source, target);
|
||||
moved.add(relative);
|
||||
} catch (IOException failure) {
|
||||
for (int index = moved.size() - 1; index >= 0; index--) {
|
||||
Path rollbackRelative = moved.get(index);
|
||||
Path rollbackSource = recovery.resolve(rollbackRelative);
|
||||
Path rollbackTarget = worldRoot.resolve(rollbackRelative);
|
||||
try {
|
||||
Files.createDirectories(rollbackTarget.getParent());
|
||||
Files.move(rollbackSource, rollbackTarget);
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value.replace(":", "\\:");
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
final class ModdedDatapackTagMerge {
|
||||
private static final String REPLACE = "replace";
|
||||
private static final String VALUES = "values";
|
||||
|
||||
private ModdedDatapackTagMerge() {
|
||||
}
|
||||
|
||||
static boolean isTag(Path relative) {
|
||||
return relative.getNameCount() >= 4
|
||||
&& "data".equals(relative.getName(0).toString())
|
||||
&& "tags".equals(relative.getName(2).toString())
|
||||
&& relative.getFileName().toString().endsWith(".json");
|
||||
}
|
||||
|
||||
static void merge(Path source, Path destination) throws IOException {
|
||||
JSONObject incoming = read(source);
|
||||
JSONObject existing = read(destination);
|
||||
Map<String, Object> values = new TreeMap<>();
|
||||
collect(existing, values, destination);
|
||||
collect(incoming, values, source);
|
||||
JSONArray merged = new JSONArray();
|
||||
for (Object value : values.values()) {
|
||||
merged.put(value);
|
||||
}
|
||||
JSONObject tag = new JSONObject();
|
||||
tag.put(REPLACE, existing.optBoolean(REPLACE, false) || incoming.optBoolean(REPLACE, false));
|
||||
tag.put(VALUES, merged);
|
||||
Files.writeString(destination, tag.toString(4), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static JSONObject read(Path tag) throws IOException {
|
||||
try {
|
||||
return new JSONObject(Files.readString(tag, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException invalid) {
|
||||
throw new IOException("Iris cannot merge the invalid datapack tag " + tag + ".", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collect(JSONObject tag, Map<String, Object> values, Path origin) throws IOException {
|
||||
JSONArray entries = tag.optJSONArray(VALUES);
|
||||
if (entries == null) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < entries.length(); index++) {
|
||||
Object entry = entries.get(index);
|
||||
String key = entry instanceof JSONObject object ? object.optString("id", "") : entry.toString();
|
||||
if (key.isBlank()) {
|
||||
throw new IOException("Iris cannot merge datapack tag " + origin + ": entry " + index + " has no id.");
|
||||
}
|
||||
values.putIfAbsent(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -511,19 +511,21 @@ public final class ModdedDimensionManager {
|
||||
.normalize();
|
||||
PackValidationRegistry.requireLoadable(packRoot);
|
||||
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
|
||||
String dimensionTypeRef;
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
|
||||
if (dimension == null) {
|
||||
throw new IllegalStateException("Transient Iris Studio pack does not contain dimension '"
|
||||
+ dimensionKey + "'.");
|
||||
}
|
||||
dimensionTypeRef = ModdedWorldgenIds.dimensionTypeRef(pack, dimension);
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
return new RuntimePack(
|
||||
packRoot,
|
||||
dimensionKey,
|
||||
ModdedWorldgenIds.dimensionTypeRef(pack, dimensionKey),
|
||||
dimensionTypeRef,
|
||||
ModdedGenerationMode.TRANSIENT_STUDIO
|
||||
);
|
||||
}
|
||||
|
||||
+4
@@ -43,6 +43,10 @@ public final class ModdedDimensionStorage {
|
||||
private ModdedDimensionStorage() {
|
||||
}
|
||||
|
||||
static List<String> chunkDataFolders() {
|
||||
return CHUNK_DATA_FOLDERS;
|
||||
}
|
||||
|
||||
public static File storageFolder(NativeModdedServer server, String dimension) {
|
||||
return server.dimensionFolder(dimension).toFile();
|
||||
}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <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;
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
|
||||
public final class ModdedDimensionTakeovers {
|
||||
private static final String GENERATOR_TYPE = "irisworldgen:iris";
|
||||
private static final String PLACEHOLDER_BIOME_SOURCE = "minecraft:fixed";
|
||||
private static final String PLACEHOLDER_BIOME = "minecraft:plains";
|
||||
|
||||
private ModdedDimensionTakeovers() {
|
||||
}
|
||||
|
||||
public static List<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,
|
||||
Map<String, String> stagedDimensionTypes) throws IOException {
|
||||
for (ModdedDimensionTakeover takeover : parseAll(configured)) {
|
||||
String typeRef = stagedDimensionTypes.get(takeover.pack() + ":" + takeover.packDimension());
|
||||
if (typeRef == null) {
|
||||
ModdedIrisLog.error("Iris skipped the dimension takeover of {}: pack '{}' dimension '{}' is not installed or failed validation, so the dimension keeps its own generator",
|
||||
takeover.levelKey(), takeover.pack(), takeover.packDimension());
|
||||
continue;
|
||||
}
|
||||
writeLevelStem(datapackRoot, takeover, typeRef);
|
||||
ModdedIrisLog.info("Iris dimension takeover staged: {} generates from {} with dimension type {}",
|
||||
takeover.levelKey(), takeover.packRef(), typeRef);
|
||||
}
|
||||
}
|
||||
|
||||
public static Set<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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,9 @@ public final class ModdedEngineBootstrap {
|
||||
bindWorldGenerators(server);
|
||||
ModdedStartup.runOnce(server);
|
||||
reconcileSpawn(server);
|
||||
if (!loader().clientEnvironment()) {
|
||||
ModdedTakeoverReconciler.recordApplied(server);
|
||||
}
|
||||
ModdedWorldCheck.serverStarted(server);
|
||||
ModdedWorldgenCatalogs.capture(server);
|
||||
}
|
||||
@@ -369,6 +372,9 @@ public final class ModdedEngineBootstrap {
|
||||
selfTest(moddedLoader.getClass().getClassLoader());
|
||||
bind();
|
||||
IrisLanguage.initialize();
|
||||
if (!moddedLoader.clientEnvironment()) {
|
||||
ModdedTakeoverReconciler.reconcileEarly();
|
||||
}
|
||||
ModdedStartup.prefetchStartupDatapack();
|
||||
if (!moddedLoader.clientEnvironment()) {
|
||||
MainWorldService.reconcileEarly();
|
||||
|
||||
+60
-20
@@ -324,6 +324,8 @@ public final class ModdedForcedDatapack {
|
||||
false
|
||||
);
|
||||
digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8));
|
||||
digest.update(ModdedDimensionTakeovers.fingerprint(ModdedModConfig.get().dimensionTakeovers())
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
@@ -333,15 +335,20 @@ public final class ModdedForcedDatapack {
|
||||
|
||||
int packCount = 0;
|
||||
KList<String> presetIds = new KList<>();
|
||||
Map<String, String> stagedDimensionTypes = new LinkedHashMap<>();
|
||||
Path worldRoot = worldRootOrNull();
|
||||
Set<PackSelection> legacySelections = unadoptedSelections(worldRoot);
|
||||
List<File> packRoots = compilationPackRoots(worldRoot);
|
||||
for (File pack : packRoots) {
|
||||
PackStageContext context = packStageContext(pack.toPath(), legacySelections);
|
||||
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds)) {
|
||||
if (stagePack(context, fixer, stagingDirectory, seenBiomes, presetIds, stagedDimensionTypes)) {
|
||||
packCount++;
|
||||
}
|
||||
}
|
||||
if (!ModdedEngineBootstrap.loader().clientEnvironment()) {
|
||||
ModdedDimensionTakeovers.writeLevelStems(stagingDirectory,
|
||||
ModdedModConfig.get().dimensionTakeovers(), stagedDimensionTypes);
|
||||
}
|
||||
|
||||
IrisDatapackCompiler.installRetainedRegistrySources(packRoots, List.of(stagingDirectory.toFile()), fixer);
|
||||
writePackMeta(stagingDirectory);
|
||||
@@ -362,7 +369,8 @@ public final class ModdedForcedDatapack {
|
||||
|
||||
private static boolean stagePack(PackStageContext context, IDataFixer fixer, Path stagingDirectory,
|
||||
Map<String, KSet<String>> seenBiomes,
|
||||
KList<String> presetIds) throws IOException {
|
||||
KList<String> presetIds,
|
||||
Map<String, String> stagedDimensionTypes) throws IOException {
|
||||
File sourcePack = context.packRoot().toFile();
|
||||
PackValidationResult validation;
|
||||
try {
|
||||
@@ -392,11 +400,12 @@ public final class ModdedForcedDatapack {
|
||||
stagingDirectory.getParent(), PACK_FOLDER + ".pack-" + sourcePack.getName() + "-");
|
||||
Map<String, KSet<String>> packBiomes = new LinkedHashMap<>();
|
||||
KList<String> packPresetIds = new KList<>();
|
||||
Map<String, String> packDimensionTypes = new LinkedHashMap<>();
|
||||
KList<File> packFolders = new KList<>();
|
||||
packFolders.add(packStagingDirectory.toFile());
|
||||
boolean installed;
|
||||
try {
|
||||
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds);
|
||||
installed = installPack(context, fixer, packFolders, packBiomes, packPresetIds, packDimensionTypes);
|
||||
} catch (Throwable installationFailure) {
|
||||
if (context.required()) {
|
||||
throw requiredPackFailure(context, "datapack serialization", installationFailure);
|
||||
@@ -414,6 +423,7 @@ public final class ModdedForcedDatapack {
|
||||
mergeDirectory(packStagingDirectory, stagingDirectory);
|
||||
mergeBiomes(seenBiomes, packBiomes);
|
||||
presetIds.addAll(packPresetIds);
|
||||
stagedDimensionTypes.putAll(packDimensionTypes);
|
||||
return true;
|
||||
} finally {
|
||||
try {
|
||||
@@ -454,6 +464,8 @@ public final class ModdedForcedDatapack {
|
||||
Files.createDirectories(destination);
|
||||
} else if (!Files.exists(destination)) {
|
||||
Files.copy(source, destination);
|
||||
} else if (Files.isRegularFile(destination) && ModdedDatapackTagMerge.isTag(relative)) {
|
||||
ModdedDatapackTagMerge.merge(source, destination);
|
||||
} else if (!Files.isRegularFile(destination) || Files.mismatch(source, destination) != -1L) {
|
||||
throw new IOException("Conflicting Iris datapack output at " + destination + ".");
|
||||
}
|
||||
@@ -470,7 +482,8 @@ public final class ModdedForcedDatapack {
|
||||
|
||||
private static boolean installPack(PackStageContext context, IDataFixer fixer, KList<File> folders,
|
||||
Map<String, KSet<String>> seenBiomes,
|
||||
KList<String> presetIds) throws IOException {
|
||||
KList<String> presetIds,
|
||||
Map<String, String> dimensionTypes) throws IOException {
|
||||
File packFolder = context.packRoot().toFile();
|
||||
String packName = context.packName();
|
||||
File dimensionsDirectory = new File(packFolder, "dimensions");
|
||||
@@ -504,18 +517,27 @@ public final class ModdedForcedDatapack {
|
||||
biomesForNamespace(seenBiomes, "iris"),
|
||||
context
|
||||
);
|
||||
writeDimensionType(
|
||||
folders,
|
||||
fixer,
|
||||
dimension,
|
||||
context.dimensionTypeRef(dimensionKey),
|
||||
context.emitLegacyDimensionTypeAlias(),
|
||||
context
|
||||
);
|
||||
String typeRef = context.dimensionTypeRef(dimensionKey);
|
||||
if (dimension.hasBorrowedDimensionType()) {
|
||||
typeRef = requireBorrowedType(context, dimension, typeRef);
|
||||
} else {
|
||||
requireScopedType(context, typeRef);
|
||||
writeDimensionType(
|
||||
folders,
|
||||
fixer,
|
||||
dimension,
|
||||
typeRef,
|
||||
context.emitLegacyDimensionTypeAlias(),
|
||||
context
|
||||
);
|
||||
}
|
||||
if (context.emitPresets()) {
|
||||
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
|
||||
writeWorldPreset(folders, packName, dimensionKey, presetRef);
|
||||
presetIds.add(presetRef);
|
||||
dimensionTypes.put(packName + ":" + dimensionKey, typeRef);
|
||||
if (!dimension.hasBorrowedDimensionType()) {
|
||||
String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey);
|
||||
writeWorldPreset(folders, packName, dimensionKey, presetRef);
|
||||
presetIds.add(presetRef);
|
||||
}
|
||||
}
|
||||
installedDimensions++;
|
||||
}
|
||||
@@ -542,6 +564,22 @@ public final class ModdedForcedDatapack {
|
||||
+ " (datapack source injection failed; see the Iris boot ERROR), so a restart alone will not register it.")));
|
||||
}
|
||||
|
||||
private static void requireScopedType(PackStageContext context, String stagedTypeRef) throws IOException {
|
||||
if (context.required() && ModdedWorldgenIds.scopedDimensionType(stagedTypeRef).isEmpty()) {
|
||||
throw new IOException("Retained Iris dimension type key is not scoped: " + stagedTypeRef);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireBorrowedType(PackStageContext context, IrisDimension dimension, String stagedTypeRef)
|
||||
throws IOException {
|
||||
String borrowed = dimension.getBorrowedDimensionType().trim();
|
||||
if (context.required() && !borrowed.equals(stagedTypeRef)) {
|
||||
throw new IOException("Retained Iris generation pack at " + context.packRoot() + " borrows dimension type "
|
||||
+ borrowed + ", but its generation history records " + stagedTypeRef + ".");
|
||||
}
|
||||
return borrowed;
|
||||
}
|
||||
|
||||
private static void writeWorldPreset(KList<File> folders, String packName, String dimensionKey,
|
||||
String presetRef) throws IOException {
|
||||
String dimensionRef = dimensionKey.equals(packName) ? packName : packName + ":" + dimensionKey;
|
||||
@@ -789,11 +827,13 @@ public final class ModdedForcedDatapack {
|
||||
Path normalizedRoot = packRoot.toAbsolutePath().normalize();
|
||||
RetainedPack retained = retainedPack(normalizedRoot);
|
||||
if (retained != null) {
|
||||
ModdedWorldgenIds.ScopedDimension identity = ModdedWorldgenIds
|
||||
.scopedDimensionType(retained.epoch().dimensionContract().dimensionTypeKey())
|
||||
.orElseThrow(() -> new IOException("Retained Iris dimension type key is not scoped: "
|
||||
+ retained.epoch().dimensionContract().dimensionTypeKey()));
|
||||
if (!identity.dimension().equals(retained.epoch().dimensionContract().dimensionKey())) {
|
||||
GenerationEpoch.DimensionContract contract = retained.epoch().dimensionContract();
|
||||
Optional<ModdedWorldgenIds.ScopedDimension> scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey());
|
||||
if (scoped.isEmpty()) {
|
||||
return PackStageContext.retained(normalizedRoot, contract.dimensionKey(), retained.epoch());
|
||||
}
|
||||
ModdedWorldgenIds.ScopedDimension identity = scoped.get();
|
||||
if (!identity.dimension().equals(contract.dimensionKey())) {
|
||||
throw new IOException("Retained Iris dimension type identity does not match its epoch contract.");
|
||||
}
|
||||
return PackStageContext.retained(normalizedRoot, identity.pack(), retained.epoch());
|
||||
|
||||
+18
-5
@@ -28,6 +28,7 @@ import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class ModdedGenerationHistoryStorage {
|
||||
private ModdedGenerationHistoryStorage() {
|
||||
@@ -75,7 +76,7 @@ final class ModdedGenerationHistoryStorage {
|
||||
return new AdoptionSource(source, captureCandidate(
|
||||
source,
|
||||
selectedDimensionKey,
|
||||
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
|
||||
adoptionAliasPolicy(dimensionRoot)
|
||||
));
|
||||
},
|
||||
ModdedGenerationHistoryStorage::requireRegistryDefinitions
|
||||
@@ -170,6 +171,19 @@ final class ModdedGenerationHistoryStorage {
|
||||
);
|
||||
}
|
||||
|
||||
static GenerationRegistryContractFactory.CustomBiomeAliasPolicy adoptionAliasPolicy(Path dimensionRoot)
|
||||
throws IOException {
|
||||
Path regions = dimensionRoot.resolve("region");
|
||||
if (!Files.isDirectory(regions)) {
|
||||
return GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY;
|
||||
}
|
||||
try (Stream<Path> files = Files.list(regions)) {
|
||||
return files.anyMatch((Path file) -> file.getFileName().toString().endsWith(".mca"))
|
||||
? GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
|
||||
: GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY;
|
||||
}
|
||||
}
|
||||
|
||||
static ActivePack resolveActive(GenerationHistory history) throws IOException {
|
||||
GenerationEpoch epoch = history.activeEpoch();
|
||||
Path packRoot = normalize(history.activePackRoot());
|
||||
@@ -246,10 +260,9 @@ final class ModdedGenerationHistoryStorage {
|
||||
PlatformGenerationRegistry generationRegistry = IrisPlatforms.get()
|
||||
.registries()
|
||||
.generationRegistry();
|
||||
String dimensionTypeKey = generationRegistry.dimensionTypeResourceKey(
|
||||
source.getFileName().toString(),
|
||||
dimension.getLoadKey(),
|
||||
dimension.getDimensionTypeKey()
|
||||
String dimensionTypeKey = dimension.dimensionTypeResourceKey(
|
||||
generationRegistry,
|
||||
source.getFileName().toString()
|
||||
);
|
||||
GenerationEpoch.DimensionContract dimensionContract = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
|
||||
@@ -24,9 +24,12 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
public final class ModdedModConfig {
|
||||
private static final Object LOCK = new Object();
|
||||
private static final String DIMENSION_TAKEOVERS = "dimensionTakeovers";
|
||||
private static volatile ModdedModConfig instance;
|
||||
|
||||
private final String defaultPack;
|
||||
@@ -35,15 +38,18 @@ public final class ModdedModConfig {
|
||||
private final String mainWorldPack;
|
||||
private final long mainWorldSeed;
|
||||
private final boolean mainWorldAutoRestart;
|
||||
private final Map<String, String> dimensionTakeovers;
|
||||
|
||||
private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld,
|
||||
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart) {
|
||||
String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart,
|
||||
Map<String, String> dimensionTakeovers) {
|
||||
this.defaultPack = defaultPack;
|
||||
this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim();
|
||||
this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld;
|
||||
this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim();
|
||||
this.mainWorldSeed = mainWorldSeed;
|
||||
this.mainWorldAutoRestart = mainWorldAutoRestart;
|
||||
this.dimensionTakeovers = Map.copyOf(dimensionTakeovers);
|
||||
}
|
||||
|
||||
public static ModdedModConfig get() {
|
||||
@@ -64,7 +70,7 @@ public final class ModdedModConfig {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = get();
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld,
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart);
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.dimensionTakeovers);
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
}
|
||||
@@ -74,12 +80,20 @@ public final class ModdedModConfig {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = get();
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
|
||||
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart);
|
||||
packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.dimensionTakeovers);
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean setDimensionTakeover(ModdedDimensionTakeover takeover) {
|
||||
return updateDimensionTakeover(takeover.levelKey(), takeover.packRef());
|
||||
}
|
||||
|
||||
public static boolean clearDimensionTakeover(String levelKey) {
|
||||
return updateDimensionTakeover(levelKey, null);
|
||||
}
|
||||
|
||||
public String defaultPack() {
|
||||
return defaultPack;
|
||||
}
|
||||
@@ -104,13 +118,33 @@ public final class ModdedModConfig {
|
||||
return mainWorldAutoRestart;
|
||||
}
|
||||
|
||||
public Map<String, String> dimensionTakeovers() {
|
||||
return dimensionTakeovers;
|
||||
}
|
||||
|
||||
private static boolean updateDimensionTakeover(String levelKey, String packRef) {
|
||||
synchronized (LOCK) {
|
||||
ModdedModConfig current = get();
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld,
|
||||
current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, takeovers);
|
||||
instance = updated;
|
||||
write(configFile(), updated);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static Path configFile() {
|
||||
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("modded.json");
|
||||
}
|
||||
|
||||
private static ModdedModConfig load() {
|
||||
Path file = configFile();
|
||||
ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false);
|
||||
ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false, Map.of());
|
||||
if (!Files.isRegularFile(file)) {
|
||||
write(file, defaults);
|
||||
return defaults;
|
||||
@@ -123,13 +157,30 @@ public final class ModdedModConfig {
|
||||
json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld),
|
||||
json.optString("mainWorldPack", defaults.mainWorldPack),
|
||||
json.optLong("mainWorldSeed", defaults.mainWorldSeed),
|
||||
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart));
|
||||
json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart),
|
||||
readDimensionTakeovers(json.optJSONObject(DIMENSION_TAKEOVERS), file));
|
||||
} catch (RuntimeException | IOException e) {
|
||||
ModdedIrisLog.error("Iris modded config at {} is invalid; using defaults", file, e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, String> readDimensionTakeovers(JSONObject json, Path file) {
|
||||
if (json == null) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, String> takeovers = new TreeMap<>();
|
||||
for (String levelKey : json.keySet()) {
|
||||
try {
|
||||
ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, json.optString(levelKey, ""));
|
||||
takeovers.put(takeover.levelKey(), takeover.packRef());
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
ModdedIrisLog.error("Iris ignored dimension takeover '{}' in {}: {}", levelKey, file, invalid.getMessage());
|
||||
}
|
||||
}
|
||||
return takeovers;
|
||||
}
|
||||
|
||||
private static void write(Path file, ModdedModConfig config) {
|
||||
JSONObject json = new JSONObject();
|
||||
json.put("defaultPack", config.defaultPack);
|
||||
@@ -138,6 +189,11 @@ public final class ModdedModConfig {
|
||||
json.put("mainWorldPack", config.mainWorldPack);
|
||||
json.put("mainWorldSeed", config.mainWorldSeed);
|
||||
json.put("mainWorldAutoRestart", config.mainWorldAutoRestart);
|
||||
JSONObject takeovers = new JSONObject();
|
||||
for (Map.Entry<String, String> entry : new TreeMap<>(config.dimensionTakeovers).entrySet()) {
|
||||
takeovers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
json.put(DIMENSION_TAKEOVERS, takeovers);
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, json.toString(4), StandardCharsets.UTF_8);
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldPaths;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class ModdedTakeoverReconciler {
|
||||
private static final String STATE_FILE = "data/irisworldgen/dimension-takeovers.json";
|
||||
private static final String TAKEOVERS = "takeovers";
|
||||
|
||||
private ModdedTakeoverReconciler() {
|
||||
}
|
||||
|
||||
public static void reconcileEarly() {
|
||||
try {
|
||||
Map<String, String> configured = ModdedModConfig.get().dimensionTakeovers();
|
||||
Path worldRoot = MainWorldService.configuredWorldRootIfPresent();
|
||||
if (worldRoot == null) {
|
||||
if (!configured.isEmpty()) {
|
||||
ModdedIrisLog.info("Iris dimension takeovers {} apply to a new world; nothing to quarantine",
|
||||
configured.keySet());
|
||||
}
|
||||
return;
|
||||
}
|
||||
Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen");
|
||||
Result result = reconcile(worldRoot, configured, recoveryParent);
|
||||
if (result.changedLevels().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", result.changedLevels(),
|
||||
result.recovery() == null
|
||||
? "no existing chunk data to quarantine"
|
||||
: "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery()
|
||||
+ " so they regenerate (player data kept)");
|
||||
} catch (Throwable failure) {
|
||||
ModdedIrisLog.error("Iris dimension takeover reconciliation failed", failure);
|
||||
throw new IllegalStateException("Iris refused startup after dimension takeover reconciliation failed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public static void recordApplied(NativeModdedServer server) {
|
||||
Optional<Path> worldRoot = NativeWorldPaths.root(server);
|
||||
if (worldRoot.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> configured = ModdedModConfig.get().dimensionTakeovers();
|
||||
try {
|
||||
if (readApplied(worldRoot.get()).equals(configured)) {
|
||||
return;
|
||||
}
|
||||
writeApplied(worldRoot.get(), configured);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
ModdedIrisLog.error("Iris could not record the applied dimension takeovers under {}", worldRoot.get(), failure);
|
||||
}
|
||||
}
|
||||
|
||||
static Result reconcile(Path worldRoot, Map<String, String> configured, Path recoveryParent) throws IOException {
|
||||
Map<String, String> applied = readApplied(worldRoot);
|
||||
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, configured);
|
||||
if (changed.isEmpty()) {
|
||||
return new Result(changed, null);
|
||||
}
|
||||
List<Path> sources = new ArrayList<>();
|
||||
for (String level : changed) {
|
||||
Path storage = ModdedDimensionTakeover.storageFolder(worldRoot, level);
|
||||
for (String folder : ModdedDimensionStorage.chunkDataFolders()) {
|
||||
sources.add(storage.resolve(folder));
|
||||
}
|
||||
}
|
||||
Path recovery = recoveryParent.resolve("takeover-recovery-" + UUID.randomUUID());
|
||||
List<Path> moved = ModdedWorldQuarantine.move(worldRoot, sources, recovery);
|
||||
writeApplied(worldRoot, configured);
|
||||
return new Result(changed, moved.isEmpty() ? null : recovery);
|
||||
}
|
||||
|
||||
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 Result(Set<String> changedLevels, Path recovery) {
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,15 @@ public final class ModdedWorldEngines {
|
||||
(actualType.maximumHeight() - actualType.minimumHeight()),
|
||||
actualType.logicalHeight());
|
||||
String runtimeName = "Modded level '" + level.name() + "'";
|
||||
if (dimension.hasBorrowedDimensionType()) {
|
||||
if (!recorded.dimensionTypeKey().equals(dimension.getBorrowedDimensionType().trim())) {
|
||||
throw new IllegalStateException("Immutable Iris dimension contract for '" + level.name()
|
||||
+ "' records dimension type " + recorded.dimensionTypeKey() + ", but its pack borrows "
|
||||
+ dimension.getBorrowedDimensionType().trim() + ".");
|
||||
}
|
||||
expected.requireBorrowedFit(runtimeName, actual);
|
||||
return;
|
||||
}
|
||||
expected.requireExact(runtimeName, actual);
|
||||
expected.requireHeight(runtimeName, level.minHeight(), (level.maxHeight() - level.minHeight()));
|
||||
}
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class ModdedWorldQuarantine {
|
||||
private ModdedWorldQuarantine() {
|
||||
}
|
||||
|
||||
static List<Path> move(Path worldRoot, List<Path> sources, Path recovery) throws IOException {
|
||||
List<Path> moved = new ArrayList<>(sources.size());
|
||||
try {
|
||||
for (Path source : sources) {
|
||||
moveOne(worldRoot, source, recovery, moved);
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
rollback(worldRoot, recovery, moved, failure);
|
||||
throw failure;
|
||||
}
|
||||
if (moved.isEmpty()) {
|
||||
Files.deleteIfExists(recovery);
|
||||
}
|
||||
return List.copyOf(moved);
|
||||
}
|
||||
|
||||
private static void moveOne(Path worldRoot, Path source, Path recovery, List<Path> moved) throws IOException {
|
||||
if (!Files.exists(source)) {
|
||||
return;
|
||||
}
|
||||
Path relative = worldRoot.relativize(source);
|
||||
if (relative.isAbsolute() || relative.startsWith("..")) {
|
||||
throw new IOException("Iris refuses to quarantine " + source + " outside the world " + worldRoot);
|
||||
}
|
||||
Path target = recovery.resolve(relative);
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.move(source, target);
|
||||
moved.add(relative);
|
||||
}
|
||||
|
||||
private static void rollback(Path worldRoot, Path recovery, List<Path> moved, IOException failure) {
|
||||
for (int index = moved.size() - 1; index >= 0; index--) {
|
||||
Path relative = moved.get(index);
|
||||
Path rollbackTarget = worldRoot.resolve(relative);
|
||||
try {
|
||||
Files.createDirectories(rollbackTarget.getParent());
|
||||
Files.move(recovery.resolve(relative), rollbackTarget);
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HexFormat;
|
||||
@@ -22,6 +23,12 @@ public final class ModdedWorldgenIds {
|
||||
return NAMESPACE + ":" + scopedPath(pack, dimension) + "/dimension_type";
|
||||
}
|
||||
|
||||
public static String dimensionTypeRef(String pack, IrisDimension dimension) {
|
||||
return dimension.hasBorrowedDimensionType()
|
||||
? dimension.getBorrowedDimensionType().trim()
|
||||
: dimensionTypeRef(pack, dimension.getLoadKey());
|
||||
}
|
||||
|
||||
public static String biomePathPrefix(String pack, String dimension) {
|
||||
return scopedPath(pack, dimension) + "/biomes";
|
||||
}
|
||||
|
||||
+22
@@ -22,6 +22,7 @@ package art.arcane.iris.modded;
|
||||
import art.arcane.iris.pack.datapack.DataVersion;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldDimensions;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldInspection.Dimension;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
@@ -34,6 +35,9 @@ final class WorldCheckDimensionContract {
|
||||
static boolean checkDimensionType(NativeWorld level, IrisModdedChunkGenerator generator) {
|
||||
try {
|
||||
IrisDimension dimension = generator.commandEngine().getDimension();
|
||||
if (dimension.hasBorrowedDimensionType()) {
|
||||
return checkBorrowedDimensionType(level, dimension);
|
||||
}
|
||||
Dimension expected = expectedDimensionContract(dimension);
|
||||
Dimension actual = new NativeWorldInspection(level).dimension();
|
||||
boolean pass = matchesDimensionContract(level.minHeight(), (level.maxHeight() - level.minHeight()), expected, actual);
|
||||
@@ -54,6 +58,24 @@ final class WorldCheckDimensionContract {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean checkBorrowedDimensionType(NativeWorld level, IrisDimension dimension) {
|
||||
String borrowed = dimension.getBorrowedDimensionType().trim();
|
||||
String actualKey = NativeWorldDimensions.settings(level).key();
|
||||
boolean pass = borrowed.equals(actualKey)
|
||||
&& dimension.getMinHeight() == level.minHeight()
|
||||
&& dimension.getMaxHeight() <= level.maxHeight();
|
||||
String detail = "borrowed=" + borrowed + ",actual=" + actualKey
|
||||
+ ",packRange=" + dimension.getMinHeight() + ".." + dimension.getMaxHeight()
|
||||
+ ",levelRange=" + level.minHeight() + ".." + level.maxHeight();
|
||||
WorldCheckPredicates.qaEvent("dimension_type", dimension.getLoadKey(), pass, detail);
|
||||
if (!pass) {
|
||||
ModdedIrisLog.error("[worldcheck] borrowed dimension type mismatch for {}: {}", dimension.getLoadKey(), detail);
|
||||
} else {
|
||||
ModdedIrisLog.info("[worldcheck] borrowed dimension type contract: {}", detail);
|
||||
}
|
||||
return pass;
|
||||
}
|
||||
|
||||
static Dimension expectedDimensionContract(IrisDimension dimension) {
|
||||
JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getRuntime().get()));
|
||||
return new Dimension(
|
||||
|
||||
+1
@@ -175,6 +175,7 @@ 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"),
|
||||
|
||||
+3
@@ -163,6 +163,9 @@ public final class ModdedDatapackCommands {
|
||||
continue;
|
||||
}
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
if (dimension.hasBorrowedDimensionType()) {
|
||||
continue;
|
||||
}
|
||||
String json;
|
||||
try {
|
||||
json = dimension.getDimensionType().toJson(DataVersion.getRuntime().get());
|
||||
|
||||
+73
@@ -26,6 +26,8 @@ import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
|
||||
import art.arcane.iris.modded.MainWorldService;
|
||||
import art.arcane.iris.modded.ModdedDimensionTakeover;
|
||||
import art.arcane.iris.modded.ModdedForcedDatapack;
|
||||
import art.arcane.iris.modded.ModdedDimensionManager;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedModConfig;
|
||||
@@ -59,6 +61,7 @@ public final class ModdedWorldCommands {
|
||||
private static final String DEFAULT_NAMESPACE = "irisworldgen";
|
||||
private static final long DEFAULT_SEED = 1337L;
|
||||
private static final SuggestionProvider<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() {
|
||||
}
|
||||
@@ -80,6 +83,7 @@ public final class ModdedWorldCommands {
|
||||
root.then(updateTree());
|
||||
root.then(replaceOverworldTree());
|
||||
root.then(mainWorldTree());
|
||||
root.then(takeoverTree());
|
||||
|
||||
root.then(disableTree());
|
||||
root.then(deleteTree("delete"));
|
||||
@@ -156,6 +160,18 @@ public final class ModdedWorldCommands {
|
||||
StringArgumentType.getString(context, "seed"))))));
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<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);
|
||||
@@ -286,6 +302,54 @@ public final class ModdedWorldCommands {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int takeover(NativeCommandSource source, String targetDimension, String packRaw) {
|
||||
ModdedDimensionTakeover takeover;
|
||||
try {
|
||||
takeover = ModdedDimensionTakeover.parse(normalizeDimensionId(targetDimension), packRaw);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
IrisModdedCommands.fail(source, invalid.getMessage());
|
||||
return 0;
|
||||
}
|
||||
String pack = takeover.pack();
|
||||
if (!new File(ModdedPackCommands.packsRoot(), pack).isDirectory()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_WORLD_COMMANDS_REQUIRED_PACK_IS_NOT_INSTALLED_INSTALL_THEN_RESTART,
|
||||
MessageArgument.untrusted("pack", pack)));
|
||||
return 0;
|
||||
}
|
||||
if (blockIfPackBroken(source, takeover.levelKey(), pack) || !loadPackDimension(source, pack, takeover.packDimension())) {
|
||||
return 0;
|
||||
}
|
||||
ModdedModConfig.setDimensionTakeover(takeover);
|
||||
ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + takeover.levelKey());
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_SET,
|
||||
MessageArgument.untrusted("dimensionId", takeover.levelKey()),
|
||||
MessageArgument.untrusted("pack", pack),
|
||||
MessageArgument.untrusted("packDimension", takeover.packDimension())));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED));
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int clearTakeover(NativeCommandSource source, String targetDimension) {
|
||||
String dimensionId;
|
||||
try {
|
||||
dimensionId = normalizeDimensionId(targetDimension);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
IrisModdedCommands.fail(source, invalid.getMessage());
|
||||
return 0;
|
||||
}
|
||||
if (!ModdedModConfig.clearDimensionTakeover(dimensionId)) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
return 0;
|
||||
}
|
||||
ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + dimensionId + " turned off");
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED));
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int clearMainWorld(NativeCommandSource source) {
|
||||
ModdedModConfig.setMainWorld("", 0L);
|
||||
MainWorldService.clearOverride();
|
||||
@@ -496,6 +560,15 @@ public final class ModdedWorldCommands {
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static List<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()) {
|
||||
|
||||
+20
@@ -1152,6 +1152,22 @@ public final class ModdedCommandMessages {
|
||||
"iris.modded.moddedworldcommands.iris_main_world_override_cleared_overworld_keeps_its_current_generator",
|
||||
"Iris main world override cleared. The overworld keeps its current generator; edit server.properties level-type and restart to change it back."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_SET = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.takeover_set",
|
||||
"Iris takeover of " + "{dimensionId}" + " set to pack '" + "{pack}" + "', dimension '" + "{packDimension}" + "'."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.takeover_cleared",
|
||||
"Iris takeover of " + "{dimensionId}" + " turned off; the dimension's own generator returns on the next restart."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.takeover_not_configured",
|
||||
"No Iris takeover is configured for " + "{dimensionId}" + "."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.takeover_restart_required",
|
||||
"Restart required: on the next start the dimension's chunk, entity, POI, and Iris Mantle data move to a recovery folder and regenerate. Player data is kept."
|
||||
);
|
||||
public static final TextKey MODDED_WORLD_COMMANDS_INVALID_SEED_USE_NUMBER_RANDOM = TextKey.of(
|
||||
"iris.modded.moddedworldcommands.invalid_seed_use_number_random",
|
||||
"Invalid seed '" + "{seedRaw}" + "'. Use a number or 'random'."
|
||||
@@ -1548,6 +1564,10 @@ public final class ModdedCommandMessages {
|
||||
MODDED_WORLD_COMMANDS_VANILLA_OVERWORLD_GENERATOR_CANNOT_BE_HOT_SWAPPED_SO_THIS_DOES,
|
||||
MODDED_WORLD_COMMANDS_INSTEAD_IS_NOW_CONFIGURED_PRIMARY_WORLD_PLAYERS_VANILLA_OVERWORLD_ARE,
|
||||
MODDED_WORLD_COMMANDS_IRIS_MAIN_WORLD_OVERRIDE_CLEARED_OVERWORLD_KEEPS_ITS_CURRENT_GENERATOR,
|
||||
MODDED_WORLD_COMMANDS_TAKEOVER_SET,
|
||||
MODDED_WORLD_COMMANDS_TAKEOVER_CLEARED,
|
||||
MODDED_WORLD_COMMANDS_TAKEOVER_NOT_CONFIGURED,
|
||||
MODDED_WORLD_COMMANDS_TAKEOVER_RESTART_REQUIRED,
|
||||
MODDED_WORLD_COMMANDS_INVALID_SEED_USE_NUMBER_RANDOM,
|
||||
MODDED_WORLD_COMMANDS_PACK_IS_NOT_READY_YET_STILL_LOADING_VALIDATING_TRY_COMMAND,
|
||||
MODDED_WORLD_COMMANDS_FAILED_WRITE_SERVER_PROPERTIES_CHECK_FILE_PERMISSIONS_SET_LEVEL_TYPE,
|
||||
|
||||
+5
@@ -390,6 +390,10 @@ public final class ModdedHelpMessages {
|
||||
"iris.modded.help.entry.command.mainworld",
|
||||
"Configure or clear the Iris primary-world preset used on restart"
|
||||
);
|
||||
public static final TextKey COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK = TextKey.of(
|
||||
"iris.modded.help.entry.command.takeover",
|
||||
"Take over an existing dimension with an Iris pack on the next restart, or turn it off"
|
||||
);
|
||||
public static final TextKey COMMAND_OBJECT_WE_BUKKIT_ONLY = TextKey.of(
|
||||
"iris.modded.help.entry.command.object.we",
|
||||
"Explain why WorldEdit selection import requires the Bukkit plugin"
|
||||
@@ -516,6 +520,7 @@ public final class ModdedHelpMessages {
|
||||
COMMAND_HEIGHT_PRINT_WORLD_HEIGHT,
|
||||
COMMAND_WORLDS_LIST_WORLD_ACCESS,
|
||||
COMMAND_MAINWORLD_CONFIGURE_PRIMARY_WORLD_PRESET,
|
||||
COMMAND_TAKEOVER_REPLACE_AN_EXISTING_DIMENSION_WITH_AN_IRIS_PACK,
|
||||
COMMAND_OBJECT_WE_BUKKIT_ONLY,
|
||||
COMMAND_OBJECT_STUDIO_BUKKIT_ONLY,
|
||||
COMMAND_OBJECT_CONVERT_BUKKIT_ONLY,
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <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.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"));
|
||||
}
|
||||
}
|
||||
+55
@@ -260,6 +260,61 @@ public class ModdedForcedDatapackTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mergesTagFilesFromDifferentPacksIntoTheUnionOfTheirValues() throws IOException {
|
||||
Path root = Files.createTempDirectory("iris-forced-pack-tag-merge");
|
||||
try {
|
||||
Path tag = Path.of("data/minecraft/tags/worldgen/biome/water_on_map_outlines.json");
|
||||
Path source = root.resolve("source");
|
||||
Path destination = root.resolve("destination");
|
||||
Files.createDirectories(source.resolve(tag).getParent());
|
||||
Files.createDirectories(destination.resolve(tag).getParent());
|
||||
Files.writeString(source.resolve(tag),
|
||||
"{\"replace\":false,\"values\":[\"iris:biomes/b\",\"iris:biomes/c\",{\"id\":\"mod:river\",\"required\":false}]}",
|
||||
StandardCharsets.UTF_8);
|
||||
Files.writeString(destination.resolve(tag),
|
||||
"{\"replace\":false,\"values\":[\"iris:biomes/a\",\"iris:biomes/b\"]}",
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
ModdedForcedDatapack.mergeDirectory(source, destination);
|
||||
|
||||
JSONObject merged = new JSONObject(Files.readString(destination.resolve(tag), StandardCharsets.UTF_8));
|
||||
assertFalse(merged.getBoolean("replace"));
|
||||
List<String> values = new ArrayList<>();
|
||||
for (int index = 0; index < merged.getJSONArray("values").length(); index++) {
|
||||
Object value = merged.getJSONArray("values").get(index);
|
||||
values.add(value instanceof JSONObject entry ? entry.getString("id") : value.toString());
|
||||
}
|
||||
assertEquals(List.of("iris:biomes/a", "iris:biomes/b", "iris:biomes/c", "mod:river"), values);
|
||||
} finally {
|
||||
deleteTree(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tagMergeKeepsStrictConflictsForNonTagFilesInTheSameMerge() throws IOException {
|
||||
Path root = Files.createTempDirectory("iris-forced-pack-tag-strict");
|
||||
try {
|
||||
Path tag = Path.of("data/minecraft/tags/worldgen/biome/is_overworld.json");
|
||||
Path biome = Path.of("data/iris/worldgen/biome/shared.json");
|
||||
for (String side : List.of("source", "destination")) {
|
||||
Files.createDirectories(root.resolve(side).resolve(tag).getParent());
|
||||
Files.createDirectories(root.resolve(side).resolve(biome).getParent());
|
||||
Files.writeString(root.resolve(side).resolve(tag),
|
||||
"{\"replace\":false,\"values\":[\"iris:biomes/" + side + "\"]}", StandardCharsets.UTF_8);
|
||||
}
|
||||
Files.writeString(root.resolve("source").resolve(biome), "{\"temperature\":1}", StandardCharsets.UTF_8);
|
||||
Files.writeString(root.resolve("destination").resolve(biome), "{\"temperature\":2}", StandardCharsets.UTF_8);
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> ModdedForcedDatapack.mergeDirectory(root.resolve("source"), root.resolve("destination"))
|
||||
);
|
||||
} finally {
|
||||
deleteTree(root);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> directoryEntries(Path root) throws IOException {
|
||||
try (Stream<Path> entries = Files.list(root)) {
|
||||
return entries.map((Path path) -> path.getFileName().toString())
|
||||
|
||||
+23
@@ -5,6 +5,7 @@ import art.arcane.iris.world.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.world.history.GenerationHistory;
|
||||
import art.arcane.iris.world.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.world.history.GenerationRegistryContractFactory;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -28,6 +29,28 @@ public class ModdedGenerationHistoryStorageTest {
|
||||
private static final String DEFINITION_FINGERPRINT = "22".repeat(32);
|
||||
private static final int TRANSITION_WIDTH_BLOCKS = 256;
|
||||
|
||||
@Test
|
||||
public void adoptionRetainsLegacyBiomeAliasesOnlyWhenTheLevelAlreadyHasChunks() throws IOException {
|
||||
Path root = Files.createTempDirectory("iris-modded-history-alias-policy");
|
||||
try {
|
||||
Path fresh = root.resolve("fresh");
|
||||
Path emptyRegion = root.resolve("empty-region");
|
||||
Files.createDirectories(emptyRegion.resolve("region"));
|
||||
Path legacy = root.resolve("legacy");
|
||||
Files.createDirectories(legacy.resolve("region"));
|
||||
Files.writeString(legacy.resolve("region").resolve("r.0.0.mca"), "chunk", StandardCharsets.UTF_8);
|
||||
|
||||
assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY,
|
||||
ModdedGenerationHistoryStorage.adoptionAliasPolicy(fresh));
|
||||
assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.CONTENT_ADDRESSED_ONLY,
|
||||
ModdedGenerationHistoryStorage.adoptionAliasPolicy(emptyRegion));
|
||||
assertEquals(GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES,
|
||||
ModdedGenerationHistoryStorage.adoptionAliasPolicy(legacy));
|
||||
} finally {
|
||||
deleteTree(root);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void newDimensionPublishesAnImmutableWorldLocalPack() throws IOException {
|
||||
Path root = Files.createTempDirectory("iris-modded-history-create");
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedTakeoverReconcilerTest {
|
||||
@Test
|
||||
public void appliedStateRoundTripsAndDefaultsToEmpty() throws IOException {
|
||||
Path worldRoot = Files.createTempDirectory("iris-takeover-state");
|
||||
try {
|
||||
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
|
||||
|
||||
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld:sky"));
|
||||
|
||||
assertEquals(Map.of("iristest:skylands", "overworld:sky"), ModdedTakeoverReconciler.readApplied(worldRoot));
|
||||
} finally {
|
||||
deleteTree(worldRoot);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void newTakeoverQuarantinesOnlyThatLevelsChunkDataAndKeepsPlayerData() throws IOException {
|
||||
Path temporary = Files.createTempDirectory("iris-takeover-reconcile");
|
||||
Path worldRoot = temporary.resolve("world");
|
||||
Path recoveryParent = temporary.resolve("config");
|
||||
try {
|
||||
Path skylands = worldRoot.resolve("dimensions/iristest/skylands");
|
||||
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris", "data")) {
|
||||
touch(skylands.resolve(folder).resolve("payload.bin"));
|
||||
}
|
||||
touch(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca"));
|
||||
touch(worldRoot.resolve("players/data/player.dat"));
|
||||
|
||||
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent);
|
||||
|
||||
assertEquals(Set.of("iristest:skylands"), result.changedLevels());
|
||||
for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) {
|
||||
assertFalse(folder, Files.exists(skylands.resolve(folder)));
|
||||
assertTrue(folder, Files.isRegularFile(result.recovery()
|
||||
.resolve("dimensions/iristest/skylands").resolve(folder).resolve("payload.bin")));
|
||||
}
|
||||
assertTrue(Files.isRegularFile(skylands.resolve("data/payload.bin")));
|
||||
assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
|
||||
assertTrue(Files.isRegularFile(worldRoot.resolve("players/data/player.dat")));
|
||||
assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
|
||||
} finally {
|
||||
deleteTree(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unchangedTakeoverIsANoOpAndRemovalQuarantinesTheIrisChunks() throws IOException {
|
||||
Path temporary = Files.createTempDirectory("iris-takeover-cycle");
|
||||
Path worldRoot = temporary.resolve("world");
|
||||
Path recoveryParent = temporary.resolve("config");
|
||||
try {
|
||||
Path region = worldRoot.resolve("dimensions/iristest/skylands/region/r.0.0.mca");
|
||||
ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld"));
|
||||
touch(region);
|
||||
|
||||
ModdedTakeoverReconciler.Result unchanged = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent);
|
||||
|
||||
assertEquals(Set.of(), unchanged.changedLevels());
|
||||
assertNull(unchanged.recovery());
|
||||
assertTrue(Files.isRegularFile(region));
|
||||
|
||||
ModdedTakeoverReconciler.Result removed = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of(), recoveryParent);
|
||||
|
||||
assertEquals(Set.of("iristest:skylands"), removed.changedLevels());
|
||||
assertFalse(Files.exists(region));
|
||||
assertTrue(Files.isRegularFile(removed.recovery().resolve("dimensions/iristest/skylands/region/r.0.0.mca")));
|
||||
assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot));
|
||||
} finally {
|
||||
deleteTree(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changeWithoutChunkDataRecordsStateWithoutARecoveryFolder() throws IOException {
|
||||
Path temporary = Files.createTempDirectory("iris-takeover-empty");
|
||||
Path worldRoot = temporary.resolve("world");
|
||||
Path recoveryParent = temporary.resolve("config");
|
||||
try {
|
||||
Files.createDirectories(worldRoot);
|
||||
|
||||
ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile(
|
||||
worldRoot, Map.of("aether:the_aether", "overworld"), recoveryParent);
|
||||
|
||||
assertEquals(Set.of("aether:the_aether"), result.changedLevels());
|
||||
assertNull(result.recovery());
|
||||
assertFalse(Files.exists(recoveryParent));
|
||||
assertEquals(Map.of("aether:the_aether", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot));
|
||||
} finally {
|
||||
deleteTree(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void touch(Path file) throws IOException {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, "x", StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
static void deleteTree(Path root) throws IOException {
|
||||
if (!Files.exists(root)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -19,6 +20,19 @@ public class ModdedWorldgenIdsTest {
|
||||
ModdedWorldgenIds.dimensionTypeRef("Pack/A-_", "É山")).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void borrowedDimensionTypeReplacesTheScopedGeneratedType() {
|
||||
IrisDimension generated = new IrisDimension();
|
||||
generated.setLoadKey("sky");
|
||||
IrisDimension borrowed = new IrisDimension().setBorrowedDimensionType("iristest:skylands");
|
||||
borrowed.setLoadKey("sky");
|
||||
|
||||
assertEquals(ModdedWorldgenIds.dimensionTypeRef("overworld", "sky"),
|
||||
ModdedWorldgenIds.dimensionTypeRef("overworld", generated));
|
||||
assertEquals("iristest:skylands", ModdedWorldgenIds.dimensionTypeRef("overworld", borrowed));
|
||||
assertTrue(ModdedWorldgenIds.scopedDimensionType("iristest:skylands").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedIdentifiersRetainRejectionBehavior() {
|
||||
assertTrue(ModdedWorldgenIds.scopedDimensionType(
|
||||
|
||||
+1
@@ -173,6 +173,7 @@ public class IrisModdedCommandParityTest {
|
||||
assertTrue(ModdedCommandHelp.documents("studio", "pkg"));
|
||||
assertTrue(ModdedCommandHelp.documents("object", "we"));
|
||||
assertTrue(ModdedCommandHelp.documents("world", "mainworld"));
|
||||
assertTrue(ModdedCommandHelp.documents("world", "takeover"));
|
||||
assertTrue(ModdedCommandHelp.documents("goto", "unregistered"));
|
||||
assertTrue(ModdedCommandHelp.documents("pack", "compat"));
|
||||
assertTrue(ModdedCommandHelp.documents("pack", "cp"));
|
||||
|
||||
Reference in new issue
Block a user