mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-19 06:54:45 +00:00
Hardened takeover state recording
- A changed level whose existing Iris generation history already uses the configured pack is recorded as applied instead of quarantined - New and changed takeovers are recorded only once their level generates from the configured pack; removals are recorded at reconciliation - A changed takeover keeps its previous applied state until the new pack generates, so a later staging failure still sees the level as taken over - Failing to write the applied takeover state stops the server instead of only logging
This commit is contained in:
+82
-11
@@ -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<String> 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<String, String> 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<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(Path worldRoot, Map<String, String> configured, List<String> configProblems,
|
||||
Path recoveryParent) throws IOException {
|
||||
requireReadableConfig(worldRoot, configProblems);
|
||||
Map<String, String> applied = readApplied(worldRoot);
|
||||
Set<String> changed = ModdedDimensionTakeovers.changedLevels(applied, configured);
|
||||
if (changed.isEmpty()) {
|
||||
return new Result(changed, null);
|
||||
return new Result(changed, Set.of(), null);
|
||||
}
|
||||
Map<String, String> state = new TreeMap<>(applied);
|
||||
Set<String> adopted = new TreeSet<>();
|
||||
List<Path> 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<Path> 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<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 {
|
||||
@@ -154,6 +225,6 @@ public final class ModdedTakeoverReconciler {
|
||||
}
|
||||
}
|
||||
|
||||
record Result(Set<String> changedLevels, Path recovery) {
|
||||
record Result(Set<String> changedLevels, Set<String> adoptedLevels, Path recovery) {
|
||||
}
|
||||
}
|
||||
|
||||
+140
-3
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user