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 cd99c8f12..396284151 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 @@ -18,13 +18,19 @@ 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.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; @@ -33,7 +39,9 @@ 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; public final class ModdedTakeoverReconciler { private static final String STATE_FILE = "data/irisworldgen/dimension-takeovers.json"; @@ -56,10 +64,16 @@ public final class ModdedTakeoverReconciler { } Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen"); Result result = reconcile(worldRoot, configured, config.takeoverProblems(), recoveryParent); - if (result.changedLevels().isEmpty()) { + if (!result.adoptedLevels().isEmpty()) { + ModdedIrisLog.info("Iris dimension takeover recorded for {}: their existing Iris generation history already uses the configured pack", + result.adoptedLevels()); + } + Set quarantined = new TreeSet<>(result.changedLevels()); + quarantined.removeAll(result.adoptedLevels()); + if (quarantined.isEmpty()) { return; } - ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", result.changedLevels(), + ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", quarantined, result.recovery() == null ? "no existing chunk data to quarantine" : "moved their chunk, entity, POI, and Iris Mantle data to " + result.recovery() @@ -77,34 +91,91 @@ public final class ModdedTakeoverReconciler { } Map configured = ModdedModConfig.get().dimensionTakeovers(); try { - if (readApplied(worldRoot.get()).equals(configured)) { - return; - } - writeApplied(worldRoot.get(), configured); + recordApplied(worldRoot.get(), configured, + (String levelKey, String packRef) -> generatesFrom(server, levelKey, packRef)); } catch (IOException | RuntimeException failure) { - ModdedIrisLog.error("Iris could not record the applied dimension takeovers under {}", worldRoot.get(), 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 recordApplied(Path worldRoot, Map configured, + BiPredicate generating) throws IOException { + Map applied = readApplied(worldRoot); + Map recorded = new TreeMap<>(applied); + for (Map.Entry 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(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()) { - return new Result(changed, null); + return new Result(changed, Set.of(), null); } + Map state = new TreeMap<>(applied); + Set adopted = new TreeSet<>(); List sources = new ArrayList<>(); for (String level : changed) { Path storage = ModdedDimensionTakeover.storageFolder(worldRoot, level); + String packRef = configured.get(level); + if (packRef != null && historyMatches(storage, ModdedDimensionTakeover.parse(level, packRef))) { + state.put(level, packRef); + adopted.add(level); + continue; + } + if (packRef == null) { + state.remove(level); + } for (String folder : ModdedDimensionStorage.chunkDataFolders()) { sources.add(storage.resolve(folder)); } } Path recovery = recoveryParent.resolve("takeover-recovery-" + UUID.randomUUID()); List moved = ModdedWorldQuarantine.move(worldRoot, sources, recovery); - writeApplied(worldRoot, configured); - return new Result(changed, moved.isEmpty() ? null : recovery); + if (!state.equals(applied)) { + writeApplied(worldRoot, state); + } + return new Result(changed, Set.copyOf(adopted), moved.isEmpty() ? null : recovery); + } + + static boolean historyMatches(Path storage, ModdedDimensionTakeover takeover) throws IOException { + if (!Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS)) { + return false; + } + GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch(); + GenerationEpoch.DimensionContract contract = epoch.dimensionContract(); + if (!contract.dimensionKey().equals(takeover.packDimension())) { + return false; + } + Optional scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey()); + if (scoped.isPresent()) { + return scoped.get().equals(new ModdedWorldgenIds.ScopedDimension(takeover.pack(), takeover.packDimension())); + } + Set 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 configProblems) throws IOException { @@ -154,6 +225,6 @@ public final class ModdedTakeoverReconciler { } } - record Result(Set changedLevels, Path recovery) { + record Result(Set changedLevels, Set adoptedLevels, Path recovery) { } } 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 ae430d686..e82769fb1 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 @@ -18,6 +18,9 @@ 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; @@ -76,7 +79,7 @@ public class ModdedTakeoverReconcilerTest { assertTrue(Files.isRegularFile(skylands.resolve("data/payload.bin"))); assertTrue(Files.isRegularFile(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca"))); assertTrue(Files.isRegularFile(worldRoot.resolve("players/data/player.dat"))); - assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot)); + assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); } finally { deleteTree(temporary); } @@ -112,7 +115,7 @@ public class ModdedTakeoverReconcilerTest { } @Test - public void changeWithoutChunkDataRecordsStateWithoutARecoveryFolder() throws IOException { + public void newTakeoverWithoutChunkDataStaysPendingWithoutARecoveryFolder() throws IOException { Path temporary = Files.createTempDirectory("iris-takeover-empty"); Path worldRoot = temporary.resolve("world"); Path recoveryParent = temporary.resolve("config"); @@ -125,7 +128,7 @@ public class ModdedTakeoverReconcilerTest { assertEquals(Set.of("aether:the_aether"), result.changedLevels()); assertNull(result.recovery()); assertFalse(Files.exists(recoveryParent)); - assertEquals(Map.of("aether:the_aether", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot)); + assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); } finally { deleteTree(temporary); } @@ -184,6 +187,140 @@ public class ModdedTakeoverReconcilerTest { } } + @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 = ModdedTakeoverReconciler.reconcile( + worldRoot, Map.of("iristest:skylands", "skypack"), List.of(), temporary.resolve("config")); + + assertEquals(Set.of("iristest:skylands"), result.changedLevels()); + 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 = ModdedTakeoverReconciler.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 = ModdedTakeoverReconciler.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 = ModdedTakeoverReconciler.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 = ModdedTakeoverReconciler.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 recorded = ModdedTakeoverReconciler.recordApplied(worldRoot, + Map.of("iristest:kept", "overworld", "iristest:live", "skypack", "iristest:skipped", "skypack"), + (String level, String packRef) -> !level.equals("iristest:skipped")); + + Map 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); + } + } + + private static void createHistory(Path temporary, Path dimensionRoot, String dimensionTypeKey, String dimensionKey, + List 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);