mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-22 08:07:42 +00:00
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:
1 parent
0ca33068f3
commit
a7cf2cac9a
46 files changed
+2663
-1454
No files matched your search
+12
@@ -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)) {
|
||||
|
||||
+5
@@ -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) {
|
||||
|
||||
+144
@@ -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;
|
||||
}
|
||||
}
|
||||
+206
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
-84
@@ -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;
|
||||
}
|
||||
}
|
||||
-197
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -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()) {
|
||||
|
||||
+7
-7
@@ -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);
|
||||
|
||||
+150
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+417
@@ -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) {
|
||||
}
|
||||
}
|
||||
-262
@@ -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) {
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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"),
|
||||
|
||||
+3
@@ -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"));
|
||||
|
||||
+113
@@ -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));
|
||||
}
|
||||
}
|
||||
+301
@@ -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;
|
||||
}
|
||||
}
|
||||
+122
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-84
@@ -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()) {
|
||||
|
||||
-20
@@ -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,
|
||||
|
||||
-5
@@ -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,
|
||||
|
||||
+290
@@ -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));
|
||||
}
|
||||
}
|
||||
-246
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-23
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+584
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-386
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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"));
|
||||
|
||||
+112
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user