diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java index 4a076904c..2bc1f4608 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedModConfig.java @@ -24,8 +24,11 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.TreeSet; public final class ModdedModConfig { private static final Object LOCK = new Object(); @@ -39,17 +42,19 @@ public final class ModdedModConfig { private final long mainWorldSeed; private final boolean mainWorldAutoRestart; private final Map dimensionTakeovers; + private final List takeoverProblems; private ModdedModConfig(String defaultPack, String primaryWorld, boolean routePlayersToPrimaryWorld, String mainWorldPack, long mainWorldSeed, boolean mainWorldAutoRestart, - Map dimensionTakeovers) { + Takeovers takeovers) { this.defaultPack = defaultPack; this.primaryWorld = primaryWorld == null ? "" : primaryWorld.trim(); this.routePlayersToPrimaryWorld = routePlayersToPrimaryWorld; this.mainWorldPack = mainWorldPack == null ? "" : mainWorldPack.trim(); this.mainWorldSeed = mainWorldSeed; this.mainWorldAutoRestart = mainWorldAutoRestart; - this.dimensionTakeovers = Map.copyOf(dimensionTakeovers); + this.dimensionTakeovers = Map.copyOf(takeovers.entries()); + this.takeoverProblems = List.copyOf(takeovers.problems()); } public static ModdedModConfig get() { @@ -68,9 +73,9 @@ public final class ModdedModConfig { public static void setPrimaryWorld(String dimensionId) { synchronized (LOCK) { - ModdedModConfig current = get(); + ModdedModConfig current = writable(); ModdedModConfig updated = new ModdedModConfig(current.defaultPack, dimensionId, current.routePlayersToPrimaryWorld, - current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.dimensionTakeovers); + current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, current.takeovers()); instance = updated; write(configFile(), updated); } @@ -78,9 +83,9 @@ public final class ModdedModConfig { public static void setMainWorld(String packRef, long seed) { synchronized (LOCK) { - ModdedModConfig current = get(); + ModdedModConfig current = writable(); ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld, - packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.dimensionTakeovers); + packRef == null ? "" : packRef.trim(), seed, current.mainWorldAutoRestart, current.takeovers()); instance = updated; write(configFile(), updated); } @@ -122,16 +127,59 @@ public final class ModdedModConfig { return dimensionTakeovers; } + public List takeoverProblems() { + return takeoverProblems; + } + + static ModdedModConfig read(Path file) { + ModdedModConfig defaults = defaults(); + JSONObject json; + try { + json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8)); + } catch (RuntimeException | IOException unreadable) { + 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()))); + } + return new ModdedModConfig( + json.optString("defaultPack", defaults.defaultPack), + json.optString("primaryWorld", defaults.primaryWorld), + json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld), + json.optString("mainWorldPack", defaults.mainWorldPack), + json.optLong("mainWorldSeed", defaults.mainWorldSeed), + json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart), + readDimensionTakeovers(json, file)); + } + + private Takeovers takeovers() { + return new Takeovers(dimensionTakeovers, takeoverProblems); + } + + private static ModdedModConfig writable() { + ModdedModConfig current = get(); + if (!current.takeoverProblems.isEmpty()) { + throw new IllegalStateException("Iris will not rewrite " + configFile() + + " while its dimension takeovers are unreadable: " + String.join("; ", current.takeoverProblems) + + ". 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())); + } + private static boolean updateDimensionTakeover(String levelKey, String packRef) { synchronized (LOCK) { - ModdedModConfig current = get(); + ModdedModConfig current = writable(); Map takeovers = new TreeMap<>(current.dimensionTakeovers); String previous = packRef == null ? takeovers.remove(levelKey) : takeovers.put(levelKey, packRef); if (packRef == null ? previous == null : packRef.equals(previous)) { return false; } ModdedModConfig updated = new ModdedModConfig(current.defaultPack, current.primaryWorld, current.routePlayersToPrimaryWorld, - current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, takeovers); + current.mainWorldPack, current.mainWorldSeed, current.mainWorldAutoRestart, new Takeovers(takeovers, List.of())); instance = updated; write(configFile(), updated); return true; @@ -144,41 +192,41 @@ public final class ModdedModConfig { private static ModdedModConfig load() { Path file = configFile(); - ModdedModConfig defaults = new ModdedModConfig("overworld", "", true, "", 0L, false, Map.of()); if (!Files.isRegularFile(file)) { + ModdedModConfig defaults = defaults(); write(file, defaults); return defaults; } - try { - JSONObject json = new JSONObject(Files.readString(file, StandardCharsets.UTF_8)); - return new ModdedModConfig( - json.optString("defaultPack", defaults.defaultPack), - json.optString("primaryWorld", defaults.primaryWorld), - json.optBoolean("routePlayersToPrimaryWorld", defaults.routePlayersToPrimaryWorld), - json.optString("mainWorldPack", defaults.mainWorldPack), - json.optLong("mainWorldSeed", defaults.mainWorldSeed), - json.optBoolean("mainWorldAutoRestart", defaults.mainWorldAutoRestart), - readDimensionTakeovers(json.optJSONObject(DIMENSION_TAKEOVERS), file)); - } catch (RuntimeException | IOException e) { - ModdedIrisLog.error("Iris modded config at {} is invalid; using defaults", file, e); - return defaults; - } + return read(file); } - private static Map readDimensionTakeovers(JSONObject json, Path file) { - if (json == null) { - return Map.of(); + private static Takeovers readDimensionTakeovers(JSONObject json, Path file) { + if (!json.has(DIMENSION_TAKEOVERS) || json.isNull(DIMENSION_TAKEOVERS)) { + return new Takeovers(Map.of(), List.of()); + } + JSONObject section = json.optJSONObject(DIMENSION_TAKEOVERS); + 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")); } Map takeovers = new TreeMap<>(); - for (String levelKey : json.keySet()) { + List problems = new ArrayList<>(); + for (String levelKey : new TreeSet<>(section.keySet())) { + Object value = section.opt(levelKey); try { - ModdedDimensionTakeover takeover = ModdedDimensionTakeover.parse(levelKey, json.optString(levelKey, "")); + 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()); } catch (IllegalArgumentException invalid) { - ModdedIrisLog.error("Iris ignored dimension takeover '{}' in {}: {}", levelKey, file, invalid.getMessage()); + problems.add("Iris could not read dimension takeover '" + levelKey + "' in " + file + ": " + invalid.getMessage()); } } - return takeovers; + for (String problem : problems) { + ModdedIrisLog.error(problem); + } + return new Takeovers(takeovers, problems); } private static void write(Path file, ModdedModConfig config) { @@ -201,4 +249,7 @@ public final class ModdedModConfig { ModdedIrisLog.error("Iris failed to write modded config at {}", file, e); } } + + private record Takeovers(Map entries, List problems) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java index 7329e60d5..cd99c8f12 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTakeoverReconciler.java @@ -44,7 +44,8 @@ public final class ModdedTakeoverReconciler { public static void reconcileEarly() { try { - Map configured = ModdedModConfig.get().dimensionTakeovers(); + ModdedModConfig config = ModdedModConfig.get(); + Map configured = config.dimensionTakeovers(); Path worldRoot = MainWorldService.configuredWorldRootIfPresent(); if (worldRoot == null) { if (!configured.isEmpty()) { @@ -54,7 +55,7 @@ public final class ModdedTakeoverReconciler { return; } Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen"); - Result result = reconcile(worldRoot, configured, recoveryParent); + Result result = reconcile(worldRoot, configured, config.takeoverProblems(), recoveryParent); if (result.changedLevels().isEmpty()) { return; } @@ -85,7 +86,9 @@ public final class ModdedTakeoverReconciler { } } - static Result reconcile(Path worldRoot, Map configured, Path recoveryParent) throws IOException { + static Result reconcile(Path worldRoot, Map configured, List configProblems, + Path recoveryParent) throws IOException { + requireReadableConfig(worldRoot, configProblems); Map applied = readApplied(worldRoot); Set changed = ModdedDimensionTakeovers.changedLevels(applied, configured); if (changed.isEmpty()) { @@ -104,6 +107,19 @@ public final class ModdedTakeoverReconciler { return new Result(changed, moved.isEmpty() ? null : recovery); } + private static void requireReadableConfig(Path worldRoot, List 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 readApplied(Path worldRoot) throws IOException { Path state = worldRoot.resolve(STATE_FILE); if (!Files.isRegularFile(state)) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedModConfigTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedModConfigTest.java new file mode 100644 index 000000000..cf8a31569 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedModConfigTest.java @@ -0,0 +1,95 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +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\"}}"); + try { + ModdedModConfig config = ModdedModConfig.read(file); + + assertEquals(Map.of("aether:the_aether", "overworld:sky"), config.dimensionTakeovers()); + assertEquals(List.of(), config.takeoverProblems()); + } finally { + ModdedTakeoverReconcilerTest.deleteTree(file.getParent()); + } + } + + @Test + public void unparseableConfigReportsAProblemNamingTheFile() throws IOException { + Path file = config("{\"dimensionTakeovers\": {\"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())); + } finally { + ModdedTakeoverReconcilerTest.deleteTree(file.getParent()); + } + } + + @Test + public void invalidTakeoverEntryIsReportedInsteadOfSilentlyDropped() throws IOException { + Path file = config("{\"dimensionTakeovers\": {\"aether:the_aether\": \"overworld\", \"Broken Key\": \"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())); + } finally { + ModdedTakeoverReconcilerTest.deleteTree(file.getParent()); + } + } + + @Test + public void takeoverSectionThatIsNotAnObjectIsReported() throws IOException { + Path file = config("{\"dimensionTakeovers\": [\"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")); + } finally { + ModdedTakeoverReconcilerTest.deleteTree(file.getParent()); + } + } + + private static Path config(String json) throws IOException { + Path file = Files.createTempDirectory("iris-modded-config").resolve("modded.json"); + Files.writeString(file, json, StandardCharsets.UTF_8); + return file; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java index 51bbc8246..ae430d686 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTakeoverReconcilerTest.java @@ -33,6 +33,7 @@ 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 { @@ -64,7 +65,7 @@ public class ModdedTakeoverReconcilerTest { touch(worldRoot.resolve("players/data/player.dat")); ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( - worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent); + worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent); assertEquals(Set.of("iristest:skylands"), result.changedLevels()); for (String folder : List.of("region", "entities", "poi", "mantle-hydrology", "iris")) { @@ -92,14 +93,14 @@ public class ModdedTakeoverReconcilerTest { touch(region); ModdedTakeoverReconciler.Result unchanged = ModdedTakeoverReconciler.reconcile( - worldRoot, Map.of("iristest:skylands", "overworld"), recoveryParent); + worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent); assertEquals(Set.of(), unchanged.changedLevels()); assertNull(unchanged.recovery()); assertTrue(Files.isRegularFile(region)); ModdedTakeoverReconciler.Result removed = ModdedTakeoverReconciler.reconcile( - worldRoot, Map.of(), recoveryParent); + worldRoot, Map.of(), List.of(), recoveryParent); assertEquals(Set.of("iristest:skylands"), removed.changedLevels()); assertFalse(Files.exists(region)); @@ -119,7 +120,7 @@ public class ModdedTakeoverReconcilerTest { Files.createDirectories(worldRoot); ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( - worldRoot, Map.of("aether:the_aether", "overworld"), recoveryParent); + worldRoot, Map.of("aether:the_aether", "overworld"), List.of(), recoveryParent); assertEquals(Set.of("aether:the_aether"), result.changedLevels()); assertNull(result.recovery()); @@ -130,6 +131,59 @@ public class ModdedTakeoverReconcilerTest { } } + @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 problems = List.of("Iris could not read /srv/config/irisworldgen/modded.json: Unterminated object"); + + IOException refused = assertThrows(IOException.class, () -> ModdedTakeoverReconciler.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, () -> ModdedTakeoverReconciler.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 = ModdedTakeoverReconciler.reconcile( + worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config")); + + assertEquals(Set.of(), result.changedLevels()); + } finally { + deleteTree(temporary); + } + } + private static void touch(Path file) throws IOException { Files.createDirectories(file.getParent()); Files.writeString(file, "x", StandardCharsets.UTF_8);