From 038833e6149ec757c55025fe47e0accb0ef084f7 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 14 Oct 2026 20:59:09 -0400 Subject: [PATCH] Refused unstageable takeovers - Datapack staging refuses a takeover it cannot stage when the level has applied takeover state or Iris generation history, instead of silently leaving the mod generator in place - A level's retained generation history now stages its takeover even when the installed pack is gone - Takeover pack names and dimension keys are resolved to the installed folder and file names before staging, reconciliation, and recording, and the takeover command stores that form - Reconciliation refuses to switch an applied level to an uninstalled pack before moving any data, and leaves a never-applied level untouched until its pack is installed --- .../iris/modded/ModdedDimensionTakeovers.java | 90 +++++++++++++- .../iris/modded/ModdedForcedDatapack.java | 34 ++++-- .../iris/modded/ModdedTakeoverReconciler.java | 88 +++++++++----- .../modded/command/ModdedWorldCommands.java | 20 +++- .../modded/ModdedDimensionTakeoversTest.java | 113 ++++++++++++++++++ .../iris/modded/ModdedForcedDatapackTest.java | 4 +- .../modded/ModdedTakeoverReconcilerTest.java | 83 ++++++++++--- 7 files changed, 361 insertions(+), 71 deletions(-) diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java index ef7f53a56..0c2ccf5d8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionTakeovers.java @@ -18,13 +18,16 @@ 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; @@ -36,6 +39,8 @@ 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() { } @@ -80,13 +85,12 @@ public final class ModdedDimensionTakeovers { return output; } - public static void writeLevelStems(Path datapackRoot, Map configured, - Map stagedDimensionTypes) throws IOException { + public static void writeLevelStems(Path datapackRoot, Map configured, StagedTypes staged, + Path worldRoot) throws IOException { for (ModdedDimensionTakeover takeover : parseAll(configured)) { - String typeRef = stagedDimensionTypes.get(takeover.pack() + ":" + takeover.packDimension()); + String typeRef = staged.typeRef(takeover, worldRoot); if (typeRef == null) { - ModdedIrisLog.error("Iris skipped the dimension takeover of {}: pack '{}' dimension '{}' is not installed or failed validation, so the dimension keeps its own generator", - takeover.levelKey(), takeover.pack(), takeover.packDimension()); + skipUnstaged(takeover, staged, worldRoot); continue; } writeLevelStem(datapackRoot, takeover, typeRef); @@ -95,6 +99,40 @@ public final class ModdedDimensionTakeovers { } } + public static Map configured() throws IOException { + return canonical(ModdedModConfig.get().dimensionTakeovers(), IrisPlatforms.get().packsFolderNoCreate().toPath()); + } + + static Map canonical(Map configured, Path packsRoot) throws IOException { + Map 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 changedLevels(Map applied, Map configured) { Set levels = new TreeSet<>(applied.keySet()); levels.addAll(configured.keySet()); @@ -107,6 +145,32 @@ public final class ModdedDimensionTakeovers { 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 configured) { StringBuilder canonical = new StringBuilder(); for (Map.Entry entry : new TreeMap<>(configured).entrySet()) { @@ -114,4 +178,20 @@ public final class ModdedDimensionTakeovers { } return canonical.toString(); } + + public record StagedTypes(Map installed, Map 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()); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index f0bf00eb4..0cd6b280f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -324,7 +324,7 @@ public final class ModdedForcedDatapack { false ); digest.update(inputFingerprint.getBytes(StandardCharsets.UTF_8)); - digest.update(ModdedDimensionTakeovers.fingerprint(ModdedModConfig.get().dimensionTakeovers()) + digest.update(ModdedDimensionTakeovers.fingerprint(ModdedDimensionTakeovers.configured()) .getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest.digest()); } @@ -335,7 +335,7 @@ public final class ModdedForcedDatapack { int packCount = 0; KList presetIds = new KList<>(); - Map stagedDimensionTypes = new LinkedHashMap<>(); + ModdedDimensionTakeovers.StagedTypes stagedDimensionTypes = ModdedDimensionTakeovers.StagedTypes.create(); Path worldRoot = worldRootOrNull(); Set legacySelections = unadoptedSelections(worldRoot); List packRoots = compilationPackRoots(worldRoot); @@ -347,7 +347,7 @@ public final class ModdedForcedDatapack { } if (!ModdedEngineBootstrap.loader().clientEnvironment()) { ModdedDimensionTakeovers.writeLevelStems(stagingDirectory, - ModdedModConfig.get().dimensionTakeovers(), stagedDimensionTypes); + ModdedDimensionTakeovers.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> seenBiomes, KList presetIds, - Map stagedDimensionTypes) throws IOException { + ModdedDimensionTakeovers.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> packBiomes = new LinkedHashMap<>(); KList packPresetIds = new KList<>(); - Map packDimensionTypes = new LinkedHashMap<>(); + ModdedDimensionTakeovers.StagedTypes packDimensionTypes = ModdedDimensionTakeovers.StagedTypes.create(); KList packFolders = new KList<>(); packFolders.add(packStagingDirectory.toFile()); boolean installed; @@ -423,7 +423,8 @@ public final class ModdedForcedDatapack { mergeDirectory(packStagingDirectory, stagingDirectory); mergeBiomes(seenBiomes, packBiomes); presetIds.addAll(packPresetIds); - stagedDimensionTypes.putAll(packDimensionTypes); + stagedDimensionTypes.installed().putAll(packDimensionTypes.installed()); + stagedDimensionTypes.retained().putAll(packDimensionTypes.retained()); return true; } finally { try { @@ -483,7 +484,7 @@ public final class ModdedForcedDatapack { private static boolean installPack(PackStageContext context, IDataFixer fixer, KList folders, Map> seenBiomes, KList presetIds, - Map dimensionTypes) throws IOException { + ModdedDimensionTakeovers.StagedTypes dimensionTypes) throws IOException { File packFolder = context.packRoot().toFile(); String packName = context.packName(); File dimensionsDirectory = new File(packFolder, "dimensions"); @@ -531,8 +532,11 @@ public final class ModdedForcedDatapack { context ); } + if (context.retainedDimensionRoot() != null) { + dimensionTypes.retained().put(context.retainedDimensionRoot(), typeRef); + } if (context.emitPresets()) { - dimensionTypes.put(packName + ":" + dimensionKey, typeRef); + dimensionTypes.installed().put(packName + ":" + dimensionKey, typeRef); if (!dimension.hasBorrowedDimensionType()) { String presetRef = ModdedWorldgenIds.presetRef(packName, dimensionKey); writeWorldPreset(folders, packName, dimensionKey, presetRef); @@ -830,13 +834,13 @@ public final class ModdedForcedDatapack { GenerationEpoch.DimensionContract contract = retained.epoch().dimensionContract(); Optional scoped = ModdedWorldgenIds.scopedDimensionType(contract.dimensionTypeKey()); if (scoped.isEmpty()) { - return PackStageContext.retained(normalizedRoot, retainedPackName(retained.epoch(), normalizedRoot), retained.epoch()); + return PackStageContext.retained(normalizedRoot, retainedPackName(retained.epoch(), normalizedRoot), retained); } ModdedWorldgenIds.ScopedDimension identity = scoped.get(); if (!identity.dimension().equals(contract.dimensionKey())) { throw new IOException("Retained Iris dimension type identity does not match its epoch contract."); } - return PackStageContext.retained(normalizedRoot, identity.pack(), retained.epoch()); + return PackStageContext.retained(normalizedRoot, identity.pack(), retained); } String packName = normalizedRoot.getFileName().toString(); @@ -918,7 +922,8 @@ public final class ModdedForcedDatapack { boolean emitLegacyDimensionTypeAlias, boolean required, Set legacyDimensions, - GenerationRegistryContract retainedContract + GenerationRegistryContract retainedContract, + Path retainedDimensionRoot ) implements IrisCustomBiomeAliasResolver { private static PackStageContext installed( Path packRoot, @@ -934,6 +939,7 @@ public final class ModdedForcedDatapack { true, false, Set.copyOf(legacyDimensions), + null, null ); } @@ -941,8 +947,9 @@ public final class ModdedForcedDatapack { private static PackStageContext retained( Path packRoot, String packName, - GenerationEpoch epoch + RetainedPack retained ) { + GenerationEpoch epoch = retained.epoch(); return new PackStageContext( packRoot, packName, @@ -952,7 +959,8 @@ public final class ModdedForcedDatapack { true, true, Set.of(), - epoch.registryContract() + epoch.registryContract(), + retained.history().paths().dimensionRoot().toAbsolutePath().normalize() ); } 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 396284151..a37922f51 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,6 +18,7 @@ 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; @@ -42,6 +43,7 @@ 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"; @@ -53,7 +55,7 @@ public final class ModdedTakeoverReconciler { public static void reconcileEarly() { try { ModdedModConfig config = ModdedModConfig.get(); - Map configured = config.dimensionTakeovers(); + Map configured = ModdedDimensionTakeovers.configured(); Path worldRoot = MainWorldService.configuredWorldRootIfPresent(); if (worldRoot == null) { if (!configured.isEmpty()) { @@ -63,17 +65,17 @@ public final class ModdedTakeoverReconciler { return; } Path recoveryParent = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen"); - Result result = reconcile(worldRoot, configured, config.takeoverProblems(), recoveryParent); + 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()); } - Set quarantined = new TreeSet<>(result.changedLevels()); - quarantined.removeAll(result.adoptedLevels()); - if (quarantined.isEmpty()) { + if (result.quarantinedLevels().isEmpty()) { return; } - ModdedIrisLog.warn("Iris dimension takeover changed for {}: {}", quarantined, + 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() @@ -89,9 +91,8 @@ public final class ModdedTakeoverReconciler { if (worldRoot.isEmpty()) { return; } - Map configured = ModdedModConfig.get().dimensionTakeovers(); try { - recordApplied(worldRoot.get(), configured, + 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", @@ -118,42 +119,69 @@ public final class ModdedTakeoverReconciler { return Map.copyOf(recorded); } - static Result reconcile(Path worldRoot, Map configured, List configProblems, - Path recoveryParent) throws IOException { - requireReadableConfig(worldRoot, configProblems); + static Result reconcile(Request request) throws IOException { + Path worldRoot = request.worldRoot(); + requireReadableConfig(worldRoot, request.configProblems()); Map applied = readApplied(worldRoot); - Set changed = ModdedDimensionTakeovers.changedLevels(applied, configured); - if (changed.isEmpty()) { - return new Result(changed, Set.of(), null); - } + Set changed = ModdedDimensionTakeovers.changedLevels(applied, request.configured()); Map state = new TreeMap<>(applied); + Set quarantined = new TreeSet<>(); 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; - } + 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 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 = recoveryParent.resolve("takeover-recovery-" + UUID.randomUUID()); - List moved = ModdedWorldQuarantine.move(worldRoot, sources, recovery); + Path recovery = request.recoveryParent().resolve("takeover-recovery-" + UUID.randomUUID()); + List moved = sources.isEmpty() ? List.of() : ModdedWorldQuarantine.move(worldRoot, sources, recovery); if (!state.equals(applied)) { writeApplied(worldRoot, state); } - return new Result(changed, Set.copyOf(adopted), moved.isEmpty() ? null : recovery); + 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 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 (!Files.exists(GenerationHistoryPaths.forDimension(storage).generationRoot(), LinkOption.NOFOLLOW_LINKS)) { + if (!hasGenerationHistory(storage)) { return false; } GenerationEpoch epoch = GenerationHistory.inspectPacks(storage).manifest().activeEpoch(); @@ -225,6 +253,10 @@ public final class ModdedTakeoverReconciler { } } - record Result(Set changedLevels, Set adoptedLevels, Path recovery) { + record Request(Path worldRoot, Map configured, List configProblems, + Predicate installed, Path recoveryParent) { + } + + record Result(Set quarantinedLevels, Set adoptedLevels, Path recovery) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java index 1380870bb..7718929bb 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java @@ -27,6 +27,7 @@ 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; @@ -46,6 +47,7 @@ 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; @@ -320,12 +322,20 @@ public final class ModdedWorldCommands { if (blockIfPackBroken(source, takeover.levelKey(), pack) || !loadPackDimension(source, pack, takeover.packDimension())) { return 0; } - ModdedModConfig.setDimensionTakeover(takeover); - ModdedForcedDatapack.scheduleRegeneration("dimension takeover of " + takeover.levelKey()); + 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", takeover.levelKey()), - MessageArgument.untrusted("pack", pack), - MessageArgument.untrusted("packDimension", takeover.packDimension()))); + 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; } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java index d90889747..b2df3247c 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTakeoversTest.java @@ -30,6 +30,7 @@ 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; @@ -130,4 +131,116 @@ public class ModdedDimensionTakeoversTest { 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 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); + } + } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java index 58697dc83..9a4f04f16 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java @@ -111,10 +111,10 @@ public class ModdedForcedDatapackTest { Map.of(key, saved)); Class stageType = Class.forName(ModdedForcedDatapack.class.getName() + "$PackStageContext"); Constructor constructor = stageType.getDeclaredConstructor(Path.class, String.class, String.class, - String.class, boolean.class, boolean.class, boolean.class, Set.class, GenerationRegistryContract.class); + String.class, boolean.class, boolean.class, boolean.class, Set.class, GenerationRegistryContract.class, Path.class); constructor.setAccessible(true); IrisCustomBiomeAliasResolver resolver = (IrisCustomBiomeAliasResolver) constructor.newInstance( - Path.of("retained"), "test", "test", "iris:test_type", false, false, true, Set.of(), contract); + Path.of("retained"), "test", "test", "iris:test_type", false, false, true, Set.of(), contract, Path.of("dimension")); assertEquals(source, resolver.generatedSource(key.registryKey(), key.resourceKey(), "{}", fixer)); assertThrows(IOException.class, () -> resolver.generatedSource( key.registryKey(), "iris:missing_type", "{}", fixer)); 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 e82769fb1..e6bc58250 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 @@ -67,10 +67,10 @@ public class ModdedTakeoverReconcilerTest { touch(worldRoot.resolve("dimensions/minecraft/overworld/region/r.0.0.mca")); touch(worldRoot.resolve("players/data/player.dat")); - ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result result = reconcile( worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent); - assertEquals(Set.of("iristest:skylands"), result.changedLevels()); + 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() @@ -95,17 +95,17 @@ public class ModdedTakeoverReconcilerTest { ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld")); touch(region); - ModdedTakeoverReconciler.Result unchanged = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result unchanged = reconcile( worldRoot, Map.of("iristest:skylands", "overworld"), List.of(), recoveryParent); - assertEquals(Set.of(), unchanged.changedLevels()); + assertEquals(Set.of(), unchanged.quarantinedLevels()); assertNull(unchanged.recovery()); assertTrue(Files.isRegularFile(region)); - ModdedTakeoverReconciler.Result removed = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result removed = reconcile( worldRoot, Map.of(), List.of(), recoveryParent); - assertEquals(Set.of("iristest:skylands"), removed.changedLevels()); + 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)); @@ -122,10 +122,10 @@ public class ModdedTakeoverReconcilerTest { try { Files.createDirectories(worldRoot); - ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result result = reconcile( worldRoot, Map.of("aether:the_aether", "overworld"), List.of(), recoveryParent); - assertEquals(Set.of("aether:the_aether"), result.changedLevels()); + assertEquals(Set.of("aether:the_aether"), result.quarantinedLevels()); assertNull(result.recovery()); assertFalse(Files.exists(recoveryParent)); assertEquals(Map.of(), ModdedTakeoverReconciler.readApplied(worldRoot)); @@ -145,7 +145,7 @@ public class ModdedTakeoverReconcilerTest { touch(region); List problems = List.of("Iris could not read /srv/config/irisworldgen/modded.json: Unterminated object"); - IOException refused = assertThrows(IOException.class, () -> ModdedTakeoverReconciler.reconcile( + IOException refused = assertThrows(IOException.class, () -> reconcile( worldRoot, Map.of(), problems, recoveryParent)); assertTrue(refused.getMessage(), refused.getMessage().contains("modded.json")); @@ -164,7 +164,7 @@ public class ModdedTakeoverReconcilerTest { try { ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of()); - assertThrows(IOException.class, () -> ModdedTakeoverReconciler.reconcile( + assertThrows(IOException.class, () -> reconcile( worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config"))); } finally { deleteTree(temporary); @@ -178,10 +178,10 @@ public class ModdedTakeoverReconcilerTest { try { Files.createDirectories(worldRoot); - ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result result = reconcile( worldRoot, Map.of(), List.of("invalid takeover entry"), temporary.resolve("config")); - assertEquals(Set.of(), result.changedLevels()); + assertEquals(Set.of(), result.quarantinedLevels()); } finally { deleteTree(temporary); } @@ -196,10 +196,10 @@ public class ModdedTakeoverReconcilerTest { ModdedTakeoverReconciler.writeApplied(worldRoot, Map.of("iristest:skylands", "overworld")); touch(region); - ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result result = reconcile( worldRoot, Map.of("iristest:skylands", "skypack"), List.of(), temporary.resolve("config")); - assertEquals(Set.of("iristest:skylands"), result.changedLevels()); + assertEquals(Set.of("iristest:skylands"), result.quarantinedLevels()); assertFalse(Files.exists(region)); assertEquals(Map.of("iristest:skylands", "overworld"), ModdedTakeoverReconciler.readApplied(worldRoot)); } finally { @@ -216,7 +216,7 @@ public class ModdedTakeoverReconcilerTest { createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("skypack", "sky"), "sky", List.of()); touch(skylands.resolve("region/r.0.0.mca")); - ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result result = reconcile( worldRoot, Map.of("iristest:skylands", "skypack:sky"), List.of(), temporary.resolve("config")); assertEquals(Set.of("iristest:skylands"), result.adoptedLevels()); @@ -237,7 +237,7 @@ public class ModdedTakeoverReconcilerTest { createHistory(temporary, skylands, ModdedWorldgenIds.dimensionTypeRef("otherpack", "sky"), "sky", List.of()); touch(skylands.resolve("region/r.0.0.mca")); - ModdedTakeoverReconciler.Result result = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result result = reconcile( worldRoot, Map.of("iristest:skylands", "skypack:sky"), List.of(), temporary.resolve("config")); assertEquals(Set.of(), result.adoptedLevels()); @@ -259,7 +259,7 @@ public class ModdedTakeoverReconcilerTest { List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow"))); touch(aether.resolve("region/r.0.0.mca")); - ModdedTakeoverReconciler.Result other = ModdedTakeoverReconciler.reconcile( + ModdedTakeoverReconciler.Result other = reconcile( worldRoot, Map.of("aether:the_aether", "otherpack:sky"), List.of(), temporary.resolve("config")); assertEquals(Set.of(), other.adoptedLevels()); @@ -267,7 +267,7 @@ public class ModdedTakeoverReconcilerTest { createHistory(temporary, aether, "aether:the_aether", "sky", List.of(ModdedWorldgenIds.biomeRef("skypack", "sky", "meadow"))); - ModdedTakeoverReconciler.Result matching = ModdedTakeoverReconciler.reconcile( + 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()); @@ -310,6 +310,53 @@ public class ModdedTakeoverReconcilerTest { } } + @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 configured, + List 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 biomeKeys) throws IOException { Path source = Files.createTempDirectory(temporary, "pack");