diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index e52cb14c3..07586cb74 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -4,7 +4,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.IrisStructureLocator; -import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.object.IrisDimension; @@ -14,11 +14,15 @@ import art.arcane.iris.nativegen.NativeStructureGenerationException; import art.arcane.iris.nativegen.NativeStructureStartInjector; import art.arcane.iris.nativegen.NativeStructureReferenceEnvelope; import art.arcane.iris.nativegen.NativeStructureLocateResults; +import art.arcane.iris.nativegen.NativeStructureLocatePersistence; +import art.arcane.iris.nativegen.NativeStructureOwnershipRecovery; import art.arcane.iris.nativegen.NativeStructurePostProcessor; +import art.arcane.iris.nativegen.NativeStructureReferenceRepair; import art.arcane.iris.nativegen.NativeStructureSurfaceFitter; import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator; import art.arcane.iris.nativegen.NativeStructureVegetationClearer; import art.arcane.iris.nativegen.NativeStructureVerticalPlacer; +import art.arcane.iris.nativegen.NativeStructureVanillaLocator; import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.IrisCustomData; @@ -87,6 +91,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -131,55 +136,99 @@ public class IrisChunkGenerator extends CustomChunkGenerator { public @Nullable Pair> findNearestMapStructure(ServerLevel level, HolderSet holders, BlockPos pos, int radius, boolean findUnexplored) { try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - Pair> irisPlaced = findNearestIrisStructure( - level, holders, pos, Math.max(1, radius), findUnexplored); HolderSet reachable = filterReachableStructures(level, holders); - Pair> nativeLocated = reachable == null || reachable.size() == 0 - ? null - : delegate.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); - return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); + NativeStructureVanillaLocator.Candidate nativeCandidate = + reachable == null || reachable.size() == 0 ? null + : NativeStructureVanillaLocator.predict( + level, reachable, pos, radius, findUnexplored); + return findNearestIrisStructure( + level, holders, pos, Math.max(0, radius), + findUnexplored, nativeCandidate); } } private Pair> findNearestIrisStructure(ServerLevel level, HolderSet holders, BlockPos pos, int radius, - boolean findUnexplored) { - if (findUnexplored) { - return null; - } + boolean findUnexplored, + NativeStructureVanillaLocator.Candidate nativeCandidate) { + Pair> nativeLocated = + nativeCandidate == null ? null : nativeCandidate.result(); + Runnable nativeReference = () -> { + if (nativeCandidate != null) { + nativeCandidate.reference(level.structureManager()); + } + }; Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); - BlockPos best = null; - Holder bestHolder = null; - long bestDist = Long.MAX_VALUE; + List searches = new ArrayList<>(holders.size()); + NativeStructureLocatePersistence.ProbeBudget budget = NativeStructureLocatePersistence.probeBudget(); for (Holder holder : holders) { Object id = registry.getKey(holder.value()); if (id == null) { throw new IllegalStateException("Native structure locate received an unregistered structure holder"); } String structureId = id.toString(); - if (!IrisStructureLocator.isPlaced(engine, structureId)) { + if (!IrisStructureLocator.hasNativePlacement(engine, structureId)) { continue; } - IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( - engine, structureId, pos.getX(), pos.getZ(), radius); - if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { - throw new IllegalStateException("Iris structure locate reached its safety limit for " - + structureId + " within " + radius + " chunks"); - } - if (!result.found()) { - continue; - } - long dx = (long) result.originX() - pos.getX(); - long dz = (long) result.originZ() - pos.getZ(); - long d = dx * dx + dz * dz; - if (d < bestDist) { - bestDist = d; - best = new BlockPos(result.originX(), result.baseY(), result.originZ()); - bestHolder = holder; - } + NativeStructureLocatePersistence.Probe probe = NativeStructureLocatePersistence.probe( + level, holder.value(), findUnexplored, budget); + searches.add(new IrisNativeLocateSearch( + holder, structureId, NativeStructureLocatePersistence.search( + engine, structureId, pos.getX(), pos.getZ(), radius, probe))); } - return best == null ? null : Pair.of(best, bestHolder); + searches.sort(Comparator.comparing(IrisNativeLocateSearch::structureId)); + for (int attempt = 0; attempt < NativeStructureLocatePersistence.MAX_SELECTED_CANDIDATE_RETRIES; attempt++) { + IrisNativeLocateSearch bestSearch = null; + IrisStructureLocator.LocateResult bestResult = null; + long bestDistance = Long.MAX_VALUE; + for (IrisNativeLocateSearch search : searches) { + IrisStructureLocator.LocateResult result = search.search().predict(); + if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + throw new IllegalStateException("Iris structure locate reached its safety limit for " + + search.structureId() + " within " + radius + " placement rings"); + } + if (!result.found()) { + continue; + } + long dx = (long) result.originX() - pos.getX(); + long dz = (long) result.originZ() - pos.getZ(); + long distance = dx * dx + dz * dz; + if (distance < bestDistance) { + bestDistance = distance; + bestSearch = search; + bestResult = result; + } + } + if (bestSearch == null) { + return NativeStructureLocateResults.selectAndReference( + pos, null, () -> { }, nativeLocated, nativeReference); + } + Pair> predicted = Pair.of( + new BlockPos(bestResult.originX(), bestResult.baseY(), bestResult.originZ()), + bestSearch.holder()); + if (NativeStructureLocateResults.nearest(pos, predicted, nativeLocated) != predicted) { + return NativeStructureLocateResults.selectAndReference( + pos, predicted, () -> { }, nativeLocated, nativeReference); + } + NativeStructureLocatePersistence.VerifiedStart verified = + bestSearch.search().verify(bestResult); + if (verified == null) { + bestSearch.search().reject(bestResult); + continue; + } + BlockPos located = new BlockPos( + bestResult.originX(), verified.ownership().locatorY(), + bestResult.originZ()); + Pair> irisLocated = Pair.of(located, bestSearch.holder()); + IrisNativeLocateSearch selectedSearch = bestSearch; + NativeStructureLocatePersistence.VerifiedStart selectedStart = verified; + return NativeStructureLocateResults.selectAndReference( + pos, irisLocated, () -> selectedSearch.search().reference(selectedStart), + nativeLocated, nativeReference); + } + throw new IllegalStateException("Iris structure locate rejected too many selected candidates within " + + radius + " placement rings"); } private HolderSet filterReachableStructures(ServerLevel level, HolderSet holders) { @@ -323,9 +372,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator { decision.preserveSourceY(), decision.yBand(), (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); - StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( - start, structure, start.getReferences(), templateManager, - NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain())); + StructureStart wrapped = NativeStructureReferenceEnvelope.wrapForPublication( + start, structure, start.getReferences(), + NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()), + structureId); access.setStartForStructure(structure, wrapped); } catch (Throwable error) { throw NativeStructureGenerationException.failure( @@ -341,7 +391,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void createReferences(WorldGenLevel generatoraccessseed, StructureManager structuremanager, ChunkAccess ichunkaccess) { - delegate.createReferences(generatoraccessseed, structuremanager, ichunkaccess); + try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_references"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + NativeStructureReferenceRepair.createReferences( + engine, generatoraccessseed, structuremanager, ichunkaccess); + } } @Override @@ -474,7 +528,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator { int steps = GenerationStep.Decoration.values().length; List placementGroups = new ArrayList<>(); List heightmapStarts = new ArrayList<>(); - List nativeStarts = new ArrayList<>(); List vegetationTargets = new ArrayList<>(); List terrainTargets = new ArrayList<>(); for (int step = 0; step < steps; step++) { @@ -492,10 +545,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator { List starts = structureManager.startsForStructure(sectionPos, structure); List resolvedPlacements = new ArrayList<>(starts.size()); for (StructureStart start : starts) { - NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan( - engine, structureId, start.getChunkPos().x(), start.getChunkPos().z()); - IrisNativeStructureDecision decision = plan == null - ? sourceDecision : NativeStructurePlacementPlanner.decisionFor(plan); + NativeStructureOwnershipRecord ownership = + NativeStructureOwnershipRecovery.resolve( + engine, world.getLevel(), structureId, structure, start); + IrisNativeStructureDecision decision = + ownership == null ? sourceDecision : ownership.restoredDecision(); if (!decision.generate()) { continue; } @@ -505,9 +559,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator { structureId, start, NativeStructureTerrainIntegrator.resolveNativeTerrain( start, decision.terrain()))); - if (plan == null || !plan.placement().isUnderground()) { - nativeStarts.add(start); - } boolean clearEntireFootprint = NativeStructureVegetationClearer .shouldClearEntireVegetationFootprint( structure.step(), decision.clearVegetation()); @@ -533,15 +584,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator { "heightmap priming", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } - try { - NativeStructureSurfaceFitter.prepareSurfaceStructures( - world, area, nativeStarts, - (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); - } catch (Throwable error) { - throw NativeStructureGenerationException.failure( - "terrain integration", nativeStructureBatchContext(placementGroups), - chunkPos.x(), chunkPos.z(), error); - } try { NativeStructureVegetationClearer.clearIntersectingVegetation( world, chunk, area, vegetationTargets); @@ -550,6 +592,15 @@ public class IrisChunkGenerator extends CustomChunkGenerator { "vegetation cleanup", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } + try { + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world, area, terrainTargets, + (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "terrain integration", nativeStructureBatchContext(placementGroups), + chunkPos.x(), chunkPos.z(), error); + } try { NativeStructurePostProcessor.prepareTerrain( world, area, terrainTargets, this::resolvePaletteBlock); @@ -641,8 +692,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator { ChunkPos cp = chunk.getPos(); int i = cp.getMinBlockX(); int j = cp.getMinBlockZ(); - int minY = chunk.getMinY(); - int maxY = minY + chunk.getHeight() - 1; + int minY = chunk.getMinY() + 1; + int maxY = chunk.getMinY() + chunk.getHeight() - 1; return new BoundingBox(i, minY, j, i + 15, maxY, j + 15); } @@ -827,4 +878,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private record NativeLocateCandidate(Holder holder, String key) { } + + private record IrisNativeLocateSearch(Holder holder, String structureId, + NativeStructureLocatePersistence.Search search) { + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index ab372d7d2..f4d9e5dc6 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -15,7 +15,9 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.nativegen.NativeStructureFactory; import art.arcane.iris.nativegen.NativeStructureGenerationException; +import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata; import art.arcane.iris.util.project.agent.Agent; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -102,6 +104,7 @@ import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.WorldgenRandom; import net.minecraft.world.level.levelgen.XoroshiroRandomSource; import net.minecraft.world.level.levelgen.structure.StructureCheck; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature; @@ -557,6 +560,62 @@ public class NMSBinding implements INMSBinding { return keys; } + @Override + public JigsawSourceMetadata getJigsawSourceMetadata(String structureKey) { + try { + Identifier identifier = Identifier.tryParse(structureKey); + if (identifier == null) { + throw new IllegalArgumentException("Invalid registered structure key: " + structureKey); + } + Registry structures = registry().lookupOrThrow(Registries.STRUCTURE); + Structure structure = structures.getValue(identifier); + if (structure == null) { + throw new IllegalArgumentException("Registered structure does not exist: " + structureKey); + } + if (!(structure instanceof JigsawStructure jigsaw)) { + throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey); + } + MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer(); + return NativeStructureFactory.sourceMetadata( + registry(), server.getStructureManager(), jigsaw); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve live jigsaw metadata for registered structure '" + + structureKey + "'", error); + } + } + + @Override + public int getTemplatePoolHorizontalSpan(String templatePoolKey) { + try { + MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer(); + return NativeStructureFactory.templatePoolHorizontalSpan( + registry(), server.getStructureManager(), templatePoolKey); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve the live horizontal span for registered " + + "template pool '" + templatePoolKey + "'", error); + } + } + + @Override + public int getJigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) { + try { + Identifier identifier = Identifier.tryParse(structureKey); + if (identifier == null) { + throw new IllegalArgumentException("Invalid registered structure key: " + structureKey); + } + Structure structure = registry().lookupOrThrow(Registries.STRUCTURE).getValue(identifier); + if (!(structure instanceof JigsawStructure jigsaw)) { + throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey); + } + MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer(); + return NativeStructureFactory.jigsawStartPoolHorizontalSpan( + registry(), server.getStructureManager(), jigsaw, templatePoolKey); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve the effective start-pool span for registered " + + "jigsaw structure '" + structureKey + "' and pool '" + templatePoolKey + "'", error); + } + } + @Override public KList getStructureSetKeys() { KList keys = new KList<>(); @@ -700,7 +759,7 @@ public class NMSBinding implements INMSBinding { return null; } - net.minecraft.world.level.levelgen.structure.BoundingBox box = start.getBoundingBox(); + BoundingBox box = start.getBoundingBox(); int spanX = box.maxX() - box.minX() + 1; int spanY = box.maxY() - box.minY() + 1; int spanZ = box.maxZ() - box.minZ() + 1; @@ -717,7 +776,7 @@ public class NMSBinding implements INMSBinding { for (int cz = minCZ; cz <= maxCZ; cz++) { level.getChunk(cx, cz); net.minecraft.world.level.ChunkPos cp = new net.minecraft.world.level.ChunkPos(cx, cz); - net.minecraft.world.level.levelgen.structure.BoundingBox chunkBox = new net.minecraft.world.level.levelgen.structure.BoundingBox( + BoundingBox chunkBox = new BoundingBox( cp.getMinBlockX(), box.minY(), cp.getMinBlockZ(), cp.getMaxBlockX(), box.maxY(), cp.getMaxBlockZ()); start.placeInChunk(level, structureManager, generator, random, chunkBox, cp); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java index f3aa5ceba..ac4737af0 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -49,9 +49,9 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(placement.contains("because structure generation is disabled outside the pack")); assertTrue(placement.contains("prepareSurfaceStructures")); assertTrue(placement.contains("clearIntersectingVegetation")); - assertTrue(placement.indexOf("prepareSurfaceStructures") - < placement.indexOf("clearIntersectingVegetation")); assertTrue(placement.indexOf("clearIntersectingVegetation") + < placement.indexOf("prepareSurfaceStructures")); + assertTrue(placement.indexOf("prepareSurfaceStructures") < placement.indexOf("for (NativePlacementGroup group")); assertFalse(placement.contains("IrisLogging.reportError")); } @@ -66,6 +66,20 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(source.contains("catch (GenerationSessionException e)")); } + @Test + public void structureReferenceRepairIsGenerationLeased() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int referencesStart = source.indexOf("public void createReferences"); + int referencesEnd = source.indexOf("public CompletableFuture createBiomes", referencesStart); + String references = source.substring(referencesStart, referencesEnd); + + assertTrue(references.contains("requireGenerationLease(\"bukkit_nms_create_references\")")); + assertTrue(references.contains("IrisContext.open(engine, lease.sessionId(), null)")); + assertTrue(references.contains("NativeStructureReferenceRepair.createReferences(")); + assertFalse(references.contains("delegate.createReferences(")); + assertFalse(references.contains("catch (")); + } + @Test public void terrainWritesPrimeTheWorldgenHeightmapsForEveryChunk() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); @@ -89,6 +103,7 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(") < placement.indexOf("prepareSurfaceStructures")); assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_worldgen_heightmaps\")")); + assertTrue(source.contains("int minY = chunk.getMinY() + 1;")); } @Test @@ -112,6 +127,38 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(source.contains("import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;")); } + @Test + public void onlySidecarOwnedInjectedStartsUsePersistedPlacementPolicy() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int placementStart = source.indexOf("private void placeVanillaStructures"); + int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart); + String placement = source.substring(placementStart, placementEnd); + int ownershipResolution = placement.indexOf( + "NativeStructureOwnershipRecovery.resolve("); + int persistedDecision = placement.indexOf("ownership.restoredDecision()", ownershipResolution); + int adjustmentStart = source.indexOf("private void adjustGeneratedStructures"); + int adjustmentEnd = source.indexOf("public ChunkGeneratorStructureState createState", adjustmentStart); + String adjustment = source.substring(adjustmentStart, adjustmentEnd); + Path nativegen = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")).getParent(); + String factory = Files.readString(nativegen.resolve("NativeStructureFactory.java")); + String injector = Files.readString(nativegen.resolve("NativeStructureStartInjector.java")); + String recovery = Files.readString(nativegen.resolve("NativeStructureOwnershipRecovery.java")); + int ownershipRecord = injector.indexOf("NativeStructureOwnershipStore.record("); + int startPublication = injector.indexOf( + "context.structureManager().setStartForStructure(", ownershipRecord); + + assertTrue(ownershipResolution >= 0); + assertTrue(persistedDecision > ownershipResolution); + assertTrue(recovery.contains("NativeStructureOwnershipStore.findPersisted(")); + assertTrue(recovery.contains("NativeStructureOwnershipStore.record(")); + assertTrue(ownershipRecord >= 0); + assertTrue(startPublication > ownershipRecord); + assertTrue(factory.contains("NativeStructureReferenceEnvelope.wrapForPublication(")); + assertTrue(adjustment.contains("NativeStructureReferenceEnvelope.wrapForPublication(")); + assertFalse(source.contains("wrapManaged(")); + assertFalse(source.contains("isIrisManagedStart(")); + } + private static int occurrences(String source, String needle) { int count = 0; int index = source.indexOf(needle); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java index 0633975c8..ca44d0a5b 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java @@ -10,7 +10,9 @@ import org.junit.Test; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; @@ -27,17 +29,19 @@ public class IrisChunkGeneratorMonumentLocateContractTest { assertTrue(filterStart > irisHelperStart); String outerMethod = source.substring(findStart, irisHelperStart); String irisHelper = source.substring(irisHelperStart, filterStart); - int unexploredGuard = irisHelper.indexOf("if (findUnexplored)"); int registryLookup = irisHelper.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)"); int placedCheck = irisHelper.indexOf( - "if (!IrisStructureLocator.isPlaced(engine, structureId))"); - int irisLocate = irisHelper.indexOf("IrisStructureLocator.locate(", placedCheck); + "if (!IrisStructureLocator.hasNativePlacement(engine, structureId))"); + int irisLocate = irisHelper.indexOf("NativeStructureLocatePersistence.search(", placedCheck); int searchLimit = irisHelper.indexOf("LocateStatus.SEARCH_LIMIT_REACHED", irisLocate); - int limitSkip = irisHelper.indexOf("continue;", searchLimit); int nativeFilter = outerMethod.indexOf("filterReachableStructures(level, holders)"); - int delegateLocate = outerMethod.indexOf("delegate.findNearestMapStructure(level, reachable"); - int nearestSelection = outerMethod.indexOf( - "NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated)"); + int nativePrediction = outerMethod.indexOf("NativeStructureVanillaLocator.predict(", nativeFilter); + int irisResolution = outerMethod.indexOf("return findNearestIrisStructure(", nativePrediction); + int nearestSelection = irisHelper.indexOf( + "NativeStructureLocateResults.nearest(pos, predicted, nativeLocated)"); + int selectedVerification = irisHelper.indexOf("bestSearch.search().verify(bestResult)", nearestSelection); + int selectedReference = irisHelper.indexOf( + "selectedSearch.search().reference(selectedStart)", selectedVerification); int reachabilityStart = source.indexOf("private Set reachableStructureKeys", filterStart); assertTrue(reachabilityStart > filterStart); String filterMethod = source.substring(filterStart, reachabilityStart); @@ -46,22 +50,28 @@ public class IrisChunkGeneratorMonumentLocateContractTest { int emptyNativePartition = filterMethod.indexOf("if (candidates.isEmpty())", filterContinue); int reachabilityLookup = filterMethod.indexOf("reachableStructureKeys(level)", emptyNativePartition); - assertTrue(unexploredGuard >= 0); - assertTrue(registryLookup > unexploredGuard); + assertTrue(registryLookup >= 0); assertTrue(placedCheck > registryLookup); assertTrue(irisLocate > placedCheck); assertTrue(searchLimit > irisLocate); - assertTrue(limitSkip > searchLimit); assertTrue(nativeFilter >= 0); - assertTrue(delegateLocate > nativeFilter); - assertTrue(nearestSelection > delegateLocate); + assertTrue(nativePrediction > nativeFilter); + assertTrue(irisResolution > nativePrediction); + assertTrue(nearestSelection > irisLocate); + assertTrue(selectedVerification > nearestSelection); + assertTrue(selectedReference > selectedVerification); assertTrue(policyFilter >= 0); assertTrue(filterContinue > policyFilter); assertTrue(emptyNativePartition > filterContinue); assertTrue(reachabilityLookup > emptyNativePartition); assertFalse(filterMethod.contains("NativeStructureLocateCapability")); assertFalse(irisHelper.contains("NativeStructureLocateCapability.isPaperUnavailable(structureId)")); - assertTrue(irisHelper.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); + assertTrue(irisHelper.contains("NativeStructureLocatePersistence.probe(")); + assertTrue(irisHelper.contains("NativeStructureLocateResults.selectAndReference(")); + assertTrue(irisHelper.contains("selectedSearch.search().reference(selectedStart)")); + assertTrue(irisHelper.contains("findUnexplored")); + assertTrue(irisHelper.contains("verified.ownership().locatorY()")); + assertFalse(outerMethod.contains("delegate.findNearestMapStructure(")); } @Test @@ -77,6 +87,50 @@ public class IrisChunkGeneratorMonumentLocateContractTest { assertSame(nativeTie, NativeStructureLocateResults.nearest(origin, irisNear, nativeTie)); } + @Test + public void mixedUnexploredLocateReferencesOnlyTheSelectedProvider() { + BlockPos origin = BlockPos.ZERO; + Pair> irisNear = Pair.of(new BlockPos(4, 70, 0), null); + Pair> nativeFar = Pair.of(new BlockPos(8, 70, 0), null); + AtomicInteger irisReferences = new AtomicInteger(); + AtomicInteger nativeReferences = new AtomicInteger(); + + Pair> irisSelected = + NativeStructureLocateResults.selectAndReference( + origin, + irisNear, () -> irisReferences.incrementAndGet(), + nativeFar, () -> nativeReferences.incrementAndGet()); + + assertSame(irisNear, irisSelected); + assertEquals(1, irisReferences.get()); + assertEquals(0, nativeReferences.get()); + + Pair> nativeNear = Pair.of(new BlockPos(2, 70, 0), null); + Pair> nativeSelected = + NativeStructureLocateResults.selectAndReference( + origin, + irisNear, () -> irisReferences.incrementAndGet(), + nativeNear, () -> nativeReferences.incrementAndGet()); + + assertSame(nativeNear, nativeSelected); + assertEquals(1, irisReferences.get()); + assertEquals(1, nativeReferences.get()); + } + + @Test + public void nativePredictionIsReadOnlyUntilTheWinnerIsCommitted() throws IOException { + Path nativegen = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")).getParent(); + String source = Files.readString(nativegen.resolve("NativeStructureVanillaLocator.java")); + int predictionStart = source.indexOf("private static Candidate predictAt("); + int candidateStart = source.indexOf("public static final class Candidate", predictionStart); + String prediction = source.substring(predictionStart, candidateStart); + String candidate = source.substring(candidateStart); + + assertFalse(prediction.contains("addReference(")); + assertTrue(candidate.contains("structureManager.addReference(referenceStart)")); + assertTrue(candidate.contains("committed.compareAndSet(false, true)")); + } + @Test public void stiltSupportUsesPlacedSolidOccupancyWithoutSnapshotDifferenceRequirement() throws IOException { Path processor = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureFactoryTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureFactoryTest.java index 801bd2721..22904dc89 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureFactoryTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureFactoryTest.java @@ -1,11 +1,23 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.object.IrisJigsawConfiguration; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import com.mojang.datafixers.util.Either; +import com.mojang.datafixers.util.Pair; import com.mojang.serialization.MapCodec; import net.minecraft.SharedConstants; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; +import net.minecraft.core.HolderOwner; import net.minecraft.core.HolderSet; +import net.minecraft.core.Vec3i; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; import net.minecraft.server.Bootstrap; import net.minecraft.server.level.WorldGenRegion; import net.minecraft.util.RandomSource; @@ -13,10 +25,12 @@ import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.NoiseColumn; import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.biome.FixedBiomeSource; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ChunkGenerator; @@ -25,19 +39,40 @@ import net.minecraft.world.level.levelgen.RandomState; import net.minecraft.world.level.levelgen.blending.Blender; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.pools.EmptyPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElementType; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.pools.alias.PoolAliasBinding; import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import org.junit.BeforeClass; import org.junit.Test; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; public class NativeStructureFactoryTest { @BeforeClass @@ -67,20 +102,142 @@ public class NativeStructureFactoryTest { } @Test - public void forcedGeneratorSuppliesDeterministicPermissiveEnvironment() { + public void sourceMetadataReadsScalarAndAxisSpecificJigsawRanges() { + CompoundTag scalar = new CompoundTag(); + scalar.putInt("max_distance_from_center", 80); + CompoundTag axisSpecific = new CompoundTag(); + CompoundTag distance = new CompoundTag(); + distance.putInt("horizontal", 96); + distance.putInt("vertical", 64); + axisSpecific.put("max_distance_from_center", distance); + + assertEquals(80, NativeStructureFactory.sourceDistance(scalar, "horizontal")); + assertEquals(80, NativeStructureFactory.sourceDistance(scalar, "vertical")); + assertEquals(96, NativeStructureFactory.sourceDistance(axisSpecific, "horizontal")); + assertEquals(64, NativeStructureFactory.sourceDistance(axisSpecific, "vertical")); + } + + @Test + public void sourceMetadataIncludesAdjustBoundingBoxExpansion() { + Structure none = new SwampHutStructure(new Structure.StructureSettings(HolderSet.empty())); + Structure beardBox = new SwampHutStructure(new Structure.StructureSettings.Builder(HolderSet.empty()) + .terrainAdapation(TerrainAdjustment.BEARD_BOX) + .build()); + + assertEquals(0, NativeStructureFactory.horizontalReferenceExpansion(none)); + assertEquals(12, NativeStructureFactory.horizontalReferenceExpansion(beardBox)); + } + + @Test + public void templatePoolSpanIncludesWeightedListsFallbacksAndRotations() { + StructurePoolElement narrow = inlineElement(30, 8, 70); + StructurePoolElement wide = inlineElement(96, 8, 12); + ListPoolElement list = new ListPoolElement( + List.of(narrow, wide, EmptyPoolElement.INSTANCE), + StructureTemplatePool.Projection.RIGID); + BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:fallback"); + StructureTemplatePool fallback = new StructureTemplatePool( + fallbackHolder, + List.of(Pair.of(inlineElement(110, 8, 20), 1))); + fallbackHolder.bind(fallback); + BoundPoolHolder startHolder = new BoundPoolHolder("test:start"); + StructureTemplatePool start = new StructureTemplatePool( + fallbackHolder, + List.of(Pair.of(list, 5), Pair.of(EmptyPoolElement.INSTANCE, 1))); + startHolder.bind(start); + + assertEquals(110, NativeStructureTemplatePoolBounds.horizontalSpan(start, null)); + } + + @Test + public void oversizedInlineStartTemplateReportsItsFullSpan() { + BoundPoolHolder holder = new BoundPoolHolder("test:oversized"); + StructureTemplatePool pool = new StructureTemplatePool( + holder, + List.of(Pair.of(inlineElement(160, 8, 9), 1))); + holder.bind(pool); + + assertEquals(160, NativeStructureTemplatePoolBounds.horizontalSpan(pool, null)); + } + + @Test + public void sourceSpanIncludesOnlyAliasesThatCanReplaceTheStartPool() { + BoundPoolHolder emptyHolder = new BoundPoolHolder("test:empty"); + StructureTemplatePool empty = new StructureTemplatePool(emptyHolder, List.of()); + emptyHolder.bind(empty); + BoundPoolHolder startHolder = new BoundPoolHolder("test:start"); + StructureTemplatePool start = new StructureTemplatePool( + emptyHolder, List.of(Pair.of(inlineElement(20, 8, 12), 1))); + startHolder.bind(start); + BoundPoolHolder replacementHolder = new BoundPoolHolder("test:replacement"); + StructureTemplatePool replacement = new StructureTemplatePool( + emptyHolder, List.of(Pair.of(inlineElement(30, 8, 12), 1))); + replacementHolder.bind(replacement); + BoundPoolHolder unrelatedHolder = new BoundPoolHolder("test:unrelated"); + StructureTemplatePool unrelated = new StructureTemplatePool( + emptyHolder, List.of(Pair.of(inlineElement(200, 8, 12), 1))); + unrelatedHolder.bind(unrelated); + ResourceKey startKey = startHolder.unwrapKey().orElseThrow(); + ResourceKey replacementKey = replacementHolder.unwrapKey().orElseThrow(); + ResourceKey unrelatedKey = unrelatedHolder.unwrapKey().orElseThrow(); + ResourceKey childAlias = ResourceKey.create( + Registries.TEMPLATE_POOL, Identifier.parse("test:child_alias")); + List aliases = List.of( + PoolAliasBinding.direct(startKey, replacementKey), + PoolAliasBinding.direct(childAlias, unrelatedKey)); + Map, StructureTemplatePool> pools = Map.of( + replacementKey, replacement, + unrelatedKey, unrelated); + + assertEquals(30, NativeStructureTemplatePoolBounds.sourceHorizontalSpan( + null, start, startKey, aliases, pools::get)); + } + + @Test + public void sourceStartSpanDoesNotIncludeItsFallbackPool() { + BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:large_fallback"); + StructureTemplatePool fallback = new StructureTemplatePool( + fallbackHolder, List.of(Pair.of(inlineElement(180, 8, 10), 1))); + fallbackHolder.bind(fallback); + BoundPoolHolder startHolder = new BoundPoolHolder("test:small_start"); + StructureTemplatePool start = new StructureTemplatePool( + fallbackHolder, List.of(Pair.of(inlineElement(24, 8, 10), 1))); + startHolder.bind(start); + + assertEquals(180, NativeStructureTemplatePoolBounds.horizontalSpan(start, null)); + assertEquals(24, NativeStructureTemplatePoolBounds.sourceHorizontalSpan( + null, start, startHolder.unwrapKey().orElseThrow(), List.of(), ignored -> null)); + } + + @Test + public void customPoolElementUsesItsBoundingBoxCapability() { + BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:custom_fallback"); + StructureTemplatePool fallback = new StructureTemplatePool(fallbackHolder, List.of()); + fallbackHolder.bind(fallback); + BoundPoolHolder poolHolder = new BoundPoolHolder("test:custom"); + StructureTemplatePool pool = new StructureTemplatePool( + fallbackHolder, List.of(Pair.of(new BoundingPoolElement(37, 11), 1))); + poolHolder.bind(pool); + + assertEquals(37, NativeStructureTemplatePoolBounds.horizontalSpan(pool, null)); + } + + @Test + public void forcedGeneratorBypassesBiomeEligibilityWithoutFabricatingTerrain() { ChunkGenerator delegate = new TestChunkGenerator(); Holder biome = Holder.direct((Biome) null); LevelHeightAccessor heightAccessor = LevelHeightAccessor.create(-64, 384); ForcedStructureChunkGenerator generator = new ForcedStructureChunkGenerator( - delegate, biome, -20); + delegate, biome); - assertEquals(-63, generator.getSeaLevel()); - assertEquals(81, generator.getBaseHeight( + assertEquals(63, generator.getSeaLevel()); + assertEquals(64, generator.getBaseHeight( 0, 0, Heightmap.Types.WORLD_SURFACE_WG, heightAccessor, null)); NoiseColumn column = generator.getBaseColumn( 0, 0, heightAccessor, null); assertSame(Blocks.STONE, column.getBlock(80).getBlock()); - assertSame(Blocks.AIR, column.getBlock(81).getBlock()); + assertSame(Blocks.STONE, column.getBlock(81).getBlock()); + assertNotSame(delegate.getBiomeSource(), generator.getBiomeSource()); } @Test @@ -103,6 +260,96 @@ public class NativeStructureFactoryTest { assertEquals(-20, relocated.getBoundingBox().minY()); } + @Test + public void invalidGeneratedStartIsANormalSkippedCandidate() { + Structure source = new SwampHutStructure( + new Structure.StructureSettings(HolderSet.empty())); + StructureStart valid = new StructureStart( + source, + new ChunkPos(0, 0), + 0, + new PiecesContainer(List.of( + new SwampHutPiece(RandomSource.create(23L), 0, 0))) + ); + + assertFalse(NativeStructureStartInjector.isUsableGeneratedStart( + StructureStart.INVALID_START)); + assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(null)); + assertFalse(NativeStructureStartInjector.isUsableGeneratedStart( + new StructureStart(source, new ChunkPos(0, 0), 0, + new PiecesContainer(List.of())))); + assertTrue(NativeStructureStartInjector.isUsableGeneratedStart(valid)); + } + + @Test + public void unrepresentableGeneratedContentBecomesAnUnpublishedInvalidStart() { + Structure source = new SwampHutStructure( + new Structure.StructureSettings(HolderSet.empty())); + SwampHutPiece boundaryPiece = new SwampHutPiece(RandomSource.create(29L), 0, 0); + boundaryPiece.move(143 - boundaryPiece.getBoundingBox().maxX(), 0, 0); + StructureStart boundary = new StructureStart( + source, + new ChunkPos(0, 0), + 0, + new PiecesContainer(List.of(boundaryPiece)) + ); + SwampHutPiece oversizedPiece = new SwampHutPiece(RandomSource.create(31L), 0, 0); + oversizedPiece.move(144 - oversizedPiece.getBoundingBox().maxX(), 0, 0); + StructureStart oversized = new StructureStart( + source, + new ChunkPos(0, 0), + 0, + new PiecesContainer(List.of(oversizedPiece)) + ); + + StructureStart boundaryResult = NativeStructureReferenceEnvelope.wrapForPublication( + boundary, source, 0, new IrisStructureTerrain(), "test:boundary_content"); + + StructureStart result = NativeStructureReferenceEnvelope.wrapForPublication( + oversized, source, 0, + new IrisStructureTerrain(), + "test:oversized_content"); + + assertTrue(boundaryResult.isValid()); + assertSame(StructureStart.INVALID_START, result); + assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(result)); + assertEquals(1, oversized.getPieces().size()); + } + + @Test + public void failedManualGenerationClearsStaleOwnershipAfterReplacementHandling() throws Exception { + String source = Files.readString( + Path.of(System.getProperty("iris.nativeStructureStartInjectorSource")), + StandardCharsets.UTF_8); + int failureStart = source.indexOf("if (!isUsableGeneratedStart(generated))"); + int failureEnd = source.indexOf("continue;", failureStart); + String failureBranch = source.substring(failureStart, failureEnd); + + assertTrue(failureBranch.contains("if (replacement)")); + assertTrue(failureBranch.indexOf("NativeStructureOwnershipStore.discard(") + > failureBranch.lastIndexOf("}") ); + assertTrue(failureStart < source.indexOf("NativeStructureOwnershipFingerprint.capture(")); + assertTrue(failureStart < source.indexOf("NativeStructureOwnershipStore.record(")); + } + + @Test + public void failedManualStartPublicationClearsRecordedOwnershipAndPreservesTheFailure() throws Exception { + String source = Files.readString( + Path.of(System.getProperty("iris.nativeStructureStartInjectorSource")), + StandardCharsets.UTF_8); + int ownershipRecord = source.indexOf("NativeStructureOwnershipStore.record("); + int publication = source.indexOf("context.structureManager().setStartForStructure(", ownershipRecord); + int cleanup = source.indexOf("NativeStructureOwnershipStore.discard(", publication); + int suppressed = source.indexOf("publicationError.addSuppressed(cleanupError)", cleanup); + int rethrow = source.indexOf("throw publicationError;", suppressed); + + assertTrue(ownershipRecord >= 0); + assertTrue(publication > ownershipRecord); + assertTrue(cleanup > publication); + assertTrue(suppressed > cleanup); + assertTrue(rethrow > suppressed); + } + private static final class TestChunkGenerator extends ChunkGenerator { private TestChunkGenerator() { super(new FixedBiomeSource(Holder.direct((Biome) null))); @@ -171,4 +418,91 @@ public class NativeStructureFactoryTest { BlockPos feetPos) { } } + + private static StructurePoolElement inlineElement(int sizeX, int sizeY, int sizeZ) { + StructureTemplate template = new StructureTemplate(); + CompoundTag tag = new CompoundTag(); + ListTag size = new ListTag(); + size.add(IntTag.valueOf(sizeX)); + size.add(IntTag.valueOf(sizeY)); + size.add(IntTag.valueOf(sizeZ)); + tag.put("size", size); + template.load(BuiltInRegistries.BLOCK, tag); + return new InlinePoolElement(template); + } + + private static final class InlinePoolElement extends SinglePoolElement { + private final StructureTemplate inlineTemplate; + + private InlinePoolElement(StructureTemplate template) { + super(Either.right(template), Holder.direct(new StructureProcessorList(List.of())), + StructureTemplatePool.Projection.RIGID, Optional.empty()); + inlineTemplate = template; + } + + @Override + public BoundingBox getBoundingBox(StructureTemplateManager templateManager, + BlockPos position, Rotation rotation) { + return inlineTemplate.getBoundingBox( + new StructurePlaceSettings().setRotation(rotation), position); + } + } + + private static final class BoundingPoolElement extends StructurePoolElement { + private final int width; + private final int depth; + + private BoundingPoolElement(int width, int depth) { + super(StructureTemplatePool.Projection.RIGID); + this.width = width; + this.depth = depth; + } + + @Override + public Vec3i getSize(StructureTemplateManager templateManager, Rotation rotation) { + return new Vec3i(width, 1, depth); + } + + @Override + public List getShuffledJigsawBlocks( + StructureTemplateManager templateManager, BlockPos position, + Rotation rotation, RandomSource random) { + return List.of(); + } + + @Override + public BoundingBox getBoundingBox(StructureTemplateManager templateManager, + BlockPos position, Rotation rotation) { + return new BoundingBox( + position.getX(), position.getY(), position.getZ(), + position.getX() + width - 1, position.getY(), + position.getZ() + depth - 1); + } + + @Override + public boolean place(StructureTemplateManager templateManager, + WorldGenLevel world, + StructureManager structureManager, ChunkGenerator chunkGenerator, + BlockPos position, BlockPos pivot, Rotation rotation, + BoundingBox area, RandomSource random, + LiquidSettings liquidSettings, boolean keepJigsaws) { + return true; + } + + @Override + public StructurePoolElementType getType() { + return StructurePoolElementType.EMPTY; + } + } + + private static final class BoundPoolHolder extends Holder.Reference { + private BoundPoolHolder(String key) { + super(Type.STAND_ALONE, new HolderOwner<>() { + }, ResourceKey.create(Registries.TEMPLATE_POOL, Identifier.parse(key)), null); + } + + private void bind(StructureTemplatePool pool) { + bindValue(pool); + } + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureLocatePersistenceTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureLocatePersistenceTest.java new file mode 100644 index 000000000..c1f0885ff --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureLocatePersistenceTest.java @@ -0,0 +1,197 @@ +package art.arcane.iris.nativegen; + +import net.minecraft.SharedConstants; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.ChunkPos; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeStructureLocatePersistenceTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void storageProbeScansEachCandidateOnlyOnce() { + AtomicInteger reads = new AtomicInteger(); + CompoundTag chunk = new CompoundTag(); + chunk.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); + chunk.putString("Status", "full"); + CompoundTag start = new CompoundTag(); + start.putString("id", "minecraft:village_plains"); + start.putInt("references", 0); + CompoundTag starts = new CompoundTag(); + starts.put("minecraft:village_plains", start); + CompoundTag structures = new CompoundTag(); + structures.put("starts", starts); + chunk.put("structures", structures); + NativeStructureLocatePersistence.ProbeBudget budget = + new NativeStructureLocatePersistence.ProbeBudget( + 512, + (chunkPos, visitor) -> { + reads.incrementAndGet(); + chunk.acceptAsRoot(visitor); + return CompletableFuture.completedFuture(null); + }); + ChunkPos chunkPos = new ChunkPos(7, -11); + + assertTrue(budget.acceptsStored( + null, chunkPos, "minecraft:village_plains", true)); + assertTrue(budget.acceptsStored( + null, chunkPos, "minecraft:village_plains", true)); + assertEquals(1, reads.get()); + assertEquals(1, budget.used()); + } + + @Test + public void legacyStoredChunkIsDatafixedBeforeItsStartIsClassified() { + AtomicBoolean datafixed = new AtomicBoolean(); + CompoundTag legacy = new CompoundTag(); + legacy.putInt("DataVersion", 100); + legacy.putString("Status", "full"); + NativeStructureLocatePersistence.ProbeBudget budget = + new NativeStructureLocatePersistence.ProbeBudget( + 512, + (chunkPos, visitor) -> { + legacy.acceptAsRoot(visitor); + return CompletableFuture.completedFuture(null); + }, + (level, storedChunk) -> { + datafixed.set(true); + CompoundTag fixed = storedChunk.copy(); + fixed.putInt("DataVersion", + SharedConstants.getCurrentVersion().dataVersion().version()); + CompoundTag start = new CompoundTag(); + start.putString("id", "minecraft:village_plains"); + start.putInt("references", 1); + CompoundTag starts = new CompoundTag(); + starts.put("minecraft:village_plains", start); + CompoundTag structures = new CompoundTag(); + structures.put("starts", starts); + fixed.put("structures", structures); + return fixed; + }); + + assertFalse(budget.acceptsStored( + null, new ChunkPos(3, 4), "minecraft:village_plains", true)); + assertTrue(datafixed.get()); + } + + @Test + public void partialDatafixFailureFallsBackToSelectedChunkVerification() { + CompoundTag legacy = new CompoundTag(); + legacy.putInt("DataVersion", 100); + NativeStructureLocatePersistence.ProbeBudget budget = + new NativeStructureLocatePersistence.ProbeBudget( + 512, + (chunkPos, visitor) -> { + legacy.acceptAsRoot(visitor); + return CompletableFuture.completedFuture(null); + }, + (level, storedChunk) -> { + throw new IllegalStateException("broken partial datafix"); + }); + + assertTrue(budget.acceptsStored( + null, new ChunkPos(9, -2), "minecraft:village_plains", true)); + } + + @Test + public void missingStartsContainerFallsBackToSelectedChunkVerification() { + CompoundTag chunk = currentChunk(); + NativeStructureLocatePersistence.ProbeBudget budget = budget(chunk); + + assertTrue(budget.acceptsStored( + null, new ChunkPos(5, 8), "minecraft:village_plains", false)); + assertTrue(budget.acceptsStored( + null, new ChunkPos(5, 8), "minecraft:village_plains", true)); + } + + @Test + public void presentEmptyStartsContainerRejectsTheCandidateWithoutLoading() { + CompoundTag chunk = currentChunk(); + CompoundTag structures = new CompoundTag(); + structures.put("starts", new CompoundTag()); + chunk.put("structures", structures); + NativeStructureLocatePersistence.ProbeBudget budget = budget(chunk); + + assertFalse(budget.acceptsStored( + null, new ChunkPos(-3, 2), "minecraft:village_plains", false)); + } + + @Test + public void collisionTombstoneIsRejectedForNormalAndUnexploredLocate() { + CompoundTag chunk = currentChunk(); + CompoundTag tombstone = new CompoundTag(); + tombstone.putString("id", "INVALID"); + CompoundTag starts = new CompoundTag(); + starts.put("minecraft:village_plains", tombstone); + CompoundTag structures = new CompoundTag(); + structures.put("starts", starts); + chunk.put("structures", structures); + ChunkPos chunkPos = new ChunkPos(12, -9); + + assertFalse(budget(chunk).acceptsStored( + null, chunkPos, "minecraft:village_plains", false)); + assertFalse(budget(chunk).acceptsStored( + null, chunkPos, "minecraft:village_plains", true)); + } + + @Test + public void malformedNonCompoundStartIsRejectedWithoutLoading() { + CompoundTag chunk = currentChunk(); + CompoundTag starts = new CompoundTag(); + starts.putInt("minecraft:village_plains", 1); + CompoundTag structures = new CompoundTag(); + structures.put("starts", starts); + chunk.put("structures", structures); + + assertFalse(budget(chunk).acceptsStored( + null, new ChunkPos(1, 1), "minecraft:village_plains", false)); + } + + @Test + public void referencedStoredStartIsOnlyRejectedForUnexploredLocate() { + CompoundTag chunk = currentChunk(); + CompoundTag start = new CompoundTag(); + start.putString("id", "minecraft:village_plains"); + start.putInt("references", 1); + CompoundTag starts = new CompoundTag(); + starts.put("minecraft:village_plains", start); + CompoundTag structures = new CompoundTag(); + structures.put("starts", starts); + chunk.put("structures", structures); + ChunkPos chunkPos = new ChunkPos(-4, -7); + + assertTrue(budget(chunk).acceptsStored( + null, chunkPos, "minecraft:village_plains", false)); + assertFalse(budget(chunk).acceptsStored( + null, chunkPos, "minecraft:village_plains", true)); + } + + private static CompoundTag currentChunk() { + CompoundTag chunk = new CompoundTag(); + chunk.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version()); + return chunk; + } + + private static NativeStructureLocatePersistence.ProbeBudget budget(CompoundTag chunk) { + return new NativeStructureLocatePersistence.ProbeBudget( + 512, + (chunkPos, visitor) -> { + chunk.acceptAsRoot(visitor); + return CompletableFuture.completedFuture(null); + }); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipFingerprintTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipFingerprintTest.java new file mode 100644 index 000000000..542e869ef --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipFingerprintTest.java @@ -0,0 +1,346 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructureStartPlan; +import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; +import art.arcane.volmlib.util.collection.KList; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Vec3i; +import net.minecraft.nbt.ListTag; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.LegacyRandomSource; +import net.minecraft.world.level.levelgen.RandomSupport; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElementType; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class NativeStructureOwnershipFingerprintTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void monumentFingerprintSurvivesSourceYRegenerationWithoutSyntheticPieces() { + long seed = 8372619L; + ChunkPos origin = new ChunkPos(9, -4); + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + StructureStart generated = monumentStart(structure, origin, seed); + NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel( + generated, 0, 80, -64, 320); + int alignedLocatorY = NativeStructureOwnershipFingerprint.locatorY(generated); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(24); + StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( + generated, + structure, + 0, + terrain); + String beforeReload = NativeStructureOwnershipFingerprint.fingerprint( + "minecraft:monument", wrapped); + + ListTag serializedPieces = (ListTag) new PiecesContainer(wrapped.getPieces()).save(null); + PiecesContainer loadedPieces = PiecesContainer.load(serializedPieces, null); + assertEquals(1, loadedPieces.pieces().size()); + assertTrue(loadedPieces.pieces().getFirst() + instanceof OceanMonumentPieces.MonumentBuilding); + PiecesContainer regeneratedPieces = OceanMonumentStructure.regeneratePiecesAfterLoad( + origin, seed, loadedPieces); + StructureStart reloaded = new StructureStart(structure, origin, 0, regeneratedPieces); + String afterReload = NativeStructureOwnershipFingerprint.fingerprint( + "minecraft:monument", reloaded); + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture( + "minecraft:monument", + wrapped, + plan(origin, NativeStructureReferenceEnvelope.contentBounds(wrapped).minY()), + NativeStructureReferenceEnvelope.referenceBounds(wrapped, structure, terrain)); + + assertEquals(beforeReload, afterReload); + assertTrue(NativeStructureOwnershipFingerprint.matches(ownership, reloaded)); + assertNotEquals(alignedLocatorY, NativeStructureOwnershipFingerprint.locatorY(reloaded)); + assertEquals(alignedLocatorY, ownership.locatorY()); + assertEquals(1, reloaded.getPieces().size()); + assertEquals(1, wrapped.getPieces().size()); + } + + @Test + public void sameKeyAndOriginStillRequiresMatchingContentIdentity() { + long seed = 18273L; + ChunkPos origin = new ChunkPos(2, 3); + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + StructureStart expected = monumentStart(structure, origin, seed); + StructureStart moved = monumentStart(structure, origin, seed); + for (StructurePiece piece : moved.getPieces()) { + piece.move(1, 0, 0); + } + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture( + "minecraft:monument", + expected, + plan(origin, NativeStructureReferenceEnvelope.contentBounds(expected).minY()), + expected.getBoundingBox()); + + assertFalse(NativeStructureOwnershipFingerprint.matches(ownership, moved)); + } + + @Test + public void persistedEnvelopeRepairsReloadedMonumentReferenceOutsideLiveBounds() { + long seed = 772931L; + ChunkPos origin = new ChunkPos(4, -7); + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + StructureStart generated = monumentStart(structure, origin, seed); + NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel( + generated, 0, 80, -64, 320); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(24); + StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( + generated, + structure, + 0, + terrain); + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture( + "minecraft:monument", + wrapped, + plan(origin, NativeStructureReferenceEnvelope.contentBounds(wrapped).minY()), + NativeStructureReferenceEnvelope.referenceBounds(wrapped, structure, terrain)); + PiecesContainer regeneratedPieces = OceanMonumentStructure.regeneratePiecesAfterLoad( + origin, seed, new PiecesContainer(wrapped.getPieces())); + StructureStart reloaded = new StructureStart(structure, origin, 0, regeneratedPieces); + ChunkPos target = outsideLiveBounds(ownership, reloaded); + + assertNotNull(target); + assertTrue(NativeStructureReferenceRepair.requiresReference( + target, "minecraft:monument", reloaded, ownership)); + assertFalse(NativeStructureReferenceRepair.requiresReference( + target, "minecraft:monument", reloaded, null)); + } + + @Test + public void overlappingManualStartsSixteenChunksApartRemainIndependentlyReferenced() { + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + ChunkPos westOrigin = new ChunkPos(0, 0); + ChunkPos eastOrigin = new ChunkPos(16, 0); + StructureStart west = monumentStart(structure, westOrigin, 317L); + StructureStart east = monumentStart(structure, eastOrigin, 941L); + west.getPieces().getFirst().move(112, 0, 0); + east.getPieces().getFirst().move(-96, 0, 0); + NativeStructureOwnershipRecord westOwnership = ownership(westOrigin, west, structure); + NativeStructureOwnershipRecord eastOwnership = ownership(eastOrigin, east, structure); + ChunkPos shared = new ChunkPos(8, 0); + + assertTrue(NativeStructureReferenceEnvelope.contentBounds(west).intersects( + NativeStructureReferenceEnvelope.contentBounds(east))); + assertTrue(NativeStructureReferenceRepair.requiresReference( + shared, "minecraft:monument", west, westOwnership)); + assertTrue(NativeStructureReferenceRepair.requiresReference( + shared, "minecraft:monument", east, eastOwnership)); + } + + @Test + public void denseAdjacentManualStartsDoNotSuppressEachOther() { + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + ChunkPos firstOrigin = new ChunkPos(0, 0); + ChunkPos secondOrigin = new ChunkPos(1, 0); + StructureStart first = monumentStart(structure, firstOrigin, 17L); + StructureStart second = monumentStart(structure, secondOrigin, 23L); + NativeStructureOwnershipRecord firstOwnership = ownership(firstOrigin, first, structure); + NativeStructureOwnershipRecord secondOwnership = ownership(secondOrigin, second, structure); + ChunkPos shared = new ChunkPos(0, 0); + + assertTrue(NativeStructureReferenceEnvelope.contentBounds(first).intersects( + NativeStructureReferenceEnvelope.contentBounds(second))); + assertTrue(NativeStructureReferenceRepair.requiresReference( + shared, "minecraft:monument", first, firstOwnership)); + assertTrue(NativeStructureReferenceRepair.requiresReference( + shared, "minecraft:monument", second, secondOwnership)); + } + + @Test + public void customPoolElementIdentityDoesNotDependOnProcessLocalToString() { + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + ChunkPos origin = new ChunkPos(3, -2); + StructureStart first = poolStart(structure, origin, new IdentityStringPoolElement()); + StructureStart reloaded = poolStart(structure, origin, new IdentityStringPoolElement()); + + assertEquals( + NativeStructureOwnershipFingerprint.fingerprint("test:custom_pool", first), + NativeStructureOwnershipFingerprint.fingerprint("test:custom_pool", reloaded)); + } + + @Test + public void namedPoolTemplateRemainsPartOfTheStableFingerprint() { + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + ChunkPos origin = new ChunkPos(3, -2); + StructurePoolElement firstElement = StructurePoolElement.single("test:first") + .apply(StructureTemplatePool.Projection.RIGID); + StructurePoolElement secondElement = StructurePoolElement.single("test:second") + .apply(StructureTemplatePool.Projection.RIGID); + + assertNotEquals( + NativeStructureOwnershipFingerprint.fingerprint( + "test:named_pool", poolStart(structure, origin, firstElement)), + NativeStructureOwnershipFingerprint.fingerprint( + "test:named_pool", poolStart(structure, origin, secondElement))); + } + + private static StructureStart monumentStart(OceanMonumentStructure structure, + ChunkPos origin, long seed) { + WorldgenRandom random = new WorldgenRandom( + new LegacyRandomSource(RandomSupport.generateUniqueSeed())); + random.setLargeFeatureSeed(seed, origin.x(), origin.z()); + Direction orientation = Direction.Plane.HORIZONTAL.getRandomDirection(random); + OceanMonumentPieces.MonumentBuilding building = new OceanMonumentPieces.MonumentBuilding( + random, + origin.getMinBlockX() - 29, + origin.getMinBlockZ() - 29, + orientation + ); + return new StructureStart( + structure, + origin, + 0, + new PiecesContainer(List.of(building)) + ); + } + + private static NativeStructureStartPlan plan(ChunkPos origin, int baseY) { + IrisNativeStructure source = new IrisNativeStructure() + .setStructure("minecraft:monument") + .setWeight(1); + IrisStructurePlacement placement = new IrisStructurePlacement() + .setPlacementId("monument-test") + .setNativeStructures(new KList().qadd(source)); + return new NativeStructureStartPlan( + placement, + source, + origin.x(), + origin.z(), + baseY + ); + } + + private static NativeStructureOwnershipRecord ownership( + ChunkPos origin, StructureStart start, OceanMonumentStructure structure) { + return NativeStructureOwnershipFingerprint.capture( + "minecraft:monument", + start, + plan(origin, NativeStructureReferenceEnvelope.contentBounds(start).minY()), + NativeStructureReferenceEnvelope.referenceBounds( + start, structure, new IrisStructureTerrain()) + ); + } + + private static StructureStart poolStart( + OceanMonumentStructure structure, ChunkPos origin, StructurePoolElement element) { + BlockPos position = new BlockPos( + origin.getMiddleBlockX(), 64, origin.getMiddleBlockZ()); + PoolElementStructurePiece piece = new PoolElementStructurePiece( + null, element, position, 0, Rotation.NONE, + new BoundingBox(position), LiquidSettings.APPLY_WATERLOGGING); + return new StructureStart( + structure, origin, 0, new PiecesContainer(List.of(piece))); + } + + private static final class IdentityStringPoolElement extends StructurePoolElement { + private IdentityStringPoolElement() { + super(StructureTemplatePool.Projection.RIGID); + } + + @Override + public Vec3i getSize(StructureTemplateManager templateManager, Rotation rotation) { + return new Vec3i(1, 1, 1); + } + + @Override + public List getShuffledJigsawBlocks( + StructureTemplateManager templateManager, BlockPos position, + Rotation rotation, RandomSource random) { + return List.of(); + } + + @Override + public BoundingBox getBoundingBox(StructureTemplateManager templateManager, + BlockPos position, Rotation rotation) { + return new BoundingBox(position); + } + + @Override + public boolean place(StructureTemplateManager templateManager, + WorldGenLevel world, StructureManager structureManager, + ChunkGenerator chunkGenerator, BlockPos position, BlockPos pivot, + Rotation rotation, BoundingBox area, RandomSource random, + LiquidSettings liquidSettings, boolean keepJigsaws) { + return true; + } + + @Override + public StructurePoolElementType getType() { + return StructurePoolElementType.EMPTY; + } + + @Override + public String toString() { + return "IdentityStringPoolElement@" + + Integer.toHexString(System.identityHashCode(this)); + } + } + + private static ChunkPos outsideLiveBounds(NativeStructureOwnershipRecord ownership, + StructureStart start) { + for (int chunkX = ownership.referenceMinChunkX(); + chunkX <= ownership.referenceMaxChunkX(); chunkX++) { + for (int chunkZ = ownership.referenceMinChunkZ(); + chunkZ <= ownership.referenceMaxChunkZ(); chunkZ++) { + ChunkPos candidate = new ChunkPos(chunkX, chunkZ); + if (!start.getBoundingBox().intersects( + candidate.getMinBlockX(), candidate.getMinBlockZ(), + candidate.getMaxBlockX(), candidate.getMaxBlockZ())) { + return candidate; + } + } + } + return null; + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecoveryTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecoveryTest.java new file mode 100644 index 000000000..51775f427 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecoveryTest.java @@ -0,0 +1,185 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructureStartPlan; +import art.arcane.iris.engine.framework.StructurePlacementGrid; +import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; +import art.arcane.volmlib.util.collection.KList; +import net.minecraft.SharedConstants; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderSet; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.LegacyRandomSource; +import net.minecraft.world.level.levelgen.RandomSupport; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class NativeStructureOwnershipRecoveryTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void missingSidecarRecoversExactPolicyEnvelopeAndLocatorAfterReload() { + long seed = 5519231L; + ChunkPos origin = new ChunkPos(7, -5); + OceanMonumentStructure structure = structure(); + StructureStart expected = monumentStart(structure, origin, seed); + NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel( + expected, 0, 80, -64, 320); + int expectedLocatorY = NativeStructureOwnershipFingerprint.locatorY(expected); + NativeStructureStartPlan plan = plan(origin, "recovery-exact", 24); + StructureStart reloaded = new StructureStart( + structure, + origin, + expected.getReferences(), + OceanMonumentStructure.regeneratePiecesAfterLoad( + origin, seed, new PiecesContainer(expected.getPieces())) + ); + + NativeStructureOwnershipRecord recovered = + NativeStructureOwnershipRecovery.proveCandidate( + "minecraft:monument", structure, reloaded, + plan, expected, false); + + assertNotNull(recovered); + assertEquals(StructurePlacementGrid.placementIdentity(plan.placement()), + recovered.placementIdentity()); + assertEquals(expectedLocatorY, recovered.locatorY()); + assertNotEquals(NativeStructureOwnershipFingerprint.locatorY(reloaded), + recovered.locatorY()); + assertEquals(IrisStructureTerrainMode.FORCE_CARVE, + recovered.restoredDecision().terrain().resolvedMode()); + assertEquals(24, recovered.restoredDecision().terrain().getHorizontalPadding()); + assertTrue(recovered.referenceMinChunkX() + < NativeStructureReferenceEnvelope.contentBounds(reloaded).minX() >> 4); + } + + @Test + public void naturalSameKeyStartRemainsUnownedWhenOriginIsAmbiguous() { + long seed = 22817L; + ChunkPos origin = new ChunkPos(-3, 6); + OceanMonumentStructure structure = structure(); + StructureStart natural = monumentStart(structure, origin, seed); + NativeStructureStartPlan plan = plan(origin, "natural-ambiguous", 0); + + NativeStructureOwnershipRecord recovered = + NativeStructureOwnershipRecovery.proveCandidate( + "minecraft:monument", structure, natural, + plan, natural, true); + + assertNull(recovered); + } + + @Test + public void changedPlacementIdentityOrGeometryCannotRecoverOwnership() { + long seed = 991723L; + ChunkPos origin = new ChunkPos(2, 4); + OceanMonumentStructure structure = structure(); + StructureStart expected = monumentStart(structure, origin, seed); + StructureStart changedGeometry = monumentStart(structure, origin, seed); + for (StructurePiece piece : changedGeometry.getPieces()) { + piece.move(1, 0, 0); + } + NativeStructureStartPlan originalIdentity = plan( + origin, "original-identity", 0); + NativeStructureStartPlan changedIdentity = plan( + new ChunkPos(origin.x() + 1, origin.z()), "changed-identity", 0); + + assertNotEquals( + StructurePlacementGrid.placementIdentity(originalIdentity.placement()), + StructurePlacementGrid.placementIdentity(changedIdentity.placement())); + assertNull(NativeStructureOwnershipRecovery.proveCandidate( + "minecraft:monument", structure, expected, + changedIdentity, expected, false)); + assertNull(NativeStructureOwnershipRecovery.proveCandidate( + "minecraft:monument", structure, changedGeometry, + plan(origin, "geometry-check", 0), expected, false)); + } + + @Test + public void recoveredEnvelopeStillRequiresExactFingerprint() { + long seed = 81337L; + ChunkPos origin = new ChunkPos(-8, -9); + OceanMonumentStructure structure = structure(); + StructureStart expected = monumentStart(structure, origin, seed); + NativeStructureOwnershipRecord recovered = + NativeStructureOwnershipRecovery.proveCandidate( + "minecraft:monument", structure, expected, + plan(origin, "fingerprint-check", 16), expected, false); + assertNotNull(recovered); + + StructureStart moved = monumentStart(structure, origin, seed); + for (StructurePiece piece : moved.getPieces()) { + piece.move(0, 0, 1); + } + + assertFalse(NativeStructureOwnershipFingerprint.matches(recovered, moved)); + } + + private static OceanMonumentStructure structure() { + return new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + } + + private static StructureStart monumentStart(OceanMonumentStructure structure, + ChunkPos origin, long seed) { + WorldgenRandom random = new WorldgenRandom( + new LegacyRandomSource(RandomSupport.generateUniqueSeed())); + random.setLargeFeatureSeed(seed, origin.x(), origin.z()); + Direction orientation = Direction.Plane.HORIZONTAL.getRandomDirection(random); + OceanMonumentPieces.MonumentBuilding building = + new OceanMonumentPieces.MonumentBuilding( + random, + origin.getMinBlockX() - 29, + origin.getMinBlockZ() - 29, + orientation + ); + return new StructureStart( + structure, origin, 0, new PiecesContainer(List.of(building))); + } + + private static NativeStructureStartPlan plan(ChunkPos origin, + String placementId, + int horizontalPadding) { + IrisNativeStructure source = new IrisNativeStructure() + .setStructure("minecraft:monument") + .setWeight(1); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(horizontalPadding); + IrisStructurePlacement placement = new IrisStructurePlacement() + .setPlacementId(placementId) + .setTerrain(terrain) + .setNativeStructures(new KList().qadd(source)); + return new NativeStructureStartPlan( + placement, + source, + origin.x(), + origin.z(), + NativeStructureReferenceEnvelope.contentBounds( + monumentStart(structure(), origin, 1L)).minY() + ); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorEncaseTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorEncaseTest.java index d16da49c7..c4416e5fc 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorEncaseTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorEncaseTest.java @@ -7,22 +7,35 @@ import art.arcane.iris.engine.object.IrisStructureYBand; import art.arcane.volmlib.util.math.RNG; import net.minecraft.SharedConstants; import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; import net.minecraft.core.HolderSet; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; import net.minecraft.server.Bootstrap; import net.minecraft.util.RandomSource; import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.dimension.BuiltinDimensionTypes; import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlockIgnoreProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList; import org.junit.BeforeClass; import org.junit.Test; @@ -31,14 +44,16 @@ import java.lang.reflect.InvocationHandler; import java.lang.reflect.Proxy; import java.nio.file.Files; import java.nio.file.Path; +import java.util.BitSet; import java.util.HashMap; import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class NativeStructurePostProcessorEncaseTest { @@ -98,6 +113,96 @@ public class NativeStructurePostProcessorEncaseTest { state(blocks, bounds.minX(), 0, bounds.minZ())); } + @Test + public void defaultEncaseMaterialFollowsVanillaDimensionTerrain() { + assertEquals(Blocks.NETHERRACK.defaultBlockState(), + NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.NETHER, 64)); + assertEquals(Blocks.END_STONE.defaultBlockState(), + NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.END, 64)); + assertEquals(Blocks.STONE.defaultBlockState(), + NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.OVERWORLD, 64)); + } + + @Test + public void sourceEncaseMaterialFollowsNearbyIrisTerrain() { + Map blocks = new HashMap<>(); + BlockPos fill = new BlockPos(4, 64, 8); + put(blocks, fill.getX() + 3, fill.getY(), fill.getZ(), Blocks.TUFF.defaultBlockState()); + + assertEquals(Blocks.TUFF.defaultBlockState(), + NativeStructureTerrainIntegrator.sourceEncaseBlock(world(blocks), fill)); + } + + @Test + public void sourceMaterialSnapshotIgnoresPreviouslyDecoratedAdjacentChunks() { + Map blocks = new HashMap<>(); + BlockPos fill = new BlockPos(0, 64, 8); + put(blocks, -1, 64, 8, Blocks.OAK_PLANKS.defaultBlockState()); + put(blocks, 3, 64, 8, Blocks.TUFF.defaultBlockState()); + WorldGenLevel world = world(blocks); + NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain = + sourceSnapshot(world, new BoundingBox(0, 64, 0, 15, 64, 15)); + + assertEquals(Blocks.TUFF.defaultBlockState(), + NativeStructureTerrainIntegrator.sourceEncaseBlock( + world, fill, sourceTerrain)); + } + + @Test + public void sourceMaterialSnapshotIsStableAcrossEarlierFillsInTheSameChunk() { + Map blocks = new HashMap<>(); + BlockPos fill = new BlockPos(0, 64, 8); + put(blocks, 3, 64, 8, Blocks.TUFF.defaultBlockState()); + WorldGenLevel world = world(blocks); + NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain = + sourceSnapshot(world, new BoundingBox(0, 64, 0, 15, 64, 15)); + put(blocks, 1, 64, 8, Blocks.OAK_PLANKS.defaultBlockState()); + + assertEquals(Blocks.TUFF.defaultBlockState(), + NativeStructureTerrainIntegrator.sourceEncaseBlock( + world, fill, sourceTerrain)); + } + + @Test + public void sourceMaterialRejectsStructureBlocksAndOresBeforeDimensionFallback() { + Map blocks = new HashMap<>(); + BlockPos fill = new BlockPos(4, -20, 8); + put(blocks, 3, -20, 8, Blocks.OAK_PLANKS.defaultBlockState()); + put(blocks, 5, -20, 8, Blocks.STONE_BRICKS.defaultBlockState()); + put(blocks, 4, -20, 7, Blocks.IRON_ORE.defaultBlockState()); + + assertEquals(Blocks.DEEPSLATE.defaultBlockState(), + NativeStructureTerrainIntegrator.sourceEncaseBlock(world(blocks), fill)); + } + + @Test + public void sourceMaterialSnapshotOnlyAllocatesActualFillLayers() { + StructureStart start = start(TerrainAdjustment.BURY, 64); + BoundingBox area = new BoundingBox(0, -2032, 0, 15, 2030, 15); + NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain = + NativeStructureTerrainIntegrator.captureSourceTerrain( + world(new HashMap<>()), area, + List.of(new NativeStructureTerrainIntegrator.TerrainTarget( + "test:bounded-snapshot", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)))); + + assertNotNull(sourceTerrain); + assertEquals(23 * 16 * 16, sourceTerrain.sampledCells()); + } + + @Test + public void customDimensionKeyUsesRegisteredVanillaDimensionType() { + ResourceKey customDimension = ResourceKey.create( + Registries.DIMENSION, Identifier.parse("iris:custom_nether")); + + assertEquals(Blocks.NETHERRACK.defaultBlockState(), + NativeStructureTerrainIntegrator.defaultEncaseBlock( + customDimension, BuiltinDimensionTypes.NETHER, null, 64)); + assertEquals(Blocks.END_STONE.defaultBlockState(), + NativeStructureTerrainIntegrator.defaultEncaseBlock( + customDimension, BuiltinDimensionTypes.END, null, 64)); + } + @Test public void configuredEncasePaletteReplacesTheDefaultMaterial() { StructureStart start = start(TerrainAdjustment.BURY, 64); @@ -129,58 +234,170 @@ public class NativeStructurePostProcessorEncaseTest { } @Test - public void buryAndEncapsulateAdaptationsAutoDefaultToEncase() { - for (TerrainAdjustment adjustment : List.of(TerrainAdjustment.BURY, TerrainAdjustment.ENCAPSULATE)) { + public void omittedTerrainConfigurationRetainsSourceSemantics() { + for (TerrainAdjustment adjustment : TerrainAdjustment.values()) { IrisStructureTerrain resolved = NativeStructureTerrainIntegrator.resolveNativeTerrain( start(adjustment, 64), null); - assertEquals(IrisStructureTerrainMode.ENCASE, resolved.resolvedMode()); - assertEquals(3, resolved.getHorizontalPadding()); - assertEquals(3, resolved.getCeilingPadding()); - assertEquals(3, resolved.getFloorPadding()); - assertNull(resolved.getEncasePalette()); + assertEquals(IrisStructureTerrainMode.SOURCE, resolved.resolvedMode()); } } @Test - public void otherAdaptationsNeverAutoDefaultToEncase() { - for (TerrainAdjustment adjustment : List.of( - TerrainAdjustment.NONE, TerrainAdjustment.BEARD_THIN, TerrainAdjustment.BEARD_BOX)) { - assertNull(NativeStructureTerrainIntegrator.resolveNativeTerrain( - start(adjustment, 64), null)); - } + public void sourceBuryUsesTheVanillaSixByTwelveInfluenceEnvelope() { + StructureStart start = start(TerrainAdjustment.BURY, 64); + BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); + int groundY = bounds.minY(); + BoundingBox area = new BoundingBox( + bounds.minX() - 7, groundY - 13, bounds.minZ(), + bounds.maxX(), groundY + 13, bounds.maxZ()); + Map blocks = new HashMap<>(); + + NativeStructureTerrainIntegrator.integrateTerrain( + world(blocks), area, "test:bury", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE), null); + + assertEquals(Blocks.STONE.defaultBlockState(), + state(blocks, bounds.minX() - 5, groundY, bounds.minZ())); + assertEquals(Blocks.STONE.defaultBlockState(), + state(blocks, bounds.minX(), groundY + 11, bounds.minZ())); + assertEquals(Blocks.AIR.defaultBlockState(), + state(blocks, bounds.minX() - 6, groundY, bounds.minZ())); + assertEquals(Blocks.AIR.defaultBlockState(), + state(blocks, bounds.minX(), groundY + 12, bounds.minZ())); + assertEquals(Blocks.AIR.defaultBlockState(), + state(blocks, bounds.minX() - 7, groundY, bounds.minZ())); + assertEquals(Blocks.AIR.defaultBlockState(), + state(blocks, bounds.minX(), groundY + 13, bounds.minZ())); + } + + @Test + public void sourceEncapsulateWrapsTheFullPieceBounds() { + StructureStart start = start(TerrainAdjustment.ENCAPSULATE, 64); + BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); + BoundingBox area = new BoundingBox( + bounds.minX() - 13, bounds.minY(), bounds.minZ(), + bounds.maxX(), bounds.maxY() + 13, bounds.maxZ()); + Map blocks = new HashMap<>(); + + NativeStructureTerrainIntegrator.integrateTerrain( + world(blocks), area, "test:encapsulate", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE), null); + + assertEquals(Blocks.STONE.defaultBlockState(), + state(blocks, bounds.minX() - 11, bounds.minY(), bounds.minZ())); + assertEquals(Blocks.STONE.defaultBlockState(), + state(blocks, bounds.minX(), bounds.maxY() + 11, bounds.minZ())); + assertEquals(Blocks.AIR.defaultBlockState(), + state(blocks, bounds.minX() - 12, bounds.minY(), bounds.minZ())); + assertEquals(Blocks.AIR.defaultBlockState(), + state(blocks, bounds.minX(), bounds.maxY() + 12, bounds.minZ())); + assertEquals(Blocks.AIR.defaultBlockState(), + state(blocks, bounds.minX() - 13, bounds.minY(), bounds.minZ())); + assertEquals(Blocks.AIR.defaultBlockState(), + state(blocks, bounds.minX(), bounds.maxY() + 13, bounds.minZ())); + } + + @Test + public void sourceTerrainDensityOnlyUsesRigidPoolPieces() { + PoolElementStructurePiece terrainMatching = poolPiece(StructureTemplatePool.Projection.TERRAIN_MATCHING); + PoolElementStructurePiece rigid = poolPiece(StructureTemplatePool.Projection.RIGID); + + assertFalse(NativeStructureTerrainIntegrator.isSourceRigidPiece(terrainMatching)); + assertTrue(NativeStructureTerrainIntegrator.isSourceRigidPiece(rigid)); + } + + @Test + public void sourceJunctionTerrainUsesTheStrictLowerTwelveBlockEnvelope() { + assertTrue(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -1, 0)); + assertTrue(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -11, 0)); + assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, 0, 0)); + assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -12, 0)); + assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(12, -1, 0)); } @Test public void explicitTerrainConfigurationWinsOverAutoEncase() { IrisStructureTerrain configured = new IrisStructureTerrain() - .setMode(IrisStructureTerrainMode.SOURCE); + .setMode(IrisStructureTerrainMode.PRESERVE); assertSame(configured, NativeStructureTerrainIntegrator.resolveNativeTerrain( start(TerrainAdjustment.BURY, 64), configured)); } + @Test + public void legacyTemplateAirIsClearedAfterEveryTerrainFillPath() { + IrisStructureTerrain source = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.SOURCE); + IrisStructureTerrain preserve = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.PRESERVE); + IrisStructureTerrain encase = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.ENCASE); + + assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( + start(TerrainAdjustment.BURY, 64), source)); + assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( + start(TerrainAdjustment.BEARD_BOX, 64), source)); + assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( + start(TerrainAdjustment.NONE, 64), encase)); + assertFalse(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( + start(TerrainAdjustment.NONE, 64), source)); + assertFalse(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( + start(TerrainAdjustment.BURY, 64), preserve)); + } + @Test public void encaseReservesTheNeighborChunkEnvelope() { StructureStart generated = start(TerrainAdjustment.BURY, 64); - BoundingBox content = generated.getBoundingBox(); + BoundingBox sourceBounds = generated.getBoundingBox(); + BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.ENCASE) + .setHorizontalPadding(4); StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( generated, generated.getStructure(), 0, - null, - new IrisStructureTerrain() - .setMode(IrisStructureTerrainMode.ENCASE) - .setHorizontalPadding(4)); + terrain); + BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds( + wrapped, wrapped.getStructure(), terrain); - assertEquals(content.minX() - 4, wrapped.getBoundingBox().minX()); - assertEquals(content.maxX() + 4, wrapped.getBoundingBox().maxX()); - assertEquals(content.minZ() - 4, wrapped.getBoundingBox().minZ()); - assertEquals(content.maxZ() + 4, wrapped.getBoundingBox().maxZ()); - assertEquals(2, wrapped.getPieces().stream() - .filter(NativeStructureReferenceEnvelope::isMarker) - .count()); + assertEquals(sourceBounds, wrapped.getBoundingBox()); + assertEquals(content.minX() - 4, referenceBounds.minX()); + assertEquals(content.maxX() + 4, referenceBounds.maxX()); + assertEquals(content.minZ() - 4, referenceBounds.minZ()); + assertEquals(content.maxZ() + 4, referenceBounds.maxZ()); + assertEquals(generated.getPieces().size(), wrapped.getPieces().size()); + } + + @Test + public void referenceEnvelopeRejectsChunkCoordinateOverflow() { + StructureStart generated = start(TerrainAdjustment.NONE, 64); + StructureStart extreme = new StructureStart( + generated.getStructure(), new ChunkPos(Integer.MAX_VALUE, 0), 0, + new PiecesContainer(generated.getPieces())); + + assertThrows(IllegalStateException.class, () -> + NativeStructureReferenceEnvelope.referenceBounds( + extreme, extreme.getStructure(), new IrisStructureTerrain())); + } + + @Test + public void explicitTerrainEnvelopeDoesNotInheritSourceAdjustment() { + StructureStart generated = start(TerrainAdjustment.BURY, 64); + BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.ENCASE) + .setHorizontalPadding(112); + + StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( + generated, generated.getStructure(), 0, terrain); + BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds( + wrapped, wrapped.getStructure(), terrain); + + assertEquals(content.minX() - 112, referenceBounds.minX()); + assertEquals(content.maxX() + 112, referenceBounds.maxX()); } @Test @@ -259,6 +476,24 @@ public class NativeStructurePostProcessorEncaseTest { structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece))); } + private static PoolElementStructurePiece poolPiece(StructureTemplatePool.Projection projection) { + BlockPos position = new BlockPos(0, 64, 0); + StructurePoolElement element = StructurePoolElement.single( + "minecraft:empty", + Holder.direct(new StructureProcessorList(List.of( + BlockIgnoreProcessor.STRUCTURE_AND_AIR))), + LiquidSettings.APPLY_WATERLOGGING + ).apply(projection); + return new PoolElementStructurePiece( + null, + element, + position, + 0, + Rotation.NONE, + new BoundingBox(position), + LiquidSettings.APPLY_WATERLOGGING); + } + private static WorldGenLevel world(Map blocks) { InvocationHandler handler = (proxy, method, arguments) -> { String methodName = method.getName(); @@ -275,6 +510,9 @@ public class NativeStructurePostProcessorEncaseTest { if (methodName.equals("getSeed")) { return TEST_SEED; } + if (methodName.equals("getLevel")) { + return null; + } if (methodName.equals("hashCode")) { return System.identityHashCode(proxy); } @@ -290,6 +528,14 @@ public class NativeStructurePostProcessorEncaseTest { WorldGenLevel.class.getClassLoader(), new Class[]{WorldGenLevel.class}, handler); } + private static NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceSnapshot( + WorldGenLevel world, BoundingBox area) { + BitSet requiredLayers = new BitSet(area.getYSpan()); + requiredLayers.set(0, area.getYSpan()); + return NativeStructureTerrainIntegrator.SourceTerrainSnapshot.capture( + world, area, requiredLayers); + } + private static void put(Map blocks, int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x, y, z), state); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorMonumentTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorMonumentTest.java index 30379009e..ccb4971a8 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorMonumentTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorMonumentTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.nativegen; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; import net.minecraft.SharedConstants; import net.minecraft.core.Direction; import net.minecraft.core.HolderSet; @@ -16,7 +18,12 @@ import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStru import org.junit.BeforeClass; import org.junit.Test; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; @@ -80,6 +87,25 @@ public class NativeStructurePostProcessorMonumentTest { assertEquals(51, start.getBoundingBox().maxY()); } + @Test + public void referenceEnvelopeDoesNotAddMonumentPieces() { + StructureStart start = monumentStart(1337L); + StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( + start, + start.getStructure(), + 0, + new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(4)); + + int offset = NativeStructureVerticalPlacer.applyVerticalPlacement( + wrapped, "minecraft:monument", 0, 63, -64, 320, + false, false, null, (x, z) -> 0); + + assertEquals(0, offset); + assertEquals(1, wrapped.getPieces().size()); + } + @Test public void vanillaReloadRegenerationIsRealignedBeforePlacement() { long seed = 1337L; @@ -101,6 +127,42 @@ public class NativeStructurePostProcessorMonumentTest { assertEquals(48, reloaded.getBoundingBox().maxY()); } + @Test + public void neighboringChunkPlacementRealignsReloadedMonumentOnce() throws Exception { + long seed = 1337L; + ChunkPos chunkPos = new ChunkPos(0, 0); + StructureStart initial = monumentStart(seed); + NativeStructureVerticalPlacer.applyVerticalPlacement( + initial, "minecraft:monument", 0, 50, -256, 512, + false, false, null, (x, z) -> 0); + PiecesContainer regenerated = OceanMonumentStructure.regeneratePiecesAfterLoad( + chunkPos, seed, new PiecesContainer(initial.getPieces())); + StructureStart reloaded = new StructureStart( + monumentStructure(), chunkPos, 0, regenerated); + ExecutorService executor = Executors.newFixedThreadPool(8); + CountDownLatch release = new CountDownLatch(1); + List> futures = new ArrayList<>(); + try { + for (int task = 0; task < 16; task++) { + futures.add(executor.submit(() -> { + release.await(); + NativeStructureVerticalPlacer.ensureMonumentSeaLevelAlignment( + reloaded, "minecraft:monument", 0, 50, -256, 512); + return null; + })); + } + release.countDown(); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(26, reloaded.getBoundingBox().minY()); + assertEquals(48, reloaded.getBoundingBox().maxY()); + } + @Test public void impossibleSeaLevelAlignmentFailsInsteadOfClippingTheMonument() { StructureStart start = monumentStart(1337L); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorScatteredFeatureTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorScatteredFeatureTest.java index 775202bc3..c560dc1b4 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorScatteredFeatureTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorScatteredFeatureTest.java @@ -135,6 +135,27 @@ public class NativeStructurePostProcessorScatteredFeatureTest { assertTrue(field.trySetAccessible()); } + @Test + public void datapackOverridesOfSpecialVanillaIdsUseGenericPlacement() { + StructureStart pyramidOverride = swampStart( + new SwampHutPiece(RandomSource.create(43L), 0, 0)); + int pyramidMinY = pyramidOverride.getBoundingBox().minY(); + int pyramidOffset = NativeStructureVerticalPlacer.applyVerticalPlacement( + pyramidOverride, "minecraft:desert_pyramid", 5, + 63, -64, 320, false, false, null, (x, z) -> 200); + + assertEquals(5, pyramidOffset); + assertEquals(pyramidMinY + 5, pyramidOverride.getBoundingBox().minY()); + + StructureStart monumentOverride = swampStart( + new SwampHutPiece(RandomSource.create(47L), 0, 0)); + int monumentMinY = monumentOverride.getBoundingBox().minY(); + NativeStructureVerticalPlacer.ensureMonumentSeaLevelAlignment( + monumentOverride, "minecraft:monument", 0, 40, -64, 320); + + assertEquals(monumentMinY, monumentOverride.getBoundingBox().minY()); + } + private static StructureStart desertStart(StructurePiece piece) { Structure structure = new DesertPyramidStructure(new Structure.StructureSettings(HolderSet.empty())); return start(structure, piece); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java index 53e492b47..f937ea053 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java @@ -5,23 +5,41 @@ import art.arcane.iris.engine.object.IrisStructureCarveShape; import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrainMode; import com.mojang.datafixers.util.Either; +import com.mojang.serialization.Codec; import net.minecraft.SharedConstants; import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; import net.minecraft.core.HolderSet; +import net.minecraft.core.IdMapper; import net.minecraft.server.Bootstrap; import net.minecraft.util.RandomSource; import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.Strategy; +import net.minecraft.world.level.chunk.UpgradeData; import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure; import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; @@ -35,15 +53,22 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Modifier; import java.lang.reflect.Proxy; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class NativeStructurePostProcessorSurfaceTerrainTest { @@ -60,34 +85,148 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } @Test - public void onlySurfaceBeardThinStructuresPrepareTerrain() { + public void sourceBeardAdjustmentsPrepareTerrainAcrossDeclaredSteps() { assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.SURFACE_STRUCTURES)); - assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( - TerrainAdjustment.BURY, GenerationStep.Decoration.SURFACE_STRUCTURES)); - assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( - TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.SURFACE_STRUCTURES)); - assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( - TerrainAdjustment.ENCAPSULATE, GenerationStep.Decoration.SURFACE_STRUCTURES)); + assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.FLUID_SPRINGS)); + assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.UNDERGROUND_STRUCTURES)); } @Test - public void undergroundAdjustmentsNeverPrepareSurfaceTerrain() { - for (TerrainAdjustment adjustment : List.of( - TerrainAdjustment.BEARD_THIN, - TerrainAdjustment.BURY, - TerrainAdjustment.BEARD_BOX, - TerrainAdjustment.ENCAPSULATE)) { - assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( - adjustment, GenerationStep.Decoration.UNDERGROUND_STRUCTURES)); + public void nonBeardAdjustmentsDoNotPrepareSurfaceTerrain() { + assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + TerrainAdjustment.BURY, GenerationStep.Decoration.SURFACE_STRUCTURES)); + assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + TerrainAdjustment.ENCAPSULATE, GenerationStep.Decoration.SURFACE_STRUCTURES)); + assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( + TerrainAdjustment.NONE, GenerationStep.Decoration.FLUID_SPRINGS)); + } + + @Test + public void explicitTerrainOverrideDisablesSourceBeardFitting() { + StructureStart start = desertStart(TerrainAdjustment.BEARD_BOX); + + assertTrue(NativeStructureSurfaceFitter.requiresSurfaceTerrain( + new NativeStructureTerrainIntegrator.TerrainTarget( + "test:beard_box", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)))); + assertFalse(NativeStructureSurfaceFitter.requiresSurfaceTerrain( + new NativeStructureTerrainIntegrator.TerrainTarget( + "test:beard_box", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.PRESERVE)))); + } + + @Test + public void beardBoxUsesTheFullRigidHeightWhileBeardThinUsesTheGroundPlane() { + NativeStructureSurfaceFitter.SurfaceAnchor thin = + new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 64, 2); + NativeStructureSurfaceFitter.SurfaceAnchor box = + new NativeStructureSurfaceFitter.SurfaceAnchor( + 0, 4, 0, 4, 64, 2, 65, 90); + + assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(thin), 2, 2, 80)); + assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(box), 2, 2, 80)); + } + + @Test + public void beardInfluenceBeginsAtGroundYRatherThanTheSurfaceBlock() { + BoundingBox bounds = new BoundingBox(0, 64, 0, 4, 90, 4); + NativeStructureSurfaceFitter.SurfaceAnchor thin = + NativeStructureSurfaceFitter.surfaceAnchor( + bounds, 68, 2, TerrainAdjustment.BEARD_THIN); + NativeStructureSurfaceFitter.SurfaceAnchor box = + NativeStructureSurfaceFitter.surfaceAnchor( + bounds, 68, 2, TerrainAdjustment.BEARD_BOX); + + assertEquals(67, thin.meetY()); + assertEquals(0, thin.verticalDistance(68)); + assertEquals(1, thin.verticalDistance(67)); + assertEquals(0, box.verticalDistance(68)); + assertEquals(1, box.verticalDistance(67)); + assertEquals(0, box.verticalDistance(90)); + assertEquals(1, box.verticalDistance(91)); + } + + @Test + public void rigidFootprintsRaiseTerrainAcrossGapsLargerThanTheBeardKernel() { + NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(72, 2); + NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(72, 1); + + assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(rigid), 2, 2, 40)); + assertEquals(40, NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(junction), 2, 2, 40)); + } + + @Test + public void elevatedRigidFootprintsBlendIntoAHorizontalRescueTaper() { + int originalY = 40; + int kernelEdge = NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(anchor(51, 2)), 5, 2, originalY); + int gapThirteen = NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(anchor(52, 2)), 5, 2, originalY); + int gapEighteen = NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(anchor(57, 2)), 5, 2, originalY); + int gapTwentyFour = NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(anchor(63, 2)), 5, 2, originalY); + + assertEquals(originalY, kernelEdge); + assertTrue(gapThirteen >= kernelEdge); + assertTrue(gapEighteen > gapThirteen); + assertTrue(gapTwentyFour > gapEighteen); + assertTrue(gapTwentyFour < 63); + assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(anchor(63, 2)), 16, 2, originalY)); + assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(anchor(63, 1)), 5, 2, originalY)); + assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(anchor(64, 2)), 5, 2, 80)); + } + + @Test + public void projectedCenterAndRigidChildrenShareTerrainSupportAcrossTheAssembly() { + Structure structure = new DesertPyramidStructure( + new Structure.StructureSettings( + HolderSet.empty(), Map.of(), + GenerationStep.Decoration.SURFACE_STRUCTURES, + TerrainAdjustment.BEARD_BOX)); + PoolElementStructurePiece center = rigidPiece( + new BoundingBox(0, 71, 0, 3, 80, 3), 1); + PoolElementStructurePiece child = rigidPiece( + new BoundingBox(10, 65, 0, 13, 76, 3), 7); + StructureStart start = new StructureStart( + structure, new ChunkPos(0, 0), 0, + new PiecesContainer(List.of(center, child))); + BoundingBox area = new BoundingBox(0, 60, 0, 15, 90, 15); + Map blocks = new HashMap<>(); + for (int x = area.minX(); x <= area.maxX(); x++) { + for (int z = area.minZ(); z <= area.maxZ(); z++) { + put(blocks, x, 63, z, Blocks.DIRT.defaultBlockState()); + put(blocks, x, 64, z, Blocks.GRASS_BLOCK.defaultBlockState()); + } } + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(new NativeStructureTerrainIntegrator.TerrainTarget( + "nova_structures:tavern_oak", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))), + (x, z) -> 64); + + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 1, 71, 1)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 11, 71, 1)); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 11, 70, 1)); } @Test public void surfaceAnchorIsFlushInsideAndUnchangedAtRadius() { - NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(80, 2); + NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(72, 2); - assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(anchor), 2, 2, 64)); assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(anchor), 16, 2, 64)); @@ -97,54 +236,88 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { @Test public void surfaceAnchorRaisesAndLowersThroughTheTaper() { - NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(80, 2); + NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(68, 2); NativeStructureSurfaceFitter.SurfaceAnchor lowered = anchor(64, 2); - assertEquals(68, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(raised), 10, 2, 64)); - assertEquals(76, NativeStructureSurfaceFitter.resolveSurfaceTarget( - List.of(lowered), 10, 2, 80)); + assertEquals(67, NativeStructureSurfaceFitter.resolveSurfaceTarget( + List.of(lowered), 10, 2, 68)); } @Test public void containingRigidFloorsHaveDeterministicPriority() { NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(70, 2); - NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(90, 1); + NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(74, 1); assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(rigid, junction), 2, 2, 64)); assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(junction, rigid), 2, 2, 64)); - NativeStructureSurfaceFitter.SurfaceAnchor weakTie = anchor(48, 1); - NativeStructureSurfaceFitter.SurfaceAnchor strongTie = anchor(80, 2); - assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( + NativeStructureSurfaceFitter.SurfaceAnchor weakTie = anchor(58, 1); + NativeStructureSurfaceFitter.SurfaceAnchor strongTie = anchor(70, 2); + assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(weakTie, strongTie), 2, 2, 64)); - assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(strongTie, weakTie), 2, 2, 64)); } + @Test + public void stackedRigidPiecesPreserveTheLowerAuthoredSurface() { + Structure structure = new DesertPyramidStructure( + new Structure.StructureSettings( + HolderSet.empty(), Map.of(), + GenerationStep.Decoration.SURFACE_STRUCTURES, + TerrainAdjustment.BEARD_BOX)); + PoolElementStructurePiece lower = rigidPiece( + new BoundingBox(0, 62, 0, 4, 65, 4), 1); + PoolElementStructurePiece upper = rigidPiece( + new BoundingBox(0, 66, 0, 4, 78, 4), 1); + StructureStart start = new StructureStart( + structure, new ChunkPos(0, 0), 0, + new PiecesContainer(List.of(upper, lower))); + BoundingBox area = new BoundingBox(0, 58, 0, 4, 78, 4); + Map blocks = new HashMap<>(); + for (int x = area.minX(); x <= area.maxX(); x++) { + for (int z = area.minZ(); z <= area.maxZ(); z++) { + put(blocks, x, 59, z, Blocks.DIRT.defaultBlockState()); + put(blocks, x, 60, z, Blocks.GRASS_BLOCK.defaultBlockState()); + } + } + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(new NativeStructureTerrainIntegrator.TerrainTarget( + "nova_structures:tavern_oak", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))), + (x, z) -> 60); + + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 2, 62, 2)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 2, 65, 2)); + } + @Test public void containingFootprintOverridesAnAdjacentPiecesFalloff() { NativeStructureSurfaceFitter.SurfaceAnchor local = - new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 65, 2); + new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 66, 2); NativeStructureSurfaceFitter.SurfaceAnchor adjacent = - new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 80, 2); + new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 70, 2); - assertEquals(77, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(adjacent), 4, 2, 64)); - assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(local, adjacent), 4, 2, 64)); - assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget( + assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(adjacent, local), 4, 2, 64)); } @Test public void opposingFalloffsBlendWithoutAnAbruptMidpointSeam() { NativeStructureSurfaceFitter.SurfaceAnchor high = - new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 80, 2); + new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 70, 2); NativeStructureSurfaceFitter.SurfaceAnchor low = - new NativeStructureSurfaceFitter.SurfaceAnchor(12, 12, 0, 0, 48, 2); + new NativeStructureSurfaceFitter.SurfaceAnchor(12, 12, 0, 0, 58, 2); int previous = NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(high, low), 0, 0, 64); @@ -154,7 +327,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { int reversed = NativeStructureSurfaceFitter.resolveSurfaceTarget( List.of(low, high), x, 0, 64); assertEquals(forward, reversed); - assertTrue(Math.abs(forward - previous) <= 4); + assertTrue(Math.abs(forward - previous) <= 6); previous = forward; } assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( @@ -195,6 +368,44 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(raised, 0, 68, 0)); } + @Test + public void rigidSurfaceBaseClosesAOneBlockSubsurfaceGap() { + Map blocks = new HashMap<>(); + put(blocks, 0, 62, 0, Blocks.STONE.defaultBlockState()); + put(blocks, 0, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + + NativeStructureSurfaceFitter.applySurfaceColumn( + world(blocks), new BlockPos.MutableBlockPos(), + 0, 0, 64, 64, -64, 319, true); + + assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 63, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 64, 0)); + } + + @Test + public void rigidSurfaceBaseReconstructsAMissingMeetLayer() { + Map blocks = new HashMap<>(); + put(blocks, 0, 62, 0, Blocks.STONE.defaultBlockState()); + + NativeStructureSurfaceFitter.applySurfaceColumn( + world(blocks), new BlockPos.MutableBlockPos(), + 0, 0, 64, 64, -64, 319, true); + + assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 63, 0)); + assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 64, 0)); + } + + @Test + public void rigidSurfaceBaseReconstructsAMissingMeetLayerAtTheWorldFloor() { + Map blocks = new HashMap<>(); + + NativeStructureSurfaceFitter.applySurfaceColumn( + world(blocks), new BlockPos.MutableBlockPos(), + 0, 0, -64, -64, -64, 319, true); + + assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, -64, 0)); + } + @Test public void raisedSurfaceTerrainDoesNotSliceTreeBlocks() { Map blocks = new HashMap<>(); @@ -213,6 +424,31 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { assertEquals(leaves, state(blocks, 0, 68, 0)); } + @Test + public void clearedIntersectingTreeCellsBecomeRaisedTerrainSupport() { + ProtoChunk chunk = new ProtoChunk( + new ChunkPos(0, 0), UpgradeData.EMPTY, + LevelHeightAccessor.create(-64, 384), containerFactory(), null); + write(chunk, 0, 63, 0, Blocks.DIRT.defaultBlockState()); + write(chunk, 0, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + write(chunk, 0, 66, 0, Blocks.OAK_LOG.defaultBlockState()); + write(chunk, 0, 68, 0, Blocks.OAK_LEAVES.defaultBlockState()); + WorldGenLevel world = world(chunk); + BoundingBox area = new BoundingBox(0, 63, 0, 0, 68, 0); + + NativeStructureVegetationClearer.clearIntersectingVegetation( + world, chunk, area, List.of( + new NativeStructureVegetationClearer.VegetationTarget(desertStart(), false))); + NativeStructureSurfaceFitter.applySurfaceColumn( + world, new BlockPos.MutableBlockPos(), + 0, 0, 64, 68, -64, 319); + + assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 65, 0))); + assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 66, 0))); + assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 67, 0))); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 68, 0))); + } + @Test public void loweredFluidColumnsRemainFluidFilled() { Map blocks = new HashMap<>(); @@ -437,6 +673,47 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { assertNotSame(first, other); } + @Test + public void concurrentCarveRequestsShareOneFootprintBuild() throws Exception { + StructureStart start = desertStart(); + int requestCount = 16; + ExecutorService executor = Executors.newFixedThreadPool(requestCount); + CountDownLatch ready = new CountDownLatch(requestCount); + CountDownLatch release = new CountDownLatch(1); + List> futures = new ArrayList<>(); + try { + for (int request = 0; request < requestCount; request++) { + futures.add(executor.submit(() -> { + ready.countDown(); + release.await(); + return NativeStructureTerrainIntegrator.carveFootprint( + start, 11, + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + })); + } + assertTrue(ready.await(5, TimeUnit.SECONDS)); + release.countDown(); + StructureCarvingFootprint expected = futures.getFirst().get(); + for (Future future : futures) { + assertSame(expected, future.get()); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + public void carveFootprintCacheStaysInsideItsCellBudget() { + for (int index = 0; index < 40; index++) { + NativeStructureTerrainIntegrator.carveFootprint( + desertStart(), 128, + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + } + + assertTrue(NativeStructureTerrainIntegrator.cachedCarveFootprintCells() + <= NativeStructureTerrainIntegrator.maximumCachedCarveFootprintCells()); + } + @Test public void organicCarveNeverCutsBelowTheColumnSupportingFloor() { StructureStart start = desertStart(); @@ -594,43 +871,48 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { public void nativeTerrainEnvelopePersistsHorizontalReferenceCoverage() { StructureStart generated = desertStart(); BoundingBox content = generated.getBoundingBox(); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(24); StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( generated, generated.getStructure(), 0, - null, - new IrisStructureTerrain() - .setMode(IrisStructureTerrainMode.FORCE_CARVE) - .setHorizontalPadding(24)); + terrain); + BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds( + wrapped, wrapped.getStructure(), terrain); assertEquals(content, NativeStructureReferenceEnvelope.contentBounds(wrapped)); - assertEquals(content.minX() - 24, wrapped.getBoundingBox().minX()); - assertEquals(content.minZ() - 24, wrapped.getBoundingBox().minZ()); - assertEquals(content.maxX() + 24, wrapped.getBoundingBox().maxX()); - assertEquals(content.maxZ() + 24, wrapped.getBoundingBox().maxZ()); - assertEquals(2, wrapped.getPieces().stream() - .filter(NativeStructureReferenceEnvelope::isMarker) - .count()); + assertEquals(content, wrapped.getBoundingBox()); + assertEquals(content.minX() - 24, referenceBounds.minX()); + assertEquals(content.minZ() - 24, referenceBounds.minZ()); + assertEquals(content.maxX() + 24, referenceBounds.maxX()); + assertEquals(content.maxZ() + 24, referenceBounds.maxZ()); + assertEquals(generated.getPieces().size(), wrapped.getPieces().size()); } @Test - public void nativeTerrainEnvelopeClipsToMinecraftReferenceCoverage() { + public void nativeTerrainEnvelopeClipsOptionalCoverageWithoutDroppingContent() { StructureStart generated = desertStart(); + ChunkPos origin = generated.getChunkPos(); + BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(124); - StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( - generated, - generated.getStructure(), - 0, - null, - new IrisStructureTerrain() - .setMode(IrisStructureTerrainMode.FORCE_CARVE) - .setHorizontalPadding(128)); + BoundingBox references = NativeStructureReferenceEnvelope.referenceBounds( + generated, generated.getStructure(), terrain, "test:clipped_terrain"); + StructureStart published = NativeStructureReferenceEnvelope.wrapForPublication( + generated, generated.getStructure(), 0, terrain, "test:clipped_terrain"); - assertEquals(-128, wrapped.getBoundingBox().minX()); - assertEquals(-128, wrapped.getBoundingBox().minZ()); - assertEquals(143, wrapped.getBoundingBox().maxX()); - assertEquals(143, wrapped.getBoundingBox().maxZ()); + assertEquals(Math.max(content.minX() - 124, (origin.x() - 8) << 4), references.minX()); + assertEquals(Math.min(content.maxX() + 124, ((origin.x() + 8) << 4) + 15), references.maxX()); + assertEquals(Math.max(content.minZ() - 124, (origin.z() - 8) << 4), references.minZ()); + assertEquals(Math.min(content.maxZ() + 124, ((origin.z() + 8) << 4) + 15), references.maxZ()); + assertTrue(references.isInside(content.getCenter())); + assertTrue(published.isValid()); + assertEquals(1, generated.getPieces().size()); } @Test @@ -726,6 +1008,16 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { return new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, meetY, strength); } + private static PoolElementStructurePiece rigidPiece(BoundingBox bounds, int groundLevelDelta) { + StructurePoolElement element = StructurePoolElement.single( + "minecraft:empty").apply(StructureTemplatePool.Projection.RIGID); + return new PoolElementStructurePiece( + null, element, + new BlockPos(bounds.minX(), bounds.minY(), bounds.minZ()), + groundLevelDelta, Rotation.NONE, bounds, + LiquidSettings.APPLY_WATERLOGGING); + } + private static NativeStructureTerrainIntegrator.OrganicCarve organicCarve( StructureStart start, int horizontalPadding) { return organicCarve(start, horizontalPadding, 0.85D); @@ -831,8 +1123,14 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } private static StructureStart desertStart() { + return desertStart(TerrainAdjustment.NONE); + } + + private static StructureStart desertStart(TerrainAdjustment adjustment) { Structure structure = new DesertPyramidStructure( - new Structure.StructureSettings(HolderSet.empty())); + new Structure.StructureSettings( + HolderSet.empty(), Map.of(), + GenerationStep.Decoration.SURFACE_STRUCTURES, adjustment)); DesertPyramidPiece piece = new DesertPyramidPiece(RandomSource.create(7L), 0, 0); return new StructureStart( structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece))); @@ -854,6 +1152,9 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { if (methodName.equals("getSeed")) { return TEST_SEED; } + if (methodName.equals("getLevel")) { + return null; + } if (methodName.equals("hashCode")) { return System.identityHashCode(proxy); } @@ -869,6 +1170,62 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { WorldGenLevel.class.getClassLoader(), new Class[]{WorldGenLevel.class}, handler); } + private static WorldGenLevel world(ChunkAccess chunk) { + InvocationHandler handler = (proxy, method, arguments) -> { + String methodName = method.getName(); + if (methodName.equals("getBlockState")) { + return chunk.getBlockState((BlockPos) arguments[0]); + } + if (methodName.equals("setBlock")) { + return chunk.setBlockState( + ((BlockPos) arguments[0]).immutable(), + (BlockState) arguments[1], (int) arguments[2]) != null; + } + if (methodName.equals("getSeed")) { + return TEST_SEED; + } + if (methodName.equals("getLevel")) { + return null; + } + if (methodName.equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (methodName.equals("equals")) { + return proxy == arguments[0]; + } + if (methodName.equals("toString")) { + return "surface-test-chunk-world"; + } + throw new UnsupportedOperationException(method.toString()); + }; + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), new Class[]{WorldGenLevel.class}, handler); + } + + private static void write(ProtoChunk chunk, int x, int y, int z, BlockState state) { + LevelChunkSection section = chunk.getSection(chunk.getSectionIndex(y)); + section.setBlockState(x, y & 15, z, state, false); + } + + private static PalettedContainerFactory containerFactory() { + Strategy blockStates = Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY); + BlockState air = Blocks.AIR.defaultBlockState(); + IdMapper> biomeIds = new IdMapper<>(); + Holder defaultBiome = Holder.direct((Biome) null); + biomeIds.add(defaultBiome); + Strategy> biomes = Strategy.createForBiomes(biomeIds); + Codec> biomeCodec = Codec.STRING.xmap( + name -> defaultBiome, holder -> "iris-test-biome"); + return new PalettedContainerFactory( + blockStates, + air, + PalettedContainer.codecRW(BlockState.CODEC, blockStates, air), + biomes, + defaultBiome, + PalettedContainer.codecRO(biomeCodec, biomes, defaultBiome), + PalettedContainer.codecRW(biomeCodec, biomes, defaultBiome)); + } + private static void put(Map blocks, int x, int y, int z, BlockState state) { blocks.put(new BlockPos(x, y, z), state); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java index 140008f5d..af7bb22eb 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java @@ -1,14 +1,33 @@ package art.arcane.iris.nativegen; +import net.minecraft.SharedConstants; +import net.minecraft.core.HolderSet; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece; +import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure; +import org.junit.BeforeClass; import org.junit.Test; +import java.util.List; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class NativeStructurePostProcessorVegetationTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + @Test public void surfaceStructuresClearTreeColumnsAutomatically() { assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(100, 100, false)); @@ -26,6 +45,22 @@ public class NativeStructurePostProcessorVegetationTest { assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(20, 100, true)); } + @Test + public void nonForcedValidTargetsReachIntersectionProcessing() { + Structure structure = new DesertPyramidStructure( + new Structure.StructureSettings(HolderSet.empty())); + StructureStart start = new StructureStart( + structure, new ChunkPos(0, 0), 0, + new PiecesContainer(List.of( + new DesertPyramidPiece(RandomSource.create(1L), 0, 0)))); + + assertTrue(NativeStructureVegetationClearer.shouldProcessTarget( + new NativeStructureVegetationClearer.VegetationTarget(start, false))); + assertFalse(NativeStructureVegetationClearer.shouldProcessTarget( + new NativeStructureVegetationClearer.VegetationTarget( + StructureStart.INVALID_START, true))); + } + @Test public void surfaceStructuresPreserveVegetationUnlessConfigured() { assertFalse(NativeStructureVegetationClearer.shouldClearEntireVegetationFootprint( diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index 5a40bce83..98e3cc3a2 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -58,6 +58,7 @@ tasks.named('jar', Jar).configure { tasks.named('test').configure { systemProperty('iris.commandFindSource', file('src/main/java/art/arcane/iris/core/commands/CommandFind.java').absolutePath) systemProperty('iris.commandStructureSource', file('src/main/java/art/arcane/iris/core/commands/CommandStructure.java').absolutePath) + systemProperty('iris.commandIrisSource', file('src/main/java/art/arcane/iris/core/commands/CommandIris.java').absolutePath) systemProperty('iris.bukkitChunkGeneratorSource', rootProject.file('core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java').absolutePath) systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath) systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 1d5b0c7a2..b8f307354 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -36,6 +36,7 @@ import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.lifecycle.PaperLibBootstrap; import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks; +import art.arcane.iris.core.runtime.WorldDeletionQueue; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.api.terrain.IrisTerrainService; import art.arcane.iris.core.link.IrisPapiInstaller; @@ -571,7 +572,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisServices.register(EnginePlatformHooks.class, new BukkitEnginePlatformHooks()); IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) IrisWorldManager::new); - IrisServices.register(art.arcane.iris.core.runtime.WorldDeletionQueue.class, (art.arcane.iris.core.runtime.WorldDeletionQueue) pendingWorldDeletes::queueWorldDeletionOnStartup); + IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes); SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json")); settingsHotloadWatch = watch; configHotloadEngine = new ConfigHotloadEngine( diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java index 9a968077c..7e7d6f625 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java @@ -19,77 +19,381 @@ package art.arcane.iris.core; import art.arcane.iris.Iris; -import art.arcane.iris.core.lifecycle.WorldLifecycleService; +import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.platform.bukkit.BukkitEnvironment; import art.arcane.iris.util.common.format.C; -import art.arcane.iris.util.common.scheduling.J; +import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.volmlib.util.bukkit.WorldIdentity; -import art.arcane.volmlib.util.collection.KList; import org.bukkit.NamespacedKey; +import org.bukkit.World; import org.bukkit.WorldCreator; import org.bukkit.generator.ChunkGenerator; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; +import java.util.stream.Stream; -/** - * Loads Iris worlds that are staged in bukkit.yml but not yet present on the server. - */ public final class BukkitWorldReconciler { - private final Iris plugin; + private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L; + + private final Backend backend; + private final LifecycleOperationCoordinator coordinator; public BukkitWorldReconciler(Iris plugin) { - this.plugin = plugin; + this(new BukkitBackend(plugin), LifecycleOperationCoordinator.get()); } - public void checkForBukkitWorlds(Predicate filter) { + BukkitWorldReconciler(Backend backend, LifecycleOperationCoordinator coordinator) { + this.backend = Objects.requireNonNull(backend, "backend"); + this.coordinator = Objects.requireNonNull(coordinator, "coordinator"); + } + + public CompletableFuture loadWorld( + File configurationFile, + String worldName + ) { + NamespacedKey worldKey; try { - KList deferredStartupWorlds = new KList<>(); - IrisWorlds.readBukkitWorlds().forEach((s, generator) -> { - try { - NamespacedKey worldKey = IrisWorldStorage.keyFromName(s); - if (WorldIdentity.resolve(worldKey).isPresent() || !filter.test(s)) return; - - Iris.info("Loading World: %s | Generator: %s", s, generator); - ChunkGenerator gen = plugin.getDefaultWorldGenerator(s, generator); - IrisDimension dim = IrisWorldGeneratorResolver.loadDimension(s, generator); - assert dim != null && gen != null; - - Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + s + "' using Iris:" + generator + "..."); - WorldCreator c = WorldCreatorCompat.ofKey(worldKey) - .generator(gen) - .environment(BukkitEnvironment.from(dim.getEnvironment())); - Long stagedSeed = IrisWorlds.readBukkitWorldSeed(s); - if (stagedSeed != null) { - c.seed(stagedSeed); - } - INMS.get().createWorld(c); - Iris.info(C.LIGHT_PURPLE + "Loaded " + s + "!"); - } catch (Throwable e) { - if (containsCreateWorldUnsupportedOperation(e)) { - if (J.isFolia()) { - if (!deferredStartupWorlds.contains(s)) { - deferredStartupWorlds.add(s); - } - return; - } - Iris.error("Failed to load world " + s + "!"); - Iris.error("This server denied Bukkit.createWorld for \"" + s + "\" at the current startup phase."); - Iris.error("Ensure Iris is loaded at STARTUP and restart after staging worlds in bukkit.yml."); - Iris.reportError("Failed to load staged startup world \"" + s + "\".", e); - return; - } - Iris.reportError("Failed to load startup world \"" + s + "\".", e); - } - }); - if (!deferredStartupWorlds.isEmpty()) { - Iris.warn("Staged Iris worlds could not load on Folia: %s", String.join(", ", deferredStartupWorlds)); - Iris.warn("Bukkit.createWorld is unsupported on this server and the Iris runtime world backend is unavailable (%s).", WorldLifecycleService.get().capabilities().paperLikeResolution()); - } - } catch (Throwable e) { - Iris.reportError("Failed while loading startup Iris worlds.", e); + worldKey = IrisWorldStorage.managedKeyFromName(worldName); + } catch (Throwable failure) { + return CompletableFuture.completedFuture(LoadResult.validationFailure(worldName, failure)); } + + LifecycleOperationCoordinator.Lease lease; + try { + lease = acquireWorldLoad(worldKey); + } catch (LifecycleOperationCoordinator.BusyException failure) { + return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure)); + } + + DimensionResolution dimensionResolution; + Long configuredSeed; + try { + dimensionResolution = backend.resolveDimension(worldKey); + configuredSeed = dimensionResolution.succeeded() + ? backend.configuredSeed(IrisWorldStorage.logicalName(worldKey)) + : null; + } catch (Throwable failure) { + dimensionResolution = DimensionResolution.failed(failure); + configuredSeed = null; + } + if (!dimensionResolution.succeeded()) { + lease.close(); + return CompletableFuture.completedFuture(LoadResult.dimensionFailure( + worldKey, + dimensionResolution.failure())); + } + return loadWithLease( + configurationFile, + worldKey, + dimensionResolution.dimension(), + configuredSeed, + lease); + } + + public CompletableFuture checkForBukkitWorlds(Predicate filter) { + Predicate requiredFilter = Objects.requireNonNull(filter, "filter"); + Map configuredWorlds; + try { + configuredWorlds = backend.configuredWorlds(); + } catch (Throwable failure) { + Iris.reportError("Failed while reading staged Bukkit worlds.", failure); + return CompletableFuture.completedFuture(new BatchResult(List.of(), failure)); + } + + CompletableFuture> chain = CompletableFuture.completedFuture(new ArrayList<>()); + for (Map.Entry entry : configuredWorlds.entrySet()) { + String worldName = entry.getKey(); + boolean selected; + try { + selected = requiredFilter.test(worldName); + } catch (Throwable failure) { + Iris.reportError("Failed while filtering staged Bukkit world \"" + worldName + "\".", failure); + return CompletableFuture.completedFuture(new BatchResult(List.of(), failure)); + } + if (!selected) { + continue; + } + NamespacedKey worldKey; + try { + worldKey = IrisWorldStorage.keyFromName(worldName); + } catch (Throwable failure) { + chain = chain.thenApply(results -> { + results.add(LoadResult.validationFailure(worldName, failure)); + return results; + }); + continue; + } + String dimension = entry.getValue(); + Long seed; + try { + seed = backend.configuredSeed(worldName); + } catch (Throwable failure) { + chain = chain.thenApply(results -> { + results.add(LoadResult.configurationFailure(worldKey, failure)); + return results; + }); + continue; + } + chain = chain.thenCompose(results -> loadConfiguredWorld( + ServerProperties.BUKKIT_YML, + worldKey, + dimension, + seed) + .thenApply(result -> { + results.add(result); + return results; + })); + } + + return chain.thenApply(results -> { + BatchResult batchResult = new BatchResult(results, null); + reportBatch(batchResult); + return batchResult; + }); + } + + private CompletableFuture loadConfiguredWorld( + File configurationFile, + NamespacedKey worldKey, + String dimension, + Long seed + ) { + LifecycleOperationCoordinator.Lease lease; + try { + lease = acquireWorldLoad(worldKey); + } catch (LifecycleOperationCoordinator.BusyException failure) { + return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure)); + } + + return loadWithLease(configurationFile, worldKey, dimension, seed, lease); + } + + private LifecycleOperationCoordinator.Lease acquireWorldLoad(NamespacedKey worldKey) { + return coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_LOAD, + worldKey.toString()); + } + + private CompletableFuture loadWithLease( + File configurationFile, + NamespacedKey worldKey, + String dimension, + Long seed, + LifecycleOperationCoordinator.Lease lease + ) { + + BukkitWorldConfiguration.Registration registration; + String worldName = IrisWorldStorage.logicalName(worldKey); + try { + registration = BukkitWorldConfiguration.register( + configurationFile, + worldName, + dimension, + seed); + } catch (Throwable failure) { + lease.close(); + return CompletableFuture.completedFuture(LoadResult.configurationFailure(worldKey, failure)); + } + + CompletableFuture reconciliation; + try { + reconciliation = reconcile(worldKey, dimension, seed); + } catch (Throwable failure) { + reconciliation = CompletableFuture.completedFuture(ReconciliationResult.createFailure(worldKey, failure)); + } + + return reconciliation.handle((result, failure) -> { + ReconciliationResult settled = failure == null + ? result + : ReconciliationResult.createFailure(worldKey, unwrap(failure)); + if (settled == null) { + settled = ReconciliationResult.createFailure( + worldKey, + new IllegalStateException("World reconciliation completed without a result.")); + } + if (settled.succeeded() + || settled.status() == ReconciliationStatus.RESTART_REQUIRED + || registration != BukkitWorldConfiguration.Registration.CREATED) { + return new LoadResult(settled, registration, false, true, null); + } + try { + boolean rolledBack = BukkitWorldConfiguration.removeIfMatching( + configurationFile, + worldName, + dimension, + seed); + return new LoadResult(settled, registration, true, rolledBack, null); + } catch (Throwable rollbackFailure) { + return new LoadResult(settled, registration, true, false, rollbackFailure); + } + }) + .whenComplete((result, failure) -> lease.close()); + } + + private CompletableFuture reconcile( + NamespacedKey worldKey, + String dimension, + Long seed + ) { + Optional loaded = backend.loadedWorld(worldKey); + if (loaded.isPresent()) { + return CompletableFuture.completedFuture(verifyLoadedWorld(worldKey, loaded.get(), true)); + } + + CompletableFuture created; + try { + created = Objects.requireNonNull( + backend.createWorld(worldKey, dimension, seed), + "World backend returned no creation future."); + } catch (Throwable failure) { + return CompletableFuture.completedFuture(classifyCreationFailure(worldKey, failure)); + } + + CompletableFuture guardedCreation = guardCreateCompletion( + created, + worldKey, + TimeUnit.SECONDS.toMillis(WORLD_CREATE_TIMEOUT_SECONDS), + () -> ServerConfigurator.restart("World load timed out for \"" + worldKey + "\".")); + return guardedCreation.handle((createdWorld, failure) -> { + if (failure != null) { + return classifyCreationFailure(worldKey, unwrap(failure)); + } + if (createdWorld == null) { + return ReconciliationResult.notLoaded(worldKey); + } + + NamespacedKey createdKey; + try { + createdKey = WorldIdentity.key(createdWorld); + } catch (Throwable identityFailure) { + return ReconciliationResult.createFailure(worldKey, identityFailure); + } + if (!worldKey.equals(createdKey)) { + return ReconciliationResult.identityMismatch(worldKey, createdWorld, createdKey); + } + + Optional resolved = backend.loadedWorld(worldKey); + if (resolved.isEmpty()) { + return ReconciliationResult.notLoaded(worldKey); + } + return verifyLoadedWorld(worldKey, resolved.get(), false); + }); + } + + private ReconciliationResult verifyLoadedWorld(NamespacedKey worldKey, World loadedWorld, boolean alreadyLoaded) { + NamespacedKey loadedKey; + try { + loadedKey = WorldIdentity.key(loadedWorld); + } catch (Throwable identityFailure) { + return ReconciliationResult.createFailure(worldKey, identityFailure); + } + if (!worldKey.equals(loadedKey)) { + return ReconciliationResult.identityMismatch(worldKey, loadedWorld, loadedKey); + } + if (!backend.isIrisWorld(loadedWorld)) { + return ReconciliationResult.identityConflict(worldKey, loadedWorld); + } + return alreadyLoaded + ? ReconciliationResult.alreadyLoaded(worldKey, loadedWorld) + : ReconciliationResult.loaded(worldKey, loadedWorld); + } + + private static ReconciliationResult classifyCreationFailure(NamespacedKey worldKey, Throwable failure) { + Throwable cause = unwrap(failure); + if (cause instanceof TimeoutException || containsCreateWorldUnsupportedOperation(cause)) { + return ReconciliationResult.restartRequired(worldKey, cause); + } + return ReconciliationResult.createFailure(worldKey, cause); + } + + static CompletableFuture guardCreateCompletion( + CompletableFuture source, + NamespacedKey worldKey, + long timeoutMillis, + Runnable timeoutAction + ) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(worldKey, "worldKey"); + Objects.requireNonNull(timeoutAction, "timeoutAction"); + if (timeoutMillis < 1L) { + throw new IllegalArgumentException("timeoutMillis must be positive"); + } + + CompletableFuture guarded = new CompletableFuture<>(); + AtomicBoolean settled = new AtomicBoolean(false); + source.whenComplete((world, throwable) -> { + if (!settled.compareAndSet(false, true)) { + return; + } + if (throwable == null) { + guarded.complete(world); + } else { + guarded.completeExceptionally(unwrap(throwable)); + } + }); + CompletableFuture.delayedExecutor(timeoutMillis, TimeUnit.MILLISECONDS).execute(() -> { + if (!settled.compareAndSet(false, true)) { + return; + } + TimeoutException timeout = new TimeoutException( + "World load did not settle within " + timeoutMillis + " milliseconds for \"" + + worldKey + "\"."); + try { + timeoutAction.run(); + } catch (Throwable failure) { + timeout.addSuppressed(failure); + } + guarded.completeExceptionally(timeout); + }); + return guarded; + } + + private static void reportBatch(BatchResult batchResult) { + for (LoadResult result : batchResult.results()) { + if (result.succeeded()) { + Iris.info(C.LIGHT_PURPLE + result.message()); + continue; + } + if (result.status() == ReconciliationStatus.BUSY) { + Iris.warn(result.message()); + continue; + } + Iris.error(result.message()); + Throwable failure = result.failure(); + if (failure != null) { + Iris.reportError("Failed to reconcile staged world \"" + result.worldKey() + "\".", failure); + } + } + } + + private static Throwable unwrap(Throwable failure) { + Throwable current = failure; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current; } private static boolean containsCreateWorldUnsupportedOperation(Throwable throwable) { @@ -107,4 +411,321 @@ public final class BukkitWorldReconciler { } return false; } + + interface Backend { + Map configuredWorlds(); + + Long configuredSeed(String worldName); + + Optional loadedWorld(NamespacedKey worldKey); + + CompletableFuture createWorld(NamespacedKey worldKey, String dimension, Long seed); + + boolean isIrisWorld(World world); + + DimensionResolution resolveDimension(NamespacedKey worldKey); + } + + public enum ReconciliationStatus { + LOADED, + ALREADY_LOADED, + BUSY, + INVALID_WORLD, + DIMENSION_UNRESOLVED, + CONFIGURATION_FAILED, + CREATE_FAILED, + RESTART_REQUIRED, + IDENTITY_MISMATCH, + IDENTITY_CONFLICT, + NOT_LOADED + } + + public record ReconciliationResult( + ReconciliationStatus status, + NamespacedKey worldKey, + World world, + Throwable failure, + String message + ) { + public ReconciliationResult { + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(message, "message"); + } + + public boolean succeeded() { + return status == ReconciliationStatus.LOADED || status == ReconciliationStatus.ALREADY_LOADED; + } + + private static ReconciliationResult loaded(NamespacedKey worldKey, World world) { + return new ReconciliationResult( + ReconciliationStatus.LOADED, + worldKey, + world, + null, + "Loaded Iris world \"" + worldKey + "\"."); + } + + private static ReconciliationResult alreadyLoaded(NamespacedKey worldKey, World world) { + return new ReconciliationResult( + ReconciliationStatus.ALREADY_LOADED, + worldKey, + world, + null, + "Iris world \"" + worldKey + "\" is already loaded."); + } + + private static ReconciliationResult createFailure(NamespacedKey worldKey, Throwable failure) { + return new ReconciliationResult( + ReconciliationStatus.CREATE_FAILED, + worldKey, + null, + failure, + "Failed to create Iris world \"" + worldKey + "\": " + failure.getMessage()); + } + + private static ReconciliationResult restartRequired(NamespacedKey worldKey, Throwable failure) { + return new ReconciliationResult( + ReconciliationStatus.RESTART_REQUIRED, + worldKey, + null, + failure, + "The server cannot load exact Iris world \"" + worldKey + "\" at this runtime phase."); + } + + private static ReconciliationResult identityMismatch( + NamespacedKey worldKey, + World world, + NamespacedKey actualKey + ) { + return new ReconciliationResult( + ReconciliationStatus.IDENTITY_MISMATCH, + worldKey, + world, + null, + "World creation returned \"" + actualKey + "\" instead of \"" + worldKey + "\"."); + } + + private static ReconciliationResult identityConflict(NamespacedKey worldKey, World world) { + return new ReconciliationResult( + ReconciliationStatus.IDENTITY_CONFLICT, + worldKey, + world, + null, + "World \"" + worldKey + "\" is loaded, but it is not an Iris world."); + } + + private static ReconciliationResult notLoaded(NamespacedKey worldKey) { + return new ReconciliationResult( + ReconciliationStatus.NOT_LOADED, + worldKey, + null, + null, + "World creation completed without loading exact Iris world \"" + worldKey + "\"."); + } + } + + public record LoadResult( + ReconciliationResult reconciliation, + BukkitWorldConfiguration.Registration registration, + boolean rollbackAttempted, + boolean rollbackSucceeded, + Throwable rollbackFailure + ) { + public LoadResult { + Objects.requireNonNull(reconciliation, "reconciliation"); + } + + public boolean succeeded() { + return reconciliation.succeeded() && rollbackFailure == null; + } + + public ReconciliationStatus status() { + return reconciliation.status(); + } + + public NamespacedKey worldKey() { + return reconciliation.worldKey(); + } + + public World world() { + return reconciliation.world(); + } + + public Throwable failure() { + return rollbackFailure == null ? reconciliation.failure() : rollbackFailure; + } + + public String message() { + if (rollbackFailure != null) { + return reconciliation.message() + " Failed to roll back bukkit.yml: " + rollbackFailure.getMessage(); + } + if (rollbackAttempted && rollbackSucceeded) { + return reconciliation.message() + " The new bukkit.yml entry was rolled back."; + } + if (rollbackAttempted && !rollbackSucceeded) { + return reconciliation.message() + " The new bukkit.yml entry was no longer an exact match and was not modified."; + } + return reconciliation.message(); + } + + private static LoadResult validationFailure(String worldName, Throwable failure) { + ReconciliationResult reconciliation = new ReconciliationResult( + ReconciliationStatus.INVALID_WORLD, + null, + null, + failure, + "Invalid Iris world identifier \"" + worldName + "\": " + failure.getMessage()); + return new LoadResult(reconciliation, null, false, true, null); + } + + private static LoadResult busy(NamespacedKey worldKey, LifecycleOperationCoordinator.BusyException failure) { + ReconciliationResult reconciliation = new ReconciliationResult( + ReconciliationStatus.BUSY, + worldKey, + null, + failure, + failure.getMessage()); + return new LoadResult(reconciliation, null, false, true, null); + } + + private static LoadResult configurationFailure(NamespacedKey worldKey, Throwable failure) { + ReconciliationResult reconciliation = new ReconciliationResult( + ReconciliationStatus.CONFIGURATION_FAILED, + worldKey, + null, + failure, + "Failed to register Iris world \"" + worldKey + "\" in bukkit.yml: " + failure.getMessage()); + return new LoadResult(reconciliation, null, false, true, null); + } + + private static LoadResult dimensionFailure(NamespacedKey worldKey, Throwable failure) { + ReconciliationResult reconciliation = new ReconciliationResult( + ReconciliationStatus.DIMENSION_UNRESOLVED, + worldKey, + null, + failure, + "Could not determine one Iris dimension for world \"" + worldKey + "\": " + failure.getMessage()); + return new LoadResult(reconciliation, null, false, true, null); + } + } + + record DimensionResolution(String dimension, Throwable failure) { + DimensionResolution { + if ((dimension == null) == (failure == null)) { + throw new IllegalArgumentException("Dimension resolution must contain exactly one outcome."); + } + } + + static DimensionResolution resolved(String dimension) { + return new DimensionResolution(Objects.requireNonNull(dimension, "dimension"), null); + } + + static DimensionResolution failed(Throwable failure) { + return new DimensionResolution(null, Objects.requireNonNull(failure, "failure")); + } + + boolean succeeded() { + return dimension != null; + } + } + + public record BatchResult(List results, Throwable failure) { + public BatchResult { + results = List.copyOf(Objects.requireNonNull(results, "results")); + } + + public boolean succeeded() { + return failure == null && results.stream().allMatch(LoadResult::succeeded); + } + } + + private static final class BukkitBackend implements Backend { + private final Iris plugin; + + private BukkitBackend(Iris plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + } + + @Override + public Map configuredWorlds() { + return new LinkedHashMap<>(IrisWorlds.readBukkitWorlds()); + } + + @Override + public Long configuredSeed(String worldName) { + return IrisWorlds.readBukkitWorldSeed(worldName); + } + + @Override + public Optional loadedWorld(NamespacedKey worldKey) { + return WorldIdentity.resolve(worldKey); + } + + @Override + public CompletableFuture createWorld(NamespacedKey worldKey, String dimension, Long seed) { + try { + String worldName = IrisWorldStorage.logicalName(worldKey); + Iris.info("Loading World: %s | Generator: %s", worldName, dimension); + ChunkGenerator generator = plugin.getDefaultWorldGenerator(worldName, dimension); + IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension); + if (generator == null || irisDimension == null) { + throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\"."); + } + + Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + worldName + " using Iris:" + dimension + "..."); + WorldCreator creator = WorldCreatorCompat.ofKey(worldKey) + .generator(generator) + .environment(BukkitEnvironment.from(irisDimension.getEnvironment())); + if (seed != null) { + creator.seed(seed); + } + return INMS.get().createWorldAsync(creator); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + @Override + public boolean isIrisWorld(World world) { + return IrisToolbelt.isIrisWorld(world); + } + + @Override + public DimensionResolution resolveDimension(NamespacedKey worldKey) { + File dimensionsDirectory = new File(IrisWorldStorage.packRoot(worldKey), "dimensions"); + if (!dimensionsDirectory.isDirectory()) { + return DimensionResolution.failed(new IllegalStateException("The world has no Iris dimensions directory.")); + } + + List dimensions = new ArrayList<>(); + Path dimensionsRoot = dimensionsDirectory.toPath().toAbsolutePath().normalize(); + try (Stream paths = Files.walk(dimensionsRoot)) { + paths.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) + .filter(path -> path.getFileName().toString().endsWith(".json")) + .forEach(path -> { + String relative = dimensionsRoot.relativize(path).toString().replace(File.separatorChar, '/'); + dimensions.add(relative.substring(0, relative.length() - 5)); + }); + } catch (IOException failure) { + return DimensionResolution.failed(new IllegalStateException( + "The Iris dimensions directory could not be read.", + failure)); + } + Collections.sort(dimensions); + + String registeredDimension = IrisWorlds.get().getWorlds().get(worldKey.toString()); + if (registeredDimension != null && dimensions.contains(registeredDimension)) { + return DimensionResolution.resolved(registeredDimension); + } + if (dimensions.size() == 1) { + return DimensionResolution.resolved(dimensions.getFirst()); + } + if (dimensions.isEmpty()) { + return DimensionResolution.failed(new IllegalStateException("No dimension definitions were found.")); + } + return DimensionResolution.failed(new IllegalStateException( + "Multiple dimension definitions were found without an exact registered dimension: " + + String.join(", ", dimensions))); + } + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index 30b90364b..45e9ab7f8 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleStaging; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.PackDownloader; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; @@ -33,7 +34,6 @@ import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.util.common.plugin.VolmitPlugin; import art.arcane.iris.util.common.plugin.VolmitSender; -import art.arcane.volmlib.util.io.IO; import lombok.NonNull; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; @@ -42,6 +42,7 @@ import org.bukkit.generator.ChunkGenerator; import org.jetbrains.annotations.Nullable; import java.io.File; +import java.util.List; import java.util.function.Supplier; /** @@ -57,15 +58,12 @@ public final class IrisWorldGeneratorResolver { public void validateAllPacks() { File packsRoot = plugin.getDataFolder("packs"); - File[] packDirs = packsRoot.listFiles(File::isDirectory); - if (packDirs == null || packDirs.length == 0) { + List packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); + PackValidationRegistry.clear(); + if (packDirs.isEmpty()) { return; } - PackValidationRegistry.clear(); for (File packDir : packDirs) { - if (packDir.getName().contains(".importing-")) { - continue; - } try { PackValidationResult result = PackValidator.validate(packDir); PackValidationRegistry.publish(result); @@ -167,16 +165,16 @@ public final class IrisWorldGeneratorResolver { Iris.debug("Generator Config: " + w.toString()); File ff = new File(w.worldFolder(), "iris/pack"); - File[] files = ff.listFiles(); - if (files == null || files.length == 0) - IO.delete(ff); - - if (!ff.exists()) { - ff.mkdirs(); - dim = Iris.service(StudioSVC.class).installIntoWorld(Iris.getSender(), dim, w.worldFolder()); + IrisDimension installedDimension = ff.isDirectory() + ? IrisData.get(ff).getDimensionLoader().load(dim.getLoadKey(), false) + : null; + if (installedDimension == null) { + dim = Iris.service(StudioSVC.class).replaceIntoWorld(Iris.getSender(), dim, w.worldFolder()); if (dim == null) { throw new IllegalStateException("Failed to install dimension pack for " + id); } + } else { + dim = installedDimension; } return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey()); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java index cd2bd0583..faa1f70f7 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java @@ -19,14 +19,11 @@ package art.arcane.iris.core; import art.arcane.iris.Iris; +import art.arcane.iris.core.runtime.WorldDeletionQueue; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; import art.arcane.iris.core.tools.IrisCreator; -import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.iris.util.common.plugin.VolmitPlugin; import art.arcane.volmlib.util.bukkit.WorldIdentity; -import art.arcane.volmlib.util.io.IO; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.World; @@ -34,21 +31,38 @@ import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; import java.io.IOException; -import java.io.PrintWriter; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; -/** - * Persistent queue of world folders that must be deleted on the next startup, plus the startup - * drain that actually removes them. - */ -public final class PendingWorldDeleteQueue { +public final class PendingWorldDeleteQueue implements WorldDeletionQueue { private static final String PENDING_WORLD_DELETE_FILE = "pending-world-deletes.txt"; + private static final String EXACT_PREFIX = "exact:"; + private static final Pattern SAFE_LOGICAL_NAME = Pattern.compile("^[a-z0-9_-]+$"); + private static final Pattern QUARANTINE_NAME = Pattern.compile("^\\.iris-delete-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + private static final Set VANILLA_DIMENSION_ALIASES = Set.of("overworld", "the_nether", "the_end"); private final VolmitPlugin plugin; @@ -56,173 +70,413 @@ public final class PendingWorldDeleteQueue { this.plugin = plugin; } - public synchronized int queueWorldDeletionOnStartup(Collection worldNames) throws IOException { + @Override + public synchronized int queueExactForStartupDeletion(Collection worldNames) throws IOException { + return queueWorldDeletionOnStartup(worldNames, true); + } + + @Override + public synchronized int queueFamilyForStartupDeletion(Collection worldNames) throws IOException { + return queueWorldDeletionOnStartup(worldNames, false); + } + + private int queueWorldDeletionOnStartup(Collection worldNames, boolean exact) throws IOException { if (worldNames == null || worldNames.isEmpty()) { return 0; } - LinkedHashMap queue = loadPendingWorldDeleteMap(); - int before = queue.size(); - + File levelRoot = IrisWorldStorage.levelRoot(); + ArrayList normalizedNames = new ArrayList<>(worldNames.size()); for (String worldName : worldNames) { - String normalized = normalizeWorldName(worldName); + String normalized = normalizeQueueEntry(worldName, levelRoot.getName()); if (normalized == null) { - continue; + throw new IllegalArgumentException("Unsafe Iris world deletion target: " + worldName); } - queue.putIfAbsent(normalized.toLowerCase(Locale.ROOT), normalized); + normalizedNames.add(exact && !QUARANTINE_NAME.matcher(normalized).matches() + ? EXACT_PREFIX + normalized + : normalized); + } + + File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE); + LinkedHashMap queue = loadPendingWorldDeleteMap(queueFile, levelRoot.getName()); + int before = queue.size(); + for (String normalized : normalizedNames) { + mergeQueueEntry(queue, normalized); } if (queue.size() != before) { - writePendingWorldDeleteMap(queue); + writePendingWorldDeleteMap(queueFile, queue); } - return queue.size() - before; } - public void processPendingStartupWorldDeletes() { + public synchronized void processPendingStartupWorldDeletes() { try { - try { - int unregistered = IrisCreator.removeTransientStudioWorldsFromBukkitYml(); - if (unregistered > 0) { - Iris.info("Unregistered " + unregistered + " transient studio world(s) from bukkit.yml on startup."); - } - } catch (Throwable e) { - Iris.reportError("Failed to unregister transient studio worlds from bukkit.yml on startup.", e); - } + unregisterTransientStudioWorlds(); - LinkedHashMap queue = loadPendingWorldDeleteMap(); - for (String transientStudioWorld : TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot())) { - queue.putIfAbsent(transientStudioWorld.toLowerCase(Locale.ROOT), transientStudioWorld); + File levelRoot = IrisWorldStorage.levelRoot(); + File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE); + LinkedHashMap queue = loadPendingWorldDeleteMap(queueFile, levelRoot.getName()); + for (String discoveredName : discoverStartupWorldNames(levelRoot)) { + mergeQueueEntry(queue, discoveredName); } if (queue.isEmpty()) { + if (queueFile.exists()) { + writePendingWorldDeleteMap(queueFile, queue); + } return; } LinkedHashMap remaining = new LinkedHashMap<>(); for (String worldName : queue.values()) { - if (worldName.equalsIgnoreCase(ServerProperties.LEVEL_NAME)) { - Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is configured as level-name."); - continue; - } - - NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName); - World loaded = WorldIdentity.resolve(worldKey).orElse(null); - if (loaded != null) { - if (TransientWorldCleanupSupport.isTransientStudioWorldName(worldName)) { - try { - PlatformChunkGenerator generator = IrisToolbelt.access(loaded); - if (generator != null) { - generator.close(); - } - IrisToolbelt.evacuate(loaded); - Bukkit.unloadWorld(loaded, false); - Iris.info("Unloaded leftover studio world \"" + worldName + "\" for deletion."); - } catch (Throwable e) { - Iris.reportError("Failed to unload leftover studio world \"" + worldName + "\".", e); - } - - if (WorldIdentity.resolve(worldKey).isPresent()) { - Iris.warn("Studio world \"" + worldName + "\" is still loaded after unload; will retry next startup."); - remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); - continue; - } - } else { - Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded."); - remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); - continue; - } - } - - boolean foundAny = false; - boolean deletedAll = true; - for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { - File worldFolder = IrisWorldStorage.dimensionRoot(familyWorldName); - if (!worldFolder.exists()) { - continue; - } - - foundAny = true; - IO.delete(worldFolder); - if (worldFolder.exists()) { - deletedAll = false; - Iris.warn("Failed to delete queued world folder \"" + familyWorldName + "\". Retrying on next startup."); - } else { - Iris.info("Deleted queued world folder \"" + familyWorldName + "\"."); - } - } - - if (!foundAny) { - Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing)."); - continue; - } - - if (!deletedAll) { - remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); - continue; - } - } - - writePendingWorldDeleteMap(remaining); - } catch (Throwable e) { - Iris.error("Failed to process queued startup world deletions."); - Iris.reportError(e); - e.printStackTrace(); - } - } - - private LinkedHashMap loadPendingWorldDeleteMap() throws IOException { - LinkedHashMap queue = new LinkedHashMap<>(); - File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE); - if (!queueFile.exists()) { - return queue; - } - - try (BufferedReader reader = new BufferedReader(new FileReader(queueFile))) { - String line; - while ((line = reader.readLine()) != null) { - String normalized = normalizeWorldName(line); - if (normalized == null) { - continue; - } - queue.putIfAbsent(normalized.toLowerCase(Locale.ROOT), normalized); - } - } - - return queue; - } - - private void writePendingWorldDeleteMap(Map queue) throws IOException { - File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE); - if (queue.isEmpty()) { - if (queueFile.exists()) { - IO.delete(queueFile); - } - return; - } - - File parent = queueFile.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) { - throw new IOException("Failed to create queue directory: " + parent.getAbsolutePath()); - } - - try (PrintWriter writer = new PrintWriter(new FileWriter(queueFile))) { - for (String worldName : queue.values()) { - writer.println(worldName); + processEntry(levelRoot, worldName, remaining); } + writePendingWorldDeleteMap(queueFile, remaining); + } catch (Throwable failure) { + Iris.reportError("Failed to process queued startup world deletions.", failure); } } @Nullable - private static String normalizeWorldName(String worldName) { + static String normalizeQueueEntry(String worldName, String levelName) { if (worldName == null) { return null; } - String trimmed = worldName.trim(); - if (trimmed.isEmpty()) { + String candidate = worldName.trim(); + if (candidate.isEmpty()) { + return null; + } + if (QUARANTINE_NAME.matcher(candidate).matches()) { + return candidate; + } + + String logicalName = candidate.startsWith("iris:") ? candidate.substring("iris:".length()) : candidate; + if (!SAFE_LOGICAL_NAME.matcher(logicalName).matches()) { + return null; + } + String normalizedLevelName = Objects.requireNonNull(levelName, "levelName").trim().toLowerCase(Locale.ROOT); + if (VANILLA_DIMENSION_ALIASES.contains(logicalName) + || logicalName.equals(normalizedLevelName) + || logicalName.equals(normalizedLevelName + "_nether") + || logicalName.equals(normalizedLevelName + "_the_end")) { return null; } - return trimmed; + try { + NamespacedKey key = IrisWorldStorage.managedKeyFromName(candidate, normalizedLevelName); + return key.getKey().equals(logicalName) ? logicalName : null; + } catch (IllegalArgumentException failure) { + return null; + } + } + + static LinkedHashMap loadPendingWorldDeleteMap(File queueFile, String levelName) throws IOException { + LinkedHashMap queue = new LinkedHashMap<>(); + Path queuePath = Objects.requireNonNull(queueFile, "queueFile").toPath(); + if (!Files.exists(queuePath, LinkOption.NOFOLLOW_LINKS)) { + return queue; + } + + try (BufferedReader reader = Files.newBufferedReader(queuePath, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + String normalized = normalizeStoredQueueEntry(line, levelName); + if (normalized != null) { + mergeQueueEntry(queue, normalized); + } + } + } + return queue; + } + + @Nullable + private static String normalizeStoredQueueEntry(String storedEntry, String levelName) { + if (storedEntry == null) { + return null; + } + String candidate = storedEntry.trim(); + boolean exact = candidate.startsWith(EXACT_PREFIX); + String rawName = exact ? candidate.substring(EXACT_PREFIX.length()) : candidate; + String normalized = normalizeQueueEntry(rawName, levelName); + if (normalized == null || QUARANTINE_NAME.matcher(normalized).matches()) { + return normalized; + } + return exact ? EXACT_PREFIX + normalized : normalized; + } + + private static void mergeQueueEntry(Map queue, String storedEntry) { + String logicalKey = storedEntry.startsWith(EXACT_PREFIX) + ? storedEntry.substring(EXACT_PREFIX.length()) + : storedEntry; + String key = logicalKey.toLowerCase(Locale.ROOT); + String existing = queue.get(key); + if (existing == null || (existing.startsWith(EXACT_PREFIX) && !storedEntry.startsWith(EXACT_PREFIX))) { + queue.put(key, storedEntry); + } + } + + static void writePendingWorldDeleteMap(File queueFile, Map queue) throws IOException { + Path queuePath = Objects.requireNonNull(queueFile, "queueFile").toPath().toAbsolutePath().normalize(); + Path parent = queuePath.getParent(); + if (parent == null) { + throw new IOException("Queue file has no parent directory: " + queuePath); + } + Files.createDirectories(parent); + + StringBuilder content = new StringBuilder(); + for (String worldName : Objects.requireNonNull(queue, "queue").values()) { + content.append(worldName).append('\n'); + } + + Path temporary = parent.resolve(queuePath.getFileName() + ".tmp-" + UUID.randomUUID()); + IOException failure = null; + try { + byte[] bytes = content.toString().getBytes(StandardCharsets.UTF_8); + try (FileChannel channel = FileChannel.open( + temporary, + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE + )) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + + replaceQueueFile(temporary, queuePath); + forceDirectory(parent); + } catch (IOException writeFailure) { + failure = writeFailure; + throw writeFailure; + } finally { + try { + Files.deleteIfExists(temporary); + } catch (IOException cleanupFailure) { + if (failure != null) { + failure.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } + } + } + } + + static LinkedHashSet discoverStartupWorldNames(File levelRoot) throws IOException { + LinkedHashSet worldNames = new LinkedHashSet<>(); + Path root = Objects.requireNonNull(levelRoot, "levelRoot").toPath().toAbsolutePath().normalize(); + Path dimensions = root.resolve("dimensions"); + Path irisNamespace = dimensions.resolve("iris"); + if (!Files.exists(irisNamespace, LinkOption.NOFOLLOW_LINKS)) { + return worldNames; + } + if (Files.isSymbolicLink(dimensions) || Files.isSymbolicLink(irisNamespace)) { + throw new IOException("Iris dimension storage contains a symbolic link: " + irisNamespace); + } + if (!Files.isDirectory(irisNamespace, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Iris dimension storage is not a directory: " + irisNamespace); + } + + try (DirectoryStream children = Files.newDirectoryStream(irisNamespace)) { + for (Path child : children) { + if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + + String name = child.getFileName().toString(); + if (QUARANTINE_NAME.matcher(name).matches()) { + worldNames.add(name); + continue; + } + + String transientBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(name); + String normalized = normalizeQueueEntry(transientBaseName, root.getFileName().toString()); + if (normalized != null) { + worldNames.add(normalized); + } + } + } + return worldNames; + } + + static List resolveQueueEntryPaths(File levelRoot, String worldName) throws IOException { + QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName()); + List targets = entry.targets(levelRoot); + ArrayList paths = new ArrayList<>(targets.size()); + for (DeleteTarget target : targets) { + paths.add(target.path()); + } + return List.copyOf(paths); + } + + private static void replaceQueueFile(Path temporary, Path queuePath) throws IOException { + try { + Files.move(temporary, queuePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, queuePath, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static void unregisterTransientStudioWorlds() { + try { + int unregistered = IrisCreator.removeTransientStudioWorldsFromBukkitYml(); + if (unregistered > 0) { + Iris.info("Unregistered " + unregistered + " transient studio world(s) from bukkit.yml on startup."); + } + } catch (Throwable failure) { + Iris.reportError("Failed to unregister transient studio worlds from bukkit.yml on startup.", failure); + } + } + + private static void processEntry( + File levelRoot, + String worldName, + LinkedHashMap remaining + ) { + try { + QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName()); + List targets = entry.targets(levelRoot); + if (targets.stream().anyMatch(PendingWorldDeleteQueue::isLoaded)) { + Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded."); + remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); + return; + } + + boolean foundAny = false; + boolean deletedAll = true; + for (DeleteTarget target : targets) { + Path worldFolder = target.path(); + if (!Files.exists(worldFolder, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + if (Files.isSymbolicLink(worldFolder) || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Queued world target is not a safe directory: " + worldFolder); + } + + foundAny = true; + try { + deleteTree(worldFolder); + Iris.info("Deleted queued world folder \"" + worldFolder.getFileName() + "\"."); + } catch (IOException failure) { + deletedAll = false; + Iris.reportError("Failed to delete queued world folder \"" + worldFolder + "\".", failure); + } + } + + if (!foundAny) { + Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing)."); + return; + } + if (!deletedAll) { + remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); + } + } catch (Throwable failure) { + remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); + Iris.reportError("Failed to safely process queued world deletion for \"" + worldName + "\".", failure); + } + } + + private static boolean isLoaded(DeleteTarget target) { + if (target.key() != null && WorldIdentity.resolve(target.key()).isPresent()) { + return true; + } + + Path targetPath = target.path().toAbsolutePath().normalize(); + for (World world : Bukkit.getWorlds()) { + if (world.getWorldFolder().toPath().toAbsolutePath().normalize().equals(targetPath)) { + return true; + } + } + return false; + } + + private static void deleteTree(Path target) throws IOException { + Files.walkFileTree(target, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException { + if (failure != null) { + throw failure; + } + Files.delete(directory); + return FileVisitResult.CONTINUE; + } + }); + } + + private enum QueueEntryType { + EXACT, + LOGICAL, + QUARANTINE + } + + private record QueueEntry(String storedName, QueueEntryType type) { + private static QueueEntry parse(String worldName, String levelName) { + String stored = normalizeStoredQueueEntry(worldName, levelName); + if (stored == null) { + throw new IllegalArgumentException("Unsafe queued Iris world deletion target: " + worldName); + } + boolean exact = stored.startsWith(EXACT_PREFIX); + String normalized = exact ? stored.substring(EXACT_PREFIX.length()) : stored; + QueueEntryType type = QUARANTINE_NAME.matcher(normalized).matches() + ? QueueEntryType.QUARANTINE + : exact ? QueueEntryType.EXACT : QueueEntryType.LOGICAL; + return new QueueEntry(normalized, type); + } + + private List targets(File levelRoot) throws IOException { + if (type == QueueEntryType.QUARANTINE) { + return List.of(new DeleteTarget(null, requireSafeQuarantinePath(levelRoot, storedName))); + } + + if (type == QueueEntryType.EXACT) { + NamespacedKey key = IrisWorldStorage.managedKeyFromName(storedName, levelRoot.getName()); + Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath(); + return List.of(new DeleteTarget(key, path)); + } + + ArrayList targets = new ArrayList<>(3); + for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) { + NamespacedKey key = IrisWorldStorage.managedKeyFromName(familyWorldName, levelRoot.getName()); + Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath(); + targets.add(new DeleteTarget(key, path)); + } + return targets; + } + } + + private static Path requireSafeQuarantinePath(File levelRoot, String quarantineName) throws IOException { + if (!QUARANTINE_NAME.matcher(quarantineName).matches()) { + throw new IOException("Invalid Iris quarantine directory name: " + quarantineName); + } + + Path root = levelRoot.toPath().toAbsolutePath().normalize(); + Path dimensions = root.resolve("dimensions"); + Path irisNamespace = dimensions.resolve("iris"); + Path target = irisNamespace.resolve(quarantineName).normalize(); + if (!Objects.equals(target.getParent(), irisNamespace)) { + throw new IOException("Iris quarantine target escapes its namespace: " + target); + } + for (Path path : List.of(dimensions, irisNamespace, target)) { + if (Files.isSymbolicLink(path)) { + throw new IOException("Iris quarantine storage contains a symbolic link: " + path); + } + } + return target; + } + + private record DeleteTarget(@Nullable NamespacedKey key, Path path) { } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java index f278dfa45..6521aabab 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandDeveloper.java @@ -21,6 +21,7 @@ package art.arcane.iris.core.commands; import com.google.gson.JsonObject; import art.arcane.iris.Iris; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.runtime.ChunkClearer; import art.arcane.iris.core.runtime.GoldenHashScanner; @@ -216,7 +217,15 @@ public class CommandDeveloper implements DirectorExecutor { Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), true); } - Iris.service(StudioSVC.class).installIntoWorld(sender(), pack, folder); + try (LifecycleOperationCoordinator.Lease lease = LifecycleOperationCoordinator.get().acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH, + pack.getLoadKey() + )) { + Iris.service(StudioSVC.class).replaceIntoWorld(sender(), pack, folder); + } catch (LifecycleOperationCoordinator.BusyException e) { + sender().sendMessage(C.YELLOW + e.getMessage()); + } } @Director(description = "Test", descriptionKey = "iris.director.commanddeveloper.director.test") diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java index 3486e693a..14f04b483 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java @@ -125,6 +125,7 @@ public class CommandFind implements DirectorExecutor { String structureKey = structure == null ? "" : structure.trim(); Structure nativeStructure = resolveNativeStructure(structureKey); + boolean irisReplacement = false; if (nativeStructure != null) { IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve( e, structureKey, false); @@ -134,7 +135,8 @@ public class CommandFind implements DirectorExecutor { structureKey, decision.status())); return; } - if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + irisReplacement = decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS; + if (irisReplacement && !IrisStructureLocator.hasNativePlacement(e, structureKey)) { locateIrisStructure(e, structureKey, commandSender); return; } @@ -149,6 +151,9 @@ public class CommandFind implements DirectorExecutor { commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_UNKNOWN_STRUCTURE, MessageArgument.untrusted("structureKey", structureKey))); return; } + final boolean replacementLocate = irisReplacement; + final boolean explicitNativePlacement = IrisStructureLocator.hasNativePlacement( + e, structureKey); Player target = player(); if (target == null) { @@ -161,7 +166,8 @@ public class CommandFind implements DirectorExecutor { commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_LOCATING, MessageArgument.untrusted("structureKey", structureKey))); J.s(() -> { try { - if (!StructureReachability.isReachable(e, structureKey)) { + if (!replacementLocate && !explicitNativePlacement + && !StructureReachability.isReachable(e, structureKey)) { KList miss = StructureReachability.missingBiomeKeys(e, structureKey); sendStructureMessage(target, commandSender, C.YELLOW + structureKey + " cannot generate in this world (its required biomes are not produced by this pack" diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index 5b46a2fca..6a2565f34 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -19,16 +19,24 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; +import art.arcane.iris.core.BukkitWorldReconciler; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.DatapackInstallResult; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration; +import art.arcane.iris.core.lifecycle.IrisWorldRemovalService; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.core.pack.PackDownloader; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.volmlib.util.collection.KList; @@ -41,7 +49,6 @@ import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; import art.arcane.iris.util.common.director.specialhandlers.NullablePlayerHandler; import art.arcane.iris.util.common.format.C; -import art.arcane.volmlib.util.io.IO; import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; @@ -49,32 +56,44 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.NamespacedKey; import org.bukkit.World; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Objects; import java.util.Properties; import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; -import static art.arcane.iris.core.service.EditSVC.deletingWorld; import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML; import static org.bukkit.Bukkit.getServer; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisMessages; import art.arcane.volmlib.util.localization.MessageArgument; -import art.arcane.iris.core.localization.BukkitCommandMessages; import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; import art.arcane.iris.core.localization.RuntimeUiMessages; @Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command") public class CommandIris implements DirectorExecutor { + private static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L; + private CommandStudio studio; private CommandPregen pregen; private CommandObject object; @@ -85,10 +104,7 @@ public class CommandIris implements DirectorExecutor { private CommandPack pack; private CommandFind find; private CommandDatapack datapack; - public static boolean worldCreation = false; private static final AtomicReference mainWorld = new AtomicReference<>(); - String WorldEngine; - String worldNameToCheck = "YourWorldName"; VolmitSender sender = Iris.getSender(); @Director(description = "Create a new world", descriptionKey = "iris.director.commandiris.director.create_new_world", aliases = {"c"}) @@ -107,7 +123,15 @@ public class CommandIris implements DirectorExecutor { @Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", descriptionKey = "iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world", defaultValue = "false") boolean main ) { - String worldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(name)); + NamespacedKey worldKey; + try { + worldKey = IrisWorldStorage.managedKeyFromName(name); + IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey); + } catch (IllegalArgumentException e) { + sender().sendMessage(C.RED + e.getMessage()); + return; + } + String worldName = IrisWorldStorage.logicalName(worldKey); if (worldName.equalsIgnoreCase("iris")) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD)); @@ -145,7 +169,6 @@ public class CommandIris implements DirectorExecutor { } try { - worldCreation = true; IrisToolbelt.createWorld() .dimension(resolvedType) .name(worldName) @@ -160,22 +183,46 @@ public class CommandIris implements DirectorExecutor { })); } } catch (Throwable e) { + if (reportExpectedCreationInterruption(e)) { + return; + } sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_EXCEPTION_RAISED_DURING_CREATION_SEE_CONSOLE_MORE_DETAILS)); Iris.reportError("Exception raised during world creation for \"" + worldName + "\".", e); - worldCreation = false; return; } - worldCreation = false; sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_CREATED_YOUR_WORLD)); if (main) sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOUR_WORLD_WILL_AUTOMATICALLY_BE_SET_AS_MAIN_WORLD_WHEN)); } private boolean updateMainWorld(String newName) { + LifecycleOperationCoordinator.Lease lease; + try { + lease = LifecycleOperationCoordinator.get().acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_PROMOTE, + newName + ); + } catch (LifecycleOperationCoordinator.BusyException e) { + Iris.error("Could not promote Iris world \"" + newName + "\": " + e.getMessage()); + return false; + } + + try { + return updateMainWorldUnderLease(newName); + } finally { + lease.close(); + } + } + + private boolean updateMainWorldUnderLease(String newName) { try { File oldLevelRoot = IrisWorldStorage.levelRoot(); File worldContainer = oldLevelRoot.getParentFile(); - Properties data = ServerProperties.DATA; + if (worldContainer == null) { + throw new IllegalStateException("Current level folder has no world container."); + } + Properties data = new Properties(); try (FileInputStream in = new FileInputStream(ServerProperties.SERVER_PROPERTIES)) { data.load(in); } @@ -186,22 +233,6 @@ public class CommandIris implements DirectorExecutor { } File newLevelRoot = new File(worldContainer, newName); - if (!newLevelRoot.exists() && !newLevelRoot.mkdirs()) { - throw new IllegalStateException("Could not create target level folder: " + newLevelRoot.getAbsolutePath()); - } - - for (String sub : List.of("data", "datapacks", "players")) { - File source = new File(oldLevelRoot, sub); - if (!source.exists()) { - continue; - } - - IO.copyDirectory(source.toPath(), new File(newLevelRoot, sub).toPath()); - } - - File targetDimensionRoot = IrisWorldStorage.dimensionRoot(newLevelRoot, NamespacedKey.minecraft("overworld")); - IO.copyDirectory(sourceDimensionRoot.toPath(), targetDimensionRoot.toPath()); - World sourceWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(newName)).orElse(null); Long stagedSeed = IrisWorlds.readBukkitWorldSeed(newName); if (sourceWorld == null && stagedSeed == null) { @@ -210,8 +241,18 @@ public class CommandIris implements DirectorExecutor { long promotedSeed = sourceWorld == null ? stagedSeed : sourceWorld.getSeed(); data.setProperty("level-name", newName); data.setProperty("level-seed", Long.toString(promotedSeed)); - try (FileOutputStream out = new FileOutputStream(ServerProperties.SERVER_PROPERTIES)) { - data.store(out, null); + + try (MainWorldPublication publication = publishMainWorldFiles( + oldLevelRoot.toPath(), + sourceDimensionRoot.toPath(), + newLevelRoot.toPath() + )) { + writeServerPropertiesAtomically(ServerProperties.SERVER_PROPERTIES.toPath(), data); + publication.commit(); + } + synchronized (ServerProperties.DATA) { + ServerProperties.DATA.clear(); + ServerProperties.DATA.putAll(data); } return true; } catch (Throwable e) { @@ -221,57 +262,204 @@ public class CommandIris implements DirectorExecutor { } } - private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA)); - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP)); - - File worldFolder = IrisWorldStorage.dimensionRoot(name); - IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder); - if (installed == null) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey()))); - return false; + static MainWorldPublication publishMainWorldFiles( + Path currentLevelRoot, + Path sourceDimensionRoot, + Path targetLevelRoot + ) throws IOException { + Path current = Objects.requireNonNull(currentLevelRoot, "currentLevelRoot").toAbsolutePath().normalize(); + Path sourceDimension = Objects.requireNonNull(sourceDimensionRoot, "sourceDimensionRoot").toAbsolutePath().normalize(); + Path target = Objects.requireNonNull(targetLevelRoot, "targetLevelRoot").toAbsolutePath().normalize(); + Path worldContainer = current.getParent(); + Path sourceNamespace = current.resolve("dimensions/iris"); + if (worldContainer == null || !Objects.equals(target.getParent(), worldContainer)) { + throw new IOException("Promoted main world must be a direct child of the world container."); } - - if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) { - return false; + if (!Objects.equals(sourceDimension.getParent(), sourceNamespace)) { + throw new IOException("Promoted source must be a direct Iris dimension."); } + if (Objects.equals(current, target)) { + throw new IOException("Promoted main world cannot replace the current main world."); + } + if (Files.isSymbolicLink(worldContainer) + || Files.isSymbolicLink(current) + || !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Current world storage is missing or unsafe."); + } + if (Files.isSymbolicLink(sourceDimension) + || !Files.isDirectory(sourceDimension, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Promoted Iris dimension is missing or unsafe: " + sourceDimension); + } + requireAbsentMainWorldTarget(target); - if (main) { - if (updateMainWorld(name)) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name))); - } else { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD)); - return false; + Path stage = Files.createTempDirectory(worldContainer, "." + target.getFileName() + ".promoting-"); + boolean published = false; + try { + for (String subdirectory : List.of("data", "datapacks", "players")) { + Path source = current.resolve(subdirectory); + if (!Files.exists(source, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(source)) { + continue; + } + copyWorldTree(source, stage.resolve(subdirectory)); + } + + Path targetDimension = IrisWorldStorage.dimensionRoot( + stage.toFile(), + NamespacedKey.minecraft("overworld") + ).toPath(); + copyWorldTree(sourceDimension, targetDimension); + requireAbsentMainWorldTarget(target); + Files.move(stage, target); + published = true; + return new MainWorldPublication(target); + } finally { + if (!published) { + AtomicDirectoryPublisher.deleteTree(stage); } } + } - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed))); - if (main) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART)); + private static void requireAbsentMainWorldTarget(Path target) throws IOException { + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new FileAlreadyExistsException("Main-world target already exists: " + target); + } + } + + private static void copyWorldTree(Path source, Path target) throws IOException { + if (Files.isSymbolicLink(source)) { + throw new IOException("World data contains a symbolic link: " + source); + } + if (Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES); + return; + } + if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("World data contains an unsupported entry: " + source); + } + try (Stream entries = Files.walk(source)) { + for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) { + if (Files.isSymbolicLink(entry)) { + throw new IOException("World data contains a symbolic link: " + entry); + } + Path destination = target.resolve(source.relativize(entry)).normalize(); + if (!destination.startsWith(target)) { + throw new IOException("World data escapes its promotion stage: " + entry); + } + if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(destination); + } else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(destination.getParent()); + Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES); + } else { + throw new IOException("World data contains an unsupported entry: " + entry); + } + } + } + } + + private static void writeServerPropertiesAtomically(Path propertiesFile, Properties data) throws IOException { + Path target = propertiesFile.toAbsolutePath().normalize(); + Path parent = target.getParent(); + if (parent == null) { + throw new IOException("server.properties has no parent directory."); + } + Path stage = Files.createTempFile(parent, ".server.properties.promoting-", ".tmp"); + IOException operationFailure = null; + try { + try (FileOutputStream out = new FileOutputStream(stage.toFile())) { + data.store(out, null); + out.getFD().sync(); + } + try { + Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(stage, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + operationFailure = e; + throw e; + } finally { + try { + Files.deleteIfExists(stage); + } catch (IOException cleanupFailure) { + if (operationFailure != null) { + operationFailure.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } + } + } + } + + private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) { + NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(name); + LifecycleOperationCoordinator.Lease worldLease = null; + File worldFolder = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey); + try { + LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get(); + worldLease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + worldKey.toString()); + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA)); + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP)); + if (worldFolder.exists()) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS)); + return false; + } + + DatapackInstallResult datapackResult = ServerConfigurator.installDataPacksIfChanged(true); + if (!datapackResult.succeeded()) { + sender().sendMessage(C.RED + "Failed to compile the Iris datapack. No world files were staged."); + return false; + } + IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder); + if (installed == null) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey()))); + deleteDirectorySafely(worldFolder); + return false; + } + + if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) { + deleteDirectorySafely(worldFolder); + return false; + } + + if (main) { + if (updateMainWorldUnderLease(name)) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name))); + } else { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD)); + try { + BukkitWorldConfiguration.remove(BUKKIT_YML, name); + } catch (IOException e) { + Iris.reportError("Failed to roll back bukkit.yml after main-world staging failed.", e); + } + deleteDirectorySafely(worldFolder); + return false; + } + } + + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed))); + if (main) { + sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART)); + } + return true; + } catch (LifecycleOperationCoordinator.BusyException e) { + sender().sendMessage(C.YELLOW + e.getMessage()); + return false; + } finally { + if (worldLease != null) { + worldLease.close(); + } } - return true; } private boolean registerWorldInBukkitYml(String worldName, String dimension, Long seed) { String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(worldName)); - YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML); - ConfigurationSection worlds = yml.getConfigurationSection("worlds"); - if (worlds == null) { - worlds = yml.createSection("worlds"); - } - ConfigurationSection worldSection = worlds.getConfigurationSection(logicalWorldName); - if (worldSection == null) { - worldSection = worlds.createSection(logicalWorldName); - } - - String generator = "Iris:" + dimension; - worldSection.set("generator", generator); - if (seed != null) { - worldSection.set("seed", seed); - } - try { - yml.save(BUKKIT_YML); + BukkitWorldConfiguration.register(BUKKIT_YML, logicalWorldName, dimension, seed); Iris.info("Registered \"" + logicalWorldName + "\" in bukkit.yml"); return true; } catch (IOException e) { @@ -282,6 +470,31 @@ public class CommandIris implements DirectorExecutor { } } + private void deleteDirectorySafely(File directory) { + try { + AtomicDirectoryPublisher.deleteTree(directory.toPath()); + } catch (IOException e) { + Iris.reportError("Failed to roll back staged world folder \"" + directory.getAbsolutePath() + "\".", e); + } + } + + private boolean reportExpectedCreationInterruption(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof LifecycleOperationCoordinator.BusyException) { + sender().sendMessage(C.YELLOW + current.getMessage()); + return true; + } + current = current.getCause(); + } + String message = failure.getMessage(); + if (message != null && message.contains("queued a restart")) { + sender().sendMessage(C.YELLOW + message); + return true; + } + return false; + } + @Director(description = "Teleport to another world", descriptionKey = "iris.director.commandiris.director.teleport_another_world", aliases = {"tp"}, sync = true) public void teleport( @Param(description = "World to teleport to", descriptionKey = "iris.director.commandiris.param.world_teleport") @@ -368,78 +581,73 @@ public class CommandIris implements DirectorExecutor { @Director(description = "Remove an Iris world", descriptionKey = "iris.director.commandiris.director.remove_iris_world", aliases = {"rm"}, sync = true) public void remove( - @Param(description = "The world to remove", descriptionKey = "iris.director.commandiris.param.world_remove") - World world, + @Param(description = "The loaded or disk-only Iris world to remove", descriptionKey = "iris.director.commandiris.param.world_remove", customHandler = ManagedWorldNameHandler.class) + String world, @Param(description = "Whether to also remove the folder (if set to false, just does not load the world)", descriptionKey = "iris.director.commandiris.param.whether_also_remove_folder_if_set_false_just_does_not_load_world", defaultValue = "true") boolean delete ) { - if (!IrisToolbelt.isIrisWorld(world)) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList())))); - return; - } - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_REMOVING_WORLD, MessageArgument.untrusted("value", world.getName()))); - - if (!IrisToolbelt.evacuate(world)) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_EVACUATE_WORLD, MessageArgument.untrusted("value", world.getName()))); - return; - } - - if (!WorldLifecycleService.get().unload(world, false)) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD, MessageArgument.untrusted("value", world.getName()))); - return; - } - - try { - if (IrisToolbelt.removeWorld(world)) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_REMOVED_FROM_BUKKIT_YML, MessageArgument.untrusted("value", world.getName()))); - } else { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOOKS_LIKE_WORLD_WAS_ALREADY_REMOVED_FROM_BUKKIT_YML)); + VolmitSender responseSender = sender(); + responseSender.sendMessage(C.GRAY + "Removing Iris world '" + world + "'..."); + IrisWorldRemovalService.get().remove(world, delete).whenComplete((result, throwable) -> { + Runnable response = () -> reportRemovalResult(responseSender, world, result, throwable); + if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) { + return; } - } catch (IOException e) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_SAVE_BUKKIT_YML_BECAUSE, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); - Iris.reportError("Failed to remove world \"" + world.getName() + "\" from bukkit.yml.", e); - } - IrisToolbelt.evacuate(world, "Deleting world"); - deletingWorld = true; - if (!delete) { - deletingWorld = false; - return; - } - VolmitSender sender = sender(); - J.a(() -> { - int retries = 12; - - if (deleteDirectory(world.getWorldFolder())) { - sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_SUCCESSFULLY_REMOVED_WORLD_FOLDER)); - } else { - while(true){ - if (deleteDirectory(world.getWorldFolder())){ - sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_SUCCESSFULLY_REMOVED_WORLD_FOLDER_2)); - break; - } - retries--; - if (retries == 0){ - sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_FAILED_REMOVE_WORLD_FOLDER)); - break; - } - J.sleep(3000); - } - } - deletingWorld = false; + J.s(response); }); } - public static boolean deleteDirectory(File dir) { - if (dir.isDirectory()) { - File[] children = dir.listFiles(); - for (int i = 0; i < children.length; i++) { - boolean success = deleteDirectory(children[i]); - if (!success) { - return false; + private void reportRemovalResult( + VolmitSender responseSender, + String requestedWorld, + IrisWorldRemovalService.RemovalResult result, + Throwable throwable + ) { + if (throwable != null || result == null) { + Throwable failure = throwable == null + ? new IllegalStateException("World removal returned no result.") + : throwable; + responseSender.sendMessage(C.RED + "World removal failed unexpectedly; nothing further was deleted."); + Iris.reportError("Unexpected world removal failure for \"" + requestedWorld + "\".", failure); + return; + } + + switch (result.status()) { + case UNREGISTERED -> responseSender.sendMessage(C.GREEN + "Unloaded and unregistered '" + + result.target().logicalName() + "'; its files were preserved."); + case DELETED -> responseSender.sendMessage(C.GREEN + "Removed Iris world '" + + result.target().logicalName() + "' and deleted its folder."); + case DELETE_QUEUED -> responseSender.sendMessage(C.YELLOW + "Removed Iris world '" + + result.target().logicalName() + "'; its quarantined folder will be deleted at startup."); + case BUSY -> responseSender.sendMessage(C.YELLOW + "World changes are busy with " + + result.blockingOperation().kind().name().toLowerCase(Locale.ROOT) + " for '" + + result.blockingOperation().target() + "'. Try again when it completes."); + case INVALID_IDENTIFIER, PROTECTED_WORLD, NOT_IRIS_WORLD, UNSAFE_PATH, NOT_FOUND -> + responseSender.sendMessage(C.RED + removalFailureDetail(result)); + default -> { + responseSender.sendMessage(C.RED + "World removal stopped at " + + result.status().name().toLowerCase(Locale.ROOT) + ": " + removalFailureDetail(result)); + if (result.quarantineDirectory() != null) { + responseSender.sendMessage(C.YELLOW + "The recoverable world folder is " + + result.quarantineDirectory().toAbsolutePath() + "."); + } else if (result.configurationChanged() || result.registryChanged()) { + responseSender.sendMessage(C.YELLOW + "Removal changed registration state before stopping; " + + "the original world folder was not deleted."); + } + if (result.failure() != null) { + Iris.reportError("World removal failed for \"" + requestedWorld + "\" at " + + result.status().name() + ".", result.failure()); } } } - return dir.delete(); + } + + private String removalFailureDetail(IrisWorldRemovalService.RemovalResult result) { + Throwable failure = result.failure(); + if (failure == null || failure.getMessage() == null || failure.getMessage().isBlank()) { + return result.status().name().toLowerCase(Locale.ROOT).replace('_', ' '); + } + return failure.getMessage(); } @Director(description = "Toggle debug", descriptionKey = "iris.director.commandiris.director.toggle_debug") @@ -466,7 +674,6 @@ public class CommandIris implements DirectorExecutor { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("branch", branch), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : ""))); Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite); } - ServerConfigurator.installDataPacksIfChanged(true); } @Director(description = "Get metrics for your world", descriptionKey = "iris.director.commandiris.director.get_metrics_your_world", aliases = "measure", origin = DirectorOrigin.PLAYER) @@ -508,76 +715,187 @@ public class CommandIris implements DirectorExecutor { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS_2, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList())))); return; } - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UNLOADING_WORLD, MessageArgument.untrusted("value", world.getName()))); + VolmitSender responseSender = sender(); + responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UNLOADING_WORLD, MessageArgument.untrusted("value", world.getName()))); + LifecycleOperationCoordinator.Lease lease; try { - IrisToolbelt.evacuate(world); - boolean unloaded = WorldLifecycleService.get().unload(world, false); - if (unloaded) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_UNLOADED_SUCCESSFULLY)); - } else { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_2)); - } + lease = LifecycleOperationCoordinator.get().acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_UNLOAD, + WorldIdentity.serialize(world) + ); + } catch (LifecycleOperationCoordinator.BusyException e) { + responseSender.sendMessage(C.YELLOW + e.getMessage()); + return; + } + PlatformChunkGenerator generator = IrisToolbelt.access(world); + IrisToolbelt.beginWorldMaintenance(world, "world-unload", true); + try { + AtomicBoolean terminalTimeout = new AtomicBoolean(false); + CompletableFuture sequence = IrisToolbelt.evacuateAsync(world) + .thenCompose(evacuated -> { + if (terminalTimeout.get()) { + return CompletableFuture.failedFuture(new TimeoutException( + "World unload stopped after its terminal timeout.")); + } + if (!Boolean.TRUE.equals(evacuated)) { + return CompletableFuture.completedFuture(false); + } + return WorldLifecycleService.get().unloadAsync(world, true); + }) + .thenCompose(unloaded -> { + if (terminalTimeout.get()) { + return CompletableFuture.failedFuture(new TimeoutException( + "World unload stopped after its terminal timeout.")); + } + if (!Boolean.TRUE.equals(unloaded) || generator == null) { + return CompletableFuture.completedFuture(Boolean.TRUE.equals(unloaded)); + } + return generator.closeAsync().thenApply(ignored -> true); + }); + guardUnloadCompletion(sequence, terminalTimeout, world.getName()) + .whenComplete((unloaded, throwable) -> { + IrisToolbelt.endWorldMaintenance(world, "world-unload"); + lease.close(); + Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable); + if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) { + return; + } + J.s(response); + }); } catch (Exception e) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); + IrisToolbelt.endWorldMaintenance(world, "world-unload"); + lease.close(); + responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e); } } + private CompletableFuture guardUnloadCompletion( + CompletableFuture source, + AtomicBoolean terminalTimeout, + String worldName + ) { + CompletableFuture guarded = new CompletableFuture<>(); + AtomicBoolean settled = new AtomicBoolean(false); + source.whenComplete((unloaded, throwable) -> { + if (!settled.compareAndSet(false, true)) { + return; + } + if (throwable == null) { + guarded.complete(Boolean.TRUE.equals(unloaded)); + } else { + guarded.completeExceptionally(throwable); + } + }); + CompletableFuture.delayedExecutor(WORLD_UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> { + if (!settled.compareAndSet(false, true)) { + return; + } + terminalTimeout.set(true); + TimeoutException timeout = new TimeoutException( + "World unload did not settle within " + WORLD_UNLOAD_TIMEOUT_SECONDS + + " seconds for \"" + worldName + "\"."); + ServerConfigurator.restart("World unload timed out for \"" + worldName + "\"."); + guarded.completeExceptionally(timeout); + }); + return guarded; + } + + private void reportUnloadResult(VolmitSender responseSender, World world, Boolean unloaded, Throwable throwable) { + if (throwable != null) { + responseSender.sendMessage(IrisLanguage.text( + BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, + MessageArgument.untrusted("value", String.valueOf(throwable.getMessage())) + )); + Iris.reportError("Failed to unload world \"" + world.getName() + "\".", throwable); + return; + } + if (Boolean.TRUE.equals(unloaded)) { + responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_UNLOADED_SUCCESSFULLY)); + } else { + responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_2)); + } + } + @Director(description = "Load an Iris World", descriptionKey = "iris.director.commandiris.director.load_iris_world", origin = DirectorOrigin.PLAYER, sync = true, aliases = {"import"}) public void loadWorld( - @Param(description = "The name of the world to load", descriptionKey = "iris.director.commandiris.param.name_world_load") + @Param( + description = "The name of the world to load", + descriptionKey = "iris.director.commandiris.param.name_world_load", + customHandler = ManagedWorldNameHandler.class) String world ) { - String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(world)); - worldNameToCheck = logicalWorldName; - boolean worldExists = doesWorldExist(worldNameToCheck); - WorldEngine = logicalWorldName; + NamespacedKey worldKey; + try { + worldKey = IrisWorldStorage.managedKeyFromName(world); + IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey); + } catch (IllegalArgumentException failure) { + sender().sendMessage(C.RED + failure.getMessage()); + return; + } + String logicalWorldName = IrisWorldStorage.logicalName(worldKey); + boolean worldExists = doesWorldExist(logicalWorldName); if (!worldExists) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOESNT_EXIST_ON_SERVER, MessageArgument.untrusted("logicalWorldName", logicalWorldName))); return; } - File directory = new File(IrisWorldStorage.packRoot(IrisWorldStorage.keyFromName(logicalWorldName)), "dimensions"); - - String dimension = null; - if (directory.exists() && directory.isDirectory()) { - File[] files = directory.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isFile()) { - String fileName = file.getName(); - if (fileName.endsWith(".json")) { - dimension = fileName.substring(0, fileName.length() - 5); - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_GENERATOR, MessageArgument.untrusted("dimension", dimension))); - } + VolmitSender responseSender = sender(); + responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADING_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName))); + Iris.instance.worldReconciler() + .loadWorld(BUKKIT_YML, worldKey.toString()) + .whenComplete((result, failure) -> { + Runnable response = () -> reportLoadWorldResult( + responseSender, + logicalWorldName, + result, + failure); + if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) { + return; } - } - } - } else { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_IS_NOT_IRIS_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName))); - return; - } - - if (dimension == null) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_COULD_NOT_DETERMINE_IRIS_DIMENSION, MessageArgument.untrusted("logicalWorldName", logicalWorldName))); - return; - } - - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADING_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName))); - - if (!registerWorldInBukkitYml(logicalWorldName, dimension, null)) { - return; - } - - if (J.isFolia()) { - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FOLIA_CANNOT_LOAD_NEW_WORLDS_AT_RUNTIME_RESTART_SERVER_LOAD, MessageArgument.untrusted("logicalWorldName", logicalWorldName))); - return; - } - - Iris.instance.worldReconciler().checkForBukkitWorlds(logicalWorldName::equals); - sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADED_SUCCESSFULLY, MessageArgument.untrusted("logicalWorldName", logicalWorldName))); + J.s(response); + }); } + + private void reportLoadWorldResult( + VolmitSender responseSender, + String logicalWorldName, + BukkitWorldReconciler.LoadResult result, + Throwable failure + ) { + if (failure != null) { + responseSender.sendMessage(C.RED + "Failed to load Iris world \"" + logicalWorldName + "\": " + failure.getMessage()); + Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", failure); + return; + } + if (result == null) { + IllegalStateException missingResult = new IllegalStateException("World load completed without a result."); + responseSender.sendMessage(C.RED + missingResult.getMessage()); + Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", missingResult); + return; + } + if (result.succeeded()) { + responseSender.sendMessage(IrisLanguage.text( + BukkitCommandMessagesExtended.COMMAND_IRIS_LOADED_SUCCESSFULLY, + MessageArgument.untrusted("logicalWorldName", logicalWorldName))); + return; + } + + C color = result.status() == BukkitWorldReconciler.ReconciliationStatus.BUSY + || result.status() == BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED + ? C.YELLOW + : C.RED; + responseSender.sendMessage(color + result.message()); + Throwable resultFailure = result.failure(); + if (resultFailure != null + && result.status() != BukkitWorldReconciler.ReconciliationStatus.BUSY + && result.status() != BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED) { + Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", resultFailure); + } + } + @Director(description = "Evacuate an iris world", descriptionKey = "iris.director.commandiris.director.evacuate_iris_world", origin = DirectorOrigin.PLAYER, sync = true) public void evacuate( @Param(description = "Evacuate the world", descriptionKey = "iris.director.commandiris.param.evacuate_world") @@ -596,6 +914,54 @@ public class CommandIris implements DirectorExecutor { return worldDirectory.exists() && worldDirectory.isDirectory(); } + public static class ManagedWorldNameHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + Set options = new LinkedHashSet<>(); + for (World world : Bukkit.getWorlds()) { + if (IrisToolbelt.isIrisWorld(world)) { + options.add(IrisWorldStorage.logicalName(world)); + } + } + for (String identity : IrisWorlds.get().getWorlds().keySet()) { + try { + options.add(IrisWorldStorage.logicalName(WorldIdentity.parse(identity))); + } catch (IllegalArgumentException ignored) { + } + } + + File namespace = new File(IrisWorldStorage.levelRoot(), "dimensions/iris"); + File[] diskWorlds = namespace.listFiles(File::isDirectory); + if (diskWorlds != null) { + for (File diskWorld : diskWorlds) { + if (!Files.isSymbolicLink(diskWorld.toPath()) + && diskWorld.getName().matches("[a-z0-9_-]+")) { + options.add(diskWorld.getName()); + } + } + } + return new KList<>(options); + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String in, boolean force) throws DirectorParsingException { + if (in == null || in.isBlank()) { + throw new DirectorParsingException("World identifier cannot be empty"); + } + return in.trim(); + } + + @Override + public boolean supports(Class type) { + return type == String.class; + } + } + public static class PackDimensionTypeHandler implements DirectorParameterHandler { @Override public KList getPossibilities() { @@ -603,28 +969,21 @@ public class CommandIris implements DirectorExecutor { options.add("default"); File packsFolder = Iris.instance.getDataFolder("packs"); - File[] packs = packsFolder.listFiles(); - if (packs != null) { - for (File pack : packs) { - if (pack == null || !pack.isDirectory()) { - continue; - } + for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) { + options.add(pack.getName()); - options.add(pack.getName()); - - try { - IrisData data = IrisData.get(pack); - for (String key : data.getDimensionLoader().getPossibleKeys()) { - options.add(key); - options.add(pack.getName() + ":" + key); - } - } catch (Throwable ex) { - Iris.warn("Failed to read dimension keys from pack %s: %s%s", - pack.getName(), - ex.getClass().getSimpleName(), - ex.getMessage() == null ? "" : " - " + ex.getMessage()); - Iris.reportError(ex); + try { + IrisData data = IrisData.get(pack); + for (String key : data.getDimensionLoader().getPossibleKeys()) { + options.add(key); + options.add(pack.getName() + ":" + key); } + } catch (Throwable ex) { + Iris.warn("Failed to read dimension keys from pack %s: %s%s", + pack.getName(), + ex.getClass().getSimpleName(), + ex.getMessage() == null ? "" : " - " + ex.getMessage()); + Iris.reportError(ex); } } @@ -650,4 +1009,32 @@ public class CommandIris implements DirectorExecutor { return type == String.class; } } + + static final class MainWorldPublication implements AutoCloseable { + private final Path target; + private boolean committed; + private boolean closed; + + MainWorldPublication(Path target) { + this.target = target; + } + + void commit() { + if (closed) { + throw new IllegalStateException("Main-world publication is already closed."); + } + committed = true; + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + if (!committed) { + AtomicDirectoryPublisher.deleteTree(target); + } + } + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java index 2d0ad45e4..2af7c9ca1 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java @@ -22,6 +22,7 @@ import art.arcane.iris.Iris; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.link.WorldEditLink; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.runtime.ObjectStudioActivation; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.service.ObjectSVC; @@ -73,7 +74,6 @@ import java.io.File; import java.io.IOException; import java.text.NumberFormat; import java.util.ArrayList; -import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; @@ -109,25 +109,20 @@ public class CommandObject implements DirectorExecutor { sources.put(data.getDataFolder().getName(), data); } else { File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder(); - File[] packs = workspace == null ? null : workspace.listFiles(); - if (packs != null) { - Arrays.sort(packs, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); - for (File pack : packs) { - if (!pack.isDirectory()) continue; - File dimensionsDir = new File(pack, "dimensions"); - if (!dimensionsDir.isDirectory()) continue; - IrisData data = IrisData.get(pack); - String[] keys = data.getObjectLoader().getPossibleKeys(); - if (keys == null || keys.length == 0) continue; - sources.put(pack.getName(), data); - if (hostDimension == null) { - File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json")); - if (dimFiles != null && dimFiles.length > 0) { - String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", ""); - IrisDimension loaded = data.getDimensionLoader().load(loadKey); - if (loaded != null) { - hostDimension = loaded; - } + for (File pack : PackDirectoryResolver.listVisiblePackDirectories(workspace)) { + File dimensionsDir = new File(pack, "dimensions"); + if (!dimensionsDir.isDirectory()) continue; + IrisData data = IrisData.get(pack); + String[] keys = data.getObjectLoader().getPossibleKeys(); + if (keys == null || keys.length == 0) continue; + sources.put(pack.getName(), data); + if (hostDimension == null) { + File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json")); + if (dimFiles != null && dimFiles.length > 0) { + String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", ""); + IrisDimension loaded = data.getDimensionLoader().load(loadKey); + if (loaded != null) { + hostDimension = loaded; } } } @@ -394,11 +389,7 @@ public class CommandObject implements DirectorExecutor { private static List resolveFromPacks(String target) { List out = new ArrayList<>(); File packsFolder = Iris.instance.getDataFolder("packs"); - File[] packs = packsFolder.listFiles(File::isDirectory); - if (packs == null) { - return out; - } - for (File pack : packs) { + for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) { File objectsRoot = new File(pack, "objects"); if (!objectsRoot.isDirectory()) { continue; diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java index f08e96701..4c83597af 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java @@ -52,8 +52,8 @@ public class CommandPack implements DirectorExecutor { } if (pack == null || pack.isBlank()) { - File[] dirs = packsRoot.listFiles(File::isDirectory); - if (dirs == null || dirs.length == 0) { + List dirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); + if (dirs.isEmpty()) { s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_PACKS_VALIDATE)); return; } @@ -64,7 +64,7 @@ public class CommandPack implements DirectorExecutor { broken++; } } - s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.length)))); + s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.size())))); return; } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java index 8071c5502..84d4a78dd 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java @@ -153,6 +153,11 @@ public class CommandStructure implements DirectorExecutor { } } } + for (String placedKey : IrisStructureLocator.placedKeys(engine)) { + if (!structureKeys.contains(placedKey)) { + structureKeys.add(placedKey); + } + } VolmitSender commandSender = sender(); Player target = senderIsPlayer ? player() : null; commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STRUCTURE_VERIFYING_STRUCTURES_FROM_WITHIN_CHUNKS, MessageArgument.untrusted("value", world.getName()), MessageArgument.untrusted("value2", center.getBlockX()), MessageArgument.untrusted("value3", center.getBlockZ()), MessageArgument.untrusted("searchRadius", searchRadius))); @@ -169,7 +174,8 @@ public class CommandStructure implements DirectorExecutor { for (String keyName : structureKeys) { IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, keyName, false); decisions.put(keyName, decision); - requiresNativeReachability |= decision.generate(); + requiresNativeReachability |= !IrisStructureLocator.isPlaced(engine, keyName) + && decision.generate(); } Set reachable = Set.of(); if (requiresNativeReachability) { @@ -190,7 +196,7 @@ public class CommandStructure implements DirectorExecutor { int errors = 0; for (String keyName : structureKeys) { IrisNativeStructureDecision decision = decisions.get(keyName); - if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + if (IrisStructureLocator.isPlaced(engine, keyName)) { try { IrisStructureLocator.LocateResult result = IrisStructureLocator.locate(engine, keyName, centerX, centerZ, searchRadius); @@ -205,7 +211,7 @@ public class CommandStructure implements DirectorExecutor { continue; } located++; - messages.add(C.AQUA + "[iris] " + C.WHITE + keyName + C.GREEN + " @ " + messages.add(C.AQUA + "[iris-planned] " + C.WHITE + keyName + C.GREEN + " @ " + result.originX() + "," + result.baseY() + "," + result.originZ()); } catch (Throwable error) { errors++; @@ -230,7 +236,7 @@ public class CommandStructure implements DirectorExecutor { nativeEligible++; messages.add(C.GREEN + "[native-eligible] " + C.WHITE + keyName); } - messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placements located, " + messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placement plans found, " + C.WHITE + nativeEligible + C.GREEN + " native structures eligible, " + C.WHITE + disabled + C.GREEN + " disabled by policy, " + C.WHITE + unreachable + C.GREEN + " biome-unreachable, " diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 253ab31b3..188e1045b 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -229,29 +229,21 @@ public class CommandStudio implements DirectorExecutor { } } - @Director(description = "Create a new studio project", descriptionKey = "iris.director.commandstudio.director.create_new_studio_project", aliases = "+", sync = true) + @Director(description = "Create a new studio project", descriptionKey = "iris.director.commandstudio.director.create_new_studio_project", aliases = "+") public void create( @Param(description = "The name of this new Iris Project.", descriptionKey = "iris.director.commandstudio.param.name_this_new_iris_project", defaultValue = "studio") String name, @Param( description = "Copy the contents of an existing project in your packs folder and use it as a template in this new project.", descriptionKey = "iris.director.commandstudio.param.copy_contents_existing_project_your_packs_folder_use_it_as_template_this", + defaultValue = "null", contextual = true, customHandler = NullableDimensionHandler.class ) IrisDimension template) { - String projectName = name; - if (name.equals("studio")) { - File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder(); - int suffix = 2; - while (new File(workspace, projectName).exists()) { - projectName = "studio" + suffix++; - } - } - if (template != null) { - Iris.service(StudioSVC.class).create(sender(), projectName, template.getLoadKey()); + Iris.service(StudioSVC.class).create(sender(), name, template); } else { - Iris.service(StudioSVC.class).create(sender(), projectName); + Iris.service(StudioSVC.class).create(sender(), name); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/BukkitWorldReconcilerTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/BukkitWorldReconcilerTest.java new file mode 100644 index 000000000..46afc6276 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/BukkitWorldReconcilerTest.java @@ -0,0 +1,362 @@ +package art.arcane.iris.core; + +import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.lang.reflect.Proxy; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +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 BukkitWorldReconcilerTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void loadLeaseCoversRegistrationThroughExactWorldCompletion() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + LifecycleOperationCoordinator coordinator = coordinator(); + FakeBackend backend = new FakeBackend(); + CompletableFuture creation = new CompletableFuture<>(); + backend.creation = creation; + World exactWorld = world(backend.worldKey); + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator); + + CompletableFuture load = reconciler.loadWorld(configuration, backend.worldKey.toString()); + + assertFalse(load.isDone()); + assertEquals(LifecycleOperationCoordinator.OperationKind.WORLD_LOAD, + coordinator.active(LifecycleOperationCoordinator.Domain.WORLD_MUTATION) + .orElseThrow() + .kind()); + assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration) + .getString("worlds.probe.generator")); + assertThrows(LifecycleOperationCoordinator.BusyException.class, + () -> coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE, + backend.worldKey.toString())); + + backend.loaded = Optional.of(exactWorld); + creation.complete(exactWorld); + BukkitWorldReconciler.LoadResult result = load.join(); + + assertTrue(result.succeeded()); + assertEquals(BukkitWorldReconciler.ReconciliationStatus.LOADED, result.status()); + assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration()); + assertFalse(result.rollbackAttempted()); + assertTrue(coordinator.isIdle()); + } + + @Test + public void failedCreationRollsBackOnlyNewRegistration() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + FakeBackend backend = new FakeBackend(); + backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed")); + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator()); + + BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join(); + + assertEquals(BukkitWorldReconciler.ReconciliationStatus.CREATE_FAILED, result.status()); + assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration()); + assertTrue(result.rollbackAttempted()); + assertTrue(result.rollbackSucceeded()); + assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe")); + } + + @Test + public void failedCreationPreservesPreexistingMatchingRegistration() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L); + FakeBackend backend = new FakeBackend(); + backend.configuredSeed = 1337L; + backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed")); + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator()); + + BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join(); + + assertEquals(BukkitWorldConfiguration.Registration.UNCHANGED, result.registration()); + assertFalse(result.rollbackAttempted()); + assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration) + .getString("worlds.probe.generator")); + assertEquals(1337L, YamlConfiguration.loadConfiguration(configuration) + .getLong("worlds.probe.seed")); + } + + @Test + public void rollbackDoesNotRemoveARegistrationChangedDuringCreation() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + FakeBackend backend = new FakeBackend(); + CompletableFuture creation = new CompletableFuture<>(); + backend.creation = creation; + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator()); + CompletableFuture load = reconciler + .loadWorld(configuration, backend.worldKey.toString()); + YamlConfiguration changed = YamlConfiguration.loadConfiguration(configuration); + changed.set("worlds.probe.generator", "Other:generator"); + changed.save(configuration); + + creation.completeExceptionally(new IllegalStateException("create failed")); + BukkitWorldReconciler.LoadResult result = load.join(); + + assertTrue(result.rollbackAttempted()); + assertFalse(result.rollbackSucceeded()); + assertEquals("Other:generator", YamlConfiguration.loadConfiguration(configuration) + .getString("worlds.probe.generator")); + } + + @Test + public void mismatchedCreatedWorldIsNotReportedAsLoaded() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + FakeBackend backend = new FakeBackend(); + backend.creation = CompletableFuture.completedFuture(world(new NamespacedKey("iris", "other"))); + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator()); + + BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join(); + + assertFalse(result.succeeded()); + assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status()); + assertTrue(result.rollbackSucceeded()); + assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe")); + } + + @Test + public void mismatchedResolvedWorldIsNotReportedAsLoaded() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + FakeBackend backend = new FakeBackend(); + World exactCreatedWorld = world(backend.worldKey); + backend.creation = CompletableFuture.completedFuture(exactCreatedWorld); + backend.loaded = Optional.of(world(new NamespacedKey("iris", "other"))); + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator()); + + BukkitWorldReconciler.LoadResult result = reconciler + .loadWorld(configuration, backend.worldKey.toString()) + .join(); + + assertFalse(result.succeeded()); + assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status()); + assertTrue(result.rollbackSucceeded()); + } + + @Test + public void exactNonIrisWorldIsAnIdentityConflict() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + FakeBackend backend = new FakeBackend(); + backend.loaded = Optional.of(world(backend.worldKey)); + backend.irisWorld = false; + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator()); + + BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join(); + + assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_CONFLICT, result.status()); + assertEquals(0, backend.createCount.get()); + assertTrue(result.rollbackSucceeded()); + } + + @Test + public void unresolvedDimensionAndBusyLifecycleDoNotTouchConfiguration() throws Exception { + File unresolvedConfiguration = temporaryFolder.newFile("unresolved.yml"); + FakeBackend unresolvedBackend = new FakeBackend(); + unresolvedBackend.dimensionResolution = BukkitWorldReconciler.DimensionResolution.failed( + new IllegalStateException("ambiguous")); + BukkitWorldReconciler unresolved = new BukkitWorldReconciler(unresolvedBackend, coordinator()); + + BukkitWorldReconciler.LoadResult unresolvedResult = unresolved + .loadWorld(unresolvedConfiguration, unresolvedBackend.worldKey.toString()) + .join(); + + assertEquals(BukkitWorldReconciler.ReconciliationStatus.DIMENSION_UNRESOLVED, unresolvedResult.status()); + assertEquals(0L, unresolvedConfiguration.length()); + + File busyConfiguration = temporaryFolder.newFile("busy.yml"); + LifecycleOperationCoordinator coordinator = coordinator(); + LifecycleOperationCoordinator.Lease removal = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE, + "iris:other"); + FakeBackend busyBackend = new FakeBackend(); + BukkitWorldReconciler busy = new BukkitWorldReconciler(busyBackend, coordinator); + try { + BukkitWorldReconciler.LoadResult busyResult = busy.loadWorld(busyConfiguration, busyBackend.worldKey.toString()).join(); + assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, busyResult.status()); + assertEquals(0, busyBackend.createCount.get()); + assertEquals(0L, busyConfiguration.length()); + } finally { + removal.close(); + } + } + + @Test + public void pendingRestartRefusesWorldLoadBeforeRegistration() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + LifecycleOperationCoordinator coordinator = coordinator(); + assertTrue(coordinator.quiesceForRestart(() -> { + })); + FakeBackend backend = new FakeBackend(); + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator); + + BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join(); + + assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, result.status()); + assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART, + ((LifecycleOperationCoordinator.BusyException) result.failure()).operationKind()); + assertEquals(0, backend.createCount.get()); + assertEquals(0L, configuration.length()); + } + + @Test + public void terminalCreateTimeoutWinsOverLateWorldCompletion() { + NamespacedKey worldKey = new NamespacedKey("iris", "probe"); + CompletableFuture source = new CompletableFuture<>(); + AtomicInteger timeoutActions = new AtomicInteger(); + + CompletableFuture guarded = BukkitWorldReconciler.guardCreateCompletion( + source, + worldKey, + 1L, + timeoutActions::incrementAndGet); + + CompletionException failure = assertThrows(CompletionException.class, guarded::join); + assertTrue(failure.getCause() instanceof TimeoutException); + assertEquals(1, timeoutActions.get()); + source.complete(world(worldKey)); + assertTrue(guarded.isCompletedExceptionally()); + } + + @Test + public void timedOutCreationPreservesNewRegistrationForRestartReconciliation() throws Exception { + File configuration = temporaryFolder.newFile("timeout.yml"); + FakeBackend backend = new FakeBackend(); + backend.creation = CompletableFuture.failedFuture(new TimeoutException("create timed out")); + BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator()); + + BukkitWorldReconciler.LoadResult result = reconciler + .loadWorld(configuration, backend.worldKey.toString()) + .join(); + + assertEquals(BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED, result.status()); + assertFalse(result.rollbackAttempted()); + assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration) + .getString("worlds.probe.generator")); + } + + private static LifecycleOperationCoordinator coordinator() throws Exception { + Constructor constructor = LifecycleOperationCoordinator.class + .getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private static World world(NamespacedKey worldKey) { + return (World) Proxy.newProxyInstance( + BukkitWorldReconcilerTest.class.getClassLoader(), + new Class[]{World.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getKey" -> worldKey; + case "getName" -> worldKey.getKey(); + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + case "toString" -> worldKey.toString(); + default -> defaultValue(method.getReturnType()); + }); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + if (type == byte.class) { + return (byte) 0; + } + if (type == short.class) { + return (short) 0; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + if (type == float.class) { + return 0F; + } + if (type == double.class) { + return 0D; + } + throw new IllegalStateException("Unsupported primitive type: " + type); + } + + private static final class FakeBackend implements BukkitWorldReconciler.Backend { + private final NamespacedKey worldKey; + private final AtomicInteger createCount; + private CompletableFuture creation; + private Optional loaded; + private boolean irisWorld; + private Long configuredSeed; + private BukkitWorldReconciler.DimensionResolution dimensionResolution; + + private FakeBackend() { + worldKey = new NamespacedKey("iris", "probe"); + createCount = new AtomicInteger(); + creation = CompletableFuture.completedFuture(null); + loaded = Optional.empty(); + irisWorld = true; + configuredSeed = null; + dimensionResolution = BukkitWorldReconciler.DimensionResolution.resolved("overworld"); + } + + @Override + public Map configuredWorlds() { + return Map.of("probe", "overworld"); + } + + @Override + public Long configuredSeed(String worldName) { + return configuredSeed; + } + + @Override + public Optional loadedWorld(NamespacedKey requestedWorldKey) { + return worldKey.equals(requestedWorldKey) ? loaded : Optional.empty(); + } + + @Override + public CompletableFuture createWorld(NamespacedKey requestedWorldKey, String dimension, Long seed) { + createCount.incrementAndGet(); + return creation; + } + + @Override + public boolean isIrisWorld(World world) { + return irisWorld; + } + + @Override + public BukkitWorldReconciler.DimensionResolution resolveDimension(NamespacedKey requestedWorldKey) { + return dimensionResolution; + } + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java new file mode 100644 index 000000000..420be0cb4 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java @@ -0,0 +1,176 @@ +package art.arcane.iris.core; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class PendingWorldDeleteQueueTest { + private static final String QUARANTINE_NAME = ".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f45"; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsOnlyCanonicalManagedNamesAndStrictQuarantines() { + assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("alpha", "world")); + assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("iris:alpha", "world")); + assertEquals(QUARANTINE_NAME, PendingWorldDeleteQueue.normalizeQueueEntry(QUARANTINE_NAME, "world")); + + for (String rejected : List.of( + "world", + "world_nether", + "world_the_end", + "overworld", + "the_nether", + "the_end", + "minecraft:overworld", + "minecraft:the_nether", + "minecraft:the_end", + "Alpha", + "alpha beta", + "../alpha", + "alpha/beta", + ".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f4", + ".iris-delete-6A4FD7FD-8E75-4F2F-B9FA-523B90C41F45" + )) { + assertNull(rejected, PendingWorldDeleteQueue.normalizeQueueEntry(rejected, "world")); + } + } + + @Test + public void loadFiltersUnsafeEntriesAndCanonicalizesDuplicates() throws IOException { + File queueFile = temporaryFolder.newFile("pending-world-deletes.txt"); + Files.writeString( + queueFile.toPath(), + String.join("\n", "alpha", "iris:alpha", "world", "../escape", QUARANTINE_NAME), + StandardCharsets.UTF_8 + ); + + LinkedHashMap queue = PendingWorldDeleteQueue.loadPendingWorldDeleteMap(queueFile, "world"); + + assertEquals(List.of("alpha", QUARANTINE_NAME), List.copyOf(queue.values())); + } + + @Test + public void queueFileReplacementIsCompleteAndLeavesNoTemporaryFile() throws IOException { + File queueFile = new File(temporaryFolder.getRoot(), "state/pending-world-deletes.txt"); + LinkedHashMap first = new LinkedHashMap<>(); + first.put("alpha", "alpha"); + first.put(QUARANTINE_NAME, QUARANTINE_NAME); + PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, first); + + assertEquals("alpha\n" + QUARANTINE_NAME + "\n", Files.readString(queueFile.toPath())); + + LinkedHashMap replacement = new LinkedHashMap<>(); + replacement.put("beta", "beta"); + PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, replacement); + + assertEquals("beta\n", Files.readString(queueFile.toPath())); + try (Stream files = Files.list(queueFile.toPath().getParent())) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().contains(".tmp-"))); + } + } + + @Test + public void emptyQueueIsDurablyRepresentedByAnEmptyFile() throws IOException { + File queueFile = new File(temporaryFolder.getRoot(), "pending-world-deletes.txt"); + + PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>()); + + assertTrue(queueFile.isFile()); + assertEquals(0L, queueFile.length()); + } + + @Test + public void writeFailuresPropagateToTheCaller() throws IOException { + File parentFile = temporaryFolder.newFile("not-a-directory"); + File queueFile = new File(parentFile, "pending-world-deletes.txt"); + + assertThrows( + IOException.class, + () -> PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>()) + ); + } + + @Test + public void discoversOnlyDirectNonSymlinkStartupDirectories() throws IOException { + File levelRoot = temporaryFolder.newFolder("world"); + Path irisNamespace = levelRoot.toPath().resolve("dimensions/iris"); + Files.createDirectories(irisNamespace); + Files.createDirectory(irisNamespace.resolve(QUARANTINE_NAME)); + Files.createDirectory(irisNamespace.resolve(".iris-delete-not-a-uuid")); + String transientName = "iris-45ba411e-bf7c-493a-bf41-aa020754990b"; + Files.createDirectory(irisNamespace.resolve(transientName + "_nether")); + Path nested = irisNamespace.resolve("ordinary/nested"); + Files.createDirectories(nested); + Files.createDirectory(nested.resolve(".iris-delete-f70b8c21-9174-43a2-b7b7-a84fc0b2fe4a")); + Path symlinkTarget = temporaryFolder.newFolder("quarantine-target").toPath(); + Files.createSymbolicLink( + irisNamespace.resolve(".iris-delete-b8c7ff2d-2efd-410d-b228-6da5d5a46c36"), + symlinkTarget + ); + + LinkedHashSet discovered = PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot); + + assertEquals(Set.of(QUARANTINE_NAME, transientName), discovered); + } + + @Test + public void refusesSymlinkedIrisNamespace() throws IOException { + File levelRoot = temporaryFolder.newFolder("world"); + Path dimensions = levelRoot.toPath().resolve("dimensions"); + Files.createDirectories(dimensions); + Path external = temporaryFolder.newFolder("external-iris").toPath(); + Files.createSymbolicLink(dimensions.resolve("iris"), external); + + assertThrows( + IOException.class, + () -> PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot) + ); + } + + @Test + public void quarantineEntriesResolveToOnlyTheirExactDirectory() throws IOException { + File levelRoot = temporaryFolder.newFolder("world"); + + List paths = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, QUARANTINE_NAME); + + assertEquals(List.of( + levelRoot.toPath().resolve("dimensions/iris").resolve(QUARANTINE_NAME).toAbsolutePath() + ), paths); + } + + @Test + public void exactLogicalEntriesDoNotExpandIntoDimensionFamilies() throws IOException { + File levelRoot = temporaryFolder.newFolder("world"); + + List exact = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "exact:alpha"); + List family = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "alpha"); + + assertEquals(List.of( + levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath() + ), exact); + assertEquals(List.of( + levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath(), + levelRoot.toPath().resolve("dimensions/iris/alpha_nether").toAbsolutePath(), + levelRoot.toPath().resolve("dimensions/iris/alpha_the_end").toAbsolutePath() + ), family); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisLoadWorldContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisLoadWorldContractTest.java new file mode 100644 index 000000000..edb31b400 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisLoadWorldContractTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.BukkitWorldReconciler; +import art.arcane.volmlib.util.director.annotations.Param; +import org.junit.Test; + +import java.io.File; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CommandIrisLoadWorldContractTest { + @Test + public void commandUsesTypedAsyncReconciliationBeforeReportingSuccess() throws Exception { + Method reconciliation = BukkitWorldReconciler.class.getDeclaredMethod( + "loadWorld", + File.class, + String.class); + assertEquals(CompletableFuture.class, reconciliation.getReturnType()); + Method commandMethod = CommandIris.class.getDeclaredMethod("loadWorld", String.class); + Parameter worldParameter = commandMethod.getParameters()[0]; + assertEquals(CommandIris.ManagedWorldNameHandler.class, + worldParameter.getAnnotation(Param.class).customHandler()); + + String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource"))); + String command = source.substring( + source.indexOf("public void loadWorld("), + source.indexOf("private void reportLoadWorldResult(")); + String reporter = source.substring( + source.indexOf("private void reportLoadWorldResult("), + source.indexOf("@Director(description = \"Evacuate an iris world\"")); + + assertTrue(command.contains("IrisWorldStorage.managedKeyFromName(world)")); + assertTrue(command.contains(".loadWorld(BUKKIT_YML, worldKey.toString())")); + assertTrue(command.contains(".whenComplete((result, failure) ->")); + assertFalse(command.contains("checkForBukkitWorlds")); + assertFalse(command.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY")); + assertTrue(reporter.contains("if (result.succeeded())")); + assertTrue(reporter.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisMainWorldPromotionTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisMainWorldPromotionTest.java new file mode 100644 index 000000000..2db60ff04 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisMainWorldPromotionTest.java @@ -0,0 +1,93 @@ +package art.arcane.iris.core.commands; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class CommandIrisMainWorldPromotionTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void existingTopLevelWorldIsRefusedWithoutMerging() throws IOException { + PromotionPaths paths = createPromotionPaths("existing-target"); + Files.createDirectories(paths.target()); + Files.writeString(paths.target().resolve("sentinel.txt"), "keep"); + + assertThrows(FileAlreadyExistsException.class, () -> CommandIris.publishMainWorldFiles( + paths.current(), + paths.sourceDimension(), + paths.target() + )); + + assertEquals("keep", Files.readString(paths.target().resolve("sentinel.txt"))); + assertFalse(Files.exists(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca"))); + assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString())); + } + + @Test + public void uncommittedPromotionRollsBackThePublishedWorld() throws IOException { + PromotionPaths paths = createPromotionPaths("rollback-target"); + + try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles( + paths.current(), + paths.sourceDimension(), + paths.target() + )) { + assertTrue(Files.isRegularFile(paths.target().resolve("data/map.dat"))); + assertTrue(Files.isRegularFile(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca"))); + } + + assertFalse(Files.exists(paths.target())); + assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString())); + } + + @Test + public void committedPromotionKeepsTheCompleteStagedWorld() throws IOException { + PromotionPaths paths = createPromotionPaths("committed-target"); + + try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles( + paths.current(), + paths.sourceDimension(), + paths.target() + )) { + publication.commit(); + } + + assertEquals("map", Files.readString(paths.target().resolve("data/map.dat"))); + assertEquals("region", Files.readString(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca"))); + assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString())); + } + + private PromotionPaths createPromotionPaths(String targetName) throws IOException { + Path root = temporaryFolder.newFolder(targetName + "-root").toPath(); + Path current = root.resolve("world"); + Path sourceDimension = current.resolve("dimensions/iris/" + targetName); + Path target = root.resolve(targetName); + Files.createDirectories(current.resolve("data")); + Files.writeString(current.resolve("data/map.dat"), "map"); + Files.createDirectories(sourceDimension.resolve("region")); + Files.writeString(sourceDimension.resolve("region/r.0.0.mca"), "region"); + return new PromotionPaths(root, current, sourceDimension, target); + } + + private boolean hasPromotionStage(Path root, String targetName) throws IOException { + try (Stream entries = Files.list(root)) { + return entries.anyMatch(path -> path.getFileName().toString().startsWith("." + targetName + ".promoting-")); + } + } + + private record PromotionPaths(Path root, Path current, Path sourceDimension, Path target) { + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisRemovalCommandContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisRemovalCommandContractTest.java new file mode 100644 index 000000000..b80044622 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisRemovalCommandContractTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.core.commands; + +import art.arcane.volmlib.util.director.annotations.Param; +import org.bukkit.World; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class CommandIrisRemovalCommandContractTest { + @Test + public void removalAcceptsDiskOnlyNamesAndUsesManagedSuggestions() throws NoSuchMethodException { + Method command = CommandIris.class.getDeclaredMethod("remove", String.class, boolean.class); + Parameter worldParameter = command.getParameters()[0]; + Param world = worldParameter.getAnnotation(Param.class); + + assertEquals(CommandIris.ManagedWorldNameHandler.class, world.customHandler()); + assertFalse(Arrays.stream(CommandIris.class.getDeclaredMethods()) + .anyMatch(method -> method.getName().equals("remove") + && method.getParameterCount() == 2 + && method.getParameterTypes()[0] == World.class)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisUnloadContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisUnloadContractTest.java new file mode 100644 index 000000000..0abf5a1c8 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandIrisUnloadContractTest.java @@ -0,0 +1,26 @@ +package art.arcane.iris.core.commands; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class CommandIrisUnloadContractTest { + @Test + public void unloadAwaitsEvacuationAndHasATerminalLifecycleTimeout() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource"))); + String command = source.substring( + source.indexOf("public void unloadWorld("), + source.indexOf("private void reportUnloadResult(")); + + assertTrue(command.contains("IrisToolbelt.evacuateAsync(world)")); + assertTrue(command.indexOf("IrisToolbelt.evacuateAsync(world)") + < command.indexOf("WorldLifecycleService.get().unloadAsync(world, true)")); + assertTrue(command.contains("guardUnloadCompletion(sequence, terminalTimeout, world.getName())")); + assertTrue(command.contains("ServerConfigurator.restart(\"World unload timed out")); + assertTrue(command.indexOf("ServerConfigurator.restart(\"World unload timed out") + < command.indexOf("guarded.completeExceptionally(timeout)")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandStudioCreationContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandStudioCreationContractTest.java new file mode 100644 index 000000000..4a75cb3d0 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandStudioCreationContractTest.java @@ -0,0 +1,27 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.common.director.specialhandlers.NullableDimensionHandler; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class CommandStudioCreationContractTest { + @Test + public void createRunsAsynchronouslyAndTemplateIsOptional() throws NoSuchMethodException { + Method command = CommandStudio.class.getDeclaredMethod("create", String.class, IrisDimension.class); + Director director = command.getAnnotation(Director.class); + Parameter templateParameter = command.getParameters()[1]; + Param template = templateParameter.getAnnotation(Param.class); + + assertFalse(director.sync()); + assertEquals("null", template.defaultValue()); + assertEquals(NullableDimensionHandler.class, template.customHandler()); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java index 41284811e..329a46763 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java @@ -25,7 +25,7 @@ public class IrisStructureLocateCommandContractTest { } @Test - public void findRoutesOnlyExplicitNativeReplacementThroughIrisLocate() throws IOException { + public void findRoutesRegisteredReplacementThroughPersistedNativeLocate() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource"))); int methodStart = source.indexOf("public void structure("); int methodEnd = source.indexOf("private static Structure resolveNativeStructure", methodStart); @@ -34,16 +34,19 @@ public class IrisStructureLocateCommandContractTest { int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(", nativeResolution); int replacementCheck = method.indexOf( "decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); - int replacementLocate = method.indexOf("locateIrisStructure(e, structureKey, commandSender)", replacementCheck); int genericIrisLookup = method.indexOf( - "nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementLocate); + "nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementCheck); + int replacementLocate = method.indexOf("final boolean replacementLocate = irisReplacement", genericIrisLookup); int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", genericIrisLookup); assertTrue(nativeResolution >= 0); assertTrue(policyResolution > nativeResolution); assertTrue(replacementCheck > policyResolution); - assertTrue(replacementLocate > replacementCheck); - assertTrue(genericIrisLookup > replacementLocate); + assertTrue(genericIrisLookup > replacementCheck); + assertTrue(replacementLocate > genericIrisLookup); assertTrue(nativeLocate > policyResolution); + assertTrue(method.contains("irisReplacement && !IrisStructureLocator.hasNativePlacement")); + assertTrue(method.contains("!replacementLocate && !explicitNativePlacement")); + assertTrue(method.contains("&& !StructureReachability.isReachable")); assertTrue(method.contains("decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS")); assertFalse(method.contains("NativeStructureLocateCapability")); } @@ -82,7 +85,8 @@ public class IrisStructureLocateCommandContractTest { int methodEnd = source.indexOf("private void sendVerificationMessages(", methodStart); String method = source.substring(methodStart, methodEnd); int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, keyName, false)"); - int nativeRequirement = method.indexOf("requiresNativeReachability |= decision.generate()", policyResolution); + int nativeRequirement = method.indexOf( + "requiresNativeReachability |= !IrisStructureLocator.isPlaced", policyResolution); int reachabilityGuard = method.indexOf("if (requiresNativeReachability)", nativeRequirement); int reachabilityLookup = method.indexOf("StructureReachability.reachableKeys(engine)", reachabilityGuard); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/ForcedStructureChunkGenerator.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/ForcedStructureChunkGenerator.java index c7dea379b..d59ed631f 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/ForcedStructureChunkGenerator.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/ForcedStructureChunkGenerator.java @@ -10,8 +10,6 @@ import net.minecraft.world.level.StructureManager; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.biome.FixedBiomeSource; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.levelgen.Heightmap; @@ -22,16 +20,11 @@ import java.util.List; import java.util.concurrent.CompletableFuture; final class ForcedStructureChunkGenerator extends ChunkGenerator { - private static final int MIN_SAFE_STRUCTURE_Y = 80; - private static final int VERTICAL_MARGIN = 32; - private final ChunkGenerator delegate; - private final int targetY; - ForcedStructureChunkGenerator(ChunkGenerator delegate, Holder sourceBiome, int targetY) { + ForcedStructureChunkGenerator(ChunkGenerator delegate, Holder sourceBiome) { super(new FixedBiomeSource(sourceBiome)); this.delegate = delegate; - this.targetY = targetY; } @Override @@ -72,7 +65,7 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator { @Override public int getSeaLevel() { - return delegate.getMinY() + 1; + return delegate.getSeaLevel(); } @Override @@ -83,21 +76,13 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator { @Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor, RandomState randomState) { - return safeOccupiedY(heightAccessor) + 1; + return delegate.getBaseHeight(x, z, type, heightAccessor, randomState); } @Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState randomState) { - int minY = heightAccessor.getMinY(); - BlockState[] states = new BlockState[heightAccessor.getHeight()]; - for (int index = 0; index < states.length; index++) { - int y = minY + index; - states[index] = (y & 1) == 0 - ? Blocks.STONE.defaultBlockState() - : Blocks.AIR.defaultBlockState(); - } - return new NoiseColumn(minY, states); + return delegate.getBaseColumn(x, z, heightAccessor, randomState); } @Override @@ -106,13 +91,4 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator { delegate.addDebugScreenInfo(result, randomState, feetPos); } - private int safeOccupiedY(LevelHeightAccessor heightAccessor) { - int minY = heightAccessor.getMinY() + VERTICAL_MARGIN; - int maxY = heightAccessor.getMaxY() - VERTICAL_MARGIN; - if (minY > maxY) { - return heightAccessor.getMinY() - + Math.max(0, heightAccessor.getHeight() / 2); - } - return Math.max(minY, Math.min(maxY, Math.max(MIN_SAFE_STRUCTURE_Y, targetY))); - } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFactory.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFactory.java index f1144b92d..e0ab72059 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFactory.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFactory.java @@ -4,6 +4,7 @@ import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.object.IrisJigsawConfiguration; import art.arcane.iris.engine.object.IrisJigsawHeightmap; import art.arcane.iris.engine.object.IrisJigsawLiquidSettings; +import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata; import net.minecraft.core.Holder; import net.minecraft.core.RegistryAccess; import net.minecraft.nbt.CompoundTag; @@ -18,6 +19,7 @@ import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; @@ -43,7 +45,7 @@ public final class NativeStructureFactory { .orElseThrow(() -> new IllegalStateException("Configured native structure '" + plan.source().getStructure() + "' has no usable generation biome")); ChunkGenerator forcedGenerator = new ForcedStructureChunkGenerator( - context.generator(), sourceBiome, plan.baseY()); + context.generator(), sourceBiome); StructureStart generated = configured.generate( sourceHolder, context.levelKey(), @@ -82,15 +84,41 @@ public final class NativeStructureFactory { if (!positioned.isValid()) { return StructureStart.INVALID_START; } - return NativeStructureReferenceEnvelope.wrap( + return NativeStructureReferenceEnvelope.wrapForPublication( positioned, source, references, - context.templateManager(), - plan.placement().resolvedTerrain() + plan.placement().resolvedTerrain(), + plan.source().getStructure() ); } + public static JigsawSourceMetadata sourceMetadata(RegistryAccess registryAccess, + StructureTemplateManager templateManager, + JigsawStructure source) { + CompoundTag sourceTag = encodeJigsaw(registryAccess, source); + return new JigsawSourceMetadata( + sourceDistance(sourceTag, "horizontal"), + horizontalReferenceExpansion(source), + NativeStructureTemplatePoolBounds.sourceHorizontalSpan( + registryAccess, templateManager, source)); + } + + public static int templatePoolHorizontalSpan(RegistryAccess registryAccess, + StructureTemplateManager templateManager, + String templatePoolKey) { + return NativeStructureTemplatePoolBounds.horizontalSpan( + registryAccess, templateManager, templatePoolKey); + } + + public static int jigsawStartPoolHorizontalSpan(RegistryAccess registryAccess, + StructureTemplateManager templateManager, + JigsawStructure source, + String templatePoolKey) { + return NativeStructureTemplatePoolBounds.horizontalSpan( + registryAccess, templateManager, source, templatePoolKey); + } + static Structure configure(RegistryAccess registryAccess, Structure source, IrisJigsawConfiguration configuration, boolean underground, int baseY) { @@ -103,10 +131,7 @@ public final class NativeStructureFactory { return source; } RegistryOps registryOps = RegistryOps.create(NbtOps.INSTANCE, registryAccess); - Tag encoded = Structure.DIRECT_CODEC.encodeStart(registryOps, sourceJigsaw).getOrThrow(); - if (!(encoded instanceof CompoundTag structureTag)) { - throw new IllegalStateException("Native jigsaw codec did not produce a compound"); - } + CompoundTag structureTag = encodeJigsaw(registryOps, sourceJigsaw); CompoundTag configuredTag = structureTag.copy(); if (underground) { CompoundTag height = new CompoundTag(); @@ -123,6 +148,20 @@ public final class NativeStructureFactory { return configured; } + private static CompoundTag encodeJigsaw(RegistryAccess registryAccess, + JigsawStructure source) { + return encodeJigsaw(RegistryOps.create(NbtOps.INSTANCE, registryAccess), source); + } + + private static CompoundTag encodeJigsaw(RegistryOps registryOps, + JigsawStructure source) { + Tag encoded = Structure.DIRECT_CODEC.encodeStart(registryOps, source).getOrThrow(); + if (!(encoded instanceof CompoundTag structureTag)) { + throw new IllegalStateException("Native jigsaw codec did not produce a compound"); + } + return structureTag; + } + private static void applyConfiguration(CompoundTag tag, IrisJigsawConfiguration configuration) { if (configuration == null) { return; @@ -168,7 +207,7 @@ public final class NativeStructureFactory { tag.put("max_distance_from_center", distance); } - private static int sourceDistance(CompoundTag tag, String axis) { + static int sourceDistance(CompoundTag tag, String axis) { Tag raw = tag.get("max_distance_from_center"); if (raw instanceof CompoundTag compound) { return compound.getIntOr(axis, 128); @@ -176,6 +215,14 @@ public final class NativeStructureFactory { return tag.getIntOr("max_distance_from_center", 128); } + static int horizontalReferenceExpansion(Structure structure) { + BoundingBox content = new BoundingBox(0, 0, 0, 0, 0, 0); + BoundingBox adjusted = structure.adjustBoundingBox(content); + int expansion = Math.max(content.minX() - adjusted.minX(), adjusted.maxX() - content.maxX()); + expansion = Math.max(expansion, content.minZ() - adjusted.minZ()); + return Math.max(0, Math.max(expansion, adjusted.maxZ() - content.maxZ())); + } + private static void applyHeightmap(CompoundTag tag, IrisJigsawHeightmap heightmap) { if (heightmap == null || heightmap == IrisJigsawHeightmap.SOURCE) { return; diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFoundationBuilder.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFoundationBuilder.java index c5b2f71a7..161d9e436 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFoundationBuilder.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureFoundationBuilder.java @@ -60,9 +60,6 @@ public final class NativeStructureFoundationBuilder { private static void markFoundationEnvelope(BitSet envelope, List pieces, BoundingBox area, int x, int z) { for (StructurePiece piece : pieces) { - if (NativeStructureReferenceEnvelope.isMarker(piece)) { - continue; - } BoundingBox bounds = piece.getBoundingBox(); if (x < bounds.minX() || x > bounds.maxX() || z < bounds.minZ() || z > bounds.maxZ()) { continue; diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocatePersistence.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocatePersistence.java new file mode 100644 index 000000000..fb5279fcd --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocatePersistence.java @@ -0,0 +1,351 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.spi.IrisLogging; +import net.minecraft.SharedConstants; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.StreamTagVisitor; +import net.minecraft.nbt.Tag; +import net.minecraft.nbt.visitors.CollectFields; +import net.minecraft.nbt.visitors.FieldSelector; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.datafix.DataFixTypes; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.storage.SimpleRegionStorage; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +public final class NativeStructureLocatePersistence { + public static final int MAX_SELECTED_CANDIDATE_RETRIES = 64; + private static final int MAX_STORAGE_PROBES = 512; + private static final Method PAPER_LEVEL_TYPE_KEY = paperLevelTypeKey(); + + private NativeStructureLocatePersistence() { + } + + public static Probe probe(ServerLevel level, Structure structure, boolean requireUnreferenced) { + return probe(level, structure, requireUnreferenced, new ProbeBudget(MAX_STORAGE_PROBES)); + } + + public static ProbeBudget probeBudget() { + return new ProbeBudget(MAX_STORAGE_PROBES); + } + + public static Probe probe(ServerLevel level, Structure structure, boolean requireUnreferenced, + ProbeBudget budget) { + return new Probe(level, structure, requireUnreferenced, budget); + } + + public static Search search(Engine engine, String structureKey, + int blockX, int blockZ, int radius, Probe probe) { + return new Search(engine, structureKey, blockX, blockZ, radius, probe); + } + + public static final class Probe { + private final ServerLevel level; + private final Structure structure; + private final String structureKey; + private final boolean requireUnreferenced; + private final ProbeBudget budget; + private final Map storedDecisions = new HashMap<>(); + + Probe(ServerLevel level, Structure structure, boolean requireUnreferenced, + ProbeBudget budget) { + this.level = level; + this.structure = structure; + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Identifier identifier = registry.getKey(structure); + if (identifier == null) { + throw new IllegalStateException("Cannot probe an unregistered native structure"); + } + this.structureKey = identifier.toString(); + this.requireUnreferenced = requireUnreferenced; + this.budget = budget; + } + + public boolean accepts(int chunkX, int chunkZ) { + ChunkAccess loaded = level.getChunkSource().getChunk( + chunkX, chunkZ, ChunkStatus.STRUCTURE_STARTS, false); + if (loaded != null) { + StructureStart start = loaded.getStartForStructure(structure); + return start != null && start.isValid() + && (!requireUnreferenced || start.canBeReferenced()); + } + + ChunkPos chunkPos = new ChunkPos(chunkX, chunkZ); + Boolean storedDecision = storedDecisions.get(chunkPos.pack()); + if (storedDecision != null) { + return storedDecision; + } + boolean accepted = budget.acceptsStored( + level, chunkPos, structureKey, requireUnreferenced); + storedDecisions.put(chunkPos.pack(), accepted); + return accepted; + } + + public StructureStart verifySelected(int chunkX, int chunkZ) { + ChunkAccess chunk = level.getChunk(chunkX, chunkZ, ChunkStatus.STRUCTURE_STARTS); + StructureStart start = chunk.getStartForStructure(structure); + if (start == null || !start.isValid()) { + return null; + } + if (requireUnreferenced) { + if (!start.canBeReferenced()) { + return null; + } + } + return start; + } + + private void reference(StructureStart start) { + if (requireUnreferenced) { + level.structureManager().addReference(start); + } + } + } + + public static final class ProbeBudget { + private final int maximum; + private final Map storedChunks = new HashMap<>(); + private final ChunkStorageScanner scanner; + private final StoredChunkDatafixer datafixer; + private int used; + private boolean scanFailureReported; + + private ProbeBudget(int maximum) { + this(maximum, null, ProbeBudget::datafix); + } + + ProbeBudget(int maximum, ChunkStorageScanner scanner) { + this(maximum, scanner, ProbeBudget::datafix); + } + + ProbeBudget(int maximum, ChunkStorageScanner scanner, + StoredChunkDatafixer datafixer) { + this.maximum = maximum; + this.scanner = scanner; + this.datafixer = Objects.requireNonNull( + datafixer, "Stored chunk datafixer must not be null"); + } + + private void claim() { + if (used >= maximum) { + throw new IrisStructureLocator.CandidateSearchLimitException(); + } + used++; + } + + private StoredChunkState storedState(ServerLevel level, ChunkPos chunkPos) { + StoredChunkState cached = storedChunks.get(chunkPos.pack()); + if (cached != null) { + return cached; + } + claim(); + CollectFields fields = new CollectFields( + new FieldSelector(IntTag.TYPE, "DataVersion"), + new FieldSelector("Level", "Structures", CompoundTag.TYPE, "Starts"), + new FieldSelector("structures", CompoundTag.TYPE, "starts")); + ChunkStorageScanner activeScanner = scanner == null + ? level.getChunkSource().chunkMap.chunkScanner()::scanChunk : scanner; + try { + activeScanner.scan(chunkPos, fields).join(); + } catch (RuntimeException error) { + if (!scanFailureReported) { + scanFailureReported = true; + IrisLogging.reportError( + "Native structure locate could not scan stored chunk state; candidates will be verified by loading their structure starts.", + error); + } + StoredChunkState unresolved = StoredChunkState.missing(); + storedChunks.put(chunkPos.pack(), unresolved); + return unresolved; + } + Tag result = fields.getResult(); + StoredChunkState resolved; + try { + resolved = result instanceof CompoundTag storedChunk + ? StoredChunkState.parse(datafixer.datafix(level, storedChunk)) + : StoredChunkState.missing(); + } catch (RuntimeException error) { + if (!scanFailureReported) { + scanFailureReported = true; + IrisLogging.reportError( + "Native structure locate could not datafix stored chunk state; candidates will be verified by loading their structure starts.", + error); + } + resolved = StoredChunkState.missing(); + } + storedChunks.put(chunkPos.pack(), resolved); + return resolved; + } + + private static CompoundTag datafix(ServerLevel level, CompoundTag storedChunk) { + int dataVersion = NbtUtils.getDataVersion(storedChunk); + int currentDataVersion = SharedConstants.getCurrentVersion().dataVersion().version(); + if (dataVersion >= currentDataVersion) { + return storedChunk; + } + if (level == null) { + throw new IllegalStateException("Stored chunk datafix requires an active level"); + } + CompoundTag context = chunkDataFixContext(level); + SimpleRegionStorage.injectDatafixingContext(storedChunk, context); + return DataFixTypes.CHUNK.updateToCurrentVersion( + level.getServer().getFixerUpper(), storedChunk, dataVersion); + } + + private static CompoundTag chunkDataFixContext(ServerLevel level) { + CompoundTag context = new CompoundTag(); + String levelIdentifier = level.dimension().identifier().toString(); + if (PAPER_LEVEL_TYPE_KEY == null) { + context.putString("dimension", levelIdentifier); + } else { + context.putString("dimension", paperLevelTypeKey(level).identifier().toString()); + context.putString("level_identifier", levelIdentifier); + } + level.getChunkSource().getGenerator().getTypeNameForDataFixer() + .ifPresent(identifier -> context.putString("generator", identifier.toString())); + return context; + } + + boolean acceptsStored(ServerLevel level, ChunkPos chunkPos, + String structureKey, boolean requireUnreferenced) { + return storedState(level, chunkPos).accepts(structureKey, requireUnreferenced); + } + + int used() { + return used; + } + } + + public static final class Search { + private final Engine engine; + private final String structureKey; + private final int blockX; + private final int blockZ; + private final int radius; + private final Probe probe; + private final Set rejectedChunks = new HashSet<>(); + + private Search(Engine engine, String structureKey, int blockX, int blockZ, + int radius, Probe probe) { + this.engine = engine; + this.structureKey = structureKey; + this.blockX = blockX; + this.blockZ = blockZ; + this.radius = radius; + this.probe = probe; + } + + public IrisStructureLocator.LocateResult predict() { + if (rejectedChunks.size() >= MAX_SELECTED_CANDIDATE_RETRIES) { + return new IrisStructureLocator.LocateResult( + IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0); + } + return IrisStructureLocator.locateInPlacementRings( + engine, structureKey, blockX, blockZ, radius, + (chunkX, chunkZ) -> !rejectedChunks.contains(ChunkPos.pack(chunkX, chunkZ)) + && probe.accepts(chunkX, chunkZ)); + } + + public VerifiedStart verify(IrisStructureLocator.LocateResult result) { + int chunkX = result.originX() >> 4; + int chunkZ = result.originZ() >> 4; + StructureStart start = probe.verifySelected(chunkX, chunkZ); + if (start == null) { + return null; + } + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipRecovery.resolve( + engine, probe.level, structureKey, probe.structure, start); + if (ownership == null) { + return null; + } + return new VerifiedStart(start, ownership); + } + + public void reject(IrisStructureLocator.LocateResult result) { + rejectedChunks.add(ChunkPos.pack(result.originX() >> 4, result.originZ() >> 4)); + } + + public void reference(VerifiedStart verified) { + probe.reference(verified.start()); + } + } + + private static Method paperLevelTypeKey() { + try { + return ServerLevel.class.getMethod("getTypeKey"); + } catch (NoSuchMethodException ignored) { + return null; + } + } + + private static ResourceKey paperLevelTypeKey(ServerLevel level) { + try { + Object value = PAPER_LEVEL_TYPE_KEY.invoke(level); + if (value instanceof ResourceKey key) { + return key; + } + throw new IllegalStateException("Paper level type key has an unexpected value"); + } catch (IllegalAccessException | InvocationTargetException error) { + throw new IllegalStateException("Cannot read Paper level type key", error); + } + } + + public record VerifiedStart(StructureStart start, + NativeStructureOwnershipRecord ownership) { + } + + @FunctionalInterface + interface ChunkStorageScanner { + CompletableFuture scan(ChunkPos chunkPos, StreamTagVisitor visitor); + } + + @FunctionalInterface + interface StoredChunkDatafixer { + CompoundTag datafix(ServerLevel level, CompoundTag storedChunk); + } + + private record StoredChunkState(boolean stored, boolean startsPresent, CompoundTag starts) { + private static StoredChunkState missing() { + return new StoredChunkState(false, false, new CompoundTag()); + } + + private static StoredChunkState parse(CompoundTag chunk) { + CompoundTag structures = chunk.getCompoundOrEmpty("structures"); + return structures.getCompound("starts") + .map(starts -> new StoredChunkState(true, true, starts)) + .orElseGet(() -> new StoredChunkState(true, false, new CompoundTag())); + } + + private boolean accepts(String structureKey, boolean requireUnreferenced) { + if (!stored || !startsPresent) { + return true; + } + return starts.getCompound(structureKey).map(start -> { + String id = start.getStringOr("id", ""); + return !StructureStart.INVALID_START_ID.equals(id) + && (!requireUnreferenced || start.getIntOr("references", 0) == 0); + }).orElse(false); + } + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocateResults.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocateResults.java index a4a64b29a..af9fb21eb 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocateResults.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureLocateResults.java @@ -37,6 +37,19 @@ public final class NativeStructureLocateResults { <= horizontalDistanceSquared(origin, replacement.getFirst()) ? nativeResult : replacement; } + public static Pair selectAndReference( + BlockPos origin, + Pair replacement, Runnable replacementReference, + Pair nativeResult, Runnable nativeReference) { + Pair selected = nearest(origin, replacement, nativeResult); + if (selected == replacement && replacement != null) { + replacementReference.run(); + } else if (selected == nativeResult && nativeResult != null) { + nativeReference.run(); + } + return selected; + } + private static long horizontalDistanceSquared(BlockPos origin, BlockPos target) { long dx = (long) target.getX() - origin.getX(); long dz = (long) target.getZ() - origin.getZ(); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipFingerprint.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipFingerprint.java new file mode 100644 index 000000000..febd5aada --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipFingerprint.java @@ -0,0 +1,239 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; +import art.arcane.iris.engine.framework.NativeStructureStartPlan; +import art.arcane.iris.engine.framework.StructurePlacementGrid; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure; +import net.minecraft.world.level.levelgen.structure.pools.EmptyPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public final class NativeStructureOwnershipFingerprint { + private static final Comparator PIECE_ORDER = Comparator + .comparing(PieceIdentity::type) + .thenComparingInt(PieceIdentity::minX) + .thenComparingInt(PieceIdentity::minY) + .thenComparingInt(PieceIdentity::minZ) + .thenComparingInt(PieceIdentity::maxX) + .thenComparingInt(PieceIdentity::maxY) + .thenComparingInt(PieceIdentity::maxZ) + .thenComparingInt(PieceIdentity::orientation) + .thenComparingInt(PieceIdentity::generationDepth) + .thenComparing(PieceIdentity::detail); + + private NativeStructureOwnershipFingerprint() { + } + + public static NativeStructureOwnershipRecord capture(String structureKey, + StructureStart start, + NativeStructureStartPlan plan, + BoundingBox referenceEnvelope) { + StructureStart resolvedStart = requireValid(start); + NativeStructureStartPlan resolvedPlan = Objects.requireNonNull( + plan, "Native structure start plan must not be null"); + BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(resolvedStart); + BoundingBox references = Objects.requireNonNull( + referenceEnvelope, "Native structure reference envelope must not be null"); + ChunkPos origin = resolvedStart.getChunkPos(); + return NativeStructureOwnershipRecord.create( + structureKey, + origin.x(), + origin.z(), + StructurePlacementGrid.placementIdentity(resolvedPlan.placement()), + resolvedPlan.baseY(), + content.minX(), + content.minY(), + content.minZ(), + content.maxX(), + content.maxY(), + content.maxZ(), + locatorY(resolvedStart), + references.minX() >> 4, + references.maxX() >> 4, + references.minZ() >> 4, + references.maxZ() >> 4, + fingerprint(structureKey, resolvedStart), + NativeStructurePlacementPlanner.decisionFor(resolvedPlan) + ); + } + + public static boolean matches(NativeStructureOwnershipRecord record, StructureStart start) { + if (record == null || start == null || !start.isValid()) { + return false; + } + ChunkPos origin = start.getChunkPos(); + if (origin.x() != record.originChunkX() + || origin.z() != record.originChunkZ() + || !fingerprint(record.structureKey(), start).equals(record.contentFingerprint())) { + return false; + } + BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(start); + if (content.getXSpan() != (record.contentMaxX() - record.contentMinX()) + 1 + || content.getYSpan() != (record.contentMaxY() - record.contentMinY()) + 1 + || content.getZSpan() != (record.contentMaxZ() - record.contentMinZ()) + 1) { + return false; + } + return start.getStructure() instanceof OceanMonumentStructure + || content.minX() == record.contentMinX() + && content.minY() == record.contentMinY() + && content.minZ() == record.contentMinZ() + && content.maxX() == record.contentMaxX() + && content.maxY() == record.contentMaxY() + && content.maxZ() == record.contentMaxZ(); + } + + public static String fingerprint(String structureKey, StructureStart start) { + StructureStart resolvedStart = requireValid(start); + MessageDigest digest = sha256(); + updateString(digest, Objects.requireNonNull(structureKey, + "Native structure key must not be null").trim().toLowerCase(Locale.ROOT)); + updateInt(digest, resolvedStart.getChunkPos().x()); + updateInt(digest, resolvedStart.getChunkPos().z()); + BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(resolvedStart); + List pieces = contentPieces(resolvedStart, content.minY()); + updateInt(digest, pieces.size()); + for (PieceIdentity piece : pieces) { + updateString(digest, piece.type()); + updateInt(digest, piece.minX()); + updateInt(digest, piece.minY()); + updateInt(digest, piece.minZ()); + updateInt(digest, piece.maxX()); + updateInt(digest, piece.maxY()); + updateInt(digest, piece.maxZ()); + updateInt(digest, piece.orientation()); + updateInt(digest, piece.generationDepth()); + updateString(digest, piece.detail()); + } + return HexFormat.of().formatHex(digest.digest()); + } + + public static int locatorY(StructureStart start) { + StructureStart resolvedStart = requireValid(start); + if (!resolvedStart.getPieces().isEmpty()) { + return resolvedStart.getPieces().getFirst().getLocatorPosition().getY(); + } + throw new IllegalStateException("Native structure contains no locatable content pieces"); + } + + private static List contentPieces(StructureStart start, int contentMinY) { + List pieces = new ArrayList<>(); + for (StructurePiece piece : start.getPieces()) { + BoundingBox bounds = piece.getBoundingBox(); + Identifier type = BuiltInRegistries.STRUCTURE_PIECE.getKey(piece.getType()); + Direction orientation = piece.getOrientation(); + String detail = pieceDetail(piece, contentMinY); + pieces.add(new PieceIdentity( + type == null ? "" : type.toString(), + bounds.minX(), bounds.minY() - contentMinY, bounds.minZ(), + bounds.maxX(), bounds.maxY() - contentMinY, bounds.maxZ(), + orientation == null ? -1 : orientation.get2DDataValue(), + piece.getGenDepth(), + detail + )); + } + if (pieces.isEmpty()) { + throw new IllegalStateException("Native structure contains no content pieces"); + } + pieces.sort(PIECE_ORDER); + return List.copyOf(pieces); + } + + private static String pieceDetail(StructurePiece piece, int contentMinY) { + if (!(piece instanceof PoolElementStructurePiece poolPiece)) { + return ""; + } + return poolElementIdentity(poolPiece.getElement()) + "|" + + poolPiece.getPosition().getX() + "," + + (poolPiece.getPosition().getY() - contentMinY) + "," + + poolPiece.getPosition().getZ() + "|" + + poolPiece.getGroundLevelDelta() + "|" + + poolPiece.getRotation().getSerializedName(); + } + + private static String poolElementIdentity(StructurePoolElement element) { + Identifier type = BuiltInRegistries.STRUCTURE_POOL_ELEMENT.getKey(element.getType()); + StringBuilder identity = new StringBuilder(element.getClass().getName()) + .append('|') + .append(type == null ? "" : type) + .append('|') + .append(element.getProjection().getSerializedName()) + .append('|') + .append(element.getGroundLevelDelta()); + if (element == EmptyPoolElement.INSTANCE) { + return identity.append("|empty").toString(); + } + if (element instanceof SinglePoolElement single) { + try { + return identity.append("|template=") + .append(single.getTemplateLocation()).toString(); + } catch (RuntimeException inlineTemplate) { + return identity.append("|runtime-template").toString(); + } + } + if (element instanceof ListPoolElement list) { + identity.append("|list["); + for (StructurePoolElement child : list.getElements()) { + String childIdentity = poolElementIdentity(child); + identity.append(childIdentity.length()).append(':').append(childIdentity); + } + return identity.append(']').toString(); + } + return identity.append("|bounded-custom").toString(); + } + + private static StructureStart requireValid(StructureStart start) { + StructureStart resolved = Objects.requireNonNull( + start, "Native structure start must not be null"); + if (!resolved.isValid()) { + throw new IllegalArgumentException("Native structure start must be valid"); + } + return resolved; + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + private static void updateString(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + updateInt(digest, encoded.length); + digest.update(encoded); + } + + private static void updateInt(MessageDigest digest, int value) { + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private record PieceIdentity(String type, + int minX, int minY, int minZ, + int maxX, int maxY, int maxZ, + int orientation, int generationDepth, String detail) { + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java new file mode 100644 index 000000000..3e8b99a69 --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java @@ -0,0 +1,157 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; +import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; +import art.arcane.iris.engine.framework.NativeStructureStartPlan; +import art.arcane.iris.engine.framework.StructurePlacementGrid; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import art.arcane.iris.engine.object.NativeStructureSuppression; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; + +import java.util.Locale; +import java.util.Objects; + +public final class NativeStructureOwnershipRecovery { + private NativeStructureOwnershipRecovery() { + } + + public static NativeStructureOwnershipRecord resolve( + Engine engine, ServerLevel level, String structureKey, + Structure structure, StructureStart start) { + Objects.requireNonNull(engine, "Native structure ownership recovery requires an engine"); + ServerLevel activeLevel = Objects.requireNonNull( + level, "Native structure ownership recovery requires a level"); + Structure activeStructure = Objects.requireNonNull( + structure, "Native structure ownership recovery requires a structure"); + if (start == null || !start.isValid() || start.getStructure() != activeStructure) { + return null; + } + ChunkPos origin = start.getChunkPos(); + NativeStructureOwnershipRecord persisted = NativeStructureOwnershipStore.findPersisted( + engine, structureKey, origin.x(), origin.z()); + if (persisted != null) { + if (NativeStructureOwnershipFingerprint.matches(persisted, start)) { + return persisted; + } + NativeStructureOwnershipStore.discard( + engine, structureKey, origin.x(), origin.z()); + } + NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan( + engine, structureKey, origin.x(), origin.z()); + if (!matchesPlan(structureKey, origin, plan)) { + return null; + } + Registry registry = activeLevel.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Holder holder = registry.wrapAsHolder(activeStructure); + ChunkGeneratorStructureState state = activeLevel.getChunkSource().getGeneratorState(); + if (naturalStartIsAmbiguous(engine, structureKey, activeStructure, holder, state, plan)) { + return null; + } + StructureStart expected = generateExpected( + engine, activeLevel, holder, plan, start.getReferences()); + NativeStructureOwnershipRecord recovered = proveCandidate( + structureKey, activeStructure, start, plan, expected, false); + if (recovered == null) { + return null; + } + NativeStructureOwnershipStore.record(engine, recovered); + return recovered; + } + + static NativeStructureOwnershipRecord proveCandidate( + String structureKey, Structure structure, StructureStart persisted, + NativeStructureStartPlan plan, StructureStart expected, + boolean naturalStartAmbiguous) { + if (naturalStartAmbiguous || structure == null + || persisted == null || !persisted.isValid() + || expected == null || !expected.isValid() + || persisted.getStructure() != structure + || expected.getStructure() != structure + || !matchesPlan(structureKey, persisted.getChunkPos(), plan) + || !persisted.getChunkPos().equals(expected.getChunkPos())) { + return null; + } + BoundingBox referenceEnvelope = NativeStructureReferenceEnvelope.referenceBounds( + expected, structure, plan.placement().resolvedTerrain(), structureKey); + NativeStructureOwnershipRecord candidate = NativeStructureOwnershipFingerprint.capture( + structureKey, expected, plan, referenceEnvelope); + if (candidate.placementIdentity() + != StructurePlacementGrid.placementIdentity(plan.placement())) { + return null; + } + return NativeStructureOwnershipFingerprint.matches(candidate, persisted) + ? candidate : null; + } + + private static boolean matchesPlan(String structureKey, ChunkPos origin, + NativeStructureStartPlan plan) { + if (structureKey == null || structureKey.isBlank() || plan == null) { + return false; + } + return plan.chunkX() == origin.x() + && plan.chunkZ() == origin.z() + && normalize(structureKey).equals(normalize(plan.source().getStructure())); + } + + private static StructureStart generateExpected( + Engine engine, ServerLevel level, Holder holder, + NativeStructureStartPlan plan, int references) { + ChunkGeneratorStructureState state = level.getChunkSource().getGeneratorState(); + ChunkGenerator generator = level.getChunkSource().getGenerator(); + BiomeSource biomeSource = generator.getBiomeSource(); + NativeStructureFactory.GenerationContext generationContext = + new NativeStructureFactory.GenerationContext( + level.registryAccess(), + generator, + biomeSource, + state.randomState(), + level.getStructureManager(), + state.getLevelSeed(), + level.dimension(), + level, + biome -> true, + generator.getSeaLevel(), + (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight() + ); + return NativeStructureFactory.generate(generationContext, holder, plan, references); + } + + private static boolean naturalStartIsAmbiguous( + Engine engine, String structureKey, Structure structure, + Holder holder, ChunkGeneratorStructureState state, + NativeStructureStartPlan plan) { + if (plan.placement().getNativeSuppression() == NativeStructureSuppression.REPLACE_SOURCE) { + return false; + } + NativeStructureGenerationStatus sourceStatus = NativeStructureGenerationPolicy.resolve( + engine, structureKey, + NativeStructureVegetationClearer.isUndergroundStep(structure.step())).status(); + if (sourceStatus == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + return false; + } + for (StructurePlacement placement : state.getPlacementsForStructure(holder)) { + if (placement.isStructureChunk(state, plan.chunkX(), plan.chunkZ())) { + return true; + } + } + return false; + } + + private static String normalize(String structureKey) { + return structureKey == null ? "" : structureKey.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java index 60b0b4518..962cffea8 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java @@ -42,9 +42,18 @@ public final class NativeStructurePostProcessor { if (targets == null || targets.isEmpty()) { return; } + NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain = + NativeStructureTerrainIntegrator.captureSourceTerrain(world, area, targets); for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { NativeStructureTerrainIntegrator.integrateTerrain(world, area, target.structureId(), target.start(), - target.terrain(), paletteBlockResolver); + target.terrain(), paletteBlockResolver, sourceTerrain); + } + for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { + if (NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( + target.start(), target.terrain())) { + NativeStructureTerrainIntegrator.clearLegacyTemplateAir( + world, area, target.start(), () -> world.getLevel().getStructureManager()); + } } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java index c2eeed475..a5b8757cd 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java @@ -3,43 +3,61 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrainMode; import art.arcane.iris.spi.IrisLogging; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Holder; import net.minecraft.world.level.ChunkPos; -import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.levelgen.structure.BoundingBox; -import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; -import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; -import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; -import net.minecraft.world.level.levelgen.structure.templatesystem.BlockIgnoreProcessor; -import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; -import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList; -import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; -import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; public final class NativeStructureReferenceEnvelope { - private static final int MARKER_GROUND_LEVEL_DELTA = Integer.MIN_VALUE; private static final int MAX_REFERENCE_DISTANCE_CHUNKS = 8; - private static final StructurePoolElement MARKER_ELEMENT = StructurePoolElement.single( - "minecraft:empty", - Holder.direct(new StructureProcessorList(List.of( - BlockIgnoreProcessor.STRUCTURE_AND_AIR))), - LiquidSettings.APPLY_WATERLOGGING - ).apply(StructureTemplatePool.Projection.RIGID); + private static final Set WARNED_CLIPPED_TERRAIN = ConcurrentHashMap.newKeySet(); + private static final Set WARNED_SKIPPED_CONTENT = ConcurrentHashMap.newKeySet(); private NativeStructureReferenceEnvelope() { } public static StructureStart wrap(StructureStart generated, Structure source, int references, - StructureTemplateManager templateManager, IrisStructureTerrain terrain) { - List pieces = new ArrayList<>(generated.getPieces()); + return wrap(generated, source, references, terrain, null); + } + + public static StructureStart wrap(StructureStart generated, Structure source, int references, + IrisStructureTerrain terrain, String structureKey) { + referenceBounds(generated, source, terrain, structureKey); + return new StructureStart( + source, + generated.getChunkPos(), + references, + new PiecesContainer(List.copyOf(generated.getPieces())) + ); + } + + public static StructureStart wrapForPublication( + StructureStart generated, Structure source, int references, + IrisStructureTerrain terrain, String structureKey) { + try { + return wrap(generated, source, references, terrain, structureKey); + } catch (UnrepresentableContentException exception) { + warnSkippedContent(generated, source, structureKey); + return StructureStart.INVALID_START; + } + } + + public static BoundingBox referenceBounds(StructureStart start, Structure source, + IrisStructureTerrain terrain) { + return referenceBounds(start, source, terrain, null); + } + + public static BoundingBox referenceBounds(StructureStart start, Structure source, + IrisStructureTerrain terrain, + String structureKey) { IrisStructureTerrainMode mode = terrain == null ? IrisStructureTerrainMode.PRESERVE : terrain.resolvedMode(); boolean usesEnvelope = mode == IrisStructureTerrainMode.BORE @@ -47,9 +65,19 @@ public final class NativeStructureReferenceEnvelope { || mode == IrisStructureTerrainMode.VACUUM || mode == IrisStructureTerrainMode.ENCASE; int horizontalPadding = usesEnvelope ? Math.max(0, terrain.getHorizontalPadding()) : 0; - if (horizontalPadding > 0) { - BoundingBox content = contentBounds(pieces); - BoundingBox envelope = new BoundingBox( + BoundingBox content = contentBounds(start); + if (!fitsReferenceRange(start.getChunkPos(), content)) { + throw new UnrepresentableContentException("Native structure content at " + + start.getChunkPos().x() + "," + start.getChunkPos().z() + + " exceeds Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS + + "-chunk structure reference range"); + } + BoundingBox adjustedEnvelope; + boolean arithmeticClipped = false; + try { + BoundingBox envelope = horizontalPadding == 0 + ? content + : new BoundingBox( Math.subtractExact(content.minX(), horizontalPadding), content.minY(), Math.subtractExact(content.minZ(), horizontalPadding), @@ -57,41 +85,36 @@ public final class NativeStructureReferenceEnvelope { content.maxY(), Math.addExact(content.maxZ(), horizontalPadding) ); - BoundingBox referencedEnvelope = clampReferenceRange(generated.getChunkPos(), envelope); - if (!sameHorizontalBounds(envelope, referencedEnvelope)) { - IrisLogging.warn("Native structure terrain envelope at " - + generated.getChunkPos().x() + "," + generated.getChunkPos().z() - + " was clipped to Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS - + "-chunk structure reference range"); - } - pieces.add(marker(templateManager, referencedEnvelope.minX(), - referencedEnvelope.minY(), referencedEnvelope.minZ())); - pieces.add(marker(templateManager, referencedEnvelope.maxX(), - referencedEnvelope.maxY(), referencedEnvelope.maxZ())); + adjustedEnvelope = mode == IrisStructureTerrainMode.SOURCE + ? source.adjustBoundingBox(content) : envelope; + adjustedEnvelope = encapsulating(adjustedEnvelope, content); + } catch (ArithmeticException exception) { + adjustedEnvelope = maximumReferenceBounds(start.getChunkPos(), content); + arithmeticClipped = true; + } + BoundingBox referencedEnvelope = clampReferenceRange(start.getChunkPos(), adjustedEnvelope); + if (arithmeticClipped || !sameHorizontalBounds(adjustedEnvelope, referencedEnvelope)) { + warnClippedTerrain(start, source, structureKey); + return referencedEnvelope; + } + return adjustedEnvelope; + } + + public static boolean contentFitsReferenceRange(StructureStart start) { + try { + return fitsReferenceRange(start.getChunkPos(), contentBounds(start)); + } catch (UnrepresentableContentException exception) { + return false; } - return new StructureStart( - source, - generated.getChunkPos(), - references, - new PiecesContainer(List.copyOf(pieces)) - ); } public static BoundingBox contentBounds(StructureStart start) { return contentBounds(start.getPieces()); } - public static boolean isMarker(StructurePiece piece) { - return piece instanceof PoolElementStructurePiece poolPiece - && poolPiece.getGroundLevelDelta() == MARKER_GROUND_LEVEL_DELTA; - } - private static BoundingBox contentBounds(List pieces) { BoundingBox bounds = null; for (StructurePiece piece : pieces) { - if (isMarker(piece)) { - continue; - } bounds = bounds == null ? copy(piece.getBoundingBox()) : bounds.encapsulate(piece.getBoundingBox()); @@ -102,25 +125,11 @@ public final class NativeStructureReferenceEnvelope { return bounds; } - private static PoolElementStructurePiece marker(StructureTemplateManager templateManager, - int x, int y, int z) { - BlockPos position = new BlockPos(x, y, z); - return new PoolElementStructurePiece( - templateManager, - MARKER_ELEMENT, - position, - MARKER_GROUND_LEVEL_DELTA, - Rotation.NONE, - new BoundingBox(position), - LiquidSettings.APPLY_WATERLOGGING - ); - } - private static BoundingBox clampReferenceRange(ChunkPos startChunk, BoundingBox envelope) { - int minX = (startChunk.x() - MAX_REFERENCE_DISTANCE_CHUNKS) << 4; - int maxX = ((startChunk.x() + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15; - int minZ = (startChunk.z() - MAX_REFERENCE_DISTANCE_CHUNKS) << 4; - int maxZ = ((startChunk.z() + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15; + int minX = minimumReferenceBlock(startChunk.x()); + int maxX = maximumReferenceBlock(startChunk.x()); + int minZ = minimumReferenceBlock(startChunk.z()); + int maxZ = maximumReferenceBlock(startChunk.z()); return new BoundingBox( Math.max(envelope.minX(), minX), envelope.minY(), @@ -131,6 +140,40 @@ public final class NativeStructureReferenceEnvelope { ); } + private static BoundingBox maximumReferenceBounds(ChunkPos startChunk, BoundingBox content) { + return new BoundingBox( + minimumReferenceBlock(startChunk.x()), content.minY(), + minimumReferenceBlock(startChunk.z()), + maximumReferenceBlock(startChunk.x()), content.maxY(), + maximumReferenceBlock(startChunk.z()) + ); + } + + private static boolean fitsReferenceRange(ChunkPos startChunk, BoundingBox content) { + return content.minX() >= minimumReferenceBlock(startChunk.x()) + && content.maxX() <= maximumReferenceBlock(startChunk.x()) + && content.minZ() >= minimumReferenceBlock(startChunk.z()) + && content.maxZ() <= maximumReferenceBlock(startChunk.z()); + } + + private static int minimumReferenceBlock(int originChunk) { + return checkedReferenceBlock( + ((long) originChunk - MAX_REFERENCE_DISTANCE_CHUNKS) << 4, originChunk); + } + + private static int maximumReferenceBlock(int originChunk) { + return checkedReferenceBlock( + (((long) originChunk + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15L, originChunk); + } + + private static int checkedReferenceBlock(long block, int originChunk) { + if (block < Integer.MIN_VALUE || block > Integer.MAX_VALUE) { + throw new UnrepresentableContentException("Native structure origin chunk " + originChunk + + " exceeds Minecraft's block coordinate range"); + } + return (int) block; + } + private static boolean sameHorizontalBounds(BoundingBox left, BoundingBox right) { return left.minX() == right.minX() && left.minZ() == right.minZ() @@ -143,4 +186,51 @@ public final class NativeStructureReferenceEnvelope { bounds.minX(), bounds.minY(), bounds.minZ(), bounds.maxX(), bounds.maxY(), bounds.maxZ()); } + + private static BoundingBox encapsulating(BoundingBox left, BoundingBox right) { + return new BoundingBox( + Math.min(left.minX(), right.minX()), + Math.min(left.minY(), right.minY()), + Math.min(left.minZ(), right.minZ()), + Math.max(left.maxX(), right.maxX()), + Math.max(left.maxY(), right.maxY()), + Math.max(left.maxZ(), right.maxZ()) + ); + } + + private static void warnClippedTerrain( + StructureStart start, Structure source, String structureKey) { + String key = warningKey(source, structureKey); + if (WARNED_CLIPPED_TERRAIN.add(key)) { + IrisLogging.warn("Clipping optional terrain envelope for native structure '" + + key + "' at " + start.getChunkPos().x() + "," + start.getChunkPos().z() + + " to Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS + + "-chunk reference range; all generated content remains exactly referenced"); + } + } + + private static void warnSkippedContent( + StructureStart start, Structure source, String structureKey) { + String key = warningKey(source, structureKey); + if (WARNED_SKIPPED_CONTENT.add(key)) { + IrisLogging.warn("Skipping native structure '" + key + "' at " + + start.getChunkPos().x() + "," + start.getChunkPos().z() + + " because its generated content exceeds Minecraft's " + + MAX_REFERENCE_DISTANCE_CHUNKS + + "-chunk reference range; no structure start, ownership, references, or blocks were published"); + } + } + + private static String warningKey(Structure source, String structureKey) { + if (structureKey != null && !structureKey.isBlank()) { + return structureKey.trim().toLowerCase(Locale.ROOT); + } + return source.getClass().getName(); + } + + public static final class UnrepresentableContentException extends IllegalStateException { + private UnrepresentableContentException(String message) { + super(message); + } + } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceRepair.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceRepair.java new file mode 100644 index 000000000..68866a784 --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceRepair.java @@ -0,0 +1,224 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.spi.IrisLogging; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; +import it.unimi.dsi.fastutil.longs.LongSet; +import net.minecraft.core.Registry; +import net.minecraft.core.SectionPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public final class NativeStructureReferenceRepair { + private static final int REFERENCE_DISTANCE_CHUNKS = + NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS; + private static final Set WARNED_POLICY_INVALIDATIONS = ConcurrentHashMap.newKeySet(); + + private NativeStructureReferenceRepair() { + } + + public static void createReferences(Engine engine, WorldGenLevel level, + StructureManager structureManager, ChunkAccess targetChunk) { + ChunkPos target = targetChunk.getPos(); + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + ServerLevel serverLevel = level.getLevel(); + List starts = new ArrayList<>(); + for (int originChunkX = target.x() - REFERENCE_DISTANCE_CHUNKS; + originChunkX <= target.x() + REFERENCE_DISTANCE_CHUNKS; originChunkX++) { + for (int originChunkZ = target.z() - REFERENCE_DISTANCE_CHUNKS; + originChunkZ <= target.z() + REFERENCE_DISTANCE_CHUNKS; originChunkZ++) { + ChunkAccess originChunk = level.getChunk( + originChunkX, originChunkZ, ChunkStatus.STRUCTURE_STARTS); + for (Map.Entry entry : originChunk.getAllStarts().entrySet()) { + ScannedStart start = scanStart( + engine, serverLevel, structureManager, targetChunk, registry, + originChunk, entry.getKey(), entry.getValue()); + if (start != null && isTargetRelevant(engine, targetChunk, start)) { + starts.add(start); + } + } + } + } + for (ScannedStart start : starts) { + boolean valid; + synchronized (start.originChunk()) { + StructureStart current = start.originChunk().getStartForStructure(start.structure()); + valid = current == start.start() && current.isValid(); + } + if (!valid) { + continue; + } + structureManager.addReferenceForStructure( + SectionPos.bottomOf(targetChunk), start.structure(), + start.origin().pack(), targetChunk); + } + } + + private static boolean isTargetRelevant( + Engine engine, ChunkAccess targetChunk, ScannedStart scanned) { + StructureStart start = scanned.start(); + Structure structure = scanned.structure(); + ChunkPos target = targetChunk.getPos(); + if (scanned.ownership() != null) { + return requiresReference(target, scanned.structureKey(), start, scanned.ownership()); + } + if (scanned.registered()) { + return requiresNaturalReference( + engine, target, scanned.structureKey(), structure, start); + } + return NativeStructureReferenceEnvelope.contentBounds(start).intersects( + target.getMinBlockX(), target.getMinBlockZ(), + target.getMaxBlockX(), target.getMaxBlockZ()); + } + + private static ScannedStart scanStart( + Engine engine, ServerLevel level, StructureManager structureManager, + ChunkAccess targetChunk, Registry registry, + ChunkAccess originChunk, Structure structure, StructureStart start) { + Identifier identifier = registry.getKey(structure); + String structureKey = identifier == null + ? structure.getClass().getName() : identifier.toString(); + if (start == null || !start.isValid()) { + return null; + } + if (!NativeStructureReferenceEnvelope.contentFitsReferenceRange(start)) { + if (identifier != null) { + NativeStructureOwnershipStore.discard( + engine, structureKey, start.getChunkPos().x(), start.getChunkPos().z()); + } + invalidateStart(structureManager, originChunk, structure, start, targetChunk); + return null; + } + ChunkPos startOrigin = start.getChunkPos(); + NativeStructureOwnershipRecord ownership = identifier == null ? null + : NativeStructureOwnershipRecovery.resolve( + engine, level, structureKey, structure, start); + if (ownership != null) { + return new ScannedStart( + originChunk, structure, start, structureKey, ownership, true); + } + if (identifier != null && !naturalPolicyAllows(engine, structureKey, structure)) { + invalidateStart(structureManager, originChunk, structure, start, targetChunk); + if (WARNED_POLICY_INVALIDATIONS.add(structureKey)) { + IrisLogging.warn("Invalidating persisted natural structure start '" + + structureKey + "' because the current Iris dimension policy disables it"); + } + return null; + } + return new ScannedStart( + originChunk, structure, start, structureKey, null, identifier != null); + } + + private static boolean naturalPolicyAllows( + Engine engine, String structureKey, Structure structure) { + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve( + engine, structureKey, + NativeStructureVegetationClearer.isUndergroundStep(structure.step())); + return naturalDecisionAllows(decision); + } + + static boolean naturalDecisionAllows(IrisNativeStructureDecision decision) { + return Objects.requireNonNull( + decision, "Native structure decision must not be null").generate(); + } + + private static void invalidateStart( + StructureManager structureManager, ChunkAccess originChunk, + Structure structure, StructureStart start, ChunkAccess targetChunk) { + synchronized (originChunk) { + StructureStart current = originChunk.getStartForStructure(structure); + if (current == start && current.isValid()) { + structureManager.setStartForStructure( + SectionPos.bottomOf(originChunk), structure, + StructureStart.INVALID_START, originChunk); + } + } + if (targetChunk != null) { + removeTargetReference(targetChunk, structure, start.getChunkPos().pack()); + } + } + + private static void removeTargetReference( + ChunkAccess targetChunk, Structure structure, long origin) { + LongSet current = targetChunk.getAllReferences().get(structure); + if (current == null || !current.contains(origin)) { + return; + } + Map updated = new HashMap<>(targetChunk.getAllReferences()); + LongSet retained = new LongOpenHashSet(current); + retained.remove(origin); + if (retained.isEmpty()) { + updated.remove(structure); + } else { + updated.put(structure, retained); + } + targetChunk.setAllReferences(updated); + } + + static boolean requiresNaturalReference(Engine engine, ChunkPos target, String structureKey, + Structure structure, StructureStart start) { + if (target == null || start == null || !start.isValid()) { + return false; + } + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve( + engine, structureKey, + NativeStructureVegetationClearer.isUndergroundStep(structure.step())); + if (!decision.generate()) { + return false; + } + BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds( + start, structure, + NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()), + structureKey); + return referenceBounds.intersects( + target.getMinBlockX(), target.getMinBlockZ(), + target.getMaxBlockX(), target.getMaxBlockZ()); + } + + static boolean requiresReference(ChunkPos target, String structureKey, + StructureStart start, + NativeStructureOwnershipRecord ownership) { + if (target == null || start == null || !start.isValid() + || ownership == null || !ownership.structureKey().equals(structureKey) + || !ownership.covers(target.x(), target.z())) { + return false; + } + return NativeStructureOwnershipFingerprint.matches(ownership, start); + } + + private record ScannedStart( + ChunkAccess originChunk, + Structure structure, + StructureStart start, + String structureKey, + NativeStructureOwnershipRecord ownership, + boolean registered + ) { + private ChunkPos origin() { + return ownership == null + ? start.getChunkPos() + : new ChunkPos(ownership.originChunkX(), ownership.originChunkZ()); + } + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java index 062534ffb..b868bb077 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java @@ -1,9 +1,14 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; import art.arcane.iris.engine.framework.NativeStructureStartPlan; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.NativeStructureSuppression; +import art.arcane.iris.spi.IrisLogging; import net.minecraft.core.Holder; import net.minecraft.core.Registry; import net.minecraft.core.RegistryAccess; @@ -17,6 +22,7 @@ import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; @@ -24,8 +30,12 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; public final class NativeStructureStartInjector { + private static final Set WARNED_DUPLICATE_STRUCTURES = ConcurrentHashMap.newKeySet(); + private NativeStructureStartInjector() { } @@ -48,14 +58,22 @@ public final class NativeStructureStartInjector { } Holder holder = registry.wrapAsHolder(structure); if (configuredStarts.containsKey(structure)) { - throw new IllegalStateException("Multiple configured native structure placements selected '" - + identifier + "' in chunk " + chunk.getPos().x() + "," + chunk.getPos().z() - + "; Minecraft can persist only one start per registered structure per chunk"); + if (WARNED_DUPLICATE_STRUCTURES.add(identifier.toString())) { + IrisLogging.warn("Ignoring duplicate native structure placements for '" + + identifier + "'; the first deterministic candidate owns each chunk start"); + } + continue; } StructureStart existing = context.structureManager().getStartForStructure( section, structure, chunk); boolean replacement = plan.placement().getNativeSuppression() == NativeStructureSuppression.REPLACE_SOURCE; + if (!replacement && existing != null && existing.isValid()) { + NativeStructureGenerationStatus sourceStatus = NativeStructureGenerationPolicy.resolve( + context.engine(), identifier.toString(), + NativeStructureVegetationClearer.isUndergroundStep(structure.step())).status(); + replacement = sourceStatus == NativeStructureGenerationStatus.REPLACED_BY_IRIS; + } if (!replacement && existing != null && existing.isValid()) { continue; } @@ -77,18 +95,43 @@ public final class NativeStructureStartInjector { ); StructureStart generated = NativeStructureFactory.generate( generationContext, holder, plan, references); - if (!generated.isValid()) { - throw new IllegalStateException("Configured native structure '" + identifier - + "' produced no valid start in chunk " + chunk.getPos().x() - + "," + chunk.getPos().z()); + if (!isUsableGeneratedStart(generated)) { + if (replacement) { + context.structureManager().setStartForStructure( + section, structure, StructureStart.INVALID_START, chunk); + } + NativeStructureOwnershipStore.discard( + context.engine(), identifier.toString(), + chunk.getPos().x(), chunk.getPos().z()); + continue; + } + BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds( + generated, structure, plan.placement().resolvedTerrain(), identifier.toString()); + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture( + identifier.toString(), generated, plan, referenceBounds); + NativeStructureOwnershipStore.record(context.engine(), ownership); + try { + context.structureManager().setStartForStructure( + section, structure, generated, chunk); + } catch (RuntimeException | Error publicationError) { + try { + NativeStructureOwnershipStore.discard( + context.engine(), identifier.toString(), + chunk.getPos().x(), chunk.getPos().z()); + } catch (RuntimeException | Error cleanupError) { + publicationError.addSuppressed(cleanupError); + } + throw publicationError; } - context.structureManager().setStartForStructure( - section, structure, generated, chunk); configuredStarts.put(structure, plan); } return Map.copyOf(configuredStarts); } + static boolean isUsableGeneratedStart(StructureStart start) { + return start != null && start.isValid(); + } + public record InjectionContext( Engine engine, RegistryAccess registryAccess, diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java index 07b2804b6..ddcf39004 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java @@ -1,6 +1,8 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.object.IrisObjectVacuum; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; import net.minecraft.core.BlockPos; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.block.Blocks; @@ -13,13 +15,11 @@ import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction; import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; -import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.function.IntBinaryOperator; -import java.util.function.Supplier; public final class NativeStructureSurfaceFitter { private static final double SURFACE_TERRAIN_FALLOFF = 2.0; @@ -30,32 +30,31 @@ public final class NativeStructureSurfaceFitter { } public static void prepareSurfaceStructures(WorldGenLevel world, BoundingBox area, - List starts, + List targets, IntBinaryOperator surfaceHeight) { - if (starts == null || starts.isEmpty()) { + if (targets == null || targets.isEmpty()) { return; } Objects.requireNonNull(surfaceHeight, "Surface structure terrain fitting requires an Iris height resolver"); - List anchors = collectSurfaceAnchors(starts); + List anchors = collectSurfaceAnchors(targets); if (!anchors.isEmpty()) { fitSurfaceTerrain(world, area, anchors, surfaceHeight); } - Supplier templates = () -> world.getLevel().getStructureManager(); - for (StructureStart start : starts) { - if (requiresSurfaceTerrain(start)) { - NativeStructureTerrainIntegrator.clearLegacyTemplateAir(world, area, start, templates); - } - } } static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment, GenerationStep.Decoration step) { return adjustment == TerrainAdjustment.BEARD_THIN - && step == GenerationStep.Decoration.SURFACE_STRUCTURES; + || adjustment == TerrainAdjustment.BEARD_BOX; } static int resolveSurfaceTarget(List anchors, int worldX, int worldZ, int originalY) { + return resolveSurface(anchors, worldX, worldZ, originalY).targetY(); + } + + private static SurfaceResolution resolveSurface(List anchors, + int worldX, int worldZ, int originalY) { int localTargetY = originalY; SurfaceAnchor selectedLocal = null; long totalInfluence = 0L; @@ -64,11 +63,42 @@ public final class NativeStructureSurfaceFitter { for (SurfaceAnchor anchor : anchors) { int outX = IrisObjectVacuum.outset(worldX, anchor.minX(), anchor.maxX()); int outZ = IrisObjectVacuum.outset(worldZ, anchor.minZ(), anchor.maxZ()); - long distanceSquared = (long) outX * outX + (long) outZ * outZ; - if (distanceSquared > (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS) { + boolean containsColumn = outX == 0 && outZ == 0; + if (containsColumn && anchor.strength() > 1 && originalY < anchor.meetY()) { + if (precedes(anchor, selectedLocal)) { + localTargetY = anchor.meetY(); + selectedLocal = anchor; + } + continue; + } + int verticalDistance = anchor.verticalDistance(originalY); + long horizontalDistanceSquared = (long) outX * outX + (long) outZ * outZ; + long distanceSquared = (long) outX * outX + (long) outZ * outZ + + (long) verticalDistance * verticalDistance; + double factor = 0D; + long radiusSquared = (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS; + if (distanceSquared <= radiusSquared) { + double distance = Math.sqrt(distanceSquared); + factor = Math.pow( + 1D - distance / SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF); + } + if (anchor.strength() > 1 && originalY < anchor.meetY() + && verticalDistance > SURFACE_TERRAIN_RADIUS + && horizontalDistanceSquared <= radiusSquared) { + double horizontalDistance = Math.sqrt(horizontalDistanceSquared); + double horizontalFactor = Math.pow( + 1D - horizontalDistance / SURFACE_TERRAIN_RADIUS, + SURFACE_TERRAIN_FALLOFF); + double rescueProgress = Math.min(1D, + (verticalDistance - SURFACE_TERRAIN_RADIUS) + / (double) SURFACE_TERRAIN_RADIUS); + double rescueWeight = rescueProgress * rescueProgress + * (3D - 2D * rescueProgress); + factor = Math.max(factor, horizontalFactor * rescueWeight); + } + if (factor <= 0D) { continue; } - boolean containsColumn = outX == 0 && outZ == 0; if (containsColumn) { if (precedes(anchor, selectedLocal)) { localTargetY = anchor.meetY(); @@ -76,10 +106,6 @@ public final class NativeStructureSurfaceFitter { } continue; } - double factor = IrisObjectVacuum.columnInfluence( - worldX, worldZ, - anchor.minX(), anchor.maxX(), anchor.minZ(), anchor.maxZ(), - SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF); long influence = Math.round(factor * SURFACE_TERRAIN_INFLUENCE_SCALE); if (influence <= 0L) { continue; @@ -90,14 +116,15 @@ public final class NativeStructureSurfaceFitter { maximumInfluence = Math.max(maximumInfluence, influence); } if (selectedLocal != null) { - return localTargetY; + return new SurfaceResolution(localTargetY, selectedLocal.strength() > 1); } if (totalInfluence == 0L) { - return originalY; + return new SurfaceResolution(originalY, false); } double blendedMeetY = weightedMeetY / (double) totalInfluence; double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE; - return (int) Math.round(originalY + ((blendedMeetY - originalY) * factor)); + return new SurfaceResolution( + (int) Math.round(originalY + ((blendedMeetY - originalY) * factor)), false); } private static boolean precedes(SurfaceAnchor candidate, SurfaceAnchor selected) { @@ -125,41 +152,62 @@ public final class NativeStructureSurfaceFitter { return false; } - private static List collectSurfaceAnchors(List starts) { + private static List collectSurfaceAnchors( + List targets) { List anchors = new ArrayList<>(); - for (StructureStart start : starts) { - if (!requiresSurfaceTerrain(start)) { + for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { + if (!requiresSurfaceTerrain(target)) { continue; } + StructureStart start = target.start(); + TerrainAdjustment adjustment = start.getStructure().terrainAdaptation(); for (StructurePiece piece : start.getPieces()) { - if (NativeStructureReferenceEnvelope.isMarker(piece)) { - continue; - } if (piece instanceof PoolElementStructurePiece poolPiece) { if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) { BoundingBox bounds = poolPiece.getBoundingBox(); - anchors.add(new SurfaceAnchor( - bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), - bounds.minY() + poolPiece.getGroundLevelDelta() - 1, 2)); + anchors.add(surfaceAnchor( + bounds, bounds.minY() + poolPiece.getGroundLevelDelta(), + 2, adjustment)); } for (JigsawJunction junction : poolPiece.getJunctions()) { anchors.add(new SurfaceAnchor( junction.getSourceX(), junction.getSourceX(), junction.getSourceZ(), junction.getSourceZ(), - junction.getSourceGroundY() - 1, 1)); + junction.getSourceGroundY() - 1, 1, + junction.getSourceGroundY(), junction.getSourceGroundY())); } continue; } BoundingBox bounds = piece.getBoundingBox(); - anchors.add(new SurfaceAnchor( - bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), - bounds.minY() - 1, 2)); + anchors.add(surfaceAnchor(bounds, bounds.minY(), 2, adjustment)); } } return List.copyOf(anchors); } + static SurfaceAnchor surfaceAnchor(BoundingBox bounds, int groundY, int strength, + TerrainAdjustment adjustment) { + int meetY = groundY - 1; + if (adjustment == TerrainAdjustment.BEARD_BOX) { + return new SurfaceAnchor( + bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), + meetY, strength, groundY, bounds.maxY()); + } + return new SurfaceAnchor(bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), + meetY, strength, groundY, groundY); + } + static boolean requiresSurfaceTerrain(StructureStart start) { + return requiresSurfaceTerrain(new NativeStructureTerrainIntegrator.TerrainTarget( + null, start, new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))); + } + + static boolean requiresSurfaceTerrain(NativeStructureTerrainIntegrator.TerrainTarget target) { + if (target == null || target.terrain() == null + || target.terrain().resolvedMode() != IrisStructureTerrainMode.SOURCE) { + return false; + } + StructureStart start = target.start(); return start != null && start.isValid() && shouldPrepareSurfaceTerrain( @@ -173,14 +221,17 @@ public final class NativeStructureSurfaceFitter { int depth = area.getZSpan(); int[] originalHeights = new int[width * depth]; int[] targetHeights = new int[width * depth]; + boolean[] rigidBaseSupport = new boolean[width * depth]; for (int z = area.minZ(); z <= area.maxZ(); z++) { for (int x = area.minX(); x <= area.maxX(); x++) { int column = (z - area.minZ()) * width + x - area.minX(); int originalY = Math.max(area.minY(), Math.min( area.maxY(), surfaceHeight.applyAsInt(x, z))); + SurfaceResolution resolution = resolveSurface(anchors, x, z, originalY); originalHeights[column] = originalY; targetHeights[column] = Math.max(area.minY(), Math.min( - area.maxY(), resolveSurfaceTarget(anchors, x, z, originalY))); + area.maxY(), resolution.targetY())); + rigidBaseSupport[column] = resolution.rigidBaseSupport(); } } BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); @@ -188,7 +239,8 @@ public final class NativeStructureSurfaceFitter { for (int x = area.minX(); x <= area.maxX(); x++) { int column = (z - area.minZ()) * width + x - area.minX(); applySurfaceColumn(world, position, x, z, - originalHeights[column], targetHeights[column], area.minY(), area.maxY()); + originalHeights[column], targetHeights[column], area.minY(), area.maxY(), + rigidBaseSupport[column]); } } } @@ -196,7 +248,18 @@ public final class NativeStructureSurfaceFitter { static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position, int x, int z, int originalY, int targetY, int worldMinY, int worldMaxY) { + applySurfaceColumn(world, position, x, z, originalY, targetY, + worldMinY, worldMaxY, false); + } + + static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position, + int x, int z, int originalY, int targetY, + int worldMinY, int worldMaxY, + boolean requireRigidBaseSupport) { if (targetY == originalY) { + if (requireRigidBaseSupport) { + ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY); + } return; } SurfaceMaterials materials = resolveSurfaceMaterials(world, position, x, z, originalY, worldMinY); @@ -207,6 +270,9 @@ public final class NativeStructureSurfaceFitter { world.setBlock(position.set(x, y, z), clearedState, 2); } world.setBlock(position.set(x, targetY, z), materials.surface(), 2); + if (requireRigidBaseSupport) { + ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials); + } return; } for (int y = originalY + 1; y < targetY; y++) { @@ -219,6 +285,42 @@ public final class NativeStructureSurfaceFitter { if (!NativeStructureVegetationClearer.isTreeBlock(world.getBlockState(position))) { world.setBlock(position, materials.surface(), 2); } + if (requireRigidBaseSupport) { + ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials); + } + } + + private static void ensureRigidBaseTerrain(WorldGenLevel world, + BlockPos.MutableBlockPos position, + int x, int z, int targetY, int worldMinY) { + int supportY = targetY - 1; + boolean targetIsTerrain = isTerrainBlock(world.getBlockState(position.set(x, targetY, z))); + boolean supportIsTerrain = supportY < worldMinY + || isTerrainBlock(world.getBlockState(position.set(x, supportY, z))); + if (targetIsTerrain && supportIsTerrain) { + return; + } + SurfaceMaterials materials = resolveSurfaceMaterials( + world, position, x, z, targetY, worldMinY); + ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials); + } + + private static void ensureRigidBaseTerrain(WorldGenLevel world, + BlockPos.MutableBlockPos position, + int x, int z, int targetY, int worldMinY, + SurfaceMaterials materials) { + int supportY = targetY - 1; + position.set(x, targetY, z); + if (!isTerrainBlock(world.getBlockState(position))) { + world.setBlock(position, materials.surface(), 2); + } + if (supportY < worldMinY) { + return; + } + position.set(x, supportY, z); + if (!isTerrainBlock(world.getBlockState(position))) { + world.setBlock(position, materials.subsurface(), 2); + } } private static BlockState clearSurfaceDecorationAndResolveFill( @@ -271,9 +373,20 @@ public final class NativeStructureSurfaceFitter { return state.isSolid() && !NativeStructureVegetationClearer.isTreeBlock(state); } - record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) { + record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength, + int minInfluenceY, int maxInfluenceY) { + SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) { + this(minX, maxX, minZ, maxZ, meetY, strength, meetY + 1, meetY + 1); + } + + int verticalDistance(int y) { + return IrisObjectVacuum.outset(y, minInfluenceY, maxInfluenceY); + } } private record SurfaceMaterials(BlockState surface, BlockState subsurface) { } + + private record SurfaceResolution(int targetY, boolean rigidBaseSupport) { + } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplatePoolBounds.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplatePoolBounds.java new file mode 100644 index 000000000..c5335542a --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplatePoolBounds.java @@ -0,0 +1,228 @@ +package art.arcane.iris.nativegen; + +import com.mojang.datafixers.util.Pair; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.pools.EmptyPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.FeaturePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.pools.alias.DirectPoolAlias; +import net.minecraft.world.level.levelgen.structure.pools.alias.PoolAliasBinding; +import net.minecraft.world.level.levelgen.structure.pools.alias.RandomGroupPoolAlias; +import net.minecraft.world.level.levelgen.structure.pools.alias.RandomPoolAlias; +import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; + +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +public final class NativeStructureTemplatePoolBounds { + private NativeStructureTemplatePoolBounds() { + } + + public static int sourceHorizontalSpan(RegistryAccess registryAccess, + StructureTemplateManager templateManager, + JigsawStructure source) { + Registry pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL); + return sourceHorizontalSpan( + templateManager, + source.getStartPool(), + source.getPoolAliases(), + key -> pools.getValue(key.identifier())); + } + + public static int horizontalSpan(RegistryAccess registryAccess, + StructureTemplateManager templateManager, + String templatePoolKey) { + Identifier identifier = Identifier.tryParse(templatePoolKey); + if (identifier == null) { + throw new IllegalArgumentException("Invalid registered template pool key: " + templatePoolKey); + } + Registry pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL); + StructureTemplatePool pool = pools.getValue(identifier); + if (pool == null) { + throw new IllegalArgumentException("Registered template pool does not exist: " + templatePoolKey); + } + return horizontalSpan(pool, templateManager); + } + + public static int horizontalSpan(RegistryAccess registryAccess, + StructureTemplateManager templateManager, + JigsawStructure source, + String templatePoolKey) { + Identifier identifier = Identifier.tryParse(templatePoolKey); + if (identifier == null) { + throw new IllegalArgumentException("Invalid registered template pool key: " + templatePoolKey); + } + Registry pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL); + ResourceKey startPoolKey = ResourceKey.create( + Registries.TEMPLATE_POOL, identifier); + StructureTemplatePool startPool = pools.getValue(identifier); + if (startPool == null) { + throw new IllegalArgumentException("Registered template pool does not exist: " + templatePoolKey); + } + return sourceHorizontalSpan( + templateManager, + startPool, + startPoolKey, + source.getPoolAliases(), + key -> pools.getValue(key.identifier())); + } + + static int sourceHorizontalSpan(StructureTemplateManager templateManager, + Holder startPool, + List aliases, + Function, StructureTemplatePool> poolLookup) { + return sourceHorizontalSpan(templateManager, startPool.value(), + startPool.unwrapKey().orElse(null), aliases, poolLookup); + } + + static int sourceHorizontalSpan(StructureTemplateManager templateManager, + StructureTemplatePool startPool, + ResourceKey startPoolKey, + List aliases, + Function, StructureTemplatePool> poolLookup) { + int maximumSpan = directHorizontalSpan(startPool, templateManager); + if (startPoolKey == null) { + return maximumSpan; + } + Set> targets = new HashSet<>(); + for (PoolAliasBinding binding : aliases) { + collectTargets(binding, startPoolKey, targets); + } + for (ResourceKey target : targets) { + StructureTemplatePool targetPool = poolLookup.apply(target); + if (targetPool == null) { + throw new IllegalStateException("Jigsaw start-pool alias target does not exist: " + + target.identifier()); + } + maximumSpan = Math.max(maximumSpan, directHorizontalSpan(targetPool, templateManager)); + } + return maximumSpan; + } + + private static void collectTargets(PoolAliasBinding binding, + ResourceKey startPoolKey, + Set> targets) { + if (binding instanceof DirectPoolAlias direct) { + if (direct.alias().equals(startPoolKey)) { + targets.add(direct.target()); + } + return; + } + if (binding instanceof RandomPoolAlias random) { + if (random.alias().equals(startPoolKey)) { + random.targets().unwrap().forEach(entry -> targets.add(entry.value())); + } + return; + } + if (binding instanceof RandomGroupPoolAlias group) { + group.groups().unwrap().forEach(entry -> entry.value().forEach( + nested -> collectTargets(nested, startPoolKey, targets))); + return; + } + throw new IllegalStateException("Unsupported jigsaw pool alias type: " + + binding.getClass().getName()); + } + + static int horizontalSpan(StructureTemplatePool pool, + StructureTemplateManager templateManager) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + return horizontalSpan(pool, templateManager, visited); + } + + private static int horizontalSpan(StructureTemplatePool pool, + StructureTemplateManager templateManager, + Set visited) { + if (!visited.add(pool)) { + return 0; + } + int maximumSpan = directHorizontalSpan(pool, templateManager); + Holder fallback = pool.getFallback(); + if (fallback == null || !fallback.isBound()) { + throw new IllegalStateException("Template pool has an unresolved fallback"); + } + return Math.max(maximumSpan, horizontalSpan(fallback.value(), templateManager, visited)); + } + + private static int directHorizontalSpan(StructureTemplatePool pool, + StructureTemplateManager templateManager) { + int maximumSpan = 0; + for (Pair entry : pool.getTemplates()) { + StructurePoolElement element = entry.getFirst(); + validateElement(element, templateManager); + maximumSpan = Math.max(maximumSpan, horizontalSpan(element, templateManager)); + } + return maximumSpan; + } + + private static void validateElement(StructurePoolElement element, + StructureTemplateManager templateManager) { + if (element == EmptyPoolElement.INSTANCE || element instanceof FeaturePoolElement) { + return; + } + if (element instanceof SinglePoolElement single) { + Identifier template; + try { + template = single.getTemplateLocation(); + } catch (RuntimeException inlineTemplate) { + return; + } + if (templateManager.get(template).isEmpty()) { + throw new IllegalStateException("Template pool element does not resolve a structure template: " + + template); + } + return; + } + if (element instanceof ListPoolElement list) { + for (StructurePoolElement child : list.getElements()) { + validateElement(child, templateManager); + } + return; + } + } + + private static int horizontalSpan(StructurePoolElement element, + StructureTemplateManager templateManager) { + if (element == EmptyPoolElement.INSTANCE) { + return 0; + } + long maximumSpan = 0L; + for (Rotation rotation : Rotation.values()) { + BoundingBox box; + try { + box = element.getBoundingBox(templateManager, BlockPos.ZERO, rotation); + } catch (RuntimeException | LinkageError error) { + throw new IllegalStateException("Template pool element " + + element.getClass().getName() + + " could not provide bounded geometry for rotation " + rotation, error); + } + if (box == null) { + throw new IllegalStateException("Template pool element " + + element.getClass().getName() + + " returned no bounded geometry for rotation " + rotation); + } + long xSpan = (long) box.maxX() - box.minX() + 1L; + long zSpan = (long) box.maxZ() - box.minZ() + 1L; + maximumSpan = Math.max(maximumSpan, Math.max(xSpan, zSpan)); + } + if (maximumSpan < 0L || maximumSpan > Integer.MAX_VALUE) { + throw new IllegalStateException("Template pool element has an unbounded horizontal span"); + } + return (int) maximumSpan; + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java index f2017ea60..424713089 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java @@ -9,16 +9,24 @@ import art.arcane.iris.engine.object.IrisStructureTerrainMode; import art.arcane.iris.util.project.noise.CNG; import art.arcane.volmlib.util.math.RNG; import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.level.Level; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.dimension.BuiltinDimensionTypes; +import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; +import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction; import net.minecraft.world.level.levelgen.structure.pools.LegacySinglePoolElement; import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; @@ -29,62 +37,80 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import java.util.ArrayList; -import java.util.Collections; +import java.util.BitSet; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; public final class NativeStructureTerrainIntegrator { - private static final int AUTO_ENCASE_PADDING = 3; private static final long CARVE_CEILING_ROLL_SIGNATURE = 0x2A17L; private static final long CARVE_FLOOR_ROLL_SIGNATURE = 0x5B3DL; private static final long CARVE_LOBE_SIGNATURE = 0x7C41L; - private static final int MAX_CACHED_CARVE_FOOTPRINTS = 4; + private static final int MAX_CACHED_CARVE_CELLS = 2_000_000; private static final int MAX_CARVE_COLUMNS = 2_000_000; private static final int MAX_TEMPLATE_OCCUPANCY_CELLS = 4_194_304; + private static final int SOURCE_BURY_HORIZONTAL_RADIUS = 6; + private static final int SOURCE_BURY_VERTICAL_RADIUS = 12; + private static final int SOURCE_ENCAPSULATE_RADIUS = 12; + private static final int SOURCE_JUNCTION_RADIUS = 12; + private static final int SOURCE_MATERIAL_SAMPLE_RADIUS = 8; private static final List TEMPLATE_VOID_BLOCKS = List.of(Blocks.AIR, Blocks.STRUCTURE_VOID); - private static final Map CARVE_FOOTPRINTS = - Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75F, true) { - @Override - protected boolean removeEldestEntry( - Map.Entry eldest) { - return size() > MAX_CACHED_CARVE_FOOTPRINTS; - } - }); + private static final Set VANILLA_SOURCE_TERRAIN_BLOCKS = Set.of( + Blocks.STONE, Blocks.GRANITE, Blocks.DIORITE, Blocks.ANDESITE, + Blocks.TUFF, Blocks.DEEPSLATE, Blocks.NETHERRACK, Blocks.BASALT, + Blocks.BLACKSTONE, Blocks.DIRT, Blocks.COARSE_DIRT, Blocks.ROOTED_DIRT, + Blocks.GRASS_BLOCK, Blocks.PODZOL, Blocks.MYCELIUM, Blocks.MUD, + Blocks.MOSS_BLOCK, Blocks.SAND, Blocks.RED_SAND, Blocks.TERRACOTTA, + Blocks.CRIMSON_NYLIUM, Blocks.WARPED_NYLIUM, Blocks.SNOW_BLOCK, + Blocks.ICE, Blocks.PACKED_ICE, Blocks.BLUE_ICE, Blocks.SOUL_SAND, + Blocks.SOUL_SOIL, Blocks.END_STONE, Blocks.GRAVEL, Blocks.CLAY, + Blocks.CALCITE, Blocks.DRIPSTONE_BLOCK, Blocks.SANDSTONE, + Blocks.RED_SANDSTONE, Blocks.SCULK); + private static final Map CARVE_FOOTPRINTS = + new LinkedHashMap<>(16, 0.75F, true); + private static final ConcurrentHashMap> + CARVE_FOOTPRINT_BUILDS = new ConcurrentHashMap<>(); + private static int cachedCarveCells; private NativeStructureTerrainIntegrator() { } public static IrisStructureTerrain resolveNativeTerrain(StructureStart start, IrisStructureTerrain configuredTerrain) { - if (configuredTerrain != null) { - return configuredTerrain; - } - if (start == null || !start.isValid() - || !encasesTerrain(start.getStructure().terrainAdaptation())) { - return null; - } - return new IrisStructureTerrain() - .setMode(IrisStructureTerrainMode.ENCASE) - .setHorizontalPadding(AUTO_ENCASE_PADDING) - .setCeilingPadding(AUTO_ENCASE_PADDING) - .setFloorPadding(AUTO_ENCASE_PADDING); - } - - static boolean encasesTerrain(TerrainAdjustment adjustment) { - return adjustment == TerrainAdjustment.BURY || adjustment == TerrainAdjustment.ENCAPSULATE; + return configuredTerrain == null + ? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE) + : configuredTerrain; } static void integrateTerrain(WorldGenLevel world, BoundingBox area, String structureId, StructureStart start, IrisStructureTerrain configuredTerrain, NativeStructurePostProcessor.PaletteBlockResolver paletteBlockResolver) { + SourceTerrainSnapshot sourceTerrain = requiresSourceTerrainFill(start, configuredTerrain) + ? captureSourceTerrain(world, area, start, configuredTerrain) : null; + integrateTerrain(world, area, structureId, start, configuredTerrain, + paletteBlockResolver, sourceTerrain); + } + + static void integrateTerrain(WorldGenLevel world, BoundingBox area, String structureId, + StructureStart start, IrisStructureTerrain configuredTerrain, + NativeStructurePostProcessor.PaletteBlockResolver paletteBlockResolver, + SourceTerrainSnapshot sourceTerrain) { IrisStructureTerrain terrain = configuredTerrain == null ? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE) : configuredTerrain; IrisStructureTerrainMode mode = terrain.resolvedMode(); - if (mode == IrisStructureTerrainMode.SOURCE || mode == IrisStructureTerrainMode.PRESERVE) { + if (mode == IrisStructureTerrainMode.SOURCE) { + integrateSourceTerrain(world, area, start, sourceTerrain); + return; + } + if (mode == IrisStructureTerrainMode.PRESERVE) { return; } if (mode == IrisStructureTerrainMode.VACUUM) { @@ -111,12 +137,381 @@ public final class NativeStructureTerrainIntegrator { terrain, shape, carveNoiseIdentity(world, structureId, start))); } + static SourceTerrainSnapshot captureSourceTerrain( + WorldGenLevel world, BoundingBox area, List targets) { + BitSet requiredLayers = new BitSet(area.getYSpan()); + boolean requiresSnapshot = false; + for (TerrainTarget target : targets) { + if (target != null && requiresSourceTerrainFill(target.start(), target.terrain())) { + requiresSnapshot = true; + markSourceTerrainLayers(area, target.start(), target.terrain(), requiredLayers); + } + } + return requiresSnapshot ? SourceTerrainSnapshot.capture(world, area, requiredLayers) : null; + } + + private static SourceTerrainSnapshot captureSourceTerrain( + WorldGenLevel world, BoundingBox area, StructureStart start, + IrisStructureTerrain configuredTerrain) { + BitSet requiredLayers = new BitSet(area.getYSpan()); + markSourceTerrainLayers(area, start, configuredTerrain, requiredLayers); + return SourceTerrainSnapshot.capture(world, area, requiredLayers); + } + + private static void markSourceTerrainLayers( + BoundingBox area, StructureStart start, IrisStructureTerrain configuredTerrain, + BitSet requiredLayers) { + if (!requiresSourceTerrainFill(start, configuredTerrain)) { + return; + } + TerrainAdjustment adjustment = start.getStructure().terrainAdaptation(); + for (StructurePiece piece : start.getPieces()) { + if (!isSourceRigidPiece(piece)) { + continue; + } + BoundingBox bounds = piece.getBoundingBox(); + long horizontalDistanceSquared = minimumHorizontalDistanceSquared(area, bounds); + if (adjustment == TerrainAdjustment.BURY) { + long remainingDistanceSquared = (long) SOURCE_BURY_VERTICAL_RADIUS + * SOURCE_BURY_VERTICAL_RADIUS - 1L - horizontalDistanceSquared * 4L; + if (remainingDistanceSquared < 0L) { + continue; + } + int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared)); + long groundY = bounds.minY(); + if (piece instanceof PoolElementStructurePiece poolPiece) { + groundY += poolPiece.getGroundLevelDelta(); + } + markLayers(area, requiredLayers, + groundY - verticalRadius, groundY + verticalRadius); + } else { + long remainingDistanceSquared = (long) SOURCE_ENCAPSULATE_RADIUS + * SOURCE_ENCAPSULATE_RADIUS - 1L - horizontalDistanceSquared; + if (remainingDistanceSquared < 0L) { + continue; + } + int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared)); + markLayers(area, requiredLayers, + (long) bounds.minY() - verticalRadius, + (long) bounds.maxY() + verticalRadius); + } + } + markSourceJunctionLayers(area, start, requiredLayers); + } + + private static void markSourceJunctionLayers( + BoundingBox area, StructureStart start, BitSet requiredLayers) { + Set anchors = sourceJunctionAnchors(start); + for (JunctionAnchor anchor : anchors) { + long deltaX = intervalDistance(area.minX(), area.maxX(), anchor.x(), anchor.x()); + long deltaZ = intervalDistance(area.minZ(), area.maxZ(), anchor.z(), anchor.z()); + long horizontalDistanceSquared = deltaX * deltaX + deltaZ * deltaZ; + long remainingDistanceSquared = (long) SOURCE_JUNCTION_RADIUS + * SOURCE_JUNCTION_RADIUS - 1L - horizontalDistanceSquared; + if (remainingDistanceSquared < 1L) { + continue; + } + int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared)); + markLayers(area, requiredLayers, + (long) anchor.y() - verticalRadius, (long) anchor.y() - 1L); + } + } + + private static long minimumHorizontalDistanceSquared(BoundingBox area, BoundingBox bounds) { + long deltaX = intervalDistance(area.minX(), area.maxX(), bounds.minX(), bounds.maxX()); + long deltaZ = intervalDistance(area.minZ(), area.maxZ(), bounds.minZ(), bounds.maxZ()); + return deltaX * deltaX + deltaZ * deltaZ; + } + + private static long intervalDistance(int firstMin, int firstMax, int secondMin, int secondMax) { + if (firstMax < secondMin) { + return (long) secondMin - firstMax; + } + if (firstMin > secondMax) { + return (long) firstMin - secondMax; + } + return 0L; + } + + private static void markLayers( + BoundingBox area, BitSet requiredLayers, long minimumY, long maximumY) { + long clippedMinimumY = Math.max(area.minY(), minimumY); + long clippedMaximumY = Math.min(area.maxY(), maximumY); + if (clippedMinimumY > clippedMaximumY) { + return; + } + int fromIndex = Math.toIntExact(clippedMinimumY - area.minY()); + int toIndex = Math.toIntExact(clippedMaximumY - area.minY() + 1L); + requiredLayers.set(fromIndex, toIndex); + } + + private static boolean requiresSourceTerrainFill( + StructureStart start, IrisStructureTerrain configuredTerrain) { + if (start == null || !start.isValid()) { + return false; + } + IrisStructureTerrain terrain = configuredTerrain == null + ? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE) + : configuredTerrain; + if (terrain.resolvedMode() != IrisStructureTerrainMode.SOURCE) { + return false; + } + TerrainAdjustment adjustment = start.getStructure().terrainAdaptation(); + return adjustment == TerrainAdjustment.BURY + || adjustment == TerrainAdjustment.ENCAPSULATE; + } + + static boolean clearsLegacyTemplateAir(StructureStart start, IrisStructureTerrain terrain) { + if (start == null || !start.isValid() || terrain == null) { + return false; + } + IrisStructureTerrainMode mode = terrain.resolvedMode(); + return mode == IrisStructureTerrainMode.ENCASE + || mode == IrisStructureTerrainMode.SOURCE + && start.getStructure().terrainAdaptation() != TerrainAdjustment.NONE; + } + + private static void integrateSourceTerrain(WorldGenLevel world, BoundingBox area, + StructureStart start, + SourceTerrainSnapshot sourceTerrain) { + if (start == null || !start.isValid()) { + return; + } + TerrainAdjustment adjustment = start.getStructure().terrainAdaptation(); + if (adjustment == TerrainAdjustment.BURY) { + fillBuriedTerrain(world, area, start, requireSourceTerrain( + world, area, start, sourceTerrain)); + } else if (adjustment == TerrainAdjustment.ENCAPSULATE) { + fillEncapsulatedTerrain(world, area, start, requireSourceTerrain( + world, area, start, sourceTerrain)); + } + } + + private static SourceTerrainSnapshot requireSourceTerrain( + WorldGenLevel world, BoundingBox area, StructureStart start, + SourceTerrainSnapshot sourceTerrain) { + return sourceTerrain == null + ? captureSourceTerrain(world, area, start, null) : sourceTerrain; + } + + private static void fillBuriedTerrain(WorldGenLevel world, BoundingBox area, + StructureStart start, + SourceTerrainSnapshot sourceTerrain) { + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (StructurePiece piece : start.getPieces()) { + if (!isSourceRigidPiece(piece)) { + continue; + } + BoundingBox bounds = piece.getBoundingBox(); + int groundY = bounds.minY(); + if (piece instanceof PoolElementStructurePiece poolPiece) { + groundY += poolPiece.getGroundLevelDelta(); + } + int minX = Math.max(area.minX(), bounds.minX() - SOURCE_BURY_HORIZONTAL_RADIUS); + int maxX = Math.min(area.maxX(), bounds.maxX() + SOURCE_BURY_HORIZONTAL_RADIUS); + int minY = Math.max(area.minY(), groundY - SOURCE_BURY_VERTICAL_RADIUS); + int maxY = Math.min(area.maxY(), groundY + SOURCE_BURY_VERTICAL_RADIUS); + int minZ = Math.max(area.minZ(), bounds.minZ() - SOURCE_BURY_HORIZONTAL_RADIUS); + int maxZ = Math.min(area.maxZ(), bounds.maxZ() + SOURCE_BURY_HORIZONTAL_RADIUS); + for (int x = minX; x <= maxX; x++) { + int outX = outset(x, bounds.minX(), bounds.maxX()); + for (int z = minZ; z <= maxZ; z++) { + int outZ = outset(z, bounds.minZ(), bounds.maxZ()); + for (int y = minY; y <= maxY; y++) { + int vertical = Math.abs(y - groundY); + if (!insideBurialEnvelope(outX, vertical, outZ)) { + continue; + } + fillEncaseable(world, position.set(x, y, z), sourceTerrain); + } + } + } + } + fillSourceJunctionTerrain(world, area, start, position, sourceTerrain); + } + + private static void fillEncapsulatedTerrain(WorldGenLevel world, BoundingBox area, + StructureStart start, + SourceTerrainSnapshot sourceTerrain) { + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (StructurePiece piece : start.getPieces()) { + if (!isSourceRigidPiece(piece)) { + continue; + } + BoundingBox bounds = piece.getBoundingBox(); + int minX = Math.max(area.minX(), bounds.minX() - SOURCE_ENCAPSULATE_RADIUS); + int maxX = Math.min(area.maxX(), bounds.maxX() + SOURCE_ENCAPSULATE_RADIUS); + int minY = Math.max(area.minY(), bounds.minY() - SOURCE_ENCAPSULATE_RADIUS); + int maxY = Math.min(area.maxY(), bounds.maxY() + SOURCE_ENCAPSULATE_RADIUS); + int minZ = Math.max(area.minZ(), bounds.minZ() - SOURCE_ENCAPSULATE_RADIUS); + int maxZ = Math.min(area.maxZ(), bounds.maxZ() + SOURCE_ENCAPSULATE_RADIUS); + for (int x = minX; x <= maxX; x++) { + int outX = outset(x, bounds.minX(), bounds.maxX()); + for (int z = minZ; z <= maxZ; z++) { + int outZ = outset(z, bounds.minZ(), bounds.maxZ()); + for (int y = minY; y <= maxY; y++) { + int outY = outset(y, bounds.minY(), bounds.maxY()); + if (!insideEncapsulationEnvelope(outX, outY, outZ)) { + continue; + } + fillEncaseable(world, position.set(x, y, z), sourceTerrain); + } + } + } + } + fillSourceJunctionTerrain(world, area, start, position, sourceTerrain); + } + + static boolean insideBurialEnvelope(int outX, int verticalDistance, int outZ) { + long horizontalSquared = (long) outX * outX + (long) outZ * outZ; + long verticalSquared = (long) verticalDistance * verticalDistance; + return horizontalSquared * 4L + verticalSquared + < (long) SOURCE_BURY_VERTICAL_RADIUS * SOURCE_BURY_VERTICAL_RADIUS; + } + + static boolean insideEncapsulationEnvelope(int outX, int outY, int outZ) { + return (long) outX * outX + (long) outY * outY + (long) outZ * outZ + < (long) SOURCE_ENCAPSULATE_RADIUS * SOURCE_ENCAPSULATE_RADIUS; + } + + static boolean isSourceRigidPiece(StructurePiece piece) { + if (piece == null) { + return false; + } + return !(piece instanceof PoolElementStructurePiece poolPiece) + || poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID; + } + + static boolean insideSourceJunctionEnvelope(int deltaX, int deltaY, int deltaZ) { + if (deltaY >= 0) { + return false; + } + return (long) deltaX * deltaX + (long) deltaY * deltaY + (long) deltaZ * deltaZ + < (long) SOURCE_JUNCTION_RADIUS * SOURCE_JUNCTION_RADIUS; + } + + private static void fillSourceJunctionTerrain(WorldGenLevel world, BoundingBox area, + StructureStart start, + BlockPos.MutableBlockPos position, + SourceTerrainSnapshot sourceTerrain) { + Set anchors = sourceJunctionAnchors(start); + for (JunctionAnchor anchor : anchors) { + int minX = Math.max(area.minX(), anchor.x() - SOURCE_JUNCTION_RADIUS + 1); + int maxX = Math.min(area.maxX(), anchor.x() + SOURCE_JUNCTION_RADIUS - 1); + int minY = Math.max(area.minY(), anchor.y() - SOURCE_JUNCTION_RADIUS + 1); + int maxY = Math.min(area.maxY(), anchor.y() - 1); + int minZ = Math.max(area.minZ(), anchor.z() - SOURCE_JUNCTION_RADIUS + 1); + int maxZ = Math.min(area.maxZ(), anchor.z() + SOURCE_JUNCTION_RADIUS - 1); + for (int x = minX; x <= maxX; x++) { + int deltaX = x - anchor.x(); + for (int z = minZ; z <= maxZ; z++) { + int deltaZ = z - anchor.z(); + for (int y = minY; y <= maxY; y++) { + if (insideSourceJunctionEnvelope(deltaX, y - anchor.y(), deltaZ)) { + fillEncaseable(world, position.set(x, y, z), sourceTerrain); + } + } + } + } + } + } + + private static Set sourceJunctionAnchors(StructureStart start) { + Set anchors = new HashSet<>(); + for (StructurePiece piece : start.getPieces()) { + if (!(piece instanceof PoolElementStructurePiece poolPiece)) { + continue; + } + for (JigsawJunction junction : poolPiece.getJunctions()) { + anchors.add(new JunctionAnchor( + junction.getSourceX(), junction.getSourceGroundY(), junction.getSourceZ())); + } + } + return anchors; + } + + private static int outset(int value, int minimum, int maximum) { + if (value < minimum) { + return minimum - value; + } + return Math.max(0, value - maximum); + } + + private static void fillEncaseable(WorldGenLevel world, BlockPos position, + SourceTerrainSnapshot sourceTerrain) { + if (isEncaseable(world.getBlockState(position))) { + world.setBlock(position, sourceEncaseBlock(world, position, sourceTerrain), 2); + } + } + + static BlockState sourceEncaseBlock(WorldGenLevel world, BlockPos position) { + int minX = Math.subtractExact(position.getX(), SOURCE_MATERIAL_SAMPLE_RADIUS); + int minZ = Math.subtractExact(position.getZ(), SOURCE_MATERIAL_SAMPLE_RADIUS); + int maxX = Math.addExact(position.getX(), SOURCE_MATERIAL_SAMPLE_RADIUS); + int maxZ = Math.addExact(position.getZ(), SOURCE_MATERIAL_SAMPLE_RADIUS); + BoundingBox sampleArea = new BoundingBox(minX, position.getY(), minZ, + maxX, position.getY(), maxZ); + BitSet requiredLayers = new BitSet(1); + requiredLayers.set(0); + SourceTerrainSnapshot sourceTerrain = SourceTerrainSnapshot.capture( + world, sampleArea, requiredLayers); + return sourceEncaseBlock(world, position, sourceTerrain); + } + + static BlockState sourceEncaseBlock(WorldGenLevel world, BlockPos position, + SourceTerrainSnapshot sourceTerrain) { + BlockPos.MutableBlockPos probe = new BlockPos.MutableBlockPos(); + for (int distance = 1; distance <= SOURCE_MATERIAL_SAMPLE_RADIUS; distance++) { + BlockState sampled = sourceTerrain.stateAt(probe.set( + position.getX() - distance, position.getY(), position.getZ())); + if (sampled != null) { + return sampled; + } + sampled = sourceTerrain.stateAt(probe.set( + position.getX() + distance, position.getY(), position.getZ())); + if (sampled != null) { + return sampled; + } + sampled = sourceTerrain.stateAt(probe.set( + position.getX(), position.getY(), position.getZ() - distance)); + if (sampled != null) { + return sampled; + } + sampled = sourceTerrain.stateAt(probe.set( + position.getX(), position.getY(), position.getZ() + distance)); + if (sampled != null) { + return sampled; + } + } + return defaultEncaseBlock(world, position.getY()); + } + + private static BlockState sourceTerrainBlock(BlockState state) { + if (!state.isSolid() || NativeStructureVegetationClearer.isTreeBlock(state)) { + return null; + } + return VANILLA_SOURCE_TERRAIN_BLOCKS.contains(state.getBlock()) + || state.is(BlockTags.BASE_STONE_OVERWORLD) + || state.is(BlockTags.BASE_STONE_NETHER) + || state.is(BlockTags.SUBSTRATE_OVERWORLD) + || state.is(BlockTags.DIRT) + || state.is(BlockTags.SAND) + || state.is(BlockTags.TERRACOTTA) + || state.is(BlockTags.MUD) + || state.is(BlockTags.MOSS_BLOCKS) + || state.is(BlockTags.GRASS_BLOCKS) + || state.is(BlockTags.NYLIUM) + || state.is(BlockTags.SNOW) + || state.is(BlockTags.ICE) + || state.is(BlockTags.CORAL_BLOCKS) + || state.is(BlockTags.SOUL_FIRE_BASE_BLOCKS) + ? state : null; + } + static List contentPieceBounds(StructureStart start) { List bounds = new ArrayList<>(start.getPieces().size()); for (StructurePiece piece : start.getPieces()) { - if (!NativeStructureReferenceEnvelope.isMarker(piece)) { - bounds.add(piece.getBoundingBox()); - } + bounds.add(piece.getBoundingBox()); } return List.copyOf(bounds); } @@ -128,18 +523,84 @@ public final class NativeStructureTerrainIntegrator { static StructureCarvingFootprint carveFootprint(StructureStart start, int horizontalPadding, Supplier templates) { CarveFootprintKey key = new CarveFootprintKey(start, horizontalPadding); - StructureCarvingFootprint cached = CARVE_FOOTPRINTS.get(key); + StructureCarvingFootprint cached = cachedCarveFootprint(key); if (cached != null) { return cached; } - StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns( - sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS); - if (footprint == null) { - throw new IllegalStateException("Native structure carve footprint is empty or exceeds " - + MAX_CARVE_COLUMNS + " columns"); + CompletableFuture build = new CompletableFuture<>(); + CompletableFuture active = CARVE_FOOTPRINT_BUILDS.putIfAbsent(key, build); + if (active != null) { + return awaitCarveFootprint(active); + } + try { + StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns( + sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS); + if (footprint == null) { + throw new IllegalStateException("Native structure carve footprint is empty or exceeds " + + MAX_CARVE_COLUMNS + " columns"); + } + cacheCarveFootprint(key, footprint); + build.complete(footprint); + return footprint; + } catch (RuntimeException | Error error) { + build.completeExceptionally(error); + throw error; + } finally { + CARVE_FOOTPRINT_BUILDS.remove(key, build); + } + } + + static int cachedCarveFootprintCells() { + synchronized (CARVE_FOOTPRINTS) { + return cachedCarveCells; + } + } + + static int maximumCachedCarveFootprintCells() { + return MAX_CACHED_CARVE_CELLS; + } + + private static StructureCarvingFootprint cachedCarveFootprint(CarveFootprintKey key) { + synchronized (CARVE_FOOTPRINTS) { + CachedCarveFootprint cached = CARVE_FOOTPRINTS.get(key); + return cached == null ? null : cached.footprint(); + } + } + + private static StructureCarvingFootprint awaitCarveFootprint( + CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException error) { + Throwable cause = error.getCause(); + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + if (cause instanceof Error fatal) { + throw fatal; + } + throw new IllegalStateException("Native structure carve footprint build failed", cause); + } + } + + private static void cacheCarveFootprint(CarveFootprintKey key, + StructureCarvingFootprint footprint) { + int cells = Math.multiplyExact(footprint.width(), footprint.depth()); + synchronized (CARVE_FOOTPRINTS) { + CachedCarveFootprint previous = CARVE_FOOTPRINTS.remove(key); + if (previous != null) { + cachedCarveCells -= previous.cells(); + } + while (!CARVE_FOOTPRINTS.isEmpty() + && cachedCarveCells + cells > MAX_CACHED_CARVE_CELLS) { + Map.Entry eldest = + CARVE_FOOTPRINTS.entrySet().iterator().next(); + cachedCarveCells -= eldest.getValue().cells(); + CARVE_FOOTPRINTS.remove(eldest.getKey()); + } + CARVE_FOOTPRINTS.put(key, new CachedCarveFootprint(footprint, cells)); + cachedCarveCells += cells; } - CARVE_FOOTPRINTS.put(key, footprint); - return footprint; } static OrganicCarve organicCarve(StructureCarvingFootprint footprint, IrisStructureTerrain terrain, @@ -235,9 +696,6 @@ public final class NativeStructureTerrainIntegrator { Supplier templates, StructureCarvingFootprint.ColumnSink sink) { for (StructurePiece piece : start.getPieces()) { - if (NativeStructureReferenceEnvelope.isMarker(piece)) { - continue; - } BoundingBox bounds = piece.getBoundingBox(); if (!(piece instanceof PoolElementStructurePiece poolPiece) || !emitTemplateColumns(pieceTemplates(poolPiece, templates), @@ -373,7 +831,7 @@ public final class NativeStructureTerrainIntegrator { continue; } BlockState fill = palette == null - ? defaultEncaseBlock(y) + ? defaultEncaseBlock(world, y) : Objects.requireNonNull( paletteBlockResolver.resolve(palette, rng, x, y, z), "Encase palette returned no block for " + structureId + " at " @@ -393,6 +851,41 @@ public final class NativeStructureTerrainIntegrator { return y < 0 ? Blocks.DEEPSLATE.defaultBlockState() : Blocks.STONE.defaultBlockState(); } + static BlockState defaultEncaseBlock(WorldGenLevel world, int y) { + ServerLevel level = world == null ? null : world.getLevel(); + if (level == null) { + return defaultEncaseBlock(y); + } + Holder dimensionType = level.dimensionTypeRegistration(); + ResourceKey dimensionTypeKey = dimensionType.unwrapKey().orElse(null); + return defaultEncaseBlock(level.dimension(), dimensionTypeKey, dimensionType.value(), y); + } + + static BlockState defaultEncaseBlock(ResourceKey dimension, int y) { + return defaultEncaseBlock(dimension, null, null, y); + } + + static BlockState defaultEncaseBlock(ResourceKey dimension, + ResourceKey dimensionTypeKey, + DimensionType dimensionType, int y) { + if (Level.NETHER.equals(dimension)) { + return Blocks.NETHERRACK.defaultBlockState(); + } + if (Level.END.equals(dimension)) { + return Blocks.END_STONE.defaultBlockState(); + } + if (BuiltinDimensionTypes.NETHER.equals(dimensionTypeKey) + || dimensionType != null && dimensionType.hasCeiling() && !dimensionType.hasSkyLight()) { + return Blocks.NETHERRACK.defaultBlockState(); + } + if (BuiltinDimensionTypes.END.equals(dimensionTypeKey) + || dimensionType != null && (dimensionType.hasEnderDragonFight() + || dimensionType.hasEndFlashes())) { + return Blocks.END_STONE.defaultBlockState(); + } + return defaultEncaseBlock(y); + } + private static void carvePieceBoxes(WorldGenLevel world, BoundingBox area, StructureStart start, IrisStructureTerrain terrain) { BlockState air = Blocks.AIR.defaultBlockState(); @@ -431,9 +924,6 @@ public final class NativeStructureTerrainIntegrator { StructureStart start, Supplier templates) { for (StructurePiece piece : start.getPieces()) { - if (NativeStructureReferenceEnvelope.isMarker(piece)) { - continue; - } if (!(piece instanceof PoolElementStructurePiece poolPiece) || poolPiece.getElement().getProjection() != StructureTemplatePool.Projection.RIGID || !intersects(poolPiece.getBoundingBox(), area)) { @@ -496,6 +986,80 @@ public final class NativeStructureTerrainIntegrator { private record CarveFootprintKey(StructureStart start, int padding) { } + private record CachedCarveFootprint(StructureCarvingFootprint footprint, int cells) { + } + + private record JunctionAnchor(int x, int y, int z) { + } + + static final class SourceTerrainSnapshot { + private final BoundingBox area; + private final int width; + private final BlockState[][] statesByLayer; + private final int sampledCells; + + private SourceTerrainSnapshot(BoundingBox area, int width, + BlockState[][] statesByLayer, int sampledCells) { + this.area = area; + this.width = width; + this.statesByLayer = statesByLayer; + this.sampledCells = sampledCells; + } + + static SourceTerrainSnapshot capture( + WorldGenLevel world, BoundingBox area, BitSet requiredLayers) { + Objects.requireNonNull(world, "Source terrain snapshot requires a generation level"); + Objects.requireNonNull(area, "Source terrain snapshot requires writable bounds"); + Objects.requireNonNull(requiredLayers, "Source terrain snapshot requires sampled layers"); + int width = area.getXSpan(); + int depth = area.getZSpan(); + int height = area.getYSpan(); + int horizontalCells = Math.multiplyExact(width, depth); + BlockState[][] statesByLayer = new BlockState[height][]; + int sampledCells = 0; + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (int layer = requiredLayers.nextSetBit(0); + layer >= 0; layer = requiredLayers.nextSetBit(layer + 1)) { + if (layer >= height) { + throw new IllegalArgumentException( + "Source terrain snapshot layer exceeds writable bounds: " + layer); + } + BlockState[] states = new BlockState[horizontalCells]; + statesByLayer[layer] = states; + sampledCells = Math.addExact(sampledCells, horizontalCells); + int y = Math.addExact(area.minY(), layer); + for (int z = area.minZ(); z <= area.maxZ(); z++) { + for (int x = area.minX(); x <= area.maxX(); x++) { + int index = (z - area.minZ()) * width + x - area.minX(); + states[index] = sourceTerrainBlock( + world.getBlockState(position.set(x, y, z))); + } + } + } + return new SourceTerrainSnapshot(new BoundingBox( + area.minX(), area.minY(), area.minZ(), + area.maxX(), area.maxY(), area.maxZ()), width, + statesByLayer, sampledCells); + } + + BlockState stateAt(BlockPos position) { + if (!area.isInside(position)) { + return null; + } + BlockState[] states = statesByLayer[position.getY() - area.minY()]; + if (states == null) { + return null; + } + int index = (position.getZ() - area.minZ()) * width + + position.getX() - area.minX(); + return states[index]; + } + + int sampledCells() { + return sampledCells; + } + } + record OrganicCarve(StructureCarvingFootprint footprint, IrisStructureCarveShape shape, int horizontalPadding, int ceilingPadding, int floorPadding, double strength, double frequency, CNG blob, CNG ceilingRoll, CNG floorRoll, diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVanillaLocator.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVanillaLocator.java new file mode 100644 index 000000000..b04eaf2f1 --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVanillaLocator.java @@ -0,0 +1,224 @@ +package art.arcane.iris.nativegen; + +import com.mojang.datafixers.util.Pair; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.SectionPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureCheckResult; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class NativeStructureVanillaLocator { + private NativeStructureVanillaLocator() { + } + + public static Candidate predict(ServerLevel level, HolderSet holders, + BlockPos origin, int radius, boolean findUnexplored) { + if (SharedConstants.DEBUG_DISABLE_FEATURES + || !level.getServer().getWorldGenSettings().options().generateStructures()) { + return null; + } + ChunkGeneratorStructureState state = level.getChunkSource().getGeneratorState(); + Map>> byPlacement = new LinkedHashMap<>(); + for (Holder holder : holders) { + for (StructurePlacement placement : state.getPlacementsForStructure(holder)) { + byPlacement.computeIfAbsent(placement, ignored -> new LinkedHashSet<>()).add(holder); + } + } + if (byPlacement.isEmpty()) { + return null; + } + + StructureManager structureManager = level.structureManager(); + Candidate best = null; + double bestDistance = Double.MAX_VALUE; + List>>> randomPlacements = + new ArrayList<>(byPlacement.size()); + for (Map.Entry>> entry : byPlacement.entrySet()) { + StructurePlacement placement = entry.getKey(); + if (placement instanceof ConcentricRingsStructurePlacement concentric) { + Candidate candidate = predictConcentric( + entry.getValue(), level, structureManager, origin, findUnexplored, + state, concentric); + if (candidate != null) { + double distance = origin.distSqr(candidate.result().getFirst()); + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + } else if (placement instanceof RandomSpreadStructurePlacement randomSpread) { + randomPlacements.add(Map.entry(randomSpread, entry.getValue())); + } + } + + int centerChunkX = SectionPos.blockToSectionCoord(origin.getX()); + int centerChunkZ = SectionPos.blockToSectionCoord(origin.getZ()); + int searchRadius = Math.max(0, radius); + for (int ring = 0; ring <= searchRadius; ring++) { + boolean foundInRing = false; + for (Map.Entry>> entry + : randomPlacements) { + Candidate candidate = predictRandomSpread( + entry.getValue(), level, structureManager, + centerChunkX, centerChunkZ, ring, findUnexplored, + state.getLevelSeed(), entry.getKey()); + if (candidate == null) { + continue; + } + foundInRing = true; + double distance = origin.distSqr(candidate.result().getFirst()); + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + if (foundInRing) { + return best; + } + } + return best; + } + + private static Candidate predictConcentric(Set> holders, + ServerLevel level, + StructureManager structureManager, + BlockPos origin, + boolean findUnexplored, + ChunkGeneratorStructureState state, + ConcentricRingsStructurePlacement placement) { + List positions = state.getRingPositionsFor(placement); + if (positions == null) { + throw new IllegalStateException( + "Tried to locate structures for an unavailable concentric placement"); + } + Candidate best = null; + double bestDistance = Double.MAX_VALUE; + for (ChunkPos position : positions) { + BlockPos locatePosition = new BlockPos( + SectionPos.sectionToBlockCoord(position.x(), 8), + 32, + SectionPos.sectionToBlockCoord(position.z(), 8)); + double distance = locatePosition.distSqr(origin); + if (best != null && distance >= bestDistance) { + continue; + } + Candidate candidate = predictAt( + holders, level, structureManager, findUnexplored, placement, position); + if (candidate != null) { + best = candidate; + bestDistance = distance; + } + } + return best; + } + + private static Candidate predictRandomSpread(Set> holders, + ServerLevel level, + StructureManager structureManager, + int centerChunkX, int centerChunkZ, + int ring, boolean findUnexplored, + long seed, + RandomSpreadStructurePlacement placement) { + int spacing = placement.spacing(); + for (int offsetX = -ring; offsetX <= ring; offsetX++) { + boolean edgeX = offsetX == -ring || offsetX == ring; + for (int offsetZ = -ring; offsetZ <= ring; offsetZ++) { + boolean edgeZ = offsetZ == -ring || offsetZ == ring; + if (!edgeX && !edgeZ) { + continue; + } + int gridChunkX = centerChunkX + spacing * offsetX; + int gridChunkZ = centerChunkZ + spacing * offsetZ; + ChunkPos candidatePosition = placement.getPotentialStructureChunk( + seed, gridChunkX, gridChunkZ); + Candidate candidate = predictAt( + holders, level, structureManager, + findUnexplored, placement, candidatePosition); + if (candidate != null) { + return candidate; + } + } + } + return null; + } + + private static Candidate predictAt(Set> holders, + ServerLevel level, + StructureManager structureManager, + boolean findUnexplored, + StructurePlacement placement, + ChunkPos candidatePosition) { + for (Holder holder : holders) { + Structure structure = holder.value(); + StructureCheckResult result = structureManager.checkStructurePresence( + candidatePosition, structure, placement, findUnexplored); + if (result == StructureCheckResult.START_NOT_PRESENT) { + continue; + } + if (!findUnexplored && result == StructureCheckResult.START_PRESENT) { + return new Candidate( + Pair.of(placement.getLocatePos(candidatePosition), holder), null); + } + ChunkAccess chunk = level.getChunk( + candidatePosition.x(), candidatePosition.z(), ChunkStatus.STRUCTURE_STARTS); + StructureStart start = structureManager.getStartForStructure( + SectionPos.bottomOf(chunk), structure, chunk); + if (start == null || !start.isValid() + || findUnexplored && !start.canBeReferenced()) { + continue; + } + return new Candidate( + Pair.of(placement.getLocatePos(start.getChunkPos()), holder), + findUnexplored ? start : null); + } + return null; + } + + public static final class Candidate { + private final Pair> result; + private final StructureStart referenceStart; + private final AtomicBoolean committed; + + private Candidate(Pair> result, + StructureStart referenceStart) { + this.result = result; + this.referenceStart = referenceStart; + this.committed = new AtomicBoolean(); + } + + public Pair> result() { + return result; + } + + public boolean reference(StructureManager structureManager) { + if (referenceStart == null || !committed.compareAndSet(false, true)) { + return false; + } + if (!referenceStart.canBeReferenced()) { + return false; + } + structureManager.addReference(referenceStart); + return true; + } + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java index c5a0b879c..b02999fd6 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java @@ -43,13 +43,17 @@ public final class NativeStructureVegetationClearer { } boolean[] clearColumns = new boolean[area.getXSpan() * area.getZSpan()]; for (VegetationTarget target : targets) { - if (target != null && target.force() && target.start() != null && target.start().isValid()) { + if (shouldProcessTarget(target)) { markVegetationColumns(area, snapshot, target, clearColumns); } } clearVegetationColumns(world, area, snapshot, clearColumns); } + static boolean shouldProcessTarget(VegetationTarget target) { + return target != null && target.start() != null && target.start().isValid(); + } + private static VegetationSnapshot captureVegetation(ChunkAccess chunk, BoundingBox area) { int width = area.getXSpan(); int depth = area.getZSpan(); @@ -100,9 +104,6 @@ public final class NativeStructureVegetationClearer { int[] pieceTops = new int[clearColumns.length]; Arrays.fill(pieceTops, Integer.MIN_VALUE); for (StructurePiece piece : target.start().getPieces()) { - if (NativeStructureReferenceEnvelope.isMarker(piece)) { - continue; - } BoundingBox bounds = piece.getBoundingBox(); int minX = Math.max(area.minX(), bounds.minX()); int maxX = Math.min(area.maxX(), bounds.maxX()); diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVerticalPlacer.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVerticalPlacer.java index 77ca93164..789f24716 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVerticalPlacer.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVerticalPlacer.java @@ -15,8 +15,11 @@ import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece; +import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure; import net.minecraft.world.level.levelgen.structure.structures.JungleTemplePiece; +import net.minecraft.world.level.levelgen.structure.structures.JungleTempleStructure; import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure; import java.util.ArrayList; import java.util.List; @@ -39,11 +42,11 @@ public final class NativeStructureVerticalPlacer { boolean underground, boolean preserveSourceY, IrisStructureYBand yBand, IntBinaryOperator surfaceHeight) { - if (isOceanMonument(structureId)) { + if (isOceanMonument(start, structureId)) { return alignOceanMonumentToSeaLevel( start, requestedOffset, seaLevel, worldMinY, worldMaxYExclusive); } - if (isAdjustedScatteredStructure(structureId)) { + if (isAdjustedScatteredStructure(start, structureId)) { return alignScatteredStructureToSurface( start, structureId, requestedOffset, worldMinY, worldMaxYExclusive, surfaceHeight); } @@ -169,18 +172,28 @@ public final class NativeStructureVerticalPlacer { static void ensureMonumentSeaLevelAlignment(StructureStart start, String structureId, int configuredOffset, int seaLevel, int worldMinY, int worldMaxYExclusive) { - if (isOceanMonument(structureId)) { + if (!isOceanMonument(start, structureId)) { + return; + } + synchronized (start) { alignOceanMonumentToSeaLevel( start, configuredOffset, seaLevel, worldMinY, worldMaxYExclusive); } } - private static boolean isOceanMonument(String structureId) { - return OCEAN_MONUMENT_ID.equals(structureId); + private static boolean isOceanMonument(StructureStart start, String structureId) { + return OCEAN_MONUMENT_ID.equals(structureId) + && start != null + && start.getStructure() instanceof OceanMonumentStructure; } - private static boolean isAdjustedScatteredStructure(String structureId) { - return DESERT_PYRAMID_ID.equals(structureId) || JUNGLE_PYRAMID_ID.equals(structureId); + private static boolean isAdjustedScatteredStructure(StructureStart start, String structureId) { + if (start == null) { + return false; + } + Structure source = start.getStructure(); + return (DESERT_PYRAMID_ID.equals(structureId) && source instanceof DesertPyramidStructure) + || (JUNGLE_PYRAMID_ID.equals(structureId) && source instanceof JungleTempleStructure); } private static ScatteredFeaturePiece requireAdjustedScatteredPiece(StructureStart start, @@ -252,9 +265,15 @@ public final class NativeStructureVerticalPlacer { private static OceanMonumentPieces.MonumentBuilding requireOceanMonumentBuilding(StructureStart start) { Objects.requireNonNull(start, "Ocean monument start must not be null"); List pieces = start.getPieces(); - if (pieces.size() != 1 || !(pieces.get(0) instanceof OceanMonumentPieces.MonumentBuilding building)) { + OceanMonumentPieces.MonumentBuilding building = null; + for (StructurePiece piece : pieces) { + if (piece instanceof OceanMonumentPieces.MonumentBuilding monumentBuilding) { + building = monumentBuilding; + } + } + if (pieces.size() != 1 || building == null) { throw new IllegalStateException("minecraft:monument must contain exactly one MonumentBuilding, found " - + pieces.size() + " top-level pieces"); + + pieces.size() + " pieces"); } return building; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index c326fd906..becbab540 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -27,7 +27,8 @@ import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.nativegen.NativeStructureStartInjector; -import art.arcane.iris.nativegen.NativeStructureLocateResults; +import art.arcane.iris.nativegen.NativeStructureReferenceRepair; +import art.arcane.iris.nativegen.NativeStructureVanillaLocator; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; @@ -294,14 +295,14 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { Engine current = engine(); try (GenerationSessionLease lease = requireGenerationLease(current, "modded_structure_locate"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { - Pair> irisPlaced = nativeStructures.findNearestIrisStructure( - level, holders, pos, Math.max(1, radius), findUnexplored, current); HolderSet reachable = nativeStructures.filterReachableNativeStructures( level, holders, current); - Pair> nativeLocated = reachable.size() == 0 - ? null - : super.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); - return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); + NativeStructureVanillaLocator.Candidate nativeCandidate = + reachable.size() == 0 ? null + : NativeStructureVanillaLocator.predict( + level, reachable, pos, radius, findUnexplored); + return nativeStructures.findNearestIrisStructure( + level, holders, pos, Math.max(0, radius), findUnexplored, current, nativeCandidate); } } @@ -879,7 +880,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { Engine current = engine(); try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_references"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { - super.createReferences(level, structureManager, chunk); + NativeStructureReferenceRepair.createReferences( + current, level, structureManager, chunk); } } 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 2ee2bb8b4..6fa1d8dbb 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 @@ -23,7 +23,7 @@ import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.RuntimeUiMessages; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.IDataFixer; -import art.arcane.iris.core.pack.PackValidationRegistry; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.engine.object.IrisDimension; @@ -74,7 +74,6 @@ public final class ModdedForcedDatapack { // v2: custom biomes now inherit their vanilla derivative's biome tags, so every already-published pack has // to regenerate once. private static final String HASH_SALT = "iris-forced-datapack-v2"; - private static final String GIT_DIRECTORY = ".git"; private static final long PACKS_HASH_TTL_NANOS = 2_000_000_000L; private static final Object LOCK = new Object(); private static final AtomicBoolean LOADED = new AtomicBoolean(false); @@ -147,13 +146,13 @@ public final class ModdedForcedDatapack { return; } Path packsRoot = packsRoot(); - File[] packs = packsRoot.toFile().listFiles(File::isDirectory); - if (packs == null || packs.length == 0) { + List packs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot.toFile()); + if (packs.isEmpty()) { return; } LOGGER.error("==============================================================="); LOGGER.error("Iris forced datapack '{}' was never loaded by this server.", PACK_ID); - LOGGER.error("{} installed pack(s) at {} contributed no dimension types or custom biomes.", packs.length, packsRoot); + LOGGER.error("{} installed pack(s) at {} contributed no dimension types or custom biomes.", packs.size(), packsRoot); LOGGER.error("Datapack source injection failed for this loader (mixin/event not applied), so world creation will fail and restarting will not fix it."); LOGGER.error("==============================================================="); } @@ -360,8 +359,8 @@ public final class ModdedForcedDatapack { Files.walkFileTree(root, new SimpleFileVisitor() { @Override public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) { - // Studio packs can be git checkouts; .git churns constantly and never reaches the datapack. - return GIT_DIRECTORY.equals(directory.getFileName().toString()) + return !directory.equals(root) + && PackDirectoryResolver.isHiddenName(directory.getFileName().toString()) ? FileVisitResult.SKIP_SUBTREE : FileVisitResult.CONTINUE; } @@ -397,16 +396,10 @@ public final class ModdedForcedDatapack { int packCount = 0; KList presetIds = new KList<>(); File root = packsRoot().toFile(); - File[] packs = root.listFiles(File::isDirectory); - if (packs == null && root.exists()) { - throw new IOException("Iris could not read installed pack directory " + root.getAbsolutePath()); - } - if (packs != null) { - Arrays.sort(packs, Comparator.comparing(File::getName)); - for (File pack : packs) { - if (stagePack(pack, fixer, stagingDirectory, seenBiomes, presetIds)) { - packCount++; - } + List packs = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(root); + for (File pack : packs) { + if (stagePack(pack, fixer, stagingDirectory, seenBiomes, presetIds)) { + packCount++; } } @@ -431,8 +424,7 @@ public final class ModdedForcedDatapack { KList presetIds) throws IOException { PackValidationResult validation; try { - validation = PackValidator.validate(sourcePack); - PackValidationRegistry.publish(validation); + validation = PackValidator.validateForDatapackBootstrap(sourcePack); } catch (Throwable validationFailure) { LOGGER.error("Iris excluded pack '{}' from Create World because validation failed", sourcePack.getName(), validationFailure); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java index 54e67458f..418908063 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java @@ -21,17 +21,21 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; -import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.nativegen.NativeStructureGenerationException; +import art.arcane.iris.nativegen.NativeStructureLocatePersistence; +import art.arcane.iris.nativegen.NativeStructureLocateResults; +import art.arcane.iris.nativegen.NativeStructureOwnershipRecovery; import art.arcane.iris.nativegen.NativeStructurePostProcessor; import art.arcane.iris.nativegen.NativeStructureReferenceEnvelope; import art.arcane.iris.nativegen.NativeStructureSurfaceFitter; import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator; import art.arcane.iris.nativegen.NativeStructureVegetationClearer; import art.arcane.iris.nativegen.NativeStructureVerticalPlacer; +import art.arcane.iris.nativegen.NativeStructureVanillaLocator; import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.math.RNG; @@ -60,6 +64,7 @@ import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -85,42 +90,85 @@ final class ModdedNativeStructureStage { Pair> findNearestIrisStructure(ServerLevel level, HolderSet holders, BlockPos pos, int radius, boolean findUnexplored, - Engine current) { - if (findUnexplored) { - return null; - } + Engine current, + NativeStructureVanillaLocator.Candidate nativeCandidate) { + Pair> nativeLocated = + nativeCandidate == null ? null : nativeCandidate.result(); + Runnable nativeReference = () -> { + if (nativeCandidate != null) { + nativeCandidate.reference(level.structureManager()); + } + }; Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); - BlockPos best = null; - Holder bestHolder = null; - long bestDistance = Long.MAX_VALUE; + List searches = new ArrayList<>(holders.size()); + NativeStructureLocatePersistence.ProbeBudget budget = NativeStructureLocatePersistence.probeBudget(); for (Holder holder : holders) { Identifier id = registry.getKey(holder.value()); if (id == null) { throw new IllegalStateException("Native structure locate received an unregistered structure holder"); } String structureId = id.toString(); - if (!IrisStructureLocator.isPlaced(current, structureId)) { + if (!IrisStructureLocator.hasNativePlacement(current, structureId)) { continue; } - IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( - current, structureId, pos.getX(), pos.getZ(), radius); - if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { - throw new IllegalStateException("Iris structure locate reached its safety limit for " - + structureId + " within " + radius + " chunks"); - } - if (!result.found()) { - continue; - } - long dx = (long) result.originX() - pos.getX(); - long dz = (long) result.originZ() - pos.getZ(); - long distance = dx * dx + dz * dz; - if (distance < bestDistance) { - bestDistance = distance; - best = new BlockPos(result.originX(), result.baseY(), result.originZ()); - bestHolder = holder; - } + NativeStructureLocatePersistence.Probe probe = NativeStructureLocatePersistence.probe( + level, holder.value(), findUnexplored, budget); + searches.add(new IrisNativeLocateSearch( + holder, structureId, NativeStructureLocatePersistence.search( + current, structureId, pos.getX(), pos.getZ(), radius, probe))); } - return best == null ? null : Pair.of(best, bestHolder); + searches.sort(Comparator.comparing(IrisNativeLocateSearch::structureId)); + for (int attempt = 0; attempt < NativeStructureLocatePersistence.MAX_SELECTED_CANDIDATE_RETRIES; attempt++) { + IrisNativeLocateSearch bestSearch = null; + IrisStructureLocator.LocateResult bestResult = null; + long bestDistance = Long.MAX_VALUE; + for (IrisNativeLocateSearch search : searches) { + IrisStructureLocator.LocateResult result = search.search().predict(); + if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + throw new IllegalStateException("Iris structure locate reached its safety limit for " + + search.structureId() + " within " + radius + " placement rings"); + } + if (!result.found()) { + continue; + } + long dx = (long) result.originX() - pos.getX(); + long dz = (long) result.originZ() - pos.getZ(); + long distance = dx * dx + dz * dz; + if (distance < bestDistance) { + bestDistance = distance; + bestSearch = search; + bestResult = result; + } + } + if (bestSearch == null) { + return NativeStructureLocateResults.selectAndReference( + pos, null, () -> { }, nativeLocated, nativeReference); + } + Pair> predicted = Pair.of( + new BlockPos(bestResult.originX(), bestResult.baseY(), bestResult.originZ()), + bestSearch.holder()); + if (NativeStructureLocateResults.nearest(pos, predicted, nativeLocated) != predicted) { + return NativeStructureLocateResults.selectAndReference( + pos, predicted, () -> { }, nativeLocated, nativeReference); + } + NativeStructureLocatePersistence.VerifiedStart verified = + bestSearch.search().verify(bestResult); + if (verified == null) { + bestSearch.search().reject(bestResult); + continue; + } + BlockPos located = new BlockPos( + bestResult.originX(), verified.ownership().locatorY(), + bestResult.originZ()); + Pair> irisLocated = Pair.of(located, bestSearch.holder()); + IrisNativeLocateSearch selectedSearch = bestSearch; + NativeStructureLocatePersistence.VerifiedStart selectedStart = verified; + return NativeStructureLocateResults.selectAndReference( + pos, irisLocated, () -> selectedSearch.search().reference(selectedStart), + nativeLocated, nativeReference); + } + throw new IllegalStateException("Iris structure locate rejected too many selected candidates within " + + radius + " placement rings"); } HolderSet filterReachableNativeStructures(ServerLevel level, HolderSet holders, @@ -193,10 +241,14 @@ final class ModdedNativeStructureStage { decision.preserveSourceY(), decision.yBand(), (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); - StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( - start, structure, start.getReferences(), templateManager, - NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain())); + StructureStart wrapped = NativeStructureReferenceEnvelope.wrapForPublication( + start, structure, start.getReferences(), + NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()), + structureId); chunk.setStartForStructure(structure, wrapped); + if (!wrapped.isValid()) { + continue; + } } catch (Throwable error) { throw NativeStructureGenerationException.failure( "vertical adjustment", structureId, chunkPos.x(), chunkPos.z(), error); @@ -227,7 +279,6 @@ final class ModdedNativeStructureStage { Engine current = generator.engine(); List placementGroups = new ArrayList<>(); List heightmapStarts = new ArrayList<>(); - List nativeStarts = new ArrayList<>(); List vegetationTargets = new ArrayList<>(); List terrainTargets = new ArrayList<>(); for (int step = 0; step < steps; step++) { @@ -245,10 +296,11 @@ final class ModdedNativeStructureStage { List starts = structureManager.startsForStructure(sectionPos, structure); List resolvedPlacements = new ArrayList<>(starts.size()); for (StructureStart start : starts) { - NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan( - current, structureId, start.getChunkPos().x(), start.getChunkPos().z()); - IrisNativeStructureDecision decision = plan == null - ? sourceDecision : NativeStructurePlacementPlanner.decisionFor(plan); + NativeStructureOwnershipRecord ownership = + NativeStructureOwnershipRecovery.resolve( + current, world.getLevel(), structureId, structure, start); + IrisNativeStructureDecision decision = + ownership == null ? sourceDecision : ownership.restoredDecision(); if (!decision.generate()) { continue; } @@ -258,9 +310,6 @@ final class ModdedNativeStructureStage { structureId, start, NativeStructureTerrainIntegrator.resolveNativeTerrain( start, decision.terrain()))); - if (plan == null || !plan.placement().isUnderground()) { - nativeStarts.add(start); - } boolean clearEntireFootprint = NativeStructureVegetationClearer .shouldClearEntireVegetationFootprint( structure.step(), decision.clearVegetation()); @@ -289,15 +338,6 @@ final class ModdedNativeStructureStage { "heightmap priming", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } - try { - NativeStructureSurfaceFitter.prepareSurfaceStructures( - world, area, nativeStarts, - (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); - } catch (Throwable error) { - throw NativeStructureGenerationException.failure( - "terrain integration", nativeStructureBatchContext(placementGroups), - chunkPos.x(), chunkPos.z(), error); - } try { NativeStructureVegetationClearer.clearIntersectingVegetation( world, chunk, area, vegetationTargets); @@ -306,6 +346,15 @@ final class ModdedNativeStructureStage { "vegetation cleanup", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } + try { + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world, area, terrainTargets, + (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "terrain integration", nativeStructureBatchContext(placementGroups), + chunkPos.x(), chunkPos.z(), error); + } try { NativeStructurePostProcessor.prepareTerrain( world, area, terrainTargets, this::resolvePaletteBlock); @@ -413,8 +462,8 @@ final class ModdedNativeStructureStage { ChunkPos chunkPos = chunk.getPos(); int minX = chunkPos.getMinBlockX(); int minZ = chunkPos.getMinBlockZ(); - int minY = chunk.getMinY(); - int maxY = minY + chunk.getHeight() - 1; + int minY = chunk.getMinY() + 1; + int maxY = chunk.getMinY() + chunk.getHeight() - 1; return new BoundingBox(minX, minY, minZ, minX + 15, maxY, minZ + 15); } @@ -436,6 +485,10 @@ final class ModdedNativeStructureStage { List placements) { } + private record IrisNativeLocateSearch(Holder holder, String structureId, + NativeStructureLocatePersistence.Search search) { + } + private record StructureStepCache(Registry registry, List> structures) { } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java index d5395649f..567462c3c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java @@ -56,8 +56,8 @@ public final class ModdedPackInstaller { synchronized (installLock) { File packs = configDir.resolve("irisworldgen").resolve("packs").toFile(); try { - boolean installed = PackDownloader.isDefaultOverworld(pack) - ? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) != null + PackDownloader.PackInstallResult result = PackDownloader.isDefaultOverworld(pack) + ? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) : PackDownloader.download( packs, "IrisDimensions/" + pack, @@ -65,8 +65,9 @@ public final class ModdedPackInstaller { forceOverwrite, false, pack, - feedback) != null; - if (installed) { + feedback); + boolean installed = result != null; + if (result != null && result.changed()) { // Pack-install completion is one of the four forced-datapack regeneration triggers; every // install call site already runs off the server thread, so regenerate inline here. A // regeneration failure must never turn a successful install into a failed one. @@ -76,6 +77,9 @@ public final class ModdedPackInstaller { LOGGER.error("Iris installed pack '{}' but could not regenerate the forced datapack", pack, regenerationFailure); } } + if (result != null && result.restartRequired()) { + feedback.accept("Pack '" + pack + "' is installed on disk and requires a server restart before its active data changes."); + } return installed; } catch (IOException error) { LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java index a4fc38929..19c269bd3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java @@ -28,6 +28,7 @@ import art.arcane.iris.spi.PlatformEntityType; import art.arcane.iris.spi.PlatformItem; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.data.UnresolvedKeyLog; +import net.minecraft.core.HolderLookup; import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; @@ -40,6 +41,7 @@ import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.world.level.storage.loot.LootTable; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -212,6 +214,20 @@ public final class ModdedRegistries implements PlatformRegistries { return keys; } + @Override + public List lootTableKeys() { + List keys = new ArrayList<>(); + MinecraftServer instance = server.get(); + if (instance == null) { + warnNotReady("loot table"); + return keys; + } + HolderLookup.RegistryLookup registry = instance.reloadableRegistries().lookup() + .lookupOrThrow(Registries.LOOT_TABLE); + registry.listElementIds().forEach(key -> keys.add(key.identifier().toString())); + return keys; + } + @Override public Map> blockStateProperties() { Map> properties = new LinkedHashMap<>(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 991a549c8..25f4520e9 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -19,6 +19,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.pack.BrokenPackException; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; @@ -104,9 +105,9 @@ public final class ModdedStartup { public static void validateAllPacks() { File packsRoot = ModdedPackCommands.packsRoot(); - File[] packDirs = packsRoot.listFiles(File::isDirectory); + List packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); PackValidationRegistry.clear(); - if (packDirs == null || packDirs.length == 0) { + if (packDirs.isEmpty()) { LOGGER.info("Iris found no packs to validate under {}; install one with /iris download ", packsRoot.getAbsolutePath()); return; @@ -146,8 +147,8 @@ public final class ModdedStartup { if (pack == null || pack.isBlank()) { throw new IllegalArgumentException("Pack name is required for world creation"); } - File packDir = new File(ModdedPackCommands.packsRoot(), pack); - if (!packDir.isDirectory()) { + File packDir = PackDirectoryResolver.resolveExisting(ModdedPackCommands.packsRoot(), pack); + if (packDir == null) { throw new BrokenPackException(pack, List.of( "Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + ".")); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java index 4f14a0483..e36bdc40e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java @@ -19,8 +19,10 @@ package art.arcane.iris.modded; import art.arcane.iris.nativegen.NativeStructureGenerationException; +import art.arcane.iris.nativegen.NativeStructureFactory; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata; import art.arcane.iris.spi.PlatformWorld; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; @@ -95,6 +97,67 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { return registryKeys(Registries.TEMPLATE_POOL); } + @Override + public JigsawSourceMetadata jigsawSourceMetadata(String structureKey) { + MinecraftServer instance = requireServer("resolve live jigsaw metadata for registered structure '" + + structureKey + "'"); + try { + Identifier identifier = Identifier.tryParse(structureKey); + if (identifier == null) { + throw new IllegalArgumentException("Invalid registered structure key: " + structureKey); + } + Registry registry = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Structure structure = registry.getValue(identifier); + if (structure == null) { + throw new IllegalArgumentException("Registered structure does not exist: " + structureKey); + } + if (!(structure instanceof JigsawStructure jigsaw)) { + throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey); + } + return NativeStructureFactory.sourceMetadata( + instance.registryAccess(), instance.getStructureManager(), jigsaw); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve live jigsaw metadata for registered structure '" + + structureKey + "' from the modded structure registry", error); + } + } + + @Override + public int templatePoolHorizontalSpan(String templatePoolKey) { + MinecraftServer instance = requireServer("resolve the live horizontal span for registered template pool '" + + templatePoolKey + "'"); + try { + return NativeStructureFactory.templatePoolHorizontalSpan( + instance.registryAccess(), instance.getStructureManager(), templatePoolKey); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve the live horizontal span for registered " + + "template pool '" + templatePoolKey + "' from the modded template-pool registry", error); + } + } + + @Override + public int jigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) { + MinecraftServer instance = requireServer("resolve the effective start-pool span for registered jigsaw '" + + structureKey + "'"); + try { + Identifier identifier = Identifier.tryParse(structureKey); + if (identifier == null) { + throw new IllegalArgumentException("Invalid registered structure key: " + structureKey); + } + Structure structure = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE) + .getValue(identifier); + if (!(structure instanceof JigsawStructure jigsaw)) { + throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey); + } + return NativeStructureFactory.jigsawStartPoolHorizontalSpan( + instance.registryAccess(), instance.getStructureManager(), jigsaw, templatePoolKey); + } catch (RuntimeException error) { + throw new IllegalStateException("Iris failed to resolve the effective start-pool span for registered " + + "jigsaw structure '" + structureKey + "' and pool '" + templatePoolKey + + "' from the modded registries", error); + } + } + @Override public List structureSetKeys() { return registryKeys(Registries.STRUCTURE_SET); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java index 07f01fb63..adc1e16fb 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.modded.IrisModdedChunkGenerator; @@ -153,24 +154,18 @@ final class ModdedCommandSuggestions { names.add("overworld"); try { File packs = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile(); - File[] children = packs.listFiles(); - if (children != null) { - for (File child : children) { - if (!child.isDirectory()) { - continue; - } - String packName = child.getName(); - names.add(packName); - File dimensions = new File(child, "dimensions"); - File[] dimensionFiles = dimensions.listFiles( - (File directory, String name) -> name.endsWith(".json")); - if (dimensionFiles == null) { - continue; - } - for (File dimensionFile : dimensionFiles) { - String fileName = dimensionFile.getName(); - names.add(packName + ":" + fileName.substring(0, fileName.length() - 5)); - } + for (File child : PackDirectoryResolver.listVisiblePackDirectories(packs)) { + String packName = child.getName(); + names.add(packName); + File dimensions = new File(child, "dimensions"); + File[] dimensionFiles = dimensions.listFiles( + (File directory, String name) -> name.endsWith(".json")); + if (dimensionFiles == null) { + continue; + } + for (File dimensionFile : dimensionFiles) { + String fileName = dimensionFile.getName(); + names.add(packName + ":" + fileName.substring(0, fileName.length() - 5)); } } } catch (Throwable e) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java index 454f587b0..ad3b7d92f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded.command; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.datapack.DataVersion; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.modded.IrisModdedChunkGenerator; @@ -42,8 +43,12 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Set; import java.util.function.Predicate; import art.arcane.iris.core.localization.IrisLanguage; @@ -74,11 +79,11 @@ public final class ModdedDatapackCommands { root.then(Commands.literal("ls") .executes((CommandContext context) -> list(context.getSource()))); - root.then(message("ingest", "Modrinth datapack ingest requires the Bukkit plugin because its editable-resource import and manifest workflow use Bukkit tooling. Iris modded dimensions do run native vanilla and datapack structure placement; install the datapack in world/datapacks and restart to generate its registered structures.")); - root.then(message("pull", "Modrinth datapack ingest requires the Bukkit plugin because its editable-resource import and manifest workflow use Bukkit tooling. Iris modded dimensions do run native vanilla and datapack structure placement; install the datapack in world/datapacks and restart to generate its registered structures.")); + root.then(message("ingest", "Managed Modrinth datapack ingest is Bukkit-only. On modded servers install the datapack folder or zip in world/datapacks, enable it, restart, then use /iris datapack list to confirm it is enabled. Registered structures generate natively in Iris dimensions.")); + root.then(message("pull", "Managed Modrinth datapack ingest is Bukkit-only. On modded servers install the datapack folder or zip in world/datapacks, enable it, restart, then use /iris datapack list to confirm it is enabled. Registered structures generate natively in Iris dimensions.")); - root.then(message("remove", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart.")); - root.then(message("rm", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart.")); + root.then(message("remove", "Managed datapack removal is Bukkit-only. On modded servers disable the pack, delete its folder or zip from world/datapacks, and restart.")); + root.then(message("rm", "Managed datapack removal is Bukkit-only. On modded servers disable the pack, delete its folder or zip from world/datapacks, and restart.")); return root; } @@ -215,27 +220,24 @@ public final class ModdedDatapackCommands { MinecraftServer server = source.getServer(); LinkedHashSet configured = new LinkedHashSet<>(); File packsRoot = ModdedPackCommands.packsRoot(); - File[] packs = packsRoot.isDirectory() ? packsRoot.listFiles(File::isDirectory) : null; - if (packs != null) { - for (File pack : packs) { - if (!new File(pack, "dimensions").isDirectory()) { - continue; - } - try { - IrisData data = IrisData.get(pack); - for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) { - if (dimension == null || dimension.getDatapackImports() == null) { - continue; - } - for (String url : dimension.getDatapackImports()) { - if (url != null && !url.isBlank()) { - configured.add(url.trim()); - } + for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsRoot)) { + if (!new File(pack, "dimensions").isDirectory()) { + continue; + } + try { + IrisData data = IrisData.get(pack); + for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) { + if (dimension == null || dimension.getDatapackImports() == null) { + continue; + } + for (String url : dimension.getDatapackImports()) { + if (url != null && !url.isBlank()) { + configured.add(url.trim()); } } - } catch (Throwable e) { - LOGGER.error("Iris datapack import scan failed for pack {}", pack.getName(), e); } + } catch (Throwable e) { + LOGGER.error("Iris datapack import scan failed for pack {}", pack.getName(), e); } } @@ -248,19 +250,45 @@ public final class ModdedDatapackCommands { } File datapacks = worldDatapacksFolder(server); - File[] installed = datapacks.isDirectory() ? datapacks.listFiles(File::isDirectory) : null; + File[] installed = datapacks.isDirectory() + ? datapacks.listFiles(file -> file.isDirectory() || file.isFile() && file.getName().toLowerCase(Locale.ROOT).endsWith(".zip")) + : null; + Set availableIds = new HashSet<>(server.getPackRepository().getAvailableIds()); + Set selectedIds = new HashSet<>(server.getPackRepository().getSelectedIds()); KList names = new KList<>(); if (installed != null) { - for (File folder : installed) { - if (new File(folder, "pack.mcmeta").isFile()) { - names.add(folder.getName()); + for (File installedPack : installed) { + String name = installedPack.getName(); + String repositoryId = resolveRepositoryId(name, availableIds); + String state; + if (repositoryId == null) { + state = "unavailable"; + } else if (selectedIds.contains(repositoryId)) { + state = "enabled"; + } else { + state = "disabled"; } + names.add(name + " [" + state + "]"); } } + Collections.sort(names); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_INSTALLED_WORLD_DATAPACKS, MessageArgument.untrusted("value", names.size()))); for (String name : names) { IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_MESSAGE_2, MessageArgument.untrusted("name", name))); } return 1; } + + private static String resolveRepositoryId(String filename, Set availableIds) { + String direct = "file/" + filename; + if (availableIds.contains(direct)) { + return direct; + } + for (String availableId : availableIds) { + if (availableId.equals(filename) || availableId.endsWith("/" + filename)) { + return availableId; + } + } + return null; + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java index 2a74c0377..b48cfd2a7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java @@ -173,10 +173,16 @@ final class ModdedLocateCommands { return 0; } if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { - locateIrisStructure(source, level, engine, player, target.key()); + if (!IrisStructureLocator.hasNativePlacement(engine, target.key())) { + locateIrisStructure(source, level, engine, player, target.key()); + return 1; + } + IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_SEARCHING_NATIVE_STRUCTURE_WITHIN_CHUNKS, MessageArgument.untrusted("value", target.key()), MessageArgument.untrusted("NATIVESTRUCTURELOCATERADIUS", NATIVE_STRUCTURE_LOCATE_RADIUS))); + runNativeStructureLocate(source, level, player, target); return 1; } - if (target.availability() != NativeStructureAvailability.AVAILABLE) { + if (!IrisStructureLocator.hasNativePlacement(engine, target.key()) + && target.availability() != NativeStructureAvailability.AVAILABLE) { IrisModdedCommands.fail(source, nativeUnavailableMessage(target.key(), target.availability())); return 0; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java index 9762865cb..cc8f79d0d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java @@ -110,14 +110,12 @@ public final class ModdedPackCommands { List targets = new ArrayList<>(); if (pack == null || pack.isBlank()) { - File[] dirs = packsRoot.listFiles(File::isDirectory); - if (dirs == null || dirs.length == 0) { + List dirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); + if (dirs.isEmpty()) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_NO_PACKS_VALIDATE_UNDER, MessageArgument.untrusted("value", packsRoot.getAbsolutePath()))); return 0; } - for (File dir : dirs) { - targets.add(dir); - } + targets.addAll(dirs); } else { File target = PackDirectoryResolver.resolveExisting(packsRoot, pack); if (target == null) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java index 288d780f9..dca903da3 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java @@ -48,7 +48,8 @@ public class ModdedGenerationLeaseContractTest { String references = method(source, "public void createReferences("); assertTrue(references.contains("requireGenerationLease(current, \"modded_create_references\")")); assertTrue(references.contains("IrisContext.open(current, lease.sessionId(), null)")); - assertTrue(references.contains("super.createReferences(level, structureManager, chunk);")); + assertTrue(references.contains("NativeStructureReferenceRepair.createReferences(")); + assertFalse(references.contains("super.createReferences(level, structureManager, chunk);")); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java index a1f66b74c..a50527929 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java @@ -80,6 +80,11 @@ public class ModdedStructureHooksTest { IllegalStateException.class, hooks::jigsawStructureKeys); IllegalStateException templatePoolKeys = assertThrows( IllegalStateException.class, hooks::templatePoolKeys); + IllegalStateException jigsawMetadata = assertThrows( + IllegalStateException.class, () -> hooks.jigsawSourceMetadata("minecraft:village")); + IllegalStateException templatePoolSpan = assertThrows( + IllegalStateException.class, + () -> hooks.templatePoolHorizontalSpan("minecraft:village/plains/town_centers")); IllegalStateException structureSetKeys = assertThrows( IllegalStateException.class, hooks::structureSetKeys); IllegalStateException structureBiomeKeys = assertThrows( @@ -88,6 +93,8 @@ public class ModdedStructureHooksTest { assertTrue(structureKeys.getMessage().contains("before the Minecraft server is available")); assertTrue(jigsawStructureKeys.getMessage().contains("before the Minecraft server is available")); assertTrue(templatePoolKeys.getMessage().contains("before the Minecraft server is available")); + assertTrue(jigsawMetadata.getMessage().contains("before the Minecraft server is available")); + assertTrue(templatePoolSpan.getMessage().contains("before the Minecraft server is available")); assertTrue(structureSetKeys.getMessage().contains("before the Minecraft server is available")); assertTrue(structureBiomeKeys.getMessage().contains("before the Minecraft server is available")); } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java index 97ccec1aa..5150a8eae 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java @@ -56,7 +56,7 @@ public class NativeStructureFailureContractTest { } @Test - public void structureTerrainPreparationPrecedesVegetationAndPlacement() throws IOException { + public void structureVegetationCleanupPrecedesTerrainPreparationAndPlacement() throws IOException { String source = moddedSource("ModdedNativeStructureStage.java"); int placementStart = source.indexOf("void placeVanillaStructures"); int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart); @@ -65,9 +65,9 @@ public class NativeStructureFailureContractTest { assertTrue(placement.contains("\"terrain integration\"")); assertTrue(placement.contains("prepareSurfaceStructures")); assertTrue(placement.contains("clearIntersectingVegetation")); - assertTrue(placement.indexOf("prepareSurfaceStructures") - < placement.indexOf("clearIntersectingVegetation")); assertTrue(placement.indexOf("clearIntersectingVegetation") + < placement.indexOf("prepareSurfaceStructures")); + assertTrue(placement.indexOf("prepareSurfaceStructures") < placement.indexOf("for (NativePlacementGroup group")); } @@ -86,6 +86,7 @@ public class NativeStructureFailureContractTest { < placement.indexOf("prepareSurfaceStructures")); assertTrue(source.contains("generationEngine.getHeight(x, z, false) + runtimeMinY + 1")); assertTrue(source.contains("generationEngine.getHeight(x, z, true) + runtimeMinY + 1")); + assertTrue(source.contains("int minY = chunk.getMinY() + 1;")); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/SchemaBuilderPlatformIsolationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/SchemaBuilderPlatformIsolationTest.java new file mode 100644 index 000000000..42d763ab5 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/SchemaBuilderPlatformIsolationTest.java @@ -0,0 +1,53 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.core.project.SchemaBuilder; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class SchemaBuilderPlatformIsolationTest { + @Test + public void enumSchemaDoesNotResolveBukkitOnlyMethodSignatures() { + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.block.BlockFace")); + + JSONObject schema = new SchemaBuilder(DirectionModel.class, null).construct(); + JSONObject direction = schema.getJSONObject("properties").getJSONObject("direction"); + String definitionKey = direction.getString("$ref").substring("#/definitions/".length()); + JSONArray values = schema.getJSONObject("definitions").getJSONObject(definitionKey).getJSONArray("oneOf"); + List names = new ArrayList<>(values.length()); + for (int index = 0; index < values.length(); index++) { + names.add(values.getJSONObject(index).getString("const")); + } + + assertEquals(List.of( + "UP_POSITIVE_Y", + "DOWN_NEGATIVE_Y", + "NORTH_NEGATIVE_Z", + "SOUTH_POSITIVE_Z", + "EAST_POSITIVE_X", + "WEST_NEGATIVE_X"), names); + assertEquals("#/definitions/" + definitionKey, + schema.getJSONObject("properties").getJSONObject("directions") + .getJSONObject("items").getString("$ref")); + } + + @Desc("Direction model.") + public static class DirectionModel { + @Desc("Direction.") + private IrisDirection direction = IrisDirection.NORTH_NEGATIVE_Z; + + @Desc("Directions.") + @ArrayType(type = IrisDirection.class) + private KList directions = new KList<>(); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java index 78e93054d..f593598b0 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java @@ -1,15 +1,49 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.nativegen.NativeStructureLocateResults; +import com.mojang.datafixers.util.Pair; +import net.minecraft.core.BlockPos; import org.junit.Test; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public class IrisModdedStructureCommandTest { + @Test + public void mixedUnexploredLocateReferencesOnlyTheSelectedProvider() { + BlockPos origin = BlockPos.ZERO; + Pair irisNear = Pair.of(new BlockPos(4, 70, 0), "iris"); + Pair nativeFar = Pair.of(new BlockPos(8, 70, 0), "native"); + AtomicInteger irisReferences = new AtomicInteger(); + AtomicInteger nativeReferences = new AtomicInteger(); + + Pair irisSelected = NativeStructureLocateResults.selectAndReference( + origin, + irisNear, () -> irisReferences.incrementAndGet(), + nativeFar, () -> nativeReferences.incrementAndGet()); + + assertSame(irisNear, irisSelected); + assertEquals(1, irisReferences.get()); + assertEquals(0, nativeReferences.get()); + + Pair nativeNear = Pair.of(new BlockPos(2, 70, 0), "native"); + Pair nativeSelected = NativeStructureLocateResults.selectAndReference( + origin, + irisNear, () -> irisReferences.incrementAndGet(), + nativeNear, () -> nativeReferences.incrementAndGet()); + + assertSame(nativeNear, nativeSelected); + assertEquals(1, irisReferences.get()); + assertEquals(1, nativeReferences.get()); + } + @Test public void gotoStructureSupportsIrisAndNativeRegistryTargets() throws IOException { String source = source("ModdedLocateCommands.java"); @@ -40,21 +74,34 @@ public class IrisModdedStructureCommandTest { int methodStart = source.indexOf("Pair> findNearestIrisStructure("); int methodEnd = source.indexOf("HolderSet filterReachableNativeStructures(", methodStart); String method = source.substring(methodStart, methodEnd); - int unexploredGuard = method.indexOf("if (findUnexplored)"); int registryLookup = method.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)"); int placedCheck = method.indexOf( - "if (!IrisStructureLocator.isPlaced(current, structureId))"); - int irisLocate = method.indexOf("IrisStructureLocator.locate(", placedCheck); + "if (!IrisStructureLocator.hasNativePlacement(current, structureId))"); + int irisLocate = method.indexOf("NativeStructureLocatePersistence.search(", placedCheck); + int nearestSelection = method.indexOf( + "NativeStructureLocateResults.nearest(pos, predicted, nativeLocated)", irisLocate); + int selectedVerification = method.indexOf("bestSearch.search().verify(bestResult)", nearestSelection); + int selectedReference = method.indexOf( + "selectedSearch.search().reference(selectedStart)", selectedVerification); - assertTrue(unexploredGuard >= 0); - assertTrue(registryLookup > unexploredGuard); + assertTrue(registryLookup >= 0); assertTrue(placedCheck > registryLookup); assertTrue(irisLocate > placedCheck); + assertTrue(nearestSelection > irisLocate); + assertTrue(selectedVerification > nearestSelection); + assertTrue(selectedReference > selectedVerification); + assertTrue(method.contains("NativeStructureLocatePersistence.probe(")); + assertTrue(method.contains("findUnexplored")); assertTrue(method.contains("LocateStatus.SEARCH_LIMIT_REACHED")); - assertTrue(method.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); + assertTrue(method.contains("verified.ownership().locatorY()")); + assertTrue(method.contains("selectedSearch.search().reference(selectedStart)")); + assertTrue(method.contains("NativeStructureLocateResults.selectAndReference(")); assertFalse(method.contains("NativeStructureLocateCapability")); assertTrue(source.contains("structureBiomeSource.isStructureReachable(holder)")); assertFalse(source.contains("isPaperUnavailable")); + String generator = moddedSource("IrisModdedChunkGenerator.java"); + assertTrue(generator.contains("NativeStructureVanillaLocator.predict(")); + assertFalse(generator.contains("super.findNearestMapStructure(level, reachable")); } @Test @@ -68,7 +115,7 @@ public class IrisModdedStructureCommandTest { int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", genericIrisLookup); int replacementCheck = method.indexOf( "decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); - int replacementLocate = method.indexOf("locateIrisStructure(source, level, engine, player, target.key())", + int replacementLocate = method.indexOf("runNativeStructureLocate(source, level, player, target)", replacementCheck); assertTrue(nativeResolution >= 0); @@ -76,6 +123,8 @@ public class IrisModdedStructureCommandTest { assertTrue(policyResolution > genericIrisLookup); assertTrue(replacementCheck > policyResolution); assertTrue(replacementLocate > replacementCheck); + assertTrue(method.contains("!IrisStructureLocator.hasNativePlacement(engine, target.key())")); + assertTrue(method.contains("&& target.availability() != NativeStructureAvailability.AVAILABLE")); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java new file mode 100644 index 000000000..56f5d1e2f --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java @@ -0,0 +1,442 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.NativeStructureOwnershipBundle; +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; +import art.arcane.iris.engine.framework.NativeStructureStartPlan; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import art.arcane.volmlib.util.collection.KList; +import com.github.benmanes.caffeine.cache.Cache; +import net.minecraft.SharedConstants; +import net.minecraft.core.Direction; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.levelgen.LegacyRandomSource; +import net.minecraft.world.level.levelgen.RandomSupport; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class NativeStructureReferenceRepairTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void persistedEnvelopeRepairsReloadedMonumentReferenceOutsideLiveBounds() { + long seed = 119723L; + ChunkPos origin = new ChunkPos(-3, 6); + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + StructureStart generated = monumentStart(structure, origin, seed); + NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel( + generated, 0, 80, -64, 320); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(24); + StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( + generated, + structure, + 0, + terrain); + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture( + "minecraft:monument", + wrapped, + plan(origin, NativeStructureReferenceEnvelope.contentBounds(wrapped).minY()), + NativeStructureReferenceEnvelope.referenceBounds(wrapped, structure, terrain)); + PiecesContainer regeneratedPieces = OceanMonumentStructure.regeneratePiecesAfterLoad( + origin, seed, new PiecesContainer(wrapped.getPieces())); + StructureStart reloaded = new StructureStart(structure, origin, 0, regeneratedPieces); + ChunkPos target = outsideLiveBounds(ownership, reloaded); + + assertNotNull(target); + assertTrue(NativeStructureReferenceRepair.requiresReference( + target, "minecraft:monument", reloaded, ownership)); + assertFalse(NativeStructureReferenceRepair.requiresReference( + target, "minecraft:monument", reloaded, null)); + } + + @Test + public void deniedNaturalStartsCannotEnterCollisionArbitration() { + IrisNativeStructureDecision enabled = new IrisNativeStructureDecision( + NativeStructureGenerationStatus.GENERATE_NATIVE, + 0, null, false, false, null, new IrisStructureTerrain()); + IrisNativeStructureDecision disabled = new IrisNativeStructureDecision( + NativeStructureGenerationStatus.DISABLED_BY_PACK, + 0, null, false, false, null, new IrisStructureTerrain()); + IrisNativeStructureDecision replaced = new IrisNativeStructureDecision( + NativeStructureGenerationStatus.REPLACED_BY_IRIS, + 0, null, false, false, null, new IrisStructureTerrain()); + + assertTrue(NativeStructureReferenceRepair.naturalDecisionAllows(enabled)); + assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(disabled)); + assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(replaced)); + } + + @Test + public void persistedManualOwnershipSurvivesDisabledNaturalPolicyDuringReferenceScans() + throws Exception { + String structureKey = "minecraft:monument"; + long seed = 582119L; + ChunkPos origin = new ChunkPos(2, -4); + OceanMonumentStructure structure = new OceanMonumentStructure( + new OceanMonumentStructure.StructureSettings(HolderSet.empty())); + StructureStart generated = monumentStart(structure, origin, seed); + NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel( + generated, 0, 80, -64, 320); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(24); + StructureStart start = NativeStructureReferenceEnvelope.wrap( + generated, structure, 0, terrain); + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture( + structureKey, + start, + plan(origin, NativeStructureReferenceEnvelope.contentBounds(start).minY()), + NativeStructureReferenceEnvelope.referenceBounds(start, structure, terrain)); + IrisDimension dimension = new IrisDimension(); + dimension.getImportedStructures().getDisabled().add(structureKey); + IrisData data = allocateWithoutConstructor(IrisData.class); + Engine engine = engine(dimension, data); + IrisNativeStructureDecision currentDecision = NativeStructureGenerationPolicy.resolve( + engine, structureKey, false); + + assertFalse(currentDecision.generate()); + assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(currentDecision)); + + ProtoChunk originChunk = emptyChunk(origin); + originChunk.setStartForStructure(structure, start); + ProtoChunk emptyScannedChunk = emptyChunk(new ChunkPos(Integer.MIN_VALUE, Integer.MIN_VALUE)); + Registry registry = structureRegistry(structure, structureKey); + ServerLevel serverLevel = allocateWithoutConstructor(ServerLevel.class); + StructureManager structureManager = new StructureManager(null, null, null); + ChunkPos coveredTarget = new ChunkPos( + ownership.referenceMinChunkX(), ownership.referenceMinChunkZ()); + ChunkPos uncoveredTarget = outsideReferenceEnvelopeWithinScan(ownership); + + assertNotNull(uncoveredTarget); + + installOwnership(engine, ownership); + try { + WorldGenLevel level = worldGenLevel( + registry, serverLevel, originChunk, emptyScannedChunk); + ProtoChunk coveredChunk = emptyChunk(coveredTarget); + NativeStructureReferenceRepair.createReferences( + engine, level, structureManager, coveredChunk); + + assertSame(start, originChunk.getStartForStructure(structure)); + assertTrue(originChunk.getStartForStructure(structure).isValid()); + assertTrue(coveredChunk.getReferencesForStructure(structure).contains(origin.pack())); + + ProtoChunk uncoveredChunk = emptyChunk(uncoveredTarget); + NativeStructureReferenceRepair.createReferences( + engine, level, structureManager, uncoveredChunk); + + assertSame(start, originChunk.getStartForStructure(structure)); + assertTrue(originChunk.getStartForStructure(structure).isValid()); + assertFalse(uncoveredChunk.getReferencesForStructure(structure).contains(origin.pack())); + } finally { + NativeStructureOwnershipStore.close(engine); + } + } + + private static StructureStart monumentStart(OceanMonumentStructure structure, + ChunkPos origin, long seed) { + WorldgenRandom random = new WorldgenRandom( + new LegacyRandomSource(RandomSupport.generateUniqueSeed())); + random.setLargeFeatureSeed(seed, origin.x(), origin.z()); + Direction orientation = Direction.Plane.HORIZONTAL.getRandomDirection(random); + OceanMonumentPieces.MonumentBuilding building = new OceanMonumentPieces.MonumentBuilding( + random, + origin.getMinBlockX() - 29, + origin.getMinBlockZ() - 29, + orientation + ); + return new StructureStart( + structure, + origin, + 0, + new PiecesContainer(List.of(building)) + ); + } + + private static NativeStructureStartPlan plan(ChunkPos origin, int baseY) { + IrisNativeStructure source = new IrisNativeStructure() + .setStructure("minecraft:monument") + .setWeight(1); + IrisStructurePlacement placement = new IrisStructurePlacement() + .setPlacementId("monument-reference-test") + .setNativeStructures(new KList().qadd(source)); + return new NativeStructureStartPlan( + placement, + source, + origin.x(), + origin.z(), + baseY + ); + } + + private static ChunkPos outsideLiveBounds(NativeStructureOwnershipRecord ownership, + StructureStart start) { + for (int chunkX = ownership.referenceMinChunkX(); + chunkX <= ownership.referenceMaxChunkX(); chunkX++) { + for (int chunkZ = ownership.referenceMinChunkZ(); + chunkZ <= ownership.referenceMaxChunkZ(); chunkZ++) { + ChunkPos candidate = new ChunkPos(chunkX, chunkZ); + if (!start.getBoundingBox().intersects( + candidate.getMinBlockX(), candidate.getMinBlockZ(), + candidate.getMaxBlockX(), candidate.getMaxBlockZ())) { + return candidate; + } + } + } + return null; + } + + private static ChunkPos outsideReferenceEnvelopeWithinScan( + NativeStructureOwnershipRecord ownership) { + int originX = ownership.originChunkX(); + int originZ = ownership.originChunkZ(); + List candidates = List.of( + new ChunkPos(ownership.referenceMinChunkX() - 1, originZ), + new ChunkPos(ownership.referenceMaxChunkX() + 1, originZ), + new ChunkPos(originX, ownership.referenceMinChunkZ() - 1), + new ChunkPos(originX, ownership.referenceMaxChunkZ() + 1)); + for (ChunkPos candidate : candidates) { + if (!ownership.covers(candidate.x(), candidate.z()) + && Math.abs(candidate.x() - originX) + <= NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS + && Math.abs(candidate.z() - originZ) + <= NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS) { + return candidate; + } + } + return null; + } + + private static ProtoChunk emptyChunk(ChunkPos position) { + return new ProtoChunk( + position, + UpgradeData.EMPTY, + LevelHeightAccessor.create(0, 0), + null, + null); + } + + @SuppressWarnings("unchecked") + private static Registry structureRegistry( + Structure structure, String structureKey) { + Identifier identifier = Identifier.parse(structureKey); + InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> { + if (method.getName().equals("getKey")) { + return arguments != null && arguments.length == 1 + && arguments[0] == structure ? identifier : null; + } + return defaultProxyValue(proxy, method, arguments); + }; + return (Registry) Proxy.newProxyInstance( + Registry.class.getClassLoader(), + new Class[]{Registry.class}, + handler); + } + + private static WorldGenLevel worldGenLevel( + Registry registry, + ServerLevel serverLevel, + ChunkAccess originChunk, + ChunkAccess emptyChunk) { + RegistryAccess registryAccess = registryAccess(registry); + ChunkPos origin = originChunk.getPos(); + InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> { + return switch (method.getName()) { + case "registryAccess" -> registryAccess; + case "getLevel" -> serverLevel; + case "getChunk" -> arguments != null + && arguments.length >= 2 + && ((Integer) arguments[0]) == origin.x() + && ((Integer) arguments[1]) == origin.z() + ? originChunk : emptyChunk; + default -> defaultProxyValue(proxy, method, arguments); + }; + }; + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), + new Class[]{WorldGenLevel.class}, + handler); + } + + private static RegistryAccess registryAccess(Registry registry) { + InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> { + return switch (method.getName()) { + case "lookupOrThrow" -> registry; + case "lookup" -> Optional.of(registry); + case "registries" -> Stream.empty(); + default -> defaultProxyValue(proxy, method, arguments); + }; + }; + return (RegistryAccess) Proxy.newProxyInstance( + RegistryAccess.class.getClassLoader(), + new Class[]{RegistryAccess.class}, + handler); + } + + private static Engine engine(IrisDimension dimension, IrisData data) throws Exception { + TestEngine engine = allocateWithoutConstructor(TestEngine.class); + engine.dimension = dimension; + engine.data = data; + return engine; + } + + @SuppressWarnings("unchecked") + private static void installOwnership( + Engine engine, NativeStructureOwnershipRecord ownership) throws Exception { + NativeStructureOwnershipBundle bundle = + NativeStructureOwnershipBundle.empty().with(ownership); + Class storageType = Class.forName( + "art.arcane.iris.engine.framework.NativeStructureOwnershipStore$Storage"); + InvocationHandler storageHandler = + (Object proxy, Method method, Object[] arguments) -> { + return switch (method.getName()) { + case "read" -> arguments != null + && arguments.length == 2 + && ((Integer) arguments[0]) == ownership.originChunkX() + && ((Integer) arguments[1]) == ownership.originChunkZ() + ? bundle : null; + case "write", "remove" -> null; + default -> defaultProxyValue(proxy, method, arguments); + }; + }; + Object storage = Proxy.newProxyInstance( + storageType.getClassLoader(), + new Class[]{storageType}, + storageHandler); + Class stateType = Class.forName( + "art.arcane.iris.engine.framework.NativeStructureOwnershipStore$State"); + Constructor constructor = stateType.getDeclaredConstructor(Engine.class, storageType); + constructor.setAccessible(true); + Object state = constructor.newInstance(engine, storage); + Field statesField = NativeStructureOwnershipStore.class.getDeclaredField("STATES"); + statesField.setAccessible(true); + Cache states = (Cache) statesField.get(null); + states.put(engine, state); + } + + private static T allocateWithoutConstructor(Class type) throws Exception { + Class unsafeType = Class.forName("sun.misc.Unsafe"); + Field unsafeField = unsafeType.getDeclaredField("theUnsafe"); + unsafeField.setAccessible(true); + Object unsafe = unsafeField.get(null); + Method allocateInstance = unsafeType.getMethod("allocateInstance", Class.class); + return type.cast(allocateInstance.invoke(unsafe, type)); + } + + private static Object defaultProxyValue( + Object proxy, Method method, Object[] arguments) { + return switch (method.getName()) { + case "equals" -> arguments != null && arguments.length == 1 + && proxy == arguments[0]; + case "hashCode" -> System.identityHashCode(proxy); + case "toString" -> proxy.getClass().getInterfaces()[0].getSimpleName() + "Proxy"; + default -> primitiveDefault(method.getReturnType()); + }; + } + + private static Object primitiveDefault(Class returnType) { + if (!returnType.isPrimitive() || returnType == void.class) { + return null; + } + if (returnType == boolean.class) { + return false; + } + if (returnType == char.class) { + return '\0'; + } + if (returnType == byte.class) { + return (byte) 0; + } + if (returnType == short.class) { + return (short) 0; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + if (returnType == float.class) { + return 0.0F; + } + return 0.0D; + } + + private static final class TestEngine extends IrisEngine { + private IrisDimension dimension; + private IrisData data; + + private TestEngine() { + super(null, false); + } + + @Override + public IrisDimension getDimension() { + return dimension; + } + + @Override + public IrisData getData() { + return data; + } + + @Override + public boolean isClosing() { + return false; + } + + @Override + public boolean isClosed() { + return false; + } + } +} diff --git a/build.gradle b/build.gradle index 70645215d..6fff5da8e 100644 --- a/build.gradle +++ b/build.gradle @@ -120,6 +120,8 @@ nmsBindings.each { key, value -> rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/IrisChunkGenerator.java").absolutePath) systemProperty('iris.nativeStructurePostProcessorSource', rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java').absolutePath) + systemProperty('iris.nativeStructureStartInjectorSource', + rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java').absolutePath) systemProperty('iris.customBiomeSource', rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/CustomBiomeSource.java").absolutePath) systemProperty('iris.vanillaStructureBiomesSource', diff --git a/core/build.gradle b/core/build.gradle index 0c973d6b5..beef4e835 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -119,6 +119,10 @@ dependencies { testRuntimeOnly(libs.paper.api) } +tasks.named('test').configure { + maxHeapSize = '1g' +} + java { toolchain { languageVersion = JavaLanguageVersion.of(25) diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index 315ff0339..e21248769 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -2,14 +2,18 @@ art/arcane/iris/core/IrisRuntimeSchedulerMode.java art/arcane/iris/core/IrisWorldStorage.java art/arcane/iris/core/IrisWorlds.java art/arcane/iris/core/ServerConfigurator.java +art/arcane/iris/core/WorldCreatorCompat.java +art/arcane/iris/core/WorldRemovalPathPolicy.java art/arcane/iris/core/datapack/DatapackIngestService.java art/arcane/iris/core/edit/BlockSignal.java art/arcane/iris/core/edit/DustRevealer.java art/arcane/iris/core/events/IrisEngineEvent.java art/arcane/iris/core/events/IrisEngineHotloadEvent.java art/arcane/iris/core/events/IrisLootEvent.java +art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java art/arcane/iris/core/lifecycle/BukkitPublicBackend.java art/arcane/iris/core/lifecycle/CapabilitySnapshot.java +art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java art/arcane/iris/core/lifecycle/PaperLibBootstrap.java art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java @@ -115,7 +119,6 @@ art/arcane/iris/engine/object/IrisVanillaLootTable.java art/arcane/iris/engine/object/LegacyTileData.java art/arcane/iris/engine/object/PotionEffectTypes.java art/arcane/iris/engine/object/TileData.java -art/arcane/iris/engine/object/annotations/functions/LootTableKeyFunction.java art/arcane/iris/engine/platform/BukkitChunkGenerator.java art/arcane/iris/engine/platform/DummyBiomeProvider.java art/arcane/iris/engine/platform/DummyChunkGenerator.java diff --git a/core/src/main/java/art/arcane/iris/core/DatapackInstallResult.java b/core/src/main/java/art/arcane/iris/core/DatapackInstallResult.java new file mode 100644 index 000000000..42ca4bbba --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/DatapackInstallResult.java @@ -0,0 +1,44 @@ +package art.arcane.iris.core; + +import java.util.Objects; + +public record DatapackInstallResult(Status status) { + public DatapackInstallResult { + Objects.requireNonNull(status, "status"); + } + + public static DatapackInstallResult failedResult() { + return new DatapackInstallResult(Status.FAILED); + } + + public static DatapackInstallResult unchangedResult() { + return new DatapackInstallResult(Status.UNCHANGED); + } + + public static DatapackInstallResult readyResult() { + return new DatapackInstallResult(Status.READY); + } + + public static DatapackInstallResult restartRequiredResult() { + return new DatapackInstallResult(Status.RESTART_REQUIRED); + } + + public boolean succeeded() { + return status != Status.FAILED; + } + + public boolean changed() { + return status == Status.READY || status == Status.RESTART_REQUIRED; + } + + public boolean restartRequired() { + return status == Status.RESTART_REQUIRED; + } + + public enum Status { + FAILED, + UNCHANGED, + READY, + RESTART_REQUIRED + } +} diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java index 8ec6a3d5f..e9531c0d6 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -3,6 +3,8 @@ package art.arcane.iris.core; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; @@ -10,8 +12,12 @@ import art.arcane.volmlib.util.collection.KSet; import java.io.File; import java.io.IOException; +import java.nio.file.FileVisitResult; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; @@ -19,6 +25,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.stream.Stream; @@ -49,9 +56,7 @@ public final class IrisDatapackCompiler { throw new IOException("No Iris datapack output roots were provided"); } - for (File datapackRoot : datapackRoots) { - Files.createDirectories(datapackRoot.toPath()); - } + resetOutputRoots(datapackRoots); IrisDimension.clearGeneratedBiomeTags(datapackRoots); DimensionHeight height = new DimensionHeight(fixer); @@ -59,6 +64,7 @@ public final class IrisDatapackCompiler { int packCount = 0; int dimensionCount = 0; for (File packRoot : packRoots) { + PackDirectoryResolver.requireSafePackTree(packRoot); if (!hasDimensions(packRoot.toPath())) { continue; } @@ -93,42 +99,42 @@ public final class IrisDatapackCompiler { } IrisDimension.writeShared(datapackRoots, height, packFormat, adjustVanillaHeight); - validateOutputs(datapackRoots); + validateOutputs(datapackRoots, dimensionCount); return new CompilationResult(packCount, dimensionCount, countBiomes(biomes)); } private static void collectInstalledPackRoots(Path packsRoot, Map roots) throws IOException { - if (!Files.isDirectory(packsRoot)) { - return; - } - try (Stream stream = Files.list(packsRoot)) { - List candidates = stream - .filter(Files::isDirectory) - .sorted(Comparator.comparing(Path::toString)) - .toList(); - for (Path candidate : candidates) { - addPackRoot(candidate, roots); - } + List candidates = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot.toFile()); + for (File candidate : candidates) { + addPackRoot(candidate.toPath(), roots); } } private static void collectWorldPackRoots(Path dimensionsRoot, Map roots) throws IOException { - if (!Files.isDirectory(dimensionsRoot)) { + if (Files.isSymbolicLink(dimensionsRoot) + || !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) { return; } - try (Stream stream = Files.find( - dimensionsRoot, - WORLD_PACK_SCAN_DEPTH, - (path, attributes) -> attributes.isDirectory() - && "pack".equals(path.getFileName().toString()) - && path.getParent() != null - && "iris".equals(path.getParent().getFileName().toString()) - && hasDimensions(path) - )) { - List candidates = stream.sorted(Comparator.comparing(Path::toString)).toList(); - for (Path candidate : candidates) { - addPackRoot(candidate, roots); + List candidates = new ArrayList<>(); + Files.walkFileTree(dimensionsRoot, Set.of(), WORLD_PACK_SCAN_DEPTH, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) { + if (!directory.equals(dimensionsRoot) + && PackDirectoryResolver.containsHiddenPathSegment(dimensionsRoot, directory)) { + return FileVisitResult.SKIP_SUBTREE; + } + if ("pack".equals(directory.getFileName().toString()) + && directory.getParent() != null + && "iris".equals(directory.getParent().getFileName().toString()) + && hasDimensions(directory)) { + candidates.add(directory); + } + return FileVisitResult.CONTINUE; } + }); + candidates.sort(Comparator.comparing(Path::toString)); + for (Path candidate : candidates) { + addPackRoot(candidate, roots); } } @@ -137,29 +143,52 @@ public final class IrisDatapackCompiler { return; } Path normalized = root.toAbsolutePath().normalize(); + if (!Files.isDirectory(normalized)) { + return; + } + PackDirectoryResolver.requireSafePackTree(normalized.toFile()); Path identity = normalized.toRealPath(); roots.putIfAbsent(identity, normalized.toFile()); } private static boolean hasDimensions(Path root) { Path dimensions = root.resolve("dimensions"); - if (!Files.isDirectory(dimensions)) { + if (Files.isSymbolicLink(dimensions) + || !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) { return false; } try (Stream stream = Files.list(dimensions)) { - return stream.anyMatch(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".json")); + return stream.anyMatch(path -> !Files.isSymbolicLink(path) + && Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) + && path.getFileName().toString().endsWith(".json")); } catch (IOException e) { return false; } } - private static void validateOutputs(Collection datapackRoots) throws IOException { + private static void resetOutputRoots(Collection datapackRoots) throws IOException { + for (File datapackRoot : datapackRoots) { + Path root = datapackRoot.toPath().toAbsolutePath().normalize(); + if (Files.isSymbolicLink(root)) { + throw new IOException("Iris datapack output root is a symbolic link: " + root); + } + if (Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Iris datapack output root is not a directory: " + root); + } + AtomicDirectoryPublisher.deleteTree(root); + } + Files.createDirectories(root); + } + } + + private static void validateOutputs(Collection datapackRoots, int dimensionCount) throws IOException { for (File datapackRoot : datapackRoots) { Path root = datapackRoot.toPath(); if (!Files.isRegularFile(root.resolve("pack.mcmeta"))) { throw new IOException("Iris datapack metadata was not generated at " + root); } - if (!Files.isDirectory(root.resolve("data/iris/dimension_type"))) { + if (dimensionCount > 0 && !Files.isDirectory(root.resolve("data/iris/dimension_type"))) { throw new IOException("Iris dimension types were not generated at " + root); } } diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java index c3176cc60..b27b52bd8 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java @@ -11,6 +11,7 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Path; +import java.nio.file.Files; import java.util.Locale; import java.util.Objects; import java.util.Optional; @@ -50,6 +51,10 @@ public final class IrisWorldStorage { return cached; } + public static String configuredLevelName() { + return levelNameFromProperties(new File("server.properties")); + } + static String levelNameFromProperties(File serverProperties) { Properties properties = new Properties(); if (Objects.requireNonNull(serverProperties, "serverProperties").isFile()) { @@ -87,6 +92,39 @@ public final class IrisWorldStorage { return keyFromName(worldName, levelRoot().getName()); } + public static NamespacedKey managedKeyFromName(String worldName) { + String requestedName = Objects.requireNonNull(worldName, "worldName").trim(); + if (requestedName.contains(":")) { + return managedKeyFromName(requestedName, DEFAULT_LEVEL_NAME); + } + return managedKeyFromName(requestedName, levelRoot().getName()); + } + + public static NamespacedKey managedKeyFromName(String worldName, String levelName) { + String requestedName = Objects.requireNonNull(worldName, "worldName").trim(); + if (requestedName.isEmpty()) { + throw new IllegalArgumentException("World name cannot be empty."); + } + if (requestedName.contains("/") || requestedName.contains("\\") || requestedName.contains("..")) { + throw new IllegalArgumentException("World name must be a safe single path segment."); + } + + NamespacedKey key; + if (requestedName.contains(":")) { + key = NamespacedKey.fromString(requestedName.toLowerCase(Locale.ENGLISH)); + if (key == null) { + throw new IllegalArgumentException("World identifier is invalid: " + requestedName); + } + } else { + key = keyFromName(requestedName, levelName); + } + + if (!IRIS_NAMESPACE.equals(key.getNamespace()) || !key.getKey().matches("[a-z0-9_-]+")) { + throw new IllegalArgumentException("Only Iris-managed dimension worlds can be changed."); + } + return key; + } + static NamespacedKey keyFromName(String worldName, String levelName) { String name = Objects.requireNonNull(worldName, "worldName").trim(); String mainLevelName = Objects.requireNonNull(levelName, "levelName").trim(); @@ -112,7 +150,11 @@ public final class IrisWorldStorage { } public static String logicalName(NamespacedKey key) { - return logicalName(key, levelRoot().getName()); + NamespacedKey worldKey = Objects.requireNonNull(key, "key"); + if (IRIS_NAMESPACE.equals(worldKey.getNamespace())) { + return worldKey.getKey(); + } + return logicalName(worldKey, levelRoot().getName()); } static String logicalName(NamespacedKey key, String levelName) { @@ -147,6 +189,31 @@ public final class IrisWorldStorage { return dimensionRoot(levelRoot(), key); } + public static File requireSafeManagedDimensionRoot(NamespacedKey key) { + return requireSafeManagedDimensionRoot(levelRoot(), key); + } + + public static File requireSafeManagedDimensionRoot(File levelRoot, NamespacedKey key) { + NamespacedKey worldKey = Objects.requireNonNull(key, "key"); + if (!IRIS_NAMESPACE.equals(worldKey.getNamespace()) || !worldKey.getKey().matches("[a-z0-9_-]+")) { + throw new IllegalArgumentException("Only safe Iris-managed dimension worlds can be changed."); + } + + Path root = Objects.requireNonNull(levelRoot, "levelRoot").toPath().toAbsolutePath().normalize(); + Path dimensions = root.resolve("dimensions"); + Path namespace = dimensions.resolve(IRIS_NAMESPACE); + Path target = namespace.resolve(worldKey.getKey()).normalize(); + if (!Objects.equals(target.getParent(), namespace)) { + throw new IllegalArgumentException("World target escapes the Iris namespace root."); + } + for (Path path : new Path[]{dimensions, namespace, target}) { + if (Files.isSymbolicLink(path)) { + throw new IllegalArgumentException("World storage path contains a symbolic link: " + path); + } + } + return target.toFile(); + } + public static File dimensionRoot(File levelRoot, NamespacedKey key) { Path dimensionsRoot = Objects.requireNonNull(levelRoot, "levelRoot") .toPath() diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java index cfc83287c..d62e62d54 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java @@ -22,8 +22,15 @@ import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; import java.io.IOException; -import java.io.OutputStreamWriter; +import java.io.UncheckedIOException; import java.lang.reflect.Type; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.util.Objects; import java.util.stream.Stream; @@ -62,9 +69,42 @@ public class IrisWorlds { }); } - public void put(String identity, String type) { - put0(identity, type); - save(); + public synchronized void put(String identity, String type) { + String canonicalIdentity = WorldIdentity.parse(identity).toString(); + String requiredType = Objects.requireNonNull(type, "type"); + String previous = worlds.put(canonicalIdentity, requiredType); + if (requiredType.equals(previous)) { + return; + } + dirty = true; + try { + saveOrThrow(); + } catch (IOException e) { + if (previous == null) { + worlds.remove(canonicalIdentity); + } else { + worlds.put(canonicalIdentity, previous); + } + dirty = true; + throw new UncheckedIOException("Failed to persist Iris world registry entry for " + canonicalIdentity, e); + } + } + + public synchronized boolean remove(String identity) { + String canonicalIdentity = WorldIdentity.parse(identity).toString(); + String previous = worlds.remove(canonicalIdentity); + if (previous == null) { + return false; + } + dirty = true; + try { + saveOrThrow(); + return true; + } catch (IOException e) { + worlds.put(canonicalIdentity, previous); + dirty = true; + throw new UncheckedIOException("Failed to remove Iris world registry entry for " + canonicalIdentity, e); + } } private void put0(String identity, String type) { @@ -74,7 +114,7 @@ public class IrisWorlds { dirty = true; } - public KMap getWorlds() { + public synchronized KMap getWorlds() { clean(); KMap result = new KMap<>(); readBukkitWorlds().forEach((name, type) -> result.put(IrisWorldStorage.keyFromName(name).toString(), type)); @@ -95,7 +135,7 @@ public class IrisWorlds { .filter(Objects::nonNull); } - public void clean() { + public synchronized void clean() { boolean removed = worlds.entrySet().removeIf(entry -> { try { File packRoot = IrisWorldStorage.packRoot(WorldIdentity.parse(entry.getKey())); @@ -108,18 +148,43 @@ public class IrisWorlds { } public synchronized void save() { - clean(); - if (!dirty) return; try { - IO.write(IrisPlatforms.get().dataFile("worlds.json"), OutputStreamWriter::new, writer -> GSON.toJson(worlds, TYPE, writer)); - dirty = false; + saveOrThrow(); } catch (IOException e) { IrisLogging.error("Failed to save worlds.json!"); - e.printStackTrace(); IrisLogging.reportError(e); } } + private void saveOrThrow() throws IOException { + clean(); + if (!dirty) { + return; + } + + Path target = IrisPlatforms.get().dataFile("worlds.json").toPath().toAbsolutePath().normalize(); + Path parent = target.getParent(); + if (parent == null) { + throw new IOException("worlds.json target has no parent: " + target); + } + Files.createDirectories(parent); + Path staged = Files.createTempFile(parent, ".iris-worlds-", ".json"); + try { + Files.writeString(staged, GSON.toJson(worlds, TYPE), StandardCharsets.UTF_8); + try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) { + channel.force(true); + } + try { + Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(staged, target, StandardCopyOption.REPLACE_EXISTING); + } + dirty = false; + } finally { + Files.deleteIfExists(staged); + } + } + public static Long readBukkitWorldSeed(String world) { YamlConfiguration bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML); ConfigurationSection worlds = bukkit.getConfigurationSection("worlds"); diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index 8bc8654cf..266f03b3c 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -22,10 +22,14 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; @@ -47,16 +51,27 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; +import java.util.Comparator; +import java.util.HexFormat; import java.util.List; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; @@ -64,6 +79,8 @@ import art.arcane.iris.core.localization.BukkitRuntimeMessages; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.volmlib.util.localization.MessageArgument; public class ServerConfigurator { + private static final Object DATAPACK_INSTALL_LOCK = new Object(); + public static void configure() { IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration(); if (s.isConfigureSpigotTimeoutTime()) { @@ -77,7 +94,10 @@ public class ServerConfigurator { if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) { IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install."); } else { - installDataPacks(true); + DatapackInstallResult result = installDataPacks(true); + if (result.restartRequired() && IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()) { + restart(); + } } } @@ -124,20 +144,20 @@ public class ServerConfigurator { return roots; } - public static boolean installDataPacks(boolean fullInstall) { - IDataFixer fixer = DataVersion.getDefault(); - if (fixer == null) { - DataVersion fallback = DataVersion.getLatest(); - IrisLogging.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion()); - fixer = fallback.get(); - } - return installDataPacks(fixer, fullInstall); + public static DatapackInstallResult installDataPacks(boolean fullInstall) { + return installDataPacks(resolveDataFixer(), fullInstall); } - public static boolean installDataPacks(IDataFixer fixer, boolean fullInstall) { + public static DatapackInstallResult installDataPacks(IDataFixer fixer, boolean fullInstall) { + synchronized (DATAPACK_INSTALL_LOCK) { + return installDataPacksLocked(fixer, fullInstall); + } + } + + private static DatapackInstallResult installDataPacksLocked(IDataFixer fixer, boolean fullInstall) { if (fixer == null) { IrisLogging.error("Unable to install datapacks, fixer is null!"); - return false; + return DatapackInstallResult.failedResult(); } if (fullInstall) { IrisLogging.info("Checking Data Packs..."); @@ -145,7 +165,10 @@ public class ServerConfigurator { IrisLogging.debug("Checking Data Packs..."); } KList datapacksFolders = getDatapacksFolder(); - DatapackIngestService.reapplyFromStaging(datapacksFolders); + if (!DatapackIngestService.reapplyFromStaging(datapacksFolders)) { + IrisLogging.error("Unable to compile Iris datapacks while external datapack recovery is incomplete."); + return DatapackInstallResult.failedResult(); + } List packRoots; try (Stream stream = allPacks()) { packRoots = stream @@ -155,17 +178,58 @@ public class ServerConfigurator { .toList(); } + KList liveRoots = getIrisDatapackRoots(); + KList stagedRoots = new KList<>(); + List stagedPaths = new ArrayList<>(liveRoots.size()); + List publications = new ArrayList<>(liveRoots.size()); try { + for (File liveRoot : liveRoots) { + Path target = liveRoot.toPath().toAbsolutePath().normalize(); + Path parent = target.getParent(); + if (parent == null) { + throw new IOException("Iris datapack root has no parent: " + target); + } + Files.createDirectories(parent); + Path staged = parent.resolve(".iris-compile-" + UUID.randomUUID()); + Files.createDirectories(staged); + stagedPaths.add(staged); + stagedRoots.add(staged.toFile()); + } IrisDatapackCompiler.compile( packRoots, - getIrisDatapackRoots(), + stagedRoots, fixer, BukkitPlatform.dataPackFormat(), IrisSettings.get().getGeneral().adjustVanillaHeight ); - } catch (IOException e) { + for (int i = 0; i < liveRoots.size(); i++) { + publications.add(AtomicDirectoryPublisher.publish( + stagedRoots.get(i).toPath(), + liveRoots.get(i).toPath() + )); + } + for (AtomicDirectoryPublisher.Publication publication : publications) { + publication.commit(); + try { + publication.cleanupBackup(); + } catch (IOException cleanupFailure) { + IrisLogging.warn("Iris datapack was committed but its backup could not be removed: " + + cleanupFailure.getMessage()); + } + } + } catch (IOException | RuntimeException e) { + closePublications(publications, e); IrisLogging.reportError("Unable to compile Iris datapacks", e); - return false; + return DatapackInstallResult.failedResult(); + } finally { + for (Path stagedPath : stagedPaths) { + try { + AtomicDirectoryPublisher.deleteTree(stagedPath); + } catch (IOException cleanupFailure) { + IrisLogging.warn("Failed to clean Iris datapack compilation stage " + stagedPath + ": " + + cleanupFailure.getMessage()); + } + } } if (fullInstall) { IrisLogging.info("Data Packs Setup!"); @@ -173,74 +237,205 @@ public class ServerConfigurator { IrisLogging.debug("Data Packs Setup!"); } - return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()); + boolean restartRequired = fullInstall && verifyDataPacksPost(); + return restartRequired + ? DatapackInstallResult.restartRequiredResult() + : DatapackInstallResult.readyResult(); } - public static boolean installDataPacksIfChanged(boolean fullInstall) { - File packsDir = IrisPlatforms.get().dataFolder("packs"); - String current = computePackFingerprint(packsDir); - File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint"); - String cached = ""; - if (cacheFile.exists()) { + private static IDataFixer resolveDataFixer() { + IDataFixer fixer = DataVersion.getDefault(); + if (fixer != null) { + return fixer; + } + DataVersion fallback = DataVersion.getLatest(); + IrisLogging.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion()); + return fallback.get(); + } + + private static void closePublications(List publications, Throwable failure) { + for (int i = publications.size() - 1; i >= 0; i--) { try { - cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim(); - } catch (IOException e) { - cached = ""; + publications.get(i).close(); + } catch (IOException rollbackFailure) { + failure.addSuppressed(rollbackFailure); } } - if (!current.isEmpty() && current.equals(cached)) { - IrisLogging.debug("Data packs unchanged, skipping install."); - return false; + } + + public static DatapackInstallResult installDataPacksIfChanged(boolean fullInstall) { + synchronized (DATAPACK_INSTALL_LOCK) { + File packsDir = IrisPlatforms.get().dataFolder("packs"); + String current; + try { + current = computePackFingerprint(packsDir); + } catch (RuntimeException exception) { + IrisLogging.reportError("Unable to fingerprint Iris packs safely", exception); + return DatapackInstallResult.failedResult(); + } + File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint"); + String cached = ""; + if (cacheFile.exists()) { + try { + cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim(); + } catch (IOException e) { + cached = ""; + } + } + if (!current.isEmpty() && current.equals(cached)) { + IrisLogging.debug("Data packs unchanged, skipping install."); + return DatapackInstallResult.unchangedResult(); + } + DatapackInstallResult result = installDataPacksLocked(resolveDataFixer(), fullInstall); + if (result.succeeded()) { + try { + writeFingerprintAtomic(cacheFile.toPath(), current); + } catch (IOException e) { + IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage()); + } + } + return result; } - boolean result = installDataPacks(fullInstall); - try { - cacheFile.getParentFile().mkdirs(); - Files.writeString(cacheFile.toPath(), current, StandardCharsets.UTF_8); - } catch (IOException e) { - IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage()); - } - return result; } public static String computePackFingerprint(File packsDir) { - if (packsDir == null || !packsDir.isDirectory()) { + if (packsDir == null) { + return ""; + } + Path root = packsDir.toPath().toAbsolutePath().normalize(); + if (Files.isSymbolicLink(root)) { + throw new IllegalArgumentException("Iris packs root is a symbolic link: " + root); + } + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { return ""; } try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); - List entries = new ArrayList<>(); - collectFingerprintEntries(packsDir, packsDir.getAbsolutePath(), entries); - Collections.sort(entries); - for (String entry : entries) { - digest.update(entry.getBytes(StandardCharsets.UTF_8)); + List entries = collectFingerprintEntries(root); + entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); + byte[] buffer = new byte[8192]; + for (FingerprintEntry entry : entries) { + byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8); + updateDigestInt(digest, relativePath.length); + digest.update(relativePath); + updateDigestLong(digest, Files.size(entry.source())); + try (InputStream input = Files.newInputStream(entry.source())) { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + } + } + } } - byte[] hash = digest.digest(); - StringBuilder sb = new StringBuilder(hash.length * 2); - for (byte b : hash) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); + return HexFormat.of().formatHex(digest.digest()); + } catch (IOException exception) { + throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 not available", e); } } - private static void collectFingerprintEntries(File dir, String rootPath, List entries) { - File[] files = dir.listFiles(); - if (files == null) { - return; + private static void writeFingerprintAtomic(Path target, String fingerprint) throws IOException { + Path absoluteTarget = target.toAbsolutePath().normalize(); + Path parent = absoluteTarget.getParent(); + if (parent == null) { + throw new IOException("Datapack fingerprint target has no parent: " + absoluteTarget); } - for (File file : files) { - if (file.isDirectory()) { - if (file.getName().startsWith(".")) { + Files.createDirectories(parent); + Path staged = Files.createTempFile(parent, ".datapack-fingerprint-", ".tmp"); + try { + Files.writeString(staged, fingerprint, StandardCharsets.UTF_8); + try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) { + channel.force(true); + } + try { + Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(staged); + } + } + + private static List collectFingerprintEntries(Path root) throws IOException { + List entries = new ArrayList<>(); + try (Stream children = Files.list(root)) { + for (Path child : children.toList()) { + String childName = child.getFileName().toString(); + if (PackDirectoryResolver.isHiddenName(childName)) { continue; } - collectFingerprintEntries(file, rootPath, entries); - } else { - String relative = file.getAbsolutePath().substring(rootPath.length()); - entries.add(relative + "|" + file.length() + "|" + file.lastModified()); + if (Files.isSymbolicLink(child)) { + if (!Files.isDirectory(child)) { + throw new IOException("Iris pack fingerprint rejected symbolic link: " + child); + } + PackDirectoryResolver.requireSafePackTree(child.toFile()); + collectFingerprintTree(child.toRealPath(), childName, entries); + } else if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + collectFingerprintTree(child, childName, entries); + } else if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)) { + entries.add(new FingerprintEntry(child, childName)); + } else { + throw new IOException("Iris pack fingerprint rejected unsupported entry: " + child); + } } } + return entries; + } + + private static void collectFingerprintTree( + Path treeRoot, + String logicalRoot, + List entries + ) throws IOException { + Files.walkFileTree(treeRoot, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) { + if (!directory.equals(treeRoot) + && PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + if (PackDirectoryResolver.isHiddenName(file.getFileName().toString())) { + return FileVisitResult.CONTINUE; + } + if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { + throw new IOException("Iris pack fingerprint rejected symbolic link: " + file); + } + if (!attributes.isRegularFile()) { + throw new IOException("Iris pack fingerprint rejected unsupported entry: " + file); + } + String relative = treeRoot.relativize(file).toString().replace(File.separatorChar, '/'); + entries.add(new FingerprintEntry(file, logicalRoot + "/" + relative)); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + throw new IOException("Unable to inspect Iris pack entry: " + file, failure); + } + }); + } + + private record FingerprintEntry(Path source, String relativePath) { + } + + private static void updateDigestInt(MessageDigest digest, int value) { + for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { + digest.update((byte) (value >>> shift)); + } + } + + private static void updateDigestLong(MessageDigest digest, long value) { + for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { + digest.update((byte) (value >>> shift)); + } } public static File resolveDatapacksFolder(File worldFolder) { @@ -255,12 +450,12 @@ public class ServerConfigurator { return IrisWorldStorage.levelRoot(worldFolder); } - private static boolean verifyDataPacksPost(boolean allowRestarting) { + private static boolean verifyDataPacksPost() { try (Stream stream = allPacks()) { boolean bad = stream .map(data -> { IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath()); - var loader = data.getDimensionLoader(); + ResourceLoader loader = data.getDimensionLoader(); return loader.loadAll(loader.getPossibleKeys()) .stream() .filter(Objects::nonNull) @@ -270,13 +465,13 @@ public class ServerConfigurator { }) .toList() .contains(true); - if (!bad) return false; + if (!bad) { + return false; + } } - if (allowRestarting) { - restart(); - } else if (INMS.get().supportsDataPacks()) { + if (INMS.get().supportsDataPacks()) { IrisLogging.error("============================================================================"); IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes."); IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes."); @@ -292,22 +487,23 @@ public class ServerConfigurator { } } - J.sleep(3000); } return true; } public static void restart() { - J.s(() -> { - IrisLogging.warn("New data pack entries have been installed in Iris! Restarting server!"); - IrisLogging.warn("This will only happen when your pack changes (updates/first time setup)"); - IrisLogging.warn("(You can disable this auto restart in iris settings)"); + restart("New data pack entries have been installed in Iris."); + } + + public static void restart(String reason) { + LifecycleOperationCoordinator.get().quiesceForRestart(() -> J.s(() -> { + IrisLogging.warn(reason + " Restarting server to restore a safe lifecycle boundary."); J.s(() -> { IrisLogging.warn("Looks like the restart command didn't work. Stopping the server instead!"); Bukkit.shutdown(); }, 100); Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "restart"); - }); + })); } public static boolean verifyDataPackInstalled(IrisDimension dimension) { @@ -360,12 +556,12 @@ public class ServerConfigurator { } public static Stream allPacks() { - File[] packs = IrisPlatforms.get().dataFolder("packs").listFiles(File::isDirectory); - Stream locals = packs == null ? Stream.empty() : Arrays.stream(packs); + Stream locals = PackDirectoryResolver.listVisiblePackDirectories( + IrisPlatforms.get().dataFolder("packs") + ).stream(); return Stream.concat(locals - .filter(base -> !base.getName().contains(".importing-")) - .filter( base -> { - var content = new File(base, "dimensions").listFiles(); + .filter(base -> { + File[] content = new File(base, "dimensions").listFiles(); return content != null && content.length > 0; }) .map(IrisData::get), IrisWorlds.get().getPacks()); diff --git a/core/src/main/java/art/arcane/iris/core/WorldRemovalPathPolicy.java b/core/src/main/java/art/arcane/iris/core/WorldRemovalPathPolicy.java new file mode 100644 index 000000000..30b81b055 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/WorldRemovalPathPolicy.java @@ -0,0 +1,169 @@ +package art.arcane.iris.core; + +import org.bukkit.NamespacedKey; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public final class WorldRemovalPathPolicy { + private WorldRemovalPathPolicy() { + } + + public static Target resolve(String identifier, String configuredMainWorld, Path levelRoot) { + return resolve(identifier, configuredMainWorld, List.of(configuredMainWorld), levelRoot); + } + + public static Target resolve( + String identifier, + String currentMainWorld, + Collection protectedWorldNames, + Path levelRoot + ) { + String requestedIdentifier = requireIdentifier(identifier); + String mainWorld = requireIdentifier(currentMainWorld); + for (String protectedWorldName : Objects.requireNonNull(protectedWorldNames, "protectedWorldNames")) { + if (protectedWorldName != null + && !protectedWorldName.isBlank() + && requestedIdentifier.equalsIgnoreCase(protectedWorldName.trim())) { + throw new Rejection(RejectionReason.CONFIGURED_MAIN_WORLD, + "A current or configured main world cannot be removed."); + } + } + if (requestedIdentifier.toLowerCase(Locale.ENGLISH).startsWith(NamespacedKey.MINECRAFT + ":")) { + throw new Rejection(RejectionReason.MINECRAFT_NAMESPACE, + "Minecraft namespace worlds cannot be removed."); + } + + NamespacedKey worldKey; + try { + worldKey = IrisWorldStorage.managedKeyFromName(requestedIdentifier, mainWorld); + } catch (IllegalArgumentException failure) { + throw classifyIdentifierFailure(requestedIdentifier, failure); + } + + Path normalizedLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize(); + if (Files.isSymbolicLink(normalizedLevelRoot)) { + throw new Rejection(RejectionReason.SYMBOLIC_LINK, + "World storage path contains a symbolic link: " + normalizedLevelRoot); + } + Path target; + try { + target = IrisWorldStorage.requireSafeManagedDimensionRoot( + normalizedLevelRoot.toFile(), + worldKey + ).toPath().toAbsolutePath().normalize(); + } catch (IllegalArgumentException failure) { + throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure); + } + validateStoragePath(normalizedLevelRoot, worldKey, target); + return new Target( + requestedIdentifier, + worldKey, + IrisWorldStorage.logicalName(worldKey, mainWorld), + normalizedLevelRoot, + target + ); + } + + public static void validateStoragePath(Path levelRoot, NamespacedKey worldKey, Path candidate) { + Path normalizedLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize(); + if (Files.isSymbolicLink(normalizedLevelRoot)) { + throw new Rejection(RejectionReason.SYMBOLIC_LINK, + "World storage path contains a symbolic link: " + normalizedLevelRoot); + } + Path expected; + try { + expected = IrisWorldStorage.requireSafeManagedDimensionRoot( + normalizedLevelRoot.toFile(), + Objects.requireNonNull(worldKey, "worldKey") + ).toPath().toAbsolutePath().normalize(); + } catch (IllegalArgumentException failure) { + throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure); + } + Path normalizedCandidate = Objects.requireNonNull(candidate, "candidate").toAbsolutePath().normalize(); + if (!normalizedCandidate.equals(expected)) { + throw new Rejection(RejectionReason.OUTSIDE_STORAGE_ROOT, + "The world directory is outside its exact Iris dimension storage root."); + } + } + + private static Rejection classifyIdentifierFailure(String identifier, IllegalArgumentException failure) { + NamespacedKey parsed = identifier.contains(":") + ? NamespacedKey.fromString(identifier.toLowerCase(Locale.ENGLISH)) + : null; + RejectionReason reason = parsed != null && !"iris".equals(parsed.getNamespace()) + ? RejectionReason.NOT_IRIS_NAMESPACE + : RejectionReason.INVALID_IDENTIFIER; + return new Rejection(reason, failure.getMessage(), failure); + } + + private static Rejection classifyStorageFailure( + Path levelRoot, + NamespacedKey worldKey, + IllegalArgumentException failure + ) { + Path dimensions = levelRoot.resolve("dimensions"); + Path namespace = dimensions.resolve(worldKey.getNamespace()); + Path target = namespace.resolve(worldKey.getKey()); + RejectionReason reason = Files.isSymbolicLink(dimensions) + || Files.isSymbolicLink(namespace) + || Files.isSymbolicLink(target) + ? RejectionReason.SYMBOLIC_LINK + : RejectionReason.OUTSIDE_STORAGE_ROOT; + return new Rejection(reason, failure.getMessage(), failure); + } + + private static String requireIdentifier(String identifier) { + if (identifier == null || identifier.isBlank()) { + throw new Rejection(RejectionReason.INVALID_IDENTIFIER, "The world identifier cannot be empty."); + } + return identifier.trim(); + } + + public record Target( + String requestedIdentifier, + NamespacedKey worldKey, + String logicalName, + Path levelRoot, + Path worldDirectory + ) { + public Target { + Objects.requireNonNull(requestedIdentifier, "requestedIdentifier"); + Objects.requireNonNull(worldKey, "worldKey"); + Objects.requireNonNull(logicalName, "logicalName"); + Objects.requireNonNull(levelRoot, "levelRoot"); + Objects.requireNonNull(worldDirectory, "worldDirectory"); + } + } + + public enum RejectionReason { + INVALID_IDENTIFIER, + CONFIGURED_MAIN_WORLD, + MINECRAFT_NAMESPACE, + NOT_IRIS_NAMESPACE, + OUTSIDE_STORAGE_ROOT, + SYMBOLIC_LINK + } + + public static final class Rejection extends IllegalArgumentException { + private final RejectionReason reason; + + private Rejection(RejectionReason reason, String message) { + super(message); + this.reason = Objects.requireNonNull(reason, "reason"); + } + + private Rejection(RejectionReason reason, String message, Throwable cause) { + super(message, cause); + this.reason = Objects.requireNonNull(reason, "reason"); + } + + public RejectionReason reason() { + return reason; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index 38d246495..ae0b28393 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -25,10 +25,14 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.datapack.ModrinthResolver.ResolvedDatapack; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.nms.MinecraftVersion; import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.project.IrisCodeWorkspace; import art.arcane.iris.core.structure.BulkStructureImporter; import art.arcane.iris.core.structure.StructureImporter; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisImportedStructureControl; import art.arcane.iris.util.common.format.C; @@ -38,36 +42,77 @@ import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.io.ZipUtils; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.bukkit.Bukkit; +import org.bukkit.Server; import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UncheckedIOException; import java.net.HttpURLConnection; import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; +import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileStore; +import java.nio.file.LinkOption; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Stream; public final class DatapackIngestService { private static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)"; private static final String OVERRIDES_STRIPPED_MARKER = ".iris-overrides-stripped"; + private static final String OWNERSHIP_MARKER = ".iris-managed.json"; + private static final String TRANSACTION_DIRECTORY = ".iris-datapack-transactions"; + private static final String TRANSACTION_JOURNAL = "journal.json"; + private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json"; + private static final int OWNERSHIP_SCHEMA = 1; + private static final int TRANSACTION_SCHEMA = 2; + private static final int MAX_REDIRECTS = 5; + private static final int MAX_ARCHIVE_ENTRIES = 100_000; + private static final int MAX_CACHE_FILES = 32; + private static final int MAX_MANAGED_PATHS = MAX_ARCHIVE_ENTRIES + 16; + private static final long MAX_DOWNLOAD_BYTES = 256L * 1024L * 1024L; + private static final long MAX_EXPANDED_BYTES = 1024L * 1024L * 1024L; + private static final long MAX_ENTRY_BYTES = 256L * 1024L * 1024L; + private static final long MAX_CACHE_BYTES = 1024L * 1024L * 1024L; + private static final long MAX_MANIFEST_BYTES = 16L * 1024L * 1024L; + private static final long MAX_TRANSACTION_JOURNAL_BYTES = 4L * 1024L * 1024L; + private static final long MAX_METADATA_BYTES = 1024L * 1024L; + private static final long MAX_OWNERSHIP_BYTES = 1024L * 1024L; + private static final int MAX_TRANSACTION_COUNT = 1_024; + private static final Set RESERVED_IDS = Set.of("iris"); + private static final ReentrantLock TRANSACTION_LOCK = new ReentrantLock(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private DatapackIngestService() { @@ -111,6 +156,15 @@ public final class DatapackIngestService { } public static Report ingest(VolmitSender sender, KList urls, boolean restart) { + TRANSACTION_LOCK.lock(); + try { + return ingestLocked(sender, urls, restart); + } finally { + TRANSACTION_LOCK.unlock(); + } + } + + private static Report ingestLocked(VolmitSender sender, KList urls, boolean restart) { Report report = new Report(); if (urls == null || urls.isEmpty()) { message(sender, C.YELLOW + "No datapackImports configured in any loaded pack. Add Modrinth URLs to a dimension's 'datapackImports' list, then run /iris datapack ingest."); @@ -120,19 +174,46 @@ public final class DatapackIngestService { File root = IrisPlatforms.get().dataFolder("datapacks"); File cacheDir = new File(root, "cache"); File stagingDir = new File(root, "staging"); - cacheDir.mkdirs(); - stagingDir.mkdirs(); + try { + ensureScratchDirectory(cacheDir, "datapack download cache"); + ensureScratchDirectory(stagingDir, "datapack staging"); + } catch (IOException e) { + report.failed.add("local storage - " + e.getMessage()); + message(sender, C.RED + "Datapack ingest failed: " + e.getMessage()); + IrisLogging.reportError(e); + return report; + } KList worldFolders = ServerConfigurator.getDatapacksFolder(); String mcVersion = serverMcVersion(); + try { + recoverTransactions(root, worldFolders); + } catch (IOException e) { + report.failed.add("transaction recovery - " + e.getMessage()); + message(sender, C.RED + "Datapack ingest blocked by incomplete transaction recovery: " + e.getMessage()); + IrisLogging.reportError(e); + return report; + } Manifest manifest = readManifest(root); boolean stripOverrides = resolveStripOverrides(); + List installs = new ArrayList<>(); message(sender, C.GRAY + "Ingesting " + C.WHITE + urls.size() + C.GRAY + " datapack import(s)" + (mcVersion == null ? "" : " for MC " + mcVersion) + (stripOverrides ? C.GRAY + " (datapackOverrides=false: minecraft-namespaced structure overrides will be stripped)" : "") + "..."); for (String url : urls) { try { - ingestSingle(sender, url, mcVersion, cacheDir, stagingDir, worldFolders, manifest, report, stripOverrides); + ingestSingle( + sender, + url, + mcVersion, + cacheDir, + stagingDir, + worldFolders, + manifest, + report, + stripOverrides, + installs + ); } catch (Exception e) { report.failed.add(url + " - " + e.getMessage()); message(sender, C.RED + " Failed: " + C.WHITE + url + C.RED + " - " + e.getMessage()); @@ -140,7 +221,46 @@ public final class DatapackIngestService { } } - writeManifest(root, manifest); + diagnoseConflicts(sender, manifest); + ManifestWrite manifestWrite = null; + boolean manifestDurabilityConfirmed = false; + try { + manifestWrite = prepareManifestWrite(root, manifest); + manifestWrite.publish(); + manifestDurabilityConfirmed = true; + } catch (IOException manifestFailure) { + if (manifestWrite == null || !manifestWrite.published()) { + rollbackInstallExecutions(installs, manifestFailure); + report.failed.add("manifest - " + manifestFailure.getMessage()); + report.updated.clear(); + report.requiresRestart = false; + message(sender, C.RED + "Datapack ingest rolled back because the manifest could not be committed: " + + manifestFailure.getMessage()); + IrisLogging.reportError(manifestFailure); + return report; + } + IrisLogging.reportError("Datapack manifest was published but durability confirmation failed; " + + "transaction backups are retained for restart recovery.", manifestFailure); + } finally { + if (manifestWrite != null) { + try { + manifestWrite.discard(); + } catch (IOException cleanupFailure) { + IrisLogging.reportError("Datapack manifest staging cleanup failed.", cleanupFailure); + } + } + } + if (manifestDurabilityConfirmed) { + for (InstallExecution install : installs) { + try { + finishInstallExecution(install); + } catch (IOException cleanupFailure) { + IrisLogging.reportError("Datapack install committed but transaction cleanup requires restart recovery.", + cleanupFailure); + } + } + } + pruneCache(cacheDir); message(sender, C.GREEN + "Datapack ingest complete: " + C.WHITE + report.updated.size() + C.GREEN + " updated, " + C.WHITE + report.upToDate.size() + C.GREEN + " up to date, " + C.WHITE + report.failed.size() + C.GREEN + " failed."); if (report.changed()) { @@ -157,57 +277,535 @@ public final class DatapackIngestService { return report; } - public static void reapplyFromStaging(KList worldFolders) { - File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging"); - if (stagingDir == null || !stagingDir.isDirectory()) { - return; - } - File[] staged = stagingDir.listFiles(File::isDirectory); - if (staged == null || staged.length == 0) { - return; - } - boolean stripOverrides = resolveStripOverrides(); - for (File stagedDir : staged) { - if (!new File(stagedDir, "pack.mcmeta").isFile()) { - continue; - } - try { - install(stagedDir, worldFolders, stagedDir.getName(), false, stripOverrides); - } catch (IOException e) { - IrisLogging.reportError(e); - } + public static boolean reapplyFromStaging(KList worldFolders) { + TRANSACTION_LOCK.lock(); + try { + return reapplyFromStagingLocked(worldFolders); + } finally { + TRANSACTION_LOCK.unlock(); } } - public static boolean remove(VolmitSender sender, String id) { - String cleaned = sanitizeId(id); + private static boolean reapplyFromStagingLocked(KList worldFolders) { File root = IrisPlatforms.get().dataFolder("datapacks"); - Manifest manifest = readManifest(root); - boolean removed = false; - - File stagedDir = new File(new File(root, "staging"), cleaned); - if (stagedDir.isDirectory()) { - IO.delete(stagedDir); - removed = true; + if (!recoverBeforeReapply(root, worldFolders)) { + return false; } - for (File worldFolder : ServerConfigurator.getDatapacksFolder()) { - File target = new File(worldFolder, cleaned); - if (target.isDirectory()) { - IO.delete(target); - removed = true; + File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging"); + return reapplyStagingRoot( + root, stagingDir, worldFolders, resolveStripOverrides()); + } + + static boolean reapplyStagingRoot( + File root, + File stagingDir, + KList worldFolders, + boolean stripOverrides + ) { + Manifest manifest = readManifest(root); + if (stagingDir == null + || !Files.exists(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) { + if (manifest.entries.isEmpty()) { + return true; + } + IrisLogging.error("Managed datapack staging is missing at " + + (stagingDir == null ? new File(root, "staging").getPath() : stagingDir.getPath())); + return false; + } + if (Files.isSymbolicLink(stagingDir.toPath()) + || !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) { + IrisLogging.error("Managed datapack staging is not a safe directory at " + stagingDir.getPath()); + return false; + } + return reapplyStagedDirectories( + root, stagingDir, worldFolders, stripOverrides, manifest); + } + + static boolean reapplyStagedDirectories( + File root, + File stagingDir, + KList worldFolders, + boolean stripOverrides + ) { + if (Files.isSymbolicLink(stagingDir.toPath()) + || !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) { + IrisLogging.error("Managed datapack staging is not a safe directory at " + stagingDir.getPath()); + return false; + } + return reapplyStagedDirectories( + root, stagingDir, worldFolders, stripOverrides, readManifest(root)); + } + + private static boolean reapplyStagedDirectories( + File root, + File stagingDir, + KList worldFolders, + boolean stripOverrides, + Manifest manifest + ) { + File[] staged = stagingDir.listFiles(File::isDirectory); + if (staged == null) { + IrisLogging.error("Unable to enumerate managed datapack staging at " + stagingDir.getPath()); + return false; + } + boolean successful = true; + for (Entry entry : manifest.entries) { + File stagedDir = new File(stagingDir, entry.id); + if (!isUsableStaging(stagedDir, entry)) { + IrisLogging.error("Managed datapack staging is unusable for '" + entry.id + + "' at " + stagedDir.getPath()); + successful = false; + continue; + } + try { + InstallResult result = install(stagedDir, worldFolders, entry, stripOverrides); + if (result.changed()) { + IrisLogging.warn("Repaired installed datapack '" + entry.id + + "' from Iris staging before datapack compilation."); + } + } catch (IOException e) { + IrisLogging.reportError(e); + successful = false; } } - if (manifest.removeById(cleaned)) { - removed = true; - } writeManifest(root, manifest); + return successful; + } - if (removed) { - message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN + "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone."); - } else { - message(sender, C.YELLOW + "No installed datapack named '" + cleaned + "'. Run /iris datapack list to see installed ids."); + static boolean recoverBeforeReapply(File root, List worldFolders) { + try { + recoverTransactions(root, worldFolders); + } catch (IOException e) { + IrisLogging.reportError("Datapack staging reapply blocked by incomplete transaction recovery.", e); + return false; + } + return true; + } + + public static boolean remove(VolmitSender sender, String id) { + TRANSACTION_LOCK.lock(); + try { + return removeLocked(sender, id); + } finally { + TRANSACTION_LOCK.unlock(); + } + } + + private static boolean removeLocked(VolmitSender sender, String id) { + File root = IrisPlatforms.get().dataFolder("datapacks"); + return removeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder()); + } + + static boolean removeLocked(VolmitSender sender, String id, File root, List worldFolders) { + String requested = id == null ? "" : id.trim().toLowerCase(Locale.ROOT); + String cleaned = sanitizeId(id); + if (requested.isBlank() || !requested.equals(cleaned) || RESERVED_IDS.contains(cleaned)) { + message(sender, C.RED + "Invalid Iris-managed datapack id '" + requested + "'. Run /iris datapack list and use the exact listed id."); + return false; + } + try { + recoverTransactions(root, worldFolders); + } catch (IOException e) { + message(sender, C.RED + "Datapack removal blocked by incomplete transaction recovery: " + e.getMessage()); + IrisLogging.reportError(e); + return false; + } + Manifest manifest = readManifest(root); + Entry ownedEntry = manifest.findById(cleaned); + if (ownedEntry == null) { + message(sender, C.YELLOW + "No Iris-managed datapack named '" + cleaned + "'. Unmanaged world datapacks are never removed by Iris."); + return false; + } + + List targets; + try { + targets = preflightRemovalTargets(root, worldFolders, ownedEntry); + } catch (IOException e) { + message(sender, C.RED + "Refused to remove datapack '" + cleaned + "': " + e.getMessage()); + IrisLogging.reportError(e); + return false; + } + + EditableImportRemoval editableRemoval = null; + DirectoryRemoval directoryRemoval = null; + ManifestWrite manifestWrite = null; + DatapackCoordinator coordinator = null; + try { + editableRemoval = prepareEntryImportRemoval(ownedEntry, manifest.entries); + directoryRemoval = prepareOwnedDirectories(targets); + if (!manifest.removeById(cleaned)) { + throw new IOException("Datapack manifest entry disappeared during removal"); + } + manifestWrite = prepareManifestWrite(root, manifest); + coordinator = createRemovalCoordinator(root, ownedEntry, directoryRemoval, editableRemoval); + coordinator.phase(CoordinatorPhase.PUBLISHING); + directoryRemoval.prepare(); + coordinator.phase(CoordinatorPhase.PUBLISHED); + manifestWrite.publish(); + } catch (IOException | RuntimeException removalFailure) { + boolean manifestCommitted = manifestWrite != null && manifestWrite.published(); + if (manifestCommitted) { + finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, + removalFailure); + message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN + + "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone."); + return true; + } + boolean restored = rollbackRemoval(manifestWrite, directoryRemoval, editableRemoval, removalFailure); + if (restored && coordinator != null) { + try { + coordinator.finish(); + } catch (IOException cleanupFailure) { + removalFailure.addSuppressed(cleanupFailure); + } + } + message(sender, C.RED + "Failed to remove datapack '" + cleaned + + "'; Iris attempted to restore every prior location: " + removalFailure.getMessage()); + IrisLogging.reportError(removalFailure); + return false; + } + finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, null); + message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN + + "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone."); + return true; + } + + private static void finishCommittedRemoval( + String id, + ManifestWrite manifestWrite, + DirectoryRemoval directoryRemoval, + EditableImportRemoval editableRemoval, + DatapackCoordinator coordinator, + Throwable priorFailure + ) { + if (priorFailure != null) { + IOException recoveryFailure = new IOException( + "Datapack manifest committed before publication durability was confirmed", + priorFailure + ); + if (editableRemoval != null) { + try { + editableRemoval.leaveForRecovery(); + } catch (IOException releaseFailure) { + recoveryFailure.addSuppressed(releaseFailure); + } + } + try { + manifestWrite.discard(); + } catch (IOException cleanupFailure) { + recoveryFailure.addSuppressed(cleanupFailure); + } + IrisLogging.reportError("Datapack '" + id + + "' was removed but transaction cleanup requires restart recovery.", recoveryFailure); + return; + } + + IOException failure = null; + if (coordinator != null) { + try { + coordinator.phase(CoordinatorPhase.COMMITTED); + } catch (IOException phaseFailure) { + failure = appendIOException(failure, phaseFailure); + if (editableRemoval != null) { + try { + editableRemoval.leaveForRecovery(); + } catch (IOException releaseFailure) { + failure = appendIOException(failure, releaseFailure); + } + } + try { + manifestWrite.discard(); + } catch (IOException cleanupFailure) { + failure = appendIOException(failure, cleanupFailure); + } + IrisLogging.reportError("Datapack '" + id + + "' was removed but transaction cleanup requires restart recovery.", failure); + return; + } + } + if (editableRemoval != null) { + try { + editableRemoval.markCommitted(); + editableRemoval.finishCommit(); + } catch (IOException | RuntimeException cleanupFailure) { + failure = appendIOException(failure, cleanupFailure); + try { + editableRemoval.leaveForRecovery(); + } catch (IOException releaseFailure) { + failure = appendIOException(failure, releaseFailure); + } + } + } + if (directoryRemoval != null) { + try { + directoryRemoval.finishCommit(); + } catch (IOException | RuntimeException cleanupFailure) { + failure = appendIOException(failure, cleanupFailure); + } + } + try { + manifestWrite.discard(); + } catch (IOException cleanupFailure) { + failure = appendIOException(failure, cleanupFailure); + } + if (failure == null && coordinator != null) { + try { + coordinator.finish(); + } catch (IOException cleanupFailure) { + failure = cleanupFailure; + } + } + if (failure != null) { + IrisLogging.reportError("Datapack '" + id + + "' was removed but transaction cleanup requires restart recovery.", failure); + } + } + + private static IOException appendIOException(IOException current, Throwable failure) { + IOException next = failure instanceof IOException ioFailure + ? ioFailure : new IOException("Datapack transaction participant failed", failure); + if (current == null) { + return next; + } + current.addSuppressed(next); + return current; + } + + private static List preflightRemovalTargets(File root, List worldFolders, Entry entry) throws IOException { + List targets = new ArrayList<>(); + Set seen = new HashSet<>(); + File staging = new File(root, "staging"); + verifyDirectoryContainerIfPresent(staging, "datapack staging"); + File stagedDir = new File(staging, entry.id); + verifyAndCollectRemovalTarget(stagedDir, entry, targets, seen); + for (File worldFolder : worldFolders) { + verifyDirectoryContainerIfPresent(worldFolder, "world datapacks"); + verifyAndCollectRemovalTarget(new File(worldFolder, entry.id), entry, targets, seen); + } + return targets; + } + + private static void verifyAndCollectRemovalTarget( + File target, + Entry entry, + List targets, + Set seen + ) throws IOException { + verifyOwnedDirectoryIfPresent(target, entry); + Path normalized = target.toPath().toAbsolutePath().normalize(); + if (pathExists(normalized, "datapack removal target")) { + Path identity = normalized.toRealPath(); + if (!seen.add(identity)) { + throw new IOException("Aliased datapack removal target " + normalized); + } + targets.add(target); + } + } + + private static EditableImportRemoval prepareEntryImportRemoval( + Entry entry, + List manifestEntries + ) throws IOException { + List prepared = new ArrayList<>(); + Set targetIdSet = new TreeSet<>(entry.importedBundles.keySet()); + targetIdSet.addAll(entry.importedTargets.keySet()); + List targetIds = new ArrayList<>(targetIdSet); + targetIds.sort(String::compareTo); + try { + for (String targetId : targetIds) { + Set retainedKeys = invalidateRetainedImportClaims( + entry, targetId, manifestEntries); + File dataFolder = new File(targetId); + if (!dataFolder.isDirectory()) { + continue; + } + StructureTransactionWriter writer = new StructureTransactionWriter(dataFolder.toPath()); + List removals = ownedImportRemovals( + writer, + entry, + targetId, + retainedKeys + ); + StructureTransactionWriter.PreparedRemoval removal = + writer.prepareMatchingOwnedRemovals(removals); + prepared.add(new PreparedEditableImport(dataFolder, removal)); + } + return new EditableImportRemoval(prepared); + } catch (IOException | RuntimeException preparationFailure) { + IOException failure = preparationFailure instanceof IOException ioFailure + ? ioFailure + : new IOException("Failed preparing editable datapack import cleanup", preparationFailure); + for (int i = prepared.size() - 1; i >= 0; i--) { + try { + prepared.get(i).removal().rollback(); + } catch (IOException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + throw failure; + } + } + + private static List ownedImportRemovals( + StructureTransactionWriter writer, + Entry removingEntry, + String targetId, + Set retainedKeys + ) throws IOException { + List removals = new ArrayList<>(); + Map> removingClaims = importBundleClaims(removingEntry, targetId); + for (Map.Entry> bundle : removingClaims.entrySet()) { + try { + StructureKey targetKey = StructureKey.parse(bundle.getKey()); + if (retainedKeys.contains(bundle.getKey())) { + continue; + } + Optional ownedSource = writer.ownedSource(targetKey); + if (ownedSource.isEmpty() || !sourceClaimsContain(bundle.getValue(), ownedSource.get())) { + continue; + } + removals.add(new StructureTransactionWriter.OwnedRemoval( + targetKey, + ownedSource.get().kind(), + ownedSource.get().key() + )); + } catch (RuntimeException e) { + throw new IOException("Invalid editable structure import inventory entry '" + + bundle.getKey() + "' -> '" + bundle.getValue() + "'", e); + } + } + return List.copyOf(removals); + } + + private static Set invalidateRetainedImportClaims( + Entry removingEntry, + String targetId, + List manifestEntries + ) { + Set removingKeys = importBundleClaims(removingEntry, targetId).keySet(); + Set retainedKeys = new TreeSet<>(); + for (Entry candidate : manifestEntries) { + if (candidate == removingEntry) { + continue; + } + Set candidateKeys = importBundleClaims(candidate, targetId).keySet(); + boolean candidateRetained = false; + for (String candidateKey : candidateKeys) { + if (removingKeys.contains(candidateKey)) { + retainedKeys.add(candidateKey); + candidateRetained = true; + } + } + if (candidateRetained) { + candidate.importedTargets.remove(targetId); + candidate.structuresImported = false; + } + } + return Set.copyOf(retainedKeys); + } + + private static Map> importBundleClaims(Entry entry, String targetId) { + Map> claims = new TreeMap<>(); + addImportBundleClaims(claims, entry.importedBundles.getOrDefault(targetId, Map.of())); + if (entry.importedBundles.containsKey(targetId) || entry.importedTargets.containsKey(targetId)) { + addImportBundleClaims(claims, importBundleInventory(entry)); + } + return claims; + } + + private static void addImportBundleClaims( + Map> claims, + Map inventory + ) { + for (Map.Entry bundle : inventory.entrySet()) { + claims.computeIfAbsent(bundle.getKey(), ignored -> new TreeSet<>()).add(bundle.getValue()); + } + } + + private static boolean sourceClaimsContain(Set claims, StructureSource source) throws IOException { + for (String claimedKey : claims) { + try { + StructureKey sourceKey = StructureKey.parse(claimedKey); + StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft") + ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK; + if (source.kind() == sourceKind && source.key().equals(sourceKey)) { + return true; + } + } catch (RuntimeException e) { + throw new IOException("Invalid editable structure source key '" + claimedKey + "'", e); + } + } + return false; + } + + private static DirectoryRemoval prepareOwnedDirectories(List targets) throws IOException { + List planned = new ArrayList<>(); + for (File target : targets) { + File parent = target.getParentFile(); + File backupRoot = new File(parent.getParentFile() == null ? parent : parent.getParentFile(), ".iris-datapack-remove"); + File backup = new File(backupRoot, target.getName() + "-" + UUID.randomUUID()); + ensureScratchDirectory(backupRoot, "datapack removal backup"); + validateInstallTree(target, parent, "Datapack removal target"); + planned.add(new DirectoryMove( + target, + backup, + directoryHash(target), + ownershipMarkerFingerprint(target), + directoryIdentity(target), + realDirectoryPath(parent, "datapack target root"), + realDirectoryPath(backupRoot, "datapack removal scratch root"), + directoryIdentity(parent), + directoryIdentity(backupRoot) + )); + } + return new DirectoryRemoval(planned); + } + + private static boolean rollbackRemoval( + ManifestWrite manifestWrite, + DirectoryRemoval directoryRemoval, + EditableImportRemoval editableRemoval, + Throwable removalFailure + ) { + if (manifestWrite != null) { + try { + manifestWrite.discard(); + } catch (IOException discardFailure) { + removalFailure.addSuppressed(discardFailure); + } + } + if (directoryRemoval != null) { + try { + directoryRemoval.rollback(); + } catch (IOException rollbackFailure) { + removalFailure.addSuppressed(rollbackFailure); + } + } + if (editableRemoval != null) { + try { + editableRemoval.rollback(); + } catch (IOException rollbackFailure) { + removalFailure.addSuppressed(rollbackFailure); + } + } + return removalFailure.getSuppressed().length == 0; + } + + private static void verifyOwnedDirectoryIfPresent(File directory, Entry entry) throws IOException { + if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (Files.isSymbolicLink(directory.toPath()) + || !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath()); + } + Ownership ownership = readOwnership(directory); + if (!ownershipSourceMatches(ownership, entry)) { + throw new IOException("Ownership marker at " + directory.getPath() + " belongs to '" + ownership.id + "'"); + } + if (!Objects.equals(ownership.contentHash, directoryHash(directory))) { + throw new IOException("Refusing to remove modified or corrupt Iris-managed datapack " + directory.getPath()); } - return removed; } public static KList collectConfiguredImports() { @@ -221,30 +819,84 @@ public final class DatapackIngestService { } public static List installed() { - return readManifest(IrisPlatforms.get().dataFolder("datapacks")).entries; + TRANSACTION_LOCK.lock(); + try { + File root = IrisPlatforms.get().dataFolder("datapacks"); + try { + recoverTransactions(root, ServerConfigurator.getDatapacksFolder()); + } catch (IOException e) { + IrisLogging.reportError("Could not recover datapack transactions before listing installed packs.", e); + return List.of(); + } + return List.copyOf(readManifest(root).entries); + } finally { + TRANSACTION_LOCK.unlock(); + } } - private static void ingestSingle(VolmitSender sender, String url, String mcVersion, File cacheDir, File stagingDir, KList worldFolders, Manifest manifest, Report report, boolean stripOverrides) throws IOException { + private static void ingestSingle( + VolmitSender sender, + String url, + String mcVersion, + File cacheDir, + File stagingDir, + KList worldFolders, + Manifest manifest, + Report report, + boolean stripOverrides, + List installs + ) throws IOException { ResolvedDatapack resolved = ModrinthResolver.resolve(url, mcVersion); - String id = deriveId(resolved); - File stagedDir = new File(stagingDir, id); Entry existing = manifest.find(url); - boolean sameVersion = existing != null + String id = existing == null ? deriveId(resolved) : existing.id; + if (RESERVED_IDS.contains(id)) { + throw new IOException("Datapack id '" + id + "' is reserved by Iris"); + } + Entry idCollision = manifest.findById(id); + if (idCollision != null && !Objects.equals(idCollision.url, url)) { + throw new IOException("Datapack id '" + id + "' is already owned by " + idCollision.url); + } + File stagedDir = new File(stagingDir, id); + boolean stageUsable = existing != null && isUsableStaging(stagedDir, existing); + boolean recoveringManagedStaging = existing != null && !stageUsable; + boolean sameVersion = !resolved.isDirect() + && existing != null && Objects.equals(existing.versionId, resolved.getVersionId()) && (resolved.getSha1() == null || Objects.equals(existing.sha1, resolved.getSha1())) - && stagedDir.isDirectory() - && new File(stagedDir, "pack.mcmeta").isFile(); + && stageUsable; if (sameVersion) { - install(stagedDir, worldFolders, id, false, stripOverrides); - report.upToDate.add(id + " (" + safe(resolved.getVersionNumber()) + ")"); - message(sender, C.GRAY + " Up to date: " + C.WHITE + id + C.GRAY + " " + safe(resolved.getVersionNumber())); + InstallExecution execution = prepareInstallExecution( + stagedDir, worldFolders, existing, stripOverrides, cacheDir.getParentFile()); + installs.add(execution); + InstallResult installResult = execution.result(); + recordInstallResult(sender, report, existing, installResult, resolved.getVersionNumber()); return; } - message(sender, C.GRAY + " Downloading " + C.WHITE + id + C.GRAY + " " + safe(resolved.getVersionNumber()) + "..."); + message(sender, C.GRAY + " Checking " + C.WHITE + id + C.GRAY + " " + safe(resolved.getVersionNumber()) + "..."); File zip = new File(cacheDir, id + "-" + safeFile(resolved.getVersionId()) + ".zip"); - download(resolved.getDownloadUrl(), zip); + DownloadResult download = download( + resolved.getDownloadUrl(), + zip, + resolved.isDirect() && stageUsable ? existing.etag : null, + resolved.isDirect() && stageUsable ? existing.lastModified : null + ); + if (download.notModified()) { + if (!stageUsable) { + throw new IOException("Remote returned not-modified but Iris staging is missing or corrupt for " + id); + } + Entry updated = copyEntry(existing); + updated.etag = download.etag(); + updated.lastModified = download.lastModified(); + InstallExecution execution = prepareInstallExecution( + stagedDir, worldFolders, updated, stripOverrides, cacheDir.getParentFile()); + installs.add(execution); + InstallResult installResult = execution.result(); + manifest.put(updated); + recordInstallResult(sender, report, updated, installResult, resolved.getVersionNumber()); + return; + } String checksum = sha1(zip); if (resolved.getSha1() != null && !resolved.getSha1().isBlank() && !resolved.getSha1().equalsIgnoreCase(checksum)) { @@ -252,32 +904,276 @@ public final class DatapackIngestService { throw new IOException("Checksum mismatch for " + id + " (expected " + resolved.getSha1() + ", got " + checksum + ")"); } - IO.delete(stagedDir); - stagedDir.mkdirs(); - ZipUtils.unzipFile(zip, stagedDir); - flattenIfWrapped(stagedDir); - if (!new File(stagedDir, "pack.mcmeta").isFile()) { - IO.delete(stagedDir); - throw new IOException(id + " is not a valid datapack (missing pack.mcmeta)"); + if (resolved.isDirect() && existing != null && Objects.equals(existing.sha1, checksum) && stageUsable) { + Entry updated = copyEntry(existing); + updated.etag = download.etag(); + updated.lastModified = download.lastModified(); + updated.installedEpoch = System.currentTimeMillis(); + InstallExecution execution = prepareInstallExecution( + stagedDir, worldFolders, updated, stripOverrides, cacheDir.getParentFile()); + installs.add(execution); + InstallResult installResult = execution.result(); + writeOwnership(stagedDir, updated); + manifest.put(updated); + recordInstallResult(sender, report, updated, installResult, resolved.getVersionNumber()); + return; } - install(stagedDir, worldFolders, id, true, stripOverrides); - - Entry entry = existing != null ? existing : new Entry(); + Entry entry = existing != null ? copyEntry(existing) : new Entry(); entry.url = url; entry.id = id; entry.versionId = resolved.getVersionId(); entry.versionNumber = resolved.getVersionNumber(); entry.sha1 = checksum; entry.filename = resolved.getFileName(); + entry.etag = download.etag(); + entry.lastModified = download.lastModified(); entry.installedEpoch = System.currentTimeMillis(); entry.structuresImported = false; + File extractedDir = extractArchive(zip, stagingDir, entry); + InstallExecution execution; + try { + VerifiedStagingInstall verifiedStagingInstall = + authorizeVerifiedStagingInstall( + cacheDir.getParentFile(), stagingDir, extractedDir, entry); + execution = prepareInstallExecution( + extractedDir, worldFolders, entry, stripOverrides, cacheDir.getParentFile(), + verifiedStagingInstall); + } finally { + cleanupExtractedStaging(extractedDir); + } + installs.add(execution); + InstallResult installResult = execution.result(); manifest.put(entry); report.updated.add(id + " (" + safe(resolved.getVersionNumber()) + ")"); + if (freshInstallRequiresRestart(installResult.changed(), recoveringManagedStaging)) { + report.requiresRestart = true; + } message(sender, C.GREEN + " Installed " + C.WHITE + id + C.GREEN + " " + safe(resolved.getVersionNumber())); } + static boolean freshInstallRequiresRestart(boolean contentChanged, boolean recoveringManagedStaging) { + return contentChanged || recoveringManagedStaging; + } + + private static File extractArchive(File zip, File stagingRoot, Entry entry) throws IOException { + ensureScratchDirectory(stagingRoot, "datapack staging"); + File pending = new File(stagingRoot, ".pending-" + entry.id + "-" + UUID.randomUUID()); + try { + if (!pending.mkdirs() && !pending.isDirectory()) { + throw new IOException("Couldn't create datapack extraction directory " + pending.getPath()); + } + ZipUtils.unzipFile(zip, pending, MAX_ARCHIVE_ENTRIES, MAX_EXPANDED_BYTES, MAX_ENTRY_BYTES); + flattenIfWrapped(pending); + validatePackMetadata(pending); + PackResources resources = scanPackResources(pending); + entry.structureKeys = resources.structureKeys; + entry.templateKeys = resources.templateKeys; + writeOwnership(pending, entry); + return pending; + } catch (UncheckedIOException e) { + IOException failure = e.getCause(); + cleanupFailedExtraction(pending, failure); + throw failure; + } catch (IOException | RuntimeException e) { + cleanupFailedExtraction(pending, e); + throw e; + } + } + + private static void cleanupFailedExtraction(File pending, Throwable failure) { + try { + deleteInstallScratch(pending, "failed datapack extraction"); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + + private static void cleanupExtractedStaging(File extractedDir) { + try { + deleteInstallScratch(extractedDir, "extracted datapack staging"); + } catch (IOException e) { + IrisLogging.warn("Preserving extracted datapack staging for restart cleanup: " + + extractedDir.getPath() + " (" + e.getMessage() + ")"); + } + } + + static VerifiedStagingInstall authorizeVerifiedStagingInstall( + File root, + File stagingRoot, + File verifiedSource, + Entry entry + ) throws IOException { + Path normalizedRoot = requireDirectoryIdentity(root, "datapack storage root"); + Path normalizedStagingRoot = requireDirectoryIdentity(stagingRoot, "datapack staging root"); + requireNoSymbolicLinkComponents(normalizedRoot, "datapack storage root"); + requireNoSymbolicLinkComponents(normalizedStagingRoot, "datapack staging root"); + Path expectedStagingRoot = normalizedRoot.resolve("staging").normalize(); + if (!normalizedStagingRoot.equals(expectedStagingRoot) + || !Files.isSameFile(normalizedStagingRoot, expectedStagingRoot)) { + throw new IOException("Verified datapack staging root is not Iris's canonical staging directory"); + } + Path normalizedSource = requireDirectoryIdentity(verifiedSource, "verified datapack extraction"); + requireNoSymbolicLinkComponents(normalizedSource, "verified datapack extraction"); + if (!Objects.equals(normalizedSource.getParent(), normalizedStagingRoot) + || !Files.isSameFile(normalizedSource.getParent(), normalizedStagingRoot)) { + throw new IOException("Verified datapack extraction is outside Iris's canonical staging directory"); + } + verifyPendingExtractionName(normalizedSource.getFileName().toString(), entry.id); + validateManagedDirectory(verifiedSource, entry.id); + validateScratchTree(normalizedSource); + if (!Objects.equals(Files.getFileStore(normalizedSource), Files.getFileStore(normalizedStagingRoot))) { + throw new IOException("Verified datapack extraction crosses a filesystem boundary"); + } + String desiredHash = directoryHash(verifiedSource); + Ownership ownership = readOwnership(verifiedSource); + if (!ownershipMetadataMatches(ownership, entry, desiredHash)) { + throw new IOException("Verified datapack extraction does not match the resolved archive metadata"); + } + Manifest committedManifest = readCommittedManifest(root); + Entry committed = committedManifest.findById(entry.id); + boolean legacyReplacementAuthorized = committed != null + && Objects.equals(committed.id, entry.id) + && Objects.equals(committed.url, entry.url); + LegacyStagingSnapshot legacyStagingSnapshot = captureLegacyStagingSnapshot( + normalizedStagingRoot, entry.id, legacyReplacementAuthorized); + return new VerifiedStagingInstall( + normalizedRoot, + normalizedStagingRoot, + normalizedSource, + entry, + desiredHash, + committed, + legacyReplacementAuthorized, + legacyStagingSnapshot + ); + } + + private static LegacyStagingSnapshot captureLegacyStagingSnapshot( + Path normalizedStagingRoot, + String id, + boolean legacyReplacementAuthorized + ) throws IOException { + if (!legacyReplacementAuthorized) { + return null; + } + Path target = normalizedStagingRoot.resolve(id).normalize(); + if (!pathExists(target, "legacy datapack staging target")) { + return null; + } + if (Files.isSymbolicLink(target) + || !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid legacy datapack staging target " + target); + } + validateInstallTree(target.toFile(), normalizedStagingRoot.toFile(), "Legacy datapack staging"); + String markerHash = ownershipMarkerFingerprint(target.toFile()); + if (!"absent".equals(markerHash)) { + return null; + } + return new LegacyStagingSnapshot( + target, + target.toRealPath(), + directoryIdentity(target.toFile()), + directoryHash(target.toFile()), + markerHash + ); + } + + private static Path requireDirectoryIdentity(File directory, String purpose) throws IOException { + Path normalized = directory.toPath().toAbsolutePath().normalize(); + if (Files.isSymbolicLink(normalized) + || !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid " + purpose + " " + normalized); + } + normalized.toRealPath(); + return normalized; + } + + private static void requireNoSymbolicLinkComponents(Path normalized, String purpose) throws IOException { + Path current = normalized.getRoot(); + for (Path component : normalized) { + current = current == null ? component : current.resolve(component); + if (Files.isSymbolicLink(current)) { + throw new IOException("Refusing symbolic-link component in " + purpose + " " + normalized); + } + } + } + + private static void verifyPendingExtractionName(String name, String id) throws IOException { + String prefix = ".pending-" + id + "-"; + if (!name.startsWith(prefix)) { + throw new IOException("Verified datapack extraction has an invalid staging name " + name); + } + try { + UUID.fromString(name.substring(prefix.length())); + } catch (IllegalArgumentException e) { + throw new IOException("Verified datapack extraction has an invalid staging identity " + name, e); + } + } + + private static void validateInstallParticipants( + List worldFolders, + VerifiedStagingInstall verifiedStagingInstall, + Entry entry + ) throws IOException { + List roots = new ArrayList<>(worldFolders); + if (verifiedStagingInstall != null) { + roots.add(verifiedStagingInstall.stagingRoot().toFile()); + } + Set normalizedTargets = new HashSet<>(); + Set realRoots = new HashSet<>(); + Set targetIdentities = new HashSet<>(); + List existingTargets = new ArrayList<>(); + for (File folder : roots) { + ensureInstallTargetRoot(folder); + Path normalizedRoot = folder.toPath().toAbsolutePath().normalize(); + Path realRoot = normalizedRoot.toRealPath(); + Path normalizedTarget = normalizedRoot.resolve(entry.id).normalize(); + if (!realRoots.add(realRoot) || !normalizedTargets.add(normalizedTarget)) { + throw new IOException("Duplicate datapack install target " + normalizedTarget); + } + Path targetIdentity = realRoot.resolve(entry.id).normalize(); + if (Files.exists(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isSymbolicLink(normalizedTarget) + || !Files.isDirectory(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Refusing invalid datapack install target " + normalizedTarget); + } + targetIdentity = normalizedTarget.toRealPath(); + for (Path existingTarget : existingTargets) { + if (Files.isSameFile(normalizedTarget, existingTarget)) { + throw new IOException("Aliased datapack install target " + normalizedTarget); + } + } + existingTargets.add(normalizedTarget); + } + if (!targetIdentities.add(targetIdentity)) { + throw new IOException("Aliased datapack install target " + normalizedTarget); + } + } + if (verifiedStagingInstall != null) { + verifiedStagingInstall.verifyStagingRoot(); + } + } + + private static void ensureInstallTargetRoot(File directory) throws IOException { + if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(directory.toPath()); + } + requireDirectoryIdentity(directory, "datapack install root"); + } + + private static void recordInstallResult(VolmitSender sender, Report report, Entry entry, InstallResult result, String versionNumber) { + if (result.changed()) { + report.updated.add(entry.id + " (" + safe(versionNumber) + ")"); + report.requiresRestart = true; + message(sender, C.GREEN + " Repaired " + C.WHITE + entry.id + C.GREEN + " " + safe(versionNumber)); + return; + } + report.upToDate.add(entry.id + " (" + safe(versionNumber) + ")"); + message(sender, C.GRAY + " Up to date: " + C.WHITE + entry.id + C.GRAY + " " + safe(versionNumber)); + } + private static void collectImports(IrisData data, LinkedHashSet urls) { if (data == null || data.getDimensionLoader() == null) { return; @@ -298,6 +1194,12 @@ public final class DatapackIngestService { } } + private static Set configuredImports(IrisData data) { + LinkedHashSet urls = new LinkedHashSet<>(); + collectImports(data, urls); + return urls; + } + private static boolean hasImports(IrisData data) { if (data.getDimensionLoader() == null) { return false; @@ -314,54 +1216,534 @@ public final class DatapackIngestService { return false; } - private static void install(File stagedDir, KList worldFolders, String id, boolean force, boolean stripOverrides) throws IOException { - for (File worldFolder : worldFolders) { - File target = new File(worldFolder, id); - File marker = new File(target, OVERRIDES_STRIPPED_MARKER); - boolean installed = target.isDirectory() && new File(target, "pack.mcmeta").isFile(); - boolean stripStateMatches = marker.isFile() == stripOverrides; - if (!force && installed && stripStateMatches) { + static InstallResult install(File stagedDir, KList worldFolders, Entry entry, boolean stripOverrides) throws IOException { + File root = inferDatapackRoot(stagedDir); + recoverTransactions(root, worldFolders); + InstallExecution execution = prepareInstallExecution( + stagedDir, + worldFolders, + entry, + stripOverrides, + root + ); + finishInstallExecution(execution); + return execution.result(); + } + + static InstallExecution prepareInstallExecution( + File stagedDir, + KList worldFolders, + Entry entry, + boolean stripOverrides, + File root + ) throws IOException { + return prepareInstallExecution( + stagedDir, worldFolders, entry, stripOverrides, root, null); + } + + static InstallExecution prepareInstallExecution( + File stagedDir, + KList worldFolders, + Entry entry, + boolean stripOverrides, + File root, + VerifiedStagingInstall verifiedStagingInstall + ) throws IOException { + validateManagedDirectory(stagedDir, entry.id); + Ownership stagedOwnership = readOwnership(stagedDir); + String stagedHash = directoryHash(stagedDir); + if (!ownershipMetadataMatches(stagedOwnership, entry, stagedHash)) { + throw new IOException("Iris datapack staging does not match the committed manifest entry for " + entry.id); + } + validateInstallParticipants(worldFolders, verifiedStagingInstall, entry); + List plans = new ArrayList<>(); + try { + for (File worldFolder : worldFolders) { + plans.add(prepareInstall( + stagedDir, worldFolder, entry, stagedHash, stripOverrides, verifiedStagingInstall)); + } + if (verifiedStagingInstall != null) { + plans.add(prepareInstall( + stagedDir, + verifiedStagingInstall.stagingRoot().toFile(), + entry, + stagedHash, + false, + verifiedStagingInstall)); + } + } catch (IOException | RuntimeException preparationFailure) { + for (InstallPlan plan : plans) { + try { + cleanupInstallPlan(plan, false); + } catch (IOException cleanupFailure) { + preparationFailure.addSuppressed(cleanupFailure); + } + } + if (preparationFailure instanceof UncheckedIOException unchecked) { + throw unchecked.getCause(); + } + if (preparationFailure instanceof IOException ioFailure) { + throw ioFailure; + } + throw preparationFailure; + } + + boolean changed = false; + List publishPlans = new ArrayList<>(); + try { + for (InstallPlan plan : plans) { + changed |= plan.contentChanged(); + if (plan.publishRequired()) { + publishPlans.add(plan); + } else { + cleanupInstallPlan(plan, true); + } + } + } catch (IOException cleanupFailure) { + for (InstallPlan plan : plans) { + try { + cleanupInstallPlan(plan, false); + } catch (IOException additionalFailure) { + cleanupFailure.addSuppressed(additionalFailure); + } + } + throw cleanupFailure; + } + if (publishPlans.isEmpty()) { + return new InstallExecution(new InstallResult(changed), null); + } + + Manifest committedManifest = readCommittedManifest(root); + boolean manifestAlreadyMatched = manifestEntryMatches(committedManifest.findById(entry.id), entry); + DatapackCoordinator coordinator; + try { + coordinator = createInstallCoordinator(root, entry, publishPlans, manifestAlreadyMatched); + } catch (IOException | RuntimeException coordinatorFailure) { + for (InstallPlan plan : publishPlans) { + try { + cleanupInstallPlan(plan, false); + } catch (IOException cleanupFailure) { + coordinatorFailure.addSuppressed(cleanupFailure); + } + } + if (coordinatorFailure instanceof IOException ioFailure) { + throw ioFailure; + } + throw coordinatorFailure; + } + + try { + coordinator.phase(CoordinatorPhase.PUBLISHING); + for (InstallPlan plan : publishPlans) { + publishInstallPlan(plan); + } + coordinator.phase(CoordinatorPhase.PUBLISHED); + } catch (IOException | RuntimeException publishFailure) { + try { + resolveCoordinatorDirectories(coordinator.journal, false); + coordinator.finish(); + } catch (IOException rollbackFailure) { + publishFailure.addSuppressed(rollbackFailure); + } + if (publishFailure instanceof IOException ioFailure) { + throw ioFailure; + } + throw publishFailure; + } + return new InstallExecution(new InstallResult(changed), coordinator); + } + + static void finishInstallExecution(InstallExecution execution) throws IOException { + if (execution.coordinator() == null) { + return; + } + execution.coordinator().phase(CoordinatorPhase.COMMITTED); + resolveCoordinatorDirectories(execution.coordinator().journal, true); + execution.coordinator().finish(); + } + + static void rollbackInstallExecutions(List executions, Throwable failure) { + List reversed = new ArrayList<>(executions); + Collections.reverse(reversed); + for (InstallExecution execution : reversed) { + if (execution.coordinator() == null) { continue; } - if (!worldFolder.isDirectory() && !worldFolder.mkdirs() && !worldFolder.isDirectory()) { - throw new IOException("Couldn't create datapacks folder " + worldFolder.getPath()); - } - // Stage outside the datapacks folder so a crash mid-copy can't leave a half-written pack for Minecraft to load. - File pendingRoot = worldFolder.getParentFile() == null - ? new File(worldFolder, ".iris-datapack-install") - : new File(worldFolder.getParentFile(), ".iris-datapack-install"); - File pending = new File(pendingRoot, id); - IO.delete(pending); try { - IO.copyDirectory(stagedDir.toPath(), pending.toPath()); - if (stripOverrides) { - stripVanillaStructureOverrides(pending); - writeMarker(new File(pending, OVERRIDES_STRIPPED_MARKER)); - } - if (!new File(pending, "pack.mcmeta").isFile()) { - throw new IOException("Staged datapack " + id + " is missing pack.mcmeta"); - } - IO.delete(target); - try { - move(pending.toPath(), target.toPath()); - } catch (IOException swapFailure) { - IrisLogging.warn("Couldn't swap staged datapack " + id + " into " + target.getPath() + " (" + swapFailure.getMessage() + "); copying instead"); - try { - IO.copyDirectory(pending.toPath(), target.toPath()); - } catch (UncheckedIOException copyFailure) { - IO.delete(target); - throw copyFailure.getCause(); - } - } - } catch (UncheckedIOException e) { - throw e.getCause(); - } finally { - IO.delete(pending); - pendingRoot.delete(); + resolveCoordinatorDirectories(execution.coordinator().journal, false); + execution.coordinator().finish(); + } catch (IOException rollbackFailure) { + failure.addSuppressed(rollbackFailure); } } } + private static File inferDatapackRoot(File stagedDir) { + File parent = stagedDir.getParentFile(); + if (parent != null && "staging".equals(parent.getName()) && parent.getParentFile() != null) { + return parent.getParentFile(); + } + return parent == null ? stagedDir : parent; + } + + private static boolean manifestEntryMatches(Entry committed, Entry desired) { + return committed != null + && Objects.equals(committed.id, desired.id) + && Objects.equals(committed.url, desired.url) + && Objects.equals(committed.versionId, desired.versionId) + && Objects.equals(committed.versionNumber, desired.versionNumber) + && Objects.equals(committed.sha1, desired.sha1); + } + + static String directoryIdentity(File directory) throws IOException { + BasicFileAttributes attributes = Files.readAttributes( + directory.toPath(), BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + throw new IOException("Invalid datapack directory identity at " + directory.getPath()); + } + Object fileKey = attributes.fileKey(); + if (fileKey != null) { + return "key:" + fileKey; + } + return ""; + } + + private static boolean pathExists(Path path, String purpose) throws IOException { + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + return true; + } + if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + throw new IOException("Cannot determine " + purpose + " state at " + path); + } + + private static void validateInstallTree(File directory, File storeAnchor, String purpose) throws IOException { + validateScratchTree(directory.toPath()); + if (!Objects.equals(Files.getFileStore(directory.toPath()), Files.getFileStore(storeAnchor.toPath()))) { + throw new IOException(purpose + " crosses a filesystem boundary at " + directory.getPath()); + } + } + + static InstallPlan prepareInstall( + File stagedDir, + File worldFolder, + Entry entry, + String stagedHash, + boolean stripOverrides, + VerifiedStagingInstall verifiedStagingInstall + ) throws IOException { + ensureInstallTargetRoot(worldFolder); + File target = new File(worldFolder, entry.id); + boolean canonicalStagingInstall = verifiedStagingInstall != null + && verifiedStagingInstall.isCanonicalInstall(worldFolder, target); + boolean legacyReplacementAuthorized = canonicalStagingInstall + && verifiedStagingInstall.consume(stagedDir, worldFolder, target, entry, stagedHash); + VerifiedStagingInstall legacyWorldAuthorization = null; + File pendingRoot = installScratchRoot(worldFolder); + File pending = new File(pendingRoot, entry.id + "-" + UUID.randomUUID()); + File backup = new File(pendingRoot, entry.id + "-backup-" + UUID.randomUUID()); + try { + ensureScratchDirectory(pendingRoot, "datapack install staging"); + String targetRootIdentity = realDirectoryPath(worldFolder, "datapack target root"); + String scratchRootIdentity = realDirectoryPath(pendingRoot, "datapack install scratch root"); + String targetRootFileIdentity = directoryIdentity(worldFolder); + String scratchRootFileIdentity = directoryIdentity(pendingRoot); + IO.copyDirectory(stagedDir.toPath(), pending.toPath()); + Files.deleteIfExists(new File(pending, OWNERSHIP_MARKER).toPath()); + if (!Objects.equals(stagedHash, directoryHash(pending))) { + throw new IOException("Datapack staging changed or copied incompletely while preparing " + entry.id); + } + Files.deleteIfExists(new File(pending, OVERRIDES_STRIPPED_MARKER).toPath()); + if (stripOverrides) { + stripVanillaStructureOverrides(pending); + writeMarker(new File(pending, OVERRIDES_STRIPPED_MARKER)); + } + validatePackMetadata(pending); + writeOwnership(pending, entry); + validateInstallTree(pending, worldFolder, "Prepared datapack install"); + Ownership desiredOwnership = readOwnership(pending); + String desiredHash = desiredOwnership.contentHash; + String desiredMarkerHash = ownershipMarkerFingerprint(pending); + String desiredIdentity = directoryIdentity(pending); + boolean hadTarget = pathExists(target.toPath(), "datapack install target"); + String originalHash = ""; + String originalMarkerHash = "absent"; + String originalIdentity = ""; + boolean contentChanged = !hadTarget; + boolean publishRequired = !hadTarget; + if (hadTarget) { + if (Files.isSymbolicLink(target.toPath()) + || !Files.isDirectory(target.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Refusing to replace non-directory or symbolic-link datapack " + target.getPath()); + } + validateInstallTree(target, worldFolder, "Existing datapack install"); + Ownership ownership = readOwnershipOrNull(target); + String currentHash = directoryHash(target); + originalHash = currentHash; + originalMarkerHash = ownershipMarkerFingerprint(target); + originalIdentity = directoryIdentity(target); + if (ownership == null) { + boolean differingLegacyTarget = !Objects.equals(currentHash, desiredHash); + if (differingLegacyTarget && !legacyReplacementAuthorized + && verifiedStagingInstall != null + && verifiedStagingInstall.authorizeLegacyWorldReplacement( + stagedDir, + worldFolder, + target, + entry, + stagedHash, + currentHash, + originalMarkerHash)) { + legacyReplacementAuthorized = true; + legacyWorldAuthorization = verifiedStagingInstall; + } + if (differingLegacyTarget && !legacyReplacementAuthorized) { + throw new IOException("Refusing to replace unmanaged datapack at " + target.getPath()); + } + if (differingLegacyTarget && (originalIdentity.isEmpty() + || targetRootFileIdentity.isEmpty() + || scratchRootFileIdentity.isEmpty() + || verifiedStagingInstall == null + || !verifiedStagingInstall.hasStablePathIdentities())) { + throw new IOException("Cannot safely identify legacy datapack staging at " + target.getPath()); + } + publishRequired = true; + } else { + if (!entry.id.equals(ownership.id)) { + throw new IOException("Datapack ownership mismatch at " + target.getPath()); + } + publishRequired = !Objects.equals(ownership.contentHash, currentHash) + || !ownershipMetadataMatches(ownership, entry, desiredHash); + } + contentChanged = !Objects.equals(currentHash, desiredHash); + publishRequired |= contentChanged; + if (ownership != null && !Objects.equals(ownership.contentHash, currentHash)) { + IrisLogging.warn("Repairing modified or corrupt Iris-managed datapack at " + target.getPath()); + } + } + return new InstallPlan( + target, + pending, + backup, + pendingRoot, + hadTarget, + publishRequired, + contentChanged, + originalHash, + desiredHash, + originalMarkerHash, + desiredMarkerHash, + originalIdentity, + desiredIdentity, + targetRootIdentity, + scratchRootIdentity, + targetRootFileIdentity, + scratchRootFileIdentity, + entry.id, + entry.url, + legacyWorldAuthorization + ); + } catch (UncheckedIOException e) { + IOException cause = e.getCause(); + cleanupPreparedInstall(pending, pendingRoot, cause); + throw cause; + } catch (IOException e) { + cleanupPreparedInstall(pending, pendingRoot, e); + throw e; + } catch (RuntimeException e) { + cleanupPreparedInstall(pending, pendingRoot, e); + throw e; + } + } + + private static File installScratchRoot(File targetFolder) { + File parent = targetFolder.getParentFile(); + return new File(parent == null ? targetFolder : parent, ".iris-datapack-install"); + } + + private static void cleanupPreparedInstall(File pending, File pendingRoot, Throwable failure) { + try { + deleteInstallScratch(pending, "prepared datapack install"); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + pendingRoot.delete(); + } + + private static boolean ownershipMetadataMatches(Ownership ownership, Entry entry, String contentHash) { + return ownership.schemaVersion == OWNERSHIP_SCHEMA + && Objects.equals(ownership.id, entry.id) + && Objects.equals(ownership.url, entry.url) + && Objects.equals(ownership.versionId, entry.versionId) + && Objects.equals(ownership.versionNumber, entry.versionNumber) + && Objects.equals(ownership.sha1, entry.sha1) + && Objects.equals(ownership.contentHash, contentHash) + && copyList(ownership.structureKeys).equals(copyList(entry.structureKeys)) + && copyList(ownership.templateKeys).equals(copyList(entry.templateKeys)); + } + + private static boolean ownershipSourceMatches(Ownership ownership, Entry entry) { + return ownership.schemaVersion == OWNERSHIP_SCHEMA + && Objects.equals(ownership.id, entry.id) + && Objects.equals(ownership.url, entry.url); + } + + static void publishInstallPlan(InstallPlan plan) throws IOException { + verifyDirectoryContainerIdentity( + plan.target().getParentFile(), plan.targetRootIdentity(), + plan.targetRootFileIdentity(), "datapack target root"); + verifyDirectoryContainerIdentity( + plan.pendingRoot(), plan.scratchRootIdentity(), + plan.scratchRootFileIdentity(), "datapack install scratch root"); + verifyDesiredInstallSnapshot(plan.pending(), plan, "prepared datapack install"); + if (plan.hadTarget()) { + verifyOriginalInstallSnapshot(plan.target(), plan, "original datapack target"); + } else if (pathExists(plan.target().toPath(), "new datapack target")) { + throw new IOException("Datapack install target was concurrently created at " + plan.target().getPath()); + } + if (plan.legacyWorldAuthorization() != null) { + plan.legacyWorldAuthorization().verifyLegacyWorldSnapshot(); + } + try { + if (plan.hadTarget()) { + moveNew(plan.target().toPath(), plan.backup().toPath()); + verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup"); + if (pathExists(plan.target().toPath(), "moved datapack target")) { + throw new IOException("Datapack install target reappeared after backup at " + plan.target().getPath()); + } + forceInstallMoveDirectories(plan); + } + moveNew(plan.pending().toPath(), plan.target().toPath()); + verifyDesiredInstallSnapshot(plan.target(), plan, "installed datapack target"); + forceInstallMoveDirectories(plan); + } catch (IOException publishFailure) { + if (plan.hadTarget() + && Files.exists(plan.backup().toPath(), LinkOption.NOFOLLOW_LINKS) + && Files.exists(plan.pending().toPath(), LinkOption.NOFOLLOW_LINKS) + && Files.notExists(plan.target().toPath(), LinkOption.NOFOLLOW_LINKS)) { + try { + verifyOriginalInstallSnapshot(plan.backup(), plan, "datapack install backup"); + moveNew(plan.backup().toPath(), plan.target().toPath()); + forceInstallMoveDirectories(plan); + } catch (IOException restoreFailure) { + publishFailure.addSuppressed(restoreFailure); + } + } + throw publishFailure; + } + } + + private static void forceInstallMoveDirectories(InstallPlan plan) throws IOException { + forceDirectoryIfSupported(plan.target().getParentFile().toPath()); + forceDirectoryIfSupported(plan.pendingRoot().toPath()); + } + + private static void verifyDirectoryContainerIdentity( + File directory, + String expectedRealPath, + String expectedFileIdentity, + String purpose + ) throws IOException { + Path normalized = directory.toPath().toAbsolutePath().normalize(); + if (Files.isSymbolicLink(normalized) + || !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS) + || !Objects.equals(normalized.toRealPath(), Path.of(expectedRealPath)) + || (!expectedFileIdentity.isEmpty() + && !Objects.equals(directoryIdentity(directory), expectedFileIdentity))) { + throw new IOException("Changed or unsafe " + purpose + " " + normalized); + } + } + + private static void verifyOriginalInstallSnapshot( + File directory, + InstallPlan plan, + String purpose + ) throws IOException { + verifyDirectorySnapshot( + directory, + plan.target().getParentFile(), + plan.originalHash(), + plan.originalMarkerHash(), + plan.originalIdentity(), + purpose + ); + } + + private static void verifyDesiredInstallSnapshot( + File directory, + InstallPlan plan, + String purpose + ) throws IOException { + verifyDirectorySnapshot( + directory, + plan.target().getParentFile(), + plan.desiredHash(), + plan.desiredMarkerHash(), + plan.desiredIdentity(), + purpose + ); + Ownership ownership = readOwnership(directory); + if (!Objects.equals(ownership.id, plan.id()) + || !Objects.equals(ownership.url, plan.url()) + || !Objects.equals(ownership.contentHash, plan.desiredHash())) { + throw new IOException("Datapack ownership changed in " + purpose + " at " + directory.getPath()); + } + } + + private static void verifyDirectorySnapshot( + File directory, + File storeAnchor, + String expectedHash, + String expectedMarkerHash, + String expectedIdentity, + String purpose + ) throws IOException { + if (!pathExists(directory.toPath(), purpose) + || Files.isSymbolicLink(directory.toPath()) + || !Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Missing or unsafe " + purpose + " at " + directory.getPath()); + } + validateInstallTree(directory, storeAnchor, purpose); + if (!expectedIdentity.isEmpty() + && !Objects.equals(directoryIdentity(directory), expectedIdentity)) { + throw new IOException("Datapack directory identity changed in " + purpose + " at " + directory.getPath()); + } + if (!Objects.equals(expectedHash, directoryHash(directory)) + || !Objects.equals(expectedMarkerHash, ownershipMarkerFingerprint(directory))) { + throw new IOException("Datapack content changed in " + purpose + " at " + directory.getPath()); + } + } + + private static void cleanupInstallPlan(InstallPlan plan, boolean committed) throws IOException { + deleteInstallScratch(plan.pending, "datapack install pending directory"); + if (committed) { + deleteInstallScratch(plan.backup, "datapack install backup"); + } + if (Files.exists(plan.backup.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Preserving prior datapack backup after incomplete install: " + + plan.backup.getPath()); + } + plan.pendingRoot.delete(); + } + + private static void deleteInstallScratch(File scratch, String purpose) throws IOException { + if (Files.notExists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (!Files.exists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(scratch.toPath()) + || !Files.isDirectory(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Refusing to remove unsafe " + purpose + " " + scratch.getPath()); + } + File parent = Objects.requireNonNull(scratch.getParentFile(), "datapack scratch parent"); + validateInstallTree(scratch, parent, purpose); + IO.delete(scratch); + if (Files.exists(scratch.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Could not remove " + purpose + " " + scratch.getPath()); + } + } + private static boolean resolveStripOverrides() { try (Stream stream = ServerConfigurator.allPacks()) { return stream.anyMatch(DatapackIngestService::packDisablesOverrides); @@ -384,7 +1766,14 @@ public final class DatapackIngestService { return false; } - private static void stripVanillaStructureOverrides(File datapackRoot) { + private static void stripVanillaStructureOverrides(File datapackRoot) throws IOException { + stripVanillaStructureOverrides(datapackRoot, IO::delete); + } + + static void stripVanillaStructureOverrides( + File datapackRoot, + DirectoryDeleter deleter + ) throws IOException { File minecraftData = new File(new File(datapackRoot, "data"), "minecraft"); if (!minecraftData.isDirectory()) { return; @@ -398,7 +1787,10 @@ public final class DatapackIngestService { for (String tree : relativeTrees) { File dir = new File(minecraftData, tree); if (dir.exists()) { - IO.delete(dir); + deleter.delete(dir); + if (Files.exists(dir.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Could not remove vanilla structure override tree " + dir.getPath()); + } } } } @@ -407,67 +1799,685 @@ public final class DatapackIngestService { Files.writeString(marker.toPath(), "stripped", StandardCharsets.UTF_8); } - private static void autoImportDatapackStructures() { - if (!IrisSettings.get().getGeneral().autoImportDatapackStructures) { + static void validatePackMetadata(File datapackRoot) throws IOException { + File metadata = new File(datapackRoot, "pack.mcmeta"); + if (!metadata.isFile() || Files.isSymbolicLink(metadata.toPath())) { + throw new IOException("Datapack is missing a regular pack.mcmeta"); + } + if (metadata.length() > MAX_METADATA_BYTES) { + throw new IOException("Datapack pack.mcmeta exceeds 1 MiB"); + } + JsonElement parsed; + try { + parsed = JsonParser.parseString(readBoundedUtf8( + metadata.toPath(), MAX_METADATA_BYTES, "Datapack pack.mcmeta")); + } catch (RuntimeException e) { + throw new IOException("Datapack pack.mcmeta is not valid JSON", e); + } + if (!parsed.isJsonObject()) { + throw new IOException("Datapack pack.mcmeta root must be an object"); + } + JsonObject root = parsed.getAsJsonObject(); + if (!root.has("pack") || !root.get("pack").isJsonObject()) { + throw new IOException("Datapack pack.mcmeta must contain a pack object"); + } + JsonObject pack = root.getAsJsonObject("pack"); + boolean hasPackFormat = pack.has("pack_format") && pack.get("pack_format").isJsonPrimitive() + && pack.getAsJsonPrimitive("pack_format").isNumber(); + boolean hasRange = validFormatValue(pack.get("min_format")) && validFormatValue(pack.get("max_format")); + if (!hasPackFormat && !hasRange) { + throw new IOException("Datapack pack.mcmeta must declare numeric pack_format or valid min_format/max_format"); + } + if (!pack.has("description") || pack.get("description").isJsonNull()) { + throw new IOException("Datapack pack.mcmeta must declare a description"); + } + File data = new File(datapackRoot, "data"); + if (data.exists() && (!data.isDirectory() || Files.isSymbolicLink(data.toPath()))) { + throw new IOException("Datapack data path is not a regular directory"); + } + } + + private static boolean validFormatValue(JsonElement value) { + if (value == null || value.isJsonNull()) { + return false; + } + if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber()) { + return true; + } + if (!value.isJsonArray() || value.getAsJsonArray().size() != 2) { + return false; + } + for (JsonElement component : value.getAsJsonArray()) { + if (!component.isJsonPrimitive() || !component.getAsJsonPrimitive().isNumber()) { + return false; + } + } + return true; + } + + static void writeOwnership(File directory, Entry entry) throws IOException { + if (!isValidManagedId(entry.id) || entry.url == null || entry.url.isBlank()) { + throw new IOException("Invalid Iris datapack ownership identity for " + directory.getPath()); + } + String contentHash = directoryHash(directory); + Ownership ownership = new Ownership( + OWNERSHIP_SCHEMA, + entry.id, + entry.url, + entry.versionId, + entry.versionNumber, + entry.sha1, + contentHash, + copyList(entry.structureKeys), + copyList(entry.templateKeys) + ); + Path marker = new File(directory, OWNERSHIP_MARKER).toPath(); + Path temporary = Files.createTempFile(directory.toPath(), OWNERSHIP_MARKER, ".tmp"); + try { + Files.writeString( + temporary, + GSON.toJson(ownership), + StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING + ); + move(temporary, marker); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static Ownership readOwnership(File directory) throws IOException { + Ownership ownership = readOwnershipOrNull(directory); + if (ownership == null) { + throw new IOException("Missing Iris datapack ownership marker in " + directory.getPath()); + } + return ownership; + } + + private static Ownership readOwnershipOrNull(File directory) throws IOException { + File marker = new File(directory, OWNERSHIP_MARKER); + Path markerPath = marker.toPath(); + if (Files.notExists(markerPath, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.exists(markerPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath()); + } + if (Files.isSymbolicLink(markerPath) + || !Files.isRegularFile(markerPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath()); + } + if (marker.length() > MAX_OWNERSHIP_BYTES) { + throw new IOException("Oversized Iris datapack ownership marker in " + directory.getPath()); + } + try { + Ownership ownership = GSON.fromJson(readBoundedUtf8( + marker.toPath(), MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker"), Ownership.class); + if (ownership == null || ownership.schemaVersion != OWNERSHIP_SCHEMA + || !isValidManagedId(ownership.id) || ownership.url == null || ownership.url.isBlank() + || ownership.contentHash == null || ownership.contentHash.isBlank()) { + throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath()); + } + return ownership; + } catch (RuntimeException e) { + throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath(), e); + } + } + + static String ownershipMarkerFingerprint(File directory) throws IOException { + Path marker = new File(directory, OWNERSHIP_MARKER).toPath(); + if (Files.notExists(marker, LinkOption.NOFOLLOW_LINKS)) { + return "absent"; + } + if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Cannot determine Iris datapack ownership marker state in " + directory.getPath()); + } + if (Files.isSymbolicLink(marker) + || !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid Iris datapack ownership marker in " + directory.getPath()); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return "sha256:" + hex(digest.digest(readBoundedBytes( + marker, MAX_OWNERSHIP_BYTES, "Iris datapack ownership marker"))); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-256 algorithm unavailable", e); + } + } + + static boolean isUsableStaging(File stagedDir, Entry entry) { + try { + validateManagedDirectory(stagedDir, entry.id); + Ownership ownership = readOwnership(stagedDir); + String contentHash = directoryHash(stagedDir); + if (!ownershipSourceMatches(ownership, entry) + || !Objects.equals(ownership.versionId, entry.versionId) + || !Objects.equals(ownership.versionNumber, entry.versionNumber) + || !Objects.equals(ownership.sha1, entry.sha1) + || !Objects.equals(ownership.contentHash, contentHash)) { + return false; + } + PackResources resources = scanPackResources(stagedDir); + if (!copyList(resources.structureKeys).equals(copyList(ownership.structureKeys)) + || !copyList(resources.templateKeys).equals(copyList(ownership.templateKeys))) { + Entry corrected = copyEntry(entry); + corrected.structureKeys = resources.structureKeys; + corrected.templateKeys = resources.templateKeys; + writeOwnership(stagedDir, corrected); + } + entry.structureKeys = resources.structureKeys; + entry.templateKeys = resources.templateKeys; + return true; + } catch (IOException e) { + IrisLogging.warn("Ignoring unusable Iris datapack staging at " + stagedDir.getPath() + ": " + e.getMessage()); + return false; + } + } + + private static void validateManagedDirectory(File directory, String id) throws IOException { + Path path = directory.toPath(); + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Missing datapack directory " + directory.getPath()); + } + if (Files.isSymbolicLink(path)) { + throw new IOException("Refusing symbolic-link datapack directory " + directory.getPath()); + } + validatePackMetadata(directory); + rejectSymbolicLinks(directory); + Ownership ownership = readOwnershipOrNull(directory); + if (ownership == null) { + throw new IOException("Datapack is not Iris-managed: " + directory.getPath()); + } + if (!id.equals(ownership.id)) { + throw new IOException("Datapack ownership mismatch at " + directory.getPath()); + } + } + + private static void rejectSymbolicLinks(File root) throws IOException { + try (Stream paths = Files.walk(root.toPath())) { + Path symbolicLink = paths.filter(Files::isSymbolicLink).findFirst().orElse(null); + if (symbolicLink != null) { + throw new IOException("Datapack contains a symbolic link: " + symbolicLink); + } + } + } + + private static String directoryHash(File root) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + Path rootPath = root.toPath().toAbsolutePath().normalize(); + Path rootMarker = rootPath.resolve(OWNERSHIP_MARKER); + List entries = new ArrayList<>(); + try (Stream paths = Files.walk(rootPath)) { + Iterator iterator = paths.iterator(); + int pathCount = 0; + while (iterator.hasNext()) { + Path path = iterator.next(); + if (path.equals(rootPath)) { + continue; + } + if (path.equals(rootMarker)) { + continue; + } + pathCount++; + if (pathCount > MAX_MANAGED_PATHS) { + throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths"); + } + if (Files.isSymbolicLink(path)) { + throw new IOException("Datapack contains a symbolic link: " + path); + } + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) + && !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Datapack contains an unsupported filesystem entry: " + path); + } + entries.add(path); + } + } + entries.sort(Comparator.comparing(path -> rootPath.relativize(path).toString())); + byte[] buffer = new byte[8192]; + long totalBytes = 0; + for (Path entry : entries) { + String relative = rootPath.relativize(entry).toString().replace(File.separatorChar, '/'); + byte[] relativeBytes = relative.getBytes(StandardCharsets.UTF_8); + BasicFileAttributes attributes = Files.readAttributes( + entry, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (!attributes.isDirectory() && !attributes.isRegularFile()) { + throw new IOException("Datapack entry changed while hashing: " + relative); + } + boolean directory = attributes.isDirectory(); + digest.update((byte) (directory ? 1 : 2)); + updateDigestInt(digest, relativeBytes.length); + digest.update(relativeBytes); + if (!directory) { + long expectedBytes = attributes.size(); + if (expectedBytes > MAX_ENTRY_BYTES) { + throw new IOException("Datapack file exceeds " + MAX_ENTRY_BYTES + " bytes: " + relative); + } + totalBytes += expectedBytes; + if (totalBytes > MAX_EXPANDED_BYTES) { + throw new IOException("Datapack contents exceed " + MAX_EXPANDED_BYTES + " bytes"); + } + updateDigestLong(digest, expectedBytes); + long entryBytes = 0; + try (InputStream input = Files.newInputStream( + entry, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS + )) { + int length; + while ((length = input.read(buffer)) > 0) { + entryBytes += length; + if (entryBytes > expectedBytes) { + throw new IOException("Datapack file changed while hashing: " + relative); + } + digest.update(buffer, 0, length); + } + } + if (entryBytes != expectedBytes) { + throw new IOException("Datapack file changed while hashing: " + relative); + } + } + } + return hex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-256 algorithm unavailable", e); + } + } + + private static PackResources scanPackResources(File root) throws IOException { + TreeSet structureKeys = new TreeSet<>(); + TreeSet templateKeys = new TreeSet<>(); + Path dataRoot = new File(root, "data").toPath(); + if (!Files.isDirectory(dataRoot, LinkOption.NOFOLLOW_LINKS)) { + return new PackResources(new ArrayList<>(), new ArrayList<>()); + } + try (Stream paths = Files.walk(dataRoot)) { + for (Path path : paths.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)).toList()) { + Path relative = dataRoot.relativize(path); + if (relative.getNameCount() < 3) { + continue; + } + String namespace = relative.getName(0).toString().toLowerCase(Locale.ROOT); + String normalized = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/'); + addResourceKey(structureKeys, namespace, normalized, "worldgen/structure/", ".json"); + addResourceKey(structureKeys, namespace, normalized, "worldgen/structures/", ".json"); + addResourceKey(templateKeys, namespace, normalized, "structure/", ".nbt"); + addResourceKey(templateKeys, namespace, normalized, "structures/", ".nbt"); + } + } + return new PackResources(new ArrayList<>(structureKeys), new ArrayList<>(templateKeys)); + } + + private static void addResourceKey(Set keys, String namespace, String path, String prefix, String suffix) { + if (!path.startsWith(prefix) || !path.endsWith(suffix)) { return; } + String resourcePath = path.substring(prefix.length(), path.length() - suffix.length()); + if (!resourcePath.isBlank()) { + keys.add(namespace + ":" + resourcePath); + } + } + + static boolean deleteOwnedDirectory(File directory, String id) throws IOException { + Path path = directory.toPath(); + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Refusing non-directory or symbolic-link target " + directory.getPath()); + } + Ownership ownership = readOwnership(directory); + if (!id.equals(ownership.id)) { + throw new IOException("Ownership marker belongs to '" + ownership.id + "'"); + } + if (!Objects.equals(ownership.contentHash, directoryHash(directory))) { + throw new IOException("Refusing to delete modified or corrupt Iris-managed datapack " + directory.getPath()); + } + File parent = Objects.requireNonNull(directory.getParentFile(), "managed datapack parent"); + validateInstallTree(directory, parent, "Managed datapack deletion"); + IO.delete(directory); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Could not delete " + directory.getPath()); + } + return true; + } + + private static void autoImportDatapackStructures() { + TRANSACTION_LOCK.lock(); + try { + autoImportDatapackStructuresLocked(); + } finally { + TRANSACTION_LOCK.unlock(); + } + } + + private static void autoImportDatapackStructuresLocked() { + boolean autoImportEnabled = IrisSettings.get().getGeneral().autoImportDatapackStructures; File root = IrisPlatforms.get().dataFolder("datapacks"); + try { + recoverTransactions(root, ServerConfigurator.getDatapacksFolder()); + } catch (IOException e) { + IrisLogging.reportError("Automatic datapack structure import blocked by incomplete transaction recovery.", e); + return; + } Manifest manifest = readManifest(root); if (manifest.entries.isEmpty()) { return; } - boolean pending = false; + Map manifestEntriesByUrl = new HashMap<>(); + Map entriesByUrl = new HashMap<>(); + File stagingRoot = new File(root, "staging"); for (Entry entry : manifest.entries) { - if (!entry.structuresImported) { - pending = true; - break; + if (entry.url != null) { + manifestEntriesByUrl.put(entry.url, entry); + } + if (entry.url != null && isUsableStaging(new File(stagingRoot, entry.id), entry)) { + entriesByUrl.put(entry.url, entry); } } - if (!pending) { - return; - } - IrisLogging.info("Importing datapack structures (jigsaw pools, pieces & objects) into packs that declare datapackImports..."); - AtomicInteger attemptedPacks = new AtomicInteger(); - AtomicInteger completedPacks = new AtomicInteger(); + int attemptedPacks = 0; + int completedPacks = 0; + int cleanupTargets = 0; + Set completedUrls = new HashSet<>(); + Set failedUrls = new HashSet<>(); + List packs; try (Stream stream = ServerConfigurator.allPacks()) { - stream.forEach(data -> { - if (data == null || !hasImports(data)) { - return; + packs = stream.filter(Objects::nonNull).toList(); + } + for (IrisData data : packs) { + Set configured = configuredImports(data); + String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString(); + for (Entry entry : manifest.entries) { + if (!configured.contains(entry.url) && entry.importedBundles.containsKey(targetId)) { + cleanupTargets++; } - attemptedPacks.incrementAndGet(); - try { - BulkStructureImporter.Report report = BulkStructureImporter.importDatapackStructures( - data, StructureImporter.Mode.ADD_ONLY, BukkitPlatform.console()); - if (report.failed() > 0) { - IrisLogging.error("Datapack structure import for pack '%s' reported %d failure(s); the manifest remains pending for retry.", - data.getDataFolder().getPath(), report.failed()); - return; + } + if (!cleanupRemovedImports(data, targetId, configured, manifest.entries, manifestEntriesByUrl)) { + for (Entry entry : manifest.entries) { + if (!configured.contains(entry.url) && entry.importedBundles.containsKey(targetId)) { + failedUrls.add(entry.url); } - completedPacks.incrementAndGet(); - } catch (RuntimeException e) { - IrisLogging.reportError("Datapack structure import failed for pack '" - + data.getDataFolder().getPath() + "'; the manifest remains pending for retry.", e); } - }); + } + if (!autoImportEnabled) { + continue; + } + + Set pendingUrls = new HashSet<>(); + for (String url : configured) { + Entry entry = entriesByUrl.get(url); + if (entry != null && !importRevision(entry).equals(entry.importedTargets.get(targetId))) { + pendingUrls.add(url); + } + } + if (pendingUrls.isEmpty()) { + continue; + } + for (String pendingUrl : pendingUrls) { + Entry entry = entriesByUrl.get(pendingUrl); + prepareImportRecoveryInventory(entry, targetId); + } + try { + writeManifestChecked(root, manifest); + } catch (IOException e) { + failedUrls.addAll(pendingUrls); + IrisLogging.reportError("Datapack structure import for pack '" + + data.getDataFolder().getPath() + + "' was blocked because its recovery inventory could not be persisted.", e); + continue; + } + Set structureKeys = new TreeSet<>(); + Set templateKeys = new TreeSet<>(); + for (Entry entry : manifest.entries) { + if (!configured.contains(entry.url)) { + continue; + } + structureKeys.addAll(copyList(entry.structureKeys)); + templateKeys.addAll(copyList(entry.templateKeys)); + } + attemptedPacks++; + try { + BulkStructureImporter.Report report = BulkStructureImporter.importDatapackStructures( + data, + StructureImporter.Mode.OVERWRITE, + BukkitPlatform.console(), + structureKeys, + templateKeys + ); + if (report.failed() > 0) { + IrisLogging.error("Datapack structure import for pack '%s' reported %d failure(s); the manifest remains pending for retry.", + data.getDataFolder().getPath(), report.failed()); + failedUrls.addAll(pendingUrls); + reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl); + continue; + } + Set successfulPendingUrls = new HashSet<>(pendingUrls); + Set incompleteUrls = new HashSet<>(); + for (String pendingUrl : pendingUrls) { + Entry entry = entriesByUrl.get(pendingUrl); + Map desired = importBundleInventory(entry); + if (!report.successfulBundles().entrySet().containsAll(desired.entrySet())) { + successfulPendingUrls.remove(pendingUrl); + incompleteUrls.add(pendingUrl); + failedUrls.add(pendingUrl); + IrisLogging.error("Datapack structure import for '%s' did not prove every requested bundle in pack '%s'; the source remains pending for retry.", + pendingUrl, data.getDataFolder().getPath()); + } + } + if (!incompleteUrls.isEmpty()) { + reconcileFailedImportInventories( + root, manifest, data, targetId, incompleteUrls, entriesByUrl); + } + Map sharedBundles = desiredBundles(configured, entriesByUrl); + boolean packCompleted = false; + for (String pendingUrl : successfulPendingUrls) { + Entry entry = entriesByUrl.get(pendingUrl); + Map desired = importBundleInventory(entry); + Map previous = entry.importedBundles.getOrDefault(targetId, Map.of()); + Map stale = new TreeMap<>(previous); + stale.keySet().removeAll(desired.keySet()); + stale.keySet().removeAll(sharedBundles.keySet()); + Map remaining = cleanupImportedBundles(data, stale); + if (!remaining.isEmpty()) { + failedUrls.add(pendingUrl); + continue; + } + entry.importedBundles.put(targetId, desired); + entry.importedTargets.put(targetId, importRevision(entry)); + completedUrls.add(pendingUrl); + packCompleted = true; + } + if (packCompleted) { + completedPacks++; + } + } catch (RuntimeException e) { + failedUrls.addAll(pendingUrls); + IrisLogging.reportError("Datapack structure import failed for pack '" + + data.getDataFolder().getPath() + "'; the manifest remains pending for retry.", e); + reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl); + } } - if (!markStructuresImportedIfComplete( - manifest.entries, attemptedPacks.get(), completedPacks.get())) { + for (Entry entry : manifest.entries) { + if (failedUrls.contains(entry.url)) { + entry.structuresImported = false; + } else if (completedUrls.contains(entry.url)) { + entry.structuresImported = true; + } + } + if (attemptedPacks == 0 && cleanupTargets == 0) { return; } writeManifest(root, manifest); - IrisLogging.info("Datapack structure import finished for " + completedPacks.get() + " pack(s). Reference the imported keys from a 'structures' placement to position them manually."); + if (attemptedPacks == 0) { + IrisLogging.info("Datapack editable-import cleanup reconciled " + cleanupTargets + " removed source target(s)."); + return; + } + IrisLogging.info("Datapack structure import refreshed " + completedUrls.size() + " source(s) across " + + completedPacks + "/" + attemptedPacks + + " pack(s). Reference the imported keys from a 'structures' placement to position them manually."); } - static boolean markStructuresImportedIfComplete(List entries, int attemptedPacks, int completedPacks) { - if (attemptedPacks < 1 || completedPacks != attemptedPacks) { - return false; + private static String importRevision(Entry entry) { + return safe(entry.versionId) + ":" + safe(entry.sha1); + } + + static void prepareImportRecoveryInventory(Entry entry, String targetId) { + Map desired = importBundleInventory(entry); + Map recovery = new TreeMap<>(desired); + recovery.putAll(entry.importedBundles.getOrDefault(targetId, Map.of())); + entry.importedBundles.put(targetId, recovery); + entry.importedTargets.remove(targetId); + entry.structuresImported = false; + } + + private static void reconcileFailedImportInventories( + File root, + Manifest manifest, + IrisData data, + String targetId, + Set pendingUrls, + Map entriesByUrl + ) { + for (String pendingUrl : pendingUrls) { + Entry entry = entriesByUrl.get(pendingUrl); + try { + reconcileFailedImportInventory(data, entry, targetId); + } catch (IOException | RuntimeException e) { + IrisLogging.reportError("Could not reconcile partial editable structure imports for '" + + pendingUrl + "' in pack '" + data.getDataFolder().getPath() + + "'; the conservative recovery inventory remains pending.", e); + } } + try { + writeManifestChecked(root, manifest); + } catch (IOException e) { + IrisLogging.reportError("Could not persist reconciled partial editable structure imports for pack '" + + data.getDataFolder().getPath() + "'; the earlier recovery inventory remains durable.", e); + } + } + + private static void reconcileFailedImportInventory( + IrisData data, + Entry entry, + String targetId + ) throws IOException { + Map> claims = importBundleClaims(entry, targetId); + Map reconciled = new TreeMap<>(); + StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath()); + for (Map.Entry> bundle : claims.entrySet()) { + StructureKey targetKey; + try { + targetKey = StructureKey.parse(bundle.getKey()); + } catch (RuntimeException e) { + throw new IOException("Invalid editable structure target key '" + bundle.getKey() + "'", e); + } + Optional source = writer.ownedSource(targetKey); + if (source.isPresent() && sourceClaimsContain(bundle.getValue(), source.get())) { + reconciled.put(bundle.getKey(), source.get().key().value()); + } + } + if (reconciled.isEmpty()) { + entry.importedBundles.remove(targetId); + } else { + entry.importedBundles.put(targetId, reconciled); + } + entry.importedTargets.remove(targetId); + entry.structuresImported = false; + } + + static boolean cleanupRemovedImports( + IrisData data, + String targetId, + Set configured, + List entries, + Map entriesByUrl + ) { + boolean successful = true; + Map retainedBundles = desiredBundles(configured, entriesByUrl); for (Entry entry : entries) { - entry.structuresImported = true; + if (configured.contains(entry.url)) { + continue; + } + Map inventory = entry.importedBundles.get(targetId); + if (inventory == null) { + continue; + } + for (Entry retainedEntry : entries) { + if (configured.contains(retainedEntry.url)) { + retainedEntry.importedTargets.remove(targetId); + retainedEntry.structuresImported = false; + } + } + Map removable = new TreeMap<>(inventory); + removable.keySet().removeAll(retainedBundles.keySet()); + Map remaining = cleanupImportedBundles(data, removable); + if (!remaining.isEmpty()) { + Map retained = new TreeMap<>(); + for (Map.Entry bundle : inventory.entrySet()) { + if (retainedBundles.containsKey(bundle.getKey()) || remaining.containsKey(bundle.getKey())) { + retained.put(bundle.getKey(), bundle.getValue()); + } + } + entry.importedBundles.put(targetId, retained); + entry.structuresImported = false; + successful = false; + continue; + } + entry.importedBundles.remove(targetId); + entry.importedTargets.remove(targetId); } - return true; + return successful; + } + + private static Map cleanupImportedBundles(IrisData data, Map inventory) { + Map remaining = new TreeMap<>(); + StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath()); + boolean removed = false; + for (Map.Entry bundle : inventory.entrySet()) { + StructureKey sourceKey; + try { + sourceKey = StructureKey.parse(bundle.getValue()); + StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft") + ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK; + removed |= writer.removeOwned(StructureKey.parse(bundle.getKey()), sourceKind, sourceKey); + } catch (IOException | RuntimeException e) { + remaining.put(bundle.getKey(), bundle.getValue()); + IrisLogging.reportError("Preserving imported structure bundle '" + bundle.getKey() + + "' in pack '" + data.getDataFolder().getPath() + + "' because ownership-safe cleanup failed.", e); + } + } + if (removed) { + data.invalidateStructureResources(); + } + return remaining; + } + + private static Map desiredBundles(Set configured, Map entriesByUrl) { + Map bundles = new TreeMap<>(); + for (String url : configured) { + Entry entry = entriesByUrl.get(url); + if (entry != null) { + bundles.putAll(importBundleInventory(entry)); + } + } + return bundles; + } + + static Map importBundleInventory(Entry entry) { + Map bundles = new TreeMap<>(); + for (String structureKey : copyList(entry.structureKeys)) { + bundles.put("iris:" + StructureImporter.deriveName(structureKey), structureKey); + } + for (String templateKey : copyList(entry.templateKeys)) { + bundles.put("iris:" + BulkStructureImporter.templateNameFor(templateKey), templateKey); + } + return bundles; } private static void flattenIfWrapped(File dir) throws IOException { @@ -506,56 +2516,97 @@ public final class DatapackIngestService { } } } - IO.delete(singleDir); + deleteInstallScratch(singleDir, "wrapped datapack extraction"); } - private static void download(String url, File dest) throws IOException { - String current = url; - for (int attempt = 0; attempt < 5; attempt++) { - URL target = URI.create(current).toURL(); + static DownloadResult download(String url, File dest, String etag, String lastModified) throws IOException { + URI current; + try { + current = new URI(url); + } catch (URISyntaxException e) { + throw new IOException("Invalid datapack URL " + url, e); + } + for (int attempt = 0; attempt < MAX_REDIRECTS; attempt++) { + if (!"http".equalsIgnoreCase(current.getScheme()) && !"https".equalsIgnoreCase(current.getScheme())) { + throw new IOException("Datapack URL must use HTTP or HTTPS: " + current); + } + URL target = current.toURL(); HttpURLConnection connection = (HttpURLConnection) target.openConnection(); connection.setRequestMethod("GET"); connection.setRequestProperty("User-Agent", USER_AGENT); + if (etag != null && !etag.isBlank()) { + connection.setRequestProperty("If-None-Match", etag); + } + if (lastModified != null && !lastModified.isBlank()) { + connection.setRequestProperty("If-Modified-Since", lastModified); + } connection.setConnectTimeout(20000); connection.setReadTimeout(60000); connection.setInstanceFollowRedirects(false); - - int code = connection.getResponseCode(); - if (code / 100 == 3) { - String location = connection.getHeaderField("Location"); - connection.disconnect(); - if (location == null || location.isBlank()) { - throw new IOException("Redirect without a location header from " + current); + try { + int code = connection.getResponseCode(); + if (code == HttpURLConnection.HTTP_NOT_MODIFIED) { + String responseEtag = headerOrFallback(connection, "ETag", etag); + String responseLastModified = headerOrFallback(connection, "Last-Modified", lastModified); + return new DownloadResult(true, responseEtag, responseLastModified); + } + if (code / 100 == 3) { + String location = connection.getHeaderField("Location"); + if (location == null || location.isBlank()) { + throw new IOException("Redirect without a location header from " + current); + } + try { + current = current.resolve(new URI(location)); + } catch (URISyntaxException e) { + throw new IOException("Invalid redirect location from " + current + ": " + location, e); + } + continue; + } + if (code != 200) { + throw new IOException("HTTP " + code + " downloading " + current); + } + long declaredLength = connection.getContentLengthLong(); + if (declaredLength > MAX_DOWNLOAD_BYTES) { + throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes"); } - current = location; - continue; - } - if (code != 200) { - connection.disconnect(); - throw new IOException("HTTP " + code + " downloading " + current); - } - File parent = dest.getParentFile(); - if (parent != null) { - parent.mkdirs(); - } - File temp = new File(parent, dest.getName() + ".part"); - try (InputStream in = connection.getInputStream(); - OutputStream out = new FileOutputStream(temp)) { - byte[] buffer = new byte[8192]; - int length; - while ((length = in.read(buffer)) > 0) { - out.write(buffer, 0, length); + File parent = dest.getParentFile(); + Path parentPath = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent.toPath(); + ensureScratchDirectory(parentPath.toFile(), "datapack download cache"); + Path temporary = Files.createTempFile(parentPath, dest.getName() + "-", ".part"); + try { + long downloaded = 0; + String responseEtag = connection.getHeaderField("ETag"); + String responseLastModified = connection.getHeaderField("Last-Modified"); + try (InputStream in = connection.getInputStream(); + OutputStream out = Files.newOutputStream(temporary)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = in.read(buffer)) > 0) { + downloaded += length; + if (downloaded > MAX_DOWNLOAD_BYTES) { + throw new IOException("Datapack download exceeds " + MAX_DOWNLOAD_BYTES + " bytes"); + } + out.write(buffer, 0, length); + } + } + move(temporary, dest.toPath()); + return new DownloadResult(false, responseEtag, responseLastModified); + } finally { + Files.deleteIfExists(temporary); } } finally { connection.disconnect(); } - Files.move(temp.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); - return; } throw new IOException("Too many redirects downloading " + url); } + private static String headerOrFallback(HttpURLConnection connection, String name, String fallback) { + String value = connection.getHeaderField(name); + return value == null || value.isBlank() ? fallback : value; + } + private static String sha1(File file) throws IOException { try { MessageDigest digest = MessageDigest.getInstance("SHA-1"); @@ -586,7 +2637,11 @@ public final class DatapackIngestService { base = base.substring(0, dot); } } - return sanitizeId(base); + String id = sanitizeId(base); + if (resolved.isDirect()) { + return id + "-" + ModrinthResolver.directIdentity(resolved.getDownloadUrl()); + } + return id; } private static String sanitizeId(String value) { @@ -608,13 +2663,18 @@ public final class DatapackIngestService { return cleaned.isBlank() ? "datapack" : cleaned; } + private static boolean isValidManagedId(String value) { + return value != null && !value.isBlank() && value.equals(sanitizeId(value)) + && !RESERVED_IDS.contains(value); + } + private static String serverMcVersion() { - String bukkit = Bukkit.getBukkitVersion(); - if (bukkit == null || bukkit.isBlank()) { - return null; - } - int dash = bukkit.indexOf('-'); - return dash > 0 ? bukkit.substring(0, dash) : bukkit; + return serverMcVersion(Bukkit.getServer()); + } + + static String serverMcVersion(Server server) { + MinecraftVersion detected = MinecraftVersion.detect(server); + return detected == null ? null : detected.value(); } private static String safe(String value) { @@ -625,6 +2685,123 @@ public final class DatapackIngestService { return value == null || value.isBlank() ? "unknown" : value.replaceAll("[^a-zA-Z0-9._-]", "_"); } + private static void diagnoseConflicts(VolmitSender sender, Manifest manifest) { + Map> owners = new TreeMap<>(); + for (Entry entry : manifest.entries) { + for (String key : copyList(entry.structureKeys)) { + owners.computeIfAbsent("structure " + key, ignored -> new ArrayList<>()).add(entry.id); + } + for (String key : copyList(entry.templateKeys)) { + owners.computeIfAbsent("template " + key, ignored -> new ArrayList<>()).add(entry.id); + } + } + int conflicts = 0; + for (Map.Entry> resource : owners.entrySet()) { + if (resource.getValue().size() < 2) { + continue; + } + conflicts++; + if (conflicts <= 20) { + message(sender, C.YELLOW + " Registry conflict: " + C.WHITE + resource.getKey() + + C.YELLOW + " is supplied by " + String.join(", ", resource.getValue())); + } + } + if (conflicts > 0) { + message(sender, C.YELLOW + "Detected " + conflicts + " external datapack registry conflict(s). Minecraft's enabled-pack order in level.dat determines precedence; datapackImports order does not."); + } + if (conflicts > 20) { + message(sender, C.GRAY + " " + (conflicts - 20) + " additional conflict(s) omitted."); + } + } + + private static void pruneCache(File cacheDir) { + File[] files = cacheDir.listFiles(File::isFile); + if (files == null) { + return; + } + List archives = new ArrayList<>(); + long totalBytes = 0; + for (File file : files) { + if (file.getName().endsWith(".part")) { + IO.delete(file); + continue; + } + if (file.getName().endsWith(".zip")) { + archives.add(file); + totalBytes += Math.max(0, file.length()); + } + } + archives.sort(Comparator.comparingLong(File::lastModified)); + int remaining = archives.size(); + for (File archive : archives) { + if (remaining <= MAX_CACHE_FILES && totalBytes <= MAX_CACHE_BYTES) { + break; + } + long size = Math.max(0, archive.length()); + IO.delete(archive); + if (!archive.exists()) { + remaining--; + totalBytes -= size; + } + } + } + + private static List copyList(List values) { + return values == null ? List.of() : List.copyOf(values); + } + + static Entry copyEntry(Entry source) { + Entry resolved = Objects.requireNonNull(source, "Datapack manifest entry must not be null"); + Entry copy = new Entry(); + copy.url = resolved.url; + copy.id = resolved.id; + copy.versionId = resolved.versionId; + copy.versionNumber = resolved.versionNumber; + copy.sha1 = resolved.sha1; + copy.filename = resolved.filename; + copy.etag = resolved.etag; + copy.lastModified = resolved.lastModified; + copy.installedEpoch = resolved.installedEpoch; + copy.structuresImported = resolved.structuresImported; + copy.structureKeys = new ArrayList<>(copyList(resolved.structureKeys)); + copy.templateKeys = new ArrayList<>(copyList(resolved.templateKeys)); + copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet( + resolved.importedTargets, Map::of)); + copy.importedBundles = new HashMap<>(); + if (resolved.importedBundles != null) { + for (Map.Entry> bundle : resolved.importedBundles.entrySet()) { + copy.importedBundles.put(bundle.getKey(), new HashMap<>(bundle.getValue())); + } + } + return copy; + } + + private static String hex(byte[] hash) { + StringBuilder builder = new StringBuilder(hash.length * 2); + for (byte value : hash) { + builder.append(String.format("%02x", value)); + } + return builder.toString(); + } + + private static void updateDigestInt(MessageDigest digest, int value) { + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private static void updateDigestLong(MessageDigest digest, long value) { + digest.update((byte) (value >>> 56)); + digest.update((byte) (value >>> 48)); + digest.update((byte) (value >>> 40)); + digest.update((byte) (value >>> 32)); + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + private static void message(VolmitSender sender, String text) { if (sender != null) { sender.sendMessage(text); @@ -635,49 +2812,206 @@ public final class DatapackIngestService { private static Manifest readManifest(File root) { File file = new File(root, "manifest.json"); - if (!file.isFile()) { - return new Manifest(); + Manifest manifest = null; + boolean recoverFromStaging = false; + Path path = file.toPath(); + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + manifest = new Manifest(); + } else if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + IrisLogging.error("Unreadable datapack manifest " + file.getPath() + + "; moving the non-regular path aside before recovering Iris-managed staging"); + recoverFromStaging = quarantine(path); + } else { + try { + String json = readBoundedUtf8(path, MAX_MANIFEST_BYTES, "Datapack manifest"); + manifest = GSON.fromJson(json, Manifest.class); + if (manifest == null) { + throw new IOException("Datapack manifest is empty"); + } + } catch (Exception e) { + IrisLogging.reportError("Unreadable datapack manifest " + file.getPath() + + "; moving it aside before recovering Iris-managed staging", e); + recoverFromStaging = quarantine(file.toPath()); + } } + if (manifest == null) { + manifest = new Manifest(); + } + normalizeManifest(manifest); + if (recoverFromStaging) { + recoverManifestFromStaging(root, manifest); + } + return manifest; + } + + private static boolean quarantine(Path file) { try { - String json = Files.readString(file.toPath(), StandardCharsets.UTF_8); - Manifest manifest = GSON.fromJson(json, Manifest.class); - if (manifest == null) { - return new Manifest(); - } - if (manifest.entries == null) { - manifest.entries = new ArrayList<>(); - } - return manifest; - } catch (Exception e) { - IrisLogging.reportError("Unreadable datapack manifest " + file.getPath() - + "; moving it to manifest.json.corrupt instead of overwriting it", e); - quarantine(file.toPath()); - return new Manifest(); + move(file, file.resolveSibling(file.getFileName().toString() + ".corrupt-" + System.currentTimeMillis())); + return true; + } catch (IOException e) { + IrisLogging.reportError("Failed to move aside corrupt datapack manifest " + file, e); + return false; } } - private static void quarantine(Path file) { - try { - move(file, file.resolveSibling(file.getFileName().toString() + ".corrupt")); - } catch (IOException e) { - IrisLogging.reportError("Failed to move aside corrupt datapack manifest " + file, e); + private static void normalizeManifest(Manifest manifest) { + if (manifest.entries == null) { + manifest.entries = new ArrayList<>(); + return; } + List normalized = new ArrayList<>(); + Set urls = new HashSet<>(); + Set ids = new HashSet<>(); + for (Entry entry : manifest.entries) { + if (entry == null || entry.url == null || entry.url.isBlank() || entry.id == null || entry.id.isBlank()) { + continue; + } + entry.url = entry.url.trim(); + if (!isValidManagedId(entry.id)) { + IrisLogging.warn("Ignoring datapack manifest entry with invalid id '" + entry.id + "'"); + continue; + } + entry.structureKeys = normalizeKeys(entry.structureKeys); + entry.templateKeys = normalizeKeys(entry.templateKeys); + entry.importedTargets = normalizeImportedTargets(entry.importedTargets); + entry.importedBundles = normalizeImportedBundles(entry.importedBundles); + if (!urls.add(entry.url) || !ids.add(entry.id)) { + IrisLogging.warn("Ignoring duplicate datapack manifest entry for id '" + entry.id + "' and url " + entry.url); + continue; + } + normalized.add(entry); + } + manifest.entries = normalized; + } + + private static void recoverManifestFromStaging(File root, Manifest manifest) { + File staging = new File(root, "staging"); + File[] directories = staging.listFiles(File::isDirectory); + if (directories == null) { + return; + } + for (File directory : directories) { + try { + Ownership ownership = readOwnershipOrNull(directory); + if (ownership == null || !directory.getName().equals(ownership.id) + || manifest.find(ownership.url) != null || manifest.findById(ownership.id) != null) { + continue; + } + validateManagedDirectory(directory, ownership.id); + if (!Objects.equals(ownership.contentHash, directoryHash(directory))) { + IrisLogging.warn("Ignoring corrupt Iris-managed datapack staging at " + directory.getPath()); + continue; + } + Entry recovered = ownership.toEntry(); + recovered.installedEpoch = directory.lastModified(); + manifest.put(recovered); + IrisLogging.warn("Recovered Iris-managed datapack manifest entry '" + recovered.id + "' from staging."); + } catch (IOException e) { + IrisLogging.warn("Ignoring orphan datapack staging at " + directory.getPath() + ": " + e.getMessage()); + } + } + } + + private static List normalizeKeys(List keys) { + TreeSet normalized = new TreeSet<>(); + if (keys != null) { + for (String key : keys) { + if (key != null && !key.isBlank()) { + normalized.add(key.trim().toLowerCase(Locale.ROOT)); + } + } + } + return new ArrayList<>(normalized); + } + + private static Map normalizeImportedTargets(Map targets) { + Map normalized = new HashMap<>(); + if (targets == null) { + return normalized; + } + for (Map.Entry target : targets.entrySet()) { + if (target.getKey() != null && !target.getKey().isBlank() + && target.getValue() != null && !target.getValue().isBlank()) { + normalized.put(target.getKey(), target.getValue()); + } + } + return normalized; + } + + private static Map> normalizeImportedBundles( + Map> targets + ) { + Map> normalized = new HashMap<>(); + if (targets == null) { + return normalized; + } + for (Map.Entry> target : targets.entrySet()) { + if (target.getKey() == null || target.getKey().isBlank() || target.getValue() == null) { + continue; + } + Map bundles = new TreeMap<>(); + for (Map.Entry bundle : target.getValue().entrySet()) { + if (bundle.getKey() != null && !bundle.getKey().isBlank() + && bundle.getValue() != null && !bundle.getValue().isBlank()) { + bundles.put(bundle.getKey(), bundle.getValue()); + } + } + normalized.put(target.getKey(), bundles); + } + return normalized; } private static void writeManifest(File root, Manifest manifest) { - Path file = new File(root, "manifest.json").toPath(); try { - Path parent = file.getParent(); - Files.createDirectories(parent); - Path temp = Files.createTempFile(parent, "manifest", ".json.tmp"); - try { - Files.writeString(temp, GSON.toJson(manifest), StandardCharsets.UTF_8); - move(temp, file); - } finally { - Files.deleteIfExists(temp); - } + writeManifestChecked(root, manifest); } catch (IOException e) { - IrisLogging.reportError("Failed to write datapack manifest " + file, e); + IrisLogging.reportError("Failed to write datapack manifest " + + new File(root, "manifest.json").toPath(), e); + } + } + + private static void writeManifestChecked(File root, Manifest manifest) throws IOException { + try (ManifestWrite write = prepareManifestWrite(root, manifest)) { + write.publish(); + } + } + + private static ManifestWrite prepareManifestWrite(File root, Manifest manifest) throws IOException { + Path file = new File(root, "manifest.json").toPath(); + Path parent = file.getParent(); + Files.createDirectories(parent); + Path temp = Files.createTempFile(parent, "manifest", ".json.tmp"); + Path rollback = null; + try { + byte[] content = GSON.toJson(manifest).getBytes(StandardCharsets.UTF_8); + if (content.length > MAX_MANIFEST_BYTES) { + throw new IOException("Datapack manifest exceeds " + MAX_MANIFEST_BYTES + " bytes"); + } + Files.write(temp, content, StandardOpenOption.TRUNCATE_EXISTING); + forceFile(temp); + if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) { + throw new IOException("Datapack manifest is not a regular file: " + file); + } + rollback = Files.createTempFile(parent, "manifest-rollback", ".json.tmp"); + Files.copy(file, rollback, StandardCopyOption.REPLACE_EXISTING); + forceFile(rollback); + } + return new ManifestWrite(temp, file, rollback); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(temp); + } catch (IOException cleanupFailure) { + e.addSuppressed(cleanupFailure); + } + if (rollback != null) { + try { + Files.deleteIfExists(rollback); + } catch (IOException cleanupFailure) { + e.addSuppressed(cleanupFailure); + } + } + throw e; } } @@ -689,13 +3023,1377 @@ public final class DatapackIngestService { } } + private static void moveNew(Path source, Path target) throws IOException { + if (pathExists(target, "move target")) { + throw new IOException("Refusing to replace concurrently-created path " + target); + } + Files.move(source, target); + } + + private static void ensureScratchDirectory(File directory, String purpose) throws IOException { + Path path = directory.toPath(); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath()); + } + return; + } + Files.createDirectories(path); + if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Could not create a safe " + purpose + " directory " + directory.getPath()); + } + } + + private static void verifyDirectoryContainerIfPresent(File directory, String purpose) throws IOException { + Path path = directory.toPath(); + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Refusing invalid " + purpose + " directory " + directory.getPath()); + } + } + + private static DatapackCoordinator createInstallCoordinator( + File root, + Entry entry, + List plans, + boolean manifestAlreadyMatched + ) throws IOException { + CoordinatorJournal journal = newCoordinatorJournal( + CoordinatorOperation.INSTALL, + entry, + manifestAlreadyMatched + ); + for (InstallPlan plan : plans) { + if (!plan.publishRequired()) { + continue; + } + CoordinatorDirectory directory = new CoordinatorDirectory(); + directory.target = normalizedPath(plan.target()); + directory.pending = normalizedPath(plan.pending()); + directory.backup = normalizedPath(plan.backup()); + directory.hadTarget = plan.hadTarget(); + directory.originalHash = plan.originalHash(); + directory.desiredHash = plan.desiredHash(); + directory.originalMarkerHash = plan.originalMarkerHash(); + directory.desiredMarkerHash = plan.desiredMarkerHash(); + directory.originalIdentity = plan.originalIdentity(); + directory.desiredIdentity = plan.desiredIdentity(); + directory.targetRoot = plan.targetRootIdentity(); + directory.scratchRoot = plan.scratchRootIdentity(); + directory.targetRootIdentity = plan.targetRootFileIdentity(); + directory.scratchRootIdentity = plan.scratchRootFileIdentity(); + journal.directories.add(directory); + } + return createCoordinator(root, journal); + } + + private static DatapackCoordinator createRemovalCoordinator( + File root, + Entry entry, + DirectoryRemoval directoryRemoval, + EditableImportRemoval editableRemoval + ) throws IOException { + CoordinatorJournal journal = newCoordinatorJournal(CoordinatorOperation.REMOVE, entry, true); + for (DirectoryMove move : directoryRemoval.moves()) { + CoordinatorDirectory directory = new CoordinatorDirectory(); + directory.target = normalizedPath(move.target()); + directory.pending = ""; + directory.backup = normalizedPath(move.backup()); + directory.hadTarget = true; + directory.originalHash = move.originalHash(); + directory.desiredHash = ""; + directory.originalMarkerHash = move.originalMarkerHash(); + directory.desiredMarkerHash = ""; + directory.originalIdentity = move.originalIdentity(); + directory.desiredIdentity = ""; + directory.targetRoot = move.targetRootIdentity(); + directory.scratchRoot = move.scratchRootIdentity(); + directory.targetRootIdentity = move.targetRootFileIdentity(); + directory.scratchRootIdentity = move.scratchRootFileIdentity(); + journal.directories.add(directory); + } + for (StructureTransactionWriter.PreparedRemovalToken token : editableRemoval.recoveryTokens()) { + CoordinatorEditable editable = new CoordinatorEditable(); + editable.packRoot = token.packRoot().toString(); + editable.transactionId = token.transactionId().toString(); + editable.claimId = UUID.randomUUID().toString(); + journal.editables.add(editable); + } + Path transactionRoot = coordinatorTransactionRoot(root, journal); + try { + editableRemoval.claimRecoveryOwners(transactionRoot, journal); + writeCoordinatorJournal(transactionRoot, journal); + return new DatapackCoordinator(transactionRoot, journal); + } catch (IOException | RuntimeException creationFailure) { + try { + deleteInstallScratch(transactionRoot.toFile(), "incomplete datapack transaction"); + } catch (IOException cleanupFailure) { + creationFailure.addSuppressed(cleanupFailure); + } + if (creationFailure instanceof IOException ioFailure) { + throw ioFailure; + } + throw creationFailure; + } + } + + private static CoordinatorJournal newCoordinatorJournal( + CoordinatorOperation operation, + Entry entry, + boolean manifestAlreadyMatched + ) { + CoordinatorJournal journal = new CoordinatorJournal(); + journal.schemaVersion = TRANSACTION_SCHEMA; + journal.transactionId = UUID.randomUUID().toString(); + journal.operation = operation; + journal.phase = CoordinatorPhase.PREPARED; + journal.id = entry.id; + journal.url = entry.url; + journal.versionId = entry.versionId; + journal.versionNumber = entry.versionNumber; + journal.sha1 = entry.sha1; + journal.manifestAlreadyMatched = manifestAlreadyMatched; + return journal; + } + + private static DatapackCoordinator createCoordinator(File root, CoordinatorJournal journal) throws IOException { + Path transactionRoot = coordinatorTransactionRoot(root, journal); + writeCoordinatorJournal(transactionRoot, journal); + return new DatapackCoordinator(transactionRoot, journal); + } + + private static Path coordinatorTransactionRoot(File root, CoordinatorJournal journal) throws IOException { + File transactionDirectory = new File(root, TRANSACTION_DIRECTORY); + ensureScratchDirectory(transactionDirectory, "datapack transaction"); + Path transactionRoot = new File(transactionDirectory, journal.transactionId).toPath().toAbsolutePath().normalize(); + if (Files.exists(transactionRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Datapack transaction id already exists: " + journal.transactionId); + } + return transactionRoot; + } + + private static String normalizedPath(File file) { + return file.toPath().toAbsolutePath().normalize().toString(); + } + + private static String realDirectoryPath(File directory, String purpose) throws IOException { + Path path = directory.toPath().toAbsolutePath().normalize(); + if (Files.isSymbolicLink(path) || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid " + purpose + " " + path); + } + return path.toRealPath().toString(); + } + + static void recoverTransactions(File root, List worldFolders) throws IOException { + recoverStagingScratch(new File(root, "staging")); + File transactionDirectory = new File(root, TRANSACTION_DIRECTORY); + Path transactionPath = transactionDirectory.toPath(); + if (!Files.exists(transactionPath, LinkOption.NOFOLLOW_LINKS)) { + recoverInstallScratch(root, worldFolders); + return; + } + verifyDirectoryContainerIfPresent(transactionDirectory, "datapack transaction"); + Manifest committedManifest = readCommittedManifest(root); + List transactionRoots; + try (Stream paths = Files.list(transactionPath)) { + transactionRoots = paths.limit(MAX_TRANSACTION_COUNT + 2L).sorted().toList(); + } + if (transactionRoots.size() == MAX_TRANSACTION_COUNT + 2) { + throw new IOException("Datapack transaction directory contains too many entries"); + } + int transactionCount = 0; + for (Path transactionRoot : transactionRoots) { + if (isHarmlessTransactionArtifact(transactionRoot)) { + continue; + } + transactionCount++; + if (transactionCount > MAX_TRANSACTION_COUNT) { + throw new IOException("Datapack transaction count exceeds " + MAX_TRANSACTION_COUNT); + } + } + for (Path transactionRoot : transactionRoots) { + if (isHarmlessTransactionArtifact(transactionRoot)) { + Files.deleteIfExists(transactionRoot); + continue; + } + recoverTransaction(root, worldFolders, committedManifest, transactionPath, transactionRoot); + } + transactionDirectory.delete(); + recoverInstallScratch(root, worldFolders); + } + + private static void recoverInstallScratch(File root, List worldFolders) throws IOException { + Set scratchRoots = new TreeSet<>(); + scratchRoots.add(installScratchRoot(new File(root, "staging")).toPath().toAbsolutePath().normalize()); + for (File worldFolder : worldFolders) { + scratchRoots.add(installScratchRoot(worldFolder).toPath().toAbsolutePath().normalize()); + } + for (Path scratchRoot : scratchRoots) { + recoverInstallScratchRoot(scratchRoot); + } + } + + private static void recoverInstallScratchRoot(Path scratchRoot) throws IOException { + if (!Files.exists(scratchRoot, LinkOption.NOFOLLOW_LINKS)) { + return; + } + verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch"); + List children; + try (Stream paths = Files.list(scratchRoot)) { + children = paths.limit(MAX_MANAGED_PATHS + 1L).sorted().toList(); + } + if (children.size() > MAX_MANAGED_PATHS) { + throw new IOException("Datapack install scratch contains too many entries"); + } + + List pending = new ArrayList<>(); + List backups = new ArrayList<>(); + for (Path child : children) { + StagingScratch scratch = parseInstallScratch(scratchRoot, child); + if (scratch == null) { + throw new IOException("Unexpected datapack install scratch artifact " + child); + } + if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid datapack install scratch artifact " + child); + } + validateScratchTree(child); + if (scratch.kind() == StagingScratchKind.BACKUP) { + backups.add(scratch); + } else { + pending.add(scratch); + } + } + if (!backups.isEmpty()) { + throw new IOException("Preserving unjournaled datapack install backup " + + backups.getFirst().path()); + } + for (StagingScratch scratch : pending) { + deleteInstallScratch(scratch.path().toFile(), "orphan datapack install pending directory"); + } + scratchRoot.toFile().delete(); + } + + private static StagingScratch parseInstallScratch(Path scratchRoot, Path child) throws IOException { + Path normalized = child.toAbsolutePath().normalize(); + if (!Objects.equals(normalized.getParent(), scratchRoot)) { + throw new IOException("Datapack install scratch artifact escapes its root: " + child); + } + String name = normalized.getFileName().toString(); + int uuidStart = name.length() - 36; + if (uuidStart <= 1 || name.charAt(uuidStart - 1) != '-') { + return null; + } + try { + UUID.fromString(name.substring(uuidStart)); + } catch (IllegalArgumentException e) { + return null; + } + String stem = name.substring(0, uuidStart - 1); + StagingScratchKind kind = stem.endsWith("-backup") + ? StagingScratchKind.BACKUP : StagingScratchKind.PENDING; + String id = kind == StagingScratchKind.BACKUP + ? stem.substring(0, stem.length() - "-backup".length()) : stem; + if (id.isBlank() || !id.equals(sanitizeId(id)) || RESERVED_IDS.contains(id)) { + return null; + } + return new StagingScratch(kind, id, normalized); + } + + private static void recoverStagingScratch(File stagingDirectory) throws IOException { + Path stagingRoot = stagingDirectory.toPath().toAbsolutePath().normalize(); + if (!Files.exists(stagingRoot, LinkOption.NOFOLLOW_LINKS)) { + return; + } + verifyDirectoryContainerIfPresent(stagingDirectory, "datapack staging"); + List children; + try (Stream paths = Files.list(stagingRoot)) { + children = paths.limit(MAX_MANAGED_PATHS + 1L).sorted().toList(); + } + if (children.size() > MAX_MANAGED_PATHS) { + throw new IOException("Datapack staging contains too many entries"); + } + + List pending = new ArrayList<>(); + Map> backups = new TreeMap<>(); + for (Path child : children) { + StagingScratch scratch = parseStagingScratch(stagingRoot, child); + if (scratch == null) { + continue; + } + if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid datapack staging scratch artifact " + child); + } + if (scratch.kind() == StagingScratchKind.PENDING) { + validateScratchTree(child); + pending.add(scratch); + } else { + backups.computeIfAbsent(scratch.id(), ignored -> new ArrayList<>()).add(scratch); + } + } + + for (Map.Entry> entry : backups.entrySet()) { + if (entry.getValue().size() != 1) { + throw new IOException("Ambiguous datapack staging backups for '" + entry.getKey() + "'"); + } + StagingScratch backup = entry.getValue().getFirst(); + Ownership backupOwnership = verifyManagedScratchDirectory(backup.path().toFile(), backup.id()); + Path target = stagingRoot.resolve(backup.id()).normalize(); + if (!Objects.equals(target.getParent(), stagingRoot)) { + throw new IOException("Datapack staging backup target escapes its root"); + } + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + Ownership targetOwnership = verifyManagedScratchDirectory(target.toFile(), backup.id()); + if (!Objects.equals(backupOwnership.url, targetOwnership.url)) { + throw new IOException("Datapack staging backup source does not match its target: " + target); + } + } + } + + for (List matches : backups.values()) { + StagingScratch backup = matches.getFirst(); + Path target = stagingRoot.resolve(backup.id()).normalize(); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + deleteVerifiedDirectory(backup.path().toFile()); + } else { + moveNew(backup.path(), target); + } + } + for (StagingScratch scratch : pending) { + deleteVerifiedDirectory(scratch.path().toFile()); + } + forceDirectoryIfSupported(stagingRoot); + } + + private static StagingScratch parseStagingScratch(Path stagingRoot, Path child) throws IOException { + Path normalized = child.toAbsolutePath().normalize(); + if (!Objects.equals(normalized.getParent(), stagingRoot)) { + throw new IOException("Datapack staging artifact escapes its root: " + child); + } + String name = normalized.getFileName().toString(); + StagingScratchKind kind; + String prefix; + if (name.startsWith(".pending-")) { + kind = StagingScratchKind.PENDING; + prefix = ".pending-"; + } else if (name.startsWith(".backup-")) { + kind = StagingScratchKind.BACKUP; + prefix = ".backup-"; + } else { + return null; + } + int uuidStart = name.length() - 36; + if (uuidStart <= prefix.length() || name.charAt(uuidStart - 1) != '-') { + return null; + } + String id = name.substring(prefix.length(), uuidStart - 1); + if (!id.equals(sanitizeId(id)) || RESERVED_IDS.contains(id)) { + return null; + } + try { + UUID.fromString(name.substring(uuidStart)); + } catch (IllegalArgumentException e) { + return null; + } + return new StagingScratch(kind, id, normalized); + } + + private static void validateScratchTree(Path root) throws IOException { + FileStore rootStore = Files.getFileStore(root); + try (Stream paths = Files.walk(root)) { + List entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList(); + if (entries.size() > MAX_MANAGED_PATHS) { + throw new IOException("Datapack scratch contains too many paths: " + root); + } + for (Path entry : entries) { + BasicFileAttributes attributes = Files.readAttributes( + entry, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + if (attributes.isSymbolicLink() + || (!attributes.isDirectory() && !attributes.isRegularFile())) { + throw new IOException("Datapack scratch contains an unsupported file: " + entry); + } + if (!Objects.equals(rootStore, Files.getFileStore(entry))) { + throw new IOException("Datapack scratch crosses a filesystem boundary: " + entry); + } + } + } + } + + private static Ownership verifyManagedScratchDirectory(File directory, String id) throws IOException { + validateManagedDirectory(directory, id); + Ownership ownership = readOwnership(directory); + if (!Objects.equals(ownership.contentHash, directoryHash(directory))) { + throw new IOException("Datapack staging backup is modified or corrupt: " + directory.getPath()); + } + return ownership; + } + + private static boolean isHarmlessTransactionArtifact(Path path) throws IOException { + if (!".DS_Store".equals(path.getFileName().toString())) { + return false; + } + if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Suspicious datapack transaction artifact " + path); + } + return true; + } + + private static void recoverTransaction( + File root, + List worldFolders, + Manifest committedManifest, + Path transactionDirectory, + Path transactionRoot + ) throws IOException { + Path normalizedRoot = transactionRoot.toAbsolutePath().normalize(); + if (!Objects.equals(normalizedRoot.getParent(), transactionDirectory.toAbsolutePath().normalize()) + || Files.isSymbolicLink(normalizedRoot) + || !Files.isDirectory(normalizedRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid datapack transaction directory " + transactionRoot); + } + try { + UUID.fromString(normalizedRoot.getFileName().toString()); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid datapack transaction directory name " + transactionRoot, e); + } + CoordinatorJournal journal = readCoordinatorJournal(normalizedRoot); + if (journal == null) { + deleteCoordinatorTransaction(normalizedRoot); + return; + } + validateCoordinatorJournal(root, worldFolders, committedManifest, normalizedRoot, journal); + boolean commit = coordinatorCommitDecision(committedManifest, journal); + resolveCoordinatorDirectories(journal, commit); + resolveCoordinatorEditables(journal, normalizedRoot, commit); + deleteCoordinatorTransaction(normalizedRoot); + } + + private static Manifest readCommittedManifest(File root) throws IOException { + Path manifestPath = new File(root, "manifest.json").toPath(); + if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + return new Manifest(); + } + if (Files.isSymbolicLink(manifestPath) + || !Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Datapack manifest is not a regular file: " + manifestPath); + } + Manifest manifest; + try { + manifest = GSON.fromJson( + readBoundedUtf8(manifestPath, MAX_MANIFEST_BYTES, "Datapack manifest"), + Manifest.class + ); + } catch (RuntimeException e) { + throw new IOException("Invalid datapack manifest " + manifestPath, e); + } + if (manifest == null) { + throw new IOException("Empty datapack manifest " + manifestPath); + } + normalizeManifest(manifest); + return manifest; + } + + private static CoordinatorJournal readCoordinatorJournal(Path transactionRoot) throws IOException { + Path committed = transactionRoot.resolve(TRANSACTION_JOURNAL); + Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT); + if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) { + return parseCoordinatorJournal(committed); + } + if (!Files.exists(next, LinkOption.NOFOLLOW_LINKS)) { + try (Stream entries = Files.list(transactionRoot)) { + if (entries.findAny().isEmpty()) { + return null; + } + } + throw new IOException("Missing datapack transaction journal " + committed); + } + try { + return parseCoordinatorJournal(next); + } catch (IOException firstWriteFailure) { + try (Stream entries = Files.list(transactionRoot)) { + List contents = entries.limit(2).toList(); + if (contents.size() == 1 && Objects.equals(contents.getFirst(), next) + && !Files.isSymbolicLink(next)) { + return null; + } + } + throw firstWriteFailure; + } + } + + private static CoordinatorJournal parseCoordinatorJournal(Path journalPath) throws IOException { + if (Files.isSymbolicLink(journalPath) + || !Files.isRegularFile(journalPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid datapack transaction journal " + journalPath); + } + try { + CoordinatorJournal journal = GSON.fromJson( + readBoundedUtf8( + journalPath, + MAX_TRANSACTION_JOURNAL_BYTES, + "Datapack transaction journal" + ), + CoordinatorJournal.class + ); + if (journal == null) { + throw new IOException("Empty datapack transaction journal " + journalPath); + } + return journal; + } catch (RuntimeException e) { + throw new IOException("Invalid datapack transaction journal " + journalPath, e); + } + } + + private static void validateCoordinatorJournal( + File root, + List worldFolders, + Manifest committedManifest, + Path transactionRoot, + CoordinatorJournal journal + ) throws IOException { + if (journal.schemaVersion != TRANSACTION_SCHEMA || journal.transactionId == null + || journal.operation == null || journal.phase == null || journal.id == null + || journal.url == null || journal.directories == null || journal.editables == null) { + throw new IOException("Incomplete datapack transaction journal at " + transactionRoot); + } + UUID transactionId; + try { + transactionId = UUID.fromString(journal.transactionId); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid datapack transaction id " + journal.transactionId, e); + } + if (!transactionId.toString().equals(transactionRoot.getFileName().toString())) { + throw new IOException("Datapack transaction id does not match its directory"); + } + if (!journal.id.equals(sanitizeId(journal.id)) || RESERVED_IDS.contains(journal.id)) { + throw new IOException("Invalid datapack transaction entry id " + journal.id); + } + if (journal.directories.size() > worldFolders.size() + 1 || journal.editables.size() > 10_000) { + throw new IOException("Datapack transaction journal contains too many participants"); + } + if (journal.operation == CoordinatorOperation.INSTALL && !journal.editables.isEmpty()) { + throw new IOException("Datapack install transaction unexpectedly contains editable participants"); + } + + Set allowedTargets = new HashSet<>(); + allowedTargets.add(new File(new File(root, "staging"), journal.id).toPath().toAbsolutePath().normalize()); + for (File worldFolder : worldFolders) { + allowedTargets.add(new File(worldFolder, journal.id).toPath().toAbsolutePath().normalize()); + } + Set seenTargets = new HashSet<>(); + Set seenTargetIdentities = new HashSet<>(); + for (CoordinatorDirectory directory : journal.directories) { + validateCoordinatorDirectory( + directory, journal.operation, allowedTargets, seenTargets, seenTargetIdentities); + } + Set seenEditables = new HashSet<>(); + Set allowedEditableRoots = journal.editables.isEmpty() + ? Set.of() : authoritativeEditableRoots(committedManifest, journal); + for (CoordinatorEditable editable : journal.editables) { + if (editable == null || editable.packRoot == null || editable.transactionId == null + || editable.claimId == null + || !seenEditables.add(editable.packRoot)) { + throw new IOException("Invalid duplicate editable participant in datapack transaction"); + } + Path packRoot = coordinatorPath(editable.packRoot, "editable pack root"); + Path realPackRoot = Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS) + ? packRoot.toRealPath() : packRoot; + if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS) + || !packRoot.equals(realPackRoot) || !allowedEditableRoots.contains(realPackRoot)) { + throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot); + } + try { + UUID.fromString(editable.transactionId); + UUID.fromString(editable.claimId); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid editable transaction recovery identity", e); + } + StructureTransactionWriter writer = new StructureTransactionWriter(packRoot); + boolean recoveryDataPresent = writer.verifyRecoveryOwner( + new StructureTransactionWriter.PreparedRemovalToken( + packRoot, + UUID.fromString(editable.transactionId) + ), + new StructureTransactionWriter.RecoveryOwner( + transactionRoot, + UUID.fromString(journal.transactionId), + UUID.fromString(editable.claimId) + ), + committedManifest.findById(journal.id) == null + && journal.phase == CoordinatorPhase.PUBLISHED + ); + if (!recoveryDataPresent && journal.phase != CoordinatorPhase.COMMITTED) { + throw new IOException("Editable structure recovery data disappeared before commit"); + } + } + } + + private static Set authoritativeEditableRoots( + Manifest committedManifest, + CoordinatorJournal journal + ) throws IOException { + Set roots = new HashSet<>(); + Entry committed = committedManifest.findById(journal.id); + if (committed != null) { + if (!Objects.equals(committed.url, journal.url)) { + throw new IOException("Datapack transaction conflicts with the committed editable pack owner"); + } + addExistingPackRoots(roots, committed.importedTargets.keySet()); + addExistingPackRoots(roots, committed.importedBundles.keySet()); + return roots; + } + if (journal.operation != CoordinatorOperation.REMOVE || !journal.phase.published()) { + throw new IOException("Datapack transaction has no committed editable pack authority"); + } + for (CoordinatorEditable editable : journal.editables) { + Path packRoot = coordinatorPath(editable.packRoot, "editable pack root"); + if (Files.isSymbolicLink(packRoot) || !Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid editable pack root in datapack transaction: " + packRoot); + } + Path realPackRoot = packRoot.toRealPath(); + if (!packRoot.equals(realPackRoot)) { + throw new IOException("Editable pack root changed before datapack recovery: " + packRoot); + } + roots.add(realPackRoot); + } + return roots; + } + + private static void addExistingPackRoots(Set roots, Set paths) throws IOException { + for (String path : paths) { + try { + Path root = Path.of(path).toAbsolutePath().normalize(); + if (Files.isDirectory(root)) { + roots.add(root.toRealPath()); + } + } catch (RuntimeException e) { + throw new IOException("Invalid editable pack root in the datapack manifest: " + path, e); + } + } + } + + private static void validateCoordinatorDirectory( + CoordinatorDirectory directory, + CoordinatorOperation operation, + Set allowedTargets, + Set seenTargets, + Set seenTargetIdentities + ) throws IOException { + if (directory == null || directory.target == null || directory.backup == null + || directory.originalHash == null || directory.desiredHash == null + || directory.originalMarkerHash == null || directory.desiredMarkerHash == null + || directory.originalIdentity == null || directory.desiredIdentity == null + || directory.targetRoot == null || directory.scratchRoot == null + || directory.targetRootIdentity == null || directory.scratchRootIdentity == null) { + throw new IOException("Incomplete directory participant in datapack transaction"); + } + Path target = coordinatorPath(directory.target, "target"); + if (!allowedTargets.contains(target) || !seenTargets.add(target)) { + throw new IOException("Datapack transaction target is outside its configured roots: " + target); + } + File targetFile = target.toFile(); + File targetParent = targetFile.getParentFile(); + Path targetRootIdentity = coordinatorPath(directory.targetRoot, "target root"); + if (!seenTargetIdentities.add(targetRootIdentity.resolve(target.getFileName()).normalize())) { + throw new IOException("Aliased datapack transaction target " + target); + } + validateRecoveryContainer( + targetParent.toPath(), + targetRootIdentity, + true, + "datapack target root" + ); + verifyDirectoryContainerIdentity( + targetParent, directory.targetRoot, directory.targetRootIdentity, "datapack target root"); + File scratchRoot = new File( + targetParent.getParentFile() == null ? targetParent : targetParent.getParentFile(), + operation == CoordinatorOperation.INSTALL ? ".iris-datapack-install" : ".iris-datapack-remove" + ); + Path expectedScratch = scratchRoot.toPath().toAbsolutePath().normalize(); + Path backup = coordinatorPath(directory.backup, "backup"); + if (!Objects.equals(backup.getParent(), expectedScratch) || Files.isSymbolicLink(backup)) { + throw new IOException("Invalid datapack transaction backup path " + backup); + } + Path pending = null; + if (operation == CoordinatorOperation.INSTALL) { + if (directory.pending == null || directory.pending.isBlank()) { + throw new IOException("Install transaction is missing its pending directory"); + } + pending = coordinatorPath(directory.pending, "pending directory"); + if (!Objects.equals(pending.getParent(), expectedScratch) || Files.isSymbolicLink(pending)) { + throw new IOException("Invalid datapack transaction pending path " + pending); + } + } else if (directory.pending != null && !directory.pending.isBlank()) { + throw new IOException("Removal transaction unexpectedly contains a pending directory"); + } + boolean scratchRequired = Files.exists(backup, LinkOption.NOFOLLOW_LINKS) + || pending != null && Files.exists(pending, LinkOption.NOFOLLOW_LINKS); + validateRecoveryContainer( + expectedScratch, + coordinatorPath(directory.scratchRoot, "scratch root"), + scratchRequired, + "datapack transaction scratch root" + ); + if (scratchRequired) { + verifyDirectoryContainerIdentity( + expectedScratch.toFile(), directory.scratchRoot, + directory.scratchRootIdentity, "datapack transaction scratch root"); + } + } + + private static void validateRecoveryContainer( + Path container, + Path expectedRealPath, + boolean required, + String purpose + ) throws IOException { + Path normalized = container.toAbsolutePath().normalize(); + Path normalizedExpected = expectedRealPath.toAbsolutePath().normalize(); + if (!Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)) { + if (required) { + throw new IOException("Missing " + purpose + " " + normalized); + } + return; + } + if (Files.isSymbolicLink(normalized) + || !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS) + || !Objects.equals(normalized.toRealPath(), normalizedExpected)) { + throw new IOException("Changed or unsafe " + purpose + " " + normalized); + } + } + + private static boolean coordinatorCommitDecision(Manifest manifest, CoordinatorJournal journal) throws IOException { + Entry committed = manifest.findById(journal.id); + if (journal.operation == CoordinatorOperation.REMOVE) { + if (committed == null) { + if (!journal.phase.published()) { + throw new IOException("Datapack removal manifest changed before every participant was published"); + } + return true; + } + if (!Objects.equals(committed.url, journal.url) || journal.phase == CoordinatorPhase.COMMITTED) { + throw new IOException("Datapack removal journal conflicts with the committed manifest"); + } + return false; + } + + boolean matches = committed != null + && Objects.equals(committed.url, journal.url) + && Objects.equals(committed.versionId, journal.versionId) + && Objects.equals(committed.versionNumber, journal.versionNumber) + && Objects.equals(committed.sha1, journal.sha1); + if (journal.manifestAlreadyMatched) { + if (!matches) { + throw new IOException("Committed datapack changed while an install transaction was incomplete"); + } + return journal.phase.published(); + } + if (matches) { + if (!journal.phase.published()) { + throw new IOException("Datapack install manifest changed before every target was published"); + } + return true; + } + if (journal.phase == CoordinatorPhase.COMMITTED) { + throw new IOException("Committed datapack install journal conflicts with the manifest"); + } + return false; + } + + private static void resolveCoordinatorDirectories(CoordinatorJournal journal, boolean commit) throws IOException { + List directories = new ArrayList<>(journal.directories); + if (!commit) { + Collections.reverse(directories); + } + IOException failure = null; + for (CoordinatorDirectory directory : directories) { + try { + if (journal.operation == CoordinatorOperation.INSTALL) { + resolveInstallDirectory(journal, directory, commit); + } else { + resolveRemovalDirectory(directory, commit); + } + } catch (IOException | RuntimeException e) { + IOException participantFailure = e instanceof IOException ioFailure + ? ioFailure : new IOException("Failed resolving datapack directory participant", e); + if (failure == null) { + failure = participantFailure; + } else { + failure.addSuppressed(participantFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + + private static void resolveInstallDirectory( + CoordinatorJournal journal, + CoordinatorDirectory directory, + boolean commit + ) throws IOException { + File target = new File(directory.target); + File pending = new File(directory.pending); + File backup = new File(directory.backup); + File targetRoot = new File(directory.targetRoot); + if (commit) { + verifyDesiredDirectory( + target, targetRoot, journal, directory.desiredHash, + directory.desiredMarkerHash, directory.desiredIdentity); + deleteOriginalBackupIfPresent( + backup, targetRoot, directory.originalHash, + directory.originalMarkerHash, directory.originalIdentity); + deleteDesiredDirectoryIfPresent( + pending, targetRoot, journal, directory.desiredHash, + directory.desiredMarkerHash, directory.desiredIdentity); + cleanupScratchParent(backup); + return; + } + + if (directory.hadTarget) { + if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) { + verifyDirectorySnapshot( + backup, targetRoot, directory.originalHash, + directory.originalMarkerHash, directory.originalIdentity, + "datapack install backup"); + deleteDesiredDirectoryIfPresent( + target, targetRoot, journal, directory.desiredHash, + directory.desiredMarkerHash, directory.desiredIdentity); + moveNew(backup.toPath(), target.toPath()); + forceDirectoryIfSupported(target.getParentFile().toPath()); + forceDirectoryIfSupported(backup.getParentFile().toPath()); + } else { + verifyDirectorySnapshot( + target, targetRoot, directory.originalHash, + directory.originalMarkerHash, directory.originalIdentity, + "original datapack target"); + } + } else { + if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Unexpected backup for newly-installed datapack " + backup.getPath()); + } + deleteDesiredDirectoryIfPresent( + target, targetRoot, journal, directory.desiredHash, + directory.desiredMarkerHash, directory.desiredIdentity); + } + deleteDesiredDirectoryIfPresent( + pending, targetRoot, journal, directory.desiredHash, + directory.desiredMarkerHash, directory.desiredIdentity); + cleanupScratchParent(backup); + } + + private static void resolveRemovalDirectory(CoordinatorDirectory directory, boolean commit) throws IOException { + File target = new File(directory.target); + File backup = new File(directory.backup); + File targetRoot = new File(directory.targetRoot); + if (commit) { + if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Removed datapack target reappeared before transaction cleanup: " + target.getPath()); + } + deleteOriginalBackupIfPresent( + backup, targetRoot, directory.originalHash, + directory.originalMarkerHash, directory.originalIdentity); + cleanupScratchParent(backup); + return; + } + if (Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) { + verifyDirectorySnapshot( + backup, targetRoot, directory.originalHash, + directory.originalMarkerHash, directory.originalIdentity, + "datapack removal backup"); + if (Files.exists(target.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Datapack removal target was concurrently recreated: " + target.getPath()); + } + moveNew(backup.toPath(), target.toPath()); + forceDirectoryIfSupported(target.getParentFile().toPath()); + forceDirectoryIfSupported(backup.getParentFile().toPath()); + } else { + verifyDirectorySnapshot( + target, targetRoot, directory.originalHash, + directory.originalMarkerHash, directory.originalIdentity, + "original datapack target"); + } + cleanupScratchParent(backup); + } + + private static void resolveCoordinatorEditables( + CoordinatorJournal journal, + Path transactionRoot, + boolean commit + ) throws IOException { + IOException failure = null; + for (CoordinatorEditable editable : journal.editables) { + try { + Path packRoot = coordinatorPath(editable.packRoot, "editable pack root"); + StructureTransactionWriter writer = new StructureTransactionWriter(packRoot); + writer.resolvePreparedRemoval( + new StructureTransactionWriter.PreparedRemovalToken( + packRoot, + UUID.fromString(editable.transactionId) + ), + new StructureTransactionWriter.RecoveryOwner( + transactionRoot, + UUID.fromString(journal.transactionId), + UUID.fromString(editable.claimId) + ), + commit + ); + } catch (IOException | RuntimeException e) { + IOException participantFailure = e instanceof IOException ioFailure + ? ioFailure : new IOException("Failed resolving editable structure participant", e); + if (failure == null) { + failure = participantFailure; + } else { + failure.addSuppressed(participantFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + + private static void verifyDesiredDirectory( + File directory, + File targetRoot, + CoordinatorJournal journal, + String expectedHash, + String expectedMarkerHash, + String expectedIdentity + ) throws IOException { + verifyDirectorySnapshot( + directory, targetRoot, expectedHash, expectedMarkerHash, + expectedIdentity, "installed datapack target"); + Ownership ownership = readOwnership(directory); + if (!Objects.equals(ownership.id, journal.id) || !Objects.equals(ownership.url, journal.url) + || !Objects.equals(ownership.contentHash, expectedHash)) { + throw new IOException("Installed datapack ownership does not match its transaction at " + directory.getPath()); + } + } + + private static void deleteDesiredDirectoryIfPresent( + File directory, + File targetRoot, + CoordinatorJournal journal, + String expectedHash, + String expectedMarkerHash, + String expectedIdentity + ) throws IOException { + if (!Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) { + return; + } + verifyDesiredDirectory( + directory, targetRoot, journal, expectedHash, + expectedMarkerHash, expectedIdentity); + deleteVerifiedDirectory(directory); + } + + private static void deleteOriginalBackupIfPresent( + File backup, + File targetRoot, + String expectedHash, + String expectedMarkerHash, + String expectedIdentity + ) throws IOException { + if (!Files.exists(backup.toPath(), LinkOption.NOFOLLOW_LINKS)) { + return; + } + verifyDirectorySnapshot( + backup, targetRoot, expectedHash, expectedMarkerHash, + expectedIdentity, "datapack transaction backup"); + deleteVerifiedDirectory(backup); + } + + private static void deleteVerifiedDirectory(File directory) throws IOException { + File parent = Objects.requireNonNull(directory.getParentFile(), "datapack transaction parent"); + validateInstallTree(directory, parent, "Datapack transaction directory"); + IO.delete(directory); + if (Files.exists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Could not delete datapack transaction directory " + directory.getPath()); + } + } + + private static void cleanupScratchParent(File participant) { + File parent = participant.getParentFile(); + if (parent != null) { + parent.delete(); + } + } + + private static void deleteCoordinatorTransaction(Path transactionRoot) throws IOException { + deleteInstallScratch(transactionRoot.toFile(), "completed datapack transaction"); + forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent")); + } + + private static void writeCoordinatorJournal(Path transactionRoot, CoordinatorJournal journal) throws IOException { + byte[] content = GSON.toJson(journal).getBytes(StandardCharsets.UTF_8); + if (content.length > MAX_TRANSACTION_JOURNAL_BYTES) { + throw new IOException("Datapack transaction journal exceeds " + MAX_TRANSACTION_JOURNAL_BYTES + " bytes"); + } + Files.createDirectories(transactionRoot); + Path next = transactionRoot.resolve(TRANSACTION_JOURNAL_NEXT); + if (Files.isSymbolicLink(next)) { + throw new IOException("Datapack transaction journal cannot be a symbolic link: " + next); + } + Files.deleteIfExists(next); + Files.write(next, content, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + forceFile(next); + move(next, transactionRoot.resolve(TRANSACTION_JOURNAL)); + forceDirectoryIfSupported(transactionRoot); + forceDirectoryIfSupported(Objects.requireNonNull(transactionRoot.getParent(), "transaction parent")); + } + + private static String readBoundedUtf8(Path path, long maxBytes, String purpose) throws IOException { + return new String(readBoundedBytes(path, maxBytes, purpose), StandardCharsets.UTF_8); + } + + private static byte[] readBoundedBytes(Path path, long maxBytes, String purpose) throws IOException { + int readLimit = Math.toIntExact(maxBytes + 1); + byte[] content; + try (InputStream input = Files.newInputStream( + path, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS + )) { + content = input.readNBytes(readLimit); + } + if (content.length > maxBytes) { + throw new IOException(purpose + " exceeds " + maxBytes + " bytes"); + } + return content; + } + + private static Path coordinatorPath(String value, String purpose) throws IOException { + try { + return Path.of(value).toAbsolutePath().normalize(); + } catch (RuntimeException e) { + throw new IOException("Invalid datapack transaction " + purpose + " path", e); + } + } + + private static void forceFile(Path file) throws IOException { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { + channel.force(true); + } + } + + private static void forceDirectoryIfSupported(Path directory) throws IOException { + if (!Files.getFileStore(directory).supportsFileAttributeView("posix")) { + return; + } + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private enum CoordinatorOperation { + INSTALL, + REMOVE + } + + private enum StagingScratchKind { + PENDING, + BACKUP + } + + private enum CoordinatorPhase { + PREPARED, + PUBLISHING, + PUBLISHED, + COMMITTED; + + private boolean published() { + return this == PUBLISHED || this == COMMITTED; + } + } + + private static final class CoordinatorJournal { + private int schemaVersion; + private String transactionId; + private CoordinatorOperation operation; + private CoordinatorPhase phase; + private String id; + private String url; + private String versionId; + private String versionNumber; + private String sha1; + private boolean manifestAlreadyMatched; + private List directories = new ArrayList<>(); + private List editables = new ArrayList<>(); + } + + private static final class CoordinatorDirectory { + private String target; + private String pending; + private String backup; + private boolean hadTarget; + private String originalHash; + private String desiredHash; + private String originalMarkerHash; + private String desiredMarkerHash; + private String originalIdentity; + private String desiredIdentity; + private String targetRoot; + private String scratchRoot; + private String targetRootIdentity; + private String scratchRootIdentity; + } + + private static final class CoordinatorEditable { + private String packRoot; + private String transactionId; + private String claimId; + } + + private static final class DatapackCoordinator { + private final Path transactionRoot; + private final CoordinatorJournal journal; + + private DatapackCoordinator(Path transactionRoot, CoordinatorJournal journal) { + this.transactionRoot = transactionRoot; + this.journal = journal; + } + + private void phase(CoordinatorPhase phase) throws IOException { + journal.phase = phase; + writeCoordinatorJournal(transactionRoot, journal); + } + + private void finish() throws IOException { + deleteCoordinatorTransaction(transactionRoot); + } + } + + static final class VerifiedStagingInstall { + private final Path normalizedRoot; + private final Path realRoot; + private final String rootIdentity; + private final Path normalizedStagingRoot; + private final Path realStagingRoot; + private final String stagingRootIdentity; + private final Path normalizedSource; + private final Path realSource; + private final String sourceIdentity; + private final String id; + private final String url; + private final String versionId; + private final String versionNumber; + private final String sha1; + private final String desiredHash; + private final Entry committedEntrySnapshot; + private final boolean legacyReplacementAuthorized; + private final LegacyStagingSnapshot legacyStagingSnapshot; + private boolean consumed; + + private VerifiedStagingInstall( + Path normalizedRoot, + Path normalizedStagingRoot, + Path normalizedSource, + Entry entry, + String desiredHash, + Entry committedEntrySnapshot, + boolean legacyReplacementAuthorized, + LegacyStagingSnapshot legacyStagingSnapshot + ) throws IOException { + this.normalizedRoot = normalizedRoot; + this.realRoot = normalizedRoot.toRealPath(); + this.rootIdentity = directoryIdentity(normalizedRoot.toFile()); + this.normalizedStagingRoot = normalizedStagingRoot; + this.realStagingRoot = normalizedStagingRoot.toRealPath(); + this.stagingRootIdentity = directoryIdentity(normalizedStagingRoot.toFile()); + this.normalizedSource = normalizedSource; + this.realSource = normalizedSource.toRealPath(); + this.sourceIdentity = directoryIdentity(normalizedSource.toFile()); + this.id = entry.id; + this.url = entry.url; + this.versionId = entry.versionId; + this.versionNumber = entry.versionNumber; + this.sha1 = entry.sha1; + this.desiredHash = desiredHash; + this.committedEntrySnapshot = committedEntrySnapshot == null ? null : copyEntry(committedEntrySnapshot); + this.legacyReplacementAuthorized = legacyReplacementAuthorized; + this.legacyStagingSnapshot = legacyStagingSnapshot; + } + + private Path stagingRoot() { + return normalizedStagingRoot; + } + + private boolean hasStablePathIdentities() { + return !rootIdentity.isEmpty() + && !stagingRootIdentity.isEmpty() + && !sourceIdentity.isEmpty(); + } + + private boolean isCanonicalInstall(File installRoot, File target) { + Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize(); + Path suppliedTarget = target.toPath().toAbsolutePath().normalize(); + return suppliedRoot.equals(normalizedStagingRoot) + && suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize()); + } + + private void verifyStagingRoot() throws IOException { + verifyPathIdentity(normalizedRoot, realRoot, rootIdentity, "datapack storage root"); + verifyPathIdentity( + normalizedStagingRoot, realStagingRoot, stagingRootIdentity, "datapack staging root"); + if (!Objects.equals(normalizedStagingRoot.getParent(), normalizedRoot) + || !Files.isSameFile(normalizedStagingRoot.getParent(), normalizedRoot)) { + throw new IOException("Verified datapack staging root changed identity"); + } + } + + private boolean consume( + File source, + File installRoot, + File target, + Entry entry, + String stagedHash + ) throws IOException { + if (consumed) { + throw new IOException("Verified datapack staging authorization was already consumed"); + } + consumed = true; + Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize(); + Path suppliedTarget = target.toPath().toAbsolutePath().normalize(); + if (!suppliedRoot.equals(normalizedStagingRoot) + || !suppliedTarget.equals(normalizedStagingRoot.resolve(id).normalize())) { + throw new IOException("Verified datapack staging authorization was paired with a different path"); + } + verifyAuthority(source, entry, stagedHash); + return legacyReplacementAuthorized; + } + + private boolean authorizeLegacyWorldReplacement( + File source, + File installRoot, + File target, + Entry entry, + String stagedHash, + String currentHash, + String currentMarkerHash + ) throws IOException { + if (legacyStagingSnapshot == null + || !"absent".equals(currentMarkerHash) + || !Objects.equals(currentHash, legacyStagingSnapshot.contentHash()) + || legacyStagingSnapshot.targetIdentity().isEmpty()) { + return false; + } + Path suppliedRoot = installRoot.toPath().toAbsolutePath().normalize(); + Path suppliedTarget = target.toPath().toAbsolutePath().normalize(); + if (!Objects.equals(suppliedTarget.getParent(), suppliedRoot) + || !Objects.equals(suppliedTarget.getFileName().toString(), id) + || Files.isSameFile(suppliedRoot, normalizedStagingRoot) + || Files.isSameFile(suppliedTarget, legacyStagingSnapshot.normalizedTarget())) { + return false; + } + verifyAuthority(source, entry, stagedHash); + verifyLegacyWorldSnapshot(); + return true; + } + + private void verifyAuthority(File source, Entry entry, String stagedHash) throws IOException { + verifyAuthorityState(); + Path suppliedSource = source.toPath().toAbsolutePath().normalize(); + if (!suppliedSource.equals(normalizedSource)) { + throw new IOException("Verified datapack staging authorization was paired with a different path"); + } + if (!Objects.equals(entry.id, id) + || !Objects.equals(entry.url, url) + || !Objects.equals(entry.versionId, versionId) + || !Objects.equals(entry.versionNumber, versionNumber) + || !Objects.equals(entry.sha1, sha1) + || !Objects.equals(stagedHash, desiredHash)) { + throw new IOException("Verified datapack staging authorization was paired with different metadata"); + } + } + + private void verifyAuthorityState() throws IOException { + verifyStagingRoot(); + verifyPathIdentity(normalizedSource, realSource, sourceIdentity, "verified datapack extraction"); + Manifest committedManifest = readCommittedManifest(normalizedRoot.toFile()); + Entry committed = committedManifest.findById(id); + if (!manifestEntrySnapshotMatches(committed, committedEntrySnapshot)) { + throw new IOException("Committed datapack staging authority changed before installation"); + } + if (committedEntrySnapshot != null && !legacyReplacementAuthorized) { + throw new IOException("Committed datapack staging authority conflicts with " + id); + } + } + + private static boolean manifestEntrySnapshotMatches(Entry current, Entry expected) { + if (current == null || expected == null) { + return current == expected; + } + return Objects.equals(current.id, expected.id) + && Objects.equals(current.url, expected.url) + && Objects.equals(current.versionId, expected.versionId) + && Objects.equals(current.versionNumber, expected.versionNumber) + && Objects.equals(current.sha1, expected.sha1) + && Objects.equals(current.filename, expected.filename) + && Objects.equals(current.etag, expected.etag) + && Objects.equals(current.lastModified, expected.lastModified) + && current.installedEpoch == expected.installedEpoch + && current.structuresImported == expected.structuresImported + && copyList(current.structureKeys).equals(copyList(expected.structureKeys)) + && copyList(current.templateKeys).equals(copyList(expected.templateKeys)) + && Objects.equals(current.importedTargets, expected.importedTargets) + && Objects.equals(current.importedBundles, expected.importedBundles); + } + + private void verifyLegacyWorldSnapshot() throws IOException { + verifyAuthorityState(); + if (legacyStagingSnapshot == null || legacyStagingSnapshot.targetIdentity().isEmpty()) { + throw new IOException("Missing stable canonical legacy datapack staging snapshot for " + id); + } + if (!Objects.equals(legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot) + || !Files.isSameFile( + legacyStagingSnapshot.normalizedTarget().getParent(), normalizedStagingRoot) + || !Objects.equals( + Files.getFileStore(legacyStagingSnapshot.normalizedTarget()), + Files.getFileStore(normalizedStagingRoot))) { + throw new IOException("Changed or unsafe canonical legacy datapack staging target for " + id); + } + verifyDirectorySnapshot( + legacyStagingSnapshot.normalizedTarget().toFile(), + normalizedStagingRoot.toFile(), + legacyStagingSnapshot.contentHash(), + legacyStagingSnapshot.markerHash(), + legacyStagingSnapshot.targetIdentity(), + "canonical legacy datapack staging target" + ); + if (!Objects.equals( + legacyStagingSnapshot.normalizedTarget().toRealPath(), + legacyStagingSnapshot.realTarget())) { + throw new IOException("Changed canonical legacy datapack staging target identity for " + id); + } + } + + private static void verifyPathIdentity( + Path normalized, + Path expectedReal, + String expectedIdentity, + String purpose + ) throws IOException { + if (Files.isSymbolicLink(normalized) + || !Files.isDirectory(normalized, LinkOption.NOFOLLOW_LINKS) + || !Objects.equals(normalized.toRealPath(), expectedReal) + || (!expectedIdentity.isEmpty() + && !Objects.equals(directoryIdentity(normalized.toFile()), expectedIdentity))) { + throw new IOException("Changed or unsafe " + purpose + " " + normalized); + } + } + } + public static final class Report { private final KList updated = new KList<>(); private final KList upToDate = new KList<>(); private final KList failed = new KList<>(); + private boolean requiresRestart; public boolean changed() { - return !updated.isEmpty(); + return requiresRestart; } public KList getUpdated() { @@ -718,8 +4416,14 @@ public final class DatapackIngestService { public String versionNumber; public String sha1; public String filename; + public String etag; + public String lastModified; public long installedEpoch; public boolean structuresImported; + public List structureKeys = new ArrayList<>(); + public List templateKeys = new ArrayList<>(); + public Map importedTargets = new HashMap<>(); + public Map> importedBundles = new HashMap<>(); } private static final class Manifest { @@ -734,6 +4438,15 @@ public final class DatapackIngestService { return null; } + private Entry findById(String id) { + for (Entry entry : entries) { + if (entry.id != null && entry.id.equals(id)) { + return entry; + } + } + return null; + } + private void put(Entry entry) { for (int i = 0; i < entries.size(); i++) { Entry current = entries.get(i); @@ -749,4 +4462,489 @@ public final class DatapackIngestService { return entries.removeIf(entry -> id.equals(entry.id)); } } + + static record DownloadResult(boolean notModified, String etag, String lastModified) { + } + + static record InstallResult(boolean changed) { + } + + record InstallExecution(InstallResult result, DatapackCoordinator coordinator) { + } + + private record PackResources(List structureKeys, List templateKeys) { + } + + private static final class EditableImportRemoval { + private final List prepared; + private boolean closed; + + private EditableImportRemoval(List prepared) { + this.prepared = List.copyOf(prepared); + } + + private List recoveryTokens() { + List tokens = new ArrayList<>(); + for (PreparedEditableImport editableImport : prepared) { + editableImport.removal().recoveryToken().ifPresent(tokens::add); + } + return List.copyOf(tokens); + } + + private void claimRecoveryOwners(Path transactionRoot, CoordinatorJournal journal) throws IOException { + int ownerIndex = 0; + for (PreparedEditableImport editableImport : prepared) { + Optional token = + editableImport.removal().recoveryToken(); + if (token.isEmpty()) { + continue; + } + if (ownerIndex >= journal.editables.size()) { + throw new IOException("Missing datapack coordinator claim for editable structure removal"); + } + CoordinatorEditable editable = journal.editables.get(ownerIndex++); + StructureTransactionWriter.PreparedRemovalToken recoveryToken = token.get(); + if (!Objects.equals(recoveryToken.packRoot().toString(), editable.packRoot) + || !Objects.equals(recoveryToken.transactionId().toString(), editable.transactionId)) { + throw new IOException("Datapack coordinator editable claim order changed during preparation"); + } + editableImport.removal().claimRecoveryOwner( + new StructureTransactionWriter.RecoveryOwner( + transactionRoot, + UUID.fromString(journal.transactionId), + UUID.fromString(editable.claimId) + ) + ); + } + if (ownerIndex != journal.editables.size()) { + throw new IOException("Unexpected datapack coordinator editable recovery claim"); + } + } + + private void markCommitted() throws IOException { + if (closed) { + throw new IllegalStateException("Editable import removal transaction is closed"); + } + for (PreparedEditableImport editableImport : prepared) { + editableImport.removal().markCommitted(); + } + } + + private void finishCommit() throws IOException { + if (closed) { + return; + } + IOException failure = null; + for (PreparedEditableImport editableImport : prepared) { + try { + editableImport.removal().finishCommit(); + } catch (IOException | RuntimeException cleanupFailure) { + IOException transactionFailure = cleanupFailure instanceof IOException ioFailure + ? ioFailure + : new IOException("Failed finalizing editable import removal", cleanupFailure); + if (failure == null) { + failure = transactionFailure; + } else { + failure.addSuppressed(transactionFailure); + } + } + if (editableImport.removal().changed()) { + try { + IrisData.getLoaded(editableImport.dataFolder()) + .ifPresent(IrisData::invalidateStructureResources); + } catch (RuntimeException invalidationFailure) { + IOException transactionFailure = new IOException( + "Failed invalidating editable import structure resources", + invalidationFailure + ); + if (failure == null) { + failure = transactionFailure; + } else { + failure.addSuppressed(transactionFailure); + } + } + } + } + closed = true; + if (failure != null) { + throw failure; + } + } + + private void rollback() throws IOException { + if (closed) { + return; + } + IOException failure = null; + for (int i = prepared.size() - 1; i >= 0; i--) { + try { + prepared.get(i).removal().rollback(); + } catch (IOException rollbackFailure) { + if (failure == null) { + failure = rollbackFailure; + } else { + failure.addSuppressed(rollbackFailure); + } + } + } + closed = true; + if (failure != null) { + throw failure; + } + } + + private void leaveForRecovery() throws IOException { + if (closed) { + return; + } + IOException failure = null; + for (PreparedEditableImport editableImport : prepared) { + try { + editableImport.removal().leaveForRecovery(); + } catch (IOException releaseFailure) { + if (failure == null) { + failure = releaseFailure; + } else { + failure.addSuppressed(releaseFailure); + } + } + } + closed = true; + if (failure != null) { + throw failure; + } + } + } + + private static final class DirectoryRemoval { + private final List moved; + private int prepared; + private boolean closed; + + private DirectoryRemoval(List moved) { + this.moved = List.copyOf(moved); + } + + private List moves() { + return moved; + } + + private void prepare() throws IOException { + if (closed || prepared > 0) { + throw new IllegalStateException("Datapack directory removal transaction is not new"); + } + try { + for (DirectoryMove directoryMove : moved) { + verifyDirectoryContainerIdentity( + directoryMove.target().getParentFile(), + directoryMove.targetRootIdentity(), + directoryMove.targetRootFileIdentity(), + "datapack removal target root" + ); + verifyDirectoryContainerIdentity( + directoryMove.backup().getParentFile(), + directoryMove.scratchRootIdentity(), + directoryMove.scratchRootFileIdentity(), + "datapack removal scratch root" + ); + verifyDirectorySnapshot( + directoryMove.target(), + new File(directoryMove.targetRootIdentity()), + directoryMove.originalHash(), + directoryMove.originalMarkerHash(), + directoryMove.originalIdentity(), + "datapack removal target" + ); + moveNew(directoryMove.target().toPath(), directoryMove.backup().toPath()); + prepared++; + forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath()); + forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath()); + verifyDirectorySnapshot( + directoryMove.backup(), + new File(directoryMove.targetRootIdentity()), + directoryMove.originalHash(), + directoryMove.originalMarkerHash(), + directoryMove.originalIdentity(), + "datapack removal backup" + ); + } + } catch (IOException removalFailure) { + try { + rollback(); + } catch (IOException restoreFailure) { + removalFailure.addSuppressed(restoreFailure); + } + throw removalFailure; + } + } + + private void rollback() throws IOException { + if (closed) { + return; + } + IOException failure = null; + for (int i = prepared - 1; i >= 0; i--) { + DirectoryMove directoryMove = moved.get(i); + if (!Files.exists(directoryMove.backup().toPath(), LinkOption.NOFOLLOW_LINKS)) { + continue; + } + try { + verifyDirectoryContainerIdentity( + directoryMove.target().getParentFile(), + directoryMove.targetRootIdentity(), + directoryMove.targetRootFileIdentity(), + "datapack removal target root" + ); + verifyDirectoryContainerIdentity( + directoryMove.backup().getParentFile(), + directoryMove.scratchRootIdentity(), + directoryMove.scratchRootFileIdentity(), + "datapack removal scratch root" + ); + verifyDirectorySnapshot( + directoryMove.backup(), + new File(directoryMove.targetRootIdentity()), + directoryMove.originalHash(), + directoryMove.originalMarkerHash(), + directoryMove.originalIdentity(), + "datapack removal backup" + ); + if (pathExists(directoryMove.target().toPath(), "datapack removal target")) { + throw new IOException("Datapack removal target was concurrently recreated at " + + directoryMove.target().getPath()); + } + DatapackIngestService.moveNew( + directoryMove.backup().toPath(), + directoryMove.target().toPath() + ); + forceDirectoryIfSupported(directoryMove.target().getParentFile().toPath()); + forceDirectoryIfSupported(directoryMove.backup().getParentFile().toPath()); + } catch (IOException restoreFailure) { + if (failure == null) { + failure = restoreFailure; + } else { + failure.addSuppressed(restoreFailure); + } + } + } + for (int i = 0; i < prepared; i++) { + DirectoryMove directoryMove = moved.get(i); + directoryMove.backup().getParentFile().delete(); + } + closed = true; + if (failure != null) { + throw failure; + } + } + + private void finishCommit() throws IOException { + if (closed) { + return; + } + IOException failure = null; + for (int i = 0; i < prepared; i++) { + DirectoryMove directoryMove = moved.get(i); + try { + verifyDirectoryContainerIdentity( + directoryMove.backup().getParentFile(), + directoryMove.scratchRootIdentity(), + directoryMove.scratchRootFileIdentity(), + "datapack removal scratch root" + ); + deleteOriginalBackupIfPresent( + directoryMove.backup(), + new File(directoryMove.targetRootIdentity()), + directoryMove.originalHash(), + directoryMove.originalMarkerHash(), + directoryMove.originalIdentity() + ); + } catch (IOException cleanupFailure) { + failure = appendIOException(failure, cleanupFailure); + } + directoryMove.backup().getParentFile().delete(); + } + closed = true; + if (failure != null) { + throw failure; + } + } + } + + private static final class ManifestWrite implements AutoCloseable { + private final Path staged; + private final Path target; + private final Path rollback; + private boolean published; + + private ManifestWrite(Path staged, Path target, Path rollback) { + this.staged = staged; + this.target = target; + this.rollback = rollback; + } + + private void publish() throws IOException { + if (published) { + throw new IllegalStateException("Datapack manifest write was already published"); + } + try { + move(staged, target); + published = true; + } catch (IOException publishFailure) { + try { + restoreOriginal(); + } catch (IOException restoreFailure) { + publishFailure.addSuppressed(restoreFailure); + } + throw publishFailure; + } + forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent")); + } + + private boolean published() { + return published; + } + + private void discard() throws IOException { + IOException failure = null; + try { + Files.deleteIfExists(staged); + } catch (IOException cleanupFailure) { + failure = cleanupFailure; + } + if (rollback != null) { + try { + Files.deleteIfExists(rollback); + } catch (IOException cleanupFailure) { + if (failure == null) { + failure = cleanupFailure; + } else { + failure.addSuppressed(cleanupFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + + @Override + public void close() throws IOException { + discard(); + } + + private void restoreOriginal() throws IOException { + if (rollback == null) { + Files.deleteIfExists(target); + } else { + move(rollback, target); + } + forceDirectoryIfSupported(Objects.requireNonNull(target.getParent(), "manifest parent")); + } + } + + private record PreparedEditableImport( + File dataFolder, + StructureTransactionWriter.PreparedRemoval removal + ) { + } + + private record DirectoryMove( + File target, + File backup, + String originalHash, + String originalMarkerHash, + String originalIdentity, + String targetRootIdentity, + String scratchRootIdentity, + String targetRootFileIdentity, + String scratchRootFileIdentity + ) { + } + + record InstallPlan( + File target, + File pending, + File backup, + File pendingRoot, + boolean hadTarget, + boolean publishRequired, + boolean contentChanged, + String originalHash, + String desiredHash, + String originalMarkerHash, + String desiredMarkerHash, + String originalIdentity, + String desiredIdentity, + String targetRootIdentity, + String scratchRootIdentity, + String targetRootFileIdentity, + String scratchRootFileIdentity, + String id, + String url, + VerifiedStagingInstall legacyWorldAuthorization + ) { + } + + private record LegacyStagingSnapshot( + Path normalizedTarget, + Path realTarget, + String targetIdentity, + String contentHash, + String markerHash + ) { + } + + private record StagingScratch(StagingScratchKind kind, String id, Path path) { + } + + @FunctionalInterface + interface DirectoryDeleter { + void delete(File directory); + } + + private static final class Ownership { + private final int schemaVersion; + private final String id; + private final String url; + private final String versionId; + private final String versionNumber; + private final String sha1; + private final String contentHash; + private final List structureKeys; + private final List templateKeys; + + private Ownership( + int schemaVersion, + String id, + String url, + String versionId, + String versionNumber, + String sha1, + String contentHash, + List structureKeys, + List templateKeys + ) { + this.schemaVersion = schemaVersion; + this.id = id; + this.url = url; + this.versionId = versionId; + this.versionNumber = versionNumber; + this.sha1 = sha1; + this.contentHash = contentHash; + this.structureKeys = List.copyOf(structureKeys); + this.templateKeys = List.copyOf(templateKeys); + } + + private Entry toEntry() { + Entry entry = new Entry(); + entry.id = id; + entry.url = url; + entry.versionId = versionId; + entry.versionNumber = versionNumber; + entry.sha1 = sha1; + entry.structureKeys = normalizeKeys(structureKeys); + entry.templateKeys = normalizeKeys(templateKeys); + return entry; + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java b/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java index 7e5588a18..d982fffbd 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/ModrinthResolver.java @@ -23,14 +23,17 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.Reader; import java.net.HttpURLConnection; import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -39,6 +42,7 @@ public final class ModrinthResolver { private static final String API = "https://api.modrinth.com/v2"; private static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)"; private static final String DATAPACK_LOADER = "datapack"; + private static final int MAX_API_RESPONSE_CHARS = 8 * 1024 * 1024; private ModrinthResolver() { } @@ -63,9 +67,19 @@ public final class ModrinthResolver { ? selectLatestDatapackVersion(versions, serverMcVersion) : selectVersionByToken(versions, ref.versionToken); if (version == null) { - String detail = ref.versionToken == null ? "no datapack-loader version" : "no version matching '" + ref.versionToken + "'"; + String detail = ref.versionToken == null + ? (serverMcVersion == null || serverMcVersion.isBlank() + ? "no datapack-loader version" + : "no datapack-loader version compatible with Minecraft " + serverMcVersion) + : "no version matching '" + ref.versionToken + "'"; throw new IOException("Modrinth project '" + ref.slug + "' has " + detail); } + if (!isDatapack(version)) { + throw new IOException("Modrinth version for '" + ref.slug + "' is not published for the datapack loader"); + } + if (serverMcVersion != null && !serverMcVersion.isBlank() && !gameVersionsContains(version, serverMcVersion)) { + throw new IOException("Modrinth version for '" + ref.slug + "' is not compatible with Minecraft " + serverMcVersion); + } JsonObject file = selectFile(version); if (file == null) { @@ -76,41 +90,30 @@ public final class ModrinthResolver { } private static ModrinthRef parse(String url) { - if (!url.toLowerCase(Locale.ROOT).contains("modrinth.com/")) { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + return null; + } + String host = uri.getHost(); + if (host == null || !(host.equalsIgnoreCase("modrinth.com") || host.equalsIgnoreCase("www.modrinth.com"))) { return null; } - String path = url.replaceFirst("^[a-zA-Z][a-zA-Z0-9+.-]*://", ""); - int query = path.indexOf('?'); - if (query >= 0) { - path = path.substring(0, query); - } - int fragment = path.indexOf('#'); - if (fragment >= 0) { - path = path.substring(0, fragment); - } - List parts = new ArrayList<>(); - for (String segment : path.split("/")) { + for (String segment : uri.getPath().split("/")) { if (!segment.isBlank()) { parts.add(segment); } } - - int base = -1; - for (int i = 0; i < parts.size(); i++) { - if (parts.get(i).equalsIgnoreCase("modrinth.com")) { - base = i; - break; - } - } - if (base < 0 || parts.size() < base + 3) { + if (parts.size() < 2) { return null; } - String slug = parts.get(base + 2); + String slug = parts.get(1); String token = null; - for (int i = base + 3; i + 1 < parts.size(); i++) { + for (int i = 2; i + 1 < parts.size(); i++) { if (parts.get(i).equalsIgnoreCase("version")) { token = parts.get(i + 1); break; @@ -125,6 +128,9 @@ public final class ModrinthResolver { String normalizedToken = normalizeVersion(token); for (JsonElement element : versions) { + if (!element.isJsonObject()) { + continue; + } JsonObject version = element.getAsJsonObject(); String id = optString(version, "id"); String number = optString(version, "version_number"); @@ -146,25 +152,24 @@ public final class ModrinthResolver { return datapackMatch != null ? datapackMatch : anyMatch; } - private static JsonObject selectLatestDatapackVersion(JsonArray versions, String serverMcVersion) { - JsonObject firstDatapack = null; + static JsonObject selectLatestDatapackVersion(JsonArray versions, String serverMcVersion) { for (JsonElement element : versions) { + if (!element.isJsonObject()) { + continue; + } JsonObject version = element.getAsJsonObject(); if (!isDatapack(version)) { continue; } - if (firstDatapack == null) { - firstDatapack = version; - } - if (serverMcVersion != null && !serverMcVersion.isBlank() && gameVersionsContains(version, serverMcVersion)) { + if (serverMcVersion == null || serverMcVersion.isBlank() || gameVersionsContains(version, serverMcVersion)) { return version; } } - return firstDatapack; + return null; } - private static JsonObject selectFile(JsonObject version) { - JsonArray files = version.getAsJsonArray("files"); + static JsonObject selectFile(JsonObject version) { + JsonArray files = optArray(version, "files"); if (files == null || files.isEmpty()) { return null; } @@ -174,11 +179,14 @@ public final class ModrinthResolver { JsonObject primary = null; JsonObject first = null; for (JsonElement element : files) { + if (!element.isJsonObject()) { + continue; + } JsonObject file = element.getAsJsonObject(); if (first == null) { first = file; } - boolean isPrimary = file.has("primary") && file.get("primary").getAsBoolean(); + boolean isPrimary = optBoolean(file, "primary"); boolean isZip = optString(file, "filename").toLowerCase(Locale.ROOT).endsWith(".zip"); if (isPrimary && primary == null) { primary = file; @@ -203,14 +211,20 @@ public final class ModrinthResolver { return first; } - private static ResolvedDatapack toResolved(JsonObject version, JsonObject file, String slug) { + private static ResolvedDatapack toResolved(JsonObject version, JsonObject file, String slug) throws IOException { String downloadUrl = optString(file, "url"); String filename = optString(file, "filename"); + if (downloadUrl.isBlank() || filename.isBlank()) { + throw new IOException("Modrinth version for '" + slug + "' has an invalid downloadable file"); + } String sha1 = null; if (file.has("hashes") && file.get("hashes").isJsonObject()) { sha1 = optString(file.getAsJsonObject("hashes"), "sha1"); + if (!sha1.isBlank() && !sha1.matches("(?i)[0-9a-f]{40}")) { + throw new IOException("Modrinth version for '" + slug + "' has an invalid SHA-1 checksum"); + } } - return new ResolvedDatapack(downloadUrl, filename, sha1, optString(version, "id"), optString(version, "version_number"), slug); + return new ResolvedDatapack(downloadUrl, filename, sha1, optString(version, "id"), optString(version, "version_number"), slug, false); } private static ResolvedDatapack directResolve(String url) { @@ -226,16 +240,45 @@ public final class ModrinthResolver { if (filename.isBlank()) { filename = "datapack.zip"; } - return new ResolvedDatapack(url, filename, null, "direct", "direct", null); + String identity = directIdentity(url); + return new ResolvedDatapack(url, filename, null, "direct-" + identity, "direct", null, true); + } + + static String directIdentity(String url) { + String normalized = url; + try { + URI parsed = new URI(url).normalize(); + normalized = new URI( + parsed.getScheme() == null ? null : parsed.getScheme().toLowerCase(Locale.ROOT), + parsed.getUserInfo(), + parsed.getHost() == null ? null : parsed.getHost().toLowerCase(Locale.ROOT), + parsed.getPort(), + parsed.getPath(), + parsed.getQuery(), + null + ).toASCIIString(); + } catch (URISyntaxException ignored) { + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(normalized.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(16); + for (int i = 0; i < 8; i++) { + builder.append(String.format("%02x", hash[i])); + } + return builder.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm unavailable", e); + } } private static boolean isDatapack(JsonObject version) { - JsonArray loaders = version.getAsJsonArray("loaders"); + JsonArray loaders = optArray(version, "loaders"); if (loaders == null) { return false; } for (JsonElement loader : loaders) { - if (DATAPACK_LOADER.equalsIgnoreCase(loader.getAsString())) { + if (isString(loader) && DATAPACK_LOADER.equalsIgnoreCase(loader.getAsString())) { return true; } } @@ -243,12 +286,12 @@ public final class ModrinthResolver { } private static boolean gameVersionsContains(JsonObject version, String mc) { - JsonArray gameVersions = version.getAsJsonArray("game_versions"); + JsonArray gameVersions = optArray(version, "game_versions"); if (gameVersions == null) { return false; } for (JsonElement gv : gameVersions) { - if (mc.equalsIgnoreCase(gv.getAsString())) { + if (isString(gv) && mc.equalsIgnoreCase(gv.getAsString())) { return true; } } @@ -267,19 +310,42 @@ public final class ModrinthResolver { } private static String optString(JsonObject object, String key) { - if (object == null || !object.has(key) || object.get(key).isJsonNull()) { + if (object == null || !object.has(key) || !isString(object.get(key))) { return ""; } return object.get(key).getAsString(); } + private static JsonArray optArray(JsonObject object, String key) { + if (object == null || !object.has(key) || !object.get(key).isJsonArray()) { + return null; + } + return object.getAsJsonArray(key); + } + + private static boolean optBoolean(JsonObject object, String key) { + if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive() + || !object.get(key).getAsJsonPrimitive().isBoolean()) { + return false; + } + return object.get(key).getAsBoolean(); + } + + private static boolean isString(JsonElement element) { + return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString(); + } + private static JsonArray getJsonArray(String url) throws IOException { String body = httpGet(url); - JsonElement parsed = JsonParser.parseString(body); - if (!parsed.isJsonArray()) { - throw new IOException("Unexpected response from " + url); + try { + JsonElement parsed = JsonParser.parseString(body); + if (!parsed.isJsonArray()) { + throw new IOException("Unexpected response from " + url); + } + return parsed.getAsJsonArray(); + } catch (RuntimeException e) { + throw new IOException("Invalid JSON response from " + url, e); } - return parsed.getAsJsonArray(); } private static String httpGet(String url) throws IOException { @@ -298,17 +364,36 @@ public final class ModrinthResolver { throw new IOException("HTTP " + code + " from " + url); } - StringBuilder builder = new StringBuilder(); try (InputStream input = connection.getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - builder.append(line); - } + InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) { + return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS); } finally { connection.disconnect(); } - return builder.toString(); + } + + static String readBoundedApiResponse(Reader reader, String source, int maxChars) throws IOException { + if (maxChars < 1) { + throw new IllegalArgumentException("Response character limit must be positive"); + } + int bufferSize = maxChars < 8192 ? maxChars + 1 : 8192; + char[] buffer = new char[bufferSize]; + StringBuilder builder = new StringBuilder(Math.min(maxChars, 8192)); + while (true) { + int remaining = maxChars - builder.length(); + int requested = remaining >= buffer.length ? buffer.length : remaining + 1; + int length = reader.read(buffer, 0, requested); + if (length == -1) { + return builder.toString(); + } + if (length == 0) { + continue; + } + if (length > remaining) { + throw new IOException("Oversized response from " + source); + } + builder.append(buffer, 0, length); + } } private static final class ModrinthRef { @@ -328,14 +413,16 @@ public final class ModrinthResolver { private final String versionId; private final String versionNumber; private final String projectSlug; + private final boolean direct; - public ResolvedDatapack(String downloadUrl, String fileName, String sha1, String versionId, String versionNumber, String projectSlug) { + public ResolvedDatapack(String downloadUrl, String fileName, String sha1, String versionId, String versionNumber, String projectSlug, boolean direct) { this.downloadUrl = downloadUrl; this.fileName = fileName; this.sha1 = sha1; this.versionId = versionId; this.versionNumber = versionNumber; this.projectSlug = projectSlug; + this.direct = direct; } public String getDownloadUrl() { @@ -361,5 +448,9 @@ public final class ModrinthResolver { public String getProjectSlug() { return projectSlug; } + + public boolean isDirect() { + return direct; + } } } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java index 18d98914c..291842f50 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitPublicBackend.java @@ -45,8 +45,8 @@ final class BukkitPublicBackend implements WorldLifecycleBackend { } @Override - public boolean unload(World world, boolean save) { - return WorldLifecycleSupport.unloadWorld(capabilities, world, save); + public CompletableFuture unloadAsync(World world, boolean save) { + return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save); } @Override diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java new file mode 100644 index 000000000..87e049fc8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java @@ -0,0 +1,194 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Objects; +import java.util.ArrayList; +import java.util.function.Predicate; + +public final class BukkitWorldConfiguration { + private static final Object MUTATION_LOCK = new Object(); + + private BukkitWorldConfiguration() { + } + + public static Registration register(File configurationFile, String worldName, String dimension, Long seed) throws IOException { + Objects.requireNonNull(configurationFile, "configurationFile"); + String requiredWorldName = requireWorldName(worldName); + String requiredDimension = requireName(dimension, "Dimension"); + synchronized (MUTATION_LOCK) { + YamlConfiguration configuration = load(configurationFile); + ConfigurationSection worlds = configuration.getConfigurationSection("worlds"); + if (worlds == null) { + worlds = configuration.createSection("worlds"); + } + + ConfigurationSection existing = worlds.getConfigurationSection(requiredWorldName); + String generator = "Iris:" + requiredDimension; + if (existing != null) { + String existingGenerator = existing.getString("generator"); + Long existingSeed = existing.contains("seed") ? existing.getLong("seed") : null; + if (!generator.equals(existingGenerator) || !Objects.equals(seed, existingSeed)) { + throw new IOException("bukkit.yml already contains a different definition for world \"" + + requiredWorldName + "\"."); + } + return Registration.UNCHANGED; + } + + ConfigurationSection created = worlds.createSection(requiredWorldName); + created.set("generator", generator); + if (seed != null) { + created.set("seed", seed); + } + saveAtomic(configurationFile.toPath(), configuration); + return Registration.CREATED; + } + } + + public static boolean remove(File configurationFile, String worldName) throws IOException { + Objects.requireNonNull(configurationFile, "configurationFile"); + String requiredWorldName = requireWorldName(worldName); + synchronized (MUTATION_LOCK) { + YamlConfiguration configuration = load(configurationFile); + ConfigurationSection worlds = configuration.getConfigurationSection("worlds"); + if (worlds == null || worlds.get(requiredWorldName) == null) { + return false; + } + + worlds.set(requiredWorldName, null); + if (worlds.getKeys(false).isEmpty()) { + configuration.set("worlds", null); + } + saveAtomic(configurationFile.toPath(), configuration); + return true; + } + } + + public static boolean removeIfMatching( + File configurationFile, + String worldName, + String dimension, + Long seed + ) throws IOException { + Objects.requireNonNull(configurationFile, "configurationFile"); + String requiredWorldName = requireWorldName(worldName); + String requiredDimension = requireName(dimension, "Dimension"); + synchronized (MUTATION_LOCK) { + YamlConfiguration configuration = load(configurationFile); + ConfigurationSection worlds = configuration.getConfigurationSection("worlds"); + if (worlds == null) { + return false; + } + + ConfigurationSection existing = worlds.getConfigurationSection(requiredWorldName); + if (existing == null) { + return false; + } + String expectedGenerator = "Iris:" + requiredDimension; + String actualGenerator = existing.getString("generator"); + Long actualSeed = existing.contains("seed") ? existing.getLong("seed") : null; + if (!expectedGenerator.equals(actualGenerator) || !Objects.equals(seed, actualSeed)) { + return false; + } + + worlds.set(requiredWorldName, null); + if (worlds.getKeys(false).isEmpty()) { + configuration.set("worlds", null); + } + saveAtomic(configurationFile.toPath(), configuration); + return true; + } + } + + public static int removeMatching(File configurationFile, Predicate matcher) throws IOException { + Objects.requireNonNull(configurationFile, "configurationFile"); + Predicate requiredMatcher = Objects.requireNonNull(matcher, "matcher"); + synchronized (MUTATION_LOCK) { + YamlConfiguration configuration = load(configurationFile); + ConfigurationSection worlds = configuration.getConfigurationSection("worlds"); + if (worlds == null) { + return 0; + } + + int removed = 0; + for (String worldName : new ArrayList<>(worlds.getKeys(false))) { + if (!requiredMatcher.test(worldName)) { + continue; + } + worlds.set(worldName, null); + removed++; + } + if (removed == 0) { + return 0; + } + if (worlds.getKeys(false).isEmpty()) { + configuration.set("worlds", null); + } + saveAtomic(configurationFile.toPath(), configuration); + return removed; + } + } + + static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException { + Path absoluteTarget = target.toAbsolutePath().normalize(); + Path parent = absoluteTarget.getParent(); + if (parent == null) { + throw new IOException("bukkit.yml target has no parent: " + absoluteTarget); + } + Files.createDirectories(parent); + Path staged = Files.createTempFile(parent, ".bukkit-worlds-", ".yml"); + try { + configuration.save(staged.toFile()); + try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) { + channel.force(true); + } + try { + Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(staged); + } + } + + private static YamlConfiguration load(File configurationFile) throws IOException { + YamlConfiguration configuration = new YamlConfiguration(); + try { + configuration.load(configurationFile); + return configuration; + } catch (InvalidConfigurationException exception) { + throw new IOException("bukkit.yml is invalid and was not changed.", exception); + } + } + + private static String requireWorldName(String value) { + String worldName = requireName(value, "World name"); + if (!worldName.matches("[a-z0-9_-]+")) { + throw new IllegalArgumentException("World name must contain only lowercase letters, numbers, underscores, or hyphens."); + } + return worldName; + } + + private static String requireName(String value, String label) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(label + " cannot be empty."); + } + return value.trim(); + } + + public enum Registration { + CREATED, + UNCHANGED + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java new file mode 100644 index 000000000..b1c61b078 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java @@ -0,0 +1,1130 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.core.IrisWorldStorage; +import art.arcane.iris.core.IrisWorlds; +import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.WorldRemovalPathPolicy; +import art.arcane.iris.core.link.MultiverseCoreLink; +import art.arcane.iris.core.runtime.WorldDeletionQueue; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.misc.ServerProperties; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public final class IrisWorldRemovalService { + private static final long PHASE_TIMEOUT_SECONDS = 120L; + private static final IrisWorldRemovalService INSTANCE = new IrisWorldRemovalService( + LifecycleOperationCoordinator.get(), + new BukkitRemovalBackend(ForkJoinPool.commonPool()) + ); + + private final LifecycleOperationCoordinator coordinator; + private final RemovalBackend backend; + private final long phaseTimeoutMillis; + private final Consumer restartRequester; + + IrisWorldRemovalService(LifecycleOperationCoordinator coordinator, RemovalBackend backend) { + this(coordinator, new ServiceOptions( + backend, + TimeUnit.SECONDS.toMillis(PHASE_TIMEOUT_SECONDS), + ServerConfigurator::restart + )); + } + + IrisWorldRemovalService(LifecycleOperationCoordinator coordinator, ServiceOptions options) { + this.coordinator = Objects.requireNonNull(coordinator, "coordinator"); + ServiceOptions requiredOptions = Objects.requireNonNull(options, "options"); + backend = requiredOptions.backend(); + phaseTimeoutMillis = requiredOptions.phaseTimeoutMillis(); + restartRequester = requiredOptions.restartRequester(); + } + + public static IrisWorldRemovalService get() { + return INSTANCE; + } + + public CompletableFuture remove(String worldIdentifier, boolean deleteFiles) { + String operationTarget = normalizeOperationTarget(worldIdentifier); + if (operationTarget == null) { + return CompletableFuture.completedFuture(RemovalResult.failure( + RemovalStatus.INVALID_IDENTIFIER, + worldIdentifier, + null, + new IllegalArgumentException("World identifier cannot be empty.") + )); + } + + LifecycleOperationCoordinator.Lease lease; + try { + lease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE, + operationTarget + ); + } catch (LifecycleOperationCoordinator.BusyException exception) { + return CompletableFuture.completedFuture(RemovalResult.busy(worldIdentifier, exception.currentOperation())); + } catch (Throwable failure) { + return CompletableFuture.completedFuture(RemovalResult.failure( + RemovalStatus.INTERNAL_FAILURE, + worldIdentifier, + null, + failure + )); + } + + CompletableFuture operation; + try { + WorldRemovalPathPolicy.Target target = backend.resolveTarget(operationTarget); + operation = execute(target, deleteFiles, new AtomicBoolean(false)); + } catch (WorldRemovalPathPolicy.Rejection rejection) { + operation = CompletableFuture.completedFuture(RemovalResult.failure( + mapRejection(rejection.reason()), + worldIdentifier, + null, + rejection + )); + } catch (Throwable failure) { + operation = CompletableFuture.completedFuture(RemovalResult.failure( + RemovalStatus.RESOLUTION_FAILED, + worldIdentifier, + null, + failure + )); + } + + CompletableFuture result = new CompletableFuture<>(); + operation.whenComplete((removalResult, throwable) -> completeAndRelease( + result, + lease, + worldIdentifier, + removalResult, + throwable + )); + return result; + } + + private CompletableFuture execute( + WorldRemovalPathPolicy.Target target, + boolean deleteFiles, + AtomicBoolean terminal + ) { + return phase(RemovalStatus.RESOLUTION_FAILED, () -> backend.resolve(target), terminal) + .thenCompose(resolvedWorld -> validateResolvedWorld(resolvedWorld)) + .thenCompose(resolvedWorld -> runWithMaintenance(resolvedWorld, deleteFiles, terminal)) + .exceptionally(throwable -> failureResult(target, throwable)); + } + + private CompletableFuture runWithMaintenance( + ResolvedWorld resolved, + boolean deleteFiles, + AtomicBoolean terminal + ) { + CompletableFuture operation = phase( + RemovalStatus.RESOLUTION_FAILED, + () -> backend.beginMaintenance(resolved).thenApply(ignored -> resolved), + terminal + ) + .thenCompose(activeWorld -> phase( + RemovalStatus.TELEPORT_FAILED, + () -> backend.evacuatePlayers(activeWorld) + .thenApply(ignored -> activeWorld), + terminal + )) + .thenCompose(activeWorld -> phase( + RemovalStatus.UNLOAD_FAILED, + () -> backend.unloadWorld(activeWorld) + .thenApply(unloaded -> requireUnloaded(activeWorld, unloaded)), + terminal + )) + .thenCompose(activeWorld -> phase( + RemovalStatus.GENERATOR_CLOSE_FAILED, + () -> backend.closeGenerator(activeWorld) + .thenApply(ignored -> activeWorld), + terminal + )) + .thenCompose(activeWorld -> phase( + RemovalStatus.CONFIGURATION_FAILED, + () -> backend.unregister(activeWorld.target(), terminal::get), + terminal + ).thenCompose(configuration -> { + if (configuration.failure() != null) { + return CompletableFuture.completedFuture(RemovalResult.partialFailure( + RemovalStatus.CONFIGURATION_FAILED, + activeWorld.target(), + null, + configuration.changed(), + false, + configuration.failure() + )); + } + return unregisterRegistryAndFinish( + activeWorld, + configuration.changed(), + deleteFiles, + terminal + ); + })); + + return operation.handle((result, throwable) -> backend.endMaintenance(resolved) + .handle((ignored, cleanupFailure) -> { + if (cleanupFailure != null) { + Throwable unwrappedCleanup = unwrap(cleanupFailure); + if (throwable != null) { + Throwable original = unwrap(throwable); + original.addSuppressed(unwrappedCleanup); + throw new CompletionException(original); + } + throw new CompletionException(unwrappedCleanup); + } + if (throwable != null) { + throw new CompletionException(unwrap(throwable)); + } + return result; + })).thenCompose(result -> result); + } + + private CompletableFuture validateResolvedWorld(ResolvedWorld resolvedWorld) { + if (!resolvedWorld.managed()) { + return CompletableFuture.failedFuture(new RemovalFailure( + RemovalStatus.NOT_FOUND, + new IllegalStateException("No Iris-managed world was found for " + + resolvedWorld.target().worldKey() + ".") + )); + } + if (resolvedWorld.loadedWorld() != null && resolvedWorld.generator() == null) { + return CompletableFuture.failedFuture(new RemovalFailure( + RemovalStatus.NOT_IRIS_WORLD, + new IllegalStateException("Loaded world " + resolvedWorld.target().worldKey() + + " is not using an Iris generator.") + )); + } + if (resolvedWorld.conflictingConfiguration()) { + return CompletableFuture.failedFuture(new RemovalFailure( + RemovalStatus.NOT_IRIS_WORLD, + new IllegalStateException("bukkit.yml assigns a non-Iris generator to " + + resolvedWorld.target().logicalName() + ".") + )); + } + return CompletableFuture.completedFuture(resolvedWorld); + } + + private CompletableFuture unregisterRegistryAndFinish( + ResolvedWorld resolvedWorld, + boolean configurationChanged, + boolean deleteFiles, + AtomicBoolean terminal + ) { + return phase( + RemovalStatus.REGISTRY_FAILED, + () -> backend.unregisterRegistry(resolvedWorld.target(), terminal::get), + terminal + ).handle((registryChanged, throwable) -> { + if (throwable == null) { + return finishRemoval( + resolvedWorld, + configurationChanged, + registryChanged, + deleteFiles, + terminal + ); + } + + Throwable failure = unwrap(throwable); + RemovalStatus status = RemovalStatus.INTERNAL_FAILURE; + Throwable cause = failure; + if (failure instanceof RemovalFailure removalFailure) { + status = removalFailure.status(); + cause = removalFailure.getCause(); + } + return CompletableFuture.completedFuture(RemovalResult.partialFailure( + status, + resolvedWorld.target(), + null, + configurationChanged, + false, + cause + )); + }).thenCompose(result -> result); + } + + private ResolvedWorld requireUnloaded(ResolvedWorld resolvedWorld, Boolean unloaded) { + if (!Boolean.TRUE.equals(unloaded)) { + throw new RemovalFailure( + RemovalStatus.UNLOAD_FAILED, + new IllegalStateException("World lifecycle backend refused to unload " + + resolvedWorld.target().worldKey() + ".") + ); + } + return resolvedWorld; + } + + private CompletableFuture finishRemoval( + ResolvedWorld resolvedWorld, + boolean configurationChanged, + boolean registryChanged, + boolean deleteFiles, + AtomicBoolean terminal + ) { + WorldRemovalPathPolicy.Target target = resolvedWorld.target(); + if (!deleteFiles) { + return CompletableFuture.completedFuture(RemovalResult.success( + RemovalStatus.UNREGISTERED, + target, + null, + configurationChanged, + registryChanged + )); + } + + return phase( + RemovalStatus.QUARANTINE_FAILED, + () -> backend.delete(target, terminal::get), + terminal + ) + .handle((disposition, throwable) -> { + if (throwable == null) { + return RemovalResult.success( + disposition.queued() ? RemovalStatus.DELETE_QUEUED : RemovalStatus.DELETED, + target, + disposition.quarantineDirectory(), + configurationChanged, + registryChanged + ); + } + + Throwable failure = unwrap(throwable); + if (failure instanceof RemovalFailure removalFailure) { + return RemovalResult.partialFailure( + removalFailure.status(), + target, + removalFailure.quarantineDirectory(), + configurationChanged, + registryChanged, + removalFailure.getCause() + ); + } + return RemovalResult.partialFailure( + RemovalStatus.INTERNAL_FAILURE, + target, + null, + configurationChanged, + registryChanged, + failure + ); + }); + } + + private CompletableFuture phase( + RemovalStatus failureStatus, + Supplier> operation, + AtomicBoolean terminal + ) { + if (terminal.get()) { + return CompletableFuture.failedFuture(wrapFailure( + failureStatus, + new TimeoutException("World removal already crossed its terminal timeout boundary.") + )); + } + + CompletableFuture future; + try { + future = Objects.requireNonNull(operation.get(), "Removal phase returned no completion future."); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(wrapFailure(failureStatus, failure)); + } + CompletableFuture guarded = new CompletableFuture<>(); + AtomicBoolean settled = new AtomicBoolean(false); + future.whenComplete((value, throwable) -> { + if (!settled.compareAndSet(false, true)) { + return; + } + if (throwable == null) { + guarded.complete(value); + } else { + guarded.completeExceptionally(unwrap(throwable)); + } + }); + CompletableFuture.delayedExecutor(phaseTimeoutMillis, TimeUnit.MILLISECONDS).execute(() -> { + if (!settled.compareAndSet(false, true)) { + return; + } + + terminal.set(true); + String phaseName = failureStatus.name().toLowerCase(); + String restartReason = "World removal timed out during " + phaseName + "."; + TimeoutException timeout = new TimeoutException("World removal phase " + + phaseName + " did not settle within " + phaseTimeoutMillis + " milliseconds."); + try { + restartRequester.accept(restartReason); + } catch (Throwable restartFailure) { + timeout.addSuppressed(restartFailure); + } + if (coordinator.active(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE).isEmpty()) { + coordinator.quiesceForRestart(() -> IrisLogging.error( + restartReason + " Automatic restart dispatch failed; restart the server manually.")); + } + try { + future.cancel(false); + } catch (Throwable cancellationFailure) { + timeout.addSuppressed(cancellationFailure); + } + guarded.completeExceptionally(timeout); + }); + return guarded.handle((value, throwable) -> { + if (throwable != null) { + throw wrapFailure(failureStatus, throwable); + } + return value; + }); + } + + private RemovalResult failureResult(WorldRemovalPathPolicy.Target target, Throwable throwable) { + Throwable failure = unwrap(throwable); + if (failure instanceof RemovalFailure removalFailure) { + return RemovalResult.failure( + removalFailure.status(), + target.requestedIdentifier(), + target, + removalFailure.getCause() + ); + } + return RemovalResult.failure( + RemovalStatus.INTERNAL_FAILURE, + target.requestedIdentifier(), + target, + failure + ); + } + + private RemovalFailure wrapFailure(RemovalStatus status, Throwable throwable) { + Throwable failure = unwrap(throwable); + if (failure instanceof RemovalFailure removalFailure) { + return removalFailure; + } + if (failure instanceof WorldRemovalPathPolicy.Rejection rejection) { + return new RemovalFailure(mapRejection(rejection.reason()), rejection); + } + return new RemovalFailure(status, failure); + } + + private void completeAndRelease( + CompletableFuture destination, + LifecycleOperationCoordinator.Lease lease, + String worldIdentifier, + RemovalResult removalResult, + Throwable throwable + ) { + RemovalResult completedResult = removalResult; + if (throwable != null) { + completedResult = RemovalResult.failure( + RemovalStatus.INTERNAL_FAILURE, + worldIdentifier, + null, + unwrap(throwable) + ); + } + try { + lease.close(); + } catch (Throwable releaseFailure) { + Throwable existingFailure = completedResult == null ? null : completedResult.failure(); + if (existingFailure != null) { + releaseFailure.addSuppressed(existingFailure); + } + completedResult = RemovalResult.failure( + RemovalStatus.INTERNAL_FAILURE, + worldIdentifier, + completedResult == null ? null : completedResult.target(), + releaseFailure + ); + } + destination.complete(completedResult); + } + + private static String normalizeOperationTarget(String worldIdentifier) { + if (worldIdentifier == null || worldIdentifier.isBlank()) { + return null; + } + return worldIdentifier.trim(); + } + + private static RemovalStatus mapRejection(WorldRemovalPathPolicy.RejectionReason reason) { + return switch (reason) { + case INVALID_IDENTIFIER -> RemovalStatus.INVALID_IDENTIFIER; + case CONFIGURED_MAIN_WORLD, MINECRAFT_NAMESPACE -> RemovalStatus.PROTECTED_WORLD; + case NOT_IRIS_NAMESPACE -> RemovalStatus.NOT_IRIS_WORLD; + case OUTSIDE_STORAGE_ROOT, SYMBOLIC_LINK -> RemovalStatus.UNSAFE_PATH; + }; + } + + private static Throwable unwrap(Throwable throwable) { + Throwable current = throwable; + while (!(current instanceof RemovalFailure) + && (current instanceof CompletionException || current instanceof ExecutionException) + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + record ServiceOptions( + RemovalBackend backend, + long phaseTimeoutMillis, + Consumer restartRequester + ) { + ServiceOptions { + Objects.requireNonNull(backend, "backend"); + if (phaseTimeoutMillis < 1L) { + throw new IllegalArgumentException("phaseTimeoutMillis must be positive"); + } + Objects.requireNonNull(restartRequester, "restartRequester"); + } + } + + interface RemovalBackend { + WorldRemovalPathPolicy.Target resolveTarget(String identifier); + + CompletableFuture resolve(WorldRemovalPathPolicy.Target target); + + CompletableFuture evacuatePlayers(ResolvedWorld resolvedWorld); + + CompletableFuture beginMaintenance(ResolvedWorld resolvedWorld); + + CompletableFuture endMaintenance(ResolvedWorld resolvedWorld); + + CompletableFuture closeGenerator(ResolvedWorld resolvedWorld); + + CompletableFuture unloadWorld(ResolvedWorld resolvedWorld); + + CompletableFuture unregister( + WorldRemovalPathPolicy.Target target, + BooleanSupplier terminal + ); + + CompletableFuture unregisterRegistry( + WorldRemovalPathPolicy.Target target, + BooleanSupplier terminal + ); + + CompletableFuture delete( + WorldRemovalPathPolicy.Target target, + BooleanSupplier terminal + ); + } + + record ResolvedWorld( + WorldRemovalPathPolicy.Target target, + World loadedWorld, + PlatformChunkGenerator generator, + boolean configurationManaged, + boolean conflictingConfiguration, + boolean registryManaged, + boolean directoryPresent + ) { + ResolvedWorld { + Objects.requireNonNull(target, "target"); + } + + boolean managed() { + return loadedWorld != null || configurationManaged || registryManaged || directoryPresent; + } + } + + record DeleteDisposition(boolean queued, Path quarantineDirectory) { + } + + record ConfigurationDisposition(boolean changed, Throwable failure) { + } + + public enum RemovalStatus { + UNREGISTERED, + DELETED, + DELETE_QUEUED, + BUSY, + INVALID_IDENTIFIER, + PROTECTED_WORLD, + NOT_IRIS_WORLD, + UNSAFE_PATH, + NOT_FOUND, + RESOLUTION_FAILED, + TELEPORT_FAILED, + GENERATOR_CLOSE_FAILED, + UNLOAD_FAILED, + CONFIGURATION_FAILED, + REGISTRY_FAILED, + QUARANTINE_FAILED, + DELETE_FAILED, + INTERNAL_FAILURE + } + + public record RemovalResult( + RemovalStatus status, + String requestedIdentifier, + WorldRemovalPathPolicy.Target target, + Path quarantineDirectory, + boolean configurationChanged, + boolean registryChanged, + LifecycleOperationCoordinator.ActiveOperation blockingOperation, + Throwable failure + ) { + public RemovalResult { + Objects.requireNonNull(status, "status"); + } + + static RemovalResult success( + RemovalStatus status, + WorldRemovalPathPolicy.Target target, + Path quarantineDirectory, + boolean configurationChanged, + boolean registryChanged + ) { + if (status != RemovalStatus.UNREGISTERED + && status != RemovalStatus.DELETED + && status != RemovalStatus.DELETE_QUEUED) { + throw new IllegalArgumentException("Not a successful removal status: " + status); + } + return new RemovalResult( + status, + target.requestedIdentifier(), + target, + quarantineDirectory, + configurationChanged, + registryChanged, + null, + null + ); + } + + static RemovalResult busy( + String requestedIdentifier, + LifecycleOperationCoordinator.ActiveOperation blockingOperation + ) { + return new RemovalResult( + RemovalStatus.BUSY, + requestedIdentifier, + null, + null, + false, + false, + Objects.requireNonNull(blockingOperation, "blockingOperation"), + null + ); + } + + static RemovalResult failure( + RemovalStatus status, + String requestedIdentifier, + WorldRemovalPathPolicy.Target target, + Throwable failure + ) { + return new RemovalResult( + status, + requestedIdentifier, + target, + null, + false, + false, + null, + Objects.requireNonNull(failure, "failure") + ); + } + + public boolean succeeded() { + return status == RemovalStatus.UNREGISTERED + || status == RemovalStatus.DELETED + || status == RemovalStatus.DELETE_QUEUED; + } + + public boolean deletionDeferred() { + return status == RemovalStatus.DELETE_QUEUED; + } + + public boolean busy() { + return status == RemovalStatus.BUSY; + } + + static RemovalResult partialFailure( + RemovalStatus status, + WorldRemovalPathPolicy.Target target, + Path quarantineDirectory, + boolean configurationChanged, + boolean registryChanged, + Throwable failure + ) { + return new RemovalResult( + status, + target.requestedIdentifier(), + target, + quarantineDirectory, + configurationChanged, + registryChanged, + null, + Objects.requireNonNull(failure, "failure") + ); + } + } + + private static final class RemovalFailure extends CompletionException { + private final RemovalStatus status; + private final Path quarantineDirectory; + + private RemovalFailure(RemovalStatus status, Throwable cause) { + this(status, cause, null); + } + + private RemovalFailure(RemovalStatus status, Throwable cause, Path quarantineDirectory) { + super(Objects.requireNonNull(cause, "cause")); + this.status = Objects.requireNonNull(status, "status"); + this.quarantineDirectory = quarantineDirectory; + } + + private RemovalStatus status() { + return status; + } + + private Path quarantineDirectory() { + return quarantineDirectory; + } + } + + private static final class BukkitRemovalBackend implements RemovalBackend { + private final Executor ioExecutor; + + private BukkitRemovalBackend(Executor ioExecutor) { + this.ioExecutor = Objects.requireNonNull(ioExecutor, "ioExecutor"); + } + + @Override + public WorldRemovalPathPolicy.Target resolveTarget(String identifier) { + String currentMainWorld = IrisWorldStorage.levelRoot().getName(); + String configuredMainWorld = IrisWorldStorage.configuredLevelName(); + return WorldRemovalPathPolicy.resolve( + identifier, + currentMainWorld, + List.of(currentMainWorld, configuredMainWorld), + IrisWorldStorage.levelRoot().toPath() + ); + } + + @Override + public CompletableFuture resolve(WorldRemovalPathPolicy.Target target) { + CompletableFuture runtimeInspection = onGlobal(() -> inspectRuntime(target)); + CompletableFuture diskInspection = CompletableFuture.supplyAsync( + () -> inspectDisk(target), + ioExecutor + ); + return runtimeInspection.thenCombine(diskInspection, (runtime, disk) -> new ResolvedWorld( + target, + runtime.world(), + runtime.generator(), + disk.configurationManaged(), + disk.conflictingConfiguration(), + runtime.registryManaged(), + disk.directoryPresent() + )); + } + + @Override + public CompletableFuture beginMaintenance(ResolvedWorld resolvedWorld) { + World world = resolvedWorld.loadedWorld(); + if (world != null) { + IrisToolbelt.beginWorldMaintenance(world, "world-remove", true); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture endMaintenance(ResolvedWorld resolvedWorld) { + World world = resolvedWorld.loadedWorld(); + if (world != null) { + IrisToolbelt.endWorldMaintenance(world, "world-remove"); + } + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture evacuatePlayers(ResolvedWorld resolvedWorld) { + World world = resolvedWorld.loadedWorld(); + if (world == null) { + return CompletableFuture.completedFuture(null); + } + + return onGlobal(() -> createEvacuationPlan(world)).thenCompose(plan -> { + if (plan.players().isEmpty()) { + return CompletableFuture.completedFuture(null); + } + if (plan.destination() == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "No destination world is available for " + plan.players().size() + " player(s)." + )); + } + + List> teleports = new ArrayList<>(plan.players().size()); + for (Player player : plan.players()) { + teleports.add(teleport(player, world, plan.destination())); + } + return CompletableFuture.allOf(teleports.toArray(CompletableFuture[]::new)); + }); + } + + @Override + public CompletableFuture closeGenerator(ResolvedWorld resolvedWorld) { + PlatformChunkGenerator generator = resolvedWorld.generator(); + if (generator == null) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture closeFuture = generator.closeAsync(); + if (closeFuture == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Iris generator returned no close completion future." + )); + } + return closeFuture; + } + + @Override + public CompletableFuture unloadWorld(ResolvedWorld resolvedWorld) { + World world = resolvedWorld.loadedWorld(); + if (world == null) { + return CompletableFuture.completedFuture(true); + } + return WorldLifecycleService.get().unloadAsync(world, true); + } + + @Override + public CompletableFuture unregister( + WorldRemovalPathPolicy.Target target, + BooleanSupplier terminal + ) { + return onGlobal(() -> { + requireNotTerminal(terminal, "Multiverse unregistration"); + return IrisServices.get(MultiverseCoreLink.class).removeFromConfig(target.logicalName()); + }).thenCompose(multiverseChanged -> { + if (terminal.getAsBoolean()) { + return CompletableFuture.failedFuture(new IllegalStateException( + "World removal stopped before Bukkit configuration unregistration.")); + } + return CompletableFuture.supplyAsync(() -> { + requireNotTerminal(terminal, "Bukkit configuration unregistration"); + try { + boolean bukkitChanged = BukkitWorldConfiguration.remove( + ServerProperties.BUKKIT_YML, + target.logicalName() + ); + return new ConfigurationDisposition(multiverseChanged || bukkitChanged, null); + } catch (Throwable failure) { + return new ConfigurationDisposition(multiverseChanged, unwrap(failure)); + } + }, ioExecutor); + }); + } + + @Override + public CompletableFuture unregisterRegistry( + WorldRemovalPathPolicy.Target target, + BooleanSupplier terminal + ) { + return CompletableFuture.supplyAsync( + () -> { + requireNotTerminal(terminal, "Iris registry unregistration"); + return IrisWorlds.get().remove(target.worldKey().toString()); + }, + ioExecutor + ); + } + + @Override + public CompletableFuture delete( + WorldRemovalPathPolicy.Target target, + BooleanSupplier terminal + ) { + Path quarantine = target.worldDirectory().resolveSibling(".iris-delete-" + UUID.randomUUID()); + return CompletableFuture.supplyAsync( + () -> { + requireNotTerminal(terminal, "deletion intent"); + return queueDeletionIntent(target, quarantine); + }, + ioExecutor + ) + .thenCompose(queuedQuarantine -> { + if (queuedQuarantine == null) { + return CompletableFuture.completedFuture(null); + } + if (terminal.getAsBoolean()) { + return CompletableFuture.failedFuture(new IllegalStateException( + "World removal stopped after recording its deletion intent.")); + } + return onGlobal(() -> { + requireNotTerminal(terminal, "world quarantine"); + return quarantineWorld(target, queuedQuarantine); + }); + }) + .thenCompose(quarantined -> { + if (quarantined == null) { + return CompletableFuture.completedFuture(new DeleteDisposition(false, null)); + } + if (terminal.getAsBoolean()) { + return CompletableFuture.failedFuture(new IllegalStateException( + "World removal stopped after quarantining its directory.")); + } + return CompletableFuture.supplyAsync(() -> { + requireNotTerminal(terminal, "quarantine deletion"); + return deleteQuarantine(quarantined); + }, ioExecutor); + }); + } + + private static void requireNotTerminal(BooleanSupplier terminal, String stage) { + if (terminal.getAsBoolean()) { + throw new IllegalStateException("World removal stopped before " + stage + "."); + } + } + + private BukkitInspection inspectRuntime(WorldRemovalPathPolicy.Target target) { + World world = WorldIdentity.resolve(target.worldKey()).orElse(null); + PlatformChunkGenerator generator = null; + if (world != null) { + Path loadedDirectory = world.getWorldFolder().toPath().toAbsolutePath().normalize(); + WorldRemovalPathPolicy.validateStoragePath(target.levelRoot(), target.worldKey(), loadedDirectory); + generator = IrisToolbelt.access(world); + } + boolean registryManaged = IrisWorlds.get().getWorlds().containsKey(target.worldKey().toString()); + return new BukkitInspection(world, generator, registryManaged); + } + + private DiskInspection inspectDisk(WorldRemovalPathPolicy.Target target) { + WorldRemovalPathPolicy.validateStoragePath( + target.levelRoot(), + target.worldKey(), + target.worldDirectory() + ); + boolean directoryPresent = Files.isDirectory(target.worldDirectory(), LinkOption.NOFOLLOW_LINKS); + YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML); + String generator = configuration.getString("worlds." + target.logicalName() + ".generator"); + boolean configurationManaged = generator != null + && (generator.equalsIgnoreCase("Iris") || generator.regionMatches(true, 0, "Iris:", 0, 5)); + boolean conflictingConfiguration = generator != null && !configurationManaged; + return new DiskInspection(configurationManaged, conflictingConfiguration, directoryPresent); + } + + private EvacuationPlan createEvacuationPlan(World source) { + List players = List.copyOf(source.getPlayers()); + Location destination = null; + for (World candidate : Bukkit.getWorlds()) { + if (!WorldIdentity.key(candidate).equals(WorldIdentity.key(source))) { + destination = candidate.getSpawnLocation().clone(); + break; + } + } + return new EvacuationPlan(players, destination); + } + + private CompletableFuture teleport(Player player, World source, Location destination) { + CompletableFuture result = new CompletableFuture<>(); + Runnable operation = () -> { + try { + if (!WorldIdentity.key(player.getWorld()).equals(WorldIdentity.key(source))) { + result.complete(null); + return; + } + CompletableFuture teleportFuture = BukkitPlatform.teleportAsync(player, destination); + if (teleportFuture == null) { + result.completeExceptionally(new IllegalStateException( + "No teleport completion future was returned for " + player.getName() + "." + )); + return; + } + teleportFuture.whenComplete((success, throwable) -> { + if (throwable != null) { + result.completeExceptionally(throwable); + } else if (!Boolean.TRUE.equals(success)) { + result.completeExceptionally(new IllegalStateException( + "Player evacuation was refused for " + player.getName() + "." + )); + } else { + result.complete(null); + } + }); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }; + boolean scheduled = J.runEntity(player, operation, 0, () -> result.completeExceptionally( + new IllegalStateException("Player retired before evacuation: " + player.getName() + ".") + )); + if (!scheduled) { + result.completeExceptionally(new IllegalStateException( + "Failed to schedule evacuation for " + player.getName() + "." + )); + } + return result; + } + + private Path queueDeletionIntent(WorldRemovalPathPolicy.Target target, Path quarantine) { + WorldRemovalPathPolicy.validateStoragePath( + target.levelRoot(), + target.worldKey(), + target.worldDirectory() + ); + Path worldDirectory = target.worldDirectory(); + if (!Files.exists(worldDirectory, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.isDirectory(worldDirectory, LinkOption.NOFOLLOW_LINKS)) { + throw new RemovalFailure( + RemovalStatus.QUARANTINE_FAILED, + new IOException("Iris world target is not a directory: " + worldDirectory) + ); + } + + WorldDeletionQueue deletionQueue = IrisServices.getOrNull(WorldDeletionQueue.class); + if (deletionQueue == null) { + throw new RemovalFailure( + RemovalStatus.QUARANTINE_FAILED, + new IOException("World deletion queue is unavailable; no files were moved.") + ); + } + try { + deletionQueue.queueExactForStartupDeletion(List.of(quarantine.getFileName().toString())); + return quarantine; + } catch (IOException failure) { + throw new RemovalFailure(RemovalStatus.QUARANTINE_FAILED, failure); + } + } + + private Path quarantineWorld(WorldRemovalPathPolicy.Target target, Path quarantine) { + if (WorldIdentity.resolve(target.worldKey()).isPresent()) { + throw new RemovalFailure( + RemovalStatus.QUARANTINE_FAILED, + new IllegalStateException("World became loaded again before quarantine: " + + target.worldKey() + ".") + ); + } + WorldRemovalPathPolicy.validateStoragePath( + target.levelRoot(), + target.worldKey(), + target.worldDirectory() + ); + Path worldDirectory = target.worldDirectory(); + if (!Files.exists(worldDirectory, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.isDirectory(worldDirectory, LinkOption.NOFOLLOW_LINKS)) { + throw new RemovalFailure( + RemovalStatus.QUARANTINE_FAILED, + new IOException("Iris world target is not a directory: " + worldDirectory) + ); + } + try { + try { + Files.move(worldDirectory, quarantine, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(worldDirectory, quarantine); + } + return quarantine; + } catch (IOException failure) { + throw new RemovalFailure(RemovalStatus.QUARANTINE_FAILED, failure); + } + } + + private DeleteDisposition deleteQuarantine(Path quarantine) { + try { + deleteTree(quarantine); + return new DeleteDisposition(false, quarantine); + } catch (Throwable deletionFailure) { + IrisLogging.reportError( + "Immediate Iris world deletion failed; startup cleanup remains queued for " + quarantine + ".", + deletionFailure + ); + return new DeleteDisposition(true, quarantine); + } + } + + private static void deleteTree(Path target) throws IOException { + Files.walkFileTree(target, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException { + if (failure != null) { + throw failure; + } + Files.delete(directory); + return FileVisitResult.CONTINUE; + } + }); + } + + private static CompletableFuture onGlobal(Supplier supplier) { + CompletableFuture result = new CompletableFuture<>(); + boolean scheduled = J.runGlobal(() -> { + try { + result.complete(supplier.get()); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }); + if (!scheduled) { + result.completeExceptionally(new IllegalStateException("Failed to schedule world removal on the global thread.")); + } + return result; + } + + private record BukkitInspection( + World world, + PlatformChunkGenerator generator, + boolean registryManaged + ) { + } + + private record DiskInspection( + boolean configurationManaged, + boolean conflictingConfiguration, + boolean directoryPresent + ) { + } + + private record EvacuationPlan(List players, Location destination) { + private EvacuationPlan { + Objects.requireNonNull(players, "players"); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinator.java b/core/src/main/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinator.java new file mode 100644 index 000000000..05d5dcf56 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinator.java @@ -0,0 +1,275 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.spi.IrisLogging; + +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class LifecycleOperationCoordinator { + private static final LifecycleOperationCoordinator INSTANCE = new LifecycleOperationCoordinator(); + + private final Object monitor; + private final ArrayDeque idleCallbacks; + private ActiveOperation mutationOperation; + private ActiveOperation restartOperation; + private Runnable pendingRestartCallback; + private long nextOperationId; + + LifecycleOperationCoordinator() { + monitor = new Object(); + idleCallbacks = new ArrayDeque<>(); + nextOperationId = 1L; + } + + public static LifecycleOperationCoordinator get() { + return INSTANCE; + } + + public Lease acquire(Domain domain, OperationKind kind, String target) { + Objects.requireNonNull(domain, "domain"); + Objects.requireNonNull(kind, "kind"); + String normalizedTarget = Objects.requireNonNull(target, "target").trim(); + if (normalizedTarget.isEmpty()) { + throw new IllegalArgumentException("target must not be blank"); + } + + synchronized (monitor) { + if (domain == Domain.SERVER_LIFECYCLE) { + throw new IllegalArgumentException("SERVER_LIFECYCLE is reserved for terminal operations"); + } + + ActiveOperation currentOperation = restartOperation == null ? mutationOperation : restartOperation; + if (currentOperation != null) { + throw new BusyException(currentOperation); + } + + ActiveOperation operation = new ActiveOperation(nextOperationId++, domain, kind, normalizedTarget); + mutationOperation = operation; + return new LeaseImpl(this, operation); + } + } + + public boolean quiesceForRestart(Runnable callback) { + Runnable restartCallback = Objects.requireNonNull(callback, "callback"); + ActiveOperation operation; + boolean dispatch; + synchronized (monitor) { + if (restartOperation != null) { + return false; + } + + operation = new ActiveOperation( + nextOperationId++, + Domain.SERVER_LIFECYCLE, + OperationKind.SERVER_RESTART, + "server"); + restartOperation = operation; + dispatch = mutationOperation == null; + if (!dispatch) { + pendingRestartCallback = restartCallback; + return true; + } + } + + runRestartCallback(restartCallback, operation); + return true; + } + + public Optional active(Domain domain) { + Objects.requireNonNull(domain, "domain"); + synchronized (monitor) { + if (domain == Domain.SERVER_LIFECYCLE) { + return Optional.ofNullable(restartOperation); + } + if (mutationOperation != null && mutationOperation.domain() == domain) { + return Optional.of(mutationOperation); + } + return Optional.empty(); + } + } + + public Map snapshot() { + synchronized (monitor) { + EnumMap operations = new EnumMap<>(Domain.class); + if (mutationOperation != null) { + operations.put(mutationOperation.domain(), mutationOperation); + } + if (restartOperation != null) { + operations.put(Domain.SERVER_LIFECYCLE, restartOperation); + } + return Collections.unmodifiableMap(operations); + } + } + + public boolean isIdle() { + synchronized (monitor) { + return mutationOperation == null && restartOperation == null; + } + } + + public void whenIdle(Runnable callback) { + Runnable idleCallback = Objects.requireNonNull(callback, "callback"); + synchronized (monitor) { + if (mutationOperation != null || restartOperation != null) { + idleCallbacks.addLast(idleCallback); + return; + } + runIdleCallback(idleCallback); + } + } + + private void release(ActiveOperation operation) { + Runnable restartCallback = null; + ActiveOperation terminalOperation = null; + synchronized (monitor) { + if (!operation.equals(mutationOperation)) { + throw new IllegalStateException("Lifecycle operation lease is not active: " + operation.id()); + } + + mutationOperation = null; + if (restartOperation != null && pendingRestartCallback != null) { + terminalOperation = restartOperation; + restartCallback = pendingRestartCallback; + pendingRestartCallback = null; + } else { + runIdleCallbacks(); + } + } + + if (restartCallback != null) { + runRestartCallback(restartCallback, terminalOperation); + } + } + + private void runIdleCallbacks() { + while (mutationOperation == null && restartOperation == null && !idleCallbacks.isEmpty()) { + runIdleCallback(idleCallbacks.removeFirst()); + } + } + + private void runIdleCallback(Runnable callback) { + try { + callback.run(); + } catch (Throwable failure) { + IrisLogging.reportError("Lifecycle idle callback failed.", failure); + } + } + + private void runRestartCallback(Runnable callback, ActiveOperation operation) { + try { + callback.run(); + } catch (Throwable failure) { + IrisLogging.reportError("Lifecycle restart dispatch failed for operation " + operation.id() + ".", failure); + } + } + + public enum Domain { + WORLD_MUTATION, + PACK_MUTATION, + SERVER_LIFECYCLE + } + + public enum OperationKind { + WORLD_CREATE, + WORLD_LOAD, + WORLD_UNLOAD, + WORLD_REMOVE, + WORLD_PROMOTE, + STUDIO_OPEN, + STUDIO_CLOSE, + PACK_CREATE, + PACK_DOWNLOAD, + PACK_PUBLISH, + DATAPACK_COMPILE, + SERVER_RESTART + } + + public record ActiveOperation(long id, Domain domain, OperationKind kind, String target) { + public ActiveOperation { + if (id < 1L) { + throw new IllegalArgumentException("id must be positive"); + } + Objects.requireNonNull(domain, "domain"); + Objects.requireNonNull(kind, "kind"); + target = Objects.requireNonNull(target, "target").trim(); + if (target.isEmpty()) { + throw new IllegalArgumentException("target must not be blank"); + } + } + } + + public static final class BusyException extends IllegalStateException { + private final ActiveOperation currentOperation; + + private BusyException(ActiveOperation currentOperation) { + super("Lifecycle domain " + currentOperation.domain() + + " is busy with " + currentOperation.kind() + + " target=" + currentOperation.target() + + " id=" + currentOperation.id()); + this.currentOperation = currentOperation; + } + + public ActiveOperation currentOperation() { + return currentOperation; + } + + public long operationId() { + return currentOperation.id(); + } + + public Domain domain() { + return currentOperation.domain(); + } + + public OperationKind operationKind() { + return currentOperation.kind(); + } + + public String target() { + return currentOperation.target(); + } + } + + public interface Lease extends AutoCloseable { + ActiveOperation operation(); + + boolean isClosed(); + + @Override + void close(); + } + + private static final class LeaseImpl implements Lease { + private final LifecycleOperationCoordinator coordinator; + private final ActiveOperation operation; + private final AtomicBoolean closed; + + private LeaseImpl(LifecycleOperationCoordinator coordinator, ActiveOperation operation) { + this.coordinator = coordinator; + this.operation = operation; + closed = new AtomicBoolean(); + } + + @Override + public ActiveOperation operation() { + return operation; + } + + @Override + public boolean isClosed() { + return closed.get(); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + coordinator.release(operation); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java index 8aa47e3f3..ffbc61a3d 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java @@ -81,8 +81,8 @@ final class PaperLikeRuntimeBackend implements WorldLifecycleBackend { } @Override - public boolean unload(World world, boolean save) { - return WorldLifecycleSupport.unloadWorld(capabilities, world, save); + public CompletableFuture unloadAsync(World world, boolean save) { + return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save); } @Override diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java index 67e103e3f..9b3d39124 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java @@ -9,7 +9,7 @@ public interface WorldLifecycleBackend { CompletableFuture create(WorldLifecycleRequest request); - boolean unload(World world, boolean save); + CompletableFuture unloadAsync(World world, boolean save); String backendName(); } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java index 41c1ecac2..8495b45d7 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java @@ -1,6 +1,8 @@ package art.arcane.iris.core.lifecycle; +import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.bukkit.WorldIdentity; import org.bukkit.NamespacedKey; @@ -8,25 +10,43 @@ import org.bukkit.World; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; public final class WorldLifecycleService { + private static final long UNLOAD_TIMEOUT_SECONDS = 120L; private static volatile WorldLifecycleService instance; private final CapabilitySnapshot capabilities; - private final WorldsProviderBackend worldsProviderBackend; - private final PaperLikeRuntimeBackend paperLikeRuntimeBackend; - private final BukkitPublicBackend bukkitPublicBackend; + private final WorldLifecycleBackend worldsProviderBackend; + private final WorldLifecycleBackend paperLikeRuntimeBackend; + private final WorldLifecycleBackend bukkitPublicBackend; private final List backends; private final Map worldBackendByKey; public WorldLifecycleService(CapabilitySnapshot capabilities) { - this.capabilities = capabilities; - this.worldsProviderBackend = new WorldsProviderBackend(capabilities); - this.paperLikeRuntimeBackend = new PaperLikeRuntimeBackend(capabilities); - this.bukkitPublicBackend = new BukkitPublicBackend(capabilities); + this( + capabilities, + new WorldsProviderBackend(capabilities), + new PaperLikeRuntimeBackend(capabilities), + new BukkitPublicBackend(capabilities) + ); + } + + WorldLifecycleService( + CapabilitySnapshot capabilities, + WorldLifecycleBackend worldsProviderBackend, + WorldLifecycleBackend paperLikeRuntimeBackend, + WorldLifecycleBackend bukkitPublicBackend + ) { + this.capabilities = Objects.requireNonNull(capabilities, "capabilities"); + this.worldsProviderBackend = Objects.requireNonNull(worldsProviderBackend, "worldsProviderBackend"); + this.paperLikeRuntimeBackend = Objects.requireNonNull(paperLikeRuntimeBackend, "paperLikeRuntimeBackend"); + this.bukkitPublicBackend = Objects.requireNonNull(bukkitPublicBackend, "bukkitPublicBackend"); this.backends = List.of(worldsProviderBackend, paperLikeRuntimeBackend, bukkitPublicBackend); this.worldBackendByKey = new ConcurrentHashMap<>(); } @@ -89,47 +109,83 @@ public final class WorldLifecycleService { } } - public boolean unload(World world, boolean save) { - if (!J.isPrimaryThread()) { - CompletableFuture future = new CompletableFuture<>(); - J.s(() -> { - try { - future.complete(unloadDirect(world, save)); - } catch (Throwable e) { - future.completeExceptionally(e); - } - }); - return future.join(); - } - - return unloadDirect(world, save); - } - - private boolean unloadDirect(World world, boolean save) { - String worldIdentity = WorldIdentity.serialize(world); + public CompletableFuture unloadAsync(World world, boolean save) { + World requiredWorld = Objects.requireNonNull(world, "world"); + String worldIdentity = WorldIdentity.serialize(requiredWorld); + String worldName = requiredWorld.getName(); WorldLifecycleBackend backend = selectUnloadBackend(worldIdentity); IrisLogging.info("WorldLifecycle unload: world=%s, backend=%s", - world.getName(), + worldName, backend.backendName()); - boolean unloaded; + + CompletableFuture unloadFuture; try { - unloaded = backend.unload(world, save); + unloadFuture = backend.unloadAsync(requiredWorld, save); + if (unloadFuture == null) { + throw new IllegalStateException("World lifecycle backend returned no unload completion future."); + } } catch (Throwable e) { - IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + world.getName() - + "\", backend=" + backend.backendName() - + ", family=" + capabilities.serverFamily().id() + ".", e); - if (e instanceof RuntimeException runtimeException) { + unloadFuture = CompletableFuture.failedFuture(e); + } + + CompletableFuture guardedFuture = guardUnloadCompletion(worldName, unloadFuture); + return guardedFuture.whenComplete((unloaded, throwable) -> { + if (throwable != null) { + Throwable cause = WorldLifecycleSupport.unwrap(throwable); + IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + worldName + + "\", backend=" + backend.backendName() + + ", family=" + capabilities.serverFamily().id() + ".", cause); + return; + } + if (Boolean.TRUE.equals(unloaded)) { + worldBackendByKey.remove(worldIdentity, backend.backendName()); + } + }); + } + + private CompletableFuture guardUnloadCompletion( + String worldName, + CompletableFuture unloadFuture + ) { + CompletableFuture guarded = new CompletableFuture<>(); + unloadFuture.whenComplete((unloaded, throwable) -> { + if (throwable == null) { + guarded.complete(Boolean.TRUE.equals(unloaded)); + } else { + guarded.completeExceptionally(WorldLifecycleSupport.unwrap(throwable)); + } + }); + if (!guarded.isDone()) { + CompletableFuture.delayedExecutor(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> { + TimeoutException timeout = new TimeoutException( + "World unload did not settle within " + UNLOAD_TIMEOUT_SECONDS + " seconds for \"" + + worldName + "\"."); + if (!guarded.completeExceptionally(timeout) || IrisToolbelt.isServerStopping()) { + return; + } + ServerConfigurator.restart("World unload timed out for \"" + worldName + "\"."); + }); + } + return guarded; + } + + public boolean unload(World world, boolean save) { + if (J.isPrimaryThread() || (J.isFolia() && WorldLifecycleSupport.isGlobalTickThread())) { + throw new IllegalStateException("WorldLifecycle unload cannot block the primary/global tick thread; use unloadAsync instead."); + } + + try { + return Boolean.TRUE.equals(unloadAsync(world, save).join()); + } catch (CompletionException e) { + Throwable cause = WorldLifecycleSupport.unwrap(e); + if (cause instanceof RuntimeException runtimeException) { throw runtimeException; } - if (e instanceof Error error) { + if (cause instanceof Error error) { throw error; } - throw new IllegalStateException(e); + throw new IllegalStateException(cause); } - if (unloaded) { - worldBackendByKey.remove(worldIdentity); - } - return unloaded; } public String backendNameForWorld(NamespacedKey worldKey) { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java index 5c361d30b..4975af635 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java @@ -5,8 +5,10 @@ import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.INMSBinding; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.bukkit.WorldIdentity; +import art.arcane.volmlib.util.scheduling.FoliaScheduler; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.NamespacedKey; @@ -414,36 +416,83 @@ final class WorldLifecycleSupport { } } - static boolean unloadWorld(CapabilitySnapshot capabilities, World world, boolean save) { + static CompletableFuture unloadWorldAsync(CapabilitySnapshot capabilities, World world, boolean save) { if (world == null) { - return false; + return CompletableFuture.completedFuture(false); } - CompletableFuture asyncUnload = unloadWorldViaAsyncApi(capabilities, world, save); - if (asyncUnload != null) { - return resolveAsyncUnload(asyncUnload); + CompletableFuture result = new CompletableFuture<>(); + Runnable invokeTask = () -> beginUnload(capabilities, world, save, result); + boolean folia = J.isFolia(); + if ((!folia && J.isPrimaryThread()) || (folia && isGlobalTickThread())) { + invokeTask.run(); + return result; } - try { - return Bukkit.unloadWorld(world, save); - } catch (UnsupportedOperationException unsupported) { - if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) { - throw unsupported; + CompletableFuture scheduled = runGlobalAsync(invokeTask); + scheduled.whenComplete((unused, throwable) -> { + if (throwable != null) { + result.completeExceptionally(unwrap(throwable)); } + }); + return result; + } + + private static void beginUnload( + CapabilitySnapshot capabilities, + World world, + boolean save, + CompletableFuture result + ) { + CompletableFuture operation; + try { + operation = unloadWorldViaAsyncApi(capabilities, world, save); + if (operation == null) { + operation = unloadWorldWithoutAsyncApi(capabilities, world, save); + } + } catch (Throwable e) { + result.completeExceptionally(unwrap(e)); + return; } + operation.whenComplete((unloaded, throwable) -> { + if (throwable == null) { + result.complete(Boolean.TRUE.equals(unloaded)); + } else { + result.completeExceptionally(unwrap(throwable)); + } + }); + } + + private static CompletableFuture unloadWorldWithoutAsyncApi( + CapabilitySnapshot capabilities, + World world, + boolean save + ) { + String worldName = world.getName(); try { + try { + return CompletableFuture.completedFuture(Bukkit.unloadWorld(world, save)); + } catch (UnsupportedOperationException unsupported) { + if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) { + return CompletableFuture.failedFuture(unsupported); + } + } + if (save) { world.save(); } - Method getHandleMethod = world.getClass().getMethod("getHandle"); Object serverLevel = getHandleMethod.invoke(world); - closeServerLevel(world, serverLevel); - detachServerLevel(capabilities, serverLevel, world); - return WorldIdentity.resolve(WorldIdentity.key(world)).isEmpty(); + CompletableFuture operation = closeServerLevelAsync(world, serverLevel) + .thenCompose(unused -> detachServerLevelAsync(capabilities, serverLevel, world)) + .thenApply(unused -> WorldIdentity.resolve(WorldIdentity.key(world)).isEmpty()); + return contextualizeUnloadFailure(worldName, operation); } catch (Throwable e) { - throw new IllegalStateException("Failed to unload world \"" + world.getName() + "\" through the selected world lifecycle backend.", unwrap(e)); + return CompletableFuture.failedFuture(new IllegalStateException( + "Failed to unload world \"" + worldName + "\" through the selected world lifecycle backend.", + unwrap(e) + )); } } @@ -452,63 +501,70 @@ final class WorldLifecycleSupport { return null; } + return invokeAsyncUnload( + capabilities.bukkitServer(), + capabilities.unloadWorldAsyncMethod(), + world, + save + ); + } + + static CompletableFuture invokeAsyncUnload( + Object bukkitServer, + Method unloadWorldAsyncMethod, + World world, + boolean save + ) { CompletableFuture callbackFuture = new CompletableFuture<>(); - Runnable invokeTask = () -> { - Consumer callback = result -> callbackFuture.complete(Boolean.TRUE.equals(result)); - try { - capabilities.unloadWorldAsyncMethod().invoke(capabilities.bukkitServer(), world, save, callback); - } catch (Throwable e) { - callbackFuture.completeExceptionally(unwrap(e)); - } - }; - - if (J.isFolia() && !isGlobalTickThread()) { - CompletableFuture scheduled = J.sfut(invokeTask); - if (scheduled == null) { - callbackFuture.completeExceptionally(new IllegalStateException("Failed to schedule global unload task.")); - return callbackFuture; - } - scheduled.whenComplete((unused, throwable) -> { - if (throwable != null) { - callbackFuture.completeExceptionally(unwrap(throwable)); - } - }); - return callbackFuture; + Consumer callback = unloaded -> callbackFuture.complete(Boolean.TRUE.equals(unloaded)); + try { + unloadWorldAsyncMethod.invoke(bukkitServer, world, save, callback); + } catch (Throwable e) { + callbackFuture.completeExceptionally(unwrap(e)); } - - invokeTask.run(); return callbackFuture; } - private static boolean resolveAsyncUnload(CompletableFuture asyncUnload) { - if (J.isPrimaryThread()) { - if (!asyncUnload.isDone()) { - return true; + private static CompletableFuture contextualizeUnloadFailure( + String worldName, + CompletableFuture operation + ) { + CompletableFuture result = new CompletableFuture<>(); + operation.whenComplete((unloaded, throwable) -> { + if (throwable == null) { + result.complete(Boolean.TRUE.equals(unloaded)); + } else { + result.completeExceptionally(new IllegalStateException( + "Failed to unload world \"" + worldName + "\" through the selected world lifecycle backend.", + unwrap(throwable) + )); } - - try { - return Boolean.TRUE.equals(asyncUnload.join()); - } catch (Throwable e) { - throw new IllegalStateException("Failed to consume async world unload result.", unwrap(e)); - } - } - - try { - return Boolean.TRUE.equals(asyncUnload.get(120, TimeUnit.SECONDS)); - } catch (Throwable e) { - throw new IllegalStateException("Failed while waiting for async world unload result.", unwrap(e)); - } + }); + return result; } - private static void closeServerLevel(World world, Object serverLevel) throws Throwable { - Method closeMethod = CapabilityResolution.resolveMethod(serverLevel.getClass(), "close", method -> method.getParameterCount() == 0); + private static CompletableFuture closeServerLevelAsync(World world, Object serverLevel) { + Method closeMethod; + try { + closeMethod = CapabilityResolution.resolveMethod( + serverLevel.getClass(), + "close", + method -> method.getParameterCount() == 0 + ); + } catch (Throwable e) { + return CompletableFuture.failedFuture(unwrap(e)); + } if (closeMethod == null) { - return; + return CompletableFuture.completedFuture(null); } if (!J.isFolia()) { - closeMethod.invoke(serverLevel); - return; + try { + closeMethod.invoke(serverLevel); + return CompletableFuture.completedFuture(null); + } catch (Throwable e) { + return CompletableFuture.failedFuture(unwrap(e)); + } } Location spawn = world.getSpawnLocation(); @@ -524,9 +580,11 @@ final class WorldLifecycleSupport { } }); if (!scheduled) { - throw new IllegalStateException("Failed to schedule region close task for world \"" + world.getName() + "\"."); + return CompletableFuture.failedFuture(new IllegalStateException( + "Failed to schedule region close task for world \"" + world.getName() + "\"." + )); } - closeFuture.get(90, TimeUnit.SECONDS); + return closeFuture.orTimeout(90L, TimeUnit.SECONDS); } @SuppressWarnings({"rawtypes", "unchecked"}) @@ -545,7 +603,11 @@ final class WorldLifecycleSupport { } } - private static void detachServerLevel(CapabilitySnapshot capabilities, Object serverLevel, World world) throws Throwable { + private static CompletableFuture detachServerLevelAsync( + CapabilitySnapshot capabilities, + Object serverLevel, + World world + ) { Runnable detachTask = () -> { try { capabilities.removeLevelMethod().invoke(capabilities.minecraftServer(), serverLevel); @@ -556,15 +618,41 @@ final class WorldLifecycleSupport { }; if (!J.isFolia() || isGlobalTickThread()) { - detachTask.run(); - return; + try { + detachTask.run(); + return CompletableFuture.completedFuture(null); + } catch (Throwable e) { + return CompletableFuture.failedFuture(unwrap(e)); + } } - CompletableFuture detachFuture = J.sfut(detachTask); - if (detachFuture == null) { - throw new IllegalStateException("Failed to schedule global detach task for world \"" + world.getName() + "\"."); + CompletableFuture detachFuture = runGlobalAsync(detachTask); + return detachFuture.orTimeout(15L, TimeUnit.SECONDS); + } + + private static CompletableFuture runGlobalAsync(Runnable task) { + if (!J.isFolia()) { + return J.sfut(task); } - detachFuture.get(15, TimeUnit.SECONDS); + + CompletableFuture result = new CompletableFuture<>(); + Runnable settlement = () -> { + try { + task.run(); + result.complete(null); + } catch (Throwable e) { + result.completeExceptionally(unwrap(e)); + } + }; + + try { + if (!FoliaScheduler.runGlobal(BukkitPlatform.plugin(), settlement)) { + result.completeExceptionally(new IllegalStateException("Failed to schedule global world lifecycle task.")); + } + } catch (Throwable e) { + result.completeExceptionally(unwrap(e)); + } + return result; } static boolean isGlobalTickThread() { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java index 2548b0006..f6ee2d31d 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldsProviderBackend.java @@ -46,8 +46,8 @@ final class WorldsProviderBackend implements WorldLifecycleBackend { } @Override - public boolean unload(World world, boolean save) { - return WorldLifecycleSupport.unloadWorld(capabilities, world, save); + public CompletableFuture unloadAsync(World world, boolean save) { + return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save); } @Override diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java index ab2b10625..3ca784f4b 100644 --- a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java +++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java @@ -22,28 +22,36 @@ import lombok.SneakyThrows; import org.bukkit.Bukkit; import org.bukkit.World; import org.mvplugins.multiverse.core.MultiverseCoreApi; +import org.mvplugins.multiverse.core.utils.result.Attempt; import org.mvplugins.multiverse.core.world.MultiverseWorld; import org.mvplugins.multiverse.core.world.WorldManager; import org.mvplugins.multiverse.core.world.options.ImportWorldOptions; import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions; +import org.mvplugins.multiverse.core.world.reasons.RemoveFailureReason; import java.lang.reflect.Field; public class MultiverseCoreLink { - public void removeFromConfig(World world) { - removeFromConfig(world.getName()); + public boolean removeFromConfig(World world) { + return removeFromConfig(world.getName()); } - public void removeFromConfig(String world) { + public boolean removeFromConfig(String world) { if (!isActive()) { - return; + return false; } WorldManager manager = worldManager(); MultiverseWorld multiverseWorld = manager.getWorld(world).getOrElse((MultiverseWorld) null); if (multiverseWorld == null) { - return; + return false; } - manager.removeWorld(RemoveWorldOptions.world(multiverseWorld)).onSuccess(ignored -> manager.saveWorldsConfig()); + Attempt removal = manager.removeWorld(RemoveWorldOptions.world(multiverseWorld)); + if (removal.isFailure()) { + throw new IllegalStateException("Multiverse refused to remove world \"" + world + "\": " + + removal.getFailureMessage()); + } + manager.saveWorldsConfig().get(); + return true; } @SneakyThrows @@ -72,7 +80,7 @@ public class MultiverseCoreLink { .invoke(config, generator); } - manager.saveWorldsConfig(); + manager.saveWorldsConfig().get(); } private WorldManager worldManager() { diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 6cc1703d7..8481792fe 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -31,6 +31,7 @@ import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; @@ -244,15 +245,14 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } } - for (File i : Objects.requireNonNull(IrisPlatforms.get().dataFolder("packs").listFiles())) { - if (i.isDirectory()) { - IrisData dm = get(i); - if (dm == nearest) continue; - T t = dm.load(type, key, false); + for (File i : PackDirectoryResolver.listVisiblePackDirectories( + IrisPlatforms.get().dataFolder("packs"))) { + IrisData dm = get(i); + if (dm == nearest) continue; + T t = dm.load(type, key, false); - if (t != null) { - return t; - } + if (t != null) { + return t; } } } catch (Throwable e) { diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index d998db960..79cf39cf4 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -28,6 +28,7 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -106,6 +107,18 @@ public interface INMSBinding { return new KList<>(); } + default JigsawSourceMetadata getJigsawSourceMetadata(String structureKey) { + throw new UnsupportedOperationException("The active NMS binding does not expose registered jigsaw metadata"); + } + + default int getTemplatePoolHorizontalSpan(String templatePoolKey) { + throw new UnsupportedOperationException("The active NMS binding does not expose registered template pool spans"); + } + + default int getJigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) { + return getTemplatePoolHorizontalSpan(templatePoolKey); + } + KList getStructureSetKeys(); KList getReachableStructureKeys(World world); diff --git a/core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java b/core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java index e50bd12bf..dde236c23 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java +++ b/core/src/main/java/art/arcane/iris/core/nms/MinecraftVersion.java @@ -6,7 +6,7 @@ import java.lang.reflect.Method; import java.util.regex.Matcher; import java.util.regex.Pattern; -final class MinecraftVersion { +public final class MinecraftVersion { private static final Pattern DECORATED_VERSION_PATTERN = Pattern.compile("\\(MC: ([0-9]+(?:\\.[0-9]+){0,2})\\)"); private final String value; diff --git a/core/src/main/java/art/arcane/iris/core/pack/AtomicDirectoryPublisher.java b/core/src/main/java/art/arcane/iris/core/pack/AtomicDirectoryPublisher.java new file mode 100644 index 000000000..55fc81784 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/AtomicDirectoryPublisher.java @@ -0,0 +1,113 @@ +package art.arcane.iris.core.pack; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Stream; + +public final class AtomicDirectoryPublisher { + private AtomicDirectoryPublisher() { + } + + public static Publication publish(Path stagedDirectory, Path targetDirectory) throws IOException { + Path staged = Objects.requireNonNull(stagedDirectory, "stagedDirectory").toAbsolutePath().normalize(); + Path target = Objects.requireNonNull(targetDirectory, "targetDirectory").toAbsolutePath().normalize(); + if (!Files.isDirectory(staged) || Files.isSymbolicLink(staged)) { + throw new IOException("Staged directory is missing or unsafe: " + staged); + } + if (!Objects.equals(staged.getParent(), target.getParent())) { + throw new IOException("Staged and target directories must have the same parent."); + } + + Path backup = null; + if (Files.exists(target) || Files.isSymbolicLink(target)) { + backup = target.resolveSibling("." + target.getFileName() + ".backup-" + UUID.randomUUID()); + move(target, backup); + } + try { + move(staged, target); + return new Publication(target, backup); + } catch (IOException failure) { + if (backup != null && (Files.exists(backup) || Files.isSymbolicLink(backup))) { + try { + move(backup, target); + } catch (IOException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + throw failure; + } + } + + private static void move(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(source, target); + } + } + + public static final class Publication implements AutoCloseable { + private final Path target; + private final Path backup; + private boolean committed; + private boolean closed; + + private Publication(Path target, Path backup) { + this.target = target; + this.backup = backup; + } + + public synchronized void commit() { + if (closed) { + throw new IllegalStateException("Directory publication is already closed."); + } + committed = true; + } + + public synchronized void cleanupBackup() throws IOException { + if (!committed) { + throw new IllegalStateException("Directory publication is not committed."); + } + if (backup != null) { + deleteTree(backup); + } + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + if (committed) { + return; + } + + deleteTree(target); + if (backup != null && (Files.exists(backup) || Files.isSymbolicLink(backup))) { + move(backup, target); + } + } + } + + public static void deleteTree(Path path) throws IOException { + if (!Files.exists(path) && !Files.isSymbolicLink(path)) { + return; + } + if (Files.isSymbolicLink(path) || !Files.isDirectory(path)) { + Files.delete(path); + return; + } + try (Stream stream = Files.walk(path)) { + for (Path entry : stream.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(entry); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java b/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java index 40c429637..2d8dcef65 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java @@ -1,9 +1,16 @@ package art.arcane.iris.core.pack; import java.io.File; +import java.io.IOException; +import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; public final class PackDirectoryResolver { private PackDirectoryResolver() { @@ -18,9 +25,103 @@ public final class PackDirectoryResolver { if (!root.equals(candidate.getParent())) { return null; } - if (Files.isSymbolicLink(candidate) || !Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) { + if (!isVisiblePackDirectory(candidate.toFile())) { return null; } return candidate.toFile(); } + + public static List listVisiblePackDirectories(File packsRoot) { + try { + return listVisiblePackDirectoriesOrThrow(packsRoot); + } catch (IOException exception) { + return List.of(); + } + } + + public static List listVisiblePackDirectoriesOrThrow(File packsRoot) throws IOException { + if (packsRoot == null) { + return List.of(); + } + Path root = packsRoot.toPath().toAbsolutePath().normalize(); + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return List.of(); + } + if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Pack workspace is missing or unsafe: " + root); + } + try (Stream stream = Files.list(root)) { + return stream + .map(Path::toFile) + .filter(PackDirectoryResolver::isVisiblePackDirectory) + .sorted(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER) + .thenComparing(File::getName)) + .toList(); + } + } + + public static boolean isVisiblePackDirectory(File candidate) { + if (candidate == null || isHiddenName(candidate.getName())) { + return false; + } + Path path = candidate.toPath(); + return Files.isDirectory(path); + } + + public static void requireSafePackTree(File candidate) throws IOException { + if (!isVisiblePackDirectory(candidate)) { + throw new IOException("Pack directory is missing or unsafe: " + candidate); + } + Path root = candidate.toPath().toAbsolutePath().normalize().toRealPath(); + if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Pack directory target is missing or unsafe: " + candidate); + } + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) { + if (!directory.equals(root) && isHiddenName(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { + throw new IOException("Pack directory contains a symbolic link: " + file); + } + if (!attributes.isRegularFile()) { + throw new IOException("Pack directory contains an unsupported entry: " + file); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + throw new IOException("Unable to inspect pack entry: " + file, failure); + } + }); + } + + public static boolean containsHiddenPathSegment(Path root, Path candidate) { + if (root == null || candidate == null) { + return true; + } + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path normalizedCandidate = candidate.toAbsolutePath().normalize(); + if (!normalizedCandidate.startsWith(normalizedRoot)) { + return true; + } + Path relative = normalizedRoot.relativize(normalizedCandidate); + for (Path segment : relative) { + if (isHiddenName(segment.toString())) { + return true; + } + } + return false; + } + + public static boolean isHiddenName(String name) { + return name != null && name.startsWith("."); + } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java index 6c04d67e5..b636ebfa1 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java @@ -23,18 +23,33 @@ import art.arcane.iris.core.localization.PackDownloadMessages; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.util.common.misc.WebCache; -import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.localization.MessageArgument; -import org.zeroturnaround.zip.ZipUtil; import org.zeroturnaround.zip.commons.FileUtils; import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.regex.Pattern; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; public final class PackDownloader { private static final String DEFAULT_OVERWORLD_PACK = "overworld"; @@ -43,7 +58,14 @@ public final class PackDownloader { private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+"); private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*"); private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}"); - private static final ConcurrentHashMap DOWNLOAD_LOCKS = new ConcurrentHashMap<>(); + private static final Pattern PACK_KEY = Pattern.compile("[a-z0-9_-]+"); + private static final ArchiveLimits ARCHIVE_LIMITS = new ArchiveLimits( + 512L * 1024L * 1024L, + 100_000, + 2L * 1024L * 1024L * 1024L, + 256L * 1024L * 1024L + ); + private static final ConcurrentHashMap DOWNLOAD_LOCKS = new ConcurrentHashMap<>(); private PackDownloader() { } @@ -63,15 +85,37 @@ public final class PackDownloader { * partial import (an interrupted copy) and counts as absent so it can be replaced. */ public static boolean isPackPresent(File packsFolder, String key) { - if (packsFolder == null || key == null || key.isBlank()) { + if (packsFolder == null || !isSafePackKey(key)) { + return false; + } + Path packsRoot = packsFolder.toPath().toAbsolutePath().normalize(); + File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key); + if (resolvedPack == null) { + return false; + } + try { + PackDirectoryResolver.requireSafePackTree(resolvedPack); + } catch (IOException exception) { + return false; + } + Path pack = resolvedPack.toPath().toAbsolutePath().normalize(); + Path dimensions = pack.resolve("dimensions"); + if (!Objects.equals(pack.getParent(), packsRoot) + || Files.isSymbolicLink(dimensions) + || !Files.isDirectory(pack) + || !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + try (Stream entries = Files.list(dimensions)) { + return entries.anyMatch(path -> path.getFileName().toString().endsWith(".json") + && !Files.isSymbolicLink(path) + && Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)); + } catch (IOException exception) { return false; } - File[] dimensions = new File(new File(packsFolder, key), "dimensions") - .listFiles((File dir, String name) -> name.endsWith(".json")); - return dimensions != null && dimensions.length > 0; } - public static String downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer feedback) throws IOException { + public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer feedback) throws IOException { return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback); } @@ -82,136 +126,414 @@ public final class PackDownloader { * per-repo lock keeps concurrent startup triggers (async default-pack install racing world * resolution) from downloading the same archive twice. */ - public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, String expectedKey, Consumer feedback) throws IOException { - // Lock on the destination pack key when known: concurrent triggers for the same pack can - // arrive with different refs (release URL vs listing branch) and must still serialize. - String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref; - Object lock = DOWNLOAD_LOCKS.computeIfAbsent(lockKey, key -> new Object()); - synchronized (lock) { - if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); - return expectedKey; - } - return downloadLocked(packsFolder, repo, ref, forceOverwrite, directUrl, feedback); + public static PackInstallResult download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, String expectedKey, Consumer feedback) throws IOException { + Objects.requireNonNull(packsFolder, "packsFolder"); + Consumer output = feedback == null ? ignored -> { + } : feedback; + if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) { + throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'"); } + String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref; + return withDownloadLock(lockKey, () -> { + if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) { + sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); + return new PackInstallResult(expectedKey, false, false); + } + return downloadLocked(packsFolder, repo, ref, forceOverwrite, directUrl, expectedKey, lockKey, output); + }); } - private static String downloadLocked(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer feedback) throws IOException { + private static PackInstallResult downloadLocked(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, + String expectedKey, String heldLockKey, Consumer feedback) throws IOException { String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref); - feedback.accept(IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL - File zip = WebCache.getNonCachedFile("pack-" + repo, url); + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " "); + File zip = WebCache.getNonCachedFile("pack-" + repo, url, ARCHIVE_LIMITS.maxArchiveBytes()); File temp = WebCache.getTemp(); File work = new File(temp, "dl-" + UUID.randomUUID()); - if (zip == null || !zip.exists()) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url))); - feedback.accept(IrisLanguage.plain(PackDownloadMessages.CHECK_REPOSITORY_AND_BRANCH)); - feedback.accept(IrisLanguage.plain(PackDownloadMessages.EXAMPLE_COMMAND)); - return null; - } - feedback.accept(IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", repo))); try { - ZipUtil.unpack(zip, work); - } catch (Throwable e) { - IrisLogging.reportError(e); - feedback.accept(IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED)); - IO.delete(work); - return null; - } - File dir = null; - File[] zipFiles = work.listFiles(); - - if (zipFiles == null) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES)); - return null; - } - - try { - dir = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null; - } catch (NullPointerException e) { - IrisLogging.reportError(e); - feedback.accept(IrisLanguage.plain(PackDownloadMessages.HOME_DIRECTORY_ERROR)); - return null; - } - - if (dir == null) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT)); - return null; - } - - IrisData data = IrisData.get(dir); - String[] dimensions = data.getDimensionLoader().getPossibleKeys(); - - if (dimensions == null || dimensions.length == 0) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.NO_DIMENSION_FILE)); - feedback.accept(IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB)); - return null; - } - - if (dimensions.length != 1) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED)); - return null; - } - - IrisDimension d = data.getDimensionLoader().load(dimensions[0]); - data.close(); - - if (d == null) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION)); - return null; - } - - String key = d.getLoadKey(); - feedback.accept(IrisLanguage.plain(PackDownloadMessages.IMPORTING, MessageArgument.untrusted("name", d.getName()), MessageArgument.untrusted("key", key))); - File packEntry = new File(packsFolder, key); - File[] staleStaging = packsFolder.listFiles((File parent, String name) -> name.startsWith(key + ".importing-")); - if (staleStaging != null) { - for (File stale : staleStaging) { - IO.delete(stale); - } - } - - if (forceOverwrite) { - IO.delete(packEntry); - } - - if (IrisData.loadAnyDimension(key, null) != null) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.DIMENSION_KEY_CONFLICT, MessageArgument.untrusted("key", key))); - return null; - } - - File[] existingEntries = packEntry.listFiles(); - if (packEntry.exists() && existingEntries != null && existingEntries.length > 0) { - if (isPackPresent(packsFolder, key)) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.PACK_KEY_CONFLICT, MessageArgument.untrusted("key", key))); + if (zip == null || !zip.exists()) { + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url))); + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_REPOSITORY_AND_BRANCH)); + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.EXAMPLE_COMMAND)); return null; } - // Non-empty but no dimension file: a partial import from an interrupted copy. - // Replace it instead of refusing forever. - IrisLogging.warn("Replacing partial pack folder " + packEntry.getPath() + " (no dimension files found)."); - IO.delete(packEntry); - } - - // Stage inside the packs folder and move into place so packs/ is never partial: - // an interrupted copy previously left a folder without dimensions/, which then blocked - // every future import as a key conflict. - File staging = new File(packsFolder, key + ".importing-" + UUID.randomUUID()); - try { - FileUtils.copyDirectory(dir, staging); - if (!staging.renameTo(packEntry)) { - throw new IOException("Unable to move imported pack into place: " + packEntry.getPath()); + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", repo))); + try { + unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS); + } catch (IOException exception) { + IrisLogging.reportError(exception); + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED)); + return null; } - } catch (IOException | RuntimeException e) { - IO.delete(staging); - throw e; + File[] zipFiles = work.listFiles(); + if (zipFiles == null) { + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES)); + return null; + } + File directory; + try { + directory = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null; + } catch (NullPointerException exception) { + IrisLogging.reportError(exception); + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.HOME_DIRECTORY_ERROR)); + return null; + } + if (directory == null) { + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT)); + return null; + } + return installExtractedPack(packsFolder, directory, forceOverwrite, expectedKey, heldLockKey, feedback); + } finally { + deleteDirectory(work); + } + } + + static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite, + String expectedKey, Consumer feedback) throws IOException { + Objects.requireNonNull(packsFolder, "packsFolder"); + Objects.requireNonNull(extractedPack, "extractedPack"); + Consumer output = feedback == null ? ignored -> { + } : feedback; + return installExtractedPack(packsFolder, extractedPack, forceOverwrite, expectedKey, null, output); + } + + private static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite, + String expectedKey, String heldLockKey, Consumer feedback) throws IOException { + if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) { + throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'"); + } + Path packsRoot = packsFolder.toPath().toAbsolutePath().normalize(); + Files.createDirectories(packsRoot); + Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID()); + try { + FileUtils.copyDirectory(extractedPack, staging.toFile()); + PreparedPack prepared = prepareStagedPack(staging.toFile(), expectedKey, feedback); + if (prepared == null) { + return null; + } + String destinationLockKey = "key:" + prepared.key(); + if (destinationLockKey.equals(heldLockKey)) { + return publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback); + } + return withDownloadLock(destinationLockKey, + () -> publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback)); + } finally { + deleteDirectory(staging.toFile()); + } + } + + private static PreparedPack prepareStagedPack(File staging, String expectedKey, + Consumer feedback) throws IOException { + IrisData data = IrisData.openDatapackCompiler(staging); + String key; + String name; + try { + String[] dimensions = data.getDimensionLoader().getPossibleKeys(); + if (dimensions == null || dimensions.length == 0) { + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_DIMENSION_FILE)); + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB)); + return null; + } + if (dimensions.length != 1) { + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED)); + return null; + } + IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]); + if (dimension == null) { + sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION)); + return null; + } + key = dimension.getLoadKey(); + name = dimension.getName(); + } finally { + data.close(); + } + if (!isSafePackKey(key)) { + throw new IOException("Downloaded pack has unsafe dimension key '" + key + "'"); + } + if (expectedKey != null && !expectedKey.isBlank() && !expectedKey.equals(key)) { + throw new IOException("Downloaded pack key '" + key + "' does not match requested key '" + expectedKey + "'"); } - IrisData.getLoaded(packEntry) - .ifPresent(IrisData::hotloaded); + PackValidationResult stagedValidation; + try { + stagedValidation = PackValidator.validate(staging); + } catch (RuntimeException exception) { + throw new IOException("Pack validation failed before publication for '" + key + "'", exception); + } + PackValidationResult validation = new PackValidationResult( + key, + stagedValidation.getBlockingErrors(), + stagedValidation.getWarnings(), + stagedValidation.getValidatedAtMillis() + ); + if (!validation.isLoadable()) { + sendValidationFeedback(validation, feedback); + return null; + } + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.IMPORTING, + MessageArgument.untrusted("name", name), + MessageArgument.untrusted("key", key) + )); + return new PreparedPack(key, name, validation); + } - feedback.accept(IrisLanguage.plain(PackDownloadMessages.ACQUIRED, MessageArgument.untrusted("name", d.getName()))); - validateDownloaded(packEntry, feedback); - return key; + private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared, + boolean forceOverwrite, Consumer feedback) throws IOException { + Path target = packsRoot.resolve(prepared.key()).normalize(); + if (!Objects.equals(target.getParent(), packsRoot)) { + throw new IOException("Pack target escapes the packs folder: " + target); + } + if (Files.isSymbolicLink(target)) { + sendFeedback(feedback, "Pack '" + prepared.key() + "' is a symbolic-link source and cannot be replaced by Iris."); + return null; + } + + Path conflictingPack = findConflictingPack(packsRoot, staging, target, prepared.key()); + if (conflictingPack != null) { + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.DIMENSION_KEY_CONFLICT, + MessageArgument.untrusted("key", prepared.key()) + )); + return null; + } + if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) { + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.PACK_KEY_CONFLICT, + MessageArgument.untrusted("key", prepared.key()) + )); + return null; + } + if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) { + IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found)."); + } + + Optional loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key())); + if (loadedData.isEmpty()) { + loadedData = IrisData.getLoaded(target.toFile()); + } + if (loadedData.isPresent()) { + sendFeedback( + feedback, + "Pack '" + prepared.key() + "' is active and cannot be replaced safely. Unload its worlds before retrying." + ); + return null; + } + try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) { + publication.commit(); + try { + publication.cleanupBackup(); + } catch (IOException exception) { + IrisLogging.reportError( + "Pack '" + prepared.key() + "' was published, but its transaction backup could not be cleaned.", + exception + ); + } + } + PackValidationRegistry.publish(prepared.validation()); + sendValidationFeedback(prepared.validation(), feedback); + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.ACQUIRED, + MessageArgument.untrusted("name", prepared.name()) + )); + return new PackInstallResult(prepared.key(), true, false); + } + + private static Path findConflictingPack(Path packsRoot, Path staging, Path target, String key) throws IOException { + Set roots = new LinkedHashSet<>(); + roots.add(packsRoot); + if (IrisPlatforms.isBound()) { + roots.add(IrisPlatforms.get().dataFolder("packs").toPath().toAbsolutePath().normalize()); + } + for (Path root : roots) { + if (!Files.isDirectory(root)) { + continue; + } + try (Stream entries = Files.list(root)) { + List candidates = entries.toList(); + for (Path candidate : candidates) { + Path normalized = candidate.toAbsolutePath().normalize(); + String candidateName = candidate.getFileName().toString(); + if (normalized.equals(staging) + || normalized.equals(target) + || PackDirectoryResolver.isHiddenName(candidateName) + || !PackDirectoryResolver.isVisiblePackDirectory(candidate.toFile())) { + continue; + } + Path dimension = candidate.resolve("dimensions").resolve(key + ".json"); + if (Files.isRegularFile(dimension)) { + return normalized; + } + } + } + } + return null; + } + + private static boolean isSafePackKey(String key) { + return key != null && PACK_KEY.matcher(key).matches(); + } + + private static PackInstallResult withDownloadLock(String key, DownloadOperation operation) throws IOException { + DownloadLock lock = DOWNLOAD_LOCKS.compute(key, (ignored, existing) -> { + DownloadLock selected = existing == null ? new DownloadLock() : existing; + selected.references++; + return selected; + }); + try { + synchronized (lock) { + return operation.run(); + } + } finally { + DOWNLOAD_LOCKS.computeIfPresent(key, (ignored, existing) -> { + if (existing != lock) { + return existing; + } + existing.references--; + return existing.references == 0 ? null : existing; + }); + } + } + + static int downloadLockCount() { + return DOWNLOAD_LOCKS.size(); + } + + static void unpackArchive(Path archive, Path destination, ArchiveLimits limits) throws IOException { + Path source = Objects.requireNonNull(archive, "archive").toAbsolutePath().normalize(); + Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize(); + ArchiveLimits safety = Objects.requireNonNull(limits, "limits"); + if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Pack archive is missing or unsafe: " + source); + } + if (Files.size(source) > safety.maxArchiveBytes()) { + throw new IOException("Pack archive exceeds the compressed size limit."); + } + if (Files.exists(root, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Pack extraction target is not a directory: " + root); + } + Files.createDirectories(root); + if (Files.isSymbolicLink(root)) { + throw new IOException("Pack extraction target is unsafe: " + root); + } + + int entryCount = 0; + long expandedBytes = 0L; + Set paths = new HashSet<>(); + try (InputStream input = Files.newInputStream(source); ZipInputStream zip = new ZipInputStream(input)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + entryCount++; + if (entryCount > safety.maxEntries()) { + throw new IOException("Pack archive contains too many entries."); + } + String normalizedName = normalizeArchiveEntry(entry.getName()); + String collisionKey = normalizedName.toLowerCase(Locale.ROOT); + if (!paths.add(collisionKey)) { + throw new IOException("Pack archive contains a duplicate path: " + normalizedName); + } + Path output = root.resolve(normalizedName).normalize(); + if (!output.startsWith(root)) { + throw new IOException("Pack archive entry escapes extraction: " + entry.getName()); + } + if (entry.isDirectory()) { + Files.createDirectories(output); + zip.closeEntry(); + continue; + } + long declaredSize = entry.getSize(); + if (declaredSize > safety.maxEntryBytes()) { + throw new IOException("Pack archive entry exceeds the file size limit: " + normalizedName); + } + Files.createDirectories(output.getParent()); + long entryBytes = 0L; + try (OutputStream file = Files.newOutputStream(output, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = zip.read(buffer)) != -1) { + if (read == 0) { + continue; + } + entryBytes += read; + expandedBytes += read; + if (entryBytes > safety.maxEntryBytes()) { + throw new IOException("Pack archive entry exceeds the file size limit: " + normalizedName); + } + if (expandedBytes > safety.maxExpandedBytes()) { + throw new IOException("Pack archive expands beyond the safety limit."); + } + file.write(buffer, 0, read); + } + } + zip.closeEntry(); + } + } + if (entryCount == 0) { + throw new IOException("Pack archive is empty."); + } + } + + private static String normalizeArchiveEntry(String rawName) throws IOException { + if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0 + || rawName.startsWith("/") || rawName.startsWith("\\")) { + throw new IOException("Pack archive contains an invalid path."); + } + String slashNormalized = rawName.replace('\\', '/'); + if (slashNormalized.matches("^[A-Za-z]:.*")) { + throw new IOException("Pack archive contains an unsafe path: " + rawName); + } + Path normalized = Path.of(slashNormalized).normalize(); + String result = normalized.toString().replace('\\', '/'); + if (normalized.isAbsolute() || normalized.startsWith("..") || result.isBlank() || ".".equals(result)) { + throw new IOException("Pack archive contains an unsafe path: " + rawName); + } + return result; + } + + private static void deleteDirectory(File directory) { + try { + AtomicDirectoryPublisher.deleteTree(directory.toPath()); + } catch (IOException exception) { + IrisLogging.reportError("Failed to clean temporary pack directory '" + directory.getPath() + "'", exception); + } + } + + private static void sendFeedback(Consumer feedback, String message) { + try { + feedback.accept(message); + } catch (RuntimeException exception) { + IrisLogging.reportError("Pack download feedback delivery failed", exception); + } + } + + private static void sendValidationFeedback(PackValidationResult result, Consumer feedback) { + if (!result.isLoadable()) { + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.VALIDATION_FAILED, + MessageArgument.untrusted("pack", result.getPackName()) + )); + for (String reason : result.getBlockingErrors()) { + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.VALIDATION_REASON, + MessageArgument.untrusted("reason", reason) + )); + } + return; + } + if (!result.getWarnings().isEmpty()) { + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.VALIDATED_WITH_WARNINGS, + MessageArgument.untrusted("pack", result.getPackName()), + MessageArgument.trusted("count", result.getWarnings().size()) + )); + return; + } + sendFeedback(feedback, IrisLanguage.plain( + PackDownloadMessages.VALIDATED, + MessageArgument.untrusted("pack", result.getPackName()) + )); } static String resolveGithubArchiveUrl(String repo, String ref) { @@ -229,6 +551,9 @@ public final class PackDownloader { if (COMMIT_SHA.matcher(ref).matches()) { return "https://github.com/" + repo + "/archive/" + ref + ".zip"; } + if ("HEAD".equals(ref)) { + return "https://github.com/" + repo + "/archive/HEAD.zip"; + } if (ref.startsWith("refs/") && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) { throw new IllegalArgumentException("Unsupported GitHub reference '" + ref + "'"); } @@ -269,27 +594,26 @@ public final class PackDownloader { } } - private static void validateDownloaded(File packEntry, Consumer feedback) { - try { - PackValidationResult result = PackValidator.validate(packEntry); - PackValidationRegistry.publish(result); + private record PreparedPack(String key, String name, PackValidationResult validation) { + } - if (!result.isLoadable()) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATION_FAILED, MessageArgument.untrusted("pack", result.getPackName()))); - for (String reason : result.getBlockingErrors()) { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATION_REASON, MessageArgument.untrusted("reason", reason))); - } - } else if (!result.getWarnings().isEmpty()) { - feedback.accept(IrisLanguage.plain( - PackDownloadMessages.VALIDATED_WITH_WARNINGS, - MessageArgument.untrusted("pack", result.getPackName()), - MessageArgument.trusted("count", result.getWarnings().size()) - )); - } else { - feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATED, MessageArgument.untrusted("pack", result.getPackName()))); + public record PackInstallResult(String key, boolean changed, boolean restartRequired) { + } + + record ArchiveLimits(long maxArchiveBytes, int maxEntries, long maxExpandedBytes, long maxEntryBytes) { + ArchiveLimits { + if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) { + throw new IllegalArgumentException("Archive limits must be positive."); } - } catch (Throwable e) { - IrisLogging.reportError("Pack validation failed for '" + packEntry.getName() + "'", e); } } + + @FunctionalInterface + private interface DownloadOperation { + PackInstallResult run() throws IOException; + } + + private static final class DownloadLock { + private int references; + } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackObjectSurfaceValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackObjectSurfaceValidator.java index 93edea8e8..7b7e8c8a6 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackObjectSurfaceValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackObjectSurfaceValidator.java @@ -117,6 +117,10 @@ final class PackObjectSurfaceValidator { } static List validateStructureGraph(File packFolder) { + return validateStructureGraph(packFolder, true); + } + + static List validateStructureGraph(File packFolder, boolean validateLiveRegistries) { List blockingErrors = new ArrayList<>(); if (packFolder == null || !packFolder.isDirectory()) { return blockingErrors; @@ -131,7 +135,8 @@ final class PackObjectSurfaceValidator { Set pieceKeys = ContentKeyValidator.deriveRegistrantKeysExact(piecesFolder); Set objectKeys = ContentKeyValidator.deriveObjectKeysExact(objectsFolder); - PackStructurePlacementValidator.validateStructurePlacements(packFolder, structureKeys, blockingErrors); + PackStructurePlacementValidator.validateStructurePlacements( + packFolder, structureKeys, validateLiveRegistries, blockingErrors); PackStructurePlacementValidator.validateStructureStartPools(structuresFolder, poolKeys, blockingErrors); PackStructurePlacementValidator.validateJigsawPools(poolsFolder, poolKeys, pieceKeys, blockingErrors); PackStructurePlacementValidator.validateJigsawPieces(piecesFolder, poolKeys, objectKeys, blockingErrors); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java index 1e16f5038..1e3235537 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java @@ -18,7 +18,10 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; @@ -26,22 +29,32 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; final class PackStructurePlacementValidator { + private static final Set TERRAIN_ENVELOPE_MODES = Set.of( + "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"); + private PackStructurePlacementValidator() { } static void validateStructurePlacements(File packFolder, Set structureKeys, + boolean validateLiveRegistries, List blockingErrors) { - Set registeredStructures = registeredStructureKeys(); - Set registeredJigsaws = registeredJigsawKeys(); - Set registeredPools = registeredTemplatePoolKeys(); + RegistrySnapshot registries = registrySnapshot(validateLiveRegistries, blockingErrors); + if (registries == null) { + return; + } + Map placementIds = new HashMap<>(); + Map anonymousGridIdentities = new HashMap<>(); for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) { File resourceFolder = new File(packFolder, folderName); if (!resourceFolder.isDirectory()) { @@ -55,22 +68,42 @@ final class PackStructurePlacementValidator { if (resource == null) { continue; } + String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile); + if (PackValidator.DIMENSIONS_FOLDER.equals(folderName)) { + validateImportedStructureAdjustmentEnvelopes( + resourceKey, resource, registries, blockingErrors); + } + Object rawPlacements = resource.opt("structures"); JSONArray placements = resource.optJSONArray("structures"); if (placements == null) { + if (resource.has("structures") && rawPlacements != JSONObject.NULL) { + blockingErrors.add(resourceType + " '" + resourceKey + "'.structures must be an array."); + } continue; } - String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile); for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) { JSONObject placement = placements.optJSONObject(placementIndex); - if (placement == null) { - continue; - } - JSONArray references = placement.optJSONArray("structures"); - JSONArray nativeStructures = placement.optJSONArray("nativeStructures"); - boolean hasIrisStructures = references != null && references.length() > 0; - boolean hasNativeStructures = nativeStructures != null && nativeStructures.length() > 0; String placementPath = resourceType + " '" + resourceKey + "' structures[" + placementIndex + "]"; + if (placement == null) { + blockingErrors.add(placementPath + " must be an object."); + continue; + } + validatePlacementConfiguration(placementPath, placement, blockingErrors); + validatePlacementIdentity( + placementPath, placement, placementIds, anonymousGridIdentities, blockingErrors); + JSONArray references = placement.optJSONArray("structures"); + JSONArray nativeStructures = placement.optJSONArray("nativeStructures"); + if (placement.has("structures") && placement.opt("structures") != JSONObject.NULL + && references == null) { + blockingErrors.add(placementPath + ".structures must be an array."); + } + if (placement.has("nativeStructures") && placement.opt("nativeStructures") != JSONObject.NULL + && nativeStructures == null) { + blockingErrors.add(placementPath + ".nativeStructures must be an array."); + } + boolean hasIrisStructures = references != null && references.length() > 0; + boolean hasNativeStructures = nativeStructures != null && nativeStructures.length() > 0; if (hasIrisStructures == hasNativeStructures) { blockingErrors.add(placementPath + " must declare exactly one non-empty backend: structures or nativeStructures."); @@ -78,14 +111,24 @@ final class PackStructurePlacementValidator { } if (hasNativeStructures) { validateNativeStructures( - placementPath, placement, nativeStructures, - registeredStructures, registeredJigsaws, - registeredPools, blockingErrors); + placementPath, nativeStructures, + registries.structures(), registries.jigsaws(), + registries.pools(), registries.jigsawMetadataResolver(), + registries.hooks(), blockingErrors); continue; } + Set editableStructureKeys = new HashSet<>(); for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) { Object rawReference = references.opt(referenceIndex); if (!(rawReference instanceof String structureKey) || structureKey.isBlank()) { + blockingErrors.add(placementPath + ".structures[" + referenceIndex + + "] must name a non-blank Iris structure."); + continue; + } + String normalizedStructureKey = structureKey.trim().toLowerCase(Locale.ROOT); + if (!editableStructureKeys.add(normalizedStructureKey)) { + blockingErrors.add(placementPath + ".structures[" + referenceIndex + + "] duplicates Iris structure '" + structureKey + "'."); continue; } if (!structureKeys.contains(structureKey)) { @@ -99,66 +142,240 @@ final class PackStructurePlacementValidator { } } - private static Set registeredJigsawKeys() { + private static RegistrySnapshot registrySnapshot(boolean validateLiveRegistries, + List blockingErrors) { + if (!validateLiveRegistries || !IrisPlatforms.isBound()) { + return new RegistrySnapshot(Set.of(), Set.of(), Set.of(), null, null); + } try { - List registered = IrisPlatforms.get().structureHooks().jigsawStructureKeys(); - if (registered == null || registered.isEmpty()) { - return Set.of(); + PlatformStructureHooks hooks = IrisPlatforms.get().structureHooks(); + if (hooks == null) { + throw new IllegalStateException("The active platform did not provide structure registry hooks"); } - Set keys = new HashSet<>(); - for (String key : registered) { - if (key != null && !key.isBlank()) { - keys.add(key.toLowerCase(Locale.ROOT)); - } + Set structures = normalizeRegistryKeys( + hooks.structureKeys(), "structure"); + Set jigsaws = normalizeRegistryKeys( + hooks.jigsawStructureKeys(), "jigsaw structure"); + Set pools = normalizeRegistryKeys( + hooks.templatePoolKeys(), "template pool"); + if (structures.isEmpty()) { + throw new IllegalStateException("The active structure registry is empty"); } - return Set.copyOf(keys); - } catch (Throwable ignored) { - return Set.of(); + if (jigsaws.isEmpty()) { + throw new IllegalStateException("The active jigsaw structure registry is empty"); + } + if (pools.isEmpty()) { + throw new IllegalStateException("The active template pool registry is empty"); + } + return new RegistrySnapshot( + structures, jigsaws, pools, new JigsawMetadataResolver(hooks), hooks); + } catch (RuntimeException | LinkageError e) { + IrisLogging.reportError("Could not read the live structure registries during pack validation", e); + blockingErrors.add("Could not validate native structure references against the live registries: " + + failureMessage(e) + "."); + return null; } } - private static Set registeredStructureKeys() { - try { - List registered = IrisPlatforms.get().structureHooks().structureKeys(); - if (registered == null || registered.isEmpty()) { - return Set.of(); + private static Set normalizeRegistryKeys(List registered, String registryName) { + if (registered == null) { + throw new IllegalStateException("The active " + registryName + " registry returned null"); + } + Set keys = new HashSet<>(); + for (String key : registered) { + if (key != null && !key.isBlank()) { + keys.add(key.toLowerCase(Locale.ROOT)); } - Set keys = new HashSet<>(); - for (String key : registered) { - if (key != null && !key.isBlank()) { - keys.add(key.toLowerCase(Locale.ROOT)); - } + } + return Set.copyOf(keys); + } + + private static String failureMessage(Throwable throwable) { + String message = throwable.getMessage(); + return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message; + } + + private static void validatePlacementConfiguration(String path, JSONObject placement, + List blockingErrors) { + PackJsonFieldChecks.validateOptionalEnum(path, placement, "distribution", + Set.of("RANDOM_SPREAD", "DENSITY", "CONCENTRIC_RINGS"), blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, placement, "spacing", 1, 4096, blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, placement, "separation", 0, Integer.MAX_VALUE, blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, placement, "salt", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, placement, "density", 0D, 1D, blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, placement, "ringCount", 1, Integer.MAX_VALUE, blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, placement, "ringDistance", 1, Integer.MAX_VALUE, blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, placement, "ringSpread", 1, Integer.MAX_VALUE, blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, placement, "minHeight", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, placement, "maxHeight", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors); + PackJsonFieldChecks.validateOptionalBoolean(path, placement, "underground", blockingErrors); + PackJsonFieldChecks.validateOptionalBoolean(path, placement, "underwater", blockingErrors); + PackJsonFieldChecks.validateOptionalEnum(path, placement, "nativeSuppression", + Set.of("NONE", "REPLACE_SOURCE"), blockingErrors); + + Integer spacing = integerValue(placement, "spacing", 32); + Integer separation = integerValue(placement, "separation", 8); + if (spacing != null && separation != null && separation >= spacing) { + blockingErrors.add(path + ".separation must be smaller than spacing."); + } + Integer minimumHeight = integerValue(placement, "minHeight", -2032); + Integer maximumHeight = integerValue(placement, "maxHeight", 2032); + if (minimumHeight != null && maximumHeight != null && minimumHeight > maximumHeight) { + blockingErrors.add(path + " has an inverted height band: minHeight must not exceed maxHeight."); + } + Integer ringCount = integerValue(placement, "ringCount", 128); + Integer ringDistance = integerValue(placement, "ringDistance", 32); + Integer ringSpread = integerValue(placement, "ringSpread", 3); + if (ringCount != null && ringDistance != null && ringSpread != null + && ringCount > 0 && ringDistance > 0 && ringSpread > 0) { + long ringRadius = (long) Math.ceilDiv(ringCount, ringSpread) * ringDistance; + if (ringRadius > Integer.MAX_VALUE) { + blockingErrors.add(path + " concentric ring radius exceeds the supported chunk coordinate range."); } - return Set.copyOf(keys); - } catch (Throwable ignored) { - return Set.of(); + } + + if (placement.has("placementId")) { + Object rawPlacementId = placement.opt("placementId"); + if (!(rawPlacementId instanceof String placementId)) { + blockingErrors.add(path + ".placementId must be a string."); + } else if (!placementId.isEmpty() && placementId.isBlank()) { + blockingErrors.add(path + ".placementId must be omitted or non-blank."); + } else if (!placementId.equals(placementId.trim())) { + blockingErrors.add(path + ".placementId must not contain leading or trailing whitespace."); + } + } + validateStilt(path, placement, blockingErrors); + validateNativeTerrain(path, placement, blockingErrors); + } + + private static void validatePlacementIdentity(String path, JSONObject placement, + Map placementIds, + Map anonymousGridIdentities, + List blockingErrors) { + Object rawPlacementId = placement.opt("placementId"); + if (rawPlacementId instanceof String placementId && !placementId.isBlank()) { + String normalizedId = placementId.trim(); + String existing = placementIds.putIfAbsent(normalizedId, path); + if (existing != null) { + blockingErrors.add(path + ".placementId duplicates '" + normalizedId + + "' already declared by " + existing + "."); + } + return; + } + String gridIdentity = anonymousGridIdentity(placement); + String existing = anonymousGridIdentities.putIfAbsent(gridIdentity, path); + if (existing != null) { + blockingErrors.add(path + " duplicates an anonymous placement grid already declared by " + + existing + "; give distinct placements unique placementId values."); } } - private static Set registeredTemplatePoolKeys() { - try { - List registered = IrisPlatforms.get().structureHooks().templatePoolKeys(); - if (registered == null || registered.isEmpty()) { - return Set.of(); + private static String anonymousGridIdentity(JSONObject placement) { + StringBuilder identity = new StringBuilder(); + appendIdentity(identity, placement.optString("distribution", "RANDOM_SPREAD")); + appendIdentity(identity, integerValue(placement, "salt", 165745296)); + appendIdentity(identity, integerValue(placement, "spacing", 32)); + appendIdentity(identity, integerValue(placement, "separation", 8)); + Object rawDensity = placement.has("density") ? placement.opt("density") : 0.02D; + appendIdentity(identity, rawDensity instanceof Number number + ? Double.doubleToLongBits(number.doubleValue()) : rawDensity); + appendIdentity(identity, integerValue(placement, "ringCount", 128)); + appendIdentity(identity, integerValue(placement, "ringDistance", 32)); + appendIdentity(identity, integerValue(placement, "ringSpread", 3)); + appendIdentity(identity, integerValue(placement, "minHeight", -2032)); + appendIdentity(identity, integerValue(placement, "maxHeight", 2032)); + appendIdentity(identity, placement.opt("underground") instanceof Boolean underground && underground); + appendIdentity(identity, placement.opt("underwater") instanceof Boolean underwater && underwater); + JSONArray structures = placement.optJSONArray("structures"); + JSONArray nativeStructures = placement.optJSONArray("nativeStructures"); + if (structures != null && structures.length() > 0) { + appendIdentity(identity, "iris"); + List orderedStructures = new ArrayList<>(structures.length()); + for (int index = 0; index < structures.length(); index++) { + orderedStructures.add(String.valueOf(structures.opt(index))); } - Set keys = new HashSet<>(); - for (String key : registered) { - if (key != null && !key.isBlank()) { - keys.add(key.toLowerCase(Locale.ROOT)); + orderedStructures.sort(String::compareTo); + for (String structure : orderedStructures) { + appendIdentity(identity, structure); + } + } else { + appendIdentity(identity, "native"); + if (nativeStructures != null) { + List orderedSources = new ArrayList<>(nativeStructures.length()); + for (int index = 0; index < nativeStructures.length(); index++) { + JSONObject source = nativeStructures.optJSONObject(index); + String sourceKey = source == null ? "null" : source.optString("structure", ""); + Integer weight = source == null ? null : integerValue(source, "weight", 1); + orderedSources.add(sourceKey.length() + ":" + sourceKey + ":" + weight); + } + orderedSources.sort(String::compareTo); + for (String source : orderedSources) { + appendIdentity(identity, source); } } - return Set.copyOf(keys); - } catch (Throwable ignored) { - return Set.of(); + } + return identity.toString(); + } + + private static void appendIdentity(StringBuilder identity, Object value) { + String text = String.valueOf(value); + identity.append(text.length()).append(':').append(text).append('|'); + } + + private static void validateStilt(String path, JSONObject placement, List blockingErrors) { + if (!placement.has("stilt") || placement.opt("stilt") == JSONObject.NULL) { + return; + } + JSONObject stilt = placement.optJSONObject("stilt"); + if (stilt == null) { + blockingErrors.add(path + ".stilt must be an object."); + return; + } + PackJsonFieldChecks.validateOptionalIntegerRange( + path + ".stilt", stilt, "maxDepth", 1, 4064, blockingErrors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path + ".stilt", stilt, "spacing", 1, 64, blockingErrors); + PackJsonFieldChecks.validateOptionalBoolean( + path + ".stilt", stilt, "supportNonOccluding", blockingErrors); + if (stilt.has("palette") && stilt.opt("palette") != JSONObject.NULL + && stilt.optJSONObject("palette") == null) { + blockingErrors.add(path + ".stilt.palette must be an object."); } } - private static void validateNativeStructures(String placementPath, JSONObject placement, + private static Integer integerValue(JSONObject object, String field, int defaultValue) { + if (!object.has(field)) { + return defaultValue; + } + Object rawValue = object.opt(field); + if (!(rawValue instanceof Number number)) { + return null; + } + double doubleValue = number.doubleValue(); + if (!Double.isFinite(doubleValue) || Math.rint(doubleValue) != doubleValue + || doubleValue < Integer.MIN_VALUE || doubleValue > Integer.MAX_VALUE) { + return null; + } + return number.intValue(); + } + + private static void validateNativeStructures(String placementPath, JSONArray nativeStructures, Set registeredStructures, Set registeredJigsaws, Set registeredPools, + JigsawMetadataResolver jigsawMetadataResolver, + PlatformStructureHooks hooks, List blockingErrors) { + Set sourceKeys = new HashSet<>(); + long totalWeight = 0L; for (int sourceIndex = 0; sourceIndex < nativeStructures.length(); sourceIndex++) { String sourcePath = placementPath + ".nativeStructures[" + sourceIndex + "]"; JSONObject source = nativeStructures.optJSONObject(sourceIndex); @@ -174,22 +391,199 @@ final class PackStructurePlacementValidator { blockingErrors.add(sourcePath + ".structure '" + structureKey + "' is not a registered structure."); } + if (!structureKey.isEmpty() && !sourceKeys.add(structureKey.toLowerCase(Locale.ROOT))) { + blockingErrors.add(sourcePath + ".structure duplicates native source '" + structureKey + "'."); + } Integer weight = PackLootValidator.lootInteger(source, "weight", 1, sourcePath, blockingErrors); PackLootValidator.requireMinimum(sourcePath + ".weight", weight, 1, blockingErrors); + if (weight != null && weight > 0) { + totalWeight += weight; + } + String normalizedStructureKey = structureKey.toLowerCase(Locale.ROOT); + boolean registeredJigsaw = registeredJigsaws.contains(normalizedStructureKey); JSONObject jigsaw = source.optJSONObject("jigsaw"); if (source.has("jigsaw") && source.opt("jigsaw") != JSONObject.NULL && jigsaw == null) { blockingErrors.add(sourcePath + ".jigsaw must be an object."); } else if (jigsaw != null) { - if (!registeredJigsaws.isEmpty() - && !registeredJigsaws.contains(structureKey.toLowerCase(Locale.ROOT))) { + if (!registeredJigsaws.isEmpty() && !registeredJigsaw) { blockingErrors.add(sourcePath + ".jigsaw requires a registered jigsaw structure."); } validateJigsawAssembly( sourcePath + ".jigsaw", jigsaw, registeredPools, blockingErrors); } + JigsawSourceMetadata sourceMetadata = registeredJigsaw + ? resolveJigsawMetadata(sourcePath, normalizedStructureKey, + jigsawMetadataResolver, blockingErrors) + : null; + if (registeredJigsaw && sourceMetadata == null) { + continue; + } + if (registeredJigsaw || jigsaw != null) { + validateReferenceEnvelope( + sourcePath, normalizedStructureKey, jigsaw, + sourceMetadata, hooks, blockingErrors); + } } - validateNativeTerrain(placementPath, placement, blockingErrors); + if (totalWeight > Integer.MAX_VALUE) { + blockingErrors.add(placementPath + ".nativeStructures total weight exceeds " + + Integer.MAX_VALUE + "."); + } + } + + private static void validateReferenceEnvelope(String sourcePath, String structureKey, + JSONObject jigsaw, JigsawSourceMetadata sourceMetadata, + PlatformStructureHooks hooks, + List blockingErrors) { + boolean overriddenDistance = jigsaw != null && jigsaw.has("maxDistanceHorizontal"); + Integer maximumDistance = overriddenDistance + ? integerValue(jigsaw, "maxDistanceHorizontal", -1) + : sourceMetadata == null ? null : sourceMetadata.maxDistanceHorizontal(); + if (maximumDistance == null || maximumDistance < 0) { + return; + } + Integer startElementSpan = effectiveStartElementSpan( + sourcePath, structureKey, jigsaw, sourceMetadata, hooks, blockingErrors); + if (startElementSpan == null) { + return; + } + long assemblySpan = Math.max(maximumDistance, startElementSpan); + if (assemblySpan > 128L) { + String distancePath = overriddenDistance + ? ".jigsaw.maxDistanceHorizontal" : " registered jigsaw source max-distance"; + blockingErrors.add(sourcePath + distancePath + " and its actual structure content" + + " (" + startElementSpan + "-block maximum start element and " + + maximumDistance + "-block maximum assembly distance) must not exceed Minecraft's" + + " 128-block (8-chunk) structure reference range."); + } + } + + private static Integer effectiveStartElementSpan(String sourcePath, String structureKey, + JSONObject jigsaw, + JigsawSourceMetadata sourceMetadata, + PlatformStructureHooks hooks, + List blockingErrors) { + int sourceSpan = sourceMetadata == null ? 0 : sourceMetadata.maxStartElementHorizontalSpan(); + if (jigsaw == null || !jigsaw.has("startPool")) { + return sourceSpan; + } + String startPool = jigsaw.optString("startPool", "").trim(); + if (startPool.isEmpty() || hooks == null) { + return sourceSpan; + } + try { + int resolvedSpan = hooks.jigsawStartPoolHorizontalSpan(structureKey, startPool); + if (resolvedSpan < 0) { + throw new IllegalStateException("negative horizontal span " + resolvedSpan); + } + return resolvedSpan; + } catch (RuntimeException | LinkageError error) { + IrisLogging.reportError("Could not resolve the effective native jigsaw start-pool span", error); + blockingErrors.add(sourcePath + ".jigsaw.startPool '" + startPool + + "' could not resolve a bounded live horizontal span: " + + failureMessage(error) + "."); + return null; + } + } + + private static void validateImportedStructureAdjustmentEnvelopes( + String dimensionKey, JSONObject dimension, RegistrySnapshot registries, + List blockingErrors) { + JSONObject policy = dimension.optJSONObject("importedStructures"); + if (policy == null) { + return; + } + JSONArray adjustments = policy.optJSONArray("adjustments"); + if (adjustments == null) { + return; + } + List orderedStructureKeys = new ArrayList<>(registries.structures()); + orderedStructureKeys.sort(String::compareTo); + Map effective = new HashMap<>(); + for (int adjustmentIndex = 0; adjustmentIndex < adjustments.length(); adjustmentIndex++) { + JSONObject adjustment = adjustments.optJSONObject(adjustmentIndex); + if (adjustment == null || adjustment.optJSONObject("terrain") == null) { + continue; + } + JSONArray matches = adjustment.optJSONArray("match"); + if (matches == null || matches.length() == 0) { + continue; + } + String path = "Dimension '" + dimensionKey + + "' importedStructures.adjustments[" + adjustmentIndex + "]"; + for (String structureKey : orderedStructureKeys) { + if (matchesStructure(matches, structureKey)) { + effective.put(structureKey, new EffectiveTerrainAdjustment(path, adjustment)); + } + } + } + for (String structureKey : orderedStructureKeys) { + EffectiveTerrainAdjustment adjustment = effective.get(structureKey); + if (adjustment == null) { + continue; + } + JSONObject terrain = adjustment.adjustment().optJSONObject("terrain"); + if (terrain == null || !TERRAIN_ENVELOPE_MODES.contains( + terrain.optString("mode", "SOURCE").toUpperCase(Locale.ROOT))) { + continue; + } + String sourcePath = adjustment.path() + " matched registered structure '" + + structureKey + "'"; + if (registries.jigsaws().contains(structureKey)) { + JigsawSourceMetadata metadata = resolveJigsawMetadata( + sourcePath, structureKey, + registries.jigsawMetadataResolver(), blockingErrors); + if (metadata == null) { + continue; + } + validateReferenceEnvelope( + sourcePath, structureKey, null, metadata, + registries.hooks(), blockingErrors); + } + } + } + + private static boolean matchesStructure(JSONArray matches, String structureKey) { + for (int matchIndex = 0; matchIndex < matches.length(); matchIndex++) { + Object rawPattern = matches.opt(matchIndex); + if (rawPattern instanceof String pattern + && matchesStructurePattern(pattern, structureKey)) { + return true; + } + } + return false; + } + + private static JigsawSourceMetadata resolveJigsawMetadata( + String sourcePath, String structureKey, + JigsawMetadataResolver resolver, List blockingErrors) { + if (resolver == null) { + return null; + } + MetadataResolution resolution = resolver.resolve(structureKey); + if (resolution.metadata() != null) { + return resolution.metadata(); + } + blockingErrors.add(sourcePath + " could not resolve live metadata for registered jigsaw source '" + + structureKey + "': " + resolution.failure() + "."); + return null; + } + + private static boolean matchesStructurePattern(String pattern, String structureKey) { + String normalizedPattern = pattern == null ? "" : pattern.trim().toLowerCase(Locale.ROOT); + String normalizedKey = structureKey == null ? "" : structureKey.trim().toLowerCase(Locale.ROOT); + if (normalizedPattern.isEmpty() || !normalizedKey.startsWith(normalizedPattern)) { + return false; + } + if (normalizedKey.length() == normalizedPattern.length()) { + return true; + } + char patternEnd = normalizedPattern.charAt(normalizedPattern.length() - 1); + if (patternEnd == ':' || patternEnd == '/' || patternEnd == '_') { + return true; + } + char boundary = normalizedKey.charAt(normalizedPattern.length()); + return boundary == '/' || boundary == '_'; } private static void validateJigsawAssembly(String path, JSONObject assembly, @@ -234,6 +628,8 @@ final class PackStructurePlacementValidator { } PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "mode", Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), blockingErrors); + PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "shape", + Set.of("BOX", "ROUNDED", "ERODED"), blockingErrors); PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain, "horizontalPadding", 0, 128, blockingErrors); PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain, @@ -386,4 +782,48 @@ final class PackStructurePlacementValidator { default -> "Resource"; }; } + + private record RegistrySnapshot(Set structures, Set jigsaws, Set pools, + JigsawMetadataResolver jigsawMetadataResolver, + PlatformStructureHooks hooks) { + } + + private static final class JigsawMetadataResolver { + private final PlatformStructureHooks hooks; + private final Map resolutions = new HashMap<>(); + + private JigsawMetadataResolver(PlatformStructureHooks hooks) { + this.hooks = hooks; + } + + private MetadataResolution resolve(String structureKey) { + MetadataResolution cached = resolutions.get(structureKey); + if (cached != null) { + return cached; + } + MetadataResolution resolved; + try { + JigsawSourceMetadata metadata = hooks.jigsawSourceMetadata(structureKey); + if (metadata == null) { + throw new IllegalStateException( + "The active structure registry returned null jigsaw metadata for '" + + structureKey + "'"); + } + resolved = new MetadataResolution(metadata, null); + } catch (RuntimeException | LinkageError error) { + IrisLogging.reportError( + "Could not resolve live jigsaw metadata for registered structure '" + + structureKey + "' during pack validation", error); + resolved = new MetadataResolution(null, failureMessage(error)); + } + resolutions.put(structureKey, resolved); + return resolved; + } + } + + private record MetadataResolution(JigsawSourceMetadata metadata, String failure) { + } + + private record EffectiveTerrainAdjustment(String path, JSONObject adjustment) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 25bdddfa3..17bff3eb8 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -45,6 +45,14 @@ public final class PackValidator { } public static PackValidationResult validate(File packFolder) { + return validate(packFolder, true); + } + + public static PackValidationResult validateForDatapackBootstrap(File packFolder) { + return validate(packFolder, false); + } + + private static PackValidationResult validate(File packFolder, boolean validateLiveRegistries) { String packName = packFolder == null ? "" : packFolder.getName(); List blockingErrors = new ArrayList<>(); List warnings = new ArrayList<>(); @@ -72,7 +80,8 @@ public final class PackValidator { blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder)); blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder)); blockingErrors.addAll(PackObjectSurfaceValidator.validateUnsupportedStructureTransforms(packFolder)); - blockingErrors.addAll(PackObjectSurfaceValidator.validateStructureGraph(packFolder)); + blockingErrors.addAll(PackObjectSurfaceValidator.validateStructureGraph( + packFolder, validateLiveRegistries)); StructureGraphPackValidator.Validation compiledStructures = StructureGraphPackValidator.validate( packFolder.toPath(), PackObjectSurfaceValidator.collectPlacedStructureKeys(packFolder)); diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java b/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java index a59947e9f..7c292dd68 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisProjectCopier.java @@ -18,52 +18,213 @@ package art.arcane.iris.core.project; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.json.JSONObject; import java.io.File; import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.Comparator; +import java.util.Objects; import java.util.stream.Stream; public final class IrisProjectCopier { private IrisProjectCopier() { } - public static void copyProject(File sourcePack, File targetPack, String sourceKey, String targetKey) throws IOException { - Path source = sourcePack.toPath(); - try (Stream walk = Files.walk(source)) { - for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) { - String relative = source.relativize(path).toString(); - if (relative.isEmpty() || relative.equals(".git") || relative.startsWith(".git" + File.separator) || relative.endsWith(".code-workspace")) { - continue; - } - Path destination = targetPack.toPath().resolve(relative); - if (Files.isDirectory(path)) { - Files.createDirectories(destination); - } else { - Files.createDirectories(destination.getParent()); - Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); - } - } - } + public static void copyProject(File sourcePack, File targetWorkspace, String sourceKey, String targetKey) throws IOException { + copyProject(sourcePack, targetWorkspace, sourceKey, targetKey, (source, target) -> { + }); + } - File oldDimension = new File(targetPack, "dimensions/" + sourceKey + ".json"); - File newDimension = new File(targetPack, "dimensions/" + targetKey + ".json"); - if (oldDimension.isFile() && !oldDimension.equals(newDimension)) { - Files.copy(oldDimension.toPath(), newDimension.toPath(), StandardCopyOption.REPLACE_EXISTING); - Files.delete(oldDimension.toPath()); - } - if (newDimension.isFile()) { - JSONObject json = new JSONObject(IO.readAll(newDimension)); - if (json.has("name")) { - json.put("name", Form.capitalizeWords(targetKey.replaceAll("\\Q-\\E", " "))); - IO.writeAll(newDimension, json.toString(4)); + static void copyProject( + File sourcePack, + File targetWorkspace, + String sourceKey, + String targetKey, + CopyHook copyHook + ) throws IOException { + String validatedSourceKey = requireSafeKey(sourceKey, "source"); + String validatedTargetKey = requireSafeKey(targetKey, "target"); + Path source = requireSafeSource( + Objects.requireNonNull(sourcePack, "sourcePack").toPath().toAbsolutePath().normalize(), + validatedSourceKey + ); + Path workspace = requireTargetWorkspace(targetWorkspace); + Path target = workspace.resolve(validatedTargetKey).normalize(); + requireAvailableTarget(target, workspace, validatedTargetKey); + + Path stage = Files.createTempDirectory(workspace, "." + validatedTargetKey + ".importing-"); + boolean published = false; + Throwable operationFailure = null; + try { + copyTree(source, stage, Objects.requireNonNull(copyHook, "copyHook")); + transformDimension(stage, validatedSourceKey, validatedTargetKey); + requireAvailableTarget(target, workspace, validatedTargetKey); + publish(stage, target); + published = true; + } catch (IOException | RuntimeException | Error e) { + operationFailure = e; + throw e; + } finally { + if (!published) { + try { + deleteTree(stage); + } catch (IOException cleanupFailure) { + if (operationFailure != null) { + operationFailure.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } + } } } } + + private static String requireSafeKey(String value, String purpose) throws IOException { + if (value == null || value.isBlank()) { + throw new IOException("Project " + purpose + " key cannot be empty."); + } + + Path key; + try { + key = Path.of(value); + } catch (RuntimeException e) { + throw new IOException("Invalid project " + purpose + " key: " + value, e); + } + if (key.isAbsolute() + || key.getNameCount() != 1 + || !key.normalize().equals(key) + || ".".equals(value) + || "..".equals(value) + || value.indexOf('/') >= 0 + || value.indexOf('\\') >= 0) { + throw new IOException("Invalid project " + purpose + " key: " + value); + } + return value; + } + + private static Path requireTargetWorkspace(File targetWorkspace) throws IOException { + Path workspace = Objects.requireNonNull(targetWorkspace, "targetWorkspace").toPath().toAbsolutePath().normalize(); + if (Files.isSymbolicLink(workspace) + || !Files.isDirectory(workspace, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Project workspace is missing or unsafe: " + workspace); + } + return workspace; + } + + private static Path requireSafeSource(Path source, String sourceKey) throws IOException { + if (!Files.isDirectory(source)) { + throw new IOException("Source project is missing or unsafe: " + source); + } + PackDirectoryResolver.requireSafePackTree(source.toFile()); + Path resolvedSource = source.toRealPath(); + + Path dimension = resolvedSource.resolve("dimensions").resolve(sourceKey + ".json").normalize(); + if (!dimension.startsWith(resolvedSource) + || Files.isSymbolicLink(dimension) + || !Files.isRegularFile(dimension, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Source project dimension is missing or unsafe: " + dimension); + } + return resolvedSource; + } + + private static void requireAvailableTarget(Path target, Path workspace, String targetKey) throws IOException { + if (!workspace.equals(target.getParent()) || !targetKey.equals(target.getFileName().toString())) { + throw new IOException("Target project must be a direct child of the workspace: " + target); + } + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new FileAlreadyExistsException(target.toString()); + } + } + + private static void copyTree(Path source, Path stage, CopyHook copyHook) throws IOException { + try (Stream walk = Files.walk(source)) { + for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) { + Path relative = source.relativize(path); + if (relative.toString().isEmpty() || shouldSkip(relative)) { + continue; + } + if (Files.isSymbolicLink(path)) { + throw new IOException("Source project contains a symbolic link: " + path); + } + + Path destination = stage.resolve(relative).normalize(); + if (!destination.startsWith(stage)) { + throw new IOException("Source project entry escapes staging: " + relative); + } + copyHook.beforeCopy(path, destination); + if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(destination); + } else if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(destination.getParent()); + Files.copy(path, destination); + } else { + throw new IOException("Source project contains an unsupported entry: " + path); + } + } + } + } + + private static boolean shouldSkip(Path relative) { + return ".git".equals(relative.getName(0).toString()) + || relative.getFileName().toString().endsWith(".code-workspace"); + } + + private static void transformDimension(Path stage, String sourceKey, String targetKey) throws IOException { + Path oldDimension = stage.resolve("dimensions").resolve(sourceKey + ".json"); + Path newDimension = stage.resolve("dimensions").resolve(targetKey + ".json"); + if (!oldDimension.equals(newDimension)) { + Files.move(oldDimension, newDimension); + } + + JSONObject json = new JSONObject(IO.readAll(newDimension.toFile())); + if (json.has("name")) { + json.put("name", Form.capitalizeWords(targetKey.replace('-', ' '))); + IO.writeAll(newDimension.toFile(), json.toString(4)); + } + } + + private static void publish(Path stage, Path target) throws IOException { + try { + Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(stage, target); + } + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return; + } + try (Stream walk = Files.walk(root)) { + IOException failure = null; + for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + } + if (failure != null) { + throw failure; + } + } + } + + @FunctionalInterface + interface CopyHook { + void beforeCopy(Path source, Path target) throws IOException; + } } diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java index e084dde48..30f7f4b36 100644 --- a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java @@ -536,7 +536,7 @@ public class SchemaBuilder { } else if (SchemaKeyedTypes.isKeyed(k.getType())) { fancyType = addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity()); } else if (k.getType().isEnum()) { - fancyType = addEnum(k.getType(), prop, description, k.getType().getEnumConstants(), o -> ((Enum) o).name()); + fancyType = addEnum(k.getType(), prop, description, enumNames(k.getType()), Function.identity()); } } case "object" -> { @@ -738,7 +738,7 @@ public class SchemaBuilder { } else if (SchemaKeyedTypes.isKeyed(t.type())) { fancyType = addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity()); } else if (t.type().isEnum()) { - fancyType = addEnumList(prop, description, t, t.type().getEnumConstants(), o -> ((Enum) o).name()); + fancyType = addEnumList(prop, description, t, enumNames(t.type()), Function.identity()); } } } @@ -851,6 +851,22 @@ public class SchemaBuilder { return "List of " + s + "s"; } + private static String[] enumNames(Class enumType) { + try { + Object[] constants = enumType.getEnumConstants(); + String[] names = new String[constants.length]; + for (int index = 0; index < constants.length; index++) { + names[index] = ((Enum) constants[index]).name(); + } + return names; + } catch (LinkageError error) { + return Arrays.stream(enumType.getDeclaredFields()) + .filter(Field::isEnumConstant) + .map(Field::getName) + .toArray(String[]::new); + } + } + @NotNull private String addEnum(Class type, JSONObject prop, KList description, T[] values, Function function) { JSONArray a = new JSONArray(); diff --git a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java index c77995e88..dba6ba7b8 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java @@ -2,9 +2,13 @@ package art.arcane.iris.core.runtime; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.core.IrisWorldStorage; +import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.core.link.MultiverseCoreLink; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.lifecycle.WorldLifecycleService; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.project.IrisCodeWorkspace; import art.arcane.iris.core.tools.IrisCreator; @@ -14,7 +18,6 @@ import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.exceptions.IrisException; import art.arcane.volmlib.util.bukkit.WorldIdentity; -import art.arcane.volmlib.util.io.IO; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; @@ -22,17 +25,27 @@ import org.bukkit.entity.Player; import java.io.File; import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; import java.util.function.Consumer; +import java.util.function.Supplier; public final class StudioOpenCoordinator { + private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L; private static volatile StudioOpenCoordinator instance; private StudioOpenCoordinator() { @@ -61,31 +74,20 @@ public final class StudioOpenCoordinator { } public CompletableFuture closeProject(IrisProject project) { - CompletableFuture future = new CompletableFuture<>(); - J.aBukkit(() -> future.complete(executeClose(project))); - return future; - } - - private StudioCloseResult executeClose(IrisProject project) { if (project == null) { - return new StudioCloseResult(null, true, true, false, null); + return CompletableFuture.completedFuture(new StudioCloseResult(null, true, true, false, null)); } PlatformChunkGenerator provider = project.getActiveProvider(); if (provider == null) { - return new StudioCloseResult(null, true, true, false, null); + return CompletableFuture.completedFuture(new StudioCloseResult(null, true, true, false, null)); } World world = BukkitWorldBinding.world(provider.getTarget().getWorld()); String worldName = world == null ? IrisWorldStorage.logicalName(WorldIdentity.parse(provider.getTarget().getWorld().identity())) : IrisWorldStorage.logicalName(world); - try { - return closeWorld(provider, worldName, world, true, project); - } catch (Throwable e) { - project.setActiveProvider(null); - return new StudioCloseResult(worldName, false, false, false, e); - } + return closeWorldCoordinated(provider, worldName, world, true, project); } private void executeOpen(StudioOpenRequest request, CompletableFuture future) { @@ -200,7 +202,16 @@ public final class StudioOpenCoordinator { if (!request.retainOnFailure()) { try { updateStage(request, "cleanup", 1.00D); - closeWorld(provider, request.worldName(), world, true, request.project()); + StudioCloseResult cleanupResult = closeWorldCoordinated( + provider, + request.worldName(), + world, + true, + request.project() + ).get(45L, TimeUnit.SECONDS); + if (cleanupResult.failureCause() != null) { + throw cleanupResult.failureCause(); + } } catch (Throwable cleanupError) { IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError); } @@ -240,164 +251,344 @@ public final class StudioOpenCoordinator { return loaded; } - private StudioCloseResult closeWorld( + private CompletableFuture closeWorldCoordinated( PlatformChunkGenerator provider, String worldName, World world, boolean deleteFolder, IrisProject project ) { - Throwable failure = null; - boolean unloadCompletedLive = world == null || !isWorldFamilyLoaded(worldName); - boolean folderDeletionCompletedLive = !deleteFolder; - boolean startupCleanupQueued = false; - CompletableFuture closeFuture = CompletableFuture.completedFuture(null); - - if (world != null) { - try { - evacuatePlayers(world); - } catch (Throwable e) { - failure = e; - } + String operationTarget = worldName == null || worldName.isBlank() ? "unknown-studio-world" : worldName; + LifecycleOperationCoordinator.Lease lease; + try { + lease = LifecycleOperationCoordinator.get().acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.STUDIO_CLOSE, + operationTarget + ); + } catch (Throwable failure) { + boolean queued = deleteFolder && queueStartupCleanup(worldName, failure); + return CompletableFuture.completedFuture(new StudioCloseResult( + worldName, + false, + false, + queued, + failure + )); } + CompletableFuture closeFuture; + try { + closeFuture = closeWorldReserved(provider, worldName, world, deleteFolder, project); + } catch (Throwable failure) { + boolean queued = deleteFolder && queueStartupCleanup(worldName, failure); + closeFuture = CompletableFuture.completedFuture(new StudioCloseResult( + worldName, + false, + false, + queued, + failure + )); + } + return closeFuture.whenComplete((result, throwable) -> lease.close()); + } + + private CompletableFuture closeWorldReserved( + PlatformChunkGenerator provider, + String worldName, + World world, + boolean deleteFolder, + IrisProject project + ) { + AtomicBoolean unloadConfirmed = new AtomicBoolean(false); + AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder); + AtomicBoolean terminalTimeout = new AtomicBoolean(false); if (world != null) { IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); } - try { - if (project != null) { - project.setActiveProvider(null); - } - if (provider != null) { - closeFuture = provider.closeAsync(); - } - - if (worldName != null && !worldName.isBlank()) { - requestWorldFamilyUnload(worldName); - } - - if (worldName != null && !worldName.isBlank()) { - long unloadDeadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20L); - CompletableFuture unloadFuture = waitForWorldFamilyUnload(worldName, unloadDeadline); - try { - unloadFuture.get(Math.max(1000L, unloadDeadline - System.currentTimeMillis()), TimeUnit.MILLISECONDS); - unloadCompletedLive = true; - } catch (TimeoutException e) { - unloadCompletedLive = !isWorldFamilyLoaded(worldName); - } catch (Throwable e) { - failure = failure == null ? unwrapFailure(e) : failure; - } - } - - try { - closeFuture.get(20L, TimeUnit.SECONDS); - } catch (Throwable e) { - Throwable cause = unwrapFailure(e); - if (failure == null) { - failure = cause; - } - } - - if (deleteFolder && worldName != null && !worldName.isBlank()) { - WorldFamilyDeleteResult deleteResult = deleteWorldFamily(worldName, unloadCompletedLive); - folderDeletionCompletedLive = deleteResult.liveDeleted(); - startupCleanupQueued = deleteResult.startupCleanupQueued(); - } - } finally { + CompletableFuture sequence = sequenceStudioClose( + () -> evacuateWorldFamily(worldName, world), + () -> unloadWorldFamily(worldName, world).thenRun(() -> { + if (terminalTimeout.get()) { + throw new CompletionException(new TimeoutException( + "Studio close stopped after its terminal timeout.")); + } + unloadConfirmed.set(true); + if (project != null) { + project.setActiveProvider(null); + } + }), + () -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(), + () -> deleteFolder + ? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true)) + : CompletableFuture.completedFuture(null), + terminalTimeout::get + ); + CompletableFuture operation = guardCloseCompletion( + sequence, + terminalTimeout, + worldName) + .thenApply(ignored -> new StudioCloseResult( + worldName, + true, + folderDeleted.get(), + false, + null + )) + .exceptionally(throwable -> { + Throwable failure = unwrapFailure(throwable); + boolean queued = deleteFolder && queueStartupCleanup(worldName, failure); + return new StudioCloseResult( + worldName, + unloadConfirmed.get(), + folderDeleted.get(), + queued, + failure + ); + }); + return operation.whenComplete((result, throwable) -> { if (world != null) { IrisToolbelt.endWorldMaintenance(world, "studio-close"); } - } - - return new StudioCloseResult(worldName, unloadCompletedLive, folderDeletionCompletedLive, startupCleanupQueued, failure); + }); } - private void evacuatePlayers(World world) throws Exception { - if (world == null) { - return; + static CompletableFuture sequenceStudioClose( + Supplier> evacuate, + Supplier> unload, + Supplier> closeGenerator, + Supplier> deleteFolders + ) { + return sequenceStudioClose(evacuate, unload, closeGenerator, deleteFolders, () -> false); + } + + static CompletableFuture sequenceStudioClose( + Supplier> evacuate, + Supplier> unload, + Supplier> closeGenerator, + Supplier> deleteFolders, + BooleanSupplier terminalTimeout + ) { + return invokePhase(evacuate) + .thenCompose(ignored -> invokePhaseUnlessTimedOut(unload, terminalTimeout)) + .thenCompose(ignored -> invokePhaseUnlessTimedOut(closeGenerator, terminalTimeout)) + .thenCompose(ignored -> invokePhaseUnlessTimedOut(deleteFolders, terminalTimeout)); + } + + private static CompletableFuture invokePhase(Supplier> phase) { + try { + CompletableFuture future = phase.get(); + if (future == null) { + return CompletableFuture.failedFuture(new IllegalStateException("Studio close phase returned no completion future.")); + } + return future; + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + private static CompletableFuture invokePhaseUnlessTimedOut( + Supplier> phase, + BooleanSupplier terminalTimeout + ) { + if (terminalTimeout.getAsBoolean()) { + return CompletableFuture.failedFuture(new TimeoutException( + "Studio close stopped after its terminal timeout.")); + } + return invokePhase(phase); + } + + private CompletableFuture guardCloseCompletion( + CompletableFuture source, + AtomicBoolean terminalTimeout, + String worldName + ) { + CompletableFuture guarded = new CompletableFuture<>(); + AtomicBoolean settled = new AtomicBoolean(false); + source.whenComplete((ignored, throwable) -> { + if (!settled.compareAndSet(false, true)) { + return; + } + if (throwable == null) { + guarded.complete(null); + } else { + guarded.completeExceptionally(throwable); + } + }); + CompletableFuture.delayedExecutor(STUDIO_CLOSE_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> { + if (!settled.compareAndSet(false, true)) { + return; + } + terminalTimeout.set(true); + TimeoutException timeout = new TimeoutException( + "Studio close did not settle within " + STUDIO_CLOSE_TIMEOUT_SECONDS + + " seconds for \"" + worldName + "\"."); + ServerConfigurator.restart("Studio close timed out for \"" + worldName + "\"."); + guarded.completeExceptionally(timeout); + }); + return guarded; + } + + private CompletableFuture evacuateWorldFamily(String worldName, World primaryWorld) { + List loadedWorlds = loadedWorldFamily(worldName, primaryWorld); + if (loadedWorlds.isEmpty()) { + return CompletableFuture.completedFuture(null); } - CompletableFuture future = J.sfut(() -> { - IrisToolbelt.evacuate(world); + ArrayList> evacuations = new ArrayList<>(loadedWorlds.size()); + for (World loadedWorld : loadedWorlds) { + CompletableFuture evacuation = J.sfut(() -> IrisToolbelt.evacuateAsync(loadedWorld)) + .thenCompose(evacuationFuture -> evacuationFuture) + .thenCompose(evacuated -> Boolean.TRUE.equals(evacuated) + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(new IllegalStateException( + "Studio player evacuation failed for \"" + loadedWorld.getName() + "\"."))); + evacuations.add(evacuation); + } + return CompletableFuture.allOf(evacuations.toArray(CompletableFuture[]::new)); + } + + private CompletableFuture unloadWorldFamily(String worldName, World primaryWorld) { + List loadedWorlds = loadedWorldFamily(worldName, primaryWorld); + ArrayList> unloads = new ArrayList<>(loadedWorlds.size()); + for (World loadedWorld : loadedWorlds) { + CompletableFuture unload = J.sfut(() -> + IrisServices.get(MultiverseCoreLink.class) + .removeFromConfig(loadedWorld)) + .thenCompose(ignored -> WorldLifecycleService.get().unloadAsync(loadedWorld, false)); + unloads.add(unload); + } + + return CompletableFuture.allOf(unloads.toArray(CompletableFuture[]::new)).thenApply(ignored -> { + for (CompletableFuture unload : unloads) { + if (!Boolean.TRUE.equals(unload.join())) { + throw new CompletionException(new IllegalStateException( + "Studio world family unload returned false for \"" + worldName + "\".")); + } + } + if (isWorldFamilyLoaded(worldName)) { + throw new CompletionException(new IllegalStateException( + "Studio world family remained loaded after confirmed unload for \"" + worldName + "\".")); + } return null; }); - if (future != null) { - future.get(10L, TimeUnit.SECONDS); - } } - private void requestWorldFamilyUnload(String worldName) { + private List loadedWorldFamily(String worldName, World primaryWorld) { + LinkedHashSet worlds = new LinkedHashSet<>(); + if (primaryWorld != null) { + worlds.add(primaryWorld); + } if (worldName == null || worldName.isBlank()) { - return; + return List.copyOf(worlds); } - for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { - World familyWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(familyWorldName)).orElse(null); - if (familyWorld == null) { - continue; - } - - IrisServices.get(art.arcane.iris.core.link.MultiverseCoreLink.class).removeFromConfig(familyWorld); - WorldLifecycleService.get().unload(familyWorld, false); + WorldIdentity.resolve(IrisWorldStorage.keyFromName(familyWorldName)).ifPresent(worlds::add); } + return List.copyOf(worlds); } - private WorldFamilyDeleteResult deleteWorldFamily(String worldName, boolean unloadCompletedLive) { + private CompletableFuture deleteWorldFamily(String worldName) { if (worldName == null || worldName.isBlank()) { - return new WorldFamilyDeleteResult(true, false); + return CompletableFuture.completedFuture(null); + } + if (isWorldFamilyLoaded(worldName)) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Refusing to delete a loaded studio world family for \"" + worldName + "\".")); } - boolean liveDeleted = true; - for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { - File folder = IrisWorldStorage.dimensionRoot(familyWorldName); - if (!folder.exists()) { - continue; + return CompletableFuture.runAsync(() -> { + for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { + try { + if (isWorldFamilyLoaded(worldName)) { + throw new IOException("Studio world family became loaded before deletion for \"" + + worldName + "\"."); + } + File folder = IrisWorldStorage.requireSafeManagedDimensionRoot( + IrisWorldStorage.managedKeyFromName(familyWorldName)); + AtomicDirectoryPublisher.deleteTree(folder.toPath()); + } catch (IOException | IllegalArgumentException failure) { + throw new CompletionException(failure); + } } + }); + } - try { - deleteWorldFolderAsync(folder, 40).get(15L, TimeUnit.SECONDS); - } catch (Throwable e) { - liveDeleted = false; - IrisLogging.reportError("Studio folder deletion retries failed for \"" + folder.getAbsolutePath() + "\".", unwrapFailure(e)); - } - - if (folder.exists()) { - liveDeleted = false; - } + private boolean queueStartupCleanup(String worldName, Throwable failure) { + if (worldName == null || worldName.isBlank()) { + return false; } - - if (liveDeleted) { - return new WorldFamilyDeleteResult(true, false); - } - try { - IrisServices.get(WorldDeletionQueue.class).queueForStartupDeletion(Collections.singleton(worldName)); - return new WorldFamilyDeleteResult(false, true); - } catch (IOException e) { - if (unloadCompletedLive) { - IrisLogging.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", e); + IrisServices.get(WorldDeletionQueue.class).queueFamilyForStartupDeletion(Collections.singleton(worldName)); + return true; + } catch (Throwable queueFailure) { + if (failure != null) { + failure.addSuppressed(queueFailure); } - return new WorldFamilyDeleteResult(false, false); + IrisLogging.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", queueFailure); + return false; } } private void cleanupStaleTransientWorlds(String worldName) { - LinkedHashSet staleWorldNames = TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot()); + LinkedHashSet staleWorldNames = collectSafeTransientWorldNames(); String requestedBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName); if (requestedBaseName != null) { staleWorldNames.add(requestedBaseName); } for (String staleWorldName : staleWorldNames) { - if (WorldIdentity.resolve(IrisWorldStorage.keyFromName(staleWorldName)).isPresent()) { - continue; + try { + StudioCloseResult cleanupResult = closeWorldCoordinated( + null, + staleWorldName, + null, + true, + null + ).get(30L, TimeUnit.SECONDS); + if (cleanupResult.failureCause() != null) { + IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", cleanupResult.failureCause()); + } + } catch (Throwable failure) { + IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", unwrapFailure(failure)); } - - deleteWorldFamily(staleWorldName, true); } } + private LinkedHashSet collectSafeTransientWorldNames() { + LinkedHashSet worldNames = new LinkedHashSet<>(); + Path irisNamespace = IrisWorldStorage.levelRoot() + .toPath() + .toAbsolutePath() + .normalize() + .resolve("dimensions") + .resolve("iris"); + if (!Files.exists(irisNamespace, LinkOption.NOFOLLOW_LINKS)) { + return worldNames; + } + if (Files.isSymbolicLink(irisNamespace) || !Files.isDirectory(irisNamespace, LinkOption.NOFOLLOW_LINKS)) { + IrisLogging.warn("Skipping stale studio cleanup because Iris dimension storage is unsafe: " + irisNamespace); + return worldNames; + } + + try (DirectoryStream children = Files.newDirectoryStream(irisNamespace)) { + for (Path child : children) { + if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + String transientName = TransientWorldCleanupSupport.transientStudioBaseWorldName( + child.getFileName().toString()); + if (transientName != null) { + worldNames.add(transientName); + } + } + } catch (IOException failure) { + IrisLogging.reportError("Failed to inspect stale studio worlds in \"" + irisNamespace + "\".", failure); + } + return worldNames; + } + private void updateStage(StudioOpenRequest request, String stage, double progress) { if (request.progressConsumer() != null) { request.progressConsumer().accept(new StudioOpenProgress(progress, stage)); @@ -419,37 +610,6 @@ public final class StudioOpenCoordinator { }; } - private CompletableFuture waitForWorldFamilyUnload(String worldName, long deadline) { - if (worldName == null || !isWorldFamilyLoaded(worldName) || System.currentTimeMillis() >= deadline) { - return CompletableFuture.completedFuture(null); - } - - return delayFuture(100L).thenCompose(ignored -> waitForWorldFamilyUnload(worldName, deadline)); - } - - private CompletableFuture deleteWorldFolderAsync(File folder, int attemptsRemaining) { - if (folder == null || !folder.exists()) { - return CompletableFuture.completedFuture(null); - } - - IO.delete(folder); - if (!folder.exists()) { - return CompletableFuture.completedFuture(null); - } - - if (attemptsRemaining <= 1) { - return CompletableFuture.failedFuture(new IllegalStateException("World folder still exists after deletion retries: " + folder.getAbsolutePath())); - } - - return delayFuture(250L).thenCompose(ignored -> deleteWorldFolderAsync(folder, attemptsRemaining - 1)); - } - - private CompletableFuture delayFuture(long delayMillis) { - long safeDelay = Math.max(0L, delayMillis); - return CompletableFuture.runAsync(() -> { - }, CompletableFuture.delayedExecutor(safeDelay, TimeUnit.MILLISECONDS)); - } - private Throwable unwrapFailure(Throwable throwable) { Throwable cursor = throwable; while (cursor instanceof CompletionException || cursor instanceof ExecutionException) { @@ -538,7 +698,4 @@ public final class StudioOpenCoordinator { return failureCause == null; } } - - private record WorldFamilyDeleteResult(boolean liveDeleted, boolean startupCleanupQueued) { - } } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldDeletionQueue.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldDeletionQueue.java index edc06cac3..8cd10327a 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldDeletionQueue.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldDeletionQueue.java @@ -25,5 +25,7 @@ import java.util.Collection; * Queues world folders for deletion at next startup. */ public interface WorldDeletionQueue { - int queueForStartupDeletion(Collection worldNames) throws IOException; + int queueExactForStartupDeletion(Collection worldNames) throws IOException; + + int queueFamilyForStartupDeletion(Collection worldNames) throws IOException; } diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index 7870362c5..f242f0884 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -24,8 +24,12 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.DatapackInstallResult; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; @@ -33,7 +37,9 @@ import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.project.IrisPackageCompiler; import art.arcane.iris.core.project.IrisCodeWorkspace; import art.arcane.iris.core.project.IrisProjectCopier; +import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; +import art.arcane.iris.core.runtime.WorldDeletionQueue; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.IrisDimension; @@ -43,22 +49,32 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.exceptions.IrisException; import art.arcane.volmlib.util.io.IO; -import art.arcane.volmlib.util.json.JSONException; import art.arcane.volmlib.util.json.JSONObject; import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import org.bukkit.Bukkit; import org.bukkit.World; -import org.zeroturnaround.zip.ZipUtil; -import org.zeroturnaround.zip.commons.FileUtils; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.regex.Pattern; +import java.util.stream.Stream; import art.arcane.iris.core.localization.BukkitRuntimeMessages; import art.arcane.iris.core.localization.IrisLanguage; @@ -67,10 +83,12 @@ import art.arcane.volmlib.util.localization.MessageArgument; public class StudioSVC implements IrisService { public static final String LISTING = "https://raw.githubusercontent.com/IrisDimensions/_listing/main/listing-v2.json"; public static final String WORKSPACE_NAME = "packs"; + private static final Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+"); private static final AtomicCache counter = new AtomicCache<>(); private final KMap cacheListing = null; - private IrisProject activeProject; - private CompletableFuture activeClose; + private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue(); + private volatile IrisProject activeProject; + private volatile CompletableFuture activeOpen; @Override public void onEnable() { @@ -83,7 +101,6 @@ public class StudioSVC implements IrisService { if (PackDownloader.isDefaultOverworld(pack)) { IrisLogging.info("Downloading Default Pack " + pack + " (beta release)"); IrisServices.get(StudioSVC.class).downloadDefaultOverworld(BukkitPlatform.console(), false); - ServerConfigurator.installDataPacksIfChanged(true); } else { IrisLogging.warn("Default pack '" + pack + "' is not installed. Please download it manually with /iris download " + pack); } @@ -141,90 +158,163 @@ public class StudioSVC implements IrisService { } public IrisDimension installIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) { - File target = new File(folder, "iris/pack"); - File source = dimension.getLoader().getDataFolder(); - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_INSTALLING_PACKAGE, MessageArgument.untrusted("name", String.valueOf(source.getName())), MessageArgument.untrusted("loadKey", String.valueOf(dimension.getLoadKey())))); - try { - FileUtils.copyDirectory(source, target); - } catch (IOException e) { - IrisLogging.reportError(e); + return installIntoDirectory(sender, dimension, new File(folder, "iris/pack"), false); + } + + public IrisDimension replaceIntoWorld(VolmitSender sender, IrisDimension dimension, File folder) { + return installIntoDirectory(sender, dimension, new File(folder, "iris/pack"), true); + } + + public IrisDimension replaceIntoPackDirectory(VolmitSender sender, IrisDimension dimension, File folder) { + return installIntoDirectory(sender, dimension, folder, true); + } + + private IrisDimension installIntoDirectory( + VolmitSender sender, + IrisDimension dimension, + File folder, + boolean replaceExisting + ) { + if (J.isPrimaryThread()) { + sender.sendMessage("Iris refused to copy a pack on the Bukkit primary thread."); return null; } - return IrisData.get(target).getDimensionLoader().load(dimension.getLoadKey()); + String dimensionKey = dimension.getLoadKey(); + Path source; + try { + source = resolveSafePackSource(dimension.getLoader().getDataFolder()); + } catch (IOException e) { + IrisLogging.reportError("Failed to inspect source dimension pack '" + dimensionKey + "'.", e); + sender.sendMessage("Failed to install studio pack '" + dimensionKey + "': " + errorDetail(e)); + return null; + } + Path target = folder.toPath().toAbsolutePath().normalize(); + Path parent = target.getParent(); + Path stage = null; + AtomicDirectoryPublisher.Publication publication = null; + IrisData previousData = IrisData.getLoaded(target.toFile()).orElse(null); + IrisData createdData = null; + boolean refreshedPreviousData = false; + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_INSTALLING_PACKAGE, MessageArgument.untrusted("name", String.valueOf(source.getFileName())), MessageArgument.untrusted("loadKey", String.valueOf(dimensionKey)))); + try { + if (parent == null) { + throw new IOException("World pack target has no parent: " + target); + } + Files.createDirectories(parent); + if (!replaceExisting + && (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target))) { + throw new FileAlreadyExistsException(target.toString()); + } + + stage = Files.createTempDirectory(parent, ".pack.installing-"); + copyPackTree(source, stage); + IrisData stagedData = IrisData.openDatapackCompiler(stage.toFile()); + try { + IrisDimension stagedDimension = stagedData.getDimensionLoader().load(dimensionKey); + if (stagedDimension == null) { + throw new IOException("Copied pack does not contain a loadable dimension '" + dimensionKey + "'."); + } + } finally { + stagedData.close(); + } + publication = AtomicDirectoryPublisher.publish(stage, target); + stage = null; + + IrisData installedData; + boolean activeRuntime = previousData != null && !previousData.getEngines().isEmpty(); + if (previousData == null) { + createdData = IrisData.get(target.toFile()); + installedData = createdData; + } else if (!activeRuntime) { + previousData.hotloaded(); + refreshedPreviousData = true; + installedData = previousData; + } else { + installedData = previousData; + } + IrisDimension installedDimension = activeRuntime + ? dimension + : installedData.getDimensionLoader().load(dimensionKey); + if (installedDimension == null) { + throw new IOException("Published pack does not contain a loadable dimension '" + dimensionKey + "'."); + } + publication.commit(); + try { + publication.cleanupBackup(); + } catch (IOException cleanupFailure) { + IrisLogging.warn("World pack was committed but its backup could not be removed: " + + cleanupFailure.getMessage()); + } + if (activeRuntime) { + ServerConfigurator.restart("An active Iris world pack was replaced."); + } + return installedDimension; + } catch (Throwable e) { + rollbackFailedPublication(createdData, publication, e); + if (refreshedPreviousData) { + try { + previousData.hotloaded(); + } catch (Throwable restoreFailure) { + e.addSuppressed(restoreFailure); + } + } + IrisLogging.reportError("Failed to install dimension pack '" + dimensionKey + "' into " + folder.getPath(), e); + sender.sendMessage("Failed to install studio pack '" + dimensionKey + "': " + errorDetail(e)); + return null; + } finally { + if (stage != null) { + try { + AtomicDirectoryPublisher.deleteTree(stage); + } catch (IOException cleanupFailure) { + IrisLogging.reportError("Failed to clean staged world pack " + stage, cleanupFailure); + } + } + } + } + + static void rollbackFailedPublication( + IrisData createdData, + AtomicDirectoryPublisher.Publication publication, + Throwable failure + ) { + if (createdData != null) { + try { + createdData.close(); + } catch (Throwable cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + if (publication != null) { + try { + publication.close(); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + } + } + + static Path resolveSafePackSource(File sourceFolder) throws IOException { + PackDirectoryResolver.requireSafePackTree(sourceFolder); + Path source = sourceFolder.toPath().toAbsolutePath().normalize().toRealPath(); + if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(source)) { + throw new IOException("Source pack is missing or unsafe: " + sourceFolder); + } + return source; } public IrisDimension installInto(VolmitSender sender, String type, File folder) { + if (J.isPrimaryThread()) { + sender.sendMessage("Iris refused to download or copy a pack on the Bukkit primary thread."); + return null; + } sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_LOOKING_PACKAGE, MessageArgument.untrusted("type", String.valueOf(type)))); - IrisDimension dim = IrisData.loadAnyDimension(type, null); - - if (dim == null) { - File[] workspaceFiles = getWorkspaceFolder().listFiles(); - if (workspaceFiles != null) { - for (File i : workspaceFiles) { - if (i.isFile() && i.getName().equals(type + ".iris")) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FOUND_IRIS_FOLDER, MessageArgument.untrusted("type", String.valueOf(type)), MessageArgument.untrusted("WORKSPACENAME", String.valueOf(WORKSPACE_NAME)))); - ZipUtil.unpack(i, folder); - break; - } - } - } - } else { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FOUND_DIMENSION_FOLDER_REPACKAGING, MessageArgument.untrusted("type", String.valueOf(type)), MessageArgument.untrusted("WORKSPACENAME", String.valueOf(WORKSPACE_NAME)))); - File f = new IrisProject(new File(getWorkspaceFolder(), type)).getPath(); - - try { - FileUtils.copyDirectory(f, folder); - } catch (IOException e) { - IrisLogging.reportError(e); - } - } - - File dimensionFile = new File(folder, "dimensions/" + type + ".json"); - - if (!dimensionFile.exists() || !dimensionFile.isFile()) { - downloadSearch(sender, type, false); - File downloaded = getWorkspaceFolder(type); - File[] files = downloaded.listFiles(); - - if (files != null) { - for (File i : files) { - if (i.isFile()) { - try { - FileUtils.copyFile(i, new File(folder, i.getName())); - } catch (IOException e) { - e.printStackTrace(); - IrisLogging.reportError(e); - } - } else { - try { - FileUtils.copyDirectory(i, new File(folder, i.getName())); - } catch (IOException e) { - e.printStackTrace(); - IrisLogging.reportError(e); - } - } - } - // The downloaded pack stays in the packs workspace: deleting it here made the - // next startup see a missing pack and download it again. - } - } - - if (!dimensionFile.exists() || !dimensionFile.isFile()) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_CAN_T_FIND_DIMENSIONS_FOLDER_THIS_PACK_FAILED, MessageArgument.untrusted("name", String.valueOf(dimensionFile.getName())))); + IrisDimension dimension = IrisData.loadAnyDimension(type, null); + if (dimension == null) { + sender.sendMessage("Iris cannot repair world pack '" + type + + "' because no installed source contains it."); return null; } - - IrisData dm = IrisData.get(folder); - dm.hotloaded(); - dim = dm.getDimensionLoader().load(type); - - if (dim == null) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_CAN_T_LOAD_DIMENSION_FAILED)); - return null; - } - - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_TYPE_INSTALLED, MessageArgument.untrusted("name", String.valueOf(folder.getName())))); - return dim; + return replaceIntoPackDirectory(sender, dimension, folder); } public void downloadSearch(VolmitSender sender, String key) { @@ -232,64 +322,25 @@ public class StudioSVC implements IrisService { } public void downloadSearch(VolmitSender sender, String key, boolean forceOverwrite) { - // The default overworld always comes from the pinned release - // (PackDownloader.DEFAULT_OVERWORLD_RELEASE_URL), never from the listing, - // so every code path ships the same pack build. - if (PackDownloader.isDefaultOverworld(key)) { - downloadDefaultOverworld(sender, forceOverwrite); - return; - } - - try { - String url = getListing(false).get(key); - - if (url == null) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_PACK_WAS_NOT_FOUND_PACK_LISTING, MessageArgument.untrusted("key", String.valueOf(key)))); - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_USE_IRIS_DOWNLOAD_PACK_BRANCH_BRANCH_DOWNLOAD_MANUALLY)); - return; - } - - IrisLogging.info("Resolved pack '" + key + "' to " + url); - String[] nodes = url.split("\\Q/\\E"); - String repo = nodes.length == 1 ? "IrisDimensions/" + nodes[0] : nodes[0] + "/" + nodes[1]; - String branch = nodes.length > 2 ? nodes[2] : "stable"; - String expectedKey = key.contains("/") ? null : key; - download(sender, repo, branch, forceOverwrite, false, expectedKey); - } catch (Throwable e) { - IrisLogging.reportError(e); - e.printStackTrace(); - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD, MessageArgument.untrusted("key", String.valueOf(key)))); - } + runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> { + DownloadOutcome outcome = downloadSearchLocked(sender, key, forceOverwrite); + return finishStandalonePackMutation(sender, outcome); + }, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD, MessageArgument.untrusted("key", String.valueOf(key)))); } public void downloadDefaultOverworld(VolmitSender sender, boolean forceOverwrite) { - // Same guard as download(): a present pack must not reach installDataPacks(true), - // which can trigger an automatic restart. - if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), PackDownloader.defaultOverworldPack())) { - sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", PackDownloader.defaultOverworldPack()))); - return; - } - - try { - String key = PackDownloader.downloadDefaultOverworld(getWorkspaceFolder(), forceOverwrite, sender::sendMessage); - if (key != null) { - ServerConfigurator.installDataPacks(true); - } - } catch (Throwable e) { - IrisLogging.reportError(e); - e.printStackTrace(); - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_IRISDIMENSIONS_OVERWORLD_BETA_RELEASE)); - } + String key = PackDownloader.defaultOverworldPack(); + runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> { + DownloadOutcome outcome = downloadDefaultOverworldLocked(sender, forceOverwrite); + return finishStandalonePackMutation(sender, outcome); + }, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_IRISDIMENSIONS_OVERWORLD_BETA_RELEASE)); } public void downloadBranch(VolmitSender sender, String repo, String branch, boolean forceOverwrite) { - try { - download(sender, repo, branch, forceOverwrite, false); - } catch (Throwable e) { - IrisLogging.reportError(e); - e.printStackTrace(); - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_BRANCH, MessageArgument.untrusted("repo", String.valueOf(repo)), MessageArgument.untrusted("branch", String.valueOf(branch)))); - } + runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, repo + "/" + branch, () -> { + DownloadOutcome outcome = downloadLocked(sender, repo, branch, forceOverwrite, false, null); + return finishStandalonePackMutation(sender, outcome); + }, IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_DOWNLOAD_BRANCH, MessageArgument.untrusted("repo", String.valueOf(repo)), MessageArgument.untrusted("branch", String.valueOf(branch)))); } public void download(VolmitSender sender, String repo, String branch) throws JsonSyntaxException, IOException { @@ -301,20 +352,83 @@ public class StudioSVC implements IrisService { } public void download(VolmitSender sender, String repo, String branch, boolean forceOverwrite, boolean directUrl, String expectedKey) throws JsonSyntaxException, IOException { - // Skip before PackDownloader so an already-present pack never reaches - // installDataPacks(true), which can trigger an automatic restart. + String target = expectedKey == null ? repo + "/" + branch : expectedKey; + runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, target, () -> { + DownloadOutcome outcome = downloadLocked(sender, repo, branch, forceOverwrite, directUrl, expectedKey); + return finishStandalonePackMutation(sender, outcome); + }, "Failed to download Iris pack '" + target + "'."); + } + + private DownloadOutcome downloadSearchLocked(VolmitSender sender, String key, boolean forceOverwrite) throws IOException { + if (PackDownloader.isDefaultOverworld(key)) { + return downloadDefaultOverworldLocked(sender, forceOverwrite); + } + + String descriptor = key.contains("/") ? key : getListing(false).get(key); + if (descriptor == null) { + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_PACK_WAS_NOT_FOUND_PACK_LISTING, MessageArgument.untrusted("key", String.valueOf(key)))); + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_USE_IRIS_DOWNLOAD_PACK_BRANCH_BRANCH_DOWNLOAD_MANUALLY)); + return DownloadOutcome.notChanged(); + } + + PackListingReference reference = resolvePackListingReference(key, descriptor); + IrisLogging.info("Resolved pack '" + key + "' to " + reference.repository() + "/" + reference.ref()); + return downloadLocked( + sender, + reference.repository(), + reference.ref(), + forceOverwrite, + false, + reference.expectedKey() + ); + } + + static PackListingReference resolvePackListingReference(String key, String descriptor) { + String[] nodes = descriptor.split("\\Q/\\E"); + String repository = nodes.length == 1 ? "IrisDimensions/" + nodes[0] : nodes[0] + "/" + nodes[1]; + String ref = nodes.length > 2 ? nodes[2] : "HEAD"; + String expectedKey = key.contains("/") && nodes.length > 1 ? nodes[1] : key; + return new PackListingReference(repository, ref, expectedKey); + } + + private DownloadOutcome downloadDefaultOverworldLocked(VolmitSender sender, boolean forceOverwrite) throws IOException { + String expectedKey = PackDownloader.defaultOverworldPack(); if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), expectedKey)) { sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); - return; + return DownloadOutcome.notChanged(); } - String key = PackDownloader.download(getWorkspaceFolder(), repo, branch, forceOverwrite, directUrl, expectedKey, sender::sendMessage); + PackDownloader.PackInstallResult result = PackDownloader.downloadDefaultOverworld( + getWorkspaceFolder(), + forceOverwrite, + sender::sendMessage + ); + return DownloadOutcome.from(result); + } - if (key == null) { - return; + private DownloadOutcome downloadLocked( + VolmitSender sender, + String repo, + String branch, + boolean forceOverwrite, + boolean directUrl, + String expectedKey + ) throws IOException { + if (!forceOverwrite && PackDownloader.isPackPresent(getWorkspaceFolder(), expectedKey)) { + sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); + return DownloadOutcome.notChanged(); } - ServerConfigurator.installDataPacks(true); + PackDownloader.PackInstallResult result = PackDownloader.download( + getWorkspaceFolder(), + repo, + branch, + forceOverwrite, + directUrl, + expectedKey, + sender::sendMessage + ); + return DownloadOutcome.from(result); } public KMap getListing(boolean cached) { @@ -371,39 +485,84 @@ public class StudioSVC implements IrisService { if (blockIfPackBroken(sender, dimm)) { return; } - CompletableFuture pendingClose = close(); - pendingClose.whenComplete((closeResult, closeThrowable) -> { - if (closeThrowable != null) { - IrisLogging.reportError("Failed while closing an existing studio project before opening \"" + dimm + "\".", closeThrowable); - J.s(() -> sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_CLOSE_EXISTING_STUDIO_PROJECT, MessageArgument.untrusted("error", String.valueOf(closeThrowable.getMessage()))))); - return; - } - - if (closeResult != null && closeResult.failureCause() != null) { - Throwable failure = closeResult.failureCause(); - IrisLogging.reportError("Failed while closing an existing studio project before opening \"" + dimm + "\".", failure); - J.s(() -> sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_CLOSE_EXISTING_STUDIO_PROJECT_2, MessageArgument.untrusted("error", String.valueOf(failure.getMessage()))))); - return; - } - - IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimm)); - activeProject = project; - try { - project.open(sender, seed, onDone).whenComplete((result, throwable) -> { + studioTransitions.submit(() -> replaceActiveProject(sender, seed, dimm, onDone)) + .whenComplete((ignored, throwable) -> { if (throwable == null) { return; } - - if (activeProject == project && !project.isOpen()) { - activeProject = null; - } + IrisLogging.reportError("Failed to replace the active studio project with \"" + dimm + "\".", throwable); + J.s(() -> sender.sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_OPEN_STUDIO_WORLD_2, + MessageArgument.untrusted("error", String.valueOf(errorDetail(throwable)))))); }); - } catch (IrisException e) { - if (activeProject == project) { - activeProject = null; - } - J.s(() -> sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_OPEN_STUDIO_WORLD_2, MessageArgument.untrusted("error", String.valueOf(e.getMessage()))))); + } + + private CompletableFuture replaceActiveProject( + VolmitSender sender, + long seed, + String dimension, + Consumer onDone + ) { + return closeActiveProject().handle((closeResult, closeThrowable) -> { + if (closeThrowable != null) { + IrisLogging.reportError("Failed while closing an existing studio project before opening \"" + dimension + "\".", closeThrowable); + J.s(() -> sender.sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_CLOSE_EXISTING_STUDIO_PROJECT, + MessageArgument.untrusted("error", String.valueOf(errorDetail(closeThrowable)))))); + return false; } + if (closeResult == null) { + IllegalStateException failure = new IllegalStateException("Studio close completed without a result."); + IrisLogging.reportError("Failed while closing an existing studio project before opening \"" + dimension + "\".", failure); + J.s(() -> sender.sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_CLOSE_EXISTING_STUDIO_PROJECT_2, + MessageArgument.untrusted("error", String.valueOf(errorDetail(failure)))))); + return false; + } + if (closeResult.failureCause() != null) { + Throwable failure = closeResult.failureCause(); + IrisLogging.reportError("Failed while closing an existing studio project before opening \"" + dimension + "\".", failure); + J.s(() -> sender.sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_CLOSE_EXISTING_STUDIO_PROJECT_2, + MessageArgument.untrusted("error", String.valueOf(errorDetail(failure)))))); + return false; + } + return true; + }).thenCompose(closed -> closed + ? beginStudioOpen(sender, seed, dimension, onDone) + : CompletableFuture.completedFuture(null)); + } + + private CompletableFuture beginStudioOpen( + VolmitSender sender, + long seed, + String dimension, + Consumer onDone + ) { + IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimension)); + activeProject = project; + CompletableFuture opening; + try { + opening = project.open(sender, seed, onDone); + } catch (IrisException e) { + if (activeProject == project) { + activeProject = null; + } + J.s(() -> sender.sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.STUDIO_S_V_C_FAILED_OPEN_STUDIO_WORLD_2, + MessageArgument.untrusted("error", String.valueOf(errorDetail(e)))))); + return CompletableFuture.completedFuture(null); + } + + activeOpen = opening; + return opening.handle((result, throwable) -> { + if (activeOpen == opening) { + activeOpen = null; + } + if (throwable != null && activeProject == project && !project.isOpen()) { + activeProject = null; + } + return null; }); } @@ -419,60 +578,63 @@ public class StudioSVC implements IrisService { return art.arcane.iris.platform.bukkit.BukkitPlatform.volmitPlugin().getDataFileList(WORKSPACE_NAME, sub); } - public CompletableFuture close() { - if (activeClose != null && !activeClose.isDone()) { - return activeClose; - } + public CompletableFuture close() { + return studioTransitions.submit(this::closeActiveProject); + } - if (activeProject == null) { - return CompletableFuture.completedFuture(new art.arcane.iris.core.runtime.StudioOpenCoordinator.StudioCloseResult(null, true, true, false, null)); + private CompletableFuture closeActiveProject() { + IrisProject project = activeProject; + if (project == null) { + return CompletableFuture.completedFuture(new StudioOpenCoordinator.StudioCloseResult( + null, + true, + true, + false, + null + )); } IrisLogging.debug("Closing Active Project"); - IrisProject project = activeProject; - activeProject = null; - activeClose = project.close(); - activeClose.whenComplete((result, throwable) -> activeClose = null); - return activeClose; + CompletableFuture closing = project.close(); + return closing.whenComplete((result, throwable) -> { + if (throwable == null + && result != null + && result.failureCause() == null + && activeProject == project) { + activeProject = null; + } + }); } private void destroyStudioWorld(World world, PlatformChunkGenerator generator) { + IrisToolbelt.beginWorldMaintenance(world, "studio-disable", true); try { IrisToolbelt.evacuate(world); } catch (Throwable e) { IrisLogging.reportError("Failed to evacuate studio world \"" + world.getName() + "\" during shutdown cleanup.", e); } - - if (generator != null) { - try { - generator.close(); - } catch (Throwable e) { - IrisLogging.reportError("Failed to close studio generator for \"" + world.getName() + "\" during shutdown cleanup.", e); - } - } - try { - WorldLifecycleService.get().unload(world, false); + WorldLifecycleService.get().unloadAsync(world, false) + .thenCompose(unloaded -> { + if (!Boolean.TRUE.equals(unloaded) || generator == null) { + return CompletableFuture.completedFuture(Boolean.TRUE.equals(unloaded)); + } + return generator.closeAsync().thenApply(ignored -> true); + }) + .whenComplete((unloaded, throwable) -> { + IrisToolbelt.endWorldMaintenance(world, "studio-disable"); + if (throwable != null) { + IrisLogging.reportError("Failed to unload studio world \"" + world.getName() + + "\" during disable cleanup; startup deletion remains queued.", throwable); + } else if (!Boolean.TRUE.equals(unloaded)) { + IrisLogging.warn("Studio world \"" + world.getName() + + "\" remained loaded during disable cleanup; startup deletion remains queued."); + } + }); } catch (Throwable e) { + IrisToolbelt.endWorldMaintenance(world, "studio-disable"); IrisLogging.reportError("Failed to unload studio world \"" + world.getName() + "\" during shutdown cleanup.", e); } - - deleteTransientStudioFolders(IrisWorldStorage.logicalName(world)); - } - - private void deleteTransientStudioFolders(String worldName) { - if (worldName == null || worldName.isBlank()) { - return; - } - - for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { - File folder = IrisWorldStorage.dimensionRoot(familyWorldName); - if (!folder.exists()) { - continue; - } - - IO.delete(folder); - } } private void queueStudioWorldDeletionOnStartup(LinkedHashSet worldNamesToDelete) { @@ -498,7 +660,7 @@ public class StudioSVC implements IrisService { } try { - IrisServices.get(art.arcane.iris.core.runtime.WorldDeletionQueue.class).queueForStartupDeletion(List.copyOf(normalizedNames)); + IrisServices.get(WorldDeletionQueue.class).queueFamilyForStartupDeletion(List.copyOf(normalizedNames)); } catch (IOException e) { IrisLogging.reportError("Failed to queue studio world deletion on startup.", e); } @@ -508,68 +670,509 @@ public class StudioSVC implements IrisService { return new IrisPackageCompiler(new IrisProject(new File(getWorkspaceFolder(), d))).compilePackage(sender, obfuscate, minify); } - public void createFrom(String existingPack, String newName) { - File importPack = getWorkspaceFolder(existingPack); - File newPack = getWorkspaceFolder(newName); - - if (importPack.listFiles().length == 0) { - IrisLogging.warn("Couldn't find the pack to create a new dimension from."); - return; + private void createFrom(File sourcePack, String sourceDimensionKey, String newName) throws IOException { + if (J.isPrimaryThread()) { + throw new IOException("Studio project copying cannot run on the Bukkit primary thread."); } + String sourceKey = normalizeProjectName(sourceDimensionKey); + String targetName = normalizeProjectName(newName); + File workspace = requireSafeWorkspace(getWorkspaceFolder()); + File newPack = new File(workspace, targetName); + IrisProjectCopier.copyProject(sourcePack, workspace, sourceKey, targetName); try { - IrisProjectCopier.copyProject(importPack, newPack, existingPack, newName); - } catch (JSONException | IOException e) { - IrisLogging.reportError(e); - e.printStackTrace(); - } - - try { - IrisProject p = new IrisProject(getWorkspaceFolder(newName)); - JSONObject ws = new IrisCodeWorkspace(p).createCodeWorkspaceConfig(); - IO.writeAll(getWorkspaceFile(newName, newName + ".code-workspace"), ws.toString(0)); - } catch (JSONException | IOException e) { - IrisLogging.reportError(e); - e.printStackTrace(); + IrisProject project = new IrisProject(newPack); + JSONObject workspaceConfiguration = new IrisCodeWorkspace(project).createCodeWorkspaceConfig(); + writeWorkspaceAtomically(newPack.toPath(), targetName, workspaceConfiguration.toString(0)); + } catch (Throwable e) { + try { + AtomicDirectoryPublisher.deleteTree(newPack.toPath()); + } catch (IOException cleanupFailure) { + e.addSuppressed(cleanupFailure); + } + if (e instanceof Error error) { + throw error; + } + throw new IOException("Failed to create the editor workspace for project '" + targetName + "'.", e); } } public void create(VolmitSender sender, String s, String downloadable) { - boolean shouldDelete = false; - File importPack = getWorkspaceFolder(downloadable); - File[] packFiles = importPack.listFiles(); + Runnable work = () -> createProject(sender, s, downloadable, null); + runOffPrimaryThread(work); + } - if (packFiles == null || packFiles.length == 0) { - downloadSearch(sender, downloadable, false); - packFiles = importPack.listFiles(); - - if (packFiles != null && packFiles.length > 0) { - shouldDelete = true; - } - } - - if (packFiles == null || packFiles.length == 0) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_COULDN_T_FIND_PACK_CREATE_NEW_DIMENSION_FROM)); - return; - } - - File importDimensionFile = new File(importPack, "dimensions/" + downloadable + ".json"); - - if (!importDimensionFile.exists()) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MISSING_IMPORTED_DIMENSION_FILE)); - return; - } - - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_IMPORTING_INTO_NEW_PROJECT, MessageArgument.untrusted("downloadable", String.valueOf(downloadable)), MessageArgument.untrusted("s", String.valueOf(s)))); - createFrom(downloadable, s); - if (shouldDelete) { - importPack.delete(); - } - open(sender, s); + public void create(VolmitSender sender, String name, IrisDimension template) { + IrisData loader = template == null ? null : template.getLoader(); + File sourcePack = loader == null ? null : loader.getDataFolder(); + String sourceKey = template == null ? null : template.getLoadKey(); + Runnable work = () -> createProject(sender, name, sourceKey, sourcePack); + runOffPrimaryThread(work); } public void create(VolmitSender sender, String s) { - create(sender, s, "example"); + Runnable work = () -> createProject(sender, s, null, null); + runOffPrimaryThread(work); + } + + private void createProject(VolmitSender sender, String requestedName, String requestedTemplate, File selectedTemplatePack) { + String normalizedName; + String templateName; + File workspace; + try { + normalizedName = normalizeProjectName(requestedName); + templateName = requestedTemplate == null ? null : normalizeProjectName(requestedTemplate); + workspace = requireSafeWorkspace(getWorkspaceFolder()); + } catch (IOException e) { + sender.sendMessage("Studio project creation refused: " + errorDetail(e)); + return; + } + + LifecycleOperationCoordinator.Lease lease; + try { + lease = LifecycleOperationCoordinator.get().acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_CREATE, + normalizedName + ); + } catch (LifecycleOperationCoordinator.BusyException e) { + sendBusy(sender, e); + return; + } + + String projectName = normalizedName; + File createdPack = null; + boolean projectPublished = false; + CreationOutcome outcome = CreationOutcome.FAILED; + try { + if ("studio".equals(projectName)) { + projectName = nextAvailableProjectName(workspace, projectName); + } + + File newPack = new File(workspace, projectName); + createdPack = newPack; + if (Files.exists(newPack.toPath(), LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(newPack.toPath())) { + sender.sendMessage("Studio project '" + projectName + "' already exists; nothing was changed."); + return; + } + + if (templateName == null) { + createStarterProject(workspace, projectName); + } else { + File importPack = selectedTemplatePack == null ? new File(workspace, templateName) : selectedTemplatePack; + if (selectedTemplatePack == null && !hasLoadableDimensionFile(importPack, templateName)) { + downloadSearchLocked(sender, templateName, false); + } + if (!hasLoadableDimensionFile(importPack, templateName)) { + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_COULDN_T_FIND_PACK_CREATE_NEW_DIMENSION_FROM)); + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_MISSING_IMPORTED_DIMENSION_FILE)); + return; + } + + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STUDIO_S_V_C_IMPORTING_INTO_NEW_PROJECT, MessageArgument.untrusted("downloadable", String.valueOf(templateName)), MessageArgument.untrusted("s", String.valueOf(projectName)))); + createFrom(importPack, templateName, projectName); + } + projectPublished = true; + + DatapackInstallResult installResult = ServerConfigurator.installDataPacksIfChanged(true); + CreationOutcome installOutcome = switch (installResult.status()) { + case FAILED -> CreationOutcome.FAILED; + case RESTART_REQUIRED -> CreationOutcome.RESTART; + case READY, UNCHANGED -> CreationOutcome.OPEN; + }; + if (installOutcome == CreationOutcome.FAILED) { + rollbackCreatedProject(sender, newPack, "Datapack installation failed; the new project was rolled back."); + return; + } + + sender.sendMessage("Created studio project '" + projectName + "' at " + newPack.getAbsolutePath() + "."); + if (installOutcome == CreationOutcome.RESTART) { + sender.sendMessage("The project is complete, but Iris must restart before opening it. After restart, run /iris studio open " + projectName + "."); + } + outcome = installOutcome; + } catch (Throwable e) { + if (projectPublished && createdPack != null) { + rollbackCreatedProject(sender, createdPack, "Studio project creation failed; the new project was rolled back."); + } + IrisLogging.reportError("Failed to create studio project '" + projectName + "'.", e); + sender.sendMessage("Studio project creation failed: " + errorDetail(e)); + } finally { + closeLease(lease); + } + + if (outcome == CreationOutcome.RESTART) { + ServerConfigurator.restart(); + } else if (outcome == CreationOutcome.OPEN) { + String completedProjectName = projectName; + LifecycleOperationCoordinator.get().whenIdle(() -> open(sender, completedProjectName)); + } + } + + private void runPackMutation( + VolmitSender sender, + LifecycleOperationCoordinator.OperationKind operationKind, + String target, + PackMutation mutation, + String failureMessage + ) { + Runnable work = () -> { + String operationTarget = target == null || target.isBlank() ? "unspecified-pack" : target.trim(); + LifecycleOperationCoordinator.Lease lease; + try { + lease = LifecycleOperationCoordinator.get().acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + operationKind, + operationTarget + ); + } catch (LifecycleOperationCoordinator.BusyException e) { + sendBusy(sender, e); + return; + } + + boolean restartRequired; + try { + restartRequired = mutation.run(); + } catch (Throwable e) { + IrisLogging.reportError(failureMessage, e); + sender.sendMessage(failureMessage + " " + errorDetail(e)); + return; + } finally { + closeLease(lease); + } + + if (restartRequired) { + sender.sendMessage("Iris must restart before the new pack data can be used."); + ServerConfigurator.restart(); + } + }; + runOffPrimaryThread(work); + } + + private boolean finishStandalonePackMutation(VolmitSender sender, DownloadOutcome outcome) { + if (!outcome.changed()) { + return false; + } + + DatapackInstallResult installResult = ServerConfigurator.installDataPacksIfChanged(true); + return switch (installResult.status()) { + case FAILED -> { + sender.sendMessage("The pack was downloaded, but Iris could not install its datapack output."); + yield false; + } + case RESTART_REQUIRED -> true; + case READY, UNCHANGED -> outcome.restartRequired(); + }; + } + + private void runOffPrimaryThread(Runnable work) { + if (J.isPrimaryThread()) { + J.a(work); + return; + } + work.run(); + } + + static String normalizeProjectName(String value) throws IOException { + if (value == null) { + throw new IOException("Project name cannot be empty."); + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty() || normalized.length() > 64 || !PROJECT_NAME.matcher(normalized).matches()) { + throw new IOException("Invalid project name '" + value + "' (allowed: lowercase a-z, 0-9, _ and -)."); + } + return normalized; + } + + static File requireSafeWorkspace(File workspaceFolder) throws IOException { + if (workspaceFolder == null) { + throw new IOException("Pack workspace is unavailable."); + } + Path workspace = workspaceFolder.toPath().toAbsolutePath().normalize(); + Files.createDirectories(workspace); + if (Files.isSymbolicLink(workspace) || !Files.isDirectory(workspace, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Pack workspace is missing or unsafe: " + workspace); + } + return workspace.toFile(); + } + + static String nextAvailableProjectName(File workspace, String baseName) throws IOException { + String normalizedBase = normalizeProjectName(baseName); + Path root = requireSafeWorkspace(workspace).toPath(); + String candidate = normalizedBase; + int suffix = 2; + while (Files.exists(root.resolve(candidate), LinkOption.NOFOLLOW_LINKS)) { + candidate = normalizedBase + suffix++; + } + return candidate; + } + + static void createStarterProject(File workspace, String projectName) throws IOException { + File safeWorkspace = requireSafeWorkspace(workspace); + String safeName = normalizeProjectName(projectName); + Path target = safeWorkspace.toPath().resolve(safeName); + Path stage = Files.createTempDirectory(safeWorkspace.toPath(), ".iris-starter-" + safeName + "-"); + boolean published = false; + try { + Files.createDirectories(stage.resolve("dimensions")); + Files.createDirectories(stage.resolve("regions")); + Files.createDirectories(stage.resolve("biomes")); + Files.createDirectories(stage.resolve("generators")); + Files.writeString(stage.resolve("dimensions/" + safeName + ".json"), """ + { + "name": "%s", + "version": 1, + "regions": ["starter"], + "logicalHeight": 384, + "dimensionHeight": {"min": -64, "max": 320} + } + """.formatted(safeName), StandardCharsets.UTF_8); + Files.writeString(stage.resolve("regions/starter.json"), """ + { + "name": "Starter", + "landBiomes": ["starter"], + "seaBiomes": ["starter"], + "shoreBiomes": ["starter"] + } + """, StandardCharsets.UTF_8); + Files.writeString(stage.resolve("biomes/starter.json"), """ + { + "name": "Starter Plains", + "layers": [{"palette": [{"block": "minecraft:grass_block"}]}], + "generators": [{"generator": "flat", "min": 96, "max": 96}], + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains" + } + """, StandardCharsets.UTF_8); + Files.writeString(stage.resolve("generators/flat.json"), """ + { + "interpolator": {"function": "NONE", "horizontalScale": 1}, + "seed": 310, + "composite": [{"seed": 310, "style": {"style": "FLAT"}}] + } + """, StandardCharsets.UTF_8); + publishNewDirectory(stage, target); + published = true; + IrisProject project = new IrisProject(target.toFile()); + JSONObject workspaceConfiguration = new IrisCodeWorkspace(project).createCodeWorkspaceConfig(); + writeWorkspaceAtomically(target, safeName, workspaceConfiguration.toString(0)); + } catch (Throwable failure) { + if (published) { + Throwable cleanupFailure = rollbackCreatedProjectFiles(target.toFile()); + if (cleanupFailure != null && cleanupFailure != failure) { + failure.addSuppressed(cleanupFailure); + } + } + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof IOException ioFailure) { + throw ioFailure; + } + throw new IOException("Failed to create starter project '" + safeName + "'.", failure); + } finally { + AtomicDirectoryPublisher.deleteTree(stage); + } + } + + private static boolean hasLoadableDimensionFile(File pack, String key) { + Path packPath = pack.toPath().toAbsolutePath().normalize(); + Path dimension = packPath.resolve("dimensions").resolve(key + ".json").normalize(); + try { + PackDirectoryResolver.requireSafePackTree(pack); + } catch (IOException exception) { + return false; + } + return dimension.startsWith(packPath) + && Files.isDirectory(packPath) + && !Files.isSymbolicLink(dimension) + && Files.isRegularFile(dimension, LinkOption.NOFOLLOW_LINKS); + } + + private static void rollbackCreatedProject(VolmitSender sender, File project, String message) { + Throwable cleanupFailure = rollbackCreatedProjectFiles(project); + if (cleanupFailure == null) { + sender.sendMessage(message); + return; + } + IrisLogging.reportError("Failed to fully roll back studio project " + project.getPath(), cleanupFailure); + sender.sendMessage(message + " Cleanup was incomplete; check the console and " + + project.getAbsolutePath() + "."); + } + + static Throwable rollbackCreatedProjectFiles(File project) { + File target = project.toPath().toAbsolutePath().normalize().toFile(); + Throwable cleanupFailure = null; + IrisData loadedData = IrisData.getLoaded(target).orElse(null); + if (loadedData != null) { + try { + loadedData.close(); + } catch (Throwable closeFailure) { + cleanupFailure = closeFailure; + } + } + try { + AtomicDirectoryPublisher.deleteTree(target.toPath()); + } catch (Throwable deleteFailure) { + if (cleanupFailure == null) { + cleanupFailure = deleteFailure; + } else if (cleanupFailure != deleteFailure) { + cleanupFailure.addSuppressed(deleteFailure); + } + } + return cleanupFailure; + } + + private static void sendBusy(VolmitSender sender, LifecycleOperationCoordinator.BusyException busy) { + LifecycleOperationCoordinator.ActiveOperation operation = busy.currentOperation(); + sender.sendMessage("Iris pack changes are busy with " + operation.kind().name().toLowerCase(Locale.ROOT) + + " for '" + operation.target() + "'. Try again when it completes."); + } + + private static void closeLease(LifecycleOperationCoordinator.Lease lease) { + try { + lease.close(); + } catch (Throwable e) { + IrisLogging.reportError("Lifecycle idle callback failed after a studio pack operation.", e); + } + } + + private static String errorDetail(Throwable failure) { + Throwable detail = failure; + while (detail.getCause() != null && detail.getCause() != detail) { + detail = detail.getCause(); + } + String message = detail.getMessage(); + return message == null || message.isBlank() ? detail.getClass().getSimpleName() : message; + } + + static void copyPackTree(Path source, Path target) throws IOException { + try (Stream entries = Files.walk(source)) { + for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) { + if (Files.isSymbolicLink(entry)) { + throw new IOException("Pack contains a symbolic link: " + entry); + } + Path destination = target.resolve(source.relativize(entry)).normalize(); + if (!destination.startsWith(target)) { + throw new IOException("Pack entry escapes its installation stage: " + entry); + } + if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(destination); + } else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(destination.getParent()); + Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES); + } else { + throw new IOException("Pack contains an unsupported entry: " + entry); + } + } + } + } + + static void publishNewDirectory(Path stage, Path target) throws IOException { + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new FileAlreadyExistsException(target.toString()); + } + try { + Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(stage, target); + } + } + + private static void writeWorkspaceAtomically(Path project, String projectName, String content) throws IOException { + Path target = project.resolve(projectName + ".code-workspace"); + Path stage = Files.createTempFile(project, "." + projectName + ".workspace-", ".tmp"); + IOException operationFailure = null; + try { + Files.writeString(stage, content, StandardCharsets.UTF_8); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new FileAlreadyExistsException(target.toString()); + } + try { + Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(stage, target); + } + } catch (IOException e) { + operationFailure = e; + throw e; + } finally { + try { + Files.deleteIfExists(stage); + } catch (IOException cleanupFailure) { + if (operationFailure != null) { + operationFailure.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } + } + } + } + + static final class StudioTransitionQueue { + private final Object monitor; + private CompletableFuture tail; + + StudioTransitionQueue() { + monitor = new Object(); + tail = CompletableFuture.completedFuture(null); + } + + CompletableFuture submit(Supplier> operation) { + Supplier> queuedOperation = Objects.requireNonNull(operation, "operation"); + CompletableFuture result = new CompletableFuture<>(); + synchronized (monitor) { + tail = tail.handle((ignored, previousFailure) -> null) + .thenCompose(ignored -> execute(queuedOperation, result)); + } + return result; + } + + private CompletableFuture execute( + Supplier> operation, + CompletableFuture result + ) { + CompletableFuture running; + try { + running = Objects.requireNonNull(operation.get(), "Studio transition returned no future."); + } catch (Throwable failure) { + result.completeExceptionally(failure); + return CompletableFuture.completedFuture(null); + } + return running.handle((value, failure) -> { + if (failure == null) { + result.complete(value); + } else { + result.completeExceptionally(failure); + } + return null; + }); + } + } + + @FunctionalInterface + private interface PackMutation { + boolean run() throws Exception; + } + + private record DownloadOutcome(boolean changed, boolean restartRequired) { + private static DownloadOutcome notChanged() { + return new DownloadOutcome(false, false); + } + + private static DownloadOutcome from(PackDownloader.PackInstallResult result) { + return result == null + ? notChanged() + : new DownloadOutcome(result.changed(), result.restartRequired()); + } + } + + private enum CreationOutcome { + FAILED, + OPEN, + RESTART + } + + record PackListingReference(String repository, String ref, String expectedKey) { } public IrisProject getActiveProject() { diff --git a/core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java b/core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java index eec101de8..02ba1a079 100644 --- a/core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java +++ b/core/src/main/java/art/arcane/iris/core/splash/IrisSplashPackScanner.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.splash; +import art.arcane.iris.core.pack.PackDirectoryResolver; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; @@ -20,12 +21,12 @@ public final class IrisSplashPackScanner { return List.of(); } - File[] folders = packFolder.listFiles(File::isDirectory); - if (folders == null || folders.length == 0) { + List folders = PackDirectoryResolver.listVisiblePackDirectories(packFolder); + if (folders.isEmpty()) { return List.of(); } - List packs = new ArrayList<>(folders.length); + List packs = new ArrayList<>(folders.size()); for (File folder : folders) { SplashPackMetadata metadata = read(folder, reporter); if (metadata != null) { diff --git a/core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java index 433e8536b..ed7ec3b52 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java @@ -30,7 +30,10 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.TreeMap; import java.util.TreeSet; import java.util.function.Predicate; @@ -38,7 +41,14 @@ import art.arcane.iris.core.localization.BukkitRuntimeMessages; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.volmlib.util.localization.MessageArgument; public final class BulkStructureImporter { - public record Report(int total, int imported, int skipped, int failed) { + public record Report(int total, int imported, int skipped, int failed, Map successfulBundles) { + public Report(int total, int imported, int skipped, int failed) { + this(total, imported, skipped, failed, Map.of()); + } + + public Report { + successfulBundles = Collections.unmodifiableMap(new TreeMap<>(successfulBundles)); + } } private BulkStructureImporter() { @@ -161,7 +171,7 @@ public final class BulkStructureImporter { templateKeys = enumerateTemplateKeys(); } catch (Throwable e) { sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAILED_ENUMERATE_STRUCTURE_TEMPLATES_VIA_SERVER_RESOURCEMANAGER, MessageArgument.untrusted("e", String.valueOf(e)))); - return new Report(0, 0, 0, 0); + return enumerationFailureReport(); } List all = new ArrayList<>(); @@ -221,24 +231,34 @@ public final class BulkStructureImporter { } public static Report importDatapackStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender) { - KList keys = INMS.get().getStructureKeys(); - List datapack = new ArrayList<>(); - for (String k : keys) { - if (k != null && !k.isBlank() && !k.startsWith("minecraft:")) { - datapack.add(k); - } - } - Collections.sort(datapack); + return importDatapackStructures(data, mode, sender, null, null); + } - int total = datapack.size(); + public static Report importDatapackStructures( + IrisData data, + StructureImporter.Mode mode, + VolmitSender sender, + Set allowedStructureKeys, + Set allowedTemplateKeys + ) { + KList keys = INMS.get().getStructureKeys(); + KeySelection structureSelection = selectDatapackKeys(keys, allowedStructureKeys); + List datapack = structureSelection.present(); + + int structureAttempts = structureSelection.total(); + int templateAttempts = 0; int imported = 0; int skipped = 0; - int failed = 0; + int failed = structureSelection.missing().size(); + Map successfulBundles = new TreeMap<>(); - if (total == 0) { + if (structureAttempts == 0) { sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_NO_DATAPACK_NON_MINECRAFT_STRUCTURES_ARE_REGISTERED_INGEST_DATAPACK_RESTART_FIRST_THEN)); } else { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_DATAPACK_STRUCTURES_MODE, MessageArgument.untrusted("total", String.valueOf(total)), MessageArgument.untrusted("mode", String.valueOf(mode)))); + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_DATAPACK_STRUCTURES_MODE, MessageArgument.untrusted("total", String.valueOf(structureAttempts)), MessageArgument.untrusted("mode", String.valueOf(mode)))); + for (String missingKey : structureSelection.missing()) { + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_8, MessageArgument.untrusted("keyString", missingKey), MessageArgument.untrusted("message", "allowed structure key is absent from the live registry"))); + } for (String keyString : datapack) { NamespacedKey nk = NamespacedKey.fromString(keyString.toLowerCase()); if (nk == null) { @@ -252,6 +272,7 @@ public final class BulkStructureImporter { VillageImporter.Result jigsaw = VillageImporter.importVillage(data, nk, name, mode); if (jigsaw.success()) { imported++; + successfulBundles.put(bundleKey(name), keyString); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_JIGSAW_2, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("name", String.valueOf(name)))); continue; } @@ -261,6 +282,7 @@ public final class BulkStructureImporter { StructureImporter.Result single = StructureImporter.importStructure(data, nk, name, mode); if (single.success()) { imported++; + successfulBundles.put(bundleKey(name), keyString); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_SINGLE_2, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("name", String.valueOf(name)))); } else if (single.message() != null && single.message().startsWith("Skipped")) { skipped++; @@ -287,50 +309,103 @@ public final class BulkStructureImporter { } } - try { - List templateKeys = enumerateTemplateKeys(); - List datapackTemplates = new ArrayList<>(); - for (String key : templateKeys) { - if (key != null && !key.startsWith("minecraft:")) { - datapackTemplates.add(key); - } + if (allowedTemplateKeys == null || !allowedTemplateKeys.isEmpty()) { + List templateKeys = List.of(); + boolean enumerationSucceeded = false; + try { + templateKeys = enumerateTemplateKeys(); + enumerationSucceeded = true; + } catch (Throwable e) { + templateAttempts++; + failed++; + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_COULD_NOT_ENUMERATE_DATAPACK_TEMPLATES_VIA_SERVER_RESOURCEMANAGER, MessageArgument.untrusted("error", String.valueOf(e.getMessage())))); } - Collections.sort(datapackTemplates); - if (!datapackTemplates.isEmpty()) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_DATAPACK_STRUCTURE_TEMPLATES, MessageArgument.untrusted("size", String.valueOf(datapackTemplates.size())))); - for (String keyString : datapackTemplates) { - NamespacedKey nk = NamespacedKey.fromString(keyString.toLowerCase()); - if (nk == null) { - failed++; - continue; - } - String name = templateNameFor(keyString); - try { - StructureImporter.Result result = StructureImporter.importStructure(data, nk, name, mode, true); - if (result.success()) { - imported++; - } else if (result.message() != null && result.message().startsWith("Skipped")) { - skipped++; - } else { + if (enumerationSucceeded) { + KeySelection templateSelection = selectDatapackKeys(templateKeys, allowedTemplateKeys); + List datapackTemplates = templateSelection.present(); + templateAttempts += templateSelection.total(); + failed += templateSelection.missing().size(); + for (String missingKey : templateSelection.missing()) { + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_10, MessageArgument.untrusted("keyString", missingKey), MessageArgument.untrusted("message", "allowed template key is absent from the live server resources"))); + } + if (!datapackTemplates.isEmpty()) { + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_DATAPACK_STRUCTURE_TEMPLATES, MessageArgument.untrusted("size", String.valueOf(datapackTemplates.size())))); + for (String keyString : datapackTemplates) { + NamespacedKey nk = NamespacedKey.fromString(keyString.toLowerCase()); + if (nk == null) { failed++; - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_10, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(result.message())))); + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_INVALID_KEY_2, MessageArgument.untrusted("keyString", String.valueOf(keyString)))); + continue; + } + String name = templateNameFor(keyString); + try { + StructureImporter.Result result = StructureImporter.importStructure(data, nk, name, mode, true); + if (result.success()) { + imported++; + successfulBundles.put(bundleKey(name), keyString); + } else if (result.message() != null && result.message().startsWith("Skipped")) { + skipped++; + } else { + failed++; + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_10, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(result.message())))); + } + } catch (Throwable e) { + failed++; + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_11, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("error", String.valueOf(e.getMessage())))); } - } catch (Throwable e) { - failed++; - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_11, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("error", String.valueOf(e.getMessage())))); } } } - } catch (Throwable e) { - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_COULD_NOT_ENUMERATE_DATAPACK_TEMPLATES_VIA_SERVER_RESOURCEMANAGER, MessageArgument.untrusted("error", String.valueOf(e.getMessage())))); } StructureIndexService.write(data); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_DATAPACK_STRUCTURE_IMPORT_COMPLETE_IMPORTED_SKIPPED_FAILED, MessageArgument.untrusted("imported", String.valueOf(imported)), MessageArgument.untrusted("skipped", String.valueOf(skipped)), MessageArgument.untrusted("failed", String.valueOf(failed)))); - return new Report(total, imported, skipped, failed); + return datapackReport(structureAttempts, templateAttempts, imported, skipped, failed, successfulBundles); } - static String templateNameFor(String key) { + static boolean isAllowedDatapackKey(String key, Set allowedKeys) { + return allowedKeys == null ? !key.startsWith("minecraft:") : allowedKeys.contains(key); + } + + static KeySelection selectDatapackKeys(Iterable discoveredKeys, Set allowedKeys) { + TreeSet discovered = normalizeKeys(discoveredKeys); + TreeSet present = new TreeSet<>(); + TreeSet missing = new TreeSet<>(); + if (allowedKeys == null) { + for (String key : discovered) { + if (isAllowedDatapackKey(key, null)) { + present.add(key); + } + } + } else { + TreeSet allowed = normalizeKeys(allowedKeys); + for (String key : allowed) { + if (discovered.contains(key)) { + present.add(key); + } else { + missing.add(key); + } + } + } + return new KeySelection(new ArrayList<>(present), new ArrayList<>(missing)); + } + + static Report datapackReport( + int structureAttempts, + int templateAttempts, + int imported, + int skipped, + int failed, + Map successfulBundles + ) { + return new Report(structureAttempts + templateAttempts, imported, skipped, failed, successfulBundles); + } + + static Report enumerationFailureReport() { + return new Report(1, 0, 0, 1); + } + + public static String templateNameFor(String key) { int colon = key.indexOf(':'); String namespace = colon >= 0 ? key.substring(0, colon) : "minecraft"; String path = colon >= 0 ? key.substring(colon + 1) : key; @@ -427,6 +502,21 @@ public final class BulkStructureImporter { } } + private static TreeSet normalizeKeys(Iterable keys) { + TreeSet normalized = new TreeSet<>(); + for (String key : keys) { + if (key == null || key.isBlank()) { + continue; + } + normalized.add(key.trim().toLowerCase(Locale.ROOT)); + } + return normalized; + } + + private static String bundleKey(String name) { + return "iris:" + name; + } + private static String identifierNamespace(Object location) { try { Method m = location.getClass().getMethod("getNamespace"); @@ -468,4 +558,15 @@ public final class BulkStructureImporter { } throw new NoSuchMethodException(method + " on " + target.getClass().getName()); } + + record KeySelection(List present, List missing) { + KeySelection { + present = List.copyOf(present); + missing = List.copyOf(missing); + } + + int total() { + return present.size() + missing.size(); + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java index 634336561..353c39446 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/FeatureImporter.java @@ -19,20 +19,27 @@ package art.arcane.iris.core.structure; import art.arcane.iris.core.IrisWorldStorage; +import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.WorldCreatorCompat; -import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import art.arcane.iris.core.runtime.WorldDeletionQueue; +import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.core.tools.TreePlausibilizer; import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.bukkit.WorldIdentity; -import art.arcane.volmlib.util.io.IO; -import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.WorldCreator; import org.bukkit.WorldType; @@ -41,16 +48,27 @@ import org.bukkit.block.Block; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; import java.util.function.Function; +import java.util.function.Supplier; import art.arcane.iris.core.localization.BukkitRuntimeMessages; import art.arcane.iris.core.localization.IrisLanguage; @@ -59,13 +77,18 @@ public final class FeatureImporter { public record Report(int total, int imported, int skipped, int failed) { } - private static final String SCRATCH_WORLD_NAME = "iris_vanilla_import"; + private static final String SCRATCH_WORLD_PREFIX = "iris-feature-import-"; + private static final int SCRATCH_ID_ATTEMPTS = 32; + private static final long SCRATCH_CREATE_TIMEOUT_SECONDS = 120L; + private static final long SCRATCH_TEARDOWN_TIMEOUT_SECONDS = 120L; private static final int CAPTURE_RADIUS = 16; private static final int CAPTURE_HEIGHT = 40; private static final int CELL_STRIDE = 48; private static final int CELL_COLUMNS = 16; private static final int PLACE_ATTEMPTS = 6; private static final long REGION_TIMEOUT_SECONDS = 30L; + private static final Set RESERVED_SCRATCH_NAMES = ConcurrentHashMap.newKeySet(); + private static final ConcurrentHashMap ACTIVE_SCRATCH_WORLDS = new ConcurrentHashMap<>(); private FeatureImporter() { } @@ -297,22 +320,123 @@ public final class FeatureImporter { } static World createScratchWorld(VolmitSender sender) { + LifecycleOperationCoordinator.Lease lease = null; + ScratchWorldReservation reservation = null; + World createdWorld = null; try { - World existing = WorldIdentity.resolve(IrisWorldStorage.keyFromName(SCRATCH_WORLD_NAME)).orElse(null); - if (existing != null) { - return existing; - } - WorldCreator creator = WorldCreatorCompat.ofKey(IrisWorldStorage.keyFromName(SCRATCH_WORLD_NAME)) + reservation = reserveScratchWorld(); + lease = LifecycleOperationCoordinator.get().acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + reservation.key().toString() + ); + requireUnusedReservation(reservation); + WorldCreator creator = WorldCreatorCompat.ofKey(reservation.key()) .environment(World.Environment.NORMAL) .type(WorldType.FLAT) .generateStructures(false); - return J.sfut(() -> INMS.get().createWorldAsync(creator)) + createdWorld = J.sfut(() -> INMS.get().createWorldAsync(creator)) .thenCompose(Function.identity()) - .get(); + .get(SCRATCH_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (createdWorld == null) { + throw new IllegalStateException("Scratch world creation returned no world."); + } + if (!reservation.key().equals(WorldIdentity.key(createdWorld))) { + throw new IllegalStateException("Scratch world creation returned an unexpected world identity."); + } + Path createdFolder = createdWorld.getWorldFolder().toPath().toAbsolutePath().normalize(); + if (!reservation.folder().equals(createdFolder)) { + throw new IllegalStateException("Scratch world creation returned an unexpected storage folder."); + } + + String identity = WorldIdentity.serialize(createdWorld); + ScratchWorldState state = new ScratchWorldState(reservation, lease); + if (ACTIVE_SCRATCH_WORLDS.putIfAbsent(identity, state) != null) { + throw new IllegalStateException("Scratch world identity is already active: " + identity); + } + lease = null; + reservation = null; + return createdWorld; } catch (Throwable e) { - IrisLogging.reportError(e); - sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.FEATURE_IMPORTER_COULD_NOT_CREATE_SCRATCH_WORLD_FEATURE_IMPORT_SKIPPING_TREE_OBJECT_PASS, MessageArgument.untrusted("error", String.valueOf(e.getMessage())))); + Throwable failure = unwrapFailure(e); + IrisLogging.reportError(failure); + boolean settled = createdWorld != null + && reservation != null + && settleFailedScratchCreation(createdWorld, reservation, failure); + if (reservation != null && (!settled || !matchesReservation(createdWorld, reservation))) { + queueScratchCleanup(reservation.name(), failure); + } + if (containsTimeout(failure)) { + ServerConfigurator.restart("Feature-import scratch world creation timed out for \"" + + (reservation == null ? "unreserved" : reservation.name()) + "\"."); + } + if (sender != null) { + sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.FEATURE_IMPORTER_COULD_NOT_CREATE_SCRATCH_WORLD_FEATURE_IMPORT_SKIPPING_TREE_OBJECT_PASS, MessageArgument.untrusted("error", String.valueOf(failure.getMessage())))); + } return null; + } finally { + if (reservation != null) { + RESERVED_SCRATCH_NAMES.remove(reservation.name()); + } + if (lease != null) { + lease.close(); + } + } + } + + private static boolean settleFailedScratchCreation( + World world, + ScratchWorldReservation expectedReservation, + Throwable creationFailure + ) { + ScratchWorldReservation actualReservation = reservationForCreatedScratch(world, creationFailure); + if (actualReservation == null) { + return false; + } + + PlatformChunkGenerator generator = null; + try { + generator = IrisToolbelt.access(world); + } catch (Throwable accessFailure) { + creationFailure.addSuppressed(accessFailure); + } + + AtomicBoolean terminalTimeout = new AtomicBoolean(false); + PlatformChunkGenerator capturedGenerator = generator; + CompletableFuture sequence = sequenceScratchTeardown( + () -> WorldLifecycleService.get().unloadAsync(world, false), + () -> WorldIdentity.resolve(actualReservation.key()).isPresent(), + () -> capturedGenerator == null + ? CompletableFuture.completedFuture(null) + : capturedGenerator.closeAsync(), + () -> { + try { + deleteScratchFolder(actualReservation, world); + return CompletableFuture.completedFuture(null); + } catch (IOException deletionFailure) { + return CompletableFuture.failedFuture(deletionFailure); + } + }, + actualReservation.name(), + terminalTimeout::get); + try { + guardScratchTeardown(sequence, terminalTimeout, actualReservation.name()).join(); + if (!expectedReservation.name().equals(actualReservation.name())) { + queueScratchCleanup(expectedReservation.name(), creationFailure); + } + return true; + } catch (Throwable cleanupFailure) { + Throwable cause = unwrapFailure(cleanupFailure); + creationFailure.addSuppressed(cause); + queueScratchCleanup(actualReservation.name(), creationFailure); + if (!expectedReservation.name().equals(actualReservation.name())) { + queueScratchCleanup(expectedReservation.name(), creationFailure); + } + if (containsTimeout(cause) || WorldIdentity.resolve(actualReservation.key()).isPresent()) { + ServerConfigurator.restart("Feature-import scratch cleanup did not reach a safe boundary for \"" + + actualReservation.name() + "\"."); + } + return false; } } @@ -320,21 +444,281 @@ public final class FeatureImporter { if (world == null) { return; } - File folder = world.getWorldFolder(); + + String identity = WorldIdentity.serialize(world); + ScratchWorldState state = ACTIVE_SCRATCH_WORLDS.remove(identity); + if (state == null) { + IrisLogging.warn("Refusing to destroy unreserved feature-import scratch world \"" + world.getName() + "\"."); + return; + } + + Throwable failure = null; try { - J.sfut(() -> { - Bukkit.unloadWorld(world, false); - return Boolean.TRUE; - }).get(); + PlatformChunkGenerator generator = IrisToolbelt.access(world); + AtomicBoolean terminalTimeout = new AtomicBoolean(false); + CompletableFuture sequence = sequenceScratchTeardown( + () -> WorldLifecycleService.get().unloadAsync(world, false), + () -> WorldIdentity.resolve(state.reservation().key()).isPresent(), + () -> generator == null ? CompletableFuture.completedFuture(null) : generator.closeAsync(), + () -> { + try { + deleteScratchFolder(state, world); + return CompletableFuture.completedFuture(null); + } catch (IOException deletionFailure) { + return CompletableFuture.failedFuture(deletionFailure); + } + }, + state.reservation().name(), + terminalTimeout::get + ); + guardScratchTeardown(sequence, terminalTimeout, state.reservation().name()).join(); } catch (Throwable e) { - IrisLogging.reportError(e); + failure = unwrapFailure(e); + queueScratchCleanup(state.reservation().name(), failure); + if (containsTimeout(failure) || WorldIdentity.resolve(state.reservation().key()).isPresent()) { + ServerConfigurator.restart("Feature-import scratch cleanup did not reach a safe boundary for \"" + + state.reservation().name() + "\"."); + } + IrisLogging.reportError("Feature-import scratch world cleanup failed for \"" + + state.reservation().name() + "\"; startup cleanup was queued.", failure); + } finally { + RESERVED_SCRATCH_NAMES.remove(state.reservation().name()); + state.lease().close(); + } + if (failure != null && sender != null) { + sender.sendMessage("Feature-import scratch world cleanup was deferred until the next startup: " + + failure.getMessage()); + } + } + + private static ScratchWorldReservation reserveScratchWorld() throws IOException { + for (int attempt = 0; attempt < SCRATCH_ID_ATTEMPTS; attempt++) { + String name = SCRATCH_WORLD_PREFIX + UUID.randomUUID(); + if (!RESERVED_SCRATCH_NAMES.add(name)) { + continue; + } + + NamespacedKey key = IrisWorldStorage.managedKeyFromName(name); + Path folder = IrisWorldStorage.requireSafeManagedDimensionRoot(key) + .toPath() + .toAbsolutePath() + .normalize(); + ScratchWorldReservation reservation = new ScratchWorldReservation(name, key, folder); + if (isReservationUnused(reservation)) { + return reservation; + } + RESERVED_SCRATCH_NAMES.remove(name); + } + throw new IOException("Could not reserve a collision-free feature-import scratch world identity."); + } + + private static void requireUnusedReservation(ScratchWorldReservation reservation) throws IOException { + if (!RESERVED_SCRATCH_NAMES.contains(reservation.name()) || !isReservationUnused(reservation)) { + throw new IOException("Feature-import scratch world reservation was claimed before creation: " + + reservation.name()); + } + } + + private static boolean isReservationUnused(ScratchWorldReservation reservation) { + return WorldIdentity.resolve(reservation.key()).isEmpty() + && !Files.exists(reservation.folder(), LinkOption.NOFOLLOW_LINKS) + && !Files.isSymbolicLink(reservation.folder()); + } + + private static boolean matchesReservation(World world, ScratchWorldReservation reservation) { + if (world == null || reservation == null) { + return false; } try { - if (folder != null && folder.exists()) { - IO.delete(folder); + return reservation.key().equals(WorldIdentity.key(world)) + && reservation.folder().equals(world.getWorldFolder().toPath().toAbsolutePath().normalize()); + } catch (Throwable failure) { + return false; + } + } + + private static ScratchWorldReservation reservationForCreatedScratch(World world, Throwable failure) { + try { + NamespacedKey key = WorldIdentity.key(world); + String name = IrisWorldStorage.logicalName(key); + if (!isReservedScratchWorldName(name)) { + failure.addSuppressed(new IllegalStateException( + "Refusing cleanup for unexpected scratch world identity \"" + key + "\".")); + return null; } - } catch (Throwable e) { - IrisLogging.reportError(e); + Path folder = IrisWorldStorage.requireSafeManagedDimensionRoot(key) + .toPath() + .toAbsolutePath() + .normalize(); + return new ScratchWorldReservation(name, key, folder); + } catch (Throwable identityFailure) { + failure.addSuppressed(identityFailure); + return null; + } + } + + private static void deleteScratchFolder(ScratchWorldState state, World world) throws IOException { + deleteScratchFolder(state.reservation(), world); + } + + private static void deleteScratchFolder(ScratchWorldReservation reservation, World world) throws IOException { + Path actualFolder = world.getWorldFolder().toPath().toAbsolutePath().normalize(); + if (!reservation.folder().equals(actualFolder)) { + throw new IOException("Scratch world storage changed during import; refusing deletion."); + } + if (WorldIdentity.resolve(reservation.key()).isPresent()) { + throw new IOException("Scratch world is still loaded; refusing deletion."); + } + AtomicDirectoryPublisher.deleteTree(reservation.folder()); + } + + private static void queueScratchCleanup(String worldName, Throwable failure) { + try { + WorldDeletionQueue queue = IrisServices.getOrNull(WorldDeletionQueue.class); + if (queue == null) { + throw new IllegalStateException("World deletion queue is unavailable."); + } + queue.queueExactForStartupDeletion(List.of(worldName)); + } catch (Throwable queueFailure) { + if (failure != null) { + failure.addSuppressed(queueFailure); + } + IrisLogging.reportError("Failed to queue startup cleanup for feature-import scratch world \"" + + worldName + "\".", queueFailure); + } + } + + private static Throwable unwrapFailure(Throwable throwable) { + Throwable cursor = throwable; + while (cursor instanceof CompletionException || cursor instanceof ExecutionException) { + if (cursor.getCause() == null) { + break; + } + cursor = cursor.getCause(); + } + return cursor; + } + + static CompletableFuture sequenceScratchTeardown( + Supplier> unload, + BooleanSupplier stillLoaded, + Supplier> closeGenerator, + Supplier> deleteFolder, + String worldName + ) { + return sequenceScratchTeardown( + unload, + stillLoaded, + closeGenerator, + deleteFolder, + worldName, + () -> false); + } + + static CompletableFuture sequenceScratchTeardown( + Supplier> unload, + BooleanSupplier stillLoaded, + Supplier> closeGenerator, + Supplier> deleteFolder, + String worldName, + BooleanSupplier terminalTimeout + ) { + CompletableFuture unloadFuture; + try { + unloadFuture = unload.get(); + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + if (unloadFuture == null) { + return CompletableFuture.failedFuture(new IllegalStateException("Scratch world unload returned no completion future.")); + } + + return unloadFuture.thenCompose(unloaded -> { + if (terminalTimeout.getAsBoolean()) { + return CompletableFuture.failedFuture(new TimeoutException( + "Scratch world cleanup stopped after its terminal timeout.")); + } + if (!Boolean.TRUE.equals(unloaded) || stillLoaded.getAsBoolean()) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Scratch world unload was not confirmed for \"" + worldName + "\".")); + } + return invokeScratchPhase(closeGenerator, "generator close"); + }).thenCompose(ignored -> { + if (terminalTimeout.getAsBoolean()) { + return CompletableFuture.failedFuture(new TimeoutException( + "Scratch world cleanup stopped after its terminal timeout.")); + } + return invokeScratchPhase(deleteFolder, "folder deletion"); + }); + } + + private static CompletableFuture guardScratchTeardown( + CompletableFuture source, + AtomicBoolean terminalTimeout, + String worldName + ) { + CompletableFuture guarded = new CompletableFuture<>(); + AtomicBoolean settled = new AtomicBoolean(false); + source.whenComplete((ignored, throwable) -> { + if (!settled.compareAndSet(false, true)) { + return; + } + if (throwable == null) { + guarded.complete(null); + } else { + guarded.completeExceptionally(throwable); + } + }); + CompletableFuture.delayedExecutor(SCRATCH_TEARDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> { + if (!settled.compareAndSet(false, true)) { + return; + } + terminalTimeout.set(true); + TimeoutException timeout = new TimeoutException( + "Scratch world cleanup did not settle within " + SCRATCH_TEARDOWN_TIMEOUT_SECONDS + + " seconds for \"" + worldName + "\"."); + ServerConfigurator.restart("Feature-import scratch cleanup timed out for \"" + worldName + "\"."); + guarded.completeExceptionally(timeout); + }); + return guarded; + } + + private static CompletableFuture invokeScratchPhase( + Supplier> phase, + String phaseName + ) { + try { + CompletableFuture future = phase.get(); + if (future == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Scratch world " + phaseName + " returned no completion future.")); + } + return future; + } catch (Throwable failure) { + return CompletableFuture.failedFuture(failure); + } + } + + private static boolean containsTimeout(Throwable throwable) { + Throwable cursor = throwable; + while (cursor != null) { + if (cursor instanceof TimeoutException) { + return true; + } + cursor = cursor.getCause(); + } + return false; + } + + static boolean isReservedScratchWorldName(String worldName) { + if (worldName == null || !worldName.startsWith(SCRATCH_WORLD_PREFIX)) { + return false; + } + try { + String identifier = worldName.substring(SCRATCH_WORLD_PREFIX.length()); + return UUID.fromString(identifier).toString().equals(identifier); + } catch (IllegalArgumentException failure) { + return false; } } @@ -343,4 +727,13 @@ public final class FeatureImporter { private record CaptureResult(boolean placed, IrisObject object) { } + + private record ScratchWorldReservation(String name, NamespacedKey key, Path folder) { + } + + private record ScratchWorldState( + ScratchWorldReservation reservation, + LifecycleOperationCoordinator.Lease lease + ) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java index 97478f6d6..da695e5c1 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureFileOperations.java @@ -64,6 +64,11 @@ interface StructureFileOperations { } } + default List list(Path root, int maxEntries) throws IOException { + List entries = list(root); + return entries.size() <= maxEntries ? entries : entries.subList(0, maxEntries + 1); + } + default void forceFile(Path path) throws IOException { try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE)) { channel.force(true); @@ -145,4 +150,11 @@ final class NioStructureFileOperations implements StructureFileOperations { Files.deleteIfExists(path); } } + + @Override + public List list(Path root, int maxEntries) throws IOException { + try (Stream stream = Files.list(root)) { + return stream.limit(maxEntries + 1L).sorted().toList(); + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionJournal.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionJournal.java index 6e93b94ce..6ae3ff0b5 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionJournal.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionJournal.java @@ -41,6 +41,7 @@ record StructureTransactionJournal( static final int CURRENT_SCHEMA_VERSION = 1; static final String FILE_NAME = "transaction.json"; static final String NEXT_FILE_NAME = "transaction.json.next"; + private static final int MAX_TARGETS = 100_000; private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Pattern MANIFEST_PATH = Pattern.compile( @@ -57,6 +58,9 @@ record StructureTransactionJournal( if (targets.isEmpty()) { throw new IllegalArgumentException("Structure transaction must contain at least one target"); } + if (targets.size() > MAX_TARGETS) { + throw new IllegalArgumentException("Structure transaction contains too many targets"); + } ArrayList orderedTargets = new ArrayList<>(targets); orderedTargets.sort(Comparator.comparing(Target::relativePath)); Set portablePaths = new HashSet<>(orderedTargets.size()); diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java index 526fde354..302c42a1c 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java @@ -18,7 +18,15 @@ package art.arcane.iris.core.structure.authoring; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; import java.nio.channels.OverlappingFileLockException; @@ -39,11 +47,19 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Stream; public final class StructureTransactionWriter { private static final String STAGING_RELATIVE_PATH = ".iris/structure-staging"; private static final String PROCESS_LOCK_RELATIVE_PATH = ".iris/structure-authoring.lock"; + private static final String RECOVERY_CLAIM_FILE = "external-coordinator.json"; + private static final int RECOVERY_CLAIM_SCHEMA = 1; + private static final int MAX_RECOVERY_CLAIM_BYTES = 64 * 1024; + private static final int MAX_COORDINATOR_JOURNAL_BYTES = 4 * 1024 * 1024; + private static final int MAX_STRUCTURE_STATE_BYTES = 64 * 1024 * 1024; + private static final int MAX_RECOVERY_TRANSACTIONS = 1_024; private static final ConcurrentMap ROOT_LOCKS = new ConcurrentHashMap<>(); + private static final Gson GSON = new Gson(); private final Path packRoot; private final StructureFileOperations files; @@ -88,6 +104,389 @@ public final class StructureTransactionWriter { return write(bundle, new StructureWriteOptions(mode, false)); } + public Optional ownedSource(StructureKey key) throws IOException { + Objects.requireNonNull(key, "key"); + rootLock.lock(); + try (ProcessLock ignored = acquireProcessLock()) { + StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked(); + if (!recovery.successful()) { + throw recoveryFailure(recovery); + } + Path manifestPath = ownershipManifestPath(key); + if (!files.exists(manifestPath)) { + return Optional.empty(); + } + if (!files.isRegularFile(manifestPath)) { + throw new IOException("Structure ownership manifest is not a regular file: " + manifestPath); + } + StructureOwnershipManifest manifest; + try { + manifest = StructureOwnershipManifest.fromJson(readBoundedBytes( + manifestPath, + MAX_STRUCTURE_STATE_BYTES, + "Structure ownership manifest" + )); + } catch (RuntimeException e) { + throw new IOException("Invalid structure ownership manifest at " + manifestPath, e); + } + if (!manifest.structure().equals(key)) { + throw new IOException("Structure ownership manifest belongs to " + manifest.structure()); + } + return Optional.of(manifest.source()); + } finally { + rootLock.unlock(); + } + } + + public boolean removeOwned(StructureKey key, StructureSource.Kind sourceKind, StructureKey sourceKey) throws IOException { + OwnedRemoval request = new OwnedRemoval(key, sourceKind, sourceKey); + try (PreparedRemoval removal = prepareOwnedRemovals(List.of(request))) { + boolean changed = removal.changed(); + removal.markCommitted(); + removal.finishCommit(); + return changed; + } + } + + public PreparedRemoval prepareOwnedRemovals(List removals) throws IOException { + return prepareOwnedRemovals(removals, false); + } + + public PreparedRemoval prepareMatchingOwnedRemovals(List removals) throws IOException { + return prepareOwnedRemovals(removals, true); + } + + private PreparedRemoval prepareOwnedRemovals( + List removals, + boolean skipOwnershipMismatches + ) throws IOException { + Objects.requireNonNull(removals, "removals"); + List requests = List.copyOf(removals); + rootLock.lock(); + ProcessLock processLock = null; + Path transactionRoot = null; + LinkedHashMap backups = new LinkedHashMap<>(); + try { + processLock = acquireProcessLock(); + StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked(); + if (!recovery.successful()) { + throw recoveryFailure(recovery); + } + RemovalPlan plan = buildRemovalPlan(requests, skipOwnershipMismatches); + if (plan.targets().isEmpty()) { + return new PreparedRemoval(null, null, backups, processLock, false); + } + + UUID transactionId = UUID.randomUUID(); + transactionRoot = stagingRoot().resolve(transactionId.toString()).normalize(); + Path backupRoot = transactionRoot.resolve("backup"); + StructureTransactionJournal journal = StructureTransactionJournal.prepared( + transactionId, + plan.targets() + ); + files.createDirectories(backupRoot); + writeJournal(transactionRoot, journal); + files.forceDirectory(transactionRoot); + files.forceDirectory(stagingRoot()); + verifyTargetSnapshot(journal); + backupTargets(journal, backupRoot, backups); + return new PreparedRemoval(transactionRoot, journal, backups, processLock, true); + } catch (IOException | RuntimeException preparationFailure) { + Optional rollbackFailure = rollback(backups, List.of()); + if (rollbackFailure.isPresent()) { + preparationFailure.addSuppressed(rollbackFailure.get()); + } else if (transactionRoot != null) { + cleanupAfterFailure(transactionRoot, preparationFailure); + } + if (processLock != null) { + try { + processLock.close(); + } catch (IOException closeFailure) { + preparationFailure.addSuppressed(closeFailure); + } + } + rootLock.unlock(); + if (preparationFailure instanceof IOException ioFailure) { + throw ioFailure; + } + throw new IOException("Failed preparing owned structure removal", preparationFailure); + } + } + + public record OwnedRemoval( + StructureKey key, + StructureSource.Kind sourceKind, + StructureKey sourceKey + ) { + public OwnedRemoval { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(sourceKind, "sourceKind"); + Objects.requireNonNull(sourceKey, "sourceKey"); + } + } + + public record PreparedRemovalToken(Path packRoot, UUID transactionId) { + public PreparedRemovalToken { + packRoot = canonicalPackRoot(Objects.requireNonNull(packRoot, "packRoot")); + Objects.requireNonNull(transactionId, "transactionId"); + } + } + + public record RecoveryOwner(Path transactionRoot, UUID transactionId, UUID claimId) { + public RecoveryOwner { + transactionRoot = validateRecoveryOwnerRoot(Objects.requireNonNull(transactionRoot, "transactionRoot")); + Objects.requireNonNull(transactionId, "transactionId"); + Objects.requireNonNull(claimId, "claimId"); + if (!transactionId.toString().equals(transactionRoot.getFileName().toString())) { + throw new IllegalArgumentException("Recovery owner transaction id does not match its directory"); + } + } + } + + public boolean verifyRecoveryOwner( + PreparedRemovalToken token, + RecoveryOwner owner, + boolean verifyPreparedState + ) throws IOException { + Objects.requireNonNull(token, "token"); + Objects.requireNonNull(owner, "owner"); + if (!packRoot.equals(token.packRoot())) { + throw new IOException("Prepared removal token belongs to a different pack root"); + } + Path transactionRoot = stagingRoot().resolve(token.transactionId().toString()).normalize(); + if (!files.exists(transactionRoot)) { + return false; + } + verifyRecoveryClaim(transactionRoot, token.transactionId(), owner); + if (verifyPreparedState) { + verifyPreparedRemovalAuthority(transactionRoot, token.transactionId()); + } + return true; + } + + public void resolvePreparedRemoval(PreparedRemovalToken token, boolean commit) throws IOException { + resolvePreparedRemoval(token, null, commit); + } + + public void resolvePreparedRemoval( + PreparedRemovalToken token, + RecoveryOwner owner, + boolean commit + ) throws IOException { + Objects.requireNonNull(token, "token"); + if (!packRoot.equals(token.packRoot())) { + throw new IOException("Prepared removal token belongs to a different pack root"); + } + rootLock.lock(); + try (ProcessLock ignored = acquireProcessLock()) { + Path transactionRoot = stagingRoot().resolve(token.transactionId().toString()).normalize(); + if (!files.exists(transactionRoot)) { + return; + } + if (owner != null) { + verifyRecoveryClaim(transactionRoot, token.transactionId(), owner); + } + Path journalPath = recoveryJournalPath(transactionRoot); + if (journalPath == null || !files.isRegularFile(journalPath)) { + throw new IOException("Missing prepared removal journal at " + transactionRoot); + } + StructureTransactionJournal journal; + try { + journal = StructureTransactionJournal.fromJson(readBoundedBytes( + journalPath, + MAX_STRUCTURE_STATE_BYTES, + "Prepared removal journal" + )); + } catch (RuntimeException e) { + throw new IOException("Invalid prepared removal journal at " + journalPath, e); + } + if (!journal.transactionId().equals(token.transactionId())) { + throw new IOException("Prepared removal journal id does not match " + token.transactionId()); + } + if (commit) { + verifyCommittedTransaction(journal); + } else { + restorePreparedTransaction(transactionRoot, journal); + } + cleanupTransaction(transactionRoot); + } finally { + rootLock.unlock(); + } + } + + public final class PreparedRemoval implements AutoCloseable { + private final Path transactionRoot; + private final StructureTransactionJournal journal; + private final LinkedHashMap backups; + private final ProcessLock processLock; + private final boolean changed; + private boolean committed; + private boolean closed; + + private PreparedRemoval( + Path transactionRoot, + StructureTransactionJournal journal, + LinkedHashMap backups, + ProcessLock processLock, + boolean changed + ) { + this.transactionRoot = transactionRoot; + this.journal = journal; + this.backups = backups; + this.processLock = processLock; + this.changed = changed; + } + + public boolean changed() { + return changed; + } + + public Optional recoveryToken() { + if (transactionRoot == null) { + return Optional.empty(); + } + UUID transactionId = UUID.fromString(Objects.requireNonNull( + transactionRoot.getFileName(), + "prepared removal transaction directory" + ).toString()); + return Optional.of(new PreparedRemovalToken(packRoot, transactionId)); + } + + public void claimRecoveryOwner(RecoveryOwner owner) throws IOException { + requireOpen(); + Objects.requireNonNull(owner, "owner"); + if (transactionRoot == null) { + return; + } + RecoveryClaim claim = new RecoveryClaim( + RECOVERY_CLAIM_SCHEMA, + owner.transactionRoot().toString(), + owner.transactionId(), + owner.claimId() + ); + byte[] claimContent = GSON.toJson(claim).getBytes(StandardCharsets.UTF_8); + if (claimContent.length > MAX_RECOVERY_CLAIM_BYTES) { + throw new IOException("External recovery claim exceeds " + MAX_RECOVERY_CLAIM_BYTES + " bytes"); + } + Path claimPath = transactionRoot.resolve(RECOVERY_CLAIM_FILE); + files.writeNew(claimPath, claimContent); + files.forceFile(claimPath); + files.forceDirectory(transactionRoot); + } + + public void markCommitted() throws IOException { + requireOpen(); + if (transactionRoot == null) { + committed = true; + return; + } + boolean committedJournalWritten = false; + try { + writeJournal(transactionRoot, journal.committed()); + committedJournalWritten = true; + files.forceDirectory(transactionRoot); + committed = true; + } catch (IOException | RuntimeException commitFailure) { + if (committedJournalWritten || isCommittedJournal(transactionRoot, commitFailure)) { + committed = true; + } + if (commitFailure instanceof IOException ioFailure) { + throw ioFailure; + } + throw new IOException("Failed marking owned structure removal committed", commitFailure); + } + } + + public void finishCommit() throws IOException { + requireOpen(); + if (!committed) { + throw new IllegalStateException("Owned structure removal has not been marked committed"); + } + IOException failure = null; + if (transactionRoot != null) { + try { + cleanupTransaction(transactionRoot); + } catch (IOException | RuntimeException cleanupFailure) { + failure = new IOException("Owned structure removal committed but cleanup remains at " + + transactionRoot, cleanupFailure); + } + } + IOException releaseFailure = release(); + if (failure == null) { + failure = releaseFailure; + } else if (releaseFailure != null) { + failure.addSuppressed(releaseFailure); + } + if (failure != null) { + throw failure; + } + } + + public void rollback() throws IOException { + if (closed) { + return; + } + IOException failure = null; + if (transactionRoot != null) { + Optional rollbackFailure = StructureTransactionWriter.this.rollback(backups, List.of()); + if (rollbackFailure.isPresent()) { + failure = new IOException("Failed restoring prepared owned structure removal at " + + transactionRoot, rollbackFailure.get()); + } else { + try { + cleanupTransaction(transactionRoot); + } catch (IOException | RuntimeException cleanupFailure) { + failure = new IOException("Restored owned structure removal but cleanup remains at " + + transactionRoot, cleanupFailure); + } + } + } + IOException releaseFailure = release(); + if (failure == null) { + failure = releaseFailure; + } else if (releaseFailure != null) { + failure.addSuppressed(releaseFailure); + } + if (failure != null) { + throw failure; + } + } + + public void leaveForRecovery() throws IOException { + if (closed) { + return; + } + IOException releaseFailure = release(); + if (releaseFailure != null) { + throw releaseFailure; + } + } + + @Override + public void close() throws IOException { + rollback(); + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Owned structure removal transaction is closed"); + } + } + + private IOException release() { + IOException failure = null; + try { + processLock.close(); + } catch (IOException e) { + failure = e; + } finally { + closed = true; + rootLock.unlock(); + } + return failure; + } + } + public StructureWriteResult preview(StructureResourceBundle bundle, StructureWriteMode mode) { return write(bundle, StructureWriteOptions.preview(mode)); } @@ -134,6 +533,81 @@ public final class StructureTransactionWriter { return commit(plan); } + private RemovalPlan buildRemovalPlan( + List removals, + boolean skipOwnershipMismatches + ) throws IOException { + TreeMap targets = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (OwnedRemoval removal : removals) { + Path manifestPath = ownershipManifestPath(removal.key()); + if (!files.exists(manifestPath)) { + continue; + } + if (!files.isRegularFile(manifestPath)) { + throw new IOException("Structure ownership manifest is not a regular file: " + manifestPath); + } + byte[] manifestContent = readBoundedBytes( + manifestPath, + MAX_STRUCTURE_STATE_BYTES, + "Structure ownership manifest" + ); + StructureOwnershipManifest manifest; + try { + manifest = StructureOwnershipManifest.fromJson(manifestContent); + } catch (RuntimeException e) { + throw new IOException("Invalid structure ownership manifest at " + manifestPath, e); + } + if (!manifest.structure().equals(removal.key())) { + throw new IOException("Structure ownership manifest belongs to " + manifest.structure()); + } + if (manifest.source().kind() != removal.sourceKind() + || !manifest.source().key().equals(removal.sourceKey())) { + if (skipOwnershipMismatches) { + continue; + } + throw new IOException("Structure '" + removal.key() + "' is owned by source " + + manifest.source().key() + " (" + manifest.source().kind() + "), not " + + removal.sourceKey() + " (" + removal.sourceKind() + ")"); + } + + for (Map.Entry resource : manifest.resourceHashes().entrySet()) { + Path target = resolveTarget(resource.getKey()); + verifyRemovalTarget(target, resource.getKey(), resource.getValue()); + addRemovalTarget(targets, resource.getKey(), resource.getValue()); + } + addRemovalTarget(targets, manifest.relativePath(), StructureHash.sha256(manifestContent)); + } + return new RemovalPlan(List.copyOf(targets.values())); + } + + private void addRemovalTarget( + Map targets, + String relativePath, + String contentHash + ) throws IOException { + StructureTransactionJournal.Target target = new StructureTransactionJournal.Target( + relativePath, + true, + contentHash, + "" + ); + StructureTransactionJournal.Target existing = targets.putIfAbsent(relativePath, target); + if (existing != null && (!existing.relativePath().equals(relativePath) + || !existing.originalHash().equals(contentHash))) { + throw new IOException("Owned structure removals overlap at incompatible resource path " + relativePath); + } + } + + private void verifyRemovalTarget(Path target, String relativePath, String expectedHash) throws IOException { + if (!files.isRegularFile(target)) { + throw new IOException("Owned structure resource is missing or not a regular file: " + relativePath); + } + String actualHash = files.sha256(target); + if (!expectedHash.equals(actualHash)) { + throw new IOException("Owned structure resource was modified and will not be removed: " + relativePath); + } + } + private StructureRecoveryResult recoverIncompleteTransactionsLocked() { Path stagingRoot = stagingRoot(); ArrayList failures = new ArrayList<>(); @@ -155,7 +629,7 @@ public final class StructureTransactionWriter { List transactionRoots; try { - transactionRoots = files.list(stagingRoot); + transactionRoots = files.list(stagingRoot, MAX_RECOVERY_TRANSACTIONS); } catch (IOException | RuntimeException e) { return new StructureRecoveryResult( 0, @@ -164,6 +638,16 @@ public final class StructureTransactionWriter { List.of(new StructureRecoveryResult.Failure(stagingRoot, e)) ); } + if (transactionRoots.size() > MAX_RECOVERY_TRANSACTIONS) { + IOException failure = new IOException("Structure recovery transaction count exceeds " + + MAX_RECOVERY_TRANSACTIONS); + return new StructureRecoveryResult( + 0, + 0, + 0, + List.of(new StructureRecoveryResult.Failure(stagingRoot, failure)) + ); + } for (Path transactionRoot : transactionRoots) { try { @@ -213,7 +697,11 @@ public final class StructureTransactionWriter { StructureTransactionJournal journal; try { - journal = StructureTransactionJournal.fromJson(files.readAllBytes(journalPath)); + journal = StructureTransactionJournal.fromJson(readBoundedBytes( + journalPath, + MAX_STRUCTURE_STATE_BYTES, + "Structure transaction journal" + )); } catch (RuntimeException e) { throw new IOException("Invalid structure transaction journal at " + journalPath, e); } @@ -223,6 +711,10 @@ public final class StructureTransactionWriter { throw new IOException("Structure transaction journal id " + journal.transactionId() + " does not match directory " + directoryName); } + if (hasActiveRecoveryOwner(normalizedRoot, journal.transactionId())) { + throw new IOException("Structure transaction recovery is owned by an active datapack coordinator: " + + normalizedRoot); + } return switch (journal.phase()) { case PREPARED -> { @@ -254,6 +746,193 @@ public final class StructureTransactionWriter { return nextJournalPath; } + private boolean hasActiveRecoveryOwner(Path transactionRoot, UUID transactionId) throws IOException { + Path claimPath = transactionRoot.resolve(RECOVERY_CLAIM_FILE); + if (!files.exists(claimPath)) { + return false; + } + RecoveryClaim claim = readRecoveryClaim(claimPath); + RecoveryOwner owner; + try { + owner = new RecoveryOwner( + Path.of(claim.coordinatorTransactionRoot()), + claim.coordinatorTransactionId(), + claim.claimId() + ); + } catch (RuntimeException e) { + throw new IOException("Invalid external recovery claim at " + claimPath, e); + } + return coordinatorReferencesClaim( + new PreparedRemovalToken(packRoot, transactionId), + owner + ); + } + + private void verifyRecoveryClaim( + Path transactionRoot, + UUID transactionId, + RecoveryOwner owner + ) throws IOException { + Path claimPath = transactionRoot.resolve(RECOVERY_CLAIM_FILE); + RecoveryClaim claim = readRecoveryClaim(claimPath); + if (!Objects.equals(claim.coordinatorTransactionRoot(), owner.transactionRoot().toString()) + || !Objects.equals(claim.coordinatorTransactionId(), owner.transactionId()) + || !Objects.equals(claim.claimId(), owner.claimId())) { + throw new IOException("Prepared removal recovery claim does not match its datapack coordinator"); + } + if (!coordinatorReferencesClaim(new PreparedRemovalToken(packRoot, transactionId), owner)) { + throw new IOException("Prepared removal recovery coordinator was not durably published"); + } + } + + private void verifyPreparedRemovalAuthority(Path transactionRoot, UUID transactionId) throws IOException { + Path journalPath = recoveryJournalPath(transactionRoot); + if (journalPath == null || !files.isRegularFile(journalPath)) { + throw new IOException("Missing prepared removal journal at " + transactionRoot); + } + StructureTransactionJournal journal; + try { + journal = StructureTransactionJournal.fromJson(readBoundedBytes( + journalPath, + MAX_STRUCTURE_STATE_BYTES, + "Prepared removal journal" + )); + } catch (RuntimeException e) { + throw new IOException("Invalid prepared removal journal at " + journalPath, e); + } + if (!journal.transactionId().equals(transactionId) + || journal.phase() != StructureTransactionJournal.Phase.PREPARED) { + throw new IOException("Prepared removal journal does not match its datapack coordinator"); + } + boolean ownershipManifestPresent = false; + Path backupRoot = transactionRoot.resolve("backup").normalize(); + for (StructureTransactionJournal.Target state : journal.targets()) { + if (!state.hadOriginal() || !state.replacementHash().isEmpty()) { + throw new IOException("External coordinator claimed a non-removal structure transaction"); + } + ownershipManifestPresent |= state.relativePath().startsWith(".iris/structure-manifests/"); + Path target = resolveTarget(state.relativePath()); + if (files.exists(target)) { + throw new IOException("Prepared removal target reappeared before coordinator recovery: " + + state.relativePath()); + } + Path backup = resolveTransactionPath(backupRoot, state.relativePath()); + if (!files.isRegularFile(backup)) { + throw new IOException("Prepared removal backup is missing or not a regular file: " + + state.relativePath()); + } + verifyOriginalContent(backup, state); + } + if (!ownershipManifestPresent) { + throw new IOException("External coordinator removal has no structure ownership manifest"); + } + } + + private RecoveryClaim readRecoveryClaim(Path claimPath) throws IOException { + if (!files.isRegularFile(claimPath)) { + throw new IOException("Invalid external recovery claim " + claimPath); + } + byte[] content = readBoundedBytes( + claimPath, + MAX_RECOVERY_CLAIM_BYTES, + "External recovery claim" + ); + try { + RecoveryClaim claim = GSON.fromJson( + new String(content, StandardCharsets.UTF_8), + RecoveryClaim.class + ); + if (claim == null || claim.schemaVersion() != RECOVERY_CLAIM_SCHEMA + || claim.coordinatorTransactionRoot() == null + || claim.coordinatorTransactionId() == null || claim.claimId() == null) { + throw new IOException("Incomplete external recovery claim " + claimPath); + } + return claim; + } catch (RuntimeException e) { + throw new IOException("Invalid external recovery claim " + claimPath, e); + } + } + + private boolean coordinatorReferencesClaim( + PreparedRemovalToken token, + RecoveryOwner owner + ) throws IOException { + Path ownerRoot = owner.transactionRoot(); + Path ownerParent = Objects.requireNonNull(ownerRoot.getParent(), "recovery owner parent"); + if (Files.isSymbolicLink(ownerParent) || Files.isSymbolicLink(ownerRoot)) { + throw new IOException("External recovery owner path contains a symbolic link: " + ownerRoot); + } + if (!Files.exists(ownerRoot, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isDirectory(ownerRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("External recovery owner is not a directory: " + ownerRoot); + } + Path committed = ownerRoot.resolve("journal.json"); + Path next = ownerRoot.resolve("journal.next.json"); + Path journalPath; + if (Files.exists(committed, LinkOption.NOFOLLOW_LINKS)) { + journalPath = committed; + } else if (Files.exists(next, LinkOption.NOFOLLOW_LINKS)) { + journalPath = next; + } else { + try (Stream contents = Files.list(ownerRoot)) { + if (contents.findAny().isEmpty()) { + return false; + } + } + throw new IOException("External recovery owner has no transaction journal: " + ownerRoot); + } + if (Files.isSymbolicLink(journalPath) + || !Files.isRegularFile(journalPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid external recovery owner journal " + journalPath); + } + JsonObject journal; + try { + journal = JsonParser.parseString(new String( + readBoundedBytes( + journalPath, + MAX_COORDINATOR_JOURNAL_BYTES, + "External recovery owner journal" + ), + StandardCharsets.UTF_8 + )).getAsJsonObject(); + } catch (RuntimeException e) { + if (journalPath.equals(next) && isOnlyOwnerArtifact(ownerRoot, next)) { + return false; + } + throw new IOException("Invalid external recovery owner journal " + journalPath, e); + } + if (!journal.has("schemaVersion") || journal.get("schemaVersion").getAsInt() != 2 + || !journal.has("transactionId") + || !owner.transactionId().toString().equals(journal.get("transactionId").getAsString()) + || !journal.has("operation") || !"REMOVE".equals(journal.get("operation").getAsString()) + || !journal.has("editables") || !journal.get("editables").isJsonArray()) { + throw new IOException("External recovery owner journal does not match its claim"); + } + JsonArray editables = journal.getAsJsonArray("editables"); + for (JsonElement element : editables) { + if (!element.isJsonObject()) { + continue; + } + JsonObject editable = element.getAsJsonObject(); + if (editable.has("packRoot") && editable.has("transactionId") && editable.has("claimId") + && packRoot.toString().equals(editable.get("packRoot").getAsString()) + && token.transactionId().toString().equals(editable.get("transactionId").getAsString()) + && owner.claimId().toString().equals(editable.get("claimId").getAsString())) { + return true; + } + } + throw new IOException("External recovery owner journal does not contain its claimed structure transaction"); + } + + private boolean isOnlyOwnerArtifact(Path ownerRoot, Path artifact) throws IOException { + try (Stream contents = Files.list(ownerRoot)) { + List entries = contents.limit(2).toList(); + return entries.size() == 1 && Objects.equals(entries.getFirst(), artifact); + } + } + private void verifyCommittedTransaction(StructureTransactionJournal journal) throws IOException { for (StructureTransactionJournal.Target state : journal.targets()) { Path target = resolveTarget(state.relativePath()); @@ -408,7 +1087,11 @@ public final class StructureTransactionWriter { StructureOwnershipManifest previousManifest; try { - previousManifest = StructureOwnershipManifest.fromJson(files.readAllBytes(manifestPath)); + previousManifest = StructureOwnershipManifest.fromJson(readBoundedBytes( + manifestPath, + MAX_STRUCTURE_STATE_BYTES, + "Structure ownership manifest" + )); } catch (RuntimeException e) { conflicts.add(StructureWriteResult.Conflict.invalidManifest(manifestRelativePath, e.toString())); return createPlan( @@ -610,10 +1293,15 @@ public final class StructureTransactionWriter { Path transactionRoot, StructureTransactionJournal journal ) throws IOException { + byte[] content = journal.toJson(); + if (content.length > MAX_STRUCTURE_STATE_BYTES) { + throw new IOException("Structure transaction journal exceeds " + + MAX_STRUCTURE_STATE_BYTES + " bytes"); + } Path journalPath = transactionRoot.resolve(StructureTransactionJournal.FILE_NAME); Path nextJournalPath = transactionRoot.resolve(StructureTransactionJournal.NEXT_FILE_NAME); files.deleteIfExists(nextJournalPath); - files.writeNew(nextJournalPath, journal.toJson()); + files.writeNew(nextJournalPath, content); files.forceFile(nextJournalPath); files.move(nextJournalPath, journalPath); } @@ -624,7 +1312,11 @@ public final class StructureTransactionWriter { return false; } try { - return StructureTransactionJournal.fromJson(files.readAllBytes(journalPath)).phase() + return StructureTransactionJournal.fromJson(readBoundedBytes( + journalPath, + MAX_STRUCTURE_STATE_BYTES, + "Structure transaction journal" + )).phase() == StructureTransactionJournal.Phase.COMMITTED; } catch (IOException | RuntimeException e) { commitPhaseFailure.addSuppressed(e); @@ -909,6 +1601,21 @@ public final class StructureTransactionWriter { } } + private byte[] readBoundedBytes(Path path, int maxBytes, String purpose) throws IOException { + byte[] content; + try (InputStream input = Files.newInputStream( + path, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS + )) { + content = input.readNBytes(maxBytes + 1); + } + if (content.length > maxBytes) { + throw new IOException(purpose + " exceeds " + maxBytes + " bytes"); + } + return content; + } + private Path resolveWithin(Path root, String relativePath, String errorPrefix) { Path target = root.resolve(relativePath).normalize(); if (!target.startsWith(root) || target.equals(root)) { @@ -941,6 +1648,37 @@ public final class StructureTransactionWriter { } } + private static Path validateRecoveryOwnerRoot(Path root) { + Path normalized = root.toAbsolutePath().normalize(); + Path parent = Objects.requireNonNull(normalized.getParent(), "recovery owner parent"); + Path parentName = Objects.requireNonNull(parent.getFileName(), "recovery owner directory"); + Path transactionName = Objects.requireNonNull(normalized.getFileName(), "recovery owner transaction"); + if (!".iris-datapack-transactions".equals(parentName.toString())) { + throw new IllegalArgumentException("Recovery owner is outside the datapack transaction directory"); + } + try { + UUID.fromString(transactionName.toString()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Recovery owner has an invalid transaction directory", e); + } + try { + if (Files.isSymbolicLink(parent) || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalArgumentException("Recovery owner transaction parent is unsafe"); + } + return parent.toRealPath().resolve(transactionName.toString()); + } catch (IOException e) { + throw new IllegalArgumentException("Unable to resolve recovery owner transaction parent", e); + } + } + + private record RecoveryClaim( + int schemaVersion, + String coordinatorTransactionRoot, + UUID coordinatorTransactionId, + UUID claimId + ) { + } + private enum RecoveryOutcome { RESTORED_PREPARED, CLEANED_COMMITTED, @@ -956,6 +1694,9 @@ public final class StructureTransactionWriter { } } + private record RemovalPlan(List targets) { + } + private record ProcessLock(FileChannel channel, FileLock lock) implements AutoCloseable { private ProcessLock { Objects.requireNonNull(channel, "channel"); diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 32d9a8c7b..19cbe8cf4 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -26,11 +26,13 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.link.MultiverseCoreLink; import art.arcane.iris.core.IrisRuntimeSchedulerMode; +import art.arcane.iris.core.DatapackInstallResult; import art.arcane.iris.core.IrisWorldStorage; -import art.arcane.iris.core.WorldCreatorCompat; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration; +import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.WorldLifecycleCaller; import art.arcane.iris.core.lifecycle.WorldLifecycleRequest; import art.arcane.iris.core.lifecycle.WorldLifecycleService; @@ -38,6 +40,8 @@ import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.RuntimeProgressMessages; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.pregenerator.PregenTask; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import art.arcane.iris.core.runtime.WorldDeletionQueue; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.platform.PlatformChunkGenerator; @@ -49,6 +53,7 @@ import art.arcane.volmlib.util.hud.HudSlotClaim; import art.arcane.volmlib.util.hud.HudSlotRequest; import art.arcane.volmlib.util.hud.HudSurface; import art.arcane.volmlib.util.localization.MessageArgument; +import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.scheduling.FoliaScheduler; @@ -56,19 +61,24 @@ import lombok.Data; import lombok.experimental.Accessors; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.WorldCreator; import org.bukkit.boss.BarColor; import org.bukkit.boss.BarStyle; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import java.io.IOException; +import java.io.File; +import java.nio.file.Files; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -84,6 +94,9 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML; @Data @Accessors(fluent = true, chain = true) public class IrisCreator { + private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L; + private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L; + /** * Specify an area to pregenerate during creation */ @@ -118,39 +131,13 @@ public class IrisCreator { private BiConsumer studioProgressConsumer; public static boolean removeFromBukkitYml(String name) throws IOException { - YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML); - ConfigurationSection section = yml.getConfigurationSection("worlds"); - if (section == null) { - return false; - } - section.set(name, null); - if (section.getValues(false).keySet().stream().noneMatch(k -> section.get(k) != null)) { - yml.set("worlds", null); - } - yml.save(BUKKIT_YML); - return true; + return BukkitWorldConfiguration.remove(BUKKIT_YML, name); } public static int removeTransientStudioWorldsFromBukkitYml() throws IOException { - YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML); - ConfigurationSection section = yml.getConfigurationSection("worlds"); - if (section == null) { - return 0; - } - int removed = 0; - for (String name : new java.util.ArrayList<>(section.getKeys(false))) { - if (TransientWorldCleanupSupport.isTransientStudioWorldName(name)) { - section.set(name, null); - removed++; - } - } - if (removed > 0) { - if (section.getKeys(false).isEmpty()) { - yml.set("worlds", null); - } - yml.save(BUKKIT_YML); - } - return removed; + return BukkitWorldConfiguration.removeMatching( + BUKKIT_YML, + TransientWorldCleanupSupport::isTransientStudioWorldName); } public static boolean worldLoaded(){ return true; @@ -167,117 +154,182 @@ public class IrisCreator { if (Bukkit.isPrimaryThread()) { throw new IrisException("You cannot invoke create() on the main thread."); } - name = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(name)); - - long createStart = System.currentTimeMillis(); - reportStudioProgress(0.02D, "resolve_dimension"); - reportStudioProgress(0.08D, "resolve_dimension"); - IrisDimension d = IrisToolbelt.getDimension(dimension()); - - if (d == null) { - throw new IrisException("Dimension cannot be found null for id " + dimension()); - } - - if (sender == null) - sender = BukkitPlatform.console(); - - reportStudioProgress(0.16D, "prepare_world_pack"); - if (!studio() || benchmark) { - d = IrisServices.get(StudioSVC.class).installIntoWorld(sender, d, IrisWorldStorage.dimensionRoot(name())); - if (d == null) { - throw new IrisException("Failed to install dimension pack for " + dimension()); - } - dimension = d.getLoadKey(); - } - if (studio()) { - IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen()); - IrisLogging.debug("Studio create scheduling: mode=" + runtimeSchedulerMode.name().toLowerCase(Locale.ROOT) - + ", regionizedRuntime=" + FoliaScheduler.isRegionizedRuntime(Bukkit.getServer())); - } - - reportStudioProgress(0.28D, "install_datapacks"); - AtomicDouble pp = new AtomicDouble(0); - AtomicBoolean done = new AtomicBoolean(false); - WorldCreator wc = new IrisWorldCreator() - .dimension(d) - .name(name) - .seed(seed) - .studio(studio) - .create(); - if (!studio()) { - IrisWorlds.get().put(WorldCreatorCompat.keyOf(wc).toString(), dimension()); - } - ServerConfigurator.installDataPacksIfChanged(!studio()); - IrisLogging.debug("[Studio timing] create.packPrep + datapacks = " + (System.currentTimeMillis() - createStart) + "ms (cumulative in create)"); - reportStudioProgress(0.40D, "install_datapacks"); - - PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator(); - if (access == null) throw new IrisException("Access is null. Something bad happened."); - HudSlotClaim createClaim = !benchmark && studioProgressConsumer == null && sender.isPlayer() - ? openLoaderClaim("iris:world-create") - : null; - AtomicInteger createProgressTask = startCreateProgressReporter(access, done, createClaim); - - - World world; - reportStudioProgress(0.46D, "create_world"); - long nmsStart = System.currentTimeMillis(); + NamespacedKey worldKey; try { - WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE; - WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind); - world = J.sfut(() -> INMS.get().createWorldAsync(wc, request)) - .thenCompose(Function.identity()) - .get(); - IrisLogging.debug("[Studio timing] create.createWorldAsync (NMS bukkit world load + spawn prep) = " + (System.currentTimeMillis() - nmsStart) + "ms"); - } catch (Throwable e) { + worldKey = IrisWorldStorage.managedKeyFromName(name); + } catch (IllegalArgumentException e) { + throw new IrisException(e.getMessage(), e); + } + name = IrisWorldStorage.logicalName(worldKey); + + LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get(); + LifecycleOperationCoordinator.Lease worldLease = null; + try { + reportStudioProgress(0.02D, "resolve_dimension"); + IrisDimension resolvedDimension = IrisToolbelt.getDimension(dimension()); + if (resolvedDimension == null) { + throw new IrisException("Dimension cannot be found for id " + dimension()); + } + worldLease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + worldKey.toString()); + return createReserved(worldKey, resolvedDimension); + } catch (LifecycleOperationCoordinator.BusyException e) { + throw new IrisException(e.getMessage(), e); + } finally { + if (worldLease != null) { + worldLease.close(); + } + } + } + + private World createReserved(NamespacedKey worldKey, IrisDimension resolvedDimension) throws IrisException { + long createStart = System.currentTimeMillis(); + File dimensionRoot; + try { + dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey); + } catch (IllegalArgumentException e) { + throw new IrisException(e.getMessage(), e); + } + if (Files.exists(dimensionRoot.toPath()) || WorldIdentity.resolve(worldKey).isPresent()) { + throw new IrisException("World \"" + name + "\" already exists or is loaded."); + } + if (sender == null) { + sender = BukkitPlatform.console(); + } + + World world = null; + boolean bukkitRegistered = false; + try { + reportStudioProgress(0.08D, "resolve_dimension"); + reportStudioProgress(0.16D, "prepare_world_pack"); + DatapackInstallResult datapackResult = ServerConfigurator.installDataPacksIfChanged(true); + if (!datapackResult.succeeded()) { + throw new IrisException("Failed to compile datapacks for dimension \"" + dimension() + "\"."); + } + if (datapackResult.restartRequired() || !ServerConfigurator.verifyDataPackInstalled(resolvedDimension)) { + ServerConfigurator.restart(); + throw new IrisException("The dimension types for pack \"" + dimension() + "\" are not loaded yet. " + + "Iris queued a restart; run the command again after the server returns."); + } + + IrisDimension installedDimension = resolvedDimension; + if (!studio() || benchmark) { + installedDimension = IrisServices.get(StudioSVC.class) + .installIntoWorld(sender, resolvedDimension, dimensionRoot); + if (installedDimension == null) { + throw new IrisException("Failed to install dimension pack for " + dimension()); + } + dimension = installedDimension.getLoadKey(); + } + if (studio()) { + IrisRuntimeSchedulerMode runtimeSchedulerMode = IrisRuntimeSchedulerMode.resolve(IrisSettings.get().getPregen()); + IrisLogging.debug("Studio create scheduling: mode=" + runtimeSchedulerMode.name().toLowerCase(Locale.ROOT) + + ", regionizedRuntime=" + FoliaScheduler.isRegionizedRuntime(Bukkit.getServer())); + } + + reportStudioProgress(0.28D, "install_datapacks"); + AtomicDouble pp = new AtomicDouble(0); + AtomicBoolean done = new AtomicBoolean(false); + WorldCreator wc = new IrisWorldCreator() + .dimension(installedDimension) + .name(name) + .seed(seed) + .studio(studio) + .create(); + IrisLogging.debug("[Studio timing] create.packPrep + datapacks = " + (System.currentTimeMillis() - createStart) + "ms (cumulative in create)"); + reportStudioProgress(0.40D, "install_datapacks"); + + PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator(); + if (access == null) { + throw new IrisException("Access is null. Something bad happened."); + } + HudSlotClaim createClaim = !benchmark && studioProgressConsumer == null && sender.isPlayer() + ? openLoaderClaim("iris:world-create") + : null; + AtomicInteger createProgressTask = startCreateProgressReporter(access, done, createClaim); + + reportStudioProgress(0.46D, "create_world"); + long nmsStart = System.currentTimeMillis(); + try { + WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE; + WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind); + world = J.sfut(() -> INMS.get().createWorldAsync(wc, request)) + .thenCompose(Function.identity()) + .get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + IrisLogging.debug("[Studio timing] create.createWorldAsync (NMS bukkit world load + spawn prep) = " + (System.currentTimeMillis() - nmsStart) + "ms"); + } catch (Throwable e) { + done.set(true); + cancelRepeatingTask(createProgressTask); + releaseLoaderClaim(createClaim, "iris:world-create"); + if (e instanceof TimeoutException) { + ServerConfigurator.restart("World creation timed out for \"" + name + "\"."); + } + if (J.isFolia() && containsCreateWorldUnsupportedOperation(e)) { + throw new IrisException("Runtime world creation is blocked and the selected world lifecycle backend could not create the world.", e); + } + if (containsMissingDimensionTypes(e)) { + ServerConfigurator.restart(); + throw new IrisException("The dimension types for pack \"" + dimension() + "\" are not loaded on this server yet. " + + "Iris queued a restart; run the command again after the server returns.", e); + } + throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e); + } + done.set(true); cancelRepeatingTask(createProgressTask); releaseLoaderClaim(createClaim, "iris:world-create"); - if (J.isFolia() && containsCreateWorldUnsupportedOperation(e)) { - throw new IrisException("Runtime world creation is blocked and the selected world lifecycle backend could not create the world.", e); + reportStudioProgress(0.86D, "create_world"); + + if (!studio && !benchmark) { + BukkitWorldConfiguration.register(BUKKIT_YML, name, dimension, seed); + bukkitRegistered = true; + World createdWorld = world; + CompletableFuture multiverseRegistration = J.sfut( + () -> IrisServices.get(MultiverseCoreLink.class).updateWorld(createdWorld, dimension) + ); + if (multiverseRegistration == null) { + throw new IrisException("Failed to schedule Multiverse registration for world \"" + name + "\"."); + } + try { + multiverseRegistration.get(30L, TimeUnit.SECONDS); + } catch (TimeoutException e) { + ServerConfigurator.restart("Multiverse registration timed out for \"" + name + "\"."); + throw e; + } } - if (containsMissingDimensionTypes(e)) { - throw new IrisException("The dimension types for pack \"" + dimension() + "\" are not loaded on this server yet. " - + "Iris installed its datapack files - restart the server, then run the command again.", e); + awaitSenderTeleport(world); + + if (pregen != null) { + CompletableFuture ff = new CompletableFuture<>(); + IrisToolbelt.pregenerate(pregen, access) + .onProgress(pp::set) + .whenDone(() -> ff.complete(true)); + + AtomicBoolean dx = new AtomicBoolean(false); + HudSlotClaim pregenClaim = sender.isPlayer() ? openLoaderClaim("iris:pregen") : null; + AtomicInteger pregenProgressTask = startPregenProgressReporter(pp, dx, pregenClaim); + try { + ff.get(); + dx.set(true); + cancelRepeatingTask(pregenProgressTask); + releaseLoaderClaim(pregenClaim, "iris:pregen"); + } catch (Throwable e) { + dx.set(true); + cancelRepeatingTask(pregenProgressTask); + releaseLoaderClaim(pregenClaim, "iris:pregen"); + IrisLogging.reportError(e); + } } - throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e); - } - - done.set(true); - cancelRepeatingTask(createProgressTask); - releaseLoaderClaim(createClaim, "iris:world-create"); - reportStudioProgress(0.86D, "create_world"); - - if (!studio && !benchmark) { - addToBukkitYml(); - J.s(() -> IrisServices.get(MultiverseCoreLink.class).updateWorld(world, dimension)); - } - scheduleSenderTeleport(world); - - if (pregen != null) { - CompletableFuture ff = new CompletableFuture<>(); - - IrisToolbelt.pregenerate(pregen, access) - .onProgress(pp::set) - .whenDone(() -> ff.complete(true)); - - AtomicBoolean dx = new AtomicBoolean(false); - HudSlotClaim pregenClaim = sender.isPlayer() ? openLoaderClaim("iris:pregen") : null; - AtomicInteger pregenProgressTask = startPregenProgressReporter(pp, dx, pregenClaim); - try { - ff.get(); - dx.set(true); - cancelRepeatingTask(pregenProgressTask); - releaseLoaderClaim(pregenClaim, "iris:pregen"); - } catch (Throwable e) { - dx.set(true); - cancelRepeatingTask(pregenProgressTask); - releaseLoaderClaim(pregenClaim, "iris:pregen"); - IrisLogging.reportError(e); - e.printStackTrace(); + return world; + } catch (Throwable failure) { + rollbackWorldCreation(worldKey, world, dimensionRoot, bukkitRegistered, failure); + if (failure instanceof IrisException irisException) { + throw irisException; } + throw new IrisException("Failed to create world \"" + name + "\".", failure); } - return world; } static Player createTeleportTarget(VolmitSender sender, boolean studio, boolean benchmark) { @@ -314,7 +366,7 @@ public class IrisCreator { }); } - private void scheduleSenderTeleport(World world) { + private void awaitSenderTeleport(World world) { Player player = createTeleportTarget(sender, studio, benchmark); if (player == null) { return; @@ -328,16 +380,18 @@ public class IrisCreator { return; } - teleportFuture.whenComplete((success, throwable) -> { - if (throwable != null) { - reportSenderTeleportFailure(player, world, throwable); - return; - } - if (!Boolean.TRUE.equals(success)) { + try { + Boolean teleported = teleportFuture.get(60L, TimeUnit.SECONDS); + if (!Boolean.TRUE.equals(teleported)) { reportSenderTeleportFailure(player, world, new IllegalStateException( "The runtime teleport operation returned false for player \"" + player.getName() + "\".")); } - }); + } catch (TimeoutException e) { + ServerConfigurator.restart("World entry teleport timed out for \"" + world.getName() + "\"."); + reportSenderTeleportFailure(player, world, e); + } catch (Throwable e) { + reportSenderTeleportFailure(player, world, e); + } } private void reportSenderTeleportFailure(Player player, World world, Throwable throwable) { @@ -562,20 +616,115 @@ public class IrisCreator { return false; } - private void addToBukkitYml() { - YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML); - String gen = "Iris:" + dimension; - ConfigurationSection section = yml.contains("worlds") ? yml.getConfigurationSection("worlds") : yml.createSection("worlds"); - if (!section.contains(name)) { - section.createSection(name).set("generator", gen); + private void rollbackWorldCreation( + NamespacedKey worldKey, + World createdWorld, + File dimensionRoot, + boolean bukkitRegistered, + Throwable failure + ) { + World activeWorld = createdWorld == null ? WorldIdentity.resolve(worldKey).orElse(null) : createdWorld; + boolean safeToDelete = activeWorld != null || !containsTimeout(failure); + if (activeWorld != null) { + IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true); try { - yml.save(BUKKIT_YML); - IrisLogging.info("Registered \"" + name + "\" in bukkit.yml"); - } catch (IOException e) { - IrisLogging.error("Failed to update bukkit.yml!"); - IrisLogging.reportError(e); - e.printStackTrace(); + PlatformChunkGenerator generator = IrisToolbelt.access(activeWorld); + boolean evacuated = Boolean.TRUE.equals(IrisToolbelt.evacuateAsync(activeWorld) + .get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + if (!evacuated) { + safeToDelete = false; + failure.addSuppressed(new IllegalStateException( + "Rollback could not evacuate world \"" + name + "\".")); + } + boolean unloaded = safeToDelete && Boolean.TRUE.equals(WorldLifecycleService.get() + .unloadAsync(activeWorld, true) + .get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + if (!unloaded) { + safeToDelete = false; + failure.addSuppressed(new IllegalStateException("Rollback could not unload world \"" + name + "\".")); + } + if (safeToDelete && generator != null) { + generator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } catch (Throwable rollbackFailure) { + Throwable cause = unwrapFailure(rollbackFailure); + failure.addSuppressed(cause); + safeToDelete = false; + if (cause instanceof TimeoutException) { + ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\"."); + } + } finally { + IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback"); } } + + if (bukkitRegistered) { + try { + CompletableFuture multiverseRemoval = J.sfut( + () -> IrisServices.get(MultiverseCoreLink.class).removeFromConfig(name) + ); + if (multiverseRemoval == null) { + throw new IllegalStateException("Failed to schedule Multiverse rollback for \"" + name + "\"."); + } + try { + multiverseRemoval.get(30L, TimeUnit.SECONDS); + } catch (TimeoutException e) { + ServerConfigurator.restart("Multiverse rollback timed out for \"" + name + "\"."); + throw e; + } + } catch (Throwable rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + try { + BukkitWorldConfiguration.remove(BUKKIT_YML, name); + } catch (Throwable rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + try { + IrisWorlds.get().remove(worldKey.toString()); + } catch (Throwable rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + if (!safeToDelete) { + queueRollbackDeletion(name, failure); + return; + } + try { + AtomicDirectoryPublisher.deleteTree(dimensionRoot.toPath()); + } catch (Throwable rollbackFailure) { + failure.addSuppressed(rollbackFailure); + queueRollbackDeletion(name, failure); + } + } + + private void queueRollbackDeletion(String worldName, Throwable failure) { + try { + IrisServices.get(WorldDeletionQueue.class).queueExactForStartupDeletion(List.of(worldName)); + } catch (Throwable queueFailure) { + failure.addSuppressed(queueFailure); + } + } + + private static boolean containsTimeout(Throwable throwable) { + Throwable cursor = throwable; + while (cursor != null) { + if (cursor instanceof TimeoutException) { + return true; + } + cursor = cursor.getCause(); + } + return false; + } + + private static Throwable unwrapFailure(Throwable throwable) { + Throwable cursor = throwable; + while (cursor instanceof CompletionException || cursor instanceof ExecutionException) { + if (cursor.getCause() == null) { + break; + } + cursor = cursor.getCause(); + } + return cursor; } } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java b/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java index fdcd1b3d1..fb743b72a 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisPackBenchmarking.java @@ -8,6 +8,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.bukkit.WorldIdentity; @@ -24,6 +25,7 @@ import java.io.IOException; import java.time.Clock; import java.time.LocalDateTime; import java.util.Collections; +import java.util.concurrent.CompletableFuture; public class IrisPackBenchmarking { @@ -106,11 +108,28 @@ public class IrisPackBenchmarking { e.printStackTrace(); } - J.s(() -> { + J.sfut(() -> { World world = benchmarkWorld(); - if (world == null) return; + if (world == null) { + return null; + } IrisToolbelt.evacuate(world); - WorldLifecycleService.get().unload(world, true); + return world; + }).thenCompose(world -> { + if (world == null) { + return CompletableFuture.completedFuture(false); + } + PlatformChunkGenerator generator = IrisToolbelt.access(world); + CompletableFuture closeFuture = generator == null + ? CompletableFuture.completedFuture(null) + : generator.closeAsync(); + return closeFuture.thenCompose(unused -> WorldLifecycleService.get().unloadAsync(world, true)); + }).whenComplete((unloaded, throwable) -> { + if (throwable != null) { + IrisLogging.reportError("Failed to close the benchmark world.", throwable); + } else if (!Boolean.TRUE.equals(unloaded)) { + IrisLogging.error("Failed to unload the benchmark world."); + } }); stopwatch.end(); diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index a8742428b..a64470b36 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -32,6 +32,7 @@ import art.arcane.iris.core.pregenerator.PregenPerformanceProfile; import art.arcane.iris.core.pregenerator.PregenTask; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.core.pregenerator.cache.PregenCache; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.pregenerator.methods.CachedPregenMethod; import art.arcane.iris.core.pregenerator.methods.HybridPregenMethod; @@ -57,6 +58,7 @@ import java.io.IOException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -94,23 +96,27 @@ public class IrisToolbelt { } File packsFolder = IrisPlatforms.get().dataFolder("packs"); - File pack = new File(packsFolder, reference.pack()); - if (!pack.exists()) { + File pack = PackDirectoryResolver.resolveExisting(packsFolder, reference.pack()); + if (pack == null) { File found = findCaseInsensitivePack(packsFolder, reference.pack()); if (found != null) { pack = found; } } - if (!pack.exists()) { + if (pack == null) { IrisServices.get(StudioSVC.class).downloadSearch(new VolmitSender(Bukkit.getConsoleSender(), BukkitPlatform.volmitPlugin().getTag()), reference.pack(), false); - File found = findCaseInsensitivePack(packsFolder, reference.pack()); + String installedPackName = installedPackName(reference.pack()); + File found = PackDirectoryResolver.resolveExisting(packsFolder, installedPackName); + if (found == null) { + found = findCaseInsensitivePack(packsFolder, installedPackName); + } if (found != null) { pack = found; } } - if (!pack.exists()) { + if (pack == null) { return null; } @@ -155,24 +161,64 @@ public class IrisToolbelt { } int separator = requested.indexOf(':'); if (separator < 0) { - return new PackReference(requested, requested, false); + if (!isSafePackDescriptor(requested)) { + return null; + } + return new PackReference(requested, installedPackName(requested), false); } String pack = requested.substring(0, separator).trim(); String dimension = requested.substring(separator + 1).trim(); - if (pack.isEmpty() || dimension.isEmpty()) { + if (!isSafePackDescriptor(pack) || !isSafeDimensionKey(dimension)) { return null; } return new PackReference(pack, dimension, true); } - private static File findCaseInsensitivePack(File packsFolder, String requested) { - File[] children = packsFolder.listFiles(); - if (children == null) { - return null; + private static boolean isSafePackDescriptor(String value) { + String[] segments = value.split("/", -1); + if (segments.length < 1 || segments.length > 3) { + return false; } + for (String segment : segments) { + if (segment.isEmpty() + || segment.equals(".") + || segment.equals("..") + || segment.startsWith(".") + || !segment.matches("[A-Za-z0-9_-]+")) { + return false; + } + } + return true; + } - for (File child : children) { - if (child.isDirectory() && child.getName().equalsIgnoreCase(requested)) { + private static boolean isSafeDimensionKey(String value) { + if (value.isEmpty() || value.length() > 256) { + return false; + } + String[] segments = value.split("/", -1); + if (segments.length > 16) { + return false; + } + for (String segment : segments) { + if (segment.isEmpty() + || segment.equals(".") + || segment.equals("..") + || segment.startsWith(".") + || !segment.matches("[A-Za-z0-9_-]+")) { + return false; + } + } + return true; + } + + private static String installedPackName(String descriptor) { + String[] segments = descriptor.split("/"); + return segments.length > 1 ? segments[1] : segments[0]; + } + + private static File findCaseInsensitivePack(File packsFolder, String requested) { + for (File child : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) { + if (child.getName().equalsIgnoreCase(requested)) { return child; } } @@ -357,26 +403,11 @@ public class IrisToolbelt { * @param world the world to evac */ public static boolean evacuate(World world) { - if (world == null || isServerStopping()) { - return false; - } + return beginEvacuation(evacuateAsync(world)); + } - for (World i : Bukkit.getWorlds()) { - if (!WorldIdentity.key(i).equals(WorldIdentity.key(world))) { - for (Player j : new ArrayList<>(world.getPlayers())) { - new VolmitSender(j, BukkitPlatform.volmitPlugin().getTag()).sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_TOOLBELT_YOU_HAVE_BEEN_EVACUATED_FROM_THIS_WORLD)); - Location target = i.getSpawnLocation(); - Runnable teleportTask = () -> teleportAsyncSafely(j, target); - if (!J.runEntity(j, teleportTask)) { - teleportTask.run(); - } - } - - return true; - } - } - - return false; + public static CompletableFuture evacuateAsync(World world) { + return evacuateAsync(world, null, false); } /** @@ -387,25 +418,11 @@ public class IrisToolbelt { * @return true if it was evacuated. */ public static boolean evacuate(World world, String m) { - if (world == null || isServerStopping()) { - return false; - } + return beginEvacuation(evacuateAsync(world, m)); + } - for (World i : Bukkit.getWorlds()) { - if (!WorldIdentity.key(i).equals(WorldIdentity.key(world))) { - for (Player j : new ArrayList<>(world.getPlayers())) { - new VolmitSender(j, BukkitPlatform.volmitPlugin().getTag()).sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_TOOLBELT_YOU_HAVE_BEEN_EVACUATED_FROM_THIS_WORLD_2, MessageArgument.untrusted("m", String.valueOf(m)))); - Location target = i.getSpawnLocation(); - Runnable teleportTask = () -> teleportAsyncSafely(j, target); - if (!J.runEntity(j, teleportTask)) { - teleportTask.run(); - } - } - return true; - } - } - - return false; + public static CompletableFuture evacuateAsync(World world, String message) { + return evacuateAsync(world, message, true); } public static boolean isStudio(World i) { @@ -417,25 +434,125 @@ public class IrisToolbelt { return generator != null && generator.isStudio(); } - private static void teleportAsyncSafely(Player player, Location target) { + static CompletableFuture settleEvacuations(List> evacuations) { + List> pending = List.copyOf(evacuations); + if (pending.isEmpty()) { + return CompletableFuture.completedFuture(true); + } + + return CompletableFuture.allOf(pending.toArray(CompletableFuture[]::new)) + .handle((ignored, throwable) -> { + if (throwable != null) { + return false; + } + for (CompletableFuture evacuation : pending) { + if (!Boolean.TRUE.equals(evacuation.getNow(false))) { + return false; + } + } + return true; + }); + } + + private static CompletableFuture evacuateAsync(World world, String message, boolean customMessage) { + if (world == null || isServerStopping()) { + return CompletableFuture.completedFuture(false); + } + + ArrayList players = new ArrayList<>(world.getPlayers()); + if (players.isEmpty()) { + return CompletableFuture.completedFuture(true); + } + + World targetWorld = null; + for (World candidate : Bukkit.getWorlds()) { + if (!WorldIdentity.key(candidate).equals(WorldIdentity.key(world))) { + targetWorld = candidate; + break; + } + } + if (targetWorld == null) { + return CompletableFuture.completedFuture(false); + } + + Location target = targetWorld.getSpawnLocation(); + ArrayList> evacuations = new ArrayList<>(players.size()); + for (Player player : players) { + CompletableFuture evacuation = new CompletableFuture<>(); + Runnable teleportTask = () -> { + try { + if (customMessage) { + new VolmitSender(player, BukkitPlatform.volmitPlugin().getTag()).sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.IRIS_TOOLBELT_YOU_HAVE_BEEN_EVACUATED_FROM_THIS_WORLD_2, + MessageArgument.untrusted("m", String.valueOf(message)))); + } else { + new VolmitSender(player, BukkitPlatform.volmitPlugin().getTag()).sendMessage(IrisLanguage.text( + BukkitRuntimeMessages.IRIS_TOOLBELT_YOU_HAVE_BEEN_EVACUATED_FROM_THIS_WORLD)); + } + teleportAsyncSafely(player, target).whenComplete((teleported, throwable) -> { + if (throwable == null) { + evacuation.complete(Boolean.TRUE.equals(teleported)); + } else { + evacuation.completeExceptionally(throwable); + } + }); + } catch (Throwable failure) { + evacuation.completeExceptionally(failure); + } + }; + try { + if (!J.runEntity(player, teleportTask)) { + teleportTask.run(); + } + } catch (Throwable failure) { + evacuation.completeExceptionally(failure); + } + evacuations.add(evacuation); + } + return settleEvacuations(evacuations); + } + + private static boolean beginEvacuation(CompletableFuture evacuation) { + if (evacuation.isDone()) { + try { + return Boolean.TRUE.equals(evacuation.getNow(false)); + } catch (Throwable failure) { + if (!isServerStopping()) { + IrisLogging.reportError(failure); + } + return false; + } + } + evacuation.exceptionally(throwable -> { + if (!isServerStopping()) { + IrisLogging.reportError(throwable); + } + return false; + }); + return true; + } + + private static CompletableFuture teleportAsyncSafely(Player player, Location target) { if (player == null || target == null || isServerStopping()) { - return; + return CompletableFuture.completedFuture(false); } try { CompletableFuture teleportFuture = PaperLib.teleportAsync(player, target); - if (teleportFuture != null) { - teleportFuture.exceptionally(throwable -> { - if (!isServerStopping()) { - IrisLogging.reportError(throwable); - } - return false; - }); + if (teleportFuture == null) { + return CompletableFuture.completedFuture(false); } + return teleportFuture.exceptionally(throwable -> { + if (!isServerStopping()) { + IrisLogging.reportError(throwable); + } + return false; + }); } catch (Throwable throwable) { if (!isServerStopping()) { IrisLogging.reportError(throwable); } + return CompletableFuture.completedFuture(false); } } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java index daae0963b..0b7d6d11c 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java @@ -22,6 +22,7 @@ import art.arcane.iris.engine.EngineBackgroundTasks.BackgroundTaskDrain; import art.arcane.iris.engine.EngineRuntimeBuilder.RuntimeAssembly; import art.arcane.iris.engine.IrisEngine.LifecycleState; import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -67,26 +68,31 @@ final class EngineShutdownSequence { } if (backgroundDrain.allowsResourceRelease()) { - Throwable prefetchFailure = runCleanup(null, engine::savePrefetchOnce); - Throwable engineDataFailure = runCleanup(null, engine::saveEngineData); - failure = appendFailure(failure, prefetchFailure); - failure = appendFailure(failure, engineDataFailure); - failure = releaseRuntime(failure); - if (runtimeReleased) { - failure = releaseTarget(failure); - } - if (targetReleased) { - failure = releaseMantle(failure); - } - if (prefetchFailure == null - && engineDataFailure == null - && runtimeReleased - && targetReleased - && mantleReleased) { - failure = releaseEngineDataForShutdown(failure); - } - if (engineDataReleased) { - failure = releasePreservation(failure); + Throwable ownershipFailure = runCleanup(null, + () -> NativeStructureOwnershipStore.close(engine)); + failure = appendFailure(failure, ownershipFailure); + if (ownershipFailure == null) { + Throwable prefetchFailure = runCleanup(null, engine::savePrefetchOnce); + Throwable engineDataFailure = runCleanup(null, engine::saveEngineData); + failure = appendFailure(failure, prefetchFailure); + failure = appendFailure(failure, engineDataFailure); + failure = releaseRuntime(failure); + if (runtimeReleased) { + failure = releaseTarget(failure); + } + if (targetReleased) { + failure = releaseMantle(failure); + } + if (prefetchFailure == null + && engineDataFailure == null + && runtimeReleased + && targetReleased + && mantleReleased) { + failure = releaseEngineDataForShutdown(failure); + } + if (engineDataReleased) { + failure = releasePreservation(failure); + } } } if (failure == null @@ -130,18 +136,23 @@ final class EngineShutdownSequence { } return; } - cleanupFailure = closeRuntime(engine.runtime, cleanupFailure); - engine.runtime = null; - cleanupFailure = runCleanup(cleanupFailure, engine.getTarget()::close); - cleanupFailure = runCleanup(cleanupFailure, engine.getMantle()::close); - cleanupFailure = runCleanup(cleanupFailure, engine.engineDataStore::releaseEngineData); - engine.closed = true; - cleanupFailure = runCleanup(cleanupFailure, () -> { - PreservationRegistry registry = IrisServices.getOrNull(PreservationRegistry.class); - if (registry != null) { - registry.dereference(); - } - }); + Throwable ownershipFailure = runCleanup(null, + () -> NativeStructureOwnershipStore.close(engine)); + cleanupFailure = appendFailure(cleanupFailure, ownershipFailure); + if (ownershipFailure == null) { + cleanupFailure = closeRuntime(engine.runtime, cleanupFailure); + engine.runtime = null; + cleanupFailure = runCleanup(cleanupFailure, engine.getTarget()::close); + cleanupFailure = runCleanup(cleanupFailure, engine.getMantle()::close); + cleanupFailure = runCleanup(cleanupFailure, engine.engineDataStore::releaseEngineData); + engine.closed = true; + cleanupFailure = runCleanup(cleanupFailure, () -> { + PreservationRegistry registry = IrisServices.getOrNull(PreservationRegistry.class); + if (registry != null) { + registry.dereference(); + } + }); + } if (cleanupFailure != null && cleanupFailure != original) { original.addSuppressed(cleanupFailure); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 9b4358be3..0a025be01 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -180,12 +180,14 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, EngineMetrics getMetrics(); default void save() { + NativeStructureOwnershipStore.flush(this); getMantle().save(); getWorldManager().onSave(); saveEngineData(); } default void saveNow() { + NativeStructureOwnershipStore.flush(this); getMantle().saveAllNow(); saveEngineData(); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index d71c58e30..8e1e1436b 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -41,9 +41,11 @@ import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Set; import java.util.regex.Pattern; @@ -61,7 +63,7 @@ import java.util.regex.Pattern; * placements enumerate their finite configured starts directly. */ public final class IrisStructureLocator { - private static final int DENSITY_CANDIDATE_BUDGET = 4_096; + private static final int CANDIDATE_BUDGET = 4_096; private static final int MAX_BURIAL_COLUMNS = 2_000_000; private static final int UNDERGROUND_SURFACE_CLEARANCE = 1; private static final Pattern NAMESPACED_RESOURCE_KEY = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); @@ -69,7 +71,7 @@ public final class IrisStructureLocator { private static final Cache INDEX_CACHE = Caffeine.newBuilder().weakKeys().build(); private static final PlacementIndex EMPTY_INDEX = new PlacementIndex( Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), - Collections.emptyList()); + Collections.emptySet(), Collections.emptyList()); private static final LocateResult NOT_FOUND_RESULT = new LocateResult(LocateStatus.NOT_FOUND, 0, 0, 0); private static final LocateResult SEARCH_LIMIT_RESULT = new LocateResult(LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0); @@ -95,6 +97,25 @@ public final class IrisStructureLocator { || placementIndex.vanillaAliases.contains(normalizedKey); } + public static boolean hasNativePlacement(Engine engine, String key) { + if (engine == null || key == null || key.isBlank()) { + return false; + } + return index(engine).nativeKeys.contains(normalize(key)); + } + + public static boolean hasEditablePlacement(Engine engine, String key) { + if (engine == null || key == null || key.isBlank()) { + return false; + } + for (IrisStructurePlacement placement : index(engine).placements) { + if (placement.hasIrisStructures() && matches(placement, key, engine.getData())) { + return true; + } + } + return false; + } + public static boolean suppressesVanilla(Engine engine, String vanillaKey) { if (engine == null || vanillaKey == null || vanillaKey.isEmpty()) { return false; @@ -116,13 +137,180 @@ public final class IrisStructureLocator { } public static LocateResult locate(Engine engine, String key, int fromBlockX, int fromBlockZ, int maxRadiusChunks) { + return locate(engine, key, fromBlockX, fromBlockZ, maxRadiusChunks, (chunkX, chunkZ) -> true); + } + + public static LocateResult locate(Engine engine, String key, int fromBlockX, int fromBlockZ, + int maxRadiusChunks, CandidateFilter candidateFilter) { + try { + return locateFiltered(engine, key, fromBlockX, fromBlockZ, maxRadiusChunks, candidateFilter); + } catch (CandidateSearchLimitException ignored) { + return SEARCH_LIMIT_RESULT; + } + } + + public static LocateResult locateInPlacementRings( + Engine engine, String key, int fromBlockX, int fromBlockZ, + int maxSearchRadius, CandidateFilter candidateFilter) { + try { + return locateInPlacementRingsFiltered( + engine, key, fromBlockX, fromBlockZ, maxSearchRadius, candidateFilter); + } catch (CandidateSearchLimitException ignored) { + return SEARCH_LIMIT_RESULT; + } + } + + private static LocateResult locateInPlacementRingsFiltered( + Engine engine, String key, int fromBlockX, int fromBlockZ, + int maxSearchRadius, CandidateFilter candidateFilter) { if (!isPlaced(engine, key)) { return NOT_FOUND_RESULT; } - int max = Math.max(1, Math.min(maxRadiusChunks, 2048)); + CandidateFilter activeFilter = Objects.requireNonNull( + candidateFilter, "Structure locate filter must not be null"); + int maximumRing = Math.max(0, Math.min(maxSearchRadius, 2048)); + int centerChunkX = fromBlockX >> 4; + int centerChunkZ = fromBlockZ >> 4; + PlacementCatalog catalog = collectPlacementCatalog(engine, key); + if (catalog.randomSpread().isEmpty() + && catalog.concentricRings().isEmpty() && !catalog.hasDensity()) { + return NOT_FOUND_RESULT; + } + SeedManager seedManager = engine.getSeedManager(); + if (seedManager == null) { + throw new IllegalStateException( + "Iris structure locate requires a bound seed manager for '" + key + "'"); + } + long seed = seedManager.getMantle(); + CandidateBudget candidateBudget = new CandidateBudget(); + LocatedCandidate best = nearestConcentricCandidate( + engine, key, fromBlockX, fromBlockZ, seed, catalog, activeFilter, candidateBudget); + + for (int ring = 0; ring <= maximumRing; ring++) { + boolean foundInRing = false; + for (RandomSpreadParameters parameters : catalog.randomSpread()) { + LocatedCandidate candidate = firstRandomSpreadCandidateInRing( + engine, key, fromBlockX, fromBlockZ, + centerChunkX, centerChunkZ, ring, seed, + parameters, activeFilter, candidateBudget); + if (candidate == null) { + continue; + } + foundInRing = true; + if (best == null || candidate.distanceSquared() < best.distanceSquared()) { + best = candidate; + } + } + if (catalog.hasDensity()) { + LocatedCandidate candidate = firstDensityCandidateInRing( + engine, key, fromBlockX, fromBlockZ, + centerChunkX, centerChunkZ, ring, + activeFilter, candidateBudget); + if (candidate != null) { + foundInRing = true; + if (best == null || candidate.distanceSquared() < best.distanceSquared()) { + best = candidate; + } + } + } + if (foundInRing) { + return found(best); + } + } + return best == null ? NOT_FOUND_RESULT : found(best); + } + + private static LocatedCandidate nearestConcentricCandidate( + Engine engine, String key, int fromBlockX, int fromBlockZ, + long seed, PlacementCatalog catalog, CandidateFilter candidateFilter, + CandidateBudget candidateBudget) { + LocatedCandidate best = null; + Set checkedChunks = new LinkedHashSet<>(); + for (IrisStructurePlacement placement : catalog.concentricRings()) { + int count = Math.max(1, placement.getRingCount()); + for (int placementIndex = 0; placementIndex < count; placementIndex++) { + candidateBudget.claim(); + int[] candidate = StructurePlacementGrid.concentricRingChunk( + placement, placementIndex, seed); + if (candidate == null || !checkedChunks.add(chunkKey(candidate[0], candidate[1]))) { + continue; + } + ResolvedStart resolved = resolveInChunk(engine, key, candidate[0], candidate[1]); + if (resolved == null || !candidateFilter.accept(candidate[0], candidate[1])) { + continue; + } + long distanceSquared = blockDistanceSquared( + resolved, fromBlockX, fromBlockZ); + if (best == null || distanceSquared < best.distanceSquared()) { + best = new LocatedCandidate(resolved, distanceSquared); + } + } + } + return best; + } + + private static LocatedCandidate firstRandomSpreadCandidateInRing( + Engine engine, String key, int fromBlockX, int fromBlockZ, + int centerChunkX, int centerChunkZ, int ring, long seed, + RandomSpreadParameters parameters, CandidateFilter candidateFilter, + CandidateBudget candidateBudget) { + int spacing = Math.max(1, parameters.spacing()); + int centerCellX = Math.floorDiv(centerChunkX, spacing); + int centerCellZ = Math.floorDiv(centerChunkZ, spacing); + for (int offsetX = -ring; offsetX <= ring; offsetX++) { + boolean edgeX = offsetX == -ring || offsetX == ring; + for (int offsetZ = -ring; offsetZ <= ring; offsetZ++) { + boolean edgeZ = offsetZ == -ring || offsetZ == ring; + if (!edgeX && !edgeZ) { + continue; + } + candidateBudget.claim(); + int[] candidate = StructurePlacementGrid.randomSpreadCellChunk( + centerCellX + offsetX, centerCellZ + offsetZ, + spacing, parameters.separation(), parameters.salt(), seed); + ResolvedStart resolved = resolveInChunk(engine, key, candidate[0], candidate[1]); + if (resolved != null && candidateFilter.accept(candidate[0], candidate[1])) { + return new LocatedCandidate( + resolved, blockDistanceSquared(resolved, fromBlockX, fromBlockZ)); + } + } + } + return null; + } + + private static LocatedCandidate firstDensityCandidateInRing( + Engine engine, String key, int fromBlockX, int fromBlockZ, + int centerChunkX, int centerChunkZ, int ring, + CandidateFilter candidateFilter, CandidateBudget candidateBudget) { + for (int offsetX = -ring; offsetX <= ring; offsetX++) { + boolean edgeX = offsetX == -ring || offsetX == ring; + for (int offsetZ = -ring; offsetZ <= ring; offsetZ++) { + boolean edgeZ = offsetZ == -ring || offsetZ == ring; + if (!edgeX && !edgeZ) { + continue; + } + int chunkX = centerChunkX + offsetX; + int chunkZ = centerChunkZ + offsetZ; + candidateBudget.claim(); + ResolvedStart resolved = resolveInChunk(engine, key, chunkX, chunkZ); + if (resolved != null && candidateFilter.accept(chunkX, chunkZ)) { + return new LocatedCandidate( + resolved, blockDistanceSquared(resolved, fromBlockX, fromBlockZ)); + } + } + } + return null; + } + + private static LocateResult locateFiltered(Engine engine, String key, int fromBlockX, int fromBlockZ, + int maxRadiusChunks, CandidateFilter candidateFilter) { + if (!isPlaced(engine, key)) { + return NOT_FOUND_RESULT; + } + CandidateFilter activeFilter = Objects.requireNonNull(candidateFilter, "Structure locate filter must not be null"); + int max = Math.max(0, Math.min(maxRadiusChunks, 2048)); int pcx = fromBlockX >> 4; int pcz = fromBlockZ >> 4; - long maxDistSq = (long) max * (long) max; LocatedCandidate best = null; PlacementCatalog catalog = collectPlacementCatalog(engine, key); if (catalog.randomSpread().isEmpty() && catalog.concentricRings().isEmpty() && !catalog.hasDensity()) { @@ -133,6 +321,7 @@ public final class IrisStructureLocator { throw new IllegalStateException("Iris structure locate requires a bound seed manager for '" + key + "'"); } long seed = seedManager.getMantle(); + CandidateBudget candidateBudget = new CandidateBudget(); for (RandomSpreadParameters parameters : catalog.randomSpread) { int spacing = Math.max(1, parameters.spacing()); @@ -141,8 +330,10 @@ public final class IrisStructureLocator { int cellRadius = (max / spacing) + 2; for (int r = 0; r <= cellRadius; r++) { - long lowerBound = cellRingDistanceLowerBound(r, spacing, pcx, pcz, centerCellX, centerCellZ); - if (best != null && lowerBound * lowerBound >= best.distanceSquared()) { + long chunkLowerBound = cellRingDistanceLowerBound( + r, spacing, pcx, pcz, centerCellX, centerCellZ); + long blockLowerBound = chunkDeltaBlockDistanceLowerBound(chunkLowerBound); + if (best != null && squareSaturated(blockLowerBound) >= best.distanceSquared()) { break; } for (int dx = -r; dx <= r; dx++) { @@ -150,17 +341,23 @@ public final class IrisStructureLocator { if (Math.max(Math.abs(dx), Math.abs(dz)) != r) { continue; } + candidateBudget.claim(); int[] candidate = StructurePlacementGrid.randomSpreadCellChunk( centerCellX + dx, centerCellZ + dz, spacing, parameters.separation(), parameters.salt(), seed); int cx = candidate[0]; int cz = candidate[1]; - long distSq = distanceSquared(cx, cz, pcx, pcz); - if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) { + if (!withinRadius(cx, cz, pcx, pcz, max) + || best != null && chunkBlockDistanceSquaredLowerBound( + cx, cz, fromBlockX, fromBlockZ) >= best.distanceSquared()) { continue; } ResolvedStart resolved = resolveInChunk(engine, key, cx, cz); - if (resolved != null) { - best = new LocatedCandidate(resolved, distSq); + if (resolved != null && activeFilter.accept(cx, cz)) { + long distanceSquared = blockDistanceSquared( + resolved, fromBlockX, fromBlockZ); + if (best == null || distanceSquared < best.distanceSquared()) { + best = new LocatedCandidate(resolved, distanceSquared); + } } } } @@ -171,25 +368,31 @@ public final class IrisStructureLocator { for (IrisStructurePlacement placement : catalog.concentricRings) { int count = Math.max(1, placement.getRingCount()); for (int placementIndex = 0; placementIndex < count; placementIndex++) { + candidateBudget.claim(); int[] candidate = StructurePlacementGrid.concentricRingChunk(placement, placementIndex, seed); if (candidate == null || !checkedRingChunks.add(chunkKey(candidate[0], candidate[1]))) { continue; } - long distSq = distanceSquared(candidate[0], candidate[1], pcx, pcz); - if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) { + if (!withinRadius(candidate[0], candidate[1], pcx, pcz, max) + || best != null && chunkBlockDistanceSquaredLowerBound( + candidate[0], candidate[1], fromBlockX, fromBlockZ) >= best.distanceSquared()) { continue; } ResolvedStart resolved = resolveInChunk(engine, key, candidate[0], candidate[1]); - if (resolved != null) { - best = new LocatedCandidate(resolved, distSq); + if (resolved != null && activeFilter.accept(candidate[0], candidate[1])) { + long distanceSquared = blockDistanceSquared( + resolved, fromBlockX, fromBlockZ); + if (best == null || distanceSquared < best.distanceSquared()) { + best = new LocatedCandidate(resolved, distanceSquared); + } } } } if (catalog.hasDensity) { - int checkedDensityCandidates = 0; for (int r = 0; r <= max; r++) { - if (best != null && (long) r * r > best.distanceSquared()) { + long ringBlockLowerBound = chunkDeltaBlockDistanceLowerBound(r); + if (best != null && squareSaturated(ringBlockLowerBound) >= best.distanceSquared()) { break; } for (int dx = -r; dx <= r; dx++) { @@ -199,17 +402,18 @@ public final class IrisStructureLocator { } int cx = pcx + dx; int cz = pcz + dz; - long distSq = distanceSquared(cx, cz, pcx, pcz); - if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) { + if (best != null && chunkBlockDistanceSquaredLowerBound( + cx, cz, fromBlockX, fromBlockZ) >= best.distanceSquared()) { continue; } - if (checkedDensityCandidates >= DENSITY_CANDIDATE_BUDGET) { - return SEARCH_LIMIT_RESULT; - } - checkedDensityCandidates++; + candidateBudget.claim(); ResolvedStart resolved = resolveInChunk(engine, key, cx, cz); - if (resolved != null) { - best = new LocatedCandidate(resolved, distSq); + if (resolved != null && activeFilter.accept(cx, cz)) { + long distanceSquared = blockDistanceSquared( + resolved, fromBlockX, fromBlockZ); + if (best == null || distanceSquared < best.distanceSquared()) { + best = new LocatedCandidate(resolved, distanceSquared); + } } } } @@ -219,8 +423,13 @@ public final class IrisStructureLocator { if (best == null) { return NOT_FOUND_RESULT; } - ResolvedStart resolved = best.resolved(); - return new LocateResult(LocateStatus.FOUND, resolved.originX(), resolved.baseY(), resolved.originZ()); + return found(best); + } + + static boolean withinRadius(int chunkX, int chunkZ, + int centerChunkX, int centerChunkZ, int radius) { + return Math.abs((long) chunkX - centerChunkX) <= radius + && Math.abs((long) chunkZ - centerChunkZ) <= radius; } public static ResolvedPlacement resolvePlacement(Engine engine, IrisStructurePlacement placement, int cx, int cz) { @@ -341,18 +550,20 @@ public final class IrisStructureLocator { private static ResolvedStart resolveInChunk(Engine engine, String key, int cx, int cz) { IrisData data = engine.getData(); + String normalizedKey = normalize(key); + if (hasNativePlacement(engine, key)) { + for (NativeStructureStartPlan plan : NativeStructurePlacementPlanner.plansAt(engine, cx, cz)) { + if (normalize(plan.source().getStructure()).equals(normalizedKey)) { + return new ResolvedStart(cx << 4, plan.baseY(), cz << 4); + } + } + } KList placements = StructurePlacementScope.placementsAt(engine, cx, cz); for (IrisStructurePlacement placement : placements) { if (!matches(placement, key, data)) { continue; } if (placement.hasNativeStructures()) { - NativeStructureStartPlan plan = NativeStructurePlacementPlanner.planAt( - engine, placement, cx, cz); - if (plan != null && normalize(plan.source().getStructure()).equals(normalize(key))) { - return new ResolvedStart( - cx << 4, plan.baseY(), cz << 4); - } continue; } ResolvedPlacement resolved = resolvePlacement(engine, placement, cx, cz); @@ -370,6 +581,10 @@ public final class IrisStructureLocator { if (worldMin > worldMax) { return null; } + if (!placement.isUnderwater() + && NativeStructurePlacementPlanner.isSubmerged(engine, originX, originZ)) { + return null; + } if (!placement.isUnderground()) { int surfaceY = engine.getHeight(originX, originZ, true) + engine.getMinHeight(); return surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight() ? null : surfaceY; @@ -613,10 +828,56 @@ public final class IrisStructureLocator { return configuredMin <= configuredMax && configuredMin <= worldMax && configuredMax >= worldMin; } - private static long distanceSquared(int cx, int cz, int pcx, int pcz) { - long dx = (long) cx - pcx; - long dz = (long) cz - pcz; - return dx * dx + dz * dz; + private static long blockDistanceSquared( + ResolvedStart resolved, int fromBlockX, int fromBlockZ) { + long dx = (long) resolved.originX() - fromBlockX; + long dz = (long) resolved.originZ() - fromBlockZ; + return distanceSquaredSaturated(dx, dz); + } + + static long chunkBlockDistanceSquaredLowerBound( + int chunkX, int chunkZ, int fromBlockX, int fromBlockZ) { + long minimumX = (long) chunkX << 4; + long minimumZ = (long) chunkZ << 4; + long dx = axisDistance(fromBlockX, minimumX, minimumX + 15L); + long dz = axisDistance(fromBlockZ, minimumZ, minimumZ + 15L); + return distanceSquaredSaturated(dx, dz); + } + + private static long axisDistance(long point, long minimum, long maximum) { + if (point < minimum) { + return minimum - point; + } + if (point > maximum) { + return point - maximum; + } + return 0L; + } + + private static long chunkDeltaBlockDistanceLowerBound(long chunkDelta) { + return chunkDelta <= 0L ? 0L : chunkDelta * 16L - 15L; + } + + private static long distanceSquaredSaturated(long dx, long dz) { + long xSquared = squareSaturated(dx); + long zSquared = squareSaturated(dz); + if (Long.MAX_VALUE - xSquared < zSquared) { + return Long.MAX_VALUE; + } + return xSquared + zSquared; + } + + private static long squareSaturated(long value) { + long absolute = Math.abs(value); + if (absolute > 3_037_000_499L) { + return Long.MAX_VALUE; + } + return absolute * absolute; + } + + private static LocateResult found(LocatedCandidate candidate) { + ResolvedStart resolved = candidate.resolved(); + return new LocateResult(LocateStatus.FOUND, resolved.originX(), resolved.baseY(), resolved.originZ()); } static long cellRingDistanceLowerBound(int ring, int spacing, int pcx, int pcz, @@ -700,22 +961,46 @@ public final class IrisStructureLocator { List placements = new ArrayList<>(); collect(engine.getDimension().getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases, suppressedVanillaSources, placements, true); - for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { - collect(region.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases, - suppressedVanillaSources, placements, false); + KList regions = engine.getDimension().getAllRegions(engine); + if (regions != null) { + for (IrisRegion region : regions) { + collect(region == null ? null : region.getStructures(), data, + loadKeys, normalizedLoadKeys, vanillaAliases, + suppressedVanillaSources, placements, false); + } } - for (IrisBiome biome : engine.getDimension().getReachableBiomes(engine)) { - collect(biome.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaAliases, - suppressedVanillaSources, placements, false); + KList biomes = engine.getDimension().getReachableBiomes(engine); + if (biomes != null) { + for (IrisBiome biome : biomes) { + collect(biome == null ? null : biome.getStructures(), data, + loadKeys, normalizedLoadKeys, vanillaAliases, + suppressedVanillaSources, placements, false); + } } return new PlacementIndex( Collections.unmodifiableSet(loadKeys), Collections.unmodifiableSet(normalizedLoadKeys), Collections.unmodifiableSet(vanillaAliases), Collections.unmodifiableSet(suppressedVanillaSources), + nativeKeys(placements), List.copyOf(placements)); } + private static Set nativeKeys(List placements) { + Set nativeKeys = new LinkedHashSet<>(); + for (IrisStructurePlacement placement : placements) { + if (!placement.hasNativeStructures()) { + continue; + } + for (IrisNativeStructure source : placement.getNativeStructures()) { + if (source != null) { + nativeKeys.add(normalize(source.getStructure())); + } + } + } + return Collections.unmodifiableSet(nativeKeys); + } + private static void collect(KList source, IrisData data, Set loadKeys, Set normalizedLoadKeys, Set vanillaAliases, Set suppressedVanillaSources, @@ -809,6 +1094,17 @@ public final class IrisStructureLocator { } } + @FunctionalInterface + public interface CandidateFilter { + boolean accept(int chunkX, int chunkZ); + } + + public static final class CandidateSearchLimitException extends RuntimeException { + public CandidateSearchLimitException() { + super(null, null, false, false); + } + } + private record RandomSpreadParameters(int spacing, int separation, int salt) { } @@ -822,20 +1118,34 @@ public final class IrisStructureLocator { private record LocatedCandidate(ResolvedStart resolved, long distanceSquared) { } + private static final class CandidateBudget { + private int checked; + + private void claim() { + if (checked >= CANDIDATE_BUDGET) { + throw new CandidateSearchLimitException(); + } + checked++; + } + } + private static final class PlacementIndex { private final Set loadKeys; private final Set normalizedLoadKeys; private final Set vanillaAliases; private final Set suppressedVanillaSources; + private final Set nativeKeys; private final List placements; private PlacementIndex(Set loadKeys, Set normalizedLoadKeys, Set vanillaAliases, Set suppressedVanillaSources, + Set nativeKeys, List placements) { this.loadKeys = loadKeys; this.normalizedLoadKeys = normalizedLoadKeys; this.vanillaAliases = vanillaAliases; this.suppressedVanillaSources = suppressedVanillaSources; + this.nativeKeys = nativeKeys; this.placements = placements; } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipBundle.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipBundle.java new file mode 100644 index 000000000..cc8a47824 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipBundle.java @@ -0,0 +1,117 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord.OwnershipKey; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record NativeStructureOwnershipBundle(Map records) { + public static final int MAX_RECORDS = 16_384; + public static final int MAX_ENCODED_BYTES = 2 * 1024 * 1024; + private static final Comparator RECORD_ORDER = Comparator + .comparing(NativeStructureOwnershipRecord::structureKey) + .thenComparingInt(NativeStructureOwnershipRecord::originChunkX) + .thenComparingInt(NativeStructureOwnershipRecord::originChunkZ); + + public NativeStructureOwnershipBundle { + records = Map.copyOf(Objects.requireNonNull(records, + "Native structure ownership records must not be null")); + if (records.size() > MAX_RECORDS) { + throw new IllegalArgumentException("Native structure ownership bundle exceeds " + + MAX_RECORDS + " records"); + } + for (Map.Entry entry : records.entrySet()) { + if (!entry.getKey().equals(entry.getValue().ownershipKey())) { + throw new IllegalArgumentException("Native structure ownership key does not match its record"); + } + } + try { + encodePayload(records); + } catch (IOException error) { + throw new IllegalArgumentException("Native structure ownership bundle is not encodable", error); + } + } + + public static NativeStructureOwnershipBundle empty() { + return new NativeStructureOwnershipBundle(Map.of()); + } + + public NativeStructureOwnershipRecord find(String structureKey, int originChunkX, int originChunkZ) { + return records.get(new OwnershipKey(structureKey, originChunkX, originChunkZ)); + } + + public NativeStructureOwnershipBundle with(NativeStructureOwnershipRecord record) { + NativeStructureOwnershipRecord resolved = Objects.requireNonNull( + record, "Native structure ownership record must not be null"); + Map updated = new LinkedHashMap<>(records); + updated.put(resolved.ownershipKey(), resolved); + return new NativeStructureOwnershipBundle(updated); + } + + public NativeStructureOwnershipBundle without(String structureKey, int originChunkX, int originChunkZ) { + Map updated = new LinkedHashMap<>(records); + updated.remove(new OwnershipKey(structureKey, originChunkX, originChunkZ)); + return new NativeStructureOwnershipBundle(updated); + } + + public void write(DataOutputStream output) throws IOException { + byte[] encoded = encodePayload(records); + output.writeInt(encoded.length); + output.write(encoded); + } + + public static NativeStructureOwnershipBundle read(DataInputStream input) throws IOException { + int byteCount = input.readInt(); + if (byteCount < 0 || byteCount > MAX_ENCODED_BYTES) { + throw new IOException("Native structure ownership bundle has invalid length " + byteCount); + } + byte[] encoded = input.readNBytes(byteCount); + if (encoded.length != byteCount) { + throw new IOException("Native structure ownership bundle ended early"); + } + try (DataInputStream payload = new DataInputStream(new ByteArrayInputStream(encoded))) { + int count = payload.readInt(); + if (count < 0 || count > MAX_RECORDS) { + throw new IOException("Native structure ownership bundle has invalid record count " + count); + } + Map records = new LinkedHashMap<>(count); + for (int i = 0; i < count; i++) { + NativeStructureOwnershipRecord record = NativeStructureOwnershipRecord.read(payload); + if (records.put(record.ownershipKey(), record) != null) { + throw new IOException("Native structure ownership bundle contains a duplicate record"); + } + } + if (payload.available() != 0) { + throw new IOException("Native structure ownership bundle contains trailing data"); + } + return new NativeStructureOwnershipBundle(records); + } + } + + private static byte[] encodePayload( + Map records) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream payload = new DataOutputStream(bytes)) { + payload.writeInt(records.size()); + List ordered = new ArrayList<>(records.values()); + ordered.sort(RECORD_ORDER); + for (NativeStructureOwnershipRecord record : ordered) { + record.write(payload); + } + } + if (bytes.size() > MAX_ENCODED_BYTES) { + throw new IOException("Native structure ownership bundle exceeds " + + MAX_ENCODED_BYTES + " bytes"); + } + return bytes.toByteArray(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecord.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecord.java new file mode 100644 index 000000000..aff6e63f3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecord.java @@ -0,0 +1,285 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisStructureStiltSettings; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import com.google.gson.Gson; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Objects; + +public record NativeStructureOwnershipRecord( + int schema, + String structureKey, + int originChunkX, + int originChunkZ, + long placementIdentity, + int baseY, + int contentMinX, + int contentMinY, + int contentMinZ, + int contentMaxX, + int contentMaxY, + int contentMaxZ, + int locatorY, + int referenceMinChunkX, + int referenceMaxChunkX, + int referenceMinChunkZ, + int referenceMaxChunkZ, + String contentFingerprint, + DecisionSnapshot decision +) { + public static final int CURRENT_SCHEMA = 1; + public static final int MAX_REFERENCE_DISTANCE_CHUNKS = 8; + private static final int MAX_KEY_BYTES = 512; + private static final int MAX_FINGERPRINT_BYTES = 128; + + public NativeStructureOwnershipRecord { + if (schema != CURRENT_SCHEMA) { + throw new IllegalArgumentException("Unsupported native structure ownership schema " + schema); + } + structureKey = normalizeKey(structureKey); + requireUtf8Limit(structureKey, MAX_KEY_BYTES, "structure key"); + if (contentMinX > contentMaxX || contentMinY > contentMaxY || contentMinZ > contentMaxZ) { + throw new IllegalArgumentException("Native structure content bounds are inverted"); + } + if ((long) originChunkX - referenceMinChunkX > MAX_REFERENCE_DISTANCE_CHUNKS + || (long) referenceMaxChunkX - originChunkX > MAX_REFERENCE_DISTANCE_CHUNKS + || (long) originChunkZ - referenceMinChunkZ > MAX_REFERENCE_DISTANCE_CHUNKS + || (long) referenceMaxChunkZ - originChunkZ > MAX_REFERENCE_DISTANCE_CHUNKS) { + throw new IllegalArgumentException("Native structure ownership exceeds Minecraft's reference range"); + } + long referenceMinBlockX = (long) referenceMinChunkX << 4; + long referenceMaxBlockX = ((long) referenceMaxChunkX << 4) + 15L; + long referenceMinBlockZ = (long) referenceMinChunkZ << 4; + long referenceMaxBlockZ = ((long) referenceMaxChunkZ << 4) + 15L; + if (contentMinX < referenceMinBlockX || contentMaxX > referenceMaxBlockX + || contentMinZ < referenceMinBlockZ || contentMaxZ > referenceMaxBlockZ) { + throw new IllegalArgumentException( + "Native structure content bounds exceed their reference envelope"); + } + contentFingerprint = Objects.requireNonNull(contentFingerprint, + "Native structure content fingerprint must not be null").toLowerCase(Locale.ROOT); + requireUtf8Limit(contentFingerprint, MAX_FINGERPRINT_BYTES, "content fingerprint"); + if (!contentFingerprint.matches("[0-9a-f]{64}")) { + throw new IllegalArgumentException("Native structure content fingerprint must be SHA-256 hex"); + } + decision = Objects.requireNonNull(decision, "Native structure decision snapshot must not be null"); + } + + public static NativeStructureOwnershipRecord create(String structureKey, + int originChunkX, + int originChunkZ, + long placementIdentity, + int baseY, + int contentMinX, + int contentMinY, + int contentMinZ, + int contentMaxX, + int contentMaxY, + int contentMaxZ, + int locatorY, + int referenceMinChunkX, + int referenceMaxChunkX, + int referenceMinChunkZ, + int referenceMaxChunkZ, + String contentFingerprint, + IrisNativeStructureDecision decision) { + return new NativeStructureOwnershipRecord( + CURRENT_SCHEMA, + structureKey, + originChunkX, + originChunkZ, + placementIdentity, + baseY, + contentMinX, + contentMinY, + contentMinZ, + contentMaxX, + contentMaxY, + contentMaxZ, + locatorY, + referenceMinChunkX, + referenceMaxChunkX, + referenceMinChunkZ, + referenceMaxChunkZ, + contentFingerprint, + DecisionSnapshot.capture(decision) + ); + } + + public boolean covers(int chunkX, int chunkZ) { + return chunkX >= referenceMinChunkX && chunkX <= referenceMaxChunkX + && chunkZ >= referenceMinChunkZ && chunkZ <= referenceMaxChunkZ; + } + + public OwnershipKey ownershipKey() { + return new OwnershipKey(structureKey, originChunkX, originChunkZ); + } + + public IrisNativeStructureDecision restoredDecision() { + return decision.restore(); + } + + void write(DataOutputStream output) throws IOException { + output.writeInt(schema); + writeString(output, structureKey, MAX_KEY_BYTES, "structure key"); + output.writeInt(originChunkX); + output.writeInt(originChunkZ); + output.writeLong(placementIdentity); + output.writeInt(baseY); + output.writeInt(contentMinX); + output.writeInt(contentMinY); + output.writeInt(contentMinZ); + output.writeInt(contentMaxX); + output.writeInt(contentMaxY); + output.writeInt(contentMaxZ); + output.writeInt(locatorY); + output.writeInt(referenceMinChunkX); + output.writeInt(referenceMaxChunkX); + output.writeInt(referenceMinChunkZ); + output.writeInt(referenceMaxChunkZ); + writeString(output, contentFingerprint, MAX_FINGERPRINT_BYTES, "content fingerprint"); + decision.write(output); + } + + static NativeStructureOwnershipRecord read(DataInputStream input) throws IOException { + int schema = input.readInt(); + if (schema != CURRENT_SCHEMA) { + throw new IOException("Unsupported native structure ownership schema " + schema); + } + try { + return new NativeStructureOwnershipRecord( + schema, + readString(input, MAX_KEY_BYTES, "structure key"), + input.readInt(), + input.readInt(), + input.readLong(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + input.readInt(), + readString(input, MAX_FINGERPRINT_BYTES, "content fingerprint"), + DecisionSnapshot.read(input) + ); + } catch (IllegalArgumentException exception) { + throw new IOException("Native structure ownership record is invalid", exception); + } + } + + private static String normalizeKey(String key) { + String normalized = Objects.requireNonNull(key, + "Native structure key must not be null").trim().toLowerCase(Locale.ROOT); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Native structure key must not be blank"); + } + return normalized; + } + + static void writeString(DataOutputStream output, String value, int maximumBytes, + String fieldName) throws IOException { + byte[] encoded = Objects.requireNonNull(value, fieldName + " must not be null") + .getBytes(StandardCharsets.UTF_8); + if (encoded.length > maximumBytes) { + throw new IOException("Native structure " + fieldName + " exceeds " + maximumBytes + " bytes"); + } + output.writeInt(encoded.length); + output.write(encoded); + } + + static String readString(DataInputStream input, int maximumBytes, String fieldName) throws IOException { + int length = input.readInt(); + if (length < 0 || length > maximumBytes) { + throw new IOException("Native structure " + fieldName + " has invalid length " + length); + } + byte[] encoded = input.readNBytes(length); + if (encoded.length != length) { + throw new IOException("Native structure " + fieldName + " ended early"); + } + return new String(encoded, StandardCharsets.UTF_8); + } + + private static void requireUtf8Limit(String value, int maximumBytes, String fieldName) { + if (value.getBytes(StandardCharsets.UTF_8).length > maximumBytes) { + throw new IllegalArgumentException("Native structure " + fieldName + + " exceeds " + maximumBytes + " bytes"); + } + } + + public record OwnershipKey(String structureKey, int originChunkX, int originChunkZ) { + public OwnershipKey { + structureKey = normalizeKey(structureKey); + } + } + + public record DecisionSnapshot(boolean clearVegetation, String stiltJson, String terrainJson) { + private static final Gson GSON = new Gson(); + private static final int MAX_JSON_BYTES = 65_536; + + public DecisionSnapshot { + stiltJson = Objects.requireNonNull(stiltJson, "Native structure stilt snapshot must not be null"); + terrainJson = Objects.requireNonNull(terrainJson, "Native structure terrain snapshot must not be null"); + requireUtf8Limit(stiltJson, MAX_JSON_BYTES, "stilt snapshot"); + requireUtf8Limit(terrainJson, MAX_JSON_BYTES, "terrain snapshot"); + } + + public static DecisionSnapshot capture(IrisNativeStructureDecision decision) { + IrisNativeStructureDecision resolved = Objects.requireNonNull( + decision, "Native structure decision must not be null"); + if (!resolved.generate()) { + throw new IllegalArgumentException("Only generated native structure decisions can be persisted"); + } + return new DecisionSnapshot( + resolved.clearVegetation(), + GSON.toJson(resolved.stilt()), + GSON.toJson(Objects.requireNonNullElseGet( + resolved.terrain(), IrisStructureTerrain::new)) + ); + } + + public IrisNativeStructureDecision restore() { + IrisStructureStiltSettings stilt = "null".equals(stiltJson) + ? null : GSON.fromJson(stiltJson, IrisStructureStiltSettings.class); + IrisStructureTerrain terrain = GSON.fromJson(terrainJson, IrisStructureTerrain.class); + if (terrain == null) { + throw new IllegalStateException("Persisted native structure terrain snapshot is null"); + } + return new IrisNativeStructureDecision( + NativeStructureGenerationStatus.GENERATE_NATIVE, + 0, + null, + false, + clearVegetation, + stilt, + terrain + ); + } + + void write(DataOutputStream output) throws IOException { + output.writeBoolean(clearVegetation); + writeString(output, stiltJson, MAX_JSON_BYTES, "stilt snapshot"); + writeString(output, terrainJson, MAX_JSON_BYTES, "terrain snapshot"); + } + + static DecisionSnapshot read(DataInputStream input) throws IOException { + return new DecisionSnapshot( + input.readBoolean(), + readString(input, MAX_JSON_BYTES, "stilt snapshot"), + readString(input, MAX_JSON_BYTES, "terrain snapshot") + ); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStore.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStore.java new file mode 100644 index 000000000..8f0290787 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStore.java @@ -0,0 +1,365 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public final class NativeStructureOwnershipStore { + private static final int MAX_CACHED_AUTHORITIES = 65_536; + private static final Cache STATES = Caffeine.newBuilder().weakKeys().build(); + + private NativeStructureOwnershipStore() { + } + + public static void record(Engine engine, NativeStructureOwnershipRecord record) { + state(engine).record(record); + } + + public static NativeStructureOwnershipRecord find(Engine engine, int targetChunkX, int targetChunkZ, + String structureKey, int originChunkX, int originChunkZ) { + return state(engine).find(targetChunkX, targetChunkZ, + structureKey, originChunkX, originChunkZ); + } + + public static NativeStructureOwnershipRecord findPersisted( + Engine engine, String structureKey, int originChunkX, int originChunkZ) { + return state(engine).findPersisted(structureKey, originChunkX, originChunkZ); + } + + public static void discard(Engine engine, String structureKey, int originChunkX, int originChunkZ) { + state(engine).discard(structureKey, originChunkX, originChunkZ); + } + + public static void flush(Engine engine) { + State state = STATES.getIfPresent(Objects.requireNonNull(engine, + "Native structure ownership store requires an engine")); + if (state != null) { + state.flush(); + } + } + + public static void close(Engine engine) { + Engine resolved = Objects.requireNonNull(engine, + "Native structure ownership store requires an engine"); + State state = STATES.get(resolved, State::new); + state.close(); + } + + private static State state(Engine engine) { + Engine resolved = Objects.requireNonNull(engine, + "Native structure ownership store requires an engine"); + return STATES.get(resolved, State::new); + } + + static final class State { + private final WeakReference engine; + private final Storage storage; + private final Cache authorities; + private final ReentrantReadWriteLock lifecycleLock; + private volatile boolean dirty; + private volatile boolean closed; + + private State(Engine engine) { + this(engine, new MantleStorage(engine)); + } + + State(Engine engine, Storage storage) { + this.engine = new WeakReference<>(Objects.requireNonNull(engine, + "Native structure ownership store requires an engine")); + this.storage = Objects.requireNonNull(storage, + "Native structure ownership storage must not be null"); + this.authorities = Caffeine.newBuilder().maximumSize(MAX_CACHED_AUTHORITIES).build(); + this.lifecycleLock = new ReentrantReadWriteLock(true); + } + + void record(NativeStructureOwnershipRecord record) { + lifecycleLock.readLock().lock(); + try { + requireOpen(); + NativeStructureOwnershipRecord resolved = Objects.requireNonNull( + record, "Native structure ownership record must not be null"); + authorities.asMap().compute(resolved.ownershipKey(), (ignored, current) -> { + storage.write(pack(resolved.originChunkX(), resolved.originChunkZ()), resolved); + dirty = true; + return Authority.present(resolved); + }); + } finally { + lifecycleLock.readLock().unlock(); + } + } + + NativeStructureOwnershipRecord find(int targetChunkX, int targetChunkZ, + String structureKey, int originChunkX, int originChunkZ) { + lifecycleLock.readLock().lock(); + try { + requireOpen(); + NativeStructureOwnershipRecord.OwnershipKey key = + new NativeStructureOwnershipRecord.OwnershipKey( + structureKey, originChunkX, originChunkZ); + Authority authority = authorities.asMap().computeIfAbsent( + key, this::loadAuthority); + NativeStructureOwnershipRecord record = authority.record(); + return record != null && record.covers(targetChunkX, targetChunkZ) + ? record : null; + } finally { + lifecycleLock.readLock().unlock(); + } + } + + NativeStructureOwnershipRecord findPersisted( + String structureKey, int originChunkX, int originChunkZ) { + lifecycleLock.readLock().lock(); + try { + requireOpen(); + NativeStructureOwnershipRecord.OwnershipKey key = + new NativeStructureOwnershipRecord.OwnershipKey( + structureKey, originChunkX, originChunkZ); + return authorities.asMap().computeIfAbsent( + key, this::loadAuthority).record(); + } finally { + lifecycleLock.readLock().unlock(); + } + } + + void discard(String structureKey, int originChunkX, int originChunkZ) { + lifecycleLock.readLock().lock(); + try { + requireOpen(); + NativeStructureOwnershipRecord.OwnershipKey key = + new NativeStructureOwnershipRecord.OwnershipKey( + structureKey, originChunkX, originChunkZ); + authorities.asMap().compute(key, (ignored, current) -> { + storage.remove(pack(originChunkX, originChunkZ), + structureKey, originChunkX, originChunkZ); + dirty = true; + return Authority.absent(); + }); + } finally { + lifecycleLock.readLock().unlock(); + } + } + + void flush() { + lifecycleLock.writeLock().lock(); + try { + if (closed) { + throw new IllegalStateException("Native structure ownership store is closed"); + } + flushDirty(); + } finally { + lifecycleLock.writeLock().unlock(); + } + } + + void close() { + lifecycleLock.writeLock().lock(); + try { + if (closed) { + return; + } + flushDirty(); + closed = true; + authorities.invalidateAll(); + } finally { + lifecycleLock.writeLock().unlock(); + } + } + + private void flushDirty() { + if (!dirty) { + return; + } + storage.flush(); + dirty = false; + } + + private Authority loadAuthority( + NativeStructureOwnershipRecord.OwnershipKey key) { + NativeStructureOwnershipBundle origin = storage.read( + key.originChunkX(), key.originChunkZ()); + NativeStructureOwnershipRecord record = origin == null ? null + : origin.find(key.structureKey(), key.originChunkX(), key.originChunkZ()); + return record == null ? Authority.absent() : Authority.present(record); + } + + private void requireOpen() { + engine(); + if (closed) { + throw new IllegalStateException("Native structure ownership store is closed"); + } + } + + private Engine engine() { + Engine active = engine.get(); + if (active == null) { + throw new IllegalStateException("Native structure ownership engine is unavailable"); + } + return active; + } + } + + interface Storage { + NativeStructureOwnershipBundle read(int chunkX, int chunkZ); + + void write(long target, NativeStructureOwnershipRecord record); + + void remove(long target, String structureKey, int originChunkX, int originChunkZ); + + void flush(); + } + + private static final class MantleStorage implements Storage { + private final WeakReference engine; + private final Map pendingBundles; + + private MantleStorage(Engine engine) { + this.engine = new WeakReference<>(Objects.requireNonNull(engine, + "Native structure ownership store requires an engine")); + this.pendingBundles = new ConcurrentHashMap<>(); + } + + @Override + public NativeStructureOwnershipBundle read(int chunkX, int chunkZ) { + MantleChunk chunk = mantle().getChunk(chunkX, chunkZ).use(); + try { + synchronized (chunk) { + return chunk.get(0, 0, 0, NativeStructureOwnershipBundle.class); + } + } finally { + chunk.release(); + } + } + + @Override + public void write(long target, NativeStructureOwnershipRecord record) { + int targetChunkX = unpackX(target); + int targetChunkZ = unpackZ(target); + MantleChunk chunk = mantle().getChunk(targetChunkX, targetChunkZ).use(); + try { + synchronized (chunk) { + Matter section = chunk.getOrCreate(0); + MatterSlice slice = + section.slice(NativeStructureOwnershipBundle.class); + NativeStructureOwnershipBundle bundle = slice.get(0, 0, 0); + NativeStructureOwnershipBundle updated = Objects.requireNonNullElseGet( + bundle, NativeStructureOwnershipBundle::empty).with(record); + slice.set(0, 0, 0, updated); + pendingBundles.put(target, updated); + } + } finally { + chunk.release(); + } + } + + @Override + public void remove(long target, String structureKey, + int originChunkX, int originChunkZ) { + int targetChunkX = unpackX(target); + int targetChunkZ = unpackZ(target); + MantleChunk chunk = mantle().getChunk(targetChunkX, targetChunkZ).use(); + try { + synchronized (chunk) { + Matter section = chunk.getOrCreate(0); + MatterSlice slice = + section.getSlice(NativeStructureOwnershipBundle.class); + if (slice == null) { + return; + } + NativeStructureOwnershipBundle bundle = slice.get(0, 0, 0); + if (bundle == null) { + return; + } + NativeStructureOwnershipBundle updated = bundle.without( + structureKey, originChunkX, originChunkZ); + if (updated.equals(bundle)) { + return; + } + slice.set(0, 0, 0, updated.records().isEmpty() ? null : updated); + section.trimSlices(); + pendingBundles.put(target, updated); + } + } finally { + chunk.release(); + } + } + + @Override + public void flush() { + Map pending = new TreeMap<>(pendingBundles); + if (pending.isEmpty()) { + return; + } + + Mantle mantle = mantle(); + Set regions = new TreeSet<>(); + for (Map.Entry entry : pending.entrySet()) { + long target = entry.getKey(); + restorePendingBundle(mantle, target, entry.getValue()); + regions.add(Mantle.key(unpackX(target) >> 5, unpackZ(target) >> 5)); + } + mantle.saveTectonicPlates(regions); + for (Map.Entry entry : pending.entrySet()) { + pendingBundles.remove(entry.getKey(), entry.getValue()); + } + } + + private void restorePendingBundle(Mantle mantle, long target, + NativeStructureOwnershipBundle bundle) { + MantleChunk chunk = mantle.getChunk(unpackX(target), unpackZ(target)).use(); + try { + synchronized (chunk) { + Matter section = chunk.getOrCreate(0); + MatterSlice slice = + section.slice(NativeStructureOwnershipBundle.class); + slice.set(0, 0, 0, bundle.records().isEmpty() ? null : bundle); + section.trimSlices(); + } + } finally { + chunk.release(); + } + } + + private Mantle mantle() { + Engine active = engine.get(); + if (active == null) { + throw new IllegalStateException("Native structure ownership engine is unavailable"); + } + return active.getMantle().getMantle(); + } + } + + private record Authority(NativeStructureOwnershipRecord record) { + private static Authority present(NativeStructureOwnershipRecord record) { + return new Authority(Objects.requireNonNull(record, + "Native structure ownership authority must not be null")); + } + + private static Authority absent() { + return new Authority(null); + } + } + + static long pack(int chunkX, int chunkZ) { + return (chunkX & 0xffffffffL) | ((chunkZ & 0xffffffffL) << 32); + } + + static int unpackX(long packed) { + return (int) packed; + } + + static int unpackZ(long packed) { + return (int) (packed >>> 32); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java index ad7bd992e..6df472f24 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java @@ -7,6 +7,9 @@ import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; import java.util.Objects; public final class NativeStructurePlacementPlanner { @@ -16,14 +19,18 @@ public final class NativeStructurePlacementPlanner { } public static KList plansAt(Engine engine, int chunkX, int chunkZ) { - KList plans = new KList<>(); + Map plansByStructure = new LinkedHashMap<>(); for (IrisStructurePlacement placement : StructurePlacementScope.placementsAt(engine, chunkX, chunkZ)) { NativeStructureStartPlan plan = planAt(engine, placement, chunkX, chunkZ); if (plan != null) { - plans.add(plan); + String structureKey = normalize(plan.source().getStructure()); + NativeStructureStartPlan current = plansByStructure.get(structureKey); + if (current == null || comparePlacementPriority(plan.placement(), current.placement()) < 0) { + plansByStructure.put(structureKey, plan); + } } } - return plans; + return new KList<>(plansByStructure.values()); } public static NativeStructureStartPlan planAt(Engine engine, IrisStructurePlacement placement, @@ -55,8 +62,9 @@ public final class NativeStructurePlacementPlanner { if (structureKey == null || structureKey.isBlank()) { return null; } + String normalizedKey = normalize(structureKey); for (NativeStructureStartPlan plan : plansAt(engine, chunkX, chunkZ)) { - if (structureKey.equals(plan.source().getStructure())) { + if (normalizedKey.equals(normalize(plan.source().getStructure()))) { return plan; } } @@ -123,9 +131,13 @@ public final class NativeStructurePlacementPlanner { if (worldMin > worldMax) { throw new IllegalStateException("Native structure placement has invalid world height bounds"); } + int blockX = (chunkX << 4) + 8; + int blockZ = (chunkZ << 4) + 8; + if (!placement.isUnderwater() + && isSubmerged(engine, blockX, blockZ)) { + return null; + } if (!placement.isUnderground()) { - int blockX = (chunkX << 4) + 8; - int blockZ = (chunkZ << 4) + 8; int surfaceY = engine.getHeight(blockX, blockZ, true) + engine.getMinHeight(); if (surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight()) { return null; @@ -135,8 +147,23 @@ public final class NativeStructurePlacementPlanner { int bandMin = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight())); int bandMax = Math.min(worldMax, Math.max(placement.getMinHeight(), placement.getMaxHeight())); if (bandMin > bandMax) { - throw new IllegalStateException("Native structure underground band does not intersect world bounds"); + return null; } return bandMin == bandMax ? bandMin : bandMin + rng.nextInt((bandMax - bandMin) + 1); } + + static boolean isSubmerged(Engine engine, int blockX, int blockZ) { + return engine.getHeight(blockX, blockZ, true) < engine.getDimension().getFluidHeight(); + } + + private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) { + long leftIdentity = StructurePlacementGrid.placementIdentity(left); + long rightIdentity = StructurePlacementGrid.placementIdentity(right); + return Long.compareUnsigned(leftIdentity, rightIdentity); + } + + private static String normalize(String structureKey) { + return structureKey == null ? "" : structureKey.toLowerCase(Locale.ROOT); + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java index 237dc7daf..ff2b374cd 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java @@ -134,7 +134,7 @@ public final class StructurePlacementGrid { return rng.chance(density); } - private static long placementSeed(IrisStructurePlacement placement, int cx, int cz, long seed) { + public static long placementIdentity(IrisStructurePlacement placement) { long signature = 1469598103934665603L; signature = appendLong(signature, placement.getDistribution().ordinal()); signature = appendLong(signature, placement.getSalt()); @@ -156,6 +156,11 @@ public final class StructurePlacementGrid { signature = appendLong(signature, placement.isUnderwater() ? 1L : 0L); signature = appendSources(signature, placement); } + return signature; + } + + private static long placementSeed(IrisStructurePlacement placement, int cx, int cz, long seed) { + long signature = placementIdentity(placement); int identitySalt = (int) (signature ^ (signature >>> 32)); return mix(seed ^ signature, cx, cz, placementSalt(placement) ^ identitySalt); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java index b61ef73b1..8653dc525 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java @@ -6,7 +6,10 @@ import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.volmlib.util.collection.KList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.Objects; +import java.util.Set; public final class StructurePlacementScope { private StructurePlacementScope() { @@ -18,19 +21,29 @@ public final class StructurePlacementScope { int blockX = (chunkX << 4) + 8; int blockZ = (chunkZ << 4) + 8; KList placements = new KList<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); if (complex != null) { IrisBiome biome = complex.getTrueBiomeStream().get(blockX, blockZ); IrisRegion region = complex.getRegionStream().get(blockX, blockZ); - if (biome != null && biome.getStructures() != null) { - placements.addAll(biome.getStructures()); - } - if (region != null && region.getStructures() != null) { - placements.addAll(region.getStructures()); - } + addUnique(placements, seen, biome == null ? null : biome.getStructures()); + addUnique(placements, seen, region == null ? null : region.getStructures()); } - if (activeEngine.getDimension() != null && activeEngine.getDimension().getStructures() != null) { - placements.addAll(activeEngine.getDimension().getStructures()); + if (activeEngine.getDimension() != null) { + addUnique(placements, seen, activeEngine.getDimension().getStructures()); } return placements; } + + private static void addUnique(KList destination, + Set seen, + KList source) { + if (source == null) { + return; + } + for (IrisStructurePlacement placement : source) { + if (placement != null && seen.add(placement)) { + destination.add(placement); + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 8d2ace69f..4717c3e3b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -274,7 +274,7 @@ public class IrisDimension extends IrisRegistrant { @Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation (ores, trees, plants, springs, geodes) for this dimension. Disabled by default: leaving this out generates exactly the terrain Iris always has. Set 'enabled' true to run the vanilla decoration feature pass over Iris terrain. Carvers are never imported.") private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl(); @ArrayType(type = String.class, min = 1) - @Desc("External datapack sources for this dimension. List Modrinth datapack page URLs or direct zip URLs. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.") + @Desc("External datapack sources requested by this dimension. Bukkit installs them into Minecraft's server-wide datapack registry, so native registry contents and conflicts cannot be isolated per dimension. Editable auto-import is limited to resources supplied by the URLs declared by each Iris pack. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.") private KList datapackImports = new KList<>(); @MinNumber(0) @MaxNumber(318) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java index 54ff7aadc..9f822b3c1 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java @@ -22,8 +22,8 @@ public class IrisStructureTerrain { private static final double MAX_EROSION_FREQUENCY = 1D; private static final double MAX_LOBE_FREQUENCY = 1D; - @Desc("Terrain operation. FORCE_CARVE is authoritative: the requested envelope is cleared before any structure piece is placed. ENCASE is its inverse: the envelope is filled with solid blocks before placement so native shells are not lost to pre-carved air.") - private IrisStructureTerrainMode mode = IrisStructureTerrainMode.PRESERVE; + @Desc("Terrain operation. SOURCE applies the registered native structure's authored terrain adaptation and is a no-op for editable Iris structures. PRESERVE disables terrain integration. FORCE_CARVE clears the requested envelope, while ENCASE fills it before placement so native shells are not lost to pre-carved air.") + private IrisStructureTerrainMode mode = IrisStructureTerrainMode.SOURCE; @MinNumber(0) @MaxNumber(128) @@ -43,7 +43,7 @@ public class IrisStructureTerrain { @Desc("Shape used by FORCE_CARVE.") private IrisStructureCarveShape shape = IrisStructureCarveShape.BOX; - @Desc("Block palette used by ENCASE. When unset, stone is filled at Y 0 and above and deepslate below Y 0.") + @Desc("Block palette used by ENCASE. When unset, the Overworld uses stone or deepslate, the Nether uses netherrack, and the End uses end stone.") private IrisMaterialPalette encasePalette = null; @MinNumber(0) @@ -67,7 +67,7 @@ public class IrisStructureTerrain { private double lobeStrength = DEFAULT_LOBE_STRENGTH; public IrisStructureTerrainMode resolvedMode() { - return mode == null ? IrisStructureTerrainMode.PRESERVE : mode; + return mode == null ? IrisStructureTerrainMode.SOURCE : mode; } public IrisStructureCarveShape resolvedShape() { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java index 26f8f5d95..791346032 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java @@ -8,8 +8,6 @@ public enum IrisStructureTerrainMode { PRESERVE, BORE, FORCE_CARVE, - SURFACE_FIT, - REQUIRE_SUPPORT, VACUUM, @Desc("Fills the padded piece volume with solid blocks before any piece is placed so shells, walls, and floors land in solid ground instead of pre-carved air. Only air and liquid cells are filled; existing terrain and structures are never overwritten. Native pieces then carve their own interiors.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java index 6730ed680..3edbbd450 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java @@ -57,7 +57,7 @@ public class IrisVanillaStructureAdjustment { @Desc("Optional foundation columns placed beneath the native structure piece bases after placement.") private IrisStructureStiltSettings stilt = null; - @Desc("Optional terrain integration override. FORCE_CARVE clears every intersecting chunk before native pieces are placed, ENCASE fills it with solid blocks instead. Left unset, structures whose vanilla terrainAdaptation is BURY or ENCAPSULATE default to ENCASE with 3-block paddings; setting this field disables that default.") + @Desc("Optional terrain integration override. FORCE_CARVE clears every intersecting chunk before native pieces are placed, ENCASE fills it with solid blocks instead. Left unset, SOURCE replays the registered structure's authored terrain adaptation, including surface fitting, burial, and encapsulation.") private IrisStructureTerrain terrain = null; public boolean matches(String key) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/annotations/functions/LootTableKeyFunction.java b/core/src/main/java/art/arcane/iris/engine/object/annotations/functions/LootTableKeyFunction.java index 138f13c4b..37107e219 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/annotations/functions/LootTableKeyFunction.java +++ b/core/src/main/java/art/arcane/iris/engine/object/annotations/functions/LootTableKeyFunction.java @@ -2,13 +2,8 @@ package art.arcane.iris.engine.object.annotations.functions; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.ListFunction; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.collection.KList; -import org.bukkit.NamespacedKey; -import org.bukkit.loot.LootTable; -import org.bukkit.loot.LootTables; - -import java.util.Arrays; -import java.util.stream.Collectors; public class LootTableKeyFunction implements ListFunction> { @Override @@ -23,10 +18,6 @@ public class LootTableKeyFunction implements ListFunction> { @Override public KList apply(IrisData data) { - return Arrays.stream(LootTables.values()) - .map(LootTables::getLootTable) - .map(LootTable::getKey) - .map(NamespacedKey::toString) - .collect(Collectors.toCollection(KList::new)); + return new KList<>(IrisPlatforms.get().registries().lootTableKeys()); } } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java index 68ab291e8..b2e5875d3 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitPlatform.java @@ -19,6 +19,7 @@ package art.arcane.iris.platform.bukkit; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.MinecraftVersion; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.LogLevel; @@ -40,6 +41,7 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.Registry; +import org.bukkit.Server; import org.bukkit.World; import org.bukkit.block.Biome; import org.bukkit.block.data.BlockData; @@ -263,7 +265,12 @@ public final class BukkitPlatform implements IrisPlatform { @Override public String minecraftVersion() { - return Bukkit.getBukkitVersion(); + return minecraftVersion(Bukkit.getServer()); + } + + static String minecraftVersion(Server server) { + MinecraftVersion detected = MinecraftVersion.detect(server); + return detected == null ? null : detected.value(); } @Override diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java index b265a10e2..f12a978be 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java @@ -41,6 +41,7 @@ import org.bukkit.block.Biome; import org.bukkit.block.data.BlockData; import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.EntityType; +import org.bukkit.loot.LootTables; import org.bukkit.potion.PotionEffectType; import java.util.ArrayList; @@ -206,6 +207,15 @@ public final class BukkitRegistries implements PlatformRegistries { return keys; } + @Override + public List lootTableKeys() { + List keys = new ArrayList<>(); + for (LootTables table : LootTables.values()) { + keys.add(table.getKey().toString()); + } + return keys; + } + @Override public Map> blockStateProperties() { Map> properties = new LinkedHashMap<>(); diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitStructureHooks.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitStructureHooks.java index 82a91e566..e8f255c88 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitStructureHooks.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitStructureHooks.java @@ -45,6 +45,21 @@ public final class BukkitStructureHooks implements PlatformStructureHooks { return new ArrayList<>(INMS.get().getTemplatePoolKeys()); } + @Override + public JigsawSourceMetadata jigsawSourceMetadata(String structureKey) { + return INMS.get().getJigsawSourceMetadata(structureKey); + } + + @Override + public int templatePoolHorizontalSpan(String templatePoolKey) { + return INMS.get().getTemplatePoolHorizontalSpan(templatePoolKey); + } + + @Override + public int jigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) { + return INMS.get().getJigsawStartPoolHorizontalSpan(structureKey, templatePoolKey); + } + @Override public List structureSetKeys() { return new ArrayList<>(INMS.get().getStructureSetKeys()); diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java index 613f7b551..afde6beb2 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectHandler.java @@ -20,6 +20,7 @@ package art.arcane.iris.util.common.director.specialhandlers; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.director.DirectorParameterHandler; import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; @@ -36,12 +37,10 @@ public class ObjectHandler implements DirectorParameterHandler { return new KList<>(data.getObjectLoader().getPossibleKeys()); } - //noinspection ConstantConditions - for (File i : IrisPlatforms.get().dataFolder("packs").listFiles()) { - if (i.isDirectory()) { - data = IrisData.get(i); - p.add(data.getObjectLoader().getPossibleKeys()); - } + for (File i : PackDirectoryResolver.listVisiblePackDirectories( + IrisPlatforms.get().dataFolder("packs"))) { + data = IrisData.get(i); + p.add(data.getObjectLoader().getPossibleKeys()); } return p; diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java index 7f9a847c4..a220e97d4 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ObjectTargetHandler.java @@ -20,6 +20,7 @@ package art.arcane.iris.util.common.director.specialhandlers; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.director.DirectorParameterHandler; import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; @@ -42,15 +43,11 @@ public class ObjectTargetHandler implements DirectorParameterHandler { } } else { File packsFolder = IrisPlatforms.get().dataFolder("packs"); - File[] packs = packsFolder.listFiles(); - if (packs != null) { - for (File pack : packs) { - if (!pack.isDirectory()) continue; - IrisData d = IrisData.get(pack); - for (String k : d.getObjectLoader().getPossibleKeys()) { - out.add(k); - collectPrefixes(k, prefixes); - } + for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) { + IrisData d = IrisData.get(pack); + for (String k : d.getObjectLoader().getPossibleKeys()) { + out.add(k); + collectPrefixes(k, prefixes); } } } diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java index 72420bbff..52ea862c4 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/RegistrantHandler.java @@ -3,6 +3,7 @@ package art.arcane.iris.util.common.director.specialhandlers; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.director.DirectorParameterHandler; import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; @@ -34,14 +35,12 @@ public abstract class RegistrantHandler implements Dir } } - //noinspection ConstantConditions - for (File i : IrisPlatforms.get().dataFolder("packs").listFiles()) { - if (i.isDirectory()) { - data = IrisData.get(i); - for (T j : data.getLoader(type).loadAll(data.getLoader(type).getPossibleKeys())) { - if (known.add(j.getLoadKey())) - p.add(j); - } + for (File i : PackDirectoryResolver.listVisiblePackDirectories( + IrisPlatforms.get().dataFolder("packs"))) { + data = IrisData.get(i); + for (T j : data.getLoader(type).loadAll(data.getLoader(type).getPossibleKeys())) { + if (known.add(j.getLoadKey())) + p.add(j); } } diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java b/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java index f0e9ecff6..fb3e2efbe 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java @@ -31,7 +31,10 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.time.Duration; @@ -82,40 +85,71 @@ public final class WebCache { } public static File getNonCachedFile(String name, String url) { + return getNonCachedFile(name, url, Long.MAX_VALUE); + } + + public static File getNonCachedFile(String name, String url, long maxBytes) { String h = IO.hash(name + "*" + url); File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h); IrisLogging.debug("Download " + name + " -> " + url); - download(name, url, f); - return f; + return download(name, url, f, maxBytes) ? f : null; } private static boolean download(String name, String url, File target) { + return download(name, url, target, Long.MAX_VALUE); + } + + private static boolean download(String name, String url, File target, long maxBytes) { + if (maxBytes < 1L) { + throw new IllegalArgumentException("Download size limit must be positive."); + } HttpRequest request = HttpRequest.newBuilder(URI.create(url)) .timeout(REQUEST_TIMEOUT) .GET() .build(); + Path staged = null; try { HttpResponse response = client() .send(request, HttpResponse.BodyHandlers.ofInputStream()); if (response.statusCode() / 100 != 2) { - try (InputStream discard = response.body()) { - discard.readAllBytes(); - } + response.body().close(); IrisLogging.reportError(new IOException("HTTP " + response.statusCode() + " downloading " + name + " from " + url)); return false; } + long declaredBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L); + if (declaredBytes > maxBytes) { + response.body().close(); + throw new IOException("Download exceeds the size limit for " + name + "."); + } + Path destination = target.toPath().toAbsolutePath().normalize(); + Path parent = destination.getParent(); + if (parent == null) { + response.body().close(); + throw new IOException("Download target has no parent: " + destination); + } + Files.createDirectories(parent); + staged = Files.createTempFile(parent, ".download-", ".tmp"); try (InputStream in = response.body(); - OutputStream out = Files.newOutputStream(target.toPath(), - StandardOpenOption.CREATE, StandardOpenOption.WRITE, - StandardOpenOption.TRUNCATE_EXISTING)) { + OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) { byte[] buffer = new byte[BUFFER_SIZE]; + long downloadedBytes = 0L; int read; while ((read = in.read(buffer)) != -1) { + if (read > maxBytes - downloadedBytes) { + throw new IOException("Download exceeds the size limit for " + name + "."); + } out.write(buffer, 0, read); + downloadedBytes += read; } out.flush(); } + try { + Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(staged, destination, StandardCopyOption.REPLACE_EXISTING); + } + staged = null; return true; } catch (IOException e) { IrisLogging.reportError(e); @@ -124,6 +158,14 @@ public final class WebCache { Thread.currentThread().interrupt(); IrisLogging.reportError(e); return false; + } finally { + if (staged != null) { + try { + Files.deleteIfExists(staged); + } catch (IOException cleanupFailure) { + IrisLogging.reportError("Failed to clean incomplete download " + staged + ".", cleanupFailure); + } + } } } diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java b/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java index 776dc18ac..8d50fffaa 100644 --- a/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java +++ b/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java @@ -23,6 +23,7 @@ import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.project.matter.slices.EntityMatter; import art.arcane.iris.util.project.matter.slices.IdentifierMatter; +import art.arcane.iris.util.project.matter.slices.NativeStructureOwnershipMatter; import art.arcane.iris.util.project.matter.slices.PlatformBlockMatter; import art.arcane.iris.util.project.matter.slices.SpawnerMatter; import art.arcane.iris.util.project.matter.slices.TileMatter; @@ -61,6 +62,7 @@ public final class IrisMatterSupport { IrisMatter.registerSliceType(new EntityMatter()); IrisMatter.registerSliceType(new IdentifierMatter()); + IrisMatter.registerSliceType(new NativeStructureOwnershipMatter()); IrisMatter.registerSliceType(new PlatformBlockMatter()); IrisMatter.registerSliceType(new SpawnerMatter()); IrisMatter.registerSliceType(new TileMatter()); diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/slices/NativeStructureOwnershipMatter.java b/core/src/main/java/art/arcane/iris/util/project/matter/slices/NativeStructureOwnershipMatter.java new file mode 100644 index 000000000..7a7f3c830 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/project/matter/slices/NativeStructureOwnershipMatter.java @@ -0,0 +1,36 @@ +package art.arcane.iris.util.project.matter.slices; + +import art.arcane.iris.engine.framework.NativeStructureOwnershipBundle; +import art.arcane.volmlib.util.data.palette.Palette; +import art.arcane.volmlib.util.matter.Sliced; +import art.arcane.volmlib.util.matter.slices.RawMatter; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +@Sliced +public final class NativeStructureOwnershipMatter extends RawMatter { + public NativeStructureOwnershipMatter() { + this(1, 1, 1); + } + + public NativeStructureOwnershipMatter(int width, int height, int depth) { + super(width, height, depth, NativeStructureOwnershipBundle.class); + } + + @Override + public Palette getGlobalPalette() { + return null; + } + + @Override + public void writeNode(NativeStructureOwnershipBundle bundle, DataOutputStream output) throws IOException { + bundle.write(output); + } + + @Override + public NativeStructureOwnershipBundle readNode(DataInputStream input) throws IOException { + return NativeStructureOwnershipBundle.read(input); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/DatapackInstallResultTest.java b/core/src/test/java/art/arcane/iris/core/DatapackInstallResultTest.java new file mode 100644 index 000000000..c8099974d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/DatapackInstallResultTest.java @@ -0,0 +1,44 @@ +package art.arcane.iris.core; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class DatapackInstallResultTest { + @Test + public void failedResultIsNotSuccessfulOrChanged() { + DatapackInstallResult result = DatapackInstallResult.failedResult(); + + assertFalse(result.succeeded()); + assertFalse(result.changed()); + assertFalse(result.restartRequired()); + } + + @Test + public void unchangedResultIsSuccessfulWithoutChange() { + DatapackInstallResult result = DatapackInstallResult.unchangedResult(); + + assertTrue(result.succeeded()); + assertFalse(result.changed()); + assertFalse(result.restartRequired()); + } + + @Test + public void readyResultIsSuccessfulAndChanged() { + DatapackInstallResult result = DatapackInstallResult.readyResult(); + + assertTrue(result.succeeded()); + assertTrue(result.changed()); + assertFalse(result.restartRequired()); + } + + @Test + public void restartResultIsSuccessfulChangedAndPendingRestart() { + DatapackInstallResult result = DatapackInstallResult.restartRequiredResult(); + + assertTrue(result.succeeded()); + assertTrue(result.changed()); + assertTrue(result.restartRequired()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java index ec207dabe..23339e590 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java @@ -14,6 +14,7 @@ import java.nio.file.Path; import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class IrisDatapackCompilerTest { @@ -26,10 +27,15 @@ public class IrisDatapackCompilerTest { Path serverRoot = temporaryFolder.newFolder("server").toPath(); createPack(dataDirectory.resolve("packs/alpha"), "alpha", "alpha_custom"); createPack(dataDirectory.resolve("packs/beta"), "beta", "beta_custom"); + createPack(dataDirectory.resolve("packs/.iris-import-stale"), "hidden", "hidden_custom"); createPack(serverRoot.resolve("dimensions/example/world/iris/pack"), "world_local", "world_custom"); + createPack(serverRoot.resolve("dimensions/example/.iris-delete-stale/iris/pack"), "deleted", "deleted_custom"); List packRoots = IrisDatapackCompiler.collectPackRoots(dataDirectory, serverRoot); Path datapackRoot = temporaryFolder.newFolder("datapack").toPath(); + Path stale = datapackRoot.resolve("data/iris/dimension_type/removed.json"); + Files.createDirectories(stale.getParent()); + Files.writeString(stale, "stale", StandardCharsets.UTF_8); IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile( packRoots, new KList().qadd(datapackRoot.toFile()), @@ -48,6 +54,9 @@ public class IrisDatapackCompilerTest { assertTrue(Files.isRegularFile(datapackRoot.resolve("data/alpha/worldgen/biome/alpha_custom.json"))); assertTrue(Files.isRegularFile(datapackRoot.resolve("data/beta/worldgen/biome/beta_custom.json"))); assertTrue(Files.isRegularFile(datapackRoot.resolve("data/world_local/worldgen/biome/world_custom.json"))); + assertFalse(Files.exists(datapackRoot.resolve("data/iris/dimension_type/hidden.json"))); + assertFalse(Files.exists(datapackRoot.resolve("data/iris/dimension_type/deleted.json"))); + assertFalse(Files.exists(stale)); } @Test @@ -72,6 +81,28 @@ public class IrisDatapackCompilerTest { assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta"))); } + @Test + public void compilingNoPacksPublishesCleanEmptyDatapack() throws Exception { + Path datapackRoot = temporaryFolder.newFolder("empty-datapack").toPath(); + Path stale = datapackRoot.resolve("data/iris/dimension_type/removed.json"); + Files.createDirectories(stale.getParent()); + Files.writeString(stale, "stale", StandardCharsets.UTF_8); + + IrisDatapackCompiler.CompilationResult result = IrisDatapackCompiler.compile( + List.of(), + new KList().qadd(datapackRoot.toFile()), + new DataFixerV1217(), + 107, + false + ); + + assertEquals(0, result.packCount()); + assertEquals(0, result.dimensionCount()); + assertEquals(0, result.biomeCount()); + assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta"))); + assertFalse(Files.exists(stale)); + } + private static void createPack(Path root, String dimensionKey, String biomeId) throws Exception { Files.createDirectories(root.resolve("dimensions")); Files.createDirectories(root.resolve("biomes")); diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java index 8ae8eda8d..8d3295324 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java @@ -8,6 +8,7 @@ import org.junit.rules.TemporaryFolder; import java.io.File; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.util.Properties; import static org.junit.Assert.assertEquals; @@ -44,6 +45,32 @@ public class IrisWorldStorageTest { assertEquals(new NamespacedKey("iris", "iris_world"), IrisWorldStorage.keyFromName("Iris World", "world")); } + @Test + public void managedKeyRejectsMainWorldsAndUnsafePaths() { + assertEquals(new NamespacedKey("iris", "iris_world"), + IrisWorldStorage.managedKeyFromName("Iris World", "world")); + assertEquals(new NamespacedKey("iris", "iris_world"), + IrisWorldStorage.managedKeyFromName("iris:iris_world", "world")); + assertThrows(IllegalArgumentException.class, + () -> IrisWorldStorage.managedKeyFromName("world", "world")); + assertThrows(IllegalArgumentException.class, + () -> IrisWorldStorage.managedKeyFromName("minecraft:overworld", "world")); + assertThrows(IllegalArgumentException.class, + () -> IrisWorldStorage.managedKeyFromName("../world", "world")); + assertThrows(IllegalArgumentException.class, + () -> IrisWorldStorage.managedKeyFromName("iris:nested/world", "world")); + assertThrows(IllegalArgumentException.class, + () -> IrisWorldStorage.managedKeyFromName("iris:unsafe.world", "world")); + } + + @Test + public void explicitManagedIdentityDoesNotRequireServerLevelLookup() { + NamespacedKey worldKey = new NamespacedKey("iris", "probe"); + + assertEquals(worldKey, IrisWorldStorage.managedKeyFromName(worldKey.toString())); + assertEquals("probe", IrisWorldStorage.logicalName(worldKey)); + } + @Test public void mapsOwnedPaperKeysBackToLogicalWorldNames() { assertEquals("world", IrisWorldStorage.logicalName(NamespacedKey.minecraft("overworld"), "world")); @@ -90,4 +117,16 @@ public class IrisWorldStorageTest { assertThrows(IllegalArgumentException.class, () -> IrisWorldStorage.dimensionRoot(levelRoot, key)); } + + @Test + public void safeManagedDimensionRootRejectsSymlinkedStorage() throws Exception { + File levelRoot = temporaryFolder.newFolder("managed-world"); + Path dimensions = Files.createDirectories(levelRoot.toPath().resolve("dimensions")); + Path outside = temporaryFolder.newFolder("outside").toPath(); + Files.createSymbolicLink(dimensions.resolve("iris"), outside); + + assertThrows(IllegalArgumentException.class, () -> IrisWorldStorage.requireSafeManagedDimensionRoot( + levelRoot, + new NamespacedKey("iris", "probe"))); + } } diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java index 8714d4ea6..d6d2b6577 100644 --- a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java @@ -2,14 +2,22 @@ package art.arcane.iris.core; import org.junit.Rule; import org.junit.Test; +import org.junit.Assume; import org.junit.rules.TemporaryFolder; import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class ServerConfiguratorDatapackFingerprintTest { @@ -17,12 +25,7 @@ public class ServerConfiguratorDatapackFingerprintTest { public TemporaryFolder tmp = new TemporaryFolder(); private Method fingerprintMethod() throws Exception { - try { - return ServerConfigurator.class.getMethod("computePackFingerprint", File.class); - } catch (NoSuchMethodException e) { - fail("ServerConfigurator.computePackFingerprint(File) does not exist yet — implement it in Task 2"); - throw e; - } + return ServerConfigurator.class.getMethod("computePackFingerprint", File.class); } @Test @@ -41,7 +44,7 @@ public class ServerConfiguratorDatapackFingerprintTest { } @Test - public void computePackFingerprintChangesWhenFileIsModified() throws Exception { + public void computePackFingerprintIgnoresMetadataOnlyChanges() throws Exception { Method method = fingerprintMethod(); File packsDir = tmp.newFolder("packs"); File dimFile = new File(packsDir, "testpack/dimensions/overworld.json"); @@ -52,7 +55,23 @@ public class ServerConfiguratorDatapackFingerprintTest { dimFile.setLastModified(dimFile.lastModified() + 2000L); String fp2 = (String) method.invoke(null, packsDir); - assertNotEquals("A modified file must produce a different fingerprint", fp1, fp2); + assertEquals("Metadata-only changes must not alter a content fingerprint", fp1, fp2); + } + + @Test + public void computePackFingerprintDetectsEqualSizeContentWithRestoredMtime() throws Exception { + File packsDir = tmp.newFolder("content-packs"); + Path dimension = packsDir.toPath().resolve("testpack/dimensions/overworld.json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, "aaaa", StandardCharsets.UTF_8); + FileTime originalMtime = Files.getLastModifiedTime(dimension); + String before = ServerConfigurator.computePackFingerprint(packsDir); + + Files.writeString(dimension, "bbbb", StandardCharsets.UTF_8); + Files.setLastModifiedTime(dimension, originalMtime); + String after = ServerConfigurator.computePackFingerprint(packsDir); + + assertNotEquals("Equal-size content changes must alter the fingerprint", before, after); } @Test @@ -71,4 +90,74 @@ public class ServerConfiguratorDatapackFingerprintTest { assertNotEquals("Adding a file must produce a different fingerprint", fp1, fp2); } + + @Test + public void computePackFingerprintExcludesHiddenTransactionStages() throws Exception { + File packsDir = tmp.newFolder("hidden-packs"); + Path visible = packsDir.toPath().resolve("testpack/dimensions/overworld.json"); + Path hidden = packsDir.toPath().resolve(".iris-import-123/dimensions/overworld.json"); + Files.createDirectories(visible.getParent()); + Files.createDirectories(hidden.getParent()); + Files.writeString(visible, "visible", StandardCharsets.UTF_8); + Files.writeString(hidden, "stage-one", StandardCharsets.UTF_8); + String before = ServerConfigurator.computePackFingerprint(packsDir); + + Files.writeString(hidden, "stage-two", StandardCharsets.UTF_8); + + assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir)); + } + + @Test + public void computePackFingerprintRejectsSymbolicLinks() throws Exception { + File packsDir = tmp.newFolder("unsafe-packs"); + Path pack = packsDir.toPath().resolve("testpack"); + Path outside = tmp.newFile("outside.json").toPath(); + Files.createDirectories(pack); + Path link = pack.resolve("linked.json"); + try { + Files.createSymbolicLink(link, outside); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + try { + ServerConfigurator.computePackFingerprint(packsDir); + fail("Symbolic links must be rejected"); + } catch (UncheckedIOException expected) { + assertTrue(expected.getMessage().contains("fingerprint")); + } + } + + @Test + public void computePackFingerprintReadsSafeSymbolicPackRoots() throws Exception { + File packsDir = tmp.newFolder("linked-root-packs"); + Path externalPack = tmp.newFolder("linked-pack").toPath(); + Path dimension = externalPack.resolve("dimensions/overworld.json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, "first", StandardCharsets.UTF_8); + Path link = packsDir.toPath().resolve("overworld"); + try { + Files.createSymbolicLink(link, externalPack); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + String before = ServerConfigurator.computePackFingerprint(packsDir); + + Files.writeString(dimension, "other", StandardCharsets.UTF_8); + + assertNotEquals(before, ServerConfigurator.computePackFingerprint(packsDir)); + } + + @Test + public void incompleteExternalDatapackRecoveryBlocksCompilation() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/ServerConfigurator.java")); + int recovery = source.indexOf("if (!DatapackIngestService.reapplyFromStaging(datapacksFolders))"); + int blocked = source.indexOf("return DatapackInstallResult.failedResult();", recovery); + int compile = source.indexOf("IrisDatapackCompiler.compile(", recovery); + + assertTrue(recovery >= 0); + assertTrue(blocked > recovery); + assertTrue(compile > blocked); + } } diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java index 2725e0b6d..d72123f41 100644 --- a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java @@ -1,47 +1,2748 @@ package art.arcane.iris.core.datapack; +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureRecoveryResult; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.io.IO; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.sun.net.httpserver.HttpServer; +import org.bukkit.Server; +import org.junit.Rule; import org.junit.Test; +import org.junit.Assume; +import org.junit.rules.TemporaryFolder; +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.channels.ServerSocketChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class DatapackIngestServiceTest { + private interface PaperLikeServer extends Server { + String getMinecraftVersion(); + } + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test - public void manifestRemainsPendingWhenAnyPackFails() { - DatapackIngestService.Entry first = new DatapackIngestService.Entry(); - DatapackIngestService.Entry second = new DatapackIngestService.Entry(); + public void serverVersionUsesTheRuntimeMinecraftReleaseInsteadOfTheLeafBuildToken() { + PaperLikeServer server = mock(PaperLikeServer.class); + doReturn("26.2").when(server).getMinecraftVersion(); + doReturn("26.2.build.33").when(server).getBukkitVersion(); - boolean completed = DatapackIngestService.markStructuresImportedIfComplete( - List.of(first, second), 2, 1); - - assertFalse(completed); - assertFalse(first.structuresImported); - assertFalse(second.structuresImported); + assertEquals("26.2", DatapackIngestService.serverMcVersion(server)); } @Test - public void manifestRemainsPendingWhenNoPackWasAttempted() { + public void serverVersionPreservesLegacyThreePartMinecraftReleases() { + Server server = mock(Server.class); + doReturn("1.21.4-R0.1-SNAPSHOT").when(server).getBukkitVersion(); + + assertEquals("1.21.4", DatapackIngestService.serverMcVersion(server)); + } + + @Test + public void packMetadataMustContainAValidPackContract() throws Exception { + File valid = temporaryFolder.newFolder("valid"); + Files.writeString(new File(valid, "pack.mcmeta").toPath(), """ + {"pack":{"description":"test","pack_format":88}} + """, StandardCharsets.UTF_8); + + DatapackIngestService.validatePackMetadata(valid); + + File invalid = temporaryFolder.newFolder("invalid"); + Files.writeString(new File(invalid, "pack.mcmeta").toPath(), "{}", StandardCharsets.UTF_8); + try { + DatapackIngestService.validatePackMetadata(invalid); + fail("Expected invalid metadata to be rejected"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("pack object")); + } + } + + @Test + public void downloadFollowsRelativeRedirects() throws Exception { + byte[] archive = "archive".getBytes(StandardCharsets.UTF_8); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/start", exchange -> { + exchange.getResponseHeaders().add("Location", "files/pack.zip"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + server.createContext("/files/pack.zip", exchange -> { + exchange.sendResponseHeaders(200, archive.length); + exchange.getResponseBody().write(archive); + exchange.close(); + }); + server.start(); + try { + File destination = new File(temporaryFolder.newFolder("download"), "pack.zip"); + DatapackIngestService.DownloadResult result = DatapackIngestService.download( + "http://127.0.0.1:" + server.getAddress().getPort() + "/start", + destination, + null, + null + ); + + assertFalse(result.notModified()); + assertEquals("archive", Files.readString(destination.toPath(), StandardCharsets.UTF_8)); + } finally { + server.stop(0); + } + } + + @Test + public void downloadUsesConditionalValidators() throws Exception { + AtomicBoolean conditional = new AtomicBoolean(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/pack.zip", exchange -> { + conditional.set("\"v1\"".equals(exchange.getRequestHeaders().getFirst("If-None-Match")) + && "Wed, 21 Oct 2015 07:28:00 GMT".equals(exchange.getRequestHeaders().getFirst("If-Modified-Since"))); + exchange.sendResponseHeaders(304, -1); + exchange.close(); + }); + server.start(); + try { + File destination = new File(temporaryFolder.newFolder("conditional"), "pack.zip"); + DatapackIngestService.DownloadResult result = DatapackIngestService.download( + "http://127.0.0.1:" + server.getAddress().getPort() + "/pack.zip", + destination, + "\"v1\"", + "Wed, 21 Oct 2015 07:28:00 GMT" + ); + + assertTrue(result.notModified()); + assertTrue(conditional.get()); + assertFalse(destination.exists()); + } finally { + server.stop(0); + } + } + + @Test + public void removalRequiresMatchingIrisOwnership() throws Exception { + File unmanaged = datapackDirectory("unmanaged"); + try { + DatapackIngestService.deleteOwnedDirectory(unmanaged, "unmanaged"); + fail("Expected unmanaged datapack removal to be rejected"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("ownership marker")); + } + assertTrue(unmanaged.isDirectory()); + + File managed = datapackDirectory("managed"); DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "managed"; + entry.url = "https://example.test/managed.zip"; + entry.versionId = "version"; + entry.versionNumber = "1"; + entry.sha1 = "hash"; + DatapackIngestService.writeOwnership(managed, entry); - boolean completed = DatapackIngestService.markStructuresImportedIfComplete( - List.of(entry), 0, 0); + assertTrue(DatapackIngestService.deleteOwnedDirectory(managed, "managed")); + assertFalse(managed.exists()); + } - assertFalse(completed); + @Test + public void removalRefusesSymbolicLinkTargets() throws Exception { + File outside = datapackDirectory("outside"); + File link = new File(temporaryFolder.getRoot(), "linked-pack"); + Files.createSymbolicLink(link.toPath(), outside.toPath()); + + try { + DatapackIngestService.deleteOwnedDirectory(link, "outside"); + fail("Expected symbolic-link removal to be rejected"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("symbolic-link")); + } + assertTrue(new File(outside, "pack.mcmeta").isFile()); + } + + @Test + public void removalRefusesModifiedManagedDirectories() throws Exception { + File managed = datapackDirectory("modified-managed"); + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "modified-managed"; + entry.url = "https://example.test/modified.zip"; + DatapackIngestService.writeOwnership(managed, entry); + Files.writeString(new File(managed, "user-edit.txt").toPath(), "preserve", StandardCharsets.UTF_8); + + try { + DatapackIngestService.deleteOwnedDirectory(managed, entry.id); + fail("Expected modified managed datapack removal to be rejected"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("modified or corrupt")); + } + assertEquals("preserve", Files.readString(new File(managed, "user-edit.txt").toPath())); + } + + @Test + public void malformedRemovalIdCannotAliasARealManagedId() throws Exception { + File root = temporaryFolder.newFolder("removal-invalid-id-root"); + Files.writeString(new File(root, "manifest.json").toPath(), """ + {"entries":[{"url":"https://example.test/pack.zip","id":"datapack"}]} + """, StandardCharsets.UTF_8); + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "datapack"; + entry.url = "https://example.test/pack.zip"; + File staging = new File(new File(root, "staging"), entry.id); + writeManagedDatapack(staging, entry); + + assertFalse(DatapackIngestService.removeLocked(null, "!!!", root, List.of())); + assertTrue(staging.isDirectory()); + assertTrue(Files.readString(new File(root, "manifest.json").toPath()).contains("datapack")); + } + + @Test + public void malformedManifestIdCannotAliasARealManagedDirectory() throws Exception { + File root = temporaryFolder.newFolder("malformed-manifest-id-root"); + Files.writeString(new File(root, "manifest.json").toPath(), """ + {"entries":[{"url":"https://example.test/managed.zip","id":"../managed"}]} + """, StandardCharsets.UTF_8); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + File staging = new File(new File(root, "staging"), entry.id); + writeManagedDatapack(staging, entry); + + assertFalse(DatapackIngestService.removeLocked(null, entry.id, root, List.of())); + + assertTrue(staging.isDirectory()); + } + + @Test + public void ownershipMarkersRejectUnsafeManagedIds() throws Exception { + File managed = datapackDirectory("unsafe-ownership-id"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + entry.id = "../managed"; + + try { + DatapackIngestService.writeOwnership(managed, entry); + fail("Expected unsafe ownership id to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("ownership identity")); + } + assertFalse(new File(managed, ".iris-managed.json").exists()); + } + + @Test + public void removalRefusesSymbolicLinkDatapacksContainer() throws Exception { + File root = temporaryFolder.newFolder("removal-link-container-root"); + Files.writeString(new File(root, "manifest.json").toPath(), """ + {"entries":[{"url":"https://example.test/managed.zip","id":"managed"}]} + """, StandardCharsets.UTF_8); + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "managed"; + entry.url = "https://example.test/managed.zip"; + File outside = temporaryFolder.newFolder("removal-link-container-outside"); + File outsideTarget = new File(outside, entry.id); + writeManagedDatapack(outsideTarget, entry); + File linkedContainer = new File(temporaryFolder.getRoot(), "linked-datapacks-container"); + Files.createSymbolicLink(linkedContainer.toPath(), outside.toPath()); + + assertFalse(DatapackIngestService.removeLocked(null, entry.id, root, List.of(linkedContainer))); + assertTrue(outsideTarget.isDirectory()); + } + + @Test + public void removalPreflightFailurePreservesEditableImportInventoryAndManifest() throws Exception { + File root = temporaryFolder.newFolder("removal-preflight-root"); + File manifest = new File(root, "manifest.json"); + String manifestJson = """ + {"entries":[{"url":"https://example.test/managed.zip","id":"managed", + "importedTargets":{"/missing/editable-pack":"revision"}, + "importedBundles":{"/missing/editable-pack":{"iris:owned":"example:owned"}}}]} + """; + Files.writeString(manifest.toPath(), manifestJson, StandardCharsets.UTF_8); + + File worldDatapacks = temporaryFolder.newFolder("removal-preflight-world"); + File unmanagedTarget = new File(worldDatapacks, "managed"); + assertTrue(unmanagedTarget.mkdirs()); + Files.writeString(new File(unmanagedTarget, "pack.mcmeta").toPath(), """ + {"pack":{"description":"test","pack_format":88}} + """, StandardCharsets.UTF_8); + KList worlds = new KList<>(); + worlds.add(worldDatapacks); + + assertFalse(DatapackIngestService.removeLocked(null, "managed", root, worlds)); + assertEquals(manifestJson, Files.readString(manifest.toPath(), StandardCharsets.UTF_8)); + assertTrue(unmanagedTarget.isDirectory()); + } + + @Test + public void laterDirectoryMoveFailureRestoresEveryEditableBundleAndLeavesManifestUntouched() throws Exception { + File root = temporaryFolder.newFolder("removal-transaction-root"); + File editablePack = temporaryFolder.newFolder("removal-transaction-editable"); + StructureKey alphaKey = StructureKey.parse("iris:alpha"); + StructureKey zetaKey = StructureKey.parse("iris:zeta"); + StructureKey alphaSource = StructureKey.parse("example:alpha"); + StructureKey zetaSource = StructureKey.parse("example:zeta"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + StructureWriteResult alphaWrite = writer.write( + importedBundle(alphaKey, alphaSource, "alpha"), + StructureWriteMode.ADD_ONLY + ); + StructureWriteResult zetaWrite = writer.write( + importedBundle(zetaKey, zetaSource, "zeta"), + StructureWriteMode.ADD_ONLY + ); + assertEquals(StructureWriteResult.Status.ADDED, alphaWrite.status()); + assertEquals(StructureWriteResult.Status.ADDED, zetaWrite.status()); + + String editablePath = editablePack.getAbsolutePath().replace("\\", "\\\\").replace("\"", "\\\""); + String manifestJson = """ + {"entries":[{"url":"https://example.test/managed.zip","id":"managed", + "importedTargets":{"%s":"revision"}, + "importedBundles":{"%s":{"iris:alpha":"example:alpha","iris:zeta":"example:zeta"}}}]} + """.formatted(editablePath, editablePath); + File manifest = new File(root, "manifest.json"); + Files.writeString(manifest.toPath(), manifestJson, StandardCharsets.UTF_8); + + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "managed"; + entry.url = "https://example.test/managed.zip"; + File stagingTarget = new File(new File(root, "staging"), entry.id); + writeManagedDatapack(stagingTarget, entry); + File worldDatapacks = temporaryFolder.newFolder("removal-transaction-world"); + File worldTarget = new File(worldDatapacks, entry.id); + writeManagedDatapack(worldTarget, entry); + + File blockedBackupRoot = new File(temporaryFolder.getRoot(), ".iris-datapack-remove"); + Files.writeString(blockedBackupRoot.toPath(), "block-directory-creation", StandardCharsets.UTF_8); + KList worlds = new KList<>(); + worlds.add(worldDatapacks); + + assertFalse(DatapackIngestService.removeLocked(null, entry.id, root, worlds)); + assertEquals(manifestJson, Files.readString(manifest.toPath(), StandardCharsets.UTF_8)); + assertEquals("alpha", Files.readString(new File(editablePack, "objects/alpha.iob").toPath())); + assertEquals("zeta", Files.readString(new File(editablePack, "objects/zeta.iob").toPath())); + assertTrue(Files.exists(writer.ownershipManifestPath(alphaKey))); + assertTrue(Files.exists(writer.ownershipManifestPath(zetaKey))); + assertTrue(stagingTarget.isDirectory()); + assertTrue(worldTarget.isDirectory()); + } + + @Test + public void successfulRemovalCommitsEditableBundlesDirectoriesAndManifestTogether() throws Exception { + File root = temporaryFolder.newFolder("removal-commit-root"); + File editablePack = temporaryFolder.newFolder("removal-commit-editable"); + StructureKey targetKey = StructureKey.parse("iris:owned"); + StructureKey sourceKey = StructureKey.parse("example:owned"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + StructureWriteResult write = writer.write( + importedBundle(targetKey, sourceKey, "owned"), + StructureWriteMode.ADD_ONLY + ); + assertEquals(StructureWriteResult.Status.ADDED, write.status()); + + String editablePath = editablePack.getAbsolutePath().replace("\\", "\\\\").replace("\"", "\\\""); + File manifest = new File(root, "manifest.json"); + Files.writeString(manifest.toPath(), """ + {"entries":[{"url":"https://example.test/managed.zip","id":"managed", + "importedTargets":{"%s":"revision"}, + "importedBundles":{"%s":{"iris:owned":"example:owned"}}}]} + """.formatted(editablePath, editablePath), StandardCharsets.UTF_8); + + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "managed"; + entry.url = "https://example.test/managed.zip"; + File stagingTarget = new File(new File(root, "staging"), entry.id); + writeManagedDatapack(stagingTarget, entry); + File worldDatapacks = temporaryFolder.newFolder("removal-commit-world"); + File worldTarget = new File(worldDatapacks, entry.id); + writeManagedDatapack(worldTarget, entry); + KList worlds = new KList<>(); + worlds.add(worldDatapacks); + + assertTrue(DatapackIngestService.removeLocked(null, entry.id, root, worlds)); + assertFalse(Files.exists(new File(editablePack, "objects/owned.iob").toPath())); + assertFalse(Files.exists(writer.ownershipManifestPath(targetKey))); + assertFalse(stagingTarget.exists()); + assertFalse(worldTarget.exists()); + assertFalse(Files.readString(manifest.toPath(), StandardCharsets.UTF_8).contains("managed")); + } + + @Test + public void manifestEntryDoesNotAdoptUnmarkedStaging() throws Exception { + File staging = datapackDirectory("legacy-staging"); + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "legacy-staging"; + entry.url = "https://example.test/legacy.zip"; + + assertFalse(DatapackIngestService.isUsableStaging(staging, entry)); + assertFalse(new File(staging, ".iris-managed.json").exists()); + } + + @Test + public void committedEmptyManifestDoesNotAdoptAnUncommittedStagingCandidate() throws Exception { + File root = temporaryFolder.newFolder("uncommitted-staging-root"); + writeManifest(root, null); + DatapackIngestService.Entry candidate = entry("candidate", "v1", "1", "sha"); + File staging = new File(new File(root, "staging"), candidate.id); + writeManagedDatapack(staging, candidate); + + assertFalse(DatapackIngestService.removeLocked(null, candidate.id, root, List.of())); + + assertTrue(staging.isDirectory()); + assertFalse(Files.readString(new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8) + .contains(candidate.id)); + } + + @Test + public void quarantinedCorruptManifestCanRecoverVerifiedManagedStaging() throws Exception { + File root = temporaryFolder.newFolder("corrupt-manifest-recovery-root"); + Files.writeString(new File(root, "manifest.json").toPath(), "{broken", StandardCharsets.UTF_8); + DatapackIngestService.Entry candidate = entry("candidate", "v1", "1", "sha"); + File staging = new File(new File(root, "staging"), candidate.id); + writeManagedDatapack(staging, candidate); + + assertTrue(DatapackIngestService.removeLocked(null, candidate.id, root, List.of())); + + assertFalse(staging.exists()); + assertFalse(Files.readString(new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8) + .contains(candidate.id)); + } + + @Test + public void stagingOwnershipHashDetectsPostInstallMutation() throws Exception { + File staging = datapackDirectory("mutated-staging"); + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "mutated-staging"; + entry.url = "https://example.test/mutated.zip"; + entry.structureKeys = List.of("original:structure"); + entry.templateKeys = List.of("original:template"); + DatapackIngestService.writeOwnership(staging, entry); + Files.writeString(new File(staging, "unexpected.txt").toPath(), "changed", StandardCharsets.UTF_8); + + assertFalse(DatapackIngestService.isUsableStaging(staging, entry)); + assertEquals(List.of("original:structure"), entry.structureKeys); + assertEquals(List.of("original:template"), entry.templateKeys); + } + + @Test + public void nestedOwnershipNamedResourceRemainsInsideTheManagedHash() throws Exception { + File staging = datapackDirectory("nested-ownership-resource"); + File nestedMarker = new File( + staging, "data/test/worldgen/structure/.iris-managed.json"); + assertTrue(nestedMarker.getParentFile().mkdirs()); + Files.writeString(nestedMarker.toPath(), "original", StandardCharsets.UTF_8); + DatapackIngestService.Entry entry = entry("nested-ownership-resource", "v1", "1", "sha"); + DatapackIngestService.writeOwnership(staging, entry); + + assertTrue(DatapackIngestService.isUsableStaging(staging, entry)); + Files.writeString(nestedMarker.toPath(), "changed", StandardCharsets.UTF_8); + + assertFalse(DatapackIngestService.isUsableStaging(staging, entry)); + try { + DatapackIngestService.deleteOwnedDirectory(staging, entry.id); + fail("Expected nested managed resource mutation to block deletion"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("modified or corrupt")); + } + assertEquals("changed", Files.readString(nestedMarker.toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void ownershipHashFramesFileBoundaries() throws Exception { + File compact = datapackDirectory("hash-boundary-compact"); + File expanded = datapackDirectory("hash-boundary-expanded"); + Files.write(new File(compact, "a").toPath(), new byte[]{'x', (byte) 0xff, 'b', 0, 'y'}); + Files.write(new File(expanded, "a").toPath(), new byte[]{'x'}); + Files.write(new File(expanded, "b").toPath(), new byte[]{'y'}); + DatapackIngestService.Entry compactEntry = entry("compact", "v1", "1", "sha"); + DatapackIngestService.Entry expandedEntry = entry("expanded", "v1", "1", "sha"); + + DatapackIngestService.writeOwnership(compact, compactEntry); + DatapackIngestService.writeOwnership(expanded, expandedEntry); + + assertFalse(ownershipHash(compact).equals(ownershipHash(expanded))); + } + + @Test + public void ownershipHashIncludesEmptyDirectories() throws Exception { + File managed = datapackDirectory("hash-empty-directory"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + DatapackIngestService.writeOwnership(managed, entry); + assertTrue(new File(managed, "user-directory").mkdir()); + + try { + DatapackIngestService.deleteOwnedDirectory(managed, entry.id); + fail("Expected an added empty directory to invalidate ownership"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("modified or corrupt")); + } + assertTrue(managed.isDirectory()); + } + + @Test + public void failedUpdateStagingCannotBeAdoptedByTheCommittedManifest() throws Exception { + File staging = datapackDirectory("candidate-staging"); + DatapackIngestService.Entry candidate = new DatapackIngestService.Entry(); + candidate.id = "candidate-staging"; + candidate.url = "https://example.test/candidate.zip"; + candidate.versionId = "v2"; + candidate.versionNumber = "2"; + candidate.sha1 = "new"; + candidate.structureKeys = List.of("candidate:new"); + DatapackIngestService.writeOwnership(staging, candidate); + + DatapackIngestService.Entry committed = new DatapackIngestService.Entry(); + committed.id = candidate.id; + committed.url = candidate.url; + committed.versionId = "v1"; + committed.versionNumber = "1"; + committed.sha1 = "old"; + committed.structureKeys = new ArrayList<>(List.of("committed:old")); + + assertFalse(DatapackIngestService.isUsableStaging(staging, committed)); + assertEquals(List.of("committed:old"), committed.structureKeys); + assertTrue(Files.readString(new File(staging, ".iris-managed.json").toPath()).contains("v2")); + } + + @Test + public void installRefusesStagingChangedAfterOwnershipWasWritten() throws Exception { + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "changed-source"; + entry.url = "https://example.test/changed.zip"; + File staging = datapackDirectory("changed-source"); + DatapackIngestService.writeOwnership(staging, entry); + Files.writeString(new File(staging, "late-change.txt").toPath(), "changed", StandardCharsets.UTF_8); + KList worlds = new KList<>(); + worlds.add(temporaryFolder.newFolder("changed-source-world")); + + try { + DatapackIngestService.install(staging, worlds, entry, false); + fail("Expected modified staging to be rejected"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("committed manifest entry")); + } + assertFalse(new File(worlds.get(0), entry.id).exists()); + } + + @Test + public void overrideStrippingFailsWhenAForbiddenTreeRemains() throws Exception { + File datapack = datapackDirectory("failed-override-strip"); + File override = new File(datapack, "data/minecraft/worldgen/structure/test.json"); + assertTrue(override.getParentFile().mkdirs()); + Files.writeString(override.toPath(), "{}", StandardCharsets.UTF_8); + + try { + DatapackIngestService.stripVanillaStructureOverrides(datapack, ignored -> { + }); + fail("Expected a retained vanilla structure override to block installation"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("vanilla structure override tree")); + } + + assertTrue(override.isFile()); + assertFalse(new File(datapack, ".iris-overrides-stripped").exists()); + } + + @Test + public void multiWorldInstallPreflightsEveryTargetBeforePublishing() throws Exception { + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = "managed"; + entry.url = "https://example.test/managed.zip"; + + File staging = datapackDirectory("source"); + Files.writeString(new File(staging, "value.txt").toPath(), "new", StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(staging, entry); + + File firstWorld = temporaryFolder.newFolder("first-world-datapacks"); + File firstTarget = new File(firstWorld, entry.id); + assertTrue(firstTarget.mkdirs()); + Files.copy(new File(staging, "pack.mcmeta").toPath(), new File(firstTarget, "pack.mcmeta").toPath()); + Files.writeString(new File(firstTarget, "value.txt").toPath(), "old", StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(firstTarget, entry); + + File secondWorld = temporaryFolder.newFolder("second-world-datapacks"); + File unmanagedTarget = new File(secondWorld, entry.id); + assertTrue(unmanagedTarget.mkdirs()); + Files.copy(new File(staging, "pack.mcmeta").toPath(), new File(unmanagedTarget, "pack.mcmeta").toPath()); + Files.writeString(new File(unmanagedTarget, "value.txt").toPath(), "unmanaged", StandardCharsets.UTF_8); + + KList worlds = new KList<>(); + worlds.add(firstWorld); + worlds.add(secondWorld); + try { + DatapackIngestService.install(staging, worlds, entry, false); + fail("Expected unmanaged second target to abort the transaction"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("unmanaged datapack")); + } + + assertEquals("old", Files.readString(new File(firstTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void exactLegacyUnmarkedWorldInstallCanReceiveManagedOwnership() throws Exception { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha"); + File staging = datapackDirectory("legacy-ownership-source"); + Files.writeString(new File(staging, "value.txt").toPath(), "same", StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(staging, entry); + File world = temporaryFolder.newFolder("legacy-ownership-world"); + File target = new File(world, entry.id); + assertTrue(target.mkdirs()); + Files.copy(new File(staging, "pack.mcmeta").toPath(), new File(target, "pack.mcmeta").toPath()); + Files.copy(new File(staging, "value.txt").toPath(), new File(target, "value.txt").toPath()); + KList worlds = new KList<>(); + worlds.add(world); + + DatapackIngestService.InstallResult result = + DatapackIngestService.install(staging, worlds, entry, false); + + assertFalse(result.changed()); + assertTrue(new File(target, ".iris-managed.json").isFile()); + assertTrue(DatapackIngestService.isUsableStaging(target, entry)); + } + + @Test + public void modifiedLegacyUnmarkedWorldInstallCannotBeAdopted() throws Exception { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha"); + File staging = datapackDirectory("modified-legacy-source"); + Files.writeString(new File(staging, "value.txt").toPath(), "desired", StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(staging, entry); + File world = temporaryFolder.newFolder("modified-legacy-world"); + File target = new File(world, entry.id); + assertTrue(target.mkdirs()); + Files.copy(new File(staging, "pack.mcmeta").toPath(), new File(target, "pack.mcmeta").toPath()); + Files.writeString(new File(target, "value.txt").toPath(), "modified", StandardCharsets.UTF_8); + KList worlds = new KList<>(); + worlds.add(world); + + try { + DatapackIngestService.install(staging, worlds, entry, false); + fail("Expected modified unmanaged datapack adoption to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("unmanaged datapack")); + } + + assertEquals("modified", Files.readString( + new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(target, ".iris-managed.json").exists()); + } + + @Test + public void verifiedFreshInstallPublishesCanonicalManagedStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("verified-first-install", false, true, false); + + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + DatapackIngestService.publishInstallPlan(plan); + + assertEquals("new", Files.readString( + new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + assertTrue(new File(fixture.target(), ".iris-managed.json").isFile()); + } + + @Test + public void verifiedLegacyStagingUpgradeReplacesDifferingUnmarkedTree() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("verified-legacy-upgrade", true, true, true); + + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + DatapackIngestService.publishInstallPlan(plan); + + assertEquals("new", Files.readString( + new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(fixture.target(), ".DS_Store").exists()); + assertTrue(DatapackIngestService.isUsableStaging(fixture.target(), fixture.desired())); + assertEquals("old", Files.readString( + new File(plan.backup(), "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void caseVariantCanonicalStagingPathIsAcceptedOnCaseInsensitiveFilesystems() throws Exception { + File parent = temporaryFolder.newFolder("case-variant-parent").toPath().toRealPath().toFile(); + File root = new File(parent, "iris-storage"); + assertTrue(root.mkdir()); + File stagingRoot = new File(root, "staging"); + assertTrue(stagingRoot.mkdir()); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + File source = new File(stagingRoot, ".pending-managed-" + UUID.randomUUID()); + writeManagedDatapack(source, entry, "new"); + File caseVariantRoot = new File(parent, "IRIS-STORAGE"); + File caseVariantStagingRoot = new File(caseVariantRoot, "staging"); + File caseVariantSource = new File(caseVariantStagingRoot, source.getName()); + Assume.assumeTrue(Files.exists(caseVariantRoot.toPath())); + Assume.assumeTrue(Files.isSameFile(root.toPath(), caseVariantRoot.toPath())); + + DatapackIngestService.VerifiedStagingInstall authorization = + DatapackIngestService.authorizeVerifiedStagingInstall( + caseVariantRoot, caseVariantStagingRoot, caseVariantSource, entry); + + assertTrue(authorization != null); + } + + @Test + public void symbolicLinkAncestorCannotAuthorizeVerifiedStaging() throws Exception { + File parent = temporaryFolder.newFolder("symbolic-authority-parent").toPath().toRealPath().toFile(); + File container = new File(parent, "container"); + assertTrue(container.mkdir()); + File root = new File(container, "iris-storage"); + assertTrue(root.mkdir()); + File stagingRoot = new File(root, "staging"); + assertTrue(stagingRoot.mkdir()); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + File source = new File(stagingRoot, ".pending-managed-" + UUID.randomUUID()); + writeManagedDatapack(source, entry, "new"); + Path alias = new File(parent, "container-link").toPath(); + try { + Files.createSymbolicLink(alias, container.toPath()); + } catch (IOException | UnsupportedOperationException unavailable) { + Assume.assumeNoException(unavailable); + } + File aliasedRoot = alias.resolve("iris-storage").toFile(); + File aliasedStagingRoot = new File(aliasedRoot, "staging"); + File aliasedSource = new File(aliasedStagingRoot, source.getName()); + + try { + DatapackIngestService.authorizeVerifiedStagingInstall( + aliasedRoot, aliasedStagingRoot, aliasedSource, entry); + fail("Expected symbolic-link staging authority to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("symbolic-link component")); + } + } + + @Test + public void sameVersionLegacyStagingMetadataForcesManagedRestageAndRestart() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture( + "same-version-legacy-restage", true, true, true, true); + + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + DatapackIngestService.publishInstallPlan(plan); + + assertTrue(plan.contentChanged()); + assertTrue(DatapackIngestService.freshInstallRequiresRestart(plan.contentChanged(), true)); + assertEquals("same", Files.readString( + new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(fixture.target(), ".DS_Store").exists()); + assertTrue(DatapackIngestService.isUsableStaging(fixture.target(), fixture.desired())); + } + + @Test + public void uncommittedManifestCannotAuthorizeDifferingLegacyStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("uncommitted-legacy-upgrade", false, true, true); + + assertLegacyStagingPreparationRejected(fixture, "unmanaged datapack"); + assertEquals("old", Files.readString( + new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void mismatchedManifestUrlCannotAuthorizeDifferingLegacyStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("mismatched-legacy-upgrade", true, false, true); + + assertLegacyStagingPreparationRejected(fixture, "authority conflicts"); + assertEquals("old", Files.readString( + new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void malformedOwnershipMarkerCannotMasqueradeAsLegacyStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("malformed-legacy-marker", true, true, true); + Files.writeString(new File(fixture.target(), ".iris-managed.json").toPath(), "{broken", + StandardCharsets.UTF_8); + + assertLegacyStagingPreparationRejected(fixture, "Invalid Iris datapack ownership marker"); + } + + @Test + public void ownershipMarkerDirectoryCannotMasqueradeAsLegacyStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("directory-legacy-marker", true, true, true); + assertTrue(new File(fixture.target(), ".iris-managed.json").mkdir()); + + assertLegacyStagingPreparationRejected(fixture, "Invalid Iris datapack ownership marker"); + } + + @Test + public void ownershipMarkerSymlinkCannotMasqueradeAsLegacyStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("symlink-legacy-marker", true, true, true); + Path markerTarget = new File(fixture.root(), "marker-target.json").toPath(); + Files.writeString(markerTarget, "{}", StandardCharsets.UTF_8); + try { + Files.createSymbolicLink(new File(fixture.target(), ".iris-managed.json").toPath(), markerTarget); + } catch (IOException | UnsupportedOperationException unavailable) { + Assume.assumeNoException(unavailable); + } + + assertLegacyStagingPreparationRejected(fixture, "unsupported file"); + } + + @Test + public void validWrongOwnerMarkerCannotMasqueradeAsLegacyStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("wrong-owner-legacy-marker", true, true, true); + DatapackIngestService.Entry other = entry("other", "v1", "1", "other-sha"); + DatapackIngestService.writeOwnership(fixture.target(), other); + + assertLegacyStagingPreparationRejected(fixture, "ownership mismatch"); + } + + @Test + public void verifiedStagingAuthorityNeverAppliesToAModifiedWorldTarget() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("world-authority-boundary", true, true, true); + File world = new File(fixture.root(), "world-datapacks"); + assertTrue(world.mkdir()); + File worldTarget = new File(world, fixture.desired().id); + writeLegacyDatapack(worldTarget, "world"); + + try { + DatapackIngestService.prepareInstall( + fixture.source(), world, fixture.desired(), fixture.sourceHash(), false, + fixture.authorization()); + fail("Expected verified staging authority to reject a world target"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("unmanaged datapack")); + } + assertEquals("world", Files.readString( + new File(worldTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void exactLegacyStagingCopyInAWorldCanMigrateBeforeCanonicalRestage() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("exact-world-legacy-copy", true, true, true); + File world = temporaryFolder.newFolder("exact-world-legacy-copy-target"); + File worldTarget = new File(world, fixture.desired().id); + IO.copyDirectory(fixture.target().toPath(), worldTarget.toPath()); + + DatapackIngestService.InstallPlan worldPlan = DatapackIngestService.prepareInstall( + fixture.source(), world, fixture.desired(), fixture.sourceHash(), false, + fixture.authorization()); + DatapackIngestService.publishInstallPlan(worldPlan); + DatapackIngestService.InstallPlan stagingPlan = prepareLegacyStagingPlan(fixture); + DatapackIngestService.publishInstallPlan(stagingPlan); + + assertEquals("new", Files.readString( + new File(worldTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertTrue(new File(worldTarget, ".iris-managed.json").isFile()); + assertEquals("new", Files.readString( + new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + assertTrue(new File(fixture.target(), ".iris-managed.json").isFile()); + } + + @Test + public void productionExecutionMigratesTwoExactLegacyWorldCopiesBeforeCanonicalStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("multi-world-legacy-copy", true, true, true); + File firstWorld = temporaryFolder.newFolder("multi-world-legacy-copy-first"); + File secondWorld = temporaryFolder.newFolder("multi-world-legacy-copy-second"); + File firstTarget = new File(firstWorld, fixture.desired().id); + File secondTarget = new File(secondWorld, fixture.desired().id); + IO.copyDirectory(fixture.target().toPath(), firstTarget.toPath()); + IO.copyDirectory(fixture.target().toPath(), secondTarget.toPath()); + KList worlds = new KList<>(); + worlds.add(firstWorld); + worlds.add(secondWorld); + + DatapackIngestService.InstallExecution execution = + DatapackIngestService.prepareInstallExecution( + fixture.source(), + worlds, + fixture.desired(), + false, + fixture.root(), + fixture.authorization()); + writeManifest(fixture.root(), fixture.desired()); + DatapackIngestService.finishInstallExecution(execution); + + for (File target : List.of(firstTarget, secondTarget, fixture.target())) { + assertEquals("new", Files.readString( + new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertTrue(new File(target, ".iris-managed.json").isFile()); + assertFalse(new File(target, ".DS_Store").exists()); + } + } + + @Test + public void modifiedSecondLegacyWorldPreventsEveryParticipantFromPublishing() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("mixed-world-legacy-copy", true, true, true); + File firstWorld = temporaryFolder.newFolder("mixed-world-legacy-copy-first"); + File secondWorld = temporaryFolder.newFolder("mixed-world-legacy-copy-second"); + File firstTarget = new File(firstWorld, fixture.desired().id); + File secondTarget = new File(secondWorld, fixture.desired().id); + IO.copyDirectory(fixture.target().toPath(), firstTarget.toPath()); + writeLegacyDatapack(secondTarget, "modified"); + KList worlds = new KList<>(); + worlds.add(firstWorld); + worlds.add(secondWorld); + + try { + DatapackIngestService.prepareInstallExecution( + fixture.source(), + worlds, + fixture.desired(), + false, + fixture.root(), + fixture.authorization()); + fail("Expected a modified legacy world to block the complete install"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("unmanaged datapack")); + } + + assertEquals("old", Files.readString( + new File(firstTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertEquals("modified", Files.readString( + new File(secondTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertEquals("old", Files.readString( + new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(firstTarget, ".iris-managed.json").exists()); + assertFalse(new File(secondTarget, ".iris-managed.json").exists()); + assertFalse(new File(fixture.target(), ".iris-managed.json").exists()); + } + + @Test + public void coordinatorRollbackRestoresLegacyWorldCopiesAndCanonicalStaging() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("rollback-world-legacy-copy", true, true, true); + File firstWorld = temporaryFolder.newFolder("rollback-world-legacy-copy-first"); + File secondWorld = temporaryFolder.newFolder("rollback-world-legacy-copy-second"); + File firstTarget = new File(firstWorld, fixture.desired().id); + File secondTarget = new File(secondWorld, fixture.desired().id); + IO.copyDirectory(fixture.target().toPath(), firstTarget.toPath()); + IO.copyDirectory(fixture.target().toPath(), secondTarget.toPath()); + KList worlds = new KList<>(); + worlds.add(firstWorld); + worlds.add(secondWorld); + DatapackIngestService.InstallExecution execution = + DatapackIngestService.prepareInstallExecution( + fixture.source(), + worlds, + fixture.desired(), + false, + fixture.root(), + fixture.authorization()); + + IOException failure = new IOException("manifest publication failed"); + DatapackIngestService.rollbackInstallExecutions(List.of(execution), failure); + + assertEquals(0, failure.getSuppressed().length); + for (File target : List.of(firstTarget, secondTarget, fixture.target())) { + assertEquals("old", Files.readString( + new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertTrue(new File(target, ".DS_Store").isFile()); + assertFalse(new File(target, ".iris-managed.json").exists()); + } + } + + @Test + public void changedCanonicalLegacySnapshotBlocksPreparedWorldMigration() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("changed-canonical-world-proof", true, true, true); + File world = temporaryFolder.newFolder("changed-canonical-world-proof-target"); + File worldTarget = new File(world, fixture.desired().id); + IO.copyDirectory(fixture.target().toPath(), worldTarget.toPath()); + DatapackIngestService.InstallPlan worldPlan = DatapackIngestService.prepareInstall( + fixture.source(), world, fixture.desired(), fixture.sourceHash(), false, + fixture.authorization()); + Files.writeString(new File(fixture.target(), "value.txt").toPath(), "changed", StandardCharsets.UTF_8); + + try { + DatapackIngestService.publishInstallPlan(worldPlan); + fail("Expected changed canonical legacy staging to block world migration"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("canonical legacy datapack staging target")); + } + assertEquals("old", Files.readString( + new File(worldTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(worldTarget, ".iris-managed.json").exists()); + } + + @Test + public void swappedCanonicalLegacySnapshotBlocksPreparedWorldMigration() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("swapped-canonical-world-proof", true, true, true); + File world = temporaryFolder.newFolder("swapped-canonical-world-proof-target"); + File worldTarget = new File(world, fixture.desired().id); + IO.copyDirectory(fixture.target().toPath(), worldTarget.toPath()); + DatapackIngestService.InstallPlan worldPlan = DatapackIngestService.prepareInstall( + fixture.source(), world, fixture.desired(), fixture.sourceHash(), false, + fixture.authorization()); + File displaced = new File(fixture.stagingRoot(), "displaced-managed"); + Files.move(fixture.target().toPath(), displaced.toPath()); + IO.copyDirectory(displaced.toPath(), fixture.target().toPath()); + + try { + DatapackIngestService.publishInstallPlan(worldPlan); + fail("Expected swapped canonical legacy staging to block world migration"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("identity changed")); + } + assertEquals("old", Files.readString( + new File(worldTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(worldTarget, ".iris-managed.json").exists()); + } + + @Test + public void changedManifestCannotReuseLegacyStagingAuthority() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("changed-manifest-authority", true, true, true); + writeManifest(fixture.root(), null); + + assertLegacyStagingPreparationRejected(fixture, "authority changed"); + } + + @Test + public void sameUrlManifestMetadataChangeBlocksPreparedWorldMigration() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("changed-world-manifest-metadata", true, true, true); + File world = temporaryFolder.newFolder("changed-world-manifest-metadata-target"); + File worldTarget = new File(world, fixture.desired().id); + IO.copyDirectory(fixture.target().toPath(), worldTarget.toPath()); + DatapackIngestService.InstallPlan worldPlan = DatapackIngestService.prepareInstall( + fixture.source(), world, fixture.desired(), fixture.sourceHash(), false, + fixture.authorization()); + DatapackIngestService.Entry changed = entry("managed", "other-version", "other", "other-sha"); + writeManifest(fixture.root(), changed); + + try { + DatapackIngestService.publishInstallPlan(worldPlan); + fail("Expected changed manifest metadata to block world migration"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("authority changed")); + } + assertEquals("old", Files.readString( + new File(worldTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(worldTarget, ".iris-managed.json").exists()); + } + + @Test + public void swappedStagingRootCannotReuseVerifiedAuthority() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("swapped-staging-authority", true, true, true); + Path displaced = new File(fixture.root(), "displaced-staging").toPath(); + Files.move(fixture.stagingRoot().toPath(), displaced); + assertTrue(fixture.stagingRoot().mkdir()); + + assertLegacyStagingPreparationRejected(fixture, "Changed or unsafe datapack staging root"); + } + + @Test + public void duplicateWorldParticipantsAreRejectedBeforePublication() throws Exception { + DatapackIngestService.Entry entry = entry("duplicate-world", "v1", "1", "sha"); + File source = datapackDirectory("duplicate-world-source"); + DatapackIngestService.writeOwnership(source, entry); + File world = temporaryFolder.newFolder("duplicate-world-target"); + KList worlds = new KList<>(); + worlds.add(world); + worlds.add(world); + + try { + DatapackIngestService.install(source, worlds, entry, false); + fail("Expected duplicate world install participants to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("Duplicate datapack install target")); + } + assertFalse(new File(world, entry.id).exists()); + } + + @Test + public void symbolicLinkWorldParticipantIsRejectedBeforePublication() throws Exception { + DatapackIngestService.Entry entry = entry("aliased-world", "v1", "1", "sha"); + File source = datapackDirectory("aliased-world-source"); + DatapackIngestService.writeOwnership(source, entry); + File world = temporaryFolder.newFolder("aliased-world-target"); + Path alias = new File(temporaryFolder.getRoot(), "aliased-world-link").toPath(); + try { + Files.createSymbolicLink(alias, world.toPath()); + } catch (IOException | UnsupportedOperationException unavailable) { + Assume.assumeNoException(unavailable); + } + KList worlds = new KList<>(); + worlds.add(world); + worlds.add(alias.toFile()); + + try { + DatapackIngestService.install(source, worlds, entry, false); + fail("Expected symbolic-link world participant to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("Invalid datapack install root")); + } + assertFalse(new File(world, entry.id).exists()); + } + + @Test + public void worldParticipantAliasThroughASymbolicParentIsRejected() throws Exception { + DatapackIngestService.Entry entry = entry("parent-aliased-world", "v1", "1", "sha"); + File source = datapackDirectory("parent-aliased-world-source"); + DatapackIngestService.writeOwnership(source, entry); + File realParent = temporaryFolder.newFolder("parent-aliased-world-root"); + File realWorld = new File(realParent, "datapacks"); + assertTrue(realWorld.mkdir()); + Path aliasParent = new File(temporaryFolder.getRoot(), "parent-aliased-world-link").toPath(); + try { + Files.createSymbolicLink(aliasParent, realParent.toPath()); + } catch (IOException | UnsupportedOperationException unavailable) { + Assume.assumeNoException(unavailable); + } + File aliasedWorld = aliasParent.resolve("datapacks").toFile(); + KList worlds = new KList<>(); + worlds.add(realWorld); + worlds.add(aliasedWorld); + + try { + DatapackIngestService.install(source, worlds, entry, false); + fail("Expected real-path world participant alias to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("Duplicate datapack install target")); + } + assertFalse(new File(realWorld, entry.id).exists()); + } + + @Test + public void changedLegacyTargetIsRejectedImmediatelyBeforePublication() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("changed-target-publication", true, true, true); + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + Files.writeString(new File(plan.target(), "value.txt").toPath(), "changed", StandardCharsets.UTF_8); + + assertInstallPublicationRejected(plan, "content changed"); + assertEquals("changed", Files.readString( + new File(plan.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void changedLegacyMarkerIsRejectedImmediatelyBeforePublication() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("changed-marker-publication", true, true, true); + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + Files.writeString(new File(plan.target(), ".iris-managed.json").toPath(), "{}", StandardCharsets.UTF_8); + + assertInstallPublicationRejected(plan, "content changed"); + assertTrue(new File(plan.target(), ".iris-managed.json").isFile()); + } + + @Test + public void byteIdenticalLegacyTargetSwapIsRejectedBeforePublication() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("swapped-target-publication", true, true, true); + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + File displaced = new File(fixture.stagingRoot(), "displaced-target"); + Files.move(plan.target().toPath(), displaced.toPath()); + writeLegacyDatapack(plan.target(), "old"); + + assertInstallPublicationRejected(plan, "identity changed"); + assertTrue(displaced.isDirectory()); + } + + @Test + public void changedPreparedContentIsRejectedBeforePublication() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("changed-pending-publication", true, true, true); + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + Files.writeString(new File(plan.pending(), "value.txt").toPath(), "changed", StandardCharsets.UTF_8); + + assertInstallPublicationRejected(plan, "content changed"); + assertEquals("old", Files.readString( + new File(plan.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void changedPreparedMarkerIsRejectedBeforePublication() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("changed-pending-marker", true, true, true); + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + Files.writeString(new File(plan.pending(), ".iris-managed.json").toPath(), "{}", StandardCharsets.UTF_8); + + assertInstallPublicationRejected(plan, "content changed"); + assertEquals("old", Files.readString( + new File(plan.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void swappedTargetRootIsRejectedBeforePublication() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("swapped-target-root", true, true, true); + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + Path displaced = new File(fixture.root(), "displaced-target-root").toPath(); + Files.move(fixture.stagingRoot().toPath(), displaced); + assertTrue(fixture.stagingRoot().mkdir()); + + assertInstallPublicationRejected(plan, "Changed or unsafe datapack target root"); + assertEquals("old", Files.readString( + displaced.resolve("managed/value.txt"), StandardCharsets.UTF_8)); + } + + @Test + public void swappedInstallScratchRootIsRejectedBeforePublication() throws Exception { + LegacyStagingFixture fixture = legacyStagingFixture("swapped-install-scratch", true, true, true); + DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); + Path displaced = new File(fixture.root(), "displaced-install-scratch").toPath(); + Files.move(plan.pendingRoot().toPath(), displaced); + assertTrue(plan.pendingRoot().mkdir()); + + assertInstallPublicationRejected(plan, "Changed or unsafe datapack install scratch root"); + assertEquals("old", Files.readString( + new File(plan.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void recoveredCommittedStagingRequiresRestartWhenContentIsUnchanged() { + assertTrue(DatapackIngestService.freshInstallRequiresRestart(false, true)); + assertFalse(DatapackIngestService.freshInstallRequiresRestart(false, false)); + assertTrue(DatapackIngestService.freshInstallRequiresRestart(true, false)); + } + + @Test + public void unchangedContentRefreshesRecoverableOwnershipMetadataWithoutRestart() throws Exception { + DatapackIngestService.Entry current = new DatapackIngestService.Entry(); + current.id = "managed"; + current.url = "https://example.test/current.zip"; + current.versionId = "v2"; + current.versionNumber = "2"; + current.sha1 = "current-hash"; + current.structureKeys = List.of("example:castle"); + + File staging = datapackDirectory("metadata-source"); + Files.writeString(new File(staging, "value.txt").toPath(), "same", StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(staging, current); + + File world = temporaryFolder.newFolder("metadata-world-datapacks"); + File target = new File(world, current.id); + assertTrue(target.mkdirs()); + Files.copy(new File(staging, "pack.mcmeta").toPath(), new File(target, "pack.mcmeta").toPath()); + Files.copy(new File(staging, "value.txt").toPath(), new File(target, "value.txt").toPath()); + DatapackIngestService.Entry old = new DatapackIngestService.Entry(); + old.id = current.id; + old.url = "https://example.test/old.zip"; + old.versionId = "v1"; + old.versionNumber = "1"; + old.sha1 = "old-hash"; + DatapackIngestService.writeOwnership(target, old); + + KList worlds = new KList<>(); + worlds.add(world); + DatapackIngestService.InstallResult result = DatapackIngestService.install(staging, worlds, current, false); + String marker = Files.readString(new File(target, ".iris-managed.json").toPath(), StandardCharsets.UTF_8); + + assertFalse(result.changed()); + assertTrue(marker.contains("https://example.test/current.zip")); + assertTrue(marker.contains("example:castle")); + assertFalse(marker.contains("https://example.test/old.zip")); + } + + @Test + public void editableImportInventoryTracksOnlySourceOwnedBundleKeys() { + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.structureKeys = List.of("nova_structures:tavern/oak"); + entry.templateKeys = List.of("nova_structures:building/house"); + + Map inventory = DatapackIngestService.importBundleInventory(entry); + + assertEquals("nova_structures:tavern/oak", inventory.get("iris:nova_structures_tavern_oak")); + assertEquals("nova_structures:building/house", inventory.get("iris:nova_structures/building/house")); + assertEquals(2, inventory.size()); + } + + @Test + public void recoveryInventoryProtectsPriorAndDesiredBundlesBeforeImport() { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "new-sha"); + entry.structureKeys = List.of("test:castle", "test:new"); + entry.importedTargets.put("pack", "old-revision"); + entry.importedBundles.put("pack", Map.of( + "iris:test_castle", "old:castle", + "iris:stale", "old:stale" + )); + entry.structuresImported = true; + + DatapackIngestService.prepareImportRecoveryInventory(entry, "pack"); + + assertEquals(Map.of( + "iris:test_castle", "old:castle", + "iris:test_new", "test:new", + "iris:stale", "old:stale" + ), entry.importedBundles.get("pack")); + assertFalse(entry.importedTargets.containsKey("pack")); assertFalse(entry.structuresImported); } @Test - public void manifestCompletesOnlyAfterEveryAttemptedPackSucceeds() { - DatapackIngestService.Entry first = new DatapackIngestService.Entry(); - DatapackIngestService.Entry second = new DatapackIngestService.Entry(); + public void removingOneDatapackPreservesAStillClaimedEditableBundle() throws Exception { + File root = temporaryFolder.newFolder("shared-removal-root"); + File editablePack = temporaryFolder.newFolder("shared-removal-editable"); + StructureKey targetKey = StructureKey.parse("iris:owned"); + StructureKey sourceKey = StructureKey.parse("example:owned"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + assertEquals(StructureWriteResult.Status.ADDED, writer.write( + importedBundle(targetKey, sourceKey, "owned"), + StructureWriteMode.ADD_ONLY + ).status()); - boolean completed = DatapackIngestService.markStructuresImportedIfComplete( - List.of(first, second), 2, 2); + String targetId = editablePack.getAbsolutePath(); + DatapackIngestService.Entry removed = entry("removed", "v1", "1", "sha-one"); + removed.importedTargets.put(targetId, "revision-one"); + removed.importedBundles.put(targetId, Map.of("iris:owned", "example:owned")); + DatapackIngestService.Entry retained = entry("retained", "v1", "1", "sha-two"); + retained.importedTargets.put(targetId, "revision-two"); + retained.importedBundles.put(targetId, Map.of("iris:owned", "example:owned")); + retained.structuresImported = true; + Files.writeString(new File(root, "manifest.json").toPath(), + new Gson().toJson(Map.of("entries", List.of(removed, retained))), + StandardCharsets.UTF_8); - assertTrue(completed); - assertTrue(first.structuresImported); - assertTrue(second.structuresImported); + assertTrue(DatapackIngestService.removeLocked(null, removed.id, root, List.of())); + + assertEquals("owned", Files.readString(new File(editablePack, "objects/owned.iob").toPath())); + assertTrue(Files.exists(writer.ownershipManifestPath(targetKey))); + JsonObject retainedJson = JsonParser.parseString(Files.readString( + new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8)) + .getAsJsonObject().getAsJsonArray("entries").get(0).getAsJsonObject(); + assertEquals("retained", retainedJson.get("id").getAsString()); + assertFalse(retainedJson.getAsJsonObject("importedTargets").has(targetId)); + assertFalse(retainedJson.get("structuresImported").getAsBoolean()); + } + + @Test + public void removalPreservesAKeyCollisionAndInvalidatesTheDifferentRetainedSource() throws Exception { + File root = temporaryFolder.newFolder("collision-removal-root"); + File editablePack = temporaryFolder.newFolder("collision-removal-editable"); + StructureKey targetKey = StructureKey.parse("iris:foo_a_b"); + StructureKey currentSource = StructureKey.parse("foo:a/b"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + assertEquals(StructureWriteResult.Status.ADDED, writer.write( + importedBundle(targetKey, currentSource, "current"), + StructureWriteMode.ADD_ONLY + ).status()); + + String targetId = editablePack.getAbsolutePath(); + DatapackIngestService.Entry removed = entry("removed", "v1", "1", "sha-one"); + removed.importedTargets.put(targetId, "revision-one"); + removed.importedBundles.put(targetId, Map.of("iris:foo_a_b", "foo:a/b")); + DatapackIngestService.Entry retained = entry("retained", "v1", "1", "sha-two"); + retained.importedTargets.put(targetId, "revision-two"); + retained.importedBundles.put(targetId, Map.of("iris:foo_a_b", "foo:a_b")); + retained.structuresImported = true; + Files.writeString(new File(root, "manifest.json").toPath(), + new Gson().toJson(Map.of("entries", List.of(removed, retained))), + StandardCharsets.UTF_8); + + assertTrue(DatapackIngestService.removeLocked(null, removed.id, root, List.of())); + + assertEquals("current", Files.readString(new File(editablePack, "objects/foo_a_b.iob").toPath())); + JsonObject retainedJson = JsonParser.parseString(Files.readString( + new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8)) + .getAsJsonObject().getAsJsonArray("entries").get(0).getAsJsonObject(); + assertFalse(retainedJson.getAsJsonObject("importedTargets").has(targetId)); + assertFalse(retainedJson.get("structuresImported").getAsBoolean()); + } + + @Test + public void failedUnrelatedCleanupStillInvalidatesARetainedCollisionOwner() throws Exception { + File editablePack = temporaryFolder.newFolder("failed-collision-cleanup-editable"); + StructureKey collisionTarget = StructureKey.parse("iris:foo_a_b"); + StructureKey removedCollisionSource = StructureKey.parse("foo:a/b"); + StructureKey staleTarget = StructureKey.parse("iris:stale"); + StructureKey staleSource = StructureKey.parse("old:stale"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + assertEquals(StructureWriteResult.Status.ADDED, writer.write( + importedBundle(collisionTarget, removedCollisionSource, "current"), + StructureWriteMode.ADD_ONLY + ).status()); + assertEquals(StructureWriteResult.Status.ADDED, writer.write( + importedBundle(staleTarget, staleSource, "stale"), + StructureWriteMode.ADD_ONLY + ).status()); + Files.writeString( + new File(editablePack, "objects/stale.iob").toPath(), + "modified", + StandardCharsets.UTF_8 + ); + + String targetId = editablePack.getAbsolutePath(); + DatapackIngestService.Entry removed = entry("removed", "v1", "1", "sha-one"); + removed.importedTargets.put(targetId, "removed-revision"); + removed.importedBundles.put(targetId, Map.of( + collisionTarget.value(), removedCollisionSource.value(), + staleTarget.value(), staleSource.value() + )); + DatapackIngestService.Entry retained = entry("retained", "v1", "1", "sha-two"); + retained.structureKeys = List.of("foo:a_b"); + retained.importedTargets.put(targetId, "retained-revision"); + retained.importedBundles.put(targetId, Map.of(collisionTarget.value(), "foo:a_b")); + retained.structuresImported = true; + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(editablePack); + + boolean cleaned = DatapackIngestService.cleanupRemovedImports( + data, + targetId, + Set.of(retained.url), + List.of(removed, retained), + Map.of(retained.url, retained) + ); + + assertFalse(cleaned); + assertEquals("modified", Files.readString( + new File(editablePack, "objects/stale.iob").toPath(), StandardCharsets.UTF_8)); + assertFalse(retained.importedTargets.containsKey(targetId)); + assertFalse(retained.structuresImported); + } + + @Test + public void removalIgnoresAGlobalCollisionNeverImportedIntoThatTarget() throws Exception { + File root = temporaryFolder.newFolder("nonparticipating-collision-removal-root"); + File editablePack = temporaryFolder.newFolder("nonparticipating-collision-removal-editable"); + StructureKey targetKey = StructureKey.parse("iris:foo_a_b"); + StructureKey currentSource = StructureKey.parse("foo:a/b"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + assertEquals(StructureWriteResult.Status.ADDED, writer.write( + importedBundle(targetKey, currentSource, "current"), + StructureWriteMode.ADD_ONLY + ).status()); + + String targetId = editablePack.getAbsolutePath(); + DatapackIngestService.Entry removed = entry("removed", "v1", "1", "sha-one"); + removed.importedTargets.put(targetId, "revision-one"); + removed.importedBundles.put(targetId, Map.of("iris:foo_a_b", "foo:a/b")); + DatapackIngestService.Entry unrelated = entry("unrelated", "v1", "1", "sha-two"); + unrelated.structureKeys = List.of("foo:a_b"); + Files.writeString(new File(root, "manifest.json").toPath(), + new Gson().toJson(Map.of("entries", List.of(removed, unrelated))), + StandardCharsets.UTF_8); + + assertTrue(DatapackIngestService.removeLocked(null, removed.id, root, List.of())); + + assertFalse(Files.exists(new File(editablePack, "objects/foo_a_b.iob").toPath())); + assertFalse(Files.exists(writer.ownershipManifestPath(targetKey))); + assertTrue(Files.readString(new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8) + .contains("unrelated")); + } + + @Test + public void removalSkipsEditableBundlesNowOwnedByAnotherSource() throws Exception { + File root = temporaryFolder.newFolder("mismatched-removal-root"); + File editablePack = temporaryFolder.newFolder("mismatched-removal-editable"); + StructureKey targetKey = StructureKey.parse("iris:owned"); + StructureKey actualSource = StructureKey.parse("other:owner"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + assertEquals(StructureWriteResult.Status.ADDED, writer.write( + importedBundle(targetKey, actualSource, "other"), + StructureWriteMode.ADD_ONLY + ).status()); + + DatapackIngestService.Entry removed = entry("removed", "v1", "1", "sha"); + String targetId = editablePack.getAbsolutePath(); + removed.importedTargets.put(targetId, "revision"); + removed.importedBundles.put(targetId, Map.of("iris:owned", "example:former")); + DatapackIngestService.Entry retained = entry("retained", "v1", "1", "retained-sha"); + retained.importedTargets.put(targetId, "retained-revision"); + retained.importedBundles.put(targetId, Map.of("iris:owned", "retained:desired")); + retained.structuresImported = true; + Files.writeString(new File(root, "manifest.json").toPath(), + new Gson().toJson(Map.of("entries", List.of(removed, retained))), + StandardCharsets.UTF_8); + + assertTrue(DatapackIngestService.removeLocked(null, removed.id, root, List.of())); + + assertEquals("other", Files.readString(new File(editablePack, "objects/owned.iob").toPath())); + assertTrue(Files.exists(writer.ownershipManifestPath(targetKey))); + JsonObject retainedJson = JsonParser.parseString(Files.readString( + new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8)) + .getAsJsonObject().getAsJsonArray("entries").get(0).getAsJsonObject(); + assertFalse(retainedJson.getAsJsonObject("importedTargets").has(targetId)); + assertFalse(retainedJson.get("structuresImported").getAsBoolean()); + } + + @Test + public void removalInvalidatesRetainedClaimsWhenTheEditableTargetIsMissing() throws Exception { + File root = temporaryFolder.newFolder("missing-editable-removal-root"); + File missing = new File(temporaryFolder.getRoot(), "missing-editable-removal-target"); + String targetId = missing.getAbsolutePath(); + DatapackIngestService.Entry removed = entry("removed", "v1", "1", "sha"); + removed.importedTargets.put(targetId, "removed-revision"); + removed.importedBundles.put(targetId, Map.of("iris:owned", "example:former")); + DatapackIngestService.Entry retained = entry("retained", "v1", "1", "retained-sha"); + retained.importedTargets.put(targetId, "retained-revision"); + retained.importedBundles.put(targetId, Map.of("iris:owned", "retained:desired")); + retained.structuresImported = true; + Files.writeString(new File(root, "manifest.json").toPath(), + new Gson().toJson(Map.of("entries", List.of(removed, retained))), + StandardCharsets.UTF_8); + + assertTrue(DatapackIngestService.removeLocked(null, removed.id, root, List.of())); + + assertFalse(missing.exists()); + JsonObject retainedJson = JsonParser.parseString(Files.readString( + new File(root, "manifest.json").toPath(), StandardCharsets.UTF_8)) + .getAsJsonObject().getAsJsonArray("entries").get(0).getAsJsonObject(); + assertFalse(retainedJson.getAsJsonObject("importedTargets").has(targetId)); + assertFalse(retainedJson.get("structuresImported").getAsBoolean()); + } + + @Test + public void failedUpdateCandidateCannotMutateCommittedManifestEntry() { + DatapackIngestService.Entry committed = new DatapackIngestService.Entry(); + committed.versionId = "v1"; + committed.structureKeys = new ArrayList<>(List.of("test:old")); + committed.importedTargets.put("pack", "old"); + committed.importedBundles.put("pack", new HashMap<>(Map.of("iris:old", "test:old"))); + + DatapackIngestService.Entry candidate = DatapackIngestService.copyEntry(committed); + candidate.versionId = "v2"; + candidate.structureKeys.add("test:new"); + candidate.importedTargets.put("pack", "new"); + candidate.importedBundles.get("pack").put("iris:new", "test:new"); + + assertEquals("v1", committed.versionId); + assertEquals(List.of("test:old"), committed.structureKeys); + assertEquals("old", committed.importedTargets.get("pack")); + assertEquals(Map.of("iris:old", "test:old"), committed.importedBundles.get("pack")); + } + + @Test + public void publishingInstallCrashRollsEveryWorldBackToTheCommittedManifest() throws Exception { + File root = temporaryFolder.newFolder("install-crash-rollback-root"); + File world = temporaryFolder.newFolder("install-crash-rollback-world"); + DatapackIngestService.Entry original = entry("managed", "v1", "1", "old-sha"); + DatapackIngestService.Entry desired = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, original); + + File target = new File(world, desired.id); + writeManagedDatapack(target, original, "old"); + String originalHash = ownershipHash(target); + File scratch = new File(world.getParentFile(), ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-pending"); + File backup = new File(scratch, "managed-backup"); + writeManagedDatapack(pending, desired, "new"); + String desiredHash = ownershipHash(pending); + Files.move(target.toPath(), backup.toPath()); + Files.move(pending.toPath(), target.toPath()); + Map directory = installDirectory(target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHING", desired, false, + List.of(directory), List.of()); + + DatapackIngestService.recoverTransactions(root, List.of(world)); + + assertEquals("old", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void publishedInstallCrashKeepsEveryWorldAfterTheManifestCommit() throws Exception { + File root = temporaryFolder.newFolder("install-crash-commit-root"); + File world = temporaryFolder.newFolder("install-crash-commit-world"); + DatapackIngestService.Entry original = entry("managed", "v1", "1", "old-sha"); + DatapackIngestService.Entry desired = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, desired); + + File target = new File(world, desired.id); + writeManagedDatapack(target, original, "old"); + String originalHash = ownershipHash(target); + File scratch = new File(world.getParentFile(), ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-pending-commit"); + File backup = new File(scratch, "managed-backup-commit"); + writeManagedDatapack(pending, desired, "new"); + String desiredHash = ownershipHash(pending); + Files.move(target.toPath(), backup.toPath()); + Files.move(pending.toPath(), target.toPath()); + Map directory = installDirectory(target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHED", desired, false, + List.of(directory), List.of()); + + DatapackIngestService.recoverTransactions(root, List.of(world)); + + assertEquals("new", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void publishingInstallCrashRestoresManagedStagingWithEveryWorld() throws Exception { + File root = temporaryFolder.newFolder("staging-install-crash-rollback-root"); + DatapackIngestService.Entry original = entry("managed", "v1", "1", "old-sha"); + DatapackIngestService.Entry desired = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, original); + + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + File target = new File(staging, desired.id); + writeManagedDatapack(target, original, "old"); + String originalHash = ownershipHash(target); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-" + UUID.randomUUID()); + File backup = new File(scratch, "managed-backup-" + UUID.randomUUID()); + writeManagedDatapack(pending, desired, "new"); + String desiredHash = ownershipHash(pending); + Files.move(target.toPath(), backup.toPath()); + Files.move(pending.toPath(), target.toPath()); + Map directory = installDirectory( + target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHING", desired, false, + List.of(directory), List.of()); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertEquals("old", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void publishedInstallCrashKeepsManagedStagingAfterManifestCommit() throws Exception { + File root = temporaryFolder.newFolder("staging-install-crash-commit-root"); + DatapackIngestService.Entry original = entry("managed", "v1", "1", "old-sha"); + DatapackIngestService.Entry desired = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, desired); + + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + File target = new File(staging, desired.id); + writeManagedDatapack(target, original, "old"); + String originalHash = ownershipHash(target); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-" + UUID.randomUUID()); + File backup = new File(scratch, "managed-backup-" + UUID.randomUUID()); + writeManagedDatapack(pending, desired, "new"); + String desiredHash = ownershipHash(pending); + Files.move(target.toPath(), backup.toPath()); + Files.move(pending.toPath(), target.toPath()); + Map directory = installDirectory( + target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHED", desired, false, + List.of(directory), List.of()); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertEquals("new", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void publishingLegacyStagingCrashRestoresTheExactUnmarkedDirectory() throws Exception { + File root = temporaryFolder.newFolder("legacy-staging-crash-rollback-root"); + DatapackIngestService.Entry original = entry("managed", "v1", "1", "old-sha"); + DatapackIngestService.Entry desired = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, original); + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + File target = new File(staging, desired.id); + writeLegacyDatapack(target, "old"); + DatapackIngestService.writeOwnership(target, original); + String originalHash = ownershipHash(target); + assertTrue(new File(target, ".iris-managed.json").delete()); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-" + UUID.randomUUID()); + File backup = new File(scratch, "managed-backup-" + UUID.randomUUID()); + writeManagedDatapack(pending, desired, "new"); + String desiredHash = ownershipHash(pending); + Files.move(target.toPath(), backup.toPath()); + Files.move(pending.toPath(), target.toPath()); + Map directory = installDirectory( + target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHING", desired, false, + List.of(directory), List.of()); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertEquals("old", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertTrue(new File(target, ".DS_Store").isFile()); + assertFalse(new File(target, ".iris-managed.json").exists()); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void publishedLegacyStagingCrashCommitsTheManagedDirectory() throws Exception { + File root = temporaryFolder.newFolder("legacy-staging-crash-commit-root"); + DatapackIngestService.Entry original = entry("managed", "v1", "1", "old-sha"); + DatapackIngestService.Entry desired = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, desired); + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + File target = new File(staging, desired.id); + writeLegacyDatapack(target, "old"); + DatapackIngestService.writeOwnership(target, original); + String originalHash = ownershipHash(target); + assertTrue(new File(target, ".iris-managed.json").delete()); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-" + UUID.randomUUID()); + File backup = new File(scratch, "managed-backup-" + UUID.randomUUID()); + writeManagedDatapack(pending, desired, "new"); + String desiredHash = ownershipHash(pending); + Files.move(target.toPath(), backup.toPath()); + Files.move(pending.toPath(), target.toPath()); + Map directory = installDirectory( + target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHED", desired, false, + List.of(directory), List.of()); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertEquals("new", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertTrue(new File(target, ".iris-managed.json").isFile()); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void recoveryDeletesAnUnjournaledPreparedInstallCopy() throws Exception { + File root = temporaryFolder.newFolder("orphan-install-pending-root"); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-" + UUID.randomUUID()); + assertTrue(pending.mkdirs()); + Files.writeString(new File(pending, "partial.dat").toPath(), "partial", StandardCharsets.UTF_8); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertFalse(pending.exists()); + assertFalse(scratch.exists()); + } + + @Test + public void recoveryPreservesAndBlocksOnAnUnjournaledInstallBackup() throws Exception { + File root = temporaryFolder.newFolder("orphan-install-backup-root"); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File backup = new File(scratch, "managed-backup-" + UUID.randomUUID()); + assertTrue(backup.mkdirs()); + Files.writeString(new File(backup, "prior.dat").toPath(), "prior", StandardCharsets.UTF_8); + + try { + DatapackIngestService.recoverTransactions(root, List.of()); + fail("Expected an unjournaled backup to block recovery"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("unjournaled datapack install backup")); + } + assertEquals("prior", Files.readString(new File(backup, "prior.dat").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void reapplyRecoveryFailurePreservesEvidenceAndBlocksCompilation() throws Exception { + File root = temporaryFolder.newFolder("blocked-reapply-root"); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File backup = new File(scratch, "managed-backup-" + UUID.randomUUID()); + assertTrue(backup.mkdirs()); + File evidence = new File(backup, "prior.dat"); + Files.writeString(evidence.toPath(), "prior", StandardCharsets.UTF_8); + + assertFalse(DatapackIngestService.recoverBeforeReapply(root, List.of())); + + assertEquals("prior", Files.readString(evidence.toPath(), StandardCharsets.UTF_8)); + assertTrue(backup.isDirectory()); + } + + @Test + public void reapplyInstallFailurePreservesTheTargetAndBlocksCompilation() throws Exception { + File root = temporaryFolder.newFolder("blocked-reapply-install-root"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + File stagingRoot = new File(root, "staging"); + File staging = new File(stagingRoot, entry.id); + writeManagedDatapack(staging, entry, "staged"); + File world = temporaryFolder.newFolder("blocked-reapply-install-world"); + File target = new File(world, entry.id); + assertTrue(target.mkdirs()); + Files.writeString(new File(target, "pack.mcmeta").toPath(), """ + {"pack":{"description":"test","pack_format":88}} + """, StandardCharsets.UTF_8); + File userFile = new File(target, "user-edit.txt"); + Files.writeString(userFile.toPath(), "preserve", StandardCharsets.UTF_8); + KList worlds = new KList<>(); + worlds.add(world); + + assertFalse(DatapackIngestService.reapplyStagedDirectories( + root, stagingRoot, worlds, false)); + + assertEquals("preserve", Files.readString(userFile.toPath(), StandardCharsets.UTF_8)); + assertTrue(staging.isDirectory()); + } + + @Test + public void unusableCommittedStagingBlocksCompilation() throws Exception { + File root = temporaryFolder.newFolder("blocked-corrupt-staging-root"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + File stagingRoot = new File(root, "staging"); + File staging = new File(stagingRoot, entry.id); + writeManagedDatapack(staging, entry, "staged"); + Files.writeString( + new File(staging, "corrupt.txt").toPath(), + "corrupt", + StandardCharsets.UTF_8 + ); + File world = temporaryFolder.newFolder("blocked-corrupt-staging-world"); + KList worlds = new KList<>(); + worlds.add(world); + + assertFalse(DatapackIngestService.reapplyStagedDirectories( + root, stagingRoot, worlds, false)); + + assertFalse(new File(world, entry.id).exists()); + assertEquals("corrupt", Files.readString( + new File(staging, "corrupt.txt").toPath(), StandardCharsets.UTF_8)); + } + + @Test + public void missingCommittedStagingBlocksAbsentAndStaleWorldInstalls() throws Exception { + File root = temporaryFolder.newFolder("blocked-missing-staging-root"); + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, entry); + File stagingRoot = new File(root, "staging"); + assertTrue(stagingRoot.mkdirs()); + File absentWorld = temporaryFolder.newFolder("blocked-missing-staging-absent-world"); + File staleWorld = temporaryFolder.newFolder("blocked-missing-staging-stale-world"); + DatapackIngestService.Entry stale = entry("managed", "v1", "1", "old-sha"); + File staleTarget = new File(staleWorld, entry.id); + writeManagedDatapack(staleTarget, stale, "old"); + KList worlds = new KList<>(); + worlds.add(absentWorld); + worlds.add(staleWorld); + + assertFalse(DatapackIngestService.reapplyStagedDirectories( + root, stagingRoot, worlds, false)); + + assertFalse(new File(absentWorld, entry.id).exists()); + assertEquals("old", Files.readString( + new File(staleTarget, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(stagingRoot, entry.id).exists()); + } + + @Test + public void absentStagingRootBlocksACommittedManifest() throws Exception { + File root = temporaryFolder.newFolder("absent-staging-committed-root"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + File stagingRoot = new File(root, "staging"); + + assertFalse(DatapackIngestService.reapplyStagingRoot( + root, stagingRoot, new KList<>(), false)); + } + + @Test + public void absentStagingRootIsAllowedForAnEmptyManifest() throws Exception { + File root = temporaryFolder.newFolder("absent-staging-empty-root"); + writeManifest(root, null); + File stagingRoot = new File(root, "staging"); + + assertTrue(DatapackIngestService.reapplyStagingRoot( + root, stagingRoot, new KList<>(), false)); + } + + @Test + public void unsafeStagingRootsBlockReapply() throws Exception { + File root = temporaryFolder.newFolder("unsafe-staging-root"); + writeManifest(root, null); + File regularFile = new File(root, "staging-file"); + Files.writeString(regularFile.toPath(), "unsafe", StandardCharsets.UTF_8); + assertFalse(DatapackIngestService.reapplyStagingRoot( + root, regularFile, new KList<>(), false)); + + File linkTarget = temporaryFolder.newFolder("unsafe-staging-link-target"); + Path symbolicLink = new File(root, "staging-link").toPath(); + try { + Files.createSymbolicLink(symbolicLink, linkTarget.toPath()); + } catch (IOException | UnsupportedOperationException unavailable) { + Assume.assumeNoException(unavailable); + } + assertFalse(DatapackIngestService.reapplyStagingRoot( + root, symbolicLink.toFile(), new KList<>(), false)); + } + + @Test + public void publishingRemovalCrashRestoresDirectoriesWhenTheManifestStillOwnsThem() throws Exception { + File root = temporaryFolder.newFolder("removal-crash-rollback-root"); + File world = temporaryFolder.newFolder("removal-crash-rollback-world"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + File target = new File(world, entry.id); + writeManagedDatapack(target, entry, "owned"); + String originalHash = ownershipHash(target); + File scratch = new File(world.getParentFile(), ".iris-datapack-remove"); + assertTrue(scratch.mkdirs()); + File backup = new File(scratch, "managed-removal-backup"); + Files.move(target.toPath(), backup.toPath()); + Map directory = removalDirectory(target, backup, originalHash); + File transaction = writeCoordinator(root, "REMOVE", "PUBLISHING", entry, true, + List.of(directory), List.of()); + + DatapackIngestService.recoverTransactions(root, List.of(world)); + + assertEquals("owned", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void publishedRemovalCrashDeletesBackupsAfterTheManifestCommit() throws Exception { + File root = temporaryFolder.newFolder("removal-crash-commit-root"); + File world = temporaryFolder.newFolder("removal-crash-commit-world"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, null); + File target = new File(world, entry.id); + writeManagedDatapack(target, entry, "owned"); + String originalHash = ownershipHash(target); + File scratch = new File(world.getParentFile(), ".iris-datapack-remove"); + assertTrue(scratch.mkdirs()); + File backup = new File(scratch, "managed-removal-backup-commit"); + Files.move(target.toPath(), backup.toPath()); + Map directory = removalDirectory(target, backup, originalHash); + File transaction = writeCoordinator(root, "REMOVE", "PUBLISHED", entry, true, + List.of(directory), List.of()); + + DatapackIngestService.recoverTransactions(root, List.of(world)); + + assertFalse(target.exists()); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void recoveryUsesTheLastPublishedJournalWhenTheNextWriteIsTorn() throws Exception { + File root = temporaryFolder.newFolder("torn-next-root"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHING", entry, true, + List.of(), List.of()); + Files.writeString(new File(transaction, "journal.next.json").toPath(), "{torn", StandardCharsets.UTF_8); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertFalse(transaction.exists()); + } + + @Test + public void recoveryDiscardsOnlyATornFirstJournalBeforeParticipantMutation() throws Exception { + File root = temporaryFolder.newFolder("torn-first-root"); + File transactionRoot = new File(new File(root, ".iris-datapack-transactions"), UUID.randomUUID().toString()); + assertTrue(transactionRoot.mkdirs()); + Files.writeString(new File(transactionRoot, "journal.next.json").toPath(), "{torn", StandardCharsets.UTF_8); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertFalse(transactionRoot.exists()); + } + + @Test + public void recoveryRejectsEditableParticipantsOutsideManifestPackRoots() throws Exception { + File root = temporaryFolder.newFolder("editable-root-validation"); + File allowed = temporaryFolder.newFolder("editable-allowed"); + File arbitrary = temporaryFolder.newFolder("editable-arbitrary"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + entry.importedTargets.put(allowed.getAbsolutePath(), "revision"); + entry.importedBundles.put(allowed.getAbsolutePath(), Map.of("iris:owned", "example:owned")); + writeManifest(root, entry); + Map editable = new LinkedHashMap<>(); + editable.put("packRoot", arbitrary.getAbsolutePath()); + editable.put("transactionId", UUID.randomUUID().toString()); + editable.put("claimId", UUID.randomUUID().toString()); + File transaction = writeCoordinator(root, "REMOVE", "PUBLISHING", entry, true, + List.of(), List.of(editable)); + + try { + DatapackIngestService.recoverTransactions(root, List.of()); + fail("Expected an arbitrary editable pack root to be rejected"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("editable pack root")); + } + assertTrue(transaction.exists()); + } + + @Test + public void recoveryRejectsAScratchRootReplacedByASymbolicLink() throws Exception { + File root = temporaryFolder.newFolder("scratch-link-recovery-root"); + File world = temporaryFolder.newFolder("scratch-link-recovery-world"); + DatapackIngestService.Entry original = entry("managed", "v1", "1", "old-sha"); + DatapackIngestService.Entry desired = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, original); + File target = new File(world, desired.id); + writeManagedDatapack(target, original, "old"); + String originalHash = ownershipHash(target); + File scratch = new File(world.getParentFile(), ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-pending-link"); + File backup = new File(scratch, "managed-backup-link"); + writeManagedDatapack(pending, desired, "new"); + String desiredHash = ownershipHash(pending); + Map directory = installDirectory(target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHING", desired, false, + List.of(directory), List.of()); + File preservedScratch = new File(world.getParentFile(), "preserved-scratch"); + Files.move(scratch.toPath(), preservedScratch.toPath()); + try { + Files.createSymbolicLink(scratch.toPath(), preservedScratch.toPath()); + } catch (Exception e) { + Assume.assumeNoException(e); + } + + try { + DatapackIngestService.recoverTransactions(root, List.of(world)); + fail("Expected a replaced scratch root to be rejected"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("scratch root")); + } + assertEquals("old", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertEquals("new", Files.readString(new File(preservedScratch, "managed-pending-link/value.txt").toPath(), + StandardCharsets.UTF_8)); + assertTrue(transaction.exists()); + } + + @Test + public void recoveryRejectsMalformedDirectoryParticipantPathsAsIoFailures() throws Exception { + String invalidPath = String.valueOf((char) 0); + for (String field : List.of("target", "targetRoot", "scratchRoot", "backup", "pending")) { + File root = temporaryFolder.newFolder("invalid-directory-path-" + field); + File world = temporaryFolder.newFolder( + "invalid-directory-container-" + field, + "datapacks" + ); + DatapackIngestService.Entry original = entry("managed", "v1", "1", "old-sha"); + DatapackIngestService.Entry desired = entry("managed", "v2", "2", "new-sha"); + writeManifest(root, original); + File target = new File(world, desired.id); + writeManagedDatapack(target, original, "old"); + String originalHash = ownershipHash(target); + File scratch = new File(world.getParentFile(), ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-pending-" + field); + File backup = new File(scratch, "managed-backup-" + field); + writeManagedDatapack(pending, desired, "new"); + String desiredHash = ownershipHash(pending); + Map directory = installDirectory( + target, + pending, + backup, + true, + originalHash, + desiredHash + ); + directory.put(field, invalidPath); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHING", desired, false, + List.of(directory), List.of()); + + try { + DatapackIngestService.recoverTransactions(root, List.of(world)); + fail("Expected malformed " + field + " path to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("path")); + } + assertEquals("old", Files.readString(new File(target, "value.txt").toPath(), + StandardCharsets.UTF_8)); + assertTrue(transaction.exists()); + } + } + + @Test + public void recoveryRejectsMalformedEditablePackRootsAsIoFailures() throws Exception { + File root = temporaryFolder.newFolder("invalid-editable-path-root"); + File allowed = temporaryFolder.newFolder("invalid-editable-path-allowed"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + entry.importedTargets.put(allowed.getAbsolutePath(), "revision"); + entry.importedBundles.put(allowed.getAbsolutePath(), Map.of("iris:owned", "example:owned")); + writeManifest(root, entry); + Map editable = new LinkedHashMap<>(); + editable.put("packRoot", String.valueOf((char) 0)); + editable.put("transactionId", UUID.randomUUID().toString()); + editable.put("claimId", UUID.randomUUID().toString()); + File transaction = writeCoordinator(root, "REMOVE", "PUBLISHING", entry, true, + List.of(), List.of(editable)); + + try { + DatapackIngestService.recoverTransactions(root, List.of()); + fail("Expected malformed editable pack root to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("editable pack root path")); + } + assertTrue(transaction.exists()); + } + + @Test + public void genericStructureRecoveryDefersToTheDatapackCoordinator() throws Exception { + File root = temporaryFolder.newFolder("coordinator-first-order-root"); + File editablePack = temporaryFolder.newFolder("coordinator-first-order-editable"); + StructureKey targetKey = StructureKey.parse("iris:owned"); + StructureKey sourceKey = StructureKey.parse("example:owned"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + StructureWriteResult write = writer.write( + importedBundle(targetKey, sourceKey, "owned"), + StructureWriteMode.ADD_ONLY + ); + assertEquals(StructureWriteResult.Status.ADDED, write.status()); + StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of( + new StructureTransactionWriter.OwnedRemoval( + targetKey, + StructureSource.Kind.DATAPACK, + sourceKey + ) + )); + StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + entry.importedTargets.put(token.packRoot().toString(), "revision"); + entry.importedBundles.put(token.packRoot().toString(), Map.of("iris:owned", "example:owned")); + writeManifest(root, entry); + UUID claimId = UUID.randomUUID(); + Map editable = editableParticipant(token, claimId); + File transaction = writeCoordinator(root, "REMOVE", "PUBLISHING", entry, true, + List.of(), List.of(editable)); + UUID coordinatorId = UUID.fromString(transaction.getName()); + removal.claimRecoveryOwner(new StructureTransactionWriter.RecoveryOwner( + transaction.toPath(), coordinatorId, claimId)); + removal.leaveForRecovery(); + + StructureRecoveryResult genericRecovery = new StructureTransactionWriter(editablePack.toPath()) + .recoverIncompleteTransactions(); + + assertFalse(genericRecovery.successful()); + assertFalse(Files.exists(new File(editablePack, "objects/owned.iob").toPath())); + DatapackIngestService.recoverTransactions(root, List.of()); + assertEquals("owned", Files.readString(new File(editablePack, "objects/owned.iob").toPath(), + StandardCharsets.UTF_8)); + assertFalse(transaction.exists()); + } + + @Test + public void datapackRecoveryCanResolveAClaimBeforeGenericStructureRecoveryRuns() throws Exception { + File root = temporaryFolder.newFolder("datapack-first-order-root"); + File editablePack = temporaryFolder.newFolder("datapack-first-order-editable"); + StructureKey targetKey = StructureKey.parse("iris:owned"); + StructureKey sourceKey = StructureKey.parse("example:owned"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + StructureWriteResult write = writer.write( + importedBundle(targetKey, sourceKey, "owned"), + StructureWriteMode.ADD_ONLY + ); + assertEquals(StructureWriteResult.Status.ADDED, write.status()); + StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of( + new StructureTransactionWriter.OwnedRemoval( + targetKey, + StructureSource.Kind.DATAPACK, + sourceKey + ) + )); + StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + entry.importedTargets.put(token.packRoot().toString(), "revision"); + entry.importedBundles.put(token.packRoot().toString(), Map.of("iris:owned", "example:owned")); + writeManifest(root, entry); + UUID claimId = UUID.randomUUID(); + Map editable = editableParticipant(token, claimId); + File transaction = writeCoordinator(root, "REMOVE", "PUBLISHING", entry, true, + List.of(), List.of(editable)); + UUID coordinatorId = UUID.fromString(transaction.getName()); + removal.claimRecoveryOwner(new StructureTransactionWriter.RecoveryOwner( + transaction.toPath(), coordinatorId, claimId)); + removal.leaveForRecovery(); + + DatapackIngestService.recoverTransactions(root, List.of()); + StructureRecoveryResult genericRecovery = new StructureTransactionWriter(editablePack.toPath()) + .recoverIncompleteTransactions(); + + assertTrue(genericRecovery.successful()); + assertEquals("owned", Files.readString(new File(editablePack, "objects/owned.iob").toPath(), + StandardCharsets.UTF_8)); + assertFalse(transaction.exists()); + } + + @Test + public void publishedRemovalWalAuthorizesClaimedEditableRecoveryAfterManifestCommit() throws Exception { + EditableRecoveryFixture fixture = editableRecoveryFixture( + "wal-committed-removal", + "REMOVE", + "PUBLISHED", + false, + true, + true + ); + + DatapackIngestService.recoverTransactions(fixture.root(), List.of()); + + assertFalse(fixture.coordinator().exists()); + assertFalse(Files.exists(new File(fixture.editablePack(), "objects/owned.iob").toPath())); + assertTrue(new StructureTransactionWriter(fixture.editablePack().toPath()) + .recoverIncompleteTransactions().successful()); + } + + @Test + public void preparedRemovalWalCannotAuthorizeEditableRecoveryWithoutTheManifest() throws Exception { + EditableRecoveryFixture fixture = editableRecoveryFixture( + "wal-prepared-removal", + "REMOVE", + "PREPARED", + false, + true, + true + ); + + assertRecoveryRejected(fixture, "authority"); + } + + @Test + public void installWalCannotAuthorizeAnEditableRemoval() throws Exception { + EditableRecoveryFixture fixture = editableRecoveryFixture( + "wal-install", + "INSTALL", + "PUBLISHED", + false, + true, + true + ); + + assertRecoveryRejected(fixture, "editable participants"); + } + + @Test + public void unclaimedPublishedRemovalWalCannotAuthorizeEditableRecovery() throws Exception { + EditableRecoveryFixture fixture = editableRecoveryFixture( + "wal-unclaimed-removal", + "REMOVE", + "PUBLISHED", + false, + false, + true + ); + + assertRecoveryRejected(fixture, "recovery claim"); + } + + @Test + public void mismatchedPublishedRemovalClaimCannotAuthorizeEditableRecovery() throws Exception { + EditableRecoveryFixture fixture = editableRecoveryFixture( + "wal-mismatched-removal", + "REMOVE", + "PUBLISHED", + false, + true, + false + ); + + assertRecoveryRejected(fixture, "does not match"); + } + + @Test + public void changedPreparedRemovalBackupCannotGainWalAuthority() throws Exception { + EditableRecoveryFixture fixture = editableRecoveryFixture( + "wal-changed-removal", + "REMOVE", + "PUBLISHED", + false, + true, + true + ); + Files.writeString(fixture.backup(), "changed", StandardCharsets.UTF_8); + + assertRecoveryRejected(fixture, "hash mismatch"); + } + + @Test + public void recoveryBoundsTransactionCountBeforeLoadingAnyJournal() throws Exception { + File root = temporaryFolder.newFolder("transaction-count-bound"); + File transactions = new File(root, ".iris-datapack-transactions"); + assertTrue(transactions.mkdirs()); + File first = null; + for (int i = 0; i < 1_025; i++) { + File transaction = new File(transactions, UUID.randomUUID().toString()); + assertTrue(transaction.mkdir()); + if (first == null) { + first = transaction; + } + } + + try { + DatapackIngestService.recoverTransactions(root, List.of()); + fail("Expected excessive transaction count to be rejected"); + } catch (Exception expected) { + assertTrue(expected.getMessage().contains("transaction count")); + } + assertTrue(first != null && first.exists()); + } + + @Test + public void recoveryRemovesHarmlessFinderMetadata() throws Exception { + File root = temporaryFolder.newFolder("transaction-finder-metadata"); + File transactions = new File(root, ".iris-datapack-transactions"); + assertTrue(transactions.mkdirs()); + File metadata = new File(transactions, ".DS_Store"); + Files.writeString(metadata.toPath(), "finder", StandardCharsets.UTF_8); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertFalse(metadata.exists()); + } + + @Test + public void recoveryDeletesOnlyExactlyNamedPendingStagingScratch() throws Exception { + File root = temporaryFolder.newFolder("pending-staging-recovery-root"); + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + File pending = new File(staging, ".pending-managed-" + UUID.randomUUID()); + assertTrue(pending.mkdirs()); + Files.writeString(new File(pending, ".iris-extract-part.tmp").toPath(), "partial", + StandardCharsets.UTF_8); + File lookalike = new File(staging, ".pending-managed-not-a-uuid"); + assertTrue(lookalike.mkdirs()); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertFalse(pending.exists()); + assertTrue(lookalike.isDirectory()); + } + + @Test + public void recoveryRefusesExactlyNamedSymbolicLinkStagingScratch() throws Exception { + File root = temporaryFolder.newFolder("linked-staging-recovery-root"); + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + File outside = temporaryFolder.newFolder("linked-staging-recovery-outside"); + File linked = new File(staging, ".pending-managed-" + UUID.randomUUID()); + try { + Files.createSymbolicLink(linked.toPath(), outside.toPath()); + } catch (Exception e) { + Assume.assumeNoException(e); + } + + try { + DatapackIngestService.recoverTransactions(root, List.of()); + fail("Expected symbolic-link staging scratch to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("staging scratch artifact")); + } + assertTrue(Files.isSymbolicLink(linked.toPath())); + assertTrue(outside.isDirectory()); + } + + @Test + public void recoveryRefusesSpecialFilesInsidePendingStagingScratch() throws Exception { + Path temporaryBase = Path.of("/tmp"); + Assume.assumeTrue(Files.isDirectory(temporaryBase)); + File root = Files.createTempDirectory(temporaryBase, "iris-s-").toFile(); + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + File pending = new File(staging, ".pending-m-" + UUID.randomUUID()); + assertTrue(pending.mkdirs()); + Path socket = new File(pending, "s").toPath(); + try { + try (ServerSocketChannel channel = ServerSocketChannel.open(StandardProtocolFamily.UNIX)) { + channel.bind(UnixDomainSocketAddress.of(socket)); + try { + DatapackIngestService.recoverTransactions(root, List.of()); + fail("Expected special staging scratch file to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("unsupported file")); + } + } + assertTrue(pending.isDirectory()); + } catch (UnsupportedOperationException e) { + Assume.assumeNoException(e); + } finally { + Files.deleteIfExists(socket); + Files.deleteIfExists(pending.toPath()); + Files.deleteIfExists(staging.toPath()); + Files.deleteIfExists(root.toPath()); + } + } + + @Test + public void recoveryRestoresTheSoleVerifiedStagingBackupWhenTargetIsMissing() throws Exception { + File root = temporaryFolder.newFolder("staging-backup-restore-root"); + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + File target = new File(staging, entry.id); + writeManagedDatapack(target, entry, "old"); + File backup = new File(staging, ".backup-managed-" + UUID.randomUUID()); + Files.move(target.toPath(), backup.toPath()); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertEquals("old", Files.readString(new File(target, "value.txt").toPath(), + StandardCharsets.UTF_8)); + assertFalse(backup.exists()); + } + + @Test + public void recoveryRetiresVerifiedStagingBackupBesideAValidTarget() throws Exception { + File root = temporaryFolder.newFolder("staging-backup-retire-root"); + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha"); + File target = new File(staging, entry.id); + File backup = new File(staging, ".backup-managed-" + UUID.randomUUID()); + writeManagedDatapack(target, entry, "new"); + writeManagedDatapack(backup, entry, "old"); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertEquals("new", Files.readString(new File(target, "value.txt").toPath(), + StandardCharsets.UTF_8)); + assertFalse(backup.exists()); + } + + @Test + public void recoveryPreservesAmbiguousStagingBackups() throws Exception { + File root = temporaryFolder.newFolder("staging-backup-ambiguous-root"); + File staging = new File(root, "staging"); + assertTrue(staging.mkdirs()); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + File first = new File(staging, ".backup-managed-" + UUID.randomUUID()); + File second = new File(staging, ".backup-managed-" + UUID.randomUUID()); + writeManagedDatapack(first, entry, "first"); + writeManagedDatapack(second, entry, "second"); + + try { + DatapackIngestService.recoverTransactions(root, List.of()); + fail("Expected ambiguous staging backups to be preserved"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("Ambiguous")); + } + assertTrue(first.isDirectory()); + assertTrue(second.isDirectory()); + assertFalse(new File(staging, entry.id).exists()); + } + + private LegacyStagingFixture legacyStagingFixture( + String name, + boolean committed, + boolean matchingUrl, + boolean legacyTarget + ) throws Exception { + return legacyStagingFixture(name, committed, matchingUrl, legacyTarget, false); + } + + private LegacyStagingFixture legacyStagingFixture( + String name, + boolean committed, + boolean matchingUrl, + boolean legacyTarget, + boolean sameMetadata + ) throws Exception { + File root = temporaryFolder.newFolder(name).toPath().toRealPath().toFile(); + File stagingRoot = new File(root, "staging"); + assertTrue(stagingRoot.mkdir()); + DatapackIngestService.Entry desired = sameMetadata + ? entry("managed", "v1", "1", "old-sha") + : entry("managed", "v2", "2", "new-sha"); + if (committed) { + DatapackIngestService.Entry prior = entry("managed", "v1", "1", "old-sha"); + if (!matchingUrl) { + prior.url = "https://example.test/different-owner.zip"; + } + writeManifest(root, prior); + } else { + writeManifest(root, null); + } + File target = new File(stagingRoot, desired.id); + if (legacyTarget) { + writeLegacyDatapack(target, sameMetadata ? "same" : "old"); + } + File source = new File(stagingRoot, ".pending-" + desired.id + "-" + UUID.randomUUID()); + writeManagedDatapack(source, desired, sameMetadata ? "same" : "new"); + DatapackIngestService.VerifiedStagingInstall authorization = + DatapackIngestService.authorizeVerifiedStagingInstall(root, stagingRoot, source, desired); + return new LegacyStagingFixture( + root, stagingRoot, target, source, desired, ownershipHash(source), authorization); + } + + private void writeLegacyDatapack(File directory, String value) throws Exception { + assertTrue(directory.mkdirs()); + Files.writeString(new File(directory, "pack.mcmeta").toPath(), """ + {"pack":{"description":"test","pack_format":88}} + """, StandardCharsets.UTF_8); + Files.writeString(new File(directory, "value.txt").toPath(), value, StandardCharsets.UTF_8); + Files.writeString(new File(directory, ".DS_Store").toPath(), "legacy", StandardCharsets.UTF_8); + } + + private DatapackIngestService.InstallPlan prepareLegacyStagingPlan( + LegacyStagingFixture fixture + ) throws Exception { + return DatapackIngestService.prepareInstall( + fixture.source(), + fixture.stagingRoot(), + fixture.desired(), + fixture.sourceHash(), + false, + fixture.authorization() + ); + } + + private void assertLegacyStagingPreparationRejected( + LegacyStagingFixture fixture, + String expectedMessage + ) throws Exception { + try { + prepareLegacyStagingPlan(fixture); + fail("Expected legacy staging preparation to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains(expectedMessage)); + } + } + + private void assertInstallPublicationRejected( + DatapackIngestService.InstallPlan plan, + String expectedMessage + ) throws Exception { + try { + DatapackIngestService.publishInstallPlan(plan); + fail("Expected changed install participant to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains(expectedMessage)); + } + } + + private File datapackDirectory(String name) throws Exception { + File directory = temporaryFolder.newFolder(name); + Files.writeString(new File(directory, "pack.mcmeta").toPath(), """ + {"pack":{"description":"test","pack_format":88}} + """, StandardCharsets.UTF_8); + return directory; + } + + private void writeManagedDatapack(File directory, DatapackIngestService.Entry entry) throws Exception { + assertTrue(directory.mkdirs()); + Files.writeString(new File(directory, "pack.mcmeta").toPath(), """ + {"pack":{"description":"test","pack_format":88}} + """, StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(directory, entry); + } + + private void writeManagedDatapack( + File directory, + DatapackIngestService.Entry entry, + String value + ) throws Exception { + assertTrue(directory.mkdirs()); + Files.writeString(new File(directory, "pack.mcmeta").toPath(), """ + {"pack":{"description":"test","pack_format":88}} + """, StandardCharsets.UTF_8); + Files.writeString(new File(directory, "value.txt").toPath(), value, StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(directory, entry); + } + + private DatapackIngestService.Entry entry( + String id, + String versionId, + String versionNumber, + String sha1 + ) { + DatapackIngestService.Entry entry = new DatapackIngestService.Entry(); + entry.id = id; + entry.url = "https://example.test/" + id + ".zip"; + entry.versionId = versionId; + entry.versionNumber = versionNumber; + entry.sha1 = sha1; + return entry; + } + + private void writeManifest(File root, DatapackIngestService.Entry entry) throws Exception { + Map manifest = new LinkedHashMap<>(); + manifest.put("entries", entry == null ? List.of() : List.of(entry)); + Files.writeString(new File(root, "manifest.json").toPath(), new Gson().toJson(manifest), + StandardCharsets.UTF_8); + } + + private String ownershipHash(File directory) throws Exception { + String marker = Files.readString(new File(directory, ".iris-managed.json").toPath(), StandardCharsets.UTF_8); + return JsonParser.parseString(marker).getAsJsonObject().get("contentHash").getAsString(); + } + + private Map installDirectory( + File target, + File pending, + File backup, + boolean hadTarget, + String originalHash, + String desiredHash + ) throws Exception { + Map directory = new LinkedHashMap<>(); + directory.put("target", target.getAbsolutePath()); + directory.put("pending", pending.getAbsolutePath()); + directory.put("backup", backup.getAbsolutePath()); + directory.put("hadTarget", hadTarget); + directory.put("originalHash", originalHash); + directory.put("desiredHash", desiredHash); + File original = backup.isDirectory() ? backup : target; + File desired = pending.isDirectory() ? pending : target; + directory.put("originalMarkerHash", hadTarget + ? DatapackIngestService.ownershipMarkerFingerprint(original) : "absent"); + directory.put("desiredMarkerHash", DatapackIngestService.ownershipMarkerFingerprint(desired)); + directory.put("originalIdentity", hadTarget + ? DatapackIngestService.directoryIdentity(original) : ""); + directory.put("desiredIdentity", DatapackIngestService.directoryIdentity(desired)); + directory.put("targetRoot", target.getParentFile().toPath().toRealPath().toString()); + directory.put("scratchRoot", backup.getParentFile().toPath().toRealPath().toString()); + directory.put("targetRootIdentity", DatapackIngestService.directoryIdentity(target.getParentFile())); + directory.put("scratchRootIdentity", DatapackIngestService.directoryIdentity(backup.getParentFile())); + return directory; + } + + private Map removalDirectory(File target, File backup, String originalHash) throws Exception { + Map directory = new LinkedHashMap<>(); + directory.put("target", target.getAbsolutePath()); + directory.put("pending", ""); + directory.put("backup", backup.getAbsolutePath()); + directory.put("hadTarget", true); + directory.put("originalHash", originalHash); + directory.put("desiredHash", ""); + File original = backup.isDirectory() ? backup : target; + directory.put("originalMarkerHash", DatapackIngestService.ownershipMarkerFingerprint(original)); + directory.put("desiredMarkerHash", ""); + directory.put("originalIdentity", DatapackIngestService.directoryIdentity(original)); + directory.put("desiredIdentity", ""); + directory.put("targetRoot", target.getParentFile().toPath().toRealPath().toString()); + directory.put("scratchRoot", backup.getParentFile().toPath().toRealPath().toString()); + directory.put("targetRootIdentity", DatapackIngestService.directoryIdentity(target.getParentFile())); + directory.put("scratchRootIdentity", DatapackIngestService.directoryIdentity(backup.getParentFile())); + return directory; + } + + private Map editableParticipant( + StructureTransactionWriter.PreparedRemovalToken token, + UUID claimId + ) { + Map editable = new LinkedHashMap<>(); + editable.put("packRoot", token.packRoot().toString()); + editable.put("transactionId", token.transactionId().toString()); + editable.put("claimId", claimId.toString()); + return editable; + } + + private File writeCoordinator( + File root, + String operation, + String phase, + DatapackIngestService.Entry entry, + boolean manifestAlreadyMatched, + List> directories, + List> editables + ) throws Exception { + UUID transactionId = UUID.randomUUID(); + File transaction = new File(new File(root, ".iris-datapack-transactions"), transactionId.toString()); + assertTrue(transaction.mkdirs()); + Map journal = new LinkedHashMap<>(); + journal.put("schemaVersion", 2); + journal.put("transactionId", transactionId.toString()); + journal.put("operation", operation); + journal.put("phase", phase); + journal.put("id", entry.id); + journal.put("url", entry.url); + journal.put("versionId", entry.versionId); + journal.put("versionNumber", entry.versionNumber); + journal.put("sha1", entry.sha1); + journal.put("manifestAlreadyMatched", manifestAlreadyMatched); + journal.put("directories", directories); + journal.put("editables", editables); + Files.writeString(new File(transaction, "journal.json").toPath(), new Gson().toJson(journal), + StandardCharsets.UTF_8); + return transaction; + } + + private StructureResourceBundle importedBundle( + StructureKey targetKey, + StructureKey sourceKey, + String content + ) { + return StructureResourceBundle.builder(targetKey) + .source(StructureSource.of(StructureSource.Kind.DATAPACK, sourceKey)) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capability(StructureCapability.BLOCKS) + .resource("objects/" + targetKey.path() + ".iob", content.getBytes(StandardCharsets.UTF_8)) + .textResource("structures/" + targetKey.path() + ".json", content) + .build(); + } + + private EditableRecoveryFixture editableRecoveryFixture( + String name, + String operation, + String phase, + boolean manifestPresent, + boolean writeClaim, + boolean matchingClaim + ) throws Exception { + File root = temporaryFolder.newFolder(name + "-root"); + File editablePack = temporaryFolder.newFolder(name + "-editable"); + StructureKey targetKey = StructureKey.parse("iris:owned"); + StructureKey sourceKey = StructureKey.parse("example:owned"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + StructureWriteResult write = writer.write( + importedBundle(targetKey, sourceKey, "owned"), + StructureWriteMode.ADD_ONLY + ); + assertEquals(StructureWriteResult.Status.ADDED, write.status()); + StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of( + new StructureTransactionWriter.OwnedRemoval( + targetKey, + StructureSource.Kind.DATAPACK, + sourceKey + ) + )); + StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + entry.importedTargets.put(token.packRoot().toString(), "revision"); + entry.importedBundles.put(token.packRoot().toString(), Map.of("iris:owned", "example:owned")); + writeManifest(root, manifestPresent ? entry : null); + UUID journalClaimId = UUID.randomUUID(); + Map editable = editableParticipant(token, journalClaimId); + File coordinator = writeCoordinator(root, operation, phase, entry, true, + List.of(), List.of(editable)); + if (writeClaim) { + UUID actualClaimId = matchingClaim ? journalClaimId : UUID.randomUUID(); + removal.claimRecoveryOwner(new StructureTransactionWriter.RecoveryOwner( + coordinator.toPath(), + UUID.fromString(coordinator.getName()), + actualClaimId + )); + } + removal.leaveForRecovery(); + Path backup = new File( + editablePack, + ".iris/structure-staging/" + token.transactionId() + "/backup/objects/owned.iob" + ).toPath(); + return new EditableRecoveryFixture(root, editablePack, coordinator, backup); + } + + private void assertRecoveryRejected(EditableRecoveryFixture fixture, String expectedMessage) throws Exception { + try { + DatapackIngestService.recoverTransactions(fixture.root(), List.of()); + fail("Expected editable recovery to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains(expectedMessage)); + } + assertTrue(fixture.coordinator().exists()); + assertTrue(Files.exists(fixture.backup())); + assertFalse(Files.exists(new File(fixture.editablePack(), "objects/owned.iob").toPath())); + } + + private record EditableRecoveryFixture( + File root, + File editablePack, + File coordinator, + Path backup + ) { + } + + private record LegacyStagingFixture( + File root, + File stagingRoot, + File target, + File source, + DatapackIngestService.Entry desired, + String sourceHash, + DatapackIngestService.VerifiedStagingInstall authorization + ) { } } diff --git a/core/src/test/java/art/arcane/iris/core/datapack/ModrinthResolverTest.java b/core/src/test/java/art/arcane/iris/core/datapack/ModrinthResolverTest.java new file mode 100644 index 000000000..857059b5c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/datapack/ModrinthResolverTest.java @@ -0,0 +1,144 @@ +package art.arcane.iris.core.datapack; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ModrinthResolverTest { + @Test + public void latestVersionMustMatchServerVersion() { + JsonArray versions = JsonParser.parseString(""" + [ + {"id":"new","loaders":["datapack"],"game_versions":["26.3"]}, + {"id":"match","loaders":["datapack"],"game_versions":["26.2"]} + ] + """).getAsJsonArray(); + + JsonObject selected = ModrinthResolver.selectLatestDatapackVersion(versions, "26.2"); + + assertEquals("match", selected.get("id").getAsString()); + } + + @Test + public void incompatibleLatestVersionDoesNotFallBack() { + JsonArray versions = JsonParser.parseString(""" + [{"id":"wrong","loaders":["datapack"],"game_versions":["26.3"]}] + """).getAsJsonArray(); + + assertNull(ModrinthResolver.selectLatestDatapackVersion(versions, "26.2")); + } + + @Test + public void malformedVersionElementsAreIgnored() { + JsonArray versions = JsonParser.parseString(""" + [ + null, + 12, + {"id":"wrong-loaders","loaders":{},"game_versions":["26.2"]}, + {"id":"wrong-loader-element","loaders":[{}],"game_versions":["26.2"]}, + {"id":"wrong-versions","loaders":["datapack"],"game_versions":{}}, + {"id":"wrong-version-element","loaders":["datapack"],"game_versions":[{}]}, + {"id":"match","loaders":["datapack"],"game_versions":["26.2"]} + ] + """).getAsJsonArray(); + + JsonObject selected = ModrinthResolver.selectLatestDatapackVersion(versions, "26.2"); + + assertEquals("match", selected.get("id").getAsString()); + } + + @Test + public void malformedFileElementsAreIgnored() { + JsonObject version = JsonParser.parseString(""" + { + "files": [ + null, + "not-an-object", + {"primary": {}, "filename": {}, "url": {}}, + {"primary": true, "filename": "pack.zip", "url": "https://example.test/pack.zip"} + ] + } + """).getAsJsonObject(); + + JsonObject selected = ModrinthResolver.selectFile(version); + + assertEquals("pack.zip", selected.get("filename").getAsString()); + } + + @Test + public void malformedFilesCollectionReturnsNoFile() { + JsonObject version = JsonParser.parseString("{\"files\": {}}").getAsJsonObject(); + + assertNull(ModrinthResolver.selectFile(version)); + } + + @Test + public void directUrlsWithSameFilenameHaveDistinctIdentities() throws Exception { + ModrinthResolver.ResolvedDatapack first = ModrinthResolver.resolve("https://one.example/files/pack.zip", "26.2"); + ModrinthResolver.ResolvedDatapack second = ModrinthResolver.resolve("https://two.example/files/pack.zip", "26.2"); + + assertTrue(first.isDirect()); + assertTrue(second.isDirect()); + assertNotEquals(first.getVersionId(), second.getVersionId()); + } + + @Test + public void modrinthTextOutsideTheHostDoesNotTriggerApiResolution() throws Exception { + ModrinthResolver.ResolvedDatapack resolved = ModrinthResolver.resolve( + "https://example.test/modrinth.com/datapack/not-a-project", "26.2"); + + assertTrue(resolved.isDirect()); + assertFalse(resolved.getVersionId().isBlank()); + } + + @Test + public void boundedApiReaderAcceptsTheExactCharacterLimit() throws Exception { + String response = "1234\n5678"; + + assertEquals(response, ModrinthResolver.readBoundedApiResponse( + new StringReader(response), "test response", response.length())); + } + + @Test + public void boundedApiReaderRejectsAnUnbrokenResponseBeforeReadingPastItsFirstBoundedChunk() throws Exception { + Reader response = new UnbrokenResponseReader(); + + try { + ModrinthResolver.readBoundedApiResponse(response, "test response", 32); + fail("Expected oversized response rejection"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("Oversized response")); + } + } + + private static final class UnbrokenResponseReader extends Reader { + private boolean read; + + @Override + public int read(char[] buffer, int offset, int length) throws IOException { + if (read) { + throw new IOException("Reader continued after the bounded chunk"); + } + read = true; + Arrays.fill(buffer, offset, offset + length, 'x'); + return length; + } + + @Override + public void close() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java new file mode 100644 index 000000000..35bd7c250 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java @@ -0,0 +1,118 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +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 BukkitWorldConfigurationTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void registersAndRemovesWorldAtomically() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + + assertEquals(BukkitWorldConfiguration.Registration.CREATED, + BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L)); + assertEquals(BukkitWorldConfiguration.Registration.UNCHANGED, + BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L)); + + YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configuration); + assertEquals("Iris:overworld", loaded.getString("worlds.probe.generator")); + assertEquals(1337L, loaded.getLong("worlds.probe.seed")); + assertTrue(BukkitWorldConfiguration.remove(configuration, "probe")); + assertFalse(BukkitWorldConfiguration.remove(configuration, "probe")); + assertNull(YamlConfiguration.loadConfiguration(configuration).getConfigurationSection("worlds")); + } + + @Test + public void conflictingRegistrationLeavesOriginalUntouched() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L); + + IOException failure = assertThrows(IOException.class, + () -> BukkitWorldConfiguration.register(configuration, "probe", "theend", 42L)); + + assertTrue(failure.getMessage().contains("different definition")); + YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configuration); + assertEquals("Iris:overworld", loaded.getString("worlds.probe.generator")); + assertEquals(1337L, loaded.getLong("worlds.probe.seed")); + } + + @Test + public void malformedConfigurationIsPreserved() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + String malformed = "worlds: [unterminated"; + Files.writeString(configuration.toPath(), malformed); + + IOException failure = assertThrows(IOException.class, + () -> BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L)); + + assertTrue(failure.getMessage().contains("invalid")); + assertEquals(malformed, Files.readString(configuration.toPath())); + } + + @Test + public void unsafeWorldNameIsRejected() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + + assertThrows(IllegalArgumentException.class, + () -> BukkitWorldConfiguration.register(configuration, "nested.world", "overworld", 1337L)); + assertEquals(0L, configuration.length()); + } + + @Test + public void matchingRemovalIsCommittedOnce() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + BukkitWorldConfiguration.register(configuration, "iris-one", "overworld", null); + BukkitWorldConfiguration.register(configuration, "iris-two", "overworld", null); + BukkitWorldConfiguration.register(configuration, "persistent", "overworld", null); + + assertEquals(2, BukkitWorldConfiguration.removeMatching( + configuration, + worldName -> worldName.startsWith("iris-"))); + + YamlConfiguration loaded = YamlConfiguration.loadConfiguration(configuration); + assertNull(loaded.get("worlds.iris-one")); + assertNull(loaded.get("worlds.iris-two")); + assertEquals("Iris:overworld", loaded.getString("worlds.persistent.generator")); + } + + @Test + public void conditionalRemovalPreservesChangedRegistration() throws Exception { + File configuration = temporaryFolder.newFile("bukkit.yml"); + BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L); + + assertFalse(BukkitWorldConfiguration.removeIfMatching( + configuration, + "probe", + "overworld", + 42L)); + assertFalse(BukkitWorldConfiguration.removeIfMatching( + configuration, + "probe", + "theend", + 1337L)); + + YamlConfiguration preserved = YamlConfiguration.loadConfiguration(configuration); + assertEquals("Iris:overworld", preserved.getString("worlds.probe.generator")); + assertEquals(1337L, preserved.getLong("worlds.probe.seed")); + assertTrue(BukkitWorldConfiguration.removeIfMatching( + configuration, + "probe", + "overworld", + 1337L)); + assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalServiceTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalServiceTest.java new file mode 100644 index 000000000..f61689501 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalServiceTest.java @@ -0,0 +1,386 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.core.WorldRemovalPathPolicy; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class IrisWorldRemovalServiceTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void reportsBusyOperationWithoutStartingBackend() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("busy")); + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + try (LifecycleOperationCoordinator.Lease existing = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "other" + )) { + IrisWorldRemovalService.RemovalResult result = service.remove("busy", true).join(); + + assertEquals(IrisWorldRemovalService.RemovalStatus.BUSY, result.status()); + assertTrue(result.busy()); + assertEquals(existing.operation(), result.blockingOperation()); + assertFalse(backend.resolvedTarget); + } + } + + @Test + public void holdsLeaseUntilEveryAsyncPhaseCompletes() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("serialized")); + CompletableFuture evacuation = new CompletableFuture<>(); + backend.evacuation = evacuation; + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + CompletableFuture pending = service.remove("serialized", false); + IrisWorldRemovalService.RemovalResult busy = service.remove("second", false).join(); + + assertFalse(pending.isDone()); + assertEquals(IrisWorldRemovalService.RemovalStatus.BUSY, busy.status()); + evacuation.complete(null); + + IrisWorldRemovalService.RemovalResult result = pending.join(); + assertEquals(IrisWorldRemovalService.RemovalStatus.UNREGISTERED, result.status()); + assertTrue(result.succeeded()); + assertTrue(result.registryChanged()); + assertEquals( + List.of("resolve", "begin", "evacuate", "unload", "close", "unregister", "unregisterRegistry", "end"), + backend.operations + ); + assertTrue(coordinator.isIdle()); + } + + @Test + public void configurationFailureStopsBeforeDeletionAndRetainsCause() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("configuration-failure")); + IOException failure = new IOException("bukkit.yml is read-only"); + backend.unregister = CompletableFuture.completedFuture( + new IrisWorldRemovalService.ConfigurationDisposition(false, failure) + ); + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + IrisWorldRemovalService.RemovalResult result = service.remove("configuration-failure", true).join(); + + assertEquals(IrisWorldRemovalService.RemovalStatus.CONFIGURATION_FAILED, result.status()); + assertSame(failure, result.failure()); + assertFalse(result.succeeded()); + assertFalse(backend.operations.contains("delete")); + assertFalse(backend.operations.contains("unregisterRegistry")); + assertFalse(result.registryChanged()); + assertTrue(coordinator.isIdle()); + } + + @Test + public void queuedDeletionIsSuccessfulAfterRegistryRemoval() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + WorldRemovalPathPolicy.Target target = target("queued"); + FakeBackend backend = new FakeBackend(target); + Path quarantine = target.worldDirectory().resolveSibling(".iris-delete-test"); + backend.deletion = CompletableFuture.completedFuture( + new IrisWorldRemovalService.DeleteDisposition(true, quarantine) + ); + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + IrisWorldRemovalService.RemovalResult result = service.remove("queued", true).join(); + + assertEquals(IrisWorldRemovalService.RemovalStatus.DELETE_QUEUED, result.status()); + assertTrue(result.succeeded()); + assertTrue(result.deletionDeferred()); + assertEquals(quarantine, result.quarantineDirectory()); + assertTrue(result.registryChanged()); + } + + @Test + public void registryFailureStopsBeforeFilesystemDeletion() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("registry-failure")); + IOException failure = new IOException("worlds.json is read-only"); + backend.unregisterRegistry = CompletableFuture.failedFuture(failure); + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + IrisWorldRemovalService.RemovalResult result = service.remove("registry-failure", true).join(); + + assertEquals(IrisWorldRemovalService.RemovalStatus.REGISTRY_FAILED, result.status()); + assertSame(failure, result.failure()); + assertTrue(result.configurationChanged()); + assertFalse(result.registryChanged()); + assertFalse(backend.operations.contains("delete")); + assertTrue(coordinator.isIdle()); + } + + @Test + public void deletionFailureRetainsCompletedUnregistrationState() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + WorldRemovalPathPolicy.Target target = target("delete-failure"); + FakeBackend backend = new FakeBackend(target); + IOException failure = new IOException("cannot quarantine directory"); + backend.deletion = CompletableFuture.failedFuture(failure); + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + IrisWorldRemovalService.RemovalResult result = service.remove("delete-failure", true).join(); + + assertEquals(IrisWorldRemovalService.RemovalStatus.QUARANTINE_FAILED, result.status()); + assertSame(failure, result.failure()); + assertTrue(result.configurationChanged()); + assertTrue(result.registryChanged()); + } + + @Test + public void unloadRefusalLeavesGeneratorOpenAndEndsMaintenance() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("unload-refused")); + backend.unload = CompletableFuture.completedFuture(false); + IrisWorldRemovalService service = new IrisWorldRemovalService(coordinator, backend); + + IrisWorldRemovalService.RemovalResult result = service.remove("unload-refused", true).join(); + + assertEquals(IrisWorldRemovalService.RemovalStatus.UNLOAD_FAILED, result.status()); + assertFalse(backend.operations.contains("close")); + assertFalse(backend.operations.contains("unregister")); + assertEquals("end", backend.operations.get(backend.operations.size() - 1)); + assertTrue(coordinator.isIdle()); + } + + @Test + public void timeoutInstallsRestartFenceBeforeResultAndRejectsLateContinuation() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("timeout-fence")); + NonCancellableFuture unload = new NonCancellableFuture<>(); + backend.unload = unload; + AtomicInteger restartDispatches = new AtomicInteger(); + AtomicBoolean fenceInstalledBeforeResult = new AtomicBoolean(false); + AtomicReference> resultReference = + new AtomicReference<>(); + IrisWorldRemovalService service = new IrisWorldRemovalService( + coordinator, + new IrisWorldRemovalService.ServiceOptions( + backend, + 100L, + reason -> { + assertTrue(coordinator.quiesceForRestart(restartDispatches::incrementAndGet)); + CompletableFuture result = resultReference.get(); + fenceInstalledBeforeResult.set( + coordinator.active(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE).isPresent() + && (result == null || !result.isDone())); + } + ) + ); + + CompletableFuture pending = + service.remove("timeout-fence", false); + resultReference.set(pending); + IrisWorldRemovalService.RemovalResult result = pending.get(2L, TimeUnit.SECONDS); + + assertEquals(IrisWorldRemovalService.RemovalStatus.UNLOAD_FAILED, result.status()); + assertTrue(fenceInstalledBeforeResult.get()); + assertEquals(1, restartDispatches.get()); + assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART, + coordinator.active(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE) + .orElseThrow() + .kind()); + + IrisWorldRemovalService.RemovalResult blocked = service.remove("second", false).join(); + assertEquals(IrisWorldRemovalService.RemovalStatus.BUSY, blocked.status()); + assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART, + blocked.blockingOperation().kind()); + + unload.complete(true); + assertFalse(backend.operations.contains("close")); + assertFalse(backend.operations.contains("unregister")); + assertFalse(backend.operations.contains("delete")); + } + + @Test + public void terminalTimeoutStopsDeletionAfterIntentCompletion() throws Exception { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + FakeBackend backend = new FakeBackend(target("delete-terminal")); + backend.stageDeletion = true; + AtomicInteger restartDispatches = new AtomicInteger(); + IrisWorldRemovalService service = new IrisWorldRemovalService( + coordinator, + new IrisWorldRemovalService.ServiceOptions( + backend, + 100L, + reason -> coordinator.quiesceForRestart(restartDispatches::incrementAndGet) + ) + ); + + IrisWorldRemovalService.RemovalResult result = service + .remove("delete-terminal", true) + .get(2L, TimeUnit.SECONDS); + + assertEquals(IrisWorldRemovalService.RemovalStatus.QUARANTINE_FAILED, result.status()); + assertTrue(backend.operations.contains("deleteIntent")); + assertEquals(1, restartDispatches.get()); + + backend.deletionIntent.complete(null); + assertFalse(backend.operations.contains("quarantine")); + assertFalse(backend.operations.contains("deleteQuarantine")); + } + + private WorldRemovalPathPolicy.Target target(String identifier) throws Exception { + Path levelRoot = temporaryFolder.newFolder(identifier + "-level").toPath(); + return WorldRemovalPathPolicy.resolve(identifier, "production", levelRoot); + } + + private static final class FakeBackend implements IrisWorldRemovalService.RemovalBackend { + private final WorldRemovalPathPolicy.Target target; + private final List operations; + private boolean resolvedTarget; + private CompletableFuture evacuation; + private CompletableFuture unload; + private CompletableFuture unregister; + private CompletableFuture unregisterRegistry; + private CompletableFuture deletion; + private final NonCancellableFuture deletionIntent; + private boolean stageDeletion; + + private FakeBackend(WorldRemovalPathPolicy.Target target) { + this.target = target; + operations = new ArrayList<>(); + evacuation = CompletableFuture.completedFuture(null); + unload = CompletableFuture.completedFuture(true); + unregister = CompletableFuture.completedFuture( + new IrisWorldRemovalService.ConfigurationDisposition(true, null) + ); + unregisterRegistry = CompletableFuture.completedFuture(true); + deletion = CompletableFuture.completedFuture( + new IrisWorldRemovalService.DeleteDisposition(false, null) + ); + deletionIntent = new NonCancellableFuture<>(); + } + + @Override + public WorldRemovalPathPolicy.Target resolveTarget(String identifier) { + resolvedTarget = true; + return target; + } + + @Override + public CompletableFuture resolve( + WorldRemovalPathPolicy.Target ignored + ) { + operations.add("resolve"); + return CompletableFuture.completedFuture(new IrisWorldRemovalService.ResolvedWorld( + target, + null, + null, + true, + false, + false, + true + )); + } + + @Override + public CompletableFuture evacuatePlayers(IrisWorldRemovalService.ResolvedWorld resolvedWorld) { + operations.add("evacuate"); + return evacuation; + } + + @Override + public CompletableFuture beginMaintenance(IrisWorldRemovalService.ResolvedWorld resolvedWorld) { + operations.add("begin"); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture endMaintenance(IrisWorldRemovalService.ResolvedWorld resolvedWorld) { + operations.add("end"); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture closeGenerator(IrisWorldRemovalService.ResolvedWorld resolvedWorld) { + operations.add("close"); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture unloadWorld(IrisWorldRemovalService.ResolvedWorld resolvedWorld) { + operations.add("unload"); + return unload; + } + + @Override + public CompletableFuture unregister( + WorldRemovalPathPolicy.Target ignored, + BooleanSupplier terminal + ) { + operations.add("unregister"); + return unregister; + } + + @Override + public CompletableFuture unregisterRegistry( + WorldRemovalPathPolicy.Target ignored, + BooleanSupplier terminal + ) { + operations.add("unregisterRegistry"); + return unregisterRegistry; + } + + @Override + public CompletableFuture delete( + WorldRemovalPathPolicy.Target ignored, + BooleanSupplier terminal + ) { + operations.add("delete"); + if (stageDeletion) { + operations.add("deleteIntent"); + NonCancellableFuture result = + new NonCancellableFuture<>(); + deletionIntent.whenComplete((ignoredValue, throwable) -> { + if (throwable != null) { + result.completeExceptionally(throwable); + return; + } + if (terminal.getAsBoolean()) { + result.completeExceptionally(new IllegalStateException( + "terminal after deletion intent")); + return; + } + operations.add("quarantine"); + if (terminal.getAsBoolean()) { + result.completeExceptionally(new IllegalStateException( + "terminal after quarantine")); + return; + } + operations.add("deleteQuarantine"); + result.complete(new IrisWorldRemovalService.DeleteDisposition(false, null)); + }); + return result; + } + return deletion; + } + } + + private static final class NonCancellableFuture extends CompletableFuture { + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return false; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinatorTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinatorTest.java new file mode 100644 index 000000000..fdfc97a2b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/LifecycleOperationCoordinatorTest.java @@ -0,0 +1,304 @@ +package art.arcane.iris.core.lifecycle; + +import org.junit.Test; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicIntegerArray; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class LifecycleOperationCoordinatorTest { + @Test + public void getReturnsSingletonInstance() { + assertSame(LifecycleOperationCoordinator.get(), LifecycleOperationCoordinator.get()); + } + + @Test + public void duplicateDomainIsRejectedWithCurrentOperationMetadata() { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease lease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "world-one"); + + LifecycleOperationCoordinator.BusyException exception = assertThrows( + LifecycleOperationCoordinator.BusyException.class, + () -> coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE, + "world-two")); + + assertEquals(lease.operation(), exception.currentOperation()); + assertEquals(lease.operation().id(), exception.operationId()); + assertEquals(LifecycleOperationCoordinator.Domain.WORLD_MUTATION, exception.domain()); + assertEquals(LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, exception.operationKind()); + assertEquals("world-one", exception.target()); + lease.close(); + } + + @Test + public void differentDomainsConflictWithCurrentOperationMetadata() { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease worldLease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "world-one"); + + LifecycleOperationCoordinator.BusyException exception = assertThrows( + LifecycleOperationCoordinator.BusyException.class, + () -> coordinator.acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_CREATE, + "pack-one")); + + Map snapshot = coordinator.snapshot(); + assertEquals(1, snapshot.size()); + assertEquals(worldLease.operation(), snapshot.get(LifecycleOperationCoordinator.Domain.WORLD_MUTATION)); + assertEquals(Optional.of(worldLease.operation()), coordinator.active(LifecycleOperationCoordinator.Domain.WORLD_MUTATION)); + assertEquals(Optional.empty(), coordinator.active(LifecycleOperationCoordinator.Domain.PACK_MUTATION)); + assertEquals(worldLease.operation(), exception.currentOperation()); + assertFalse(coordinator.isIdle()); + + worldLease.close(); + assertTrue(coordinator.isIdle()); + } + + @Test + public void closeReleasesDomainForNextOperation() { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease firstLease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_CREATE, + "pack-one"); + firstLease.close(); + + LifecycleOperationCoordinator.Lease secondLease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, + "pack-two"); + + assertTrue(firstLease.isClosed()); + assertFalse(secondLease.isClosed()); + assertTrue(secondLease.operation().id() > firstLease.operation().id()); + secondLease.close(); + } + + @Test + public void closingLeaseTwiceIsHarmless() { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease lease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE, + "world-one"); + + lease.close(); + lease.close(); + + assertTrue(lease.isClosed()); + assertTrue(coordinator.isIdle()); + } + + @Test + public void idleCallbackRunsImmediatelyExactlyOnce() { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + AtomicInteger callbackCount = new AtomicInteger(); + + coordinator.whenIdle(callbackCount::incrementAndGet); + + assertEquals(1, callbackCount.get()); + } + + @Test + public void idleCallbackWaitsForActiveMutation() { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease worldLease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "world-one"); + AtomicInteger callbackCount = new AtomicInteger(); + + coordinator.whenIdle(callbackCount::incrementAndGet); + assertEquals(0, callbackCount.get()); + worldLease.close(); + assertEquals(1, callbackCount.get()); + } + + @Test + public void idleCallbackAcquiresBeforeACompetingMutationCanEnter() { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease initialLease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "world-one"); + AtomicReference callbackLease = new AtomicReference<>(); + coordinator.whenIdle(() -> callbackLease.set(coordinator.acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_CREATE, + "pack-one"))); + + initialLease.close(); + + LifecycleOperationCoordinator.Lease reservedLease = callbackLease.get(); + assertEquals(LifecycleOperationCoordinator.Domain.PACK_MUTATION, reservedLease.operation().domain()); + LifecycleOperationCoordinator.BusyException exception = assertThrows( + LifecycleOperationCoordinator.BusyException.class, + () -> coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE, + "world-two")); + assertEquals(reservedLease.operation(), exception.currentOperation()); + reservedLease.close(); + } + + @Test + public void quiesceBlocksNewMutationsBeforeDrainAndThroughRestartDispatch() throws InterruptedException { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease activeLease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "world-one"); + CountDownLatch dispatchStarted = new CountDownLatch(1); + CountDownLatch allowDispatchCompletion = new CountDownLatch(1); + + assertTrue(coordinator.quiesceForRestart(() -> { + dispatchStarted.countDown(); + await(allowDispatchCompletion); + })); + + LifecycleOperationCoordinator.BusyException drainingException = assertThrows( + LifecycleOperationCoordinator.BusyException.class, + () -> coordinator.acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, + "pack-one")); + assertEquals(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE, drainingException.domain()); + assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART, drainingException.operationKind()); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + executor.execute(activeLease::close); + assertTrue(dispatchStarted.await(5L, TimeUnit.SECONDS)); + + LifecycleOperationCoordinator.BusyException dispatchException = assertThrows( + LifecycleOperationCoordinator.BusyException.class, + () -> coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE, + "world-two")); + assertEquals(drainingException.currentOperation(), dispatchException.currentOperation()); + + allowDispatchCompletion.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + assertFalse(coordinator.isIdle()); + assertEquals(Optional.of(drainingException.currentOperation()), + coordinator.active(LifecycleOperationCoordinator.Domain.SERVER_LIFECYCLE)); + assertFalse(coordinator.quiesceForRestart(() -> { + throw new AssertionError("duplicate restart callback must not run"); + })); + LifecycleOperationCoordinator.BusyException terminalException = assertThrows( + LifecycleOperationCoordinator.BusyException.class, + () -> coordinator.acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH, + "pack-two")); + assertEquals(drainingException.currentOperation(), terminalException.currentOperation()); + } + + @Test + public void concurrentIdleCallbacksEachRunExactlyOnce() throws InterruptedException { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease lease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "world-one"); + int callbackTotal = 32; + ExecutorService executor = Executors.newFixedThreadPool(8); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch registered = new CountDownLatch(callbackTotal); + AtomicIntegerArray callbackCounts = new AtomicIntegerArray(callbackTotal); + + for (int callbackIndex = 0; callbackIndex < callbackTotal; callbackIndex++) { + int registeredIndex = callbackIndex; + executor.execute(() -> { + await(start); + coordinator.whenIdle(() -> callbackCounts.incrementAndGet(registeredIndex)); + registered.countDown(); + }); + } + + start.countDown(); + assertTrue(registered.await(5L, TimeUnit.SECONDS)); + lease.close(); + executor.shutdown(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + + for (int callbackIndex = 0; callbackIndex < callbackTotal; callbackIndex++) { + assertEquals(1, callbackCounts.get(callbackIndex)); + } + } + + @Test + public void concurrentDoubleCloseReleasesAndSignalsIdleOnce() throws InterruptedException { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease lease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.PACK_MUTATION, + LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH, + "pack-one"); + AtomicInteger callbackCount = new AtomicInteger(); + coordinator.whenIdle(callbackCount::incrementAndGet); + ExecutorService executor = Executors.newFixedThreadPool(8); + CountDownLatch start = new CountDownLatch(1); + + for (int threadIndex = 0; threadIndex < 16; threadIndex++) { + executor.execute(() -> { + await(start); + lease.close(); + }); + } + + start.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + assertTrue(coordinator.isIdle()); + assertEquals(1, callbackCount.get()); + } + + @Test + public void failingIdleCallbackDoesNotBlockLeaseReleaseOrLaterCallbacks() { + LifecycleOperationCoordinator coordinator = new LifecycleOperationCoordinator(); + LifecycleOperationCoordinator.Lease lease = coordinator.acquire( + LifecycleOperationCoordinator.Domain.WORLD_MUTATION, + LifecycleOperationCoordinator.OperationKind.WORLD_CREATE, + "world-one"); + AtomicInteger callbackCount = new AtomicInteger(); + coordinator.whenIdle(() -> { + throw new IllegalStateException("expected"); + }); + coordinator.whenIdle(callbackCount::incrementAndGet); + + lease.close(); + + assertTrue(coordinator.isIdle()); + assertEquals(1, callbackCount.get()); + } + + private static void await(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(exception); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java new file mode 100644 index 000000000..1bc9099b3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java @@ -0,0 +1,211 @@ +package art.arcane.iris.core.lifecycle; + +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class WorldLifecycleUnloadAsyncTest { + @Test + public void reflectedAsyncUnloadWaitsForTrueCallback() throws Exception { + CallbackServer server = new CallbackServer(); + Method unloadMethod = CallbackServer.class.getMethod( + "unloadWorldAsync", + World.class, + boolean.class, + Consumer.class + ); + + CompletableFuture result = WorldLifecycleSupport.invokeAsyncUnload( + server, + unloadMethod, + mock(World.class), + false + ); + + assertFalse(result.isDone()); + server.complete(true); + assertTrue(result.join()); + } + + @Test + public void reflectedAsyncUnloadWaitsForFalseCallback() throws Exception { + CallbackServer server = new CallbackServer(); + Method unloadMethod = CallbackServer.class.getMethod( + "unloadWorldAsync", + World.class, + boolean.class, + Consumer.class + ); + + CompletableFuture result = WorldLifecycleSupport.invokeAsyncUnload( + server, + unloadMethod, + mock(World.class), + true + ); + + assertFalse(result.isDone()); + server.complete(false); + assertFalse(result.join()); + } + + @Test + public void reflectedAsyncUnloadPropagatesInvocationFailure() throws Exception { + FailingServer server = new FailingServer(); + Method unloadMethod = FailingServer.class.getMethod( + "unloadWorldAsync", + World.class, + boolean.class, + Consumer.class + ); + + CompletableFuture result = WorldLifecycleSupport.invokeAsyncUnload( + server, + unloadMethod, + mock(World.class), + false + ); + + CompletionException failure = assertThrows(CompletionException.class, result::join); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertEquals("unload failed", failure.getCause().getMessage()); + } + + @Test + public void serviceRemovesRememberedBackendOnlyAfterTrueCompletion() { + ControlledBackend remembered = new ControlledBackend("remembered"); + ControlledBackend fallback = new ControlledBackend("fallback"); + WorldLifecycleService service = service(remembered, fallback); + NamespacedKey worldKey = new NamespacedKey("iris", "async_true"); + World world = world(worldKey); + service.rememberBackend(worldKey, remembered.backendName()); + + CompletableFuture result = service.unloadAsync(world, false); + + assertFalse(result.isDone()); + assertEquals("remembered", service.backendNameForWorld(worldKey)); + remembered.complete(true); + assertTrue(result.join()); + assertEquals("fallback", service.backendNameForWorld(worldKey)); + } + + @Test + public void serviceRetainsRememberedBackendAfterFalseCompletion() { + ControlledBackend remembered = new ControlledBackend("remembered"); + ControlledBackend fallback = new ControlledBackend("fallback"); + WorldLifecycleService service = service(remembered, fallback); + NamespacedKey worldKey = new NamespacedKey("iris", "async_false"); + World world = world(worldKey); + service.rememberBackend(worldKey, remembered.backendName()); + + CompletableFuture result = service.unloadAsync(world, false); + remembered.complete(false); + + assertFalse(result.join()); + assertEquals("remembered", service.backendNameForWorld(worldKey)); + } + + @Test + public void serviceRetainsRememberedBackendAfterExceptionalCompletion() { + ControlledBackend remembered = new ControlledBackend("remembered"); + ControlledBackend fallback = new ControlledBackend("fallback"); + WorldLifecycleService service = service(remembered, fallback); + NamespacedKey worldKey = new NamespacedKey("iris", "async_failure"); + World world = world(worldKey); + service.rememberBackend(worldKey, remembered.backendName()); + + CompletableFuture result = service.unloadAsync(world, false); + remembered.fail(new IllegalStateException("delayed failure")); + + CompletionException failure = assertThrows(CompletionException.class, result::join); + assertEquals("delayed failure", failure.getCause().getMessage()); + assertEquals("remembered", service.backendNameForWorld(worldKey)); + } + + private static WorldLifecycleService service( + ControlledBackend remembered, + ControlledBackend fallback + ) { + ControlledBackend inactive = new ControlledBackend("inactive"); + return new WorldLifecycleService( + CapabilitySnapshot.forTesting(ServerFamily.PURPUR, false, false, false), + inactive, + remembered, + fallback + ); + } + + private static World world(NamespacedKey key) { + World world = mock(World.class); + when(world.getKey()).thenReturn(key); + when(world.getName()).thenReturn(key.getKey()); + return world; + } + + public static final class CallbackServer { + private Consumer callback; + + public void unloadWorldAsync(World world, boolean save, Consumer callback) { + this.callback = callback; + } + + private void complete(boolean unloaded) { + callback.accept(unloaded); + } + } + + public static final class FailingServer { + public void unloadWorldAsync(World world, boolean save, Consumer callback) { + throw new IllegalStateException("unload failed"); + } + } + + private static final class ControlledBackend implements WorldLifecycleBackend { + private final String name; + private final CompletableFuture unloadResult = new CompletableFuture<>(); + + private ControlledBackend(String name) { + this.name = name; + } + + @Override + public boolean supports(WorldLifecycleRequest request, CapabilitySnapshot capabilities) { + return false; + } + + @Override + public CompletableFuture create(WorldLifecycleRequest request) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture unloadAsync(World world, boolean save) { + return unloadResult; + } + + @Override + public String backendName() { + return name; + } + + private void complete(boolean unloaded) { + unloadResult.complete(unloaded); + } + + private void fail(Throwable failure) { + unloadResult.completeExceptionally(failure); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java new file mode 100644 index 000000000..f6200043c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java @@ -0,0 +1,97 @@ +package art.arcane.iris.core.lifecycle; + +import art.arcane.iris.core.WorldRemovalPathPolicy; +import org.bukkit.NamespacedKey; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class WorldRemovalPathPolicyTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void resolvesExactIrisDimensionDirectory() throws Exception { + Path levelRoot = temporaryFolder.newFolder("world").toPath(); + + WorldRemovalPathPolicy.Target target = WorldRemovalPathPolicy.resolve("Iris World", "world", levelRoot); + + assertEquals("iris:iris_world", target.worldKey().toString()); + assertEquals("iris_world", target.logicalName()); + assertEquals( + levelRoot.resolve("dimensions/iris/iris_world").toAbsolutePath().normalize(), + target.worldDirectory() + ); + } + + @Test + public void rejectsConfiguredMainAndMinecraftNamespace() throws Exception { + Path levelRoot = temporaryFolder.newFolder("main-protection").toPath(); + + WorldRemovalPathPolicy.Rejection mainFailure = assertThrows( + WorldRemovalPathPolicy.Rejection.class, + () -> WorldRemovalPathPolicy.resolve("production", "production", levelRoot) + ); + WorldRemovalPathPolicy.Rejection namespaceFailure = assertThrows( + WorldRemovalPathPolicy.Rejection.class, + () -> WorldRemovalPathPolicy.resolve("minecraft:the_nether", "production", levelRoot) + ); + + assertEquals(WorldRemovalPathPolicy.RejectionReason.CONFIGURED_MAIN_WORLD, mainFailure.reason()); + assertEquals(WorldRemovalPathPolicy.RejectionReason.MINECRAFT_NAMESPACE, namespaceFailure.reason()); + } + + @Test + public void rejectsTraversalAndOutsideCandidate() throws Exception { + Path levelRoot = temporaryFolder.newFolder("path-protection").toPath(); + NamespacedKey worldKey = NamespacedKey.fromString("iris:safe"); + + WorldRemovalPathPolicy.Rejection traversalFailure = assertThrows( + WorldRemovalPathPolicy.Rejection.class, + () -> WorldRemovalPathPolicy.resolve("../world", "production", levelRoot) + ); + WorldRemovalPathPolicy.Rejection outsideFailure = assertThrows( + WorldRemovalPathPolicy.Rejection.class, + () -> WorldRemovalPathPolicy.validateStoragePath( + levelRoot, + worldKey, + levelRoot.resolve("safe") + ) + ); + + assertEquals(WorldRemovalPathPolicy.RejectionReason.INVALID_IDENTIFIER, traversalFailure.reason()); + assertEquals(WorldRemovalPathPolicy.RejectionReason.OUTSIDE_STORAGE_ROOT, outsideFailure.reason()); + } + + @Test + public void rejectsSymbolicLinkTargetAndParent() throws Exception { + Path levelRoot = temporaryFolder.newFolder("symlink-protection").toPath(); + Path dimensionsRoot = Files.createDirectories(levelRoot.resolve("dimensions")); + Path realNamespace = temporaryFolder.newFolder("real-namespace").toPath(); + Path namespaceLink = dimensionsRoot.resolve("iris"); + Files.createSymbolicLink(namespaceLink, realNamespace); + + WorldRemovalPathPolicy.Rejection parentFailure = assertThrows( + WorldRemovalPathPolicy.Rejection.class, + () -> WorldRemovalPathPolicy.resolve("linked", "production", levelRoot) + ); + assertEquals(WorldRemovalPathPolicy.RejectionReason.SYMBOLIC_LINK, parentFailure.reason()); + + Files.delete(namespaceLink); + Path namespace = Files.createDirectories(dimensionsRoot.resolve("iris")); + Path realTarget = temporaryFolder.newFolder("real-target").toPath(); + Files.createSymbolicLink(namespace.resolve("linked"), realTarget); + + WorldRemovalPathPolicy.Rejection targetFailure = assertThrows( + WorldRemovalPathPolicy.Rejection.class, + () -> WorldRemovalPathPolicy.resolve("linked", "production", levelRoot) + ); + assertEquals(WorldRemovalPathPolicy.RejectionReason.SYMBOLIC_LINK, targetFailure.reason()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/AtomicDirectoryPublisherTest.java b/core/src/test/java/art/arcane/iris/core/pack/AtomicDirectoryPublisherTest.java new file mode 100644 index 000000000..7f9a6eedf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/AtomicDirectoryPublisherTest.java @@ -0,0 +1,55 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class AtomicDirectoryPublisherTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void commitPublishesStagedDirectoryAndRemovesBackup() throws Exception { + Path root = temporaryFolder.getRoot().toPath(); + Path target = Files.createDirectory(root.resolve("target")); + Files.writeString(target.resolve("value.txt"), "old"); + Path staged = Files.createDirectory(root.resolve("stage")); + Files.writeString(staged.resolve("value.txt"), "new"); + + try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staged, target)) { + assertEquals("new", Files.readString(target.resolve("value.txt"))); + publication.commit(); + publication.cleanupBackup(); + } + + assertEquals("new", Files.readString(target.resolve("value.txt"))); + assertFalse(Files.exists(staged)); + try (Stream stream = Files.list(root)) { + assertFalse(stream.anyMatch(path -> path.getFileName().toString().contains("backup-"))); + } + } + + @Test + public void closeWithoutCommitRestoresOriginalDirectory() throws Exception { + Path root = temporaryFolder.getRoot().toPath(); + Path target = Files.createDirectory(root.resolve("target")); + Files.writeString(target.resolve("value.txt"), "old"); + Path staged = Files.createDirectory(root.resolve("stage")); + Files.writeString(staged.resolve("value.txt"), "new"); + + try (AtomicDirectoryPublisher.Publication ignored = AtomicDirectoryPublisher.publish(staged, target)) { + assertEquals("new", Files.readString(target.resolve("value.txt"))); + } + + assertTrue(Files.isDirectory(target)); + assertEquals("old", Files.readString(target.resolve("value.txt"))); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java index 09c332568..dc47d34ca 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java @@ -288,6 +288,11 @@ public class ContentKeyValidatorTest { return List.of(); } + @Override + public List lootTableKeys() { + return List.of(); + } + @Override public Map> blockStateProperties() { return properties; diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java index 62aef17ee..183f0a1b3 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java @@ -9,9 +9,12 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; public class PackDirectoryResolverTest { @Rule @@ -43,7 +46,7 @@ public class PackDirectoryResolverTest { } @Test - public void rejectsSymbolicLinkChildren() throws Exception { + public void acceptsSafeSymbolicLinkPackRoots() throws Exception { File packs = temporaryFolder.newFolder("symlink-root"); File outside = temporaryFolder.newFolder("symlink-target"); Path link = new File(packs, "linked").toPath(); @@ -53,6 +56,46 @@ public class PackDirectoryResolverTest { Assume.assumeNoException(e); } - assertNull(PackDirectoryResolver.resolveExisting(packs, "linked")); + assertEquals(link.toFile().getAbsoluteFile(), PackDirectoryResolver.resolveExisting(packs, "linked")); + assertTrue(PackDirectoryResolver.listVisiblePackDirectories(packs).contains(link.toFile())); + PackDirectoryResolver.requireSafePackTree(link.toFile()); + } + + @Test + public void excludesEveryHiddenTransactionDirectory() throws Exception { + File packs = temporaryFolder.newFolder("transaction-root"); + File visible = new File(packs, "overworld"); + Files.createDirectory(visible.toPath()); + Files.createDirectory(new File(packs, ".iris-import-123").toPath()); + Files.createDirectory(new File(packs, ".overworld.backup-123").toPath()); + Files.createDirectory(new File(packs, ".importing-123").toPath()); + Files.createDirectory(new File(packs, ".custom-stage").toPath()); + + List listed = PackDirectoryResolver.listVisiblePackDirectories(packs); + + assertEquals(List.of(visible), listed); + assertTrue(PackDirectoryResolver.isVisiblePackDirectory(visible)); + assertNull(PackDirectoryResolver.resolveExisting(packs, ".custom-stage")); + } + + @Test + public void rejectsSymbolicLinksInsidePackTrees() throws Exception { + File packs = temporaryFolder.newFolder("nested-link-root"); + File pack = new File(packs, "overworld"); + Files.createDirectories(pack.toPath().resolve("dimensions")); + Path outside = temporaryFolder.newFile("outside-dimension.json").toPath(); + Path link = pack.toPath().resolve("dimensions/overworld.json"); + try { + Files.createSymbolicLink(link, outside); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + try { + PackDirectoryResolver.requireSafePackTree(pack); + fail("Pack trees containing symbolic links must be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("symbolic link")); + } } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index 94364bfa3..eb4c8e213 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -18,24 +18,76 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformStructureHooks; +import org.junit.Assume; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.Answers; 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.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; 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; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class PackDownloaderTest { @Rule public TemporaryFolder temp = new TemporaryFolder(); + + private IrisPlatform previousPlatform; + private IrisSettings previousSettings; + + @Before + public void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + previousSettings = IrisSettings.settings; + IrisPlatforms.unbind(); + IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS); + PlatformStructureHooks structureHooks = mock(PlatformStructureHooks.class, Answers.CALLS_REAL_METHODS); + when(platform.dataFolder()).thenReturn(temp.getRoot()); + when(platform.structureHooks()).thenReturn(structureHooks); + when(structureHooks.structureKeys()).thenReturn(List.of("minecraft:village")); + when(structureHooks.jigsawStructureKeys()).thenReturn(List.of("minecraft:village")); + when(structureHooks.templatePoolKeys()).thenReturn(List.of("minecraft:empty")); + IrisPlatforms.bind(platform); + IrisSettings.settings = new IrisSettings(); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + IrisSettings.settings = previousSettings; + PackValidationRegistry.clear(); + } + @Test public void resolvesDefaultOverworldBetaRelease() { assertEquals( @@ -60,6 +112,14 @@ public class PackDownloaderTest { ); } + @Test + public void resolvesRepositoryDefaultBranchReference() { + assertEquals( + "https://github.com/IrisDimensions/example/archive/HEAD.zip", + PackDownloader.resolveGithubArchiveUrl("IrisDimensions/example", "HEAD") + ); + } + @Test public void resolvesQualifiedHeadReference() { assertEquals( @@ -109,6 +169,26 @@ public class PackDownloaderTest { assertTrue(PackDownloader.isPackPresent(packsFolder, "overworld")); } + @Test + public void packPresenceAcceptsSafeSymbolicPackDirectories() throws IOException { + File packsFolder = temp.newFolder("linked-packs"); + Path external = temp.newFolder("external-pack").toPath(); + Files.createDirectories(external.resolve("dimensions")); + Files.writeString(external.resolve("dimensions/overworld.json"), "{}"); + Path linked = packsFolder.toPath().resolve("overworld"); + try { + Files.createSymbolicLink(linked, external); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + + assertTrue(PackDownloader.isPackPresent(packsFolder, "overworld")); + + Path externalFile = temp.newFile("outside-pack.txt").toPath(); + Files.createSymbolicLink(external.resolve("linked-file"), externalFile); + assertFalse(PackDownloader.isPackPresent(packsFolder, "overworld")); + } + @Test public void downloadSkipsWhenExpectedPackAlreadyPresent() throws IOException { File packsFolder = temp.newFolder("packs"); @@ -119,7 +199,7 @@ public class PackDownloaderTest { List feedback = new ArrayList<>(); // The URL is unreachable on purpose: reaching the network would fail the download and // return null, so a non-null key proves the presence check ran before any fetch. - String key = PackDownloader.download( + PackDownloader.PackInstallResult result = PackDownloader.download( packsFolder, "IrisDimensions/overworld", "http://127.0.0.1:9/unreachable.zip", @@ -129,7 +209,8 @@ public class PackDownloaderTest { feedback::add ); - assertEquals("overworld", key); + assertEquals("overworld", result.key()); + assertFalse(result.changed()); assertFalse(feedback.isEmpty()); } @@ -141,4 +222,282 @@ public class PackDownloaderTest { assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "refs/pull/123/head")); assertThrows(IllegalArgumentException.class, () -> PackDownloader.resolveGithubArchiveUrl("IrisDimensions/overworld", "")); } + + @Test + public void forceOverwritePublishesValidatedPackAndCleansTransactionState() throws Exception { + File packsFolder = temp.newFolder("force-packs"); + File target = writePack(packsFolder.toPath().resolve("replaceable"), "replaceable", "old"); + Files.writeString(target.toPath().resolve("old-only.txt"), "old", StandardCharsets.UTF_8); + File extracted = writePack(temp.newFolder("force-source").toPath(), "replaceable", "new"); + List feedback = new ArrayList<>(); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + true, + "replaceable", + feedback::add + ); + + assertEquals(feedback.toString(), "replaceable", result.key()); + assertTrue(result.changed()); + assertFalse(result.restartRequired()); + assertEquals("new", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); + assertFalse(Files.exists(target.toPath().resolve("old-only.txt"))); + assertTransactionStateClean(packsFolder); + assertEquals(0, PackDownloader.downloadLockCount()); + } + + @Test + public void forceOverwritePreservesSymbolicPackTargets() throws Exception { + File packsFolder = temp.newFolder("linked-target-packs"); + File external = writePack(temp.newFolder("linked-target-source").toPath(), "replaceable", "old"); + Path target = packsFolder.toPath().resolve("replaceable"); + try { + Files.createSymbolicLink(target, external.toPath()); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + File extracted = writePack(temp.newFolder("linked-target-update").toPath(), "replaceable", "new"); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + true, + "replaceable", + ignored -> { + } + ); + + assertNull(result); + assertTrue(Files.isSymbolicLink(target)); + assertEquals("old", Files.readString(external.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); + assertTransactionStateClean(packsFolder); + } + + @Test + public void invalidStagedPackPreservesExistingTarget() throws Exception { + File packsFolder = temp.newFolder("invalid-packs"); + File target = writePack(packsFolder.toPath().resolve("protected_pack"), "protected_pack", "old"); + File extracted = writePack(temp.newFolder("invalid-source").toPath(), "protected_pack", "new"); + Files.delete(extracted.toPath().resolve("regions/local.json")); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + true, + "protected_pack", + ignored -> { + } + ); + + assertNull(result); + assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); + assertTrue(Files.isRegularFile(target.toPath().resolve("regions/local.json"))); + assertTransactionStateClean(packsFolder); + assertEquals(0, PackDownloader.downloadLockCount()); + } + + @Test + public void forceOverwriteRefusesLoadedPackData() throws Exception { + File packsFolder = temp.newFolder("loaded-packs"); + File target = writePack(packsFolder.toPath().resolve("active_pack"), "active_pack", "old"); + File extracted = writePack(temp.newFolder("loaded-update").toPath(), "active_pack", "new"); + IrisData loaded = IrisData.get(target); + try { + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + true, + "active_pack", + ignored -> { + } + ); + + assertNull(result); + assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); + assertTransactionStateClean(packsFolder); + } finally { + loaded.close(); + } + } + + @Test + public void unexpectedDownloadedKeyPreservesExistingTarget() throws Exception { + File packsFolder = temp.newFolder("mismatch-packs"); + File target = writePack(packsFolder.toPath().resolve("requested"), "requested", "old"); + File extracted = writePack(temp.newFolder("mismatch-source").toPath(), "different", "new"); + + IOException failure = assertThrows(IOException.class, () -> PackDownloader.installExtractedPack( + packsFolder, + extracted, + true, + "requested", + ignored -> { + } + )); + + assertTrue(failure.getMessage().contains("different")); + assertTrue(failure.getMessage().contains("requested")); + assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); + assertFalse(new File(packsFolder, "different").exists()); + assertTransactionStateClean(packsFolder); + assertEquals(0, PackDownloader.downloadLockCount()); + } + + @Test + public void concurrentImportsForSameKeyPublishOnlyOnePack() throws Exception { + File packsFolder = temp.newFolder("concurrent-packs"); + File firstSource = writePack(temp.newFolder("concurrent-first").toPath(), "shared_pack", "first"); + File secondSource = writePack(temp.newFolder("concurrent-second").toPath(), "shared_pack", "second"); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> { + start.await(); + return PackDownloader.installExtractedPack( + packsFolder, firstSource, false, "shared_pack", ignored -> { + }); + }); + Future second = executor.submit(() -> { + start.await(); + return PackDownloader.installExtractedPack( + packsFolder, secondSource, false, "shared_pack", ignored -> { + }); + }); + start.countDown(); + + int successes = 0; + PackDownloader.PackInstallResult firstResult = first.get(); + PackDownloader.PackInstallResult secondResult = second.get(); + if (firstResult != null && "shared_pack".equals(firstResult.key())) { + successes++; + } + if (secondResult != null && "shared_pack".equals(secondResult.key())) { + successes++; + } + + assertEquals(1, successes); + String installedState = Files.readString( + packsFolder.toPath().resolve("shared_pack/state.txt"), + StandardCharsets.UTF_8 + ); + assertTrue(installedState.equals("first") || installedState.equals("second")); + assertTransactionStateClean(packsFolder); + assertEquals(0, PackDownloader.downloadLockCount()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void rejectsUnsafeExpectedKeyBeforeDownload() throws IOException { + File packsFolder = temp.newFolder("unsafe-key-packs"); + + assertThrows(IllegalArgumentException.class, () -> PackDownloader.download( + packsFolder, + "IrisDimensions/overworld", + "http://127.0.0.1:9/unreachable.zip", + true, + true, + "../outside", + ignored -> { + } + )); + assertEquals(0, PackDownloader.downloadLockCount()); + } + + @Test + public void boundedExtractionRejectsEscapedArchivePaths() throws Exception { + Path archive = temp.newFile("escaped.zip").toPath(); + writeArchive(archive, Map.of("../escaped.txt", "outside")); + Path destination = temp.newFolder("escaped-output").toPath(); + + assertThrows(IOException.class, () -> PackDownloader.unpackArchive( + archive, + destination, + new PackDownloader.ArchiveLimits(1024L, 10, 1024L, 1024L) + )); + assertFalse(Files.exists(destination.getParent().resolve("escaped.txt"))); + } + + @Test + public void boundedExtractionEnforcesEntryAndExpandedLimits() throws Exception { + Path archive = temp.newFile("bounded.zip").toPath(); + LinkedHashMap entries = new LinkedHashMap<>(); + entries.put("first.txt", "1234"); + entries.put("second.txt", "5678"); + writeArchive(archive, entries); + + assertThrows(IOException.class, () -> PackDownloader.unpackArchive( + archive, + temp.newFolder("entry-count-output").toPath(), + new PackDownloader.ArchiveLimits(4096L, 1, 4096L, 4096L) + )); + assertThrows(IOException.class, () -> PackDownloader.unpackArchive( + archive, + temp.newFolder("entry-size-output").toPath(), + new PackDownloader.ArchiveLimits(4096L, 10, 4096L, 3L) + )); + assertThrows(IOException.class, () -> PackDownloader.unpackArchive( + archive, + temp.newFolder("expanded-output").toPath(), + new PackDownloader.ArchiveLimits(4096L, 10, 7L, 4096L) + )); + } + + @Test + public void boundedExtractionPublishesOnlyInsideItsDestination() throws Exception { + Path archive = temp.newFile("valid.zip").toPath(); + writeArchive(archive, Map.of("pack/dimensions/overworld.json", "{}")); + Path destination = temp.newFolder("valid-output").toPath(); + + PackDownloader.unpackArchive( + archive, + destination, + new PackDownloader.ArchiveLimits(4096L, 10, 4096L, 4096L) + ); + + assertEquals("{}", Files.readString(destination.resolve("pack/dimensions/overworld.json"))); + } + + private static File writePack(Path root, String key, String state) throws IOException { + Files.createDirectories(root.resolve("dimensions")); + Files.createDirectories(root.resolve("regions")); + Files.createDirectories(root.resolve("biomes")); + Files.writeString( + root.resolve("dimensions/" + key + ".json"), + "{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256," + + "\"dimensionHeight\":{\"min\":-64,\"max\":320}}", + StandardCharsets.UTF_8 + ); + Files.writeString( + root.resolve("regions/local.json"), + "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}", + StandardCharsets.UTF_8 + ); + Files.writeString( + root.resolve("biomes/local.json"), + "{\"name\":\"Local\",\"derivative\":\"minecraft:plains\"}", + StandardCharsets.UTF_8 + ); + Files.writeString(root.resolve("state.txt"), state, StandardCharsets.UTF_8); + return root.toFile(); + } + + private static void writeArchive(Path archive, Map entries) throws IOException { + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) { + for (Map.Entry entry : entries.entrySet()) { + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + } + + private static void assertTransactionStateClean(File packsFolder) { + File[] transactionEntries = packsFolder.listFiles((File parent, String name) -> + name.startsWith(".iris-import-") || name.contains(".backup-")); + assertTrue(transactionEntries == null || transactionEntries.length == 0); + } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java index 44d42175e..d9fc885c1 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java @@ -1,5 +1,9 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -16,6 +20,11 @@ import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class PackValidatorStructureGraphTest { @Rule @@ -64,6 +73,52 @@ public class PackValidatorStructureGraphTest { pack, Set.of(), Map.of()).isEmpty()); } + @Test + public void datapackBootstrapDefersOnlyLiveRegistryValidation() throws Exception { + File pack = temporaryFolder.newFolder("bootstrap-native-structure"); + write(pack, "dimensions/main.json", "{\"structures\":[{" + + "\"nativeStructures\":[{\"structure\":\"test:structure\"}]}]," + + "\"regions\":[\"main\"]}"); + write(pack, "regions/main.json", "{\"landBiomes\":[\"main\"]}"); + write(pack, "biomes/main.json", "{\"name\":\"Main\"}"); + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenThrow( + new IllegalStateException("server unavailable")); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + PackValidationResult bootstrap = PackValidator.validateForDatapackBootstrap(pack); + PackValidationResult live = PackValidator.validate(pack); + + assertTrue(bootstrap.getBlockingErrors().toString(), bootstrap.isLoadable()); + assertFalse(live.isLoadable()); + assertTrue(live.getBlockingErrors().toString(), live.getBlockingErrors().stream().anyMatch( + error -> error.contains("server unavailable"))); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + @Test + public void datapackBootstrapStillRejectsMalformedPlacementConfiguration() throws Exception { + File pack = temporaryFolder.newFolder("bootstrap-invalid-placement"); + write(pack, "dimensions/main.json", "{\"structures\":[{" + + "\"nativeStructures\":[{\"structure\":\"test:structure\"}]," + + "\"spacing\":0}]}"); + + PackValidationResult result = PackValidator.validateForDatapackBootstrap(pack); + + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().toString(), result.getBlockingErrors().stream().anyMatch( + error -> error.contains("spacing must be at least 1"))); + } + @Test public void rejectsNativeTerrainLobeSettingsOutsideTheirRange() throws Exception { File pack = temporaryFolder.newFolder("invalid-lobe"); @@ -141,6 +196,316 @@ public class PackValidatorStructureGraphTest { message -> message.contains(".maxDepth must be at most 20"))); } + @Test + public void rejectsMalformedPlacementGridAndPolicyFields() throws Exception { + File pack = temporaryFolder.newFolder("invalid-placement-grid"); + write(pack, "dimensions/main.json", "{\"structures\":[{" + + "\"nativeStructures\":[{\"structure\":\"minecraft:ancient_city\"}]," + + "\"placementId\":42,\"distribution\":\"UNKNOWN\",\"spacing\":0," + + "\"separation\":32,\"density\":\"often\",\"ringCount\":0," + + "\"minHeight\":90,\"maxHeight\":20,\"underwater\":\"yes\"," + + "\"nativeSuppression\":\"SOMETIMES\"," + + "\"terrain\":{\"mode\":\"SURFACE_FIT\",\"shape\":\"CUBE\"}}]}"); + + List errors = PackObjectSurfaceValidator.validateStructureGraph(pack); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("placementId must be a string"))); + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("distribution must be one of"))); + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("spacing must be at least 1"))); + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("density must be a number"))); + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("inverted height band"))); + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("underwater must be a boolean"))); + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("terrain.mode must be one of"))); + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("terrain.shape must be one of"))); + } + + @Test + public void rejectsDuplicatePlacementIdsAcrossResources() throws Exception { + File pack = temporaryFolder.newFolder("duplicate-placement-id"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"placementId\":\"shared\"," + + "\"nativeStructures\":[{\"structure\":\"minecraft:ancient_city\"}]}]}"); + write(pack, "regions/forest.json", "{\"structures\":[{\"placementId\":\"shared\"," + + "\"nativeStructures\":[{\"structure\":\"minecraft:village_plains\"}]}]}"); + + List errors = PackObjectSurfaceValidator.validateStructureGraph(pack); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("placementId duplicates 'shared'"))); + } + + @Test + public void rejectsCanonicalAnonymousGridDuplicatesAcrossResources() throws Exception { + File pack = temporaryFolder.newFolder("duplicate-anonymous-grid"); + write(pack, "dimensions/main.json", "{\"structures\":[{" + + "\"nativeStructures\":[{\"structure\":\"minecraft:ancient_city\"}]}]}"); + write(pack, "regions/forest.json", "{\"structures\":[{" + + "\"spacing\":32,\"separation\":8,\"density\":0.02," + + "\"nativeStructures\":[{\"weight\":1,\"structure\":\"minecraft:ancient_city\"}]}]}"); + + List errors = PackObjectSurfaceValidator.validateStructureGraph(pack); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("duplicates an anonymous placement grid"))); + } + + @Test + public void rejectsDuplicateEditableStructureKeysIgnoringCase() throws Exception { + File pack = temporaryFolder.newFolder("duplicate-editable-key"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[" + + "\"castle\",\"CASTLE\"]}]}"); + write(pack, "structures/castle.json", "{}"); + + List errors = PackObjectSurfaceValidator.validateStructureGraph(pack); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> message.contains("duplicates Iris structure"))); + } + + @Test + public void optionalJigsawTerrainEnvelopeOverflowDoesNotBlockThePack() throws Exception { + File pack = temporaryFolder.newFolder("oversized-envelope"); + write(pack, "dimensions/main.json", "{\"structures\":[{" + + "\"nativeStructures\":[{\"structure\":\"minecraft:ancient_city\"," + + "\"jigsaw\":{\"maxDistanceHorizontal\":96}}]," + + "\"terrain\":{\"mode\":\"FORCE_CARVE\",\"horizontalPadding\":40}}]}"); + + assertTrue(PackObjectSurfaceValidator.validateStructureGraph(pack).isEmpty()); + } + + @Test + public void sharedOverworldAncientCityTerrainEnvelopeDoesNotBlockThePack() throws Exception { + File pack = nativeSourceEnvelopePack( + "shared-overworld-ancient-city", "minecraft:ancient_city", "FORCE_CARVE", 24); + + assertTrue(validateWithJigsawMetadata( + pack, "minecraft:ancient_city", 116, 12, 0, 0).isEmpty()); + } + + @Test + public void sourceAndPreserveIgnoreConfiguredEnvelopePadding() throws Exception { + File source = nativeEnvelopePack("source-padding", "SOURCE", 128, 128); + File preserve = nativeEnvelopePack("preserve-padding", "PRESERVE", 128, 128); + + assertTrue(validateWithReferenceExpansion(source, 0).isEmpty()); + assertTrue(validateWithReferenceExpansion(preserve, 12).isEmpty()); + } + + @Test + public void sourceTerrainAdjustmentOverflowDoesNotBlockThePack() throws Exception { + File boundary = nativeEnvelopePack("source-adjustment-boundary", "SOURCE", 116, 64); + File oversized = nativeEnvelopePack("source-adjustment-oversized", "SOURCE", 117, 0); + + assertTrue(validateWithReferenceExpansion(boundary, 12).isEmpty()); + assertTrue(validateWithReferenceExpansion(oversized, 12).isEmpty()); + } + + @Test + public void customTerrainEnvelopeOverflowDoesNotBlockThePack() throws Exception { + File boundary = nativeEnvelopePack("custom-envelope-boundary", "ENCASE", 96, 32); + File oversized = nativeEnvelopePack("custom-envelope-oversized", "ENCASE", 96, 33); + + assertTrue(validateWithReferenceExpansion(boundary, 12).isEmpty()); + assertTrue(validateWithReferenceExpansion(oversized, 12).isEmpty()); + } + + @Test + public void registeredJigsawOptionalPaddingDoesNotExpandItsBlockingSpan() throws Exception { + File boundary = nativeSourceEnvelopePack("dnt-source-boundary", "ENCASE", 48); + File oversized = nativeSourceEnvelopePack("dnt-source-oversized", "ENCASE", 49); + + assertTrue(validateWithJigsawMetadata(boundary, 80, 12).isEmpty()); + assertTrue(validateWithJigsawMetadata(oversized, 80, 12).isEmpty()); + } + + @Test + public void jigsawOverrideReplacesLiveSourceDistance() throws Exception { + File pack = nativeEnvelopePack("source-distance-override", "ENCASE", 80, 36); + + assertTrue(validateWithJigsawMetadata(pack, 120, 12).isEmpty()); + } + + @Test + public void oversizedLiveStartElementCannotBypassAssemblyDistance() throws Exception { + File pack = nativeSourceEnvelopePack("oversized-live-start", "SOURCE", 0); + + List errors = validateWithJigsawMetadata(pack, 80, 0, 129, 0); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> + message.contains("129-block maximum start element") + && message.contains("128-block (8-chunk)"))); + } + + @Test + public void liveStartElementSpanUsesInclusiveReferenceBoundary() throws Exception { + File pack = nativeSourceEnvelopePack("live-start-boundary", "SOURCE", 0); + + assertTrue(validateWithJigsawMetadata(pack, 80, 12, 116, 0).isEmpty()); + } + + @Test + public void overriddenStartPoolUsesItsLiveSpan() throws Exception { + File pack = nativePoolOverrideEnvelopePack("oversized-pool-override"); + + List errors = validateWithJigsawMetadata(pack, 80, 0, 20, 129); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> + message.contains("129-block maximum start element") + && message.contains("128-block (8-chunk)"))); + } + + @Test + public void smallerOverriddenStartPoolReplacesTheSourcePoolSpan() throws Exception { + File pack = nativePoolOverrideEnvelopePack("smaller-pool-override"); + + assertTrue(validateWithJigsawMetadata(pack, 80, 0, 129, 20).isEmpty()); + } + + @Test + public void unresolvedOverriddenStartPoolSpanFailsClosed() throws Exception { + File pack = nativePoolOverrideEnvelopePack("unresolved-pool-override"); + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenReturn(List.of("test:structure")); + when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:structure")); + when(hooks.templatePoolKeys()).thenReturn(List.of("test:start", "test:override")); + when(hooks.jigsawSourceMetadata("test:structure")) + .thenReturn(new JigsawSourceMetadata(80, 0, 20)); + when(hooks.jigsawStartPoolHorizontalSpan("test:structure", "test:override")) + .thenThrow(new IllegalStateException("missing template")); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + List errors = PackObjectSurfaceValidator.validateStructureGraph(pack); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> + message.contains("could not resolve a bounded live horizontal span") + && message.contains("missing template"))); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + @Test + public void registeredJigsawSourceAdjustmentOverflowDoesNotBlockThePack() throws Exception { + File pack = nativeSourceEnvelopePack("source-adjustment-no-override", "SOURCE", 0); + + assertTrue(validateWithJigsawMetadata(pack, 117, 12).isEmpty()); + } + + @Test + public void registeredJigsawAssemblyBeyondReferenceRangeStillBlocksThePack() throws Exception { + File pack = nativeEnvelopePack("oversized-live-assembly", "ENCASE", 129, 0); + + List errors = PackObjectSurfaceValidator.validateStructureGraph(pack); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> + message.contains(".jigsaw.maxDistanceHorizontal") + && message.contains("129-block maximum assembly distance") + && message.contains("128-block (8-chunk)"))); + } + + @Test + public void liveJigsawMetadataFailureBlocksValidation() throws Exception { + File pack = nativeSourceEnvelopePack("missing-source-metadata", "SOURCE", 0); + + List errors = validateWithUnavailableJigsawMetadata(pack); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> + message.contains("returned null jigsaw metadata for 'test:structure'"))); + } + + @Test + public void validationResolvesOnlyReferencedJigsawMetadataAndCachesDuplicates() throws Exception { + File pack = temporaryFolder.newFolder("lazy-jigsaw-metadata"); + write(pack, "dimensions/main.json", "{\"structures\":[" + + "{\"placementId\":\"first\",\"nativeStructures\":[" + + "{\"structure\":\"test:used\"}]}," + + "{\"placementId\":\"second\",\"nativeStructures\":[" + + "{\"structure\":\"test:used\"}]}]}"); + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenReturn(List.of("test:used", "test:unused")); + when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:used", "test:unused")); + when(hooks.templatePoolKeys()).thenReturn(List.of("test:start")); + when(hooks.jigsawSourceMetadata("test:used")) + .thenReturn(new JigsawSourceMetadata(80, 0, 20)); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + List errors = PackObjectSurfaceValidator.validateStructureGraph(pack); + + assertTrue(errors.toString(), errors.isEmpty()); + verify(hooks, times(1)).jigsawSourceMetadata("test:used"); + verify(hooks, never()).jigsawSourceMetadata("test:unused"); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + @Test + public void optionalTerrainPaddingForNonJigsawSourceDoesNotBlockThePack() throws Exception { + File pack = nativeSourceEnvelopePack("non-jigsaw-envelope", "ENCASE", 1); + + assertTrue(validateWithNonJigsawSource(pack).isEmpty()); + } + + @Test + public void acceptsZeroReferencePaddingForRegisteredNonJigsawSource() throws Exception { + File pack = nativeSourceEnvelopePack("non-jigsaw-zero-envelope", "ENCASE", 0); + + assertTrue(validateWithNonJigsawSource(pack).isEmpty()); + } + + @Test + public void optionalTerrainPaddingForNaturalNonJigsawAdjustmentDoesNotBlockThePack() throws Exception { + File pack = nativeAdjustmentEnvelopePack( + "natural-non-jigsaw-envelope", "test:structure", "ENCASE", 1); + + assertTrue(validateWithNonJigsawSource(pack).isEmpty()); + } + + @Test + public void optionalTerrainPaddingForNaturalJigsawAdjustmentDoesNotBlockThePack() throws Exception { + File boundary = nativeAdjustmentEnvelopePack( + "natural-jigsaw-envelope-boundary", "test:structure", "ENCASE", 48); + File oversized = nativeAdjustmentEnvelopePack( + "natural-jigsaw-envelope-oversized", "test:structure", "ENCASE", 49); + + assertTrue(validateWithJigsawMetadata(boundary, 80, 12).isEmpty()); + assertTrue(validateWithJigsawMetadata(oversized, 80, 12).isEmpty()); + } + + @Test + public void actualNaturalJigsawContentBeyondReferenceRangeStillBlocksThePack() throws Exception { + File pack = nativeAdjustmentEnvelopePack( + "natural-jigsaw-oversized-content", "test:structure", "ENCASE", 1); + + List errors = validateWithJigsawMetadata(pack, 80, 0, 129, 0); + + assertTrue(errors.toString(), errors.stream().anyMatch(message -> + message.contains("importedStructures.adjustments[0]") + && message.contains("129-block maximum start element") + && message.contains("128-block (8-chunk)"))); + } + + @Test + public void lastMatchingNaturalTerrainAdjustmentControlsEnvelopeValidation() throws Exception { + File pack = temporaryFolder.newFolder("natural-adjustment-precedence"); + write(pack, "dimensions/main.json", "{\"importedStructures\":{\"adjustments\":[" + + "{\"match\":[\"test:structure\"],\"terrain\":{\"mode\":\"ENCASE\"," + + "\"horizontalPadding\":128}}," + + "{\"match\":[\"test:structure\"],\"terrain\":{\"mode\":\"PRESERVE\"}}]}}}"); + + assertTrue(validateWithNonJigsawSource(pack).isEmpty()); + } + @Test public void reportsMissingReferencesInDeterministicGraphOrder() throws Exception { File pack = temporaryFolder.newFolder("pack"); @@ -415,6 +780,156 @@ public class PackValidatorStructureGraphTest { return pack; } + private File nativeEnvelopePack(String name, String mode, int maximumDistance, + int horizontalPadding) throws Exception { + File pack = temporaryFolder.newFolder(name); + write(pack, "dimensions/main.json", "{\"structures\":[{" + + "\"nativeStructures\":[{\"structure\":\"test:structure\"," + + "\"jigsaw\":{\"maxDistanceHorizontal\":" + maximumDistance + "}}]," + + "\"terrain\":{\"mode\":\"" + mode + "\",\"horizontalPadding\":" + + horizontalPadding + "}}]}"); + return pack; + } + + private File nativeSourceEnvelopePack(String name, String mode, + int horizontalPadding) throws Exception { + return nativeSourceEnvelopePack(name, "test:structure", mode, horizontalPadding); + } + + private File nativeSourceEnvelopePack(String name, String structureKey, String mode, + int horizontalPadding) throws Exception { + File pack = temporaryFolder.newFolder(name); + write(pack, "dimensions/main.json", "{\"structures\":[{" + + "\"nativeStructures\":[{\"structure\":\"" + structureKey + "\"}]," + + "\"terrain\":{\"mode\":\"" + mode + "\",\"horizontalPadding\":" + + horizontalPadding + "}}]}"); + return pack; + } + + private File nativePoolOverrideEnvelopePack(String name) throws Exception { + File pack = temporaryFolder.newFolder(name); + write(pack, "dimensions/main.json", "{\"structures\":[{" + + "\"nativeStructures\":[{\"structure\":\"test:structure\"," + + "\"jigsaw\":{\"startPool\":\"test:override\"," + + "\"maxDistanceHorizontal\":80}}]," + + "\"terrain\":{\"mode\":\"SOURCE\"}}]}"); + return pack; + } + + private File nativeAdjustmentEnvelopePack(String name, String match, String mode, + int horizontalPadding) throws Exception { + File pack = temporaryFolder.newFolder(name); + write(pack, "dimensions/main.json", "{\"importedStructures\":{\"adjustments\":[{" + + "\"match\":[\"" + match + "\"],\"terrain\":{\"mode\":\"" + mode + + "\",\"horizontalPadding\":" + horizontalPadding + "}}]}}}"); + return pack; + } + + private List validateWithReferenceExpansion(File pack, int expansion) { + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenReturn(List.of("test:structure")); + when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:structure")); + when(hooks.templatePoolKeys()).thenReturn(List.of("test:start")); + when(hooks.jigsawSourceMetadata("test:structure")) + .thenReturn(new JigsawSourceMetadata(80, expansion)); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + return PackObjectSurfaceValidator.validateStructureGraph(pack); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + private List validateWithJigsawMetadata(File pack, int maximumDistance, int expansion) { + return validateWithJigsawMetadata(pack, maximumDistance, expansion, 0, 0); + } + + private List validateWithJigsawMetadata(File pack, int maximumDistance, int expansion, + int sourceSpan, int overrideSpan) { + return validateWithJigsawMetadata( + pack, "test:structure", maximumDistance, expansion, sourceSpan, overrideSpan); + } + + private List validateWithJigsawMetadata( + File pack, + String structureKey, + int maximumDistance, + int expansion, + int sourceSpan, + int overrideSpan + ) { + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenReturn(List.of(structureKey)); + when(hooks.jigsawStructureKeys()).thenReturn(List.of(structureKey)); + when(hooks.templatePoolKeys()).thenReturn(List.of("test:start", "test:override")); + when(hooks.jigsawSourceMetadata(structureKey)) + .thenReturn(new JigsawSourceMetadata(maximumDistance, expansion, sourceSpan)); + when(hooks.jigsawStartPoolHorizontalSpan( + structureKey, "test:override")).thenReturn(overrideSpan); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + return PackObjectSurfaceValidator.validateStructureGraph(pack); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + private List validateWithUnavailableJigsawMetadata(File pack) { + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenReturn(List.of("test:structure")); + when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:structure")); + when(hooks.templatePoolKeys()).thenReturn(List.of("test:start")); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + return PackObjectSurfaceValidator.validateStructureGraph(pack); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + + private List validateWithNonJigsawSource(File pack) { + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenReturn(List.of("test:structure", "test:jigsaw")); + when(hooks.jigsawStructureKeys()).thenReturn(List.of("test:jigsaw")); + when(hooks.templatePoolKeys()).thenReturn(List.of("test:start")); + when(hooks.jigsawSourceMetadata("test:jigsaw")) + .thenReturn(new JigsawSourceMetadata(80, 0)); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + return PackObjectSurfaceValidator.validateStructureGraph(pack); + } finally { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + } + private Map> sampledEnvelope( String structureKey, int pieceCount, diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisProjectCopierTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisProjectCopierTest.java new file mode 100644 index 000000000..fa5756209 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisProjectCopierTest.java @@ -0,0 +1,246 @@ +package art.arcane.iris.core.project; + +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IrisProjectCopierTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void copiesTransformsAndAtomicallyPublishesProject() throws Exception { + Path workspace = temporaryFolder.newFolder("packs").toPath(); + Path source = createSource(workspace, "source"); + Files.createDirectories(source.resolve("biomes")); + Files.writeString(source.resolve("biomes/plains.json"), "{}"); + Files.createDirectories(source.resolve(".git")); + Files.writeString(source.resolve(".git/config"), "private"); + Files.writeString(source.resolve("source.code-workspace"), "private"); + Path target = workspace.resolve("target-pack"); + + IrisProjectCopier.copyProject( + source.toFile(), + workspace.toFile(), + "source", + "target-pack" + ); + + assertTrue(Files.isDirectory(target)); + assertTrue(Files.isRegularFile(target.resolve("biomes/plains.json"))); + assertTrue(Files.isRegularFile(target.resolve("dimensions/target-pack.json"))); + assertFalse(Files.exists(target.resolve("dimensions/source.json"))); + assertFalse(Files.exists(target.resolve(".git"))); + assertFalse(Files.exists(target.resolve("source.code-workspace"))); + JSONObject dimension = new JSONObject(Files.readString(target.resolve("dimensions/target-pack.json"))); + assertEquals("Target Pack", dimension.getString("name")); + assertNoStages(workspace, "target-pack"); + } + + @Test + public void sourceFolderAndSelectedDimensionMayHaveDifferentKeys() throws Exception { + Path workspace = temporaryFolder.newFolder("folder-key-mismatch").toPath(); + Path source = workspace.resolve("template-pack"); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/overworld.json"), "{\"name\":\"Source\"}"); + Path target = workspace.resolve("new-project"); + + IrisProjectCopier.copyProject( + source.toFile(), + workspace.toFile(), + "overworld", + "new-project" + ); + + assertTrue(Files.isRegularFile(target.resolve("dimensions/new-project.json"))); + assertFalse(Files.exists(target.resolve("dimensions/overworld.json"))); + } + + @Test + public void retainsSupportingDimensionsWhileRenamingTheSelectedDimension() throws Exception { + Path workspace = temporaryFolder.newFolder("multiple-dimensions").toPath(); + Path source = workspace.resolve("template-pack"); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/overworld.json"), "{\"name\":\"Source\"}"); + Files.writeString(source.resolve("dimensions/the_nether.json"), "{\"name\":\"Nether\"}"); + Path target = workspace.resolve("new-project"); + + IrisProjectCopier.copyProject(source.toFile(), workspace.toFile(), "overworld", "new-project"); + + assertTrue(Files.isRegularFile(target.resolve("dimensions/new-project.json"))); + assertTrue(Files.isRegularFile(target.resolve("dimensions/the_nether.json"))); + } + + @Test + public void existingTargetRemainsUnchanged() throws Exception { + Path workspace = temporaryFolder.newFolder("packs").toPath(); + Path source = createSource(workspace, "source"); + Path target = workspace.resolve("target"); + Files.createDirectories(target); + Path sentinel = target.resolve("sentinel.txt"); + Files.writeString(sentinel, "keep-me"); + + assertThrows(FileAlreadyExistsException.class, () -> IrisProjectCopier.copyProject( + source.toFile(), + workspace.toFile(), + "source", + "target" + )); + + assertEquals("keep-me", Files.readString(sentinel)); + assertEquals(1L, countEntries(target)); + assertNoStages(workspace, "target"); + } + + @Test + public void rejectsInvalidProjectKeysAndEscapedTargets() throws Exception { + Path workspace = temporaryFolder.newFolder("packs").toPath(); + Path source = createSource(workspace, "source"); + Path target = workspace.resolve("target"); + List invalidKeys = List.of( + "", + ".", + "..", + "nested/project", + "nested\\project", + workspace.resolve("absolute").toAbsolutePath().toString() + ); + + for (String invalidKey : invalidKeys) { + assertThrows(IOException.class, () -> IrisProjectCopier.copyProject( + source.toFile(), + workspace.toFile(), + "source", + invalidKey + )); + } + + assertNoStages(workspace, "target"); + } + + @Test + public void rejectsMissingSelectedDimension() throws Exception { + Path workspace = temporaryFolder.newFolder("packs").toPath(); + Path source = createSource(workspace, "source"); + Path target = workspace.resolve("target"); + + assertThrows(IOException.class, () -> IrisProjectCopier.copyProject( + source.toFile(), + workspace.toFile(), + "different-source", + "target" + )); + + assertFalse(Files.exists(target)); + assertNoStages(workspace, "target"); + } + + @Test + public void acceptsSafeSymbolicLinkSource() throws Exception { + Path workspace = temporaryFolder.newFolder("packs").toPath(); + Path realSource = workspace.resolve("real-source"); + Files.createDirectories(realSource.resolve("dimensions")); + Files.writeString(realSource.resolve("dimensions/source.json"), "{\"name\":\"Source\"}"); + Path linkedSource = workspace.resolve("source"); + try { + Files.createSymbolicLink(linkedSource, realSource.getFileName()); + } catch (IOException | UnsupportedOperationException e) { + Assume.assumeNoException(e); + } + Path target = workspace.resolve("target"); + + IrisProjectCopier.copyProject( + linkedSource.toFile(), + workspace.toFile(), + "source", + "target" + ); + + assertTrue(Files.isRegularFile(target.resolve("dimensions/target.json"))); + assertNoStages(workspace, "target"); + } + + @Test + public void injectedMidCopyFailureLeavesNoTargetOrStage() throws Exception { + Path workspace = temporaryFolder.newFolder("packs").toPath(); + Path source = createSource(workspace, "source"); + Files.createDirectories(source.resolve("objects")); + Files.writeString(source.resolve("objects/fail.iob"), "failure-point"); + Path target = workspace.resolve("target"); + + assertThrows(IOException.class, () -> IrisProjectCopier.copyProject( + source.toFile(), + workspace.toFile(), + "source", + "target", + (Path input, Path output) -> { + if ("fail.iob".equals(input.getFileName().toString())) { + throw new IOException("injected copy failure"); + } + } + )); + + assertFalse(Files.exists(target)); + assertNoStages(workspace, "target"); + } + + @Test + public void rejectsSymbolicLinksInsideSourceTreeAndCleansStage() throws Exception { + Path workspace = temporaryFolder.newFolder("packs").toPath(); + Path source = createSource(workspace, "source"); + Path external = temporaryFolder.newFile("outside.txt").toPath(); + Path link = source.resolve("objects-link"); + try { + Files.createSymbolicLink(link, external); + } catch (IOException | UnsupportedOperationException e) { + Assume.assumeNoException(e); + } + Path target = workspace.resolve("target"); + + assertThrows(IOException.class, () -> IrisProjectCopier.copyProject( + source.toFile(), + workspace.toFile(), + "source", + "target" + )); + + assertFalse(Files.exists(target)); + assertNoStages(workspace, "target"); + } + + private static Path createSource(Path workspace, String key) throws IOException { + Path source = workspace.resolve(key); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions").resolve(key + ".json"), "{\"name\":\"Source\"}"); + return source; + } + + private static long countEntries(Path directory) throws IOException { + try (Stream entries = Files.list(directory)) { + return entries.count(); + } + } + + private static void assertNoStages(Path workspace, String targetKey) throws IOException { + try (Stream entries = Files.list(workspace)) { + assertFalse(entries.anyMatch(path -> path.getFileName().toString().startsWith( + "." + targetKey + ".importing-" + ))); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java index 6f3574812..9668fb6ad 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java @@ -36,10 +36,12 @@ import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.RegistryListBiome; import art.arcane.iris.engine.object.annotations.RegistryListEnchantment; import art.arcane.iris.engine.object.annotations.RegistryListEntityType; +import art.arcane.iris.engine.object.annotations.RegistryListFunction; import art.arcane.iris.engine.object.annotations.RegistryListItemType; import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect; import art.arcane.iris.engine.object.annotations.RegistryListSpecialEntity; import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure; +import art.arcane.iris.engine.object.annotations.functions.LootTableKeyFunction; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.LogLevel; @@ -81,6 +83,7 @@ public class SchemaBuilderParityTest { private static final List STRUCTURE_KEYS = List.of("minecraft:monument", "minecraft:stronghold", "cool_mod:sky_temple"); private static final List BIOME_KEYS = List.of("minecraft:plains", "cool_mod:sky_meadow"); private static final List SPECIAL_ENTITY_KEYS = List.of("mythicmobs:skeleton_king"); + private static final List LOOT_TABLE_KEYS = List.of("minecraft:chests/simple_dungeon", "cool_mod:chests/sky_temple"); // Namespaced key first, then the legacy short form for the vanilla namespace only. A mod key is addressable // by its full key instead of a namespace-stripped path that could collide with vanilla content. @@ -252,6 +255,16 @@ public class SchemaBuilderParityTest { enumValues(schema.getJSONObject("definitions"), "enum-vanilla-structure")); } + @Test + public void lootTableFunctionUsesPlatformRegistryKeys() { + JSONObject schema = new SchemaBuilder(LootTableModel.class, (IrisData) null).construct(); + JSONObject lootTable = schema.getJSONObject("properties").getJSONObject("lootTable"); + + assertEquals("#/definitions/loot-table-key", lootTable.getString("$ref")); + assertEquals(LOOT_TABLE_KEYS, + schema.getJSONObject("definitions").getJSONObject("loot-table-key").get("enum")); + } + private static IrisData structureSchemaData() { IrisData data = mock(IrisData.class); ResourceLoader structureLoader = mock(ResourceLoader.class); @@ -368,6 +381,13 @@ public class SchemaBuilderParityTest { private KList disabled = new KList<>(); } + @Desc("Loot table model.") + public static class LootTableModel { + @Desc("Loot table.") + @RegistryListFunction(LootTableKeyFunction.class) + private String lootTable = ""; + } + public enum Flavor { ALPHA, BETA @@ -459,6 +479,11 @@ public class SchemaBuilderParityTest { return POTION_KEYS; } + @Override + public List lootTableKeys() { + return LOOT_TABLE_KEYS; + } + @Override public Map> blockStateProperties() { return Map.of(); diff --git a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorCloseSequenceTest.java b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorCloseSequenceTest.java new file mode 100644 index 000000000..7f7af9c6a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorCloseSequenceTest.java @@ -0,0 +1,107 @@ +package art.arcane.iris.core.runtime; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; + +public class StudioOpenCoordinatorCloseSequenceTest { + @Test + public void unloadCompletesBeforeGeneratorCloseAndFolderDeletion() { + ArrayList phases = new ArrayList<>(); + + StudioOpenCoordinator.sequenceStudioClose( + () -> phase(phases, "evacuate"), + () -> phase(phases, "unload"), + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folders") + ).join(); + + assertEquals(List.of("evacuate", "unload", "close-generator", "delete-folders"), phases); + } + + @Test + public void unloadFailurePreventsGeneratorCloseAndFolderDeletion() { + ArrayList phases = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("unload rejected"); + + try { + StudioOpenCoordinator.sequenceStudioClose( + () -> phase(phases, "evacuate"), + () -> { + phases.add("unload"); + return CompletableFuture.failedFuture(failure); + }, + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folders") + ).join(); + fail("Expected unload failure"); + } catch (CompletionException exception) { + assertSame(failure, exception.getCause()); + } + + assertEquals(List.of("evacuate", "unload"), phases); + } + + @Test + public void generatorCloseFailurePreventsFolderDeletion() { + ArrayList phases = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("close rejected"); + + try { + StudioOpenCoordinator.sequenceStudioClose( + () -> phase(phases, "evacuate"), + () -> phase(phases, "unload"), + () -> { + phases.add("close-generator"); + return CompletableFuture.failedFuture(failure); + }, + () -> phase(phases, "delete-folders") + ).join(); + fail("Expected generator close failure"); + } catch (CompletionException exception) { + assertSame(failure, exception.getCause()); + } + + assertEquals(List.of("evacuate", "unload", "close-generator"), phases); + } + + @Test + public void terminalTimeoutPreventsLateUnloadFromClosingOrDeleting() { + ArrayList phases = new ArrayList<>(); + AtomicBoolean terminalTimeout = new AtomicBoolean(false); + CompletableFuture unload = new CompletableFuture<>(); + + CompletableFuture close = StudioOpenCoordinator.sequenceStudioClose( + () -> phase(phases, "evacuate"), + () -> { + phases.add("unload"); + return unload; + }, + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folders"), + terminalTimeout::get); + + terminalTimeout.set(true); + unload.complete(null); + try { + close.join(); + fail("Expected terminal timeout"); + } catch (CompletionException exception) { + assertEquals("Studio close stopped after its terminal timeout.", exception.getCause().getMessage()); + } + assertEquals(List.of("evacuate", "unload"), phases); + } + + private static CompletableFuture phase(List phases, String phase) { + phases.add(phase); + return CompletableFuture.completedFuture(null); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCProjectSafetyTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCProjectSafetyTest.java new file mode 100644 index 000000000..86b5fcad5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCProjectSafetyTest.java @@ -0,0 +1,109 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidator; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; + +public class StudioSVCProjectSafetyTest { + @Test + public void listingWithoutBranchUsesRepositoryDefault() { + StudioSVC.PackListingReference reference = StudioSVC.resolvePackListingReference( + "example", + "IrisDimensions/example" + ); + + assertEquals("IrisDimensions/example", reference.repository()); + assertEquals("HEAD", reference.ref()); + assertEquals("example", reference.expectedKey()); + } + + @Test + public void createsLocalStarterWithoutARepositoryDownload() throws IOException { + File workspace = temporaryFolder.newFolder("starter-workspace"); + + StudioSVC.createStarterProject(workspace, "new_pack"); + + Path project = workspace.toPath().resolve("new_pack"); + assertTrue(Files.isRegularFile(project.resolve("dimensions/new_pack.json"))); + assertTrue(Files.isRegularFile(project.resolve("regions/starter.json"))); + assertTrue(Files.isRegularFile(project.resolve("biomes/starter.json"))); + assertTrue(Files.isRegularFile(project.resolve("generators/flat.json"))); + assertTrue(Files.isRegularFile(project.resolve("new_pack.code-workspace"))); + PackValidationResult validation = PackValidator.validate(project.toFile()); + assertTrue(validation.getBlockingErrors().toString(), validation.isLoadable()); + } + + @Test + public void starterCreationNeverDeletesAnExistingTarget() throws IOException { + File workspace = temporaryFolder.newFolder("occupied-starter-workspace"); + Path occupied = workspace.toPath().resolve("occupied"); + Files.createDirectories(occupied); + Path marker = occupied.resolve("marker.txt"); + Files.writeString(marker, "owned"); + + assertThrows(IOException.class, () -> StudioSVC.createStarterProject(workspace, "occupied")); + + assertTrue(Files.isRegularFile(marker)); + } + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void normalizesSafeProjectNames() throws IOException { + assertEquals("my_pack-2", StudioSVC.normalizeProjectName(" MY_PACK-2 ")); + } + + @Test + public void rejectsUnsafeProjectNames() { + List invalidNames = List.of( + "", + ".", + "..", + "../escape", + "nested/project", + "nested\\project", + "pack.name", + "pack name" + ); + + for (String invalidName : invalidNames) { + assertThrows(IOException.class, () -> StudioSVC.normalizeProjectName(invalidName)); + } + } + + @Test + public void choosesAnUnusedDefaultProjectWithoutReplacingExistingProjects() throws IOException { + File workspace = temporaryFolder.newFolder("packs"); + Files.createDirectory(workspace.toPath().resolve("studio")); + Files.createDirectory(workspace.toPath().resolve("studio2")); + + assertEquals("studio3", StudioSVC.nextAvailableProjectName(workspace, "studio")); + } + + @Test + public void rejectsSymbolicLinkWorkspace() throws IOException { + Path realWorkspace = temporaryFolder.newFolder("real-packs").toPath(); + Path linkedWorkspace = realWorkspace.getParent().resolve("linked-packs"); + try { + Files.createSymbolicLink(linkedWorkspace, realWorkspace.getFileName()); + } catch (IOException | UnsupportedOperationException e) { + Assume.assumeNoException(e); + } + + assertThrows(IOException.class, () -> StudioSVC.requireSafeWorkspace(linkedWorkspace.toFile())); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java new file mode 100644 index 000000000..fc5eb2e4f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java @@ -0,0 +1,179 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.AtomicDirectoryPublisher; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class StudioSVCWorldPackPublishTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void copiesToStageAndPublishesTheCompletePack() throws IOException { + Path root = temporaryFolder.newFolder("world").toPath(); + Path source = root.resolve("source"); + Path stage = root.resolve("iris/.pack.installing-test"); + Path target = root.resolve("iris/pack"); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/example.json"), "{}"); + Files.createDirectories(stage); + + StudioSVC.copyPackTree(source, stage); + assertFalse(Files.exists(target)); + StudioSVC.publishNewDirectory(stage, target); + + assertFalse(Files.exists(stage)); + assertTrue(Files.isRegularFile(target.resolve("dimensions/example.json"))); + } + + @Test + public void existingPartialTargetIsNeverReplacedOrMerged() throws IOException { + Path root = temporaryFolder.newFolder("existing-world").toPath(); + Path stage = root.resolve("iris/.pack.installing-test"); + Path target = root.resolve("iris/pack"); + Files.createDirectories(stage); + Files.writeString(stage.resolve("new.txt"), "new"); + Files.createDirectories(target); + Files.writeString(target.resolve("sentinel.txt"), "keep"); + + assertThrows(FileAlreadyExistsException.class, () -> StudioSVC.publishNewDirectory(stage, target)); + + assertEquals("keep", Files.readString(target.resolve("sentinel.txt"))); + assertFalse(Files.exists(target.resolve("new.txt"))); + assertTrue(Files.exists(stage.resolve("new.txt"))); + } + + @Test + public void symbolicLinksInSourceAreRejectedBeforePublish() throws IOException { + Path root = temporaryFolder.newFolder("linked-source").toPath(); + Path source = root.resolve("source"); + Path stage = root.resolve("stage"); + Path outside = root.resolve("outside.txt"); + Files.createDirectories(source); + Files.createDirectories(stage); + Files.writeString(outside, "outside"); + try { + Files.createSymbolicLink(source.resolve("link.txt"), outside); + } catch (IOException | UnsupportedOperationException e) { + Assume.assumeNoException(e); + } + + assertThrows(IOException.class, () -> StudioSVC.copyPackTree(source, stage)); + assertFalse(Files.exists(stage.resolve("link.txt"))); + } + + @Test + public void rootPackSymlinkResolvesWhileNestedLinksRemainRejected() throws IOException { + Path root = temporaryFolder.newFolder("linked-pack-root").toPath(); + Path source = root.resolve("source"); + Path linkedSource = root.resolve("linked_source"); + Path stage = root.resolve("stage"); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/example.json"), "{}"); + Files.createDirectories(stage); + try { + Files.createSymbolicLink(linkedSource, source); + } catch (IOException | UnsupportedOperationException e) { + Assume.assumeNoException(e); + } + + Path resolved = StudioSVC.resolveSafePackSource(linkedSource.toFile()); + StudioSVC.copyPackTree(resolved, stage); + + assertEquals(source.toRealPath(), resolved); + assertTrue(Files.isRegularFile(stage.resolve("dimensions/example.json"))); + } + + @Test + public void rejectedPublicationEvictsCreatedLoaderBeforeDiskRollback() throws IOException { + Path root = temporaryFolder.newFolder("cache-rollback").toPath(); + Path target = root.resolve("pack"); + Path stage = root.resolve("stage"); + Files.createDirectories(target); + Files.writeString(target.resolve("sentinel.txt"), "previous"); + Files.createDirectories(stage); + Files.writeString(stage.resolve("rejected.txt"), "rejected"); + AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(stage, target); + IrisData createdData = IrisData.get(target.toFile()); + + assertSame(createdData, IrisData.getLoaded(target.toFile()).orElse(null)); + StudioSVC.rollbackFailedPublication(createdData, publication, new IOException("validation failed")); + + assertTrue(IrisData.getLoaded(target.toFile()).isEmpty()); + assertEquals("previous", Files.readString(target.resolve("sentinel.txt"))); + assertFalse(Files.exists(target.resolve("rejected.txt"))); + } + + @Test + public void createdProjectRollbackEvictsOnlyItsCachedLoaderBeforeDeletion() throws IOException { + Path root = temporaryFolder.newFolder("project-cache-rollback").toPath(); + Path project = root.resolve("created_project"); + Path sibling = root.resolve("existing_project"); + Files.createDirectories(project.resolve("dimensions")); + Files.writeString(project.resolve("dimensions/created_project.json"), "{}"); + Files.createDirectories(sibling.resolve("dimensions")); + Files.writeString(sibling.resolve("dimensions/existing_project.json"), "{}"); + IrisData createdData = IrisData.get(project.toFile()); + IrisData siblingData = IrisData.get(sibling.toFile()); + + try { + assertSame(createdData, IrisData.getLoaded(project.toFile()).orElse(null)); + assertNull(StudioSVC.rollbackCreatedProjectFiles(project.toFile())); + + assertTrue(IrisData.getLoaded(project.toFile()).isEmpty()); + assertFalse(Files.exists(project)); + assertSame(siblingData, IrisData.getLoaded(sibling.toFile()).orElse(null)); + assertTrue(Files.isDirectory(sibling)); + } finally { + IrisData.getLoaded(project.toFile()).ifPresent(IrisData::close); + IrisData.getLoaded(sibling.toFile()).ifPresent(IrisData::close); + } + } + + @Test + public void studioTransitionsWaitForTheInFlightOpenBeforeReplacement() { + StudioSVC.StudioTransitionQueue transitions = new StudioSVC.StudioTransitionQueue(); + CompletableFuture firstGate = new CompletableFuture<>(); + CompletableFuture secondGate = new CompletableFuture<>(); + List events = new ArrayList<>(); + + CompletableFuture first = transitions.submit(() -> { + events.add("first-start"); + return firstGate; + }); + CompletableFuture second = transitions.submit(() -> { + events.add("second-start"); + return secondGate; + }); + + assertEquals(List.of("first-start"), events); + assertFalse(first.isDone()); + assertFalse(second.isDone()); + + firstGate.complete("first"); + assertEquals(List.of("first-start", "second-start"), events); + assertEquals("first", first.join()); + assertFalse(second.isDone()); + + secondGate.complete("second"); + assertEquals("second", second.join()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/BulkStructureImporterTemplateNameTest.java b/core/src/test/java/art/arcane/iris/core/structure/BulkStructureImporterTemplateNameTest.java index 7ffadd9a9..ef0caccff 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/BulkStructureImporterTemplateNameTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/BulkStructureImporterTemplateNameTest.java @@ -20,7 +20,15 @@ package art.arcane.iris.core.structure; import org.junit.Test; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; public class BulkStructureImporterTemplateNameTest { @Test @@ -51,4 +59,77 @@ public class BulkStructureImporterTemplateNameTest { assertEquals("nova/a/b", BulkStructureImporter.templateNameFor("nova:a//b")); assertEquals("nova/leading", BulkStructureImporter.templateNameFor("nova:/leading")); } + + @Test + public void explicitSourceScopeIncludesOnlyOwnedRegistryKeys() { + Set allowed = Set.of("nova:tavern", "minecraft:village_plains"); + + assertTrue(BulkStructureImporter.isAllowedDatapackKey("nova:tavern", allowed)); + assertTrue(BulkStructureImporter.isAllowedDatapackKey("minecraft:village_plains", allowed)); + assertFalse(BulkStructureImporter.isAllowedDatapackKey("other:castle", allowed)); + } + + @Test + public void explicitSourceSelectionReportsEveryMissingAllowedKey() { + BulkStructureImporter.KeySelection selection = BulkStructureImporter.selectDatapackKeys( + List.of("NOVA:TAVERN", "other:castle", "nova:tavern"), + Set.of("nova:tavern", "nova:missing", "minecraft:village_plains") + ); + + assertEquals(List.of("nova:tavern"), selection.present()); + assertEquals(List.of("minecraft:village_plains", "nova:missing"), selection.missing()); + assertEquals(3, selection.total()); + } + + @Test + public void defaultSourceSelectionExcludesMinecraftAndDeduplicatesKeys() { + BulkStructureImporter.KeySelection selection = BulkStructureImporter.selectDatapackKeys( + List.of("minecraft:village", "Nova:Tavern", "nova:tavern", "other:castle"), + null + ); + + assertEquals(List.of("nova:tavern", "other:castle"), selection.present()); + assertTrue(selection.missing().isEmpty()); + assertEquals(2, selection.total()); + } + + @Test + public void datapackReportIncludesStructureAndTemplateAttempts() { + BulkStructureImporter.Report report = BulkStructureImporter.datapackReport( + 2, + 3, + 3, + 1, + 1, + Map.of("iris:nova_tavern", "nova:tavern") + ); + + assertEquals(5, report.total()); + assertEquals(3, report.imported()); + assertEquals(1, report.skipped()); + assertEquals(1, report.failed()); + } + + @Test + public void enumerationFailureIsOneFailedAttempt() { + BulkStructureImporter.Report report = BulkStructureImporter.enumerationFailureReport(); + + assertEquals(1, report.total()); + assertEquals(0, report.imported()); + assertEquals(0, report.skipped()); + assertEquals(1, report.failed()); + } + + @Test + public void successfulBundleEvidenceIsDefensiveAndImmutable() { + Map successfulBundles = new HashMap<>(); + successfulBundles.put("iris:nova_tavern", "nova:tavern"); + BulkStructureImporter.Report report = new BulkStructureImporter.Report(1, 1, 0, 0, successfulBundles); + + successfulBundles.put("iris:other_castle", "other:castle"); + + assertEquals(Map.of("iris:nova_tavern", "nova:tavern"), report.successfulBundles()); + assertThrows(UnsupportedOperationException.class, + () -> report.successfulBundles().put("iris:third", "third:structure")); + } } diff --git a/core/src/test/java/art/arcane/iris/core/structure/FeatureImporterScratchLifecycleTest.java b/core/src/test/java/art/arcane/iris/core/structure/FeatureImporterScratchLifecycleTest.java new file mode 100644 index 000000000..d6d805717 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/FeatureImporterScratchLifecycleTest.java @@ -0,0 +1,151 @@ +package art.arcane.iris.core.structure; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class FeatureImporterScratchLifecycleTest { + @Test + public void scratchWorldNamesRequireReservedUuidIdentity() { + assertTrue(FeatureImporter.isReservedScratchWorldName( + "iris-feature-import-bac1678e-9bca-4d70-9510-a146566e478c")); + assertFalse(FeatureImporter.isReservedScratchWorldName("iris_vanilla_import")); + assertFalse(FeatureImporter.isReservedScratchWorldName("iris-feature-import-not-a-uuid")); + assertFalse(FeatureImporter.isReservedScratchWorldName(null)); + } + + @Test + public void confirmedUnloadClosesGeneratorBeforeDeletingFolder() { + ArrayList phases = new ArrayList<>(); + + FeatureImporter.sequenceScratchTeardown( + () -> { + phases.add("unload"); + return CompletableFuture.completedFuture(true); + }, + () -> false, + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folder"), + "scratch" + ).join(); + + assertEquals(List.of("unload", "close-generator", "delete-folder"), phases); + } + + @Test + public void failedUnloadNeverClosesGeneratorOrDeletesFolder() { + ArrayList phases = new ArrayList<>(); + + try { + FeatureImporter.sequenceScratchTeardown( + () -> { + phases.add("unload"); + return CompletableFuture.completedFuture(false); + }, + () -> true, + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folder"), + "scratch" + ).join(); + fail("Expected unload refusal"); + } catch (CompletionException exception) { + assertTrue(exception.getCause().getMessage().contains("not confirmed")); + } + + assertEquals(List.of("unload"), phases); + } + + @Test + public void identityStillLoadedPreventsGeneratorCloseAndFolderDeletion() { + ArrayList phases = new ArrayList<>(); + + try { + FeatureImporter.sequenceScratchTeardown( + () -> { + phases.add("unload"); + return CompletableFuture.completedFuture(true); + }, + () -> true, + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folder"), + "scratch" + ).join(); + fail("Expected loaded-identity refusal"); + } catch (CompletionException exception) { + assertTrue(exception.getCause().getMessage().contains("not confirmed")); + } + + assertEquals(List.of("unload"), phases); + } + + @Test + public void generatorCloseFailureNeverDeletesFolder() { + ArrayList phases = new ArrayList<>(); + IllegalStateException failure = new IllegalStateException("close rejected"); + + try { + FeatureImporter.sequenceScratchTeardown( + () -> { + phases.add("unload"); + return CompletableFuture.completedFuture(true); + }, + () -> false, + () -> { + phases.add("close-generator"); + return CompletableFuture.failedFuture(failure); + }, + () -> phase(phases, "delete-folder"), + "scratch" + ).join(); + fail("Expected generator close failure"); + } catch (CompletionException exception) { + assertSame(failure, exception.getCause()); + } + + assertEquals(List.of("unload", "close-generator"), phases); + } + + @Test + public void terminalTimeoutPreventsLateUnloadFromClosingOrDeleting() { + ArrayList phases = new ArrayList<>(); + AtomicBoolean terminalTimeout = new AtomicBoolean(false); + CompletableFuture unload = new CompletableFuture<>(); + + CompletableFuture cleanup = FeatureImporter.sequenceScratchTeardown( + () -> { + phases.add("unload"); + return unload; + }, + () -> false, + () -> phase(phases, "close-generator"), + () -> phase(phases, "delete-folder"), + "scratch", + terminalTimeout::get); + + terminalTimeout.set(true); + unload.complete(true); + try { + cleanup.join(); + fail("Expected terminal timeout"); + } catch (CompletionException exception) { + assertEquals("Scratch world cleanup stopped after its terminal timeout.", + exception.getCause().getMessage()); + } + assertEquals(List.of("unload"), phases); + } + + private static CompletableFuture phase(List phases, String phase) { + phases.add(phase); + return CompletableFuture.completedFuture(null); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java index d50ef41f4..770d31bbe 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java @@ -313,6 +313,25 @@ public class StructureTransactionWriterTest { assertFalse(Files.exists(root.resolve("objects/temple.iob"))); } + @Test + public void recoveryBoundsTransactionDirectoryCount() throws IOException { + Path root = temporaryFolder.newFolder("bounded-transaction-count").toPath(); + Path staging = root.resolve(".iris/structure-staging"); + Files.createDirectories(staging); + for (int i = 0; i < 1_025; i++) { + Files.createDirectory(staging.resolve(UUID.randomUUID().toString())); + } + + StructureRecoveryResult recovery = new StructureTransactionWriter(root) + .recoverIncompleteTransactions(); + + assertFalse(recovery.successful()); + assertTrue(recovery.failures().getFirst().cause().getMessage().contains("transaction count")); + try (Stream transactions = Files.list(staging)) { + assertEquals(1_025L, transactions.count()); + } + } + @Test public void writeRecoversPreparedTransactionBeforePreflight() throws IOException { Path root = temporaryFolder.newFolder("write-recovery").toPath(); @@ -412,6 +431,161 @@ public class StructureTransactionWriterTest { assertFalse(Files.exists(transactionRoot)); } + @Test + public void ownedRemovalDeletesOnlyVerifiedBundleResources() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + + boolean removed = writer.removeOwned(TARGET_KEY, StructureSource.Kind.VANILLA, SOURCE_KEY); + + assertTrue(removed); + assertFalse(Files.exists(root.resolve("objects/temple.iob"))); + assertFalse(Files.exists(root.resolve("structures/temple.json"))); + assertFalse(Files.exists(writer.ownershipManifestPath(TARGET_KEY))); + assertFalse(writer.removeOwned(TARGET_KEY, StructureSource.Kind.VANILLA, SOURCE_KEY)); + } + + @Test + public void ownedRemovalPreservesModifiedResourcesAndManifest() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal-modified").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + Path object = root.resolve("objects/temple.iob"); + Files.writeString(object, "user-edit", StandardCharsets.UTF_8); + + try { + writer.removeOwned(TARGET_KEY, StructureSource.Kind.VANILLA, SOURCE_KEY); + throw new AssertionError("Expected modified owned resource to block removal"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("modified")); + } + + assertEquals("user-edit", Files.readString(object, StandardCharsets.UTF_8)); + assertTrue(Files.exists(root.resolve("structures/temple.json"))); + assertTrue(Files.exists(writer.ownershipManifestPath(TARGET_KEY))); + } + + @Test + public void preparedMultiBundleRemovalRestoresEarlierMovesWhenALaterMoveFails() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal-batch-rollback").toPath(); + StructureKey alphaKey = StructureKey.parse("iris_test:alpha"); + StructureKey zetaKey = StructureKey.parse("iris_test:zeta"); + StructureKey alphaSource = StructureKey.parse("example:alpha"); + StructureKey zetaSource = StructureKey.parse("example:zeta"); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult alphaWrite = writer.write( + bundle(alphaKey, alphaSource, "alpha-object", "alpha-structure"), + StructureWriteMode.ADD_ONLY + ); + StructureWriteResult zetaWrite = writer.write( + bundle(zetaKey, zetaSource, "zeta-object", "zeta-structure"), + StructureWriteMode.ADD_ONLY + ); + assertEquals(failureMessage(alphaWrite), StructureWriteResult.Status.ADDED, alphaWrite.status()); + assertEquals(failureMessage(zetaWrite), StructureWriteResult.Status.ADDED, zetaWrite.status()); + StructureTransactionWriter failingWriter = new StructureTransactionWriter( + root, + new FailOnceMoveOperations("objects/zeta.iob") + ); + + try { + failingWriter.prepareOwnedRemovals(List.of( + new StructureTransactionWriter.OwnedRemoval( + alphaKey, + StructureSource.Kind.DATAPACK, + alphaSource + ), + new StructureTransactionWriter.OwnedRemoval( + zetaKey, + StructureSource.Kind.DATAPACK, + zetaSource + ) + )); + throw new AssertionError("Expected the later removal move to fail"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("Injected install failure")); + } + + assertEquals("alpha-object", Files.readString(root.resolve("objects/alpha.iob"), StandardCharsets.UTF_8)); + assertEquals("zeta-object", Files.readString(root.resolve("objects/zeta.iob"), StandardCharsets.UTF_8)); + assertTrue(Files.exists(writer.ownershipManifestPath(alphaKey))); + assertTrue(Files.exists(writer.ownershipManifestPath(zetaKey))); + } + + @Test + public void preparedRemovalCanRollbackAfterCommitMarkerBeforeCoordinatorPublish() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal-coordinator-rollback").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of( + new StructureTransactionWriter.OwnedRemoval( + TARGET_KEY, + StructureSource.Kind.VANILLA, + SOURCE_KEY + ) + )); + assertFalse(Files.exists(root.resolve("objects/temple.iob"))); + + removal.markCommitted(); + removal.rollback(); + + assertEquals("object-v1", Files.readString(root.resolve("objects/temple.iob"), StandardCharsets.UTF_8)); + assertEquals("structure-v1", Files.readString(root.resolve("structures/temple.json"), StandardCharsets.UTF_8)); + assertTrue(Files.exists(writer.ownershipManifestPath(TARGET_KEY))); + } + + @Test + public void preparedRemovalTokenCanRestoreAfterTheCoordinatorProcessDies() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal-token-rollback").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of( + new StructureTransactionWriter.OwnedRemoval( + TARGET_KEY, + StructureSource.Kind.VANILLA, + SOURCE_KEY + ) + )); + StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); + removal.leaveForRecovery(); + + new StructureTransactionWriter(root).resolvePreparedRemoval(token, false); + + assertEquals("object-v1", Files.readString(root.resolve("objects/temple.iob"), StandardCharsets.UTF_8)); + assertEquals("structure-v1", Files.readString(root.resolve("structures/temple.json"), StandardCharsets.UTF_8)); + assertTrue(Files.exists(writer.ownershipManifestPath(TARGET_KEY))); + assertFalse(Files.exists(transactionRoot(root, token.transactionId()))); + } + + @Test + public void preparedRemovalTokenCanCommitAfterTheCoordinatorProcessDies() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal-token-commit").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write(bundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals(List.of( + new StructureTransactionWriter.OwnedRemoval( + TARGET_KEY, + StructureSource.Kind.VANILLA, + SOURCE_KEY + ) + )); + StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); + removal.leaveForRecovery(); + + new StructureTransactionWriter(root).resolvePreparedRemoval(token, true); + + assertFalse(Files.exists(root.resolve("objects/temple.iob"))); + assertFalse(Files.exists(root.resolve("structures/temple.json"))); + assertFalse(Files.exists(writer.ownershipManifestPath(TARGET_KEY))); + assertFalse(Files.exists(transactionRoot(root, token.transactionId()))); + } + @Test public void symbolicLinkAncestorsCannotEscapeThePackRoot() throws IOException { Path root = temporaryFolder.newFolder("symlink-pack").toPath(); @@ -431,8 +605,21 @@ public class StructureTransactionWriterTest { } private StructureResourceBundle bundle(String objectContent, String structureContent) { - return StructureResourceBundle.builder(TARGET_KEY) - .source(StructureSource.of(StructureSource.Kind.VANILLA, SOURCE_KEY)) + return bundle(TARGET_KEY, SOURCE_KEY, objectContent, structureContent); + } + + private StructureResourceBundle bundle( + StructureKey targetKey, + StructureKey sourceKey, + String objectContent, + String structureContent + ) { + return StructureResourceBundle.builder(targetKey) + .source(StructureSource.of( + sourceKey.namespace().equals("minecraft") + ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK, + sourceKey + )) .backend(StructureBackend.IRIS_ASSEMBLY) .capability(StructureCapability.BLOCKS) .capability(StructureCapability.CONNECTORS) @@ -441,8 +628,8 @@ public class StructureTransactionWriterTest { "processors_omitted", "The source processor list was not represented" )) - .resource("objects/temple.iob", objectContent.getBytes(StandardCharsets.UTF_8)) - .textResource("structures/temple.json", structureContent) + .resource("objects/" + targetKey.path() + ".iob", objectContent.getBytes(StandardCharsets.UTF_8)) + .textResource("structures/" + targetKey.path() + ".json", structureContent) .build(); } diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltEvacuationTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltEvacuationTest.java new file mode 100644 index 000000000..18cca9f30 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltEvacuationTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.core.tools; + +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisToolbeltEvacuationTest { + @Test + public void evacuationCompletionWaitsForEveryPlayerTeleport() { + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + + CompletableFuture evacuation = IrisToolbelt.settleEvacuations(List.of(first, second)); + + first.complete(true); + assertFalse(evacuation.isDone()); + second.complete(true); + assertTrue(evacuation.join()); + } + + @Test + public void failedPlayerTeleportFailsEvacuation() { + CompletableFuture evacuation = IrisToolbelt.settleEvacuations(List.of( + CompletableFuture.completedFuture(true), + CompletableFuture.completedFuture(false))); + + assertFalse(evacuation.join()); + } + + @Test + public void exceptionalPlayerTeleportFailsEvacuation() { + CompletableFuture evacuation = IrisToolbelt.settleEvacuations(List.of( + CompletableFuture.completedFuture(true), + CompletableFuture.failedFuture(new IllegalStateException("teleport failed")))); + + assertFalse(evacuation.join()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltPackReferenceTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltPackReferenceTest.java index 0d20aa104..143e7d9e6 100644 --- a/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltPackReferenceTest.java +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisToolbeltPackReferenceTest.java @@ -37,6 +37,15 @@ public class IrisToolbeltPackReferenceTest { assertTrue(reference.explicitDimension()); } + @Test + public void repositoryShorthandUsesRepositoryAsDefaultDimension() { + IrisToolbelt.PackReference reference = IrisToolbelt.parsePackReference("IrisDimensions/overworld/stable"); + + assertEquals("IrisDimensions/overworld/stable", reference.pack()); + assertEquals("overworld", reference.dimension()); + assertFalse(reference.explicitDimension()); + } + @Test public void malformedReferencesAreRejected() { assertNull(IrisToolbelt.parsePackReference(null)); @@ -44,6 +53,14 @@ public class IrisToolbeltPackReferenceTest { assertNull(IrisToolbelt.parsePackReference(":")); assertNull(IrisToolbelt.parsePackReference("pack:")); assertNull(IrisToolbelt.parsePackReference(":dimension")); + assertNull(IrisToolbelt.parsePackReference("../outside:dimension")); + assertNull(IrisToolbelt.parsePackReference("owner/../outside:dimension")); + assertNull(IrisToolbelt.parsePackReference("/absolute:dimension")); + assertNull(IrisToolbelt.parsePackReference(".iris-import-stage:dimension")); + assertNull(IrisToolbelt.parsePackReference("pack:../../outside")); + assertNull(IrisToolbelt.parsePackReference("pack:/absolute")); + assertNull(IrisToolbelt.parsePackReference("pack:nested\\outside")); + assertNull(IrisToolbelt.parsePackReference("pack:.hidden")); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java b/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java new file mode 100644 index 000000000..b7349a752 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/EngineShutdownOwnershipContractTest.java @@ -0,0 +1,42 @@ +package art.arcane.iris.engine; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class EngineShutdownOwnershipContractTest { + @Test + public void ownershipCloseMustSucceedBeforeMantleRelease() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")); + int ownershipClose = source.indexOf("NativeStructureOwnershipStore.close(engine)"); + int ownershipGate = source.indexOf("if (ownershipFailure == null)", ownershipClose); + int mantleRelease = source.indexOf("releaseMantle(failure)", ownershipGate); + + assertTrue(ownershipClose >= 0); + assertTrue(ownershipGate > ownershipClose); + assertTrue(mantleRelease > ownershipGate); + } + + @Test + public void failedConstructionKeepsMantleOpenWhileOwnershipWritesRemain() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")); + int cleanupStart = source.indexOf("void cleanupFailedConstruction"); + int cleanupEnd = source.indexOf("Throwable closeAssembly", cleanupStart); + String cleanup = source.substring(cleanupStart, cleanupEnd); + int ownershipClose = cleanup.indexOf("NativeStructureOwnershipStore.close(engine)"); + int ownershipGate = cleanup.indexOf("if (ownershipFailure == null)", ownershipClose); + int mantleRelease = cleanup.indexOf("engine.getMantle()::close", ownershipGate); + int closedPublication = cleanup.indexOf("engine.closed = true", ownershipGate); + + assertTrue(ownershipClose >= 0); + assertTrue(ownershipGate > ownershipClose); + assertTrue(mantleRelease > ownershipGate); + assertTrue(closedPublication > ownershipGate); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java index d7e01feab..de38234e3 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java @@ -46,6 +46,7 @@ import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -354,6 +355,57 @@ public class IrisStructureLocatorContractTest { verify(engine, times(4_096)).getComplex(); } + @Test + public void densityLocateDoesNotReturnPartialRingWinnerAtSafetyLimit() { + Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032); + + IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( + engine, "test:density", 0, 0, 2048, + (chunkX, chunkZ) -> chunkX == 31 && chunkZ == 31 + || chunkX == 0 && chunkZ == 32); + + assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status()); + assertFalse(result.found()); + } + + @Test + public void zeroRadiusDoesNotSearchAdjacentDensityChunks() { + Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032); + + IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( + engine, "test:density", 0, 0, 0, + (chunkX, chunkZ) -> chunkX == 1 && chunkZ == 0); + + assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, result.status()); + } + + @Test + public void locateRanksEditableStartsByResolvedBlockOrigin() { + Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032); + when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(128); + when(engine.getDimension().getFluidHeight()).thenReturn(63); + IrisStructurePlacement placement = engine.getDimension().getStructures().get(0); + NearestScenario scenario = nearestEqualChunkDistanceScenario(engine, placement); + assertNotNull(scenario); + + IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( + engine, "test:density", scenario.fromBlockX(), scenario.fromBlockZ(), 1, + (chunkX, chunkZ) -> chunkX == scenario.firstChunkX() && chunkZ == scenario.firstChunkZ() + || chunkX == scenario.nearestChunkX() && chunkZ == scenario.nearestChunkZ()); + + assertEquals(IrisStructureLocator.LocateStatus.FOUND, result.status()); + assertEquals(scenario.nearestOriginX(), result.originX()); + assertEquals(scenario.nearestOriginZ(), result.originZ()); + } + + @Test + public void diagonalRadiusBoundaryUsesVanillaChebyshevSemantics() { + assertTrue(IrisStructureLocator.withinRadius(1, 1, 0, 0, 1)); + assertTrue(IrisStructureLocator.withinRadius(-1, -1, 0, 0, 1)); + assertFalse(IrisStructureLocator.withinRadius(2, 1, 0, 0, 1)); + assertFalse(IrisStructureLocator.withinRadius(1, 1, 0, 0, 0)); + } + @Test public void searchableDensityRequiresPositiveProbabilityAndWorldHeightOverlap() { Engine engine = mock(Engine.class); @@ -566,6 +618,60 @@ public class IrisStructureLocatorContractTest { assertEquals(1L, IrisStructureLocator.cellRingDistanceLowerBound(1, 32, 0, 0, 0, 0)); } + @Test + public void chunkBlockLowerBoundSaturatesAtExtremeCoordinates() { + assertEquals(Long.MAX_VALUE, IrisStructureLocator.chunkBlockDistanceSquaredLowerBound( + Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE)); + assertEquals(1L, IrisStructureLocator.chunkBlockDistanceSquaredLowerBound(1, 0, 15, 8)); + assertEquals(0L, IrisStructureLocator.chunkBlockDistanceSquaredLowerBound(0, 0, 15, 8)); + } + + private NearestScenario nearestEqualChunkDistanceScenario( + Engine engine, IrisStructurePlacement placement) { + int[][] chunks = { + {-1, -1}, {-1, 0}, {-1, 1}, {0, -1}, + {0, 1}, {1, -1}, {1, 0}, {1, 1} + }; + IrisStructureLocator.ResolvedPlacement[] resolved = + new IrisStructureLocator.ResolvedPlacement[chunks.length]; + for (int i = 0; i < chunks.length; i++) { + resolved[i] = IrisStructureLocator.resolvePlacement( + engine, placement, chunks[i][0], chunks[i][1]); + assertNotNull(resolved[i]); + } + for (int fromBlockX = 0; fromBlockX < 16; fromBlockX++) { + for (int fromBlockZ = 0; fromBlockZ < 16; fromBlockZ++) { + for (int first = 0; first < chunks.length; first++) { + int firstChunkDistance = chunks[first][0] * chunks[first][0] + + chunks[first][1] * chunks[first][1]; + long firstDistance = resolvedDistanceSquared( + resolved[first], fromBlockX, fromBlockZ); + for (int later = first + 1; later < chunks.length; later++) { + int laterChunkDistance = chunks[later][0] * chunks[later][0] + + chunks[later][1] * chunks[later][1]; + long laterDistance = resolvedDistanceSquared( + resolved[later], fromBlockX, fromBlockZ); + if (firstChunkDistance == laterChunkDistance && laterDistance < firstDistance) { + return new NearestScenario( + fromBlockX, fromBlockZ, + chunks[first][0], chunks[first][1], + chunks[later][0], chunks[later][1], + resolved[later].originX(), resolved[later].originZ()); + } + } + } + } + } + return null; + } + + private long resolvedDistanceSquared( + IrisStructureLocator.ResolvedPlacement resolved, int fromBlockX, int fromBlockZ) { + long dx = (long) resolved.originX() - fromBlockX; + long dz = (long) resolved.originZ() - fromBlockZ; + return dx * dx + dz * dz; + } + private PlacedStructurePiece piece(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { return new PlacedStructurePiece(null, null, 0, 0, 0, null, minX, minY, minZ, maxX, maxY, maxZ); } @@ -639,4 +745,15 @@ public class IrisStructureLocatorContractTest { } throw new AssertionError("Expected strict native replacement failure containing '" + expectedMessage + "'"); } + + private record NearestScenario( + int fromBlockX, + int fromBlockZ, + int firstChunkX, + int firstChunkZ, + int nearestChunkX, + int nearestChunkZ, + int nearestOriginX, + int nearestOriginZ) { + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructurePlacementRingLocatorTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructurePlacementRingLocatorTest.java new file mode 100644 index 000000000..7b88fa501 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructurePlacementRingLocatorTest.java @@ -0,0 +1,191 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.StructureDistribution; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisStructurePlacementRingLocatorTest { + private static final String STRUCTURE_KEY = "test:manual_native"; + + @Test + public void zeroVanillaRingSearchesTheOriginPlacementCell() { + IrisStructurePlacement placement = randomPlacement("origin-cell", 24, 6); + Engine engine = engine(placement); + long seed = engine.getSeedManager().getMantle(); + int[] expected = StructurePlacementGrid.randomSpreadCellChunk( + 0, 0, placement.getSpacing(), placement.getSeparation(), + StructurePlacementGrid.placementSalt(placement), seed); + assertTrue(expected[0] != 0 || expected[1] != 0); + + IrisStructureLocator.LocateResult chunkRadius = IrisStructureLocator.locate( + engine, STRUCTURE_KEY, 0, 0, 0); + IrisStructureLocator.LocateResult placementRadius = + IrisStructureLocator.locateInPlacementRings( + engine, STRUCTURE_KEY, 0, 0, 0, (chunkX, chunkZ) -> true); + + assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, chunkRadius.status()); + assertEquals(IrisStructureLocator.LocateStatus.FOUND, placementRadius.status()); + assertEquals(expected[0] << 4, placementRadius.originX()); + assertEquals(expected[1] << 4, placementRadius.originZ()); + } + + @Test + public void mixedSpacingPlacementsShareTheSameVanillaRingIndex() { + IrisStructurePlacement small = randomPlacement("small-grid", 8, 2); + IrisStructurePlacement large = randomPlacement("large-grid", 40, 10); + Engine engine = engine(small, large); + long seed = engine.getSeedManager().getMantle(); + int[] smallRingZero = candidate(small, seed, 0, 0); + int[] largeRingZero = candidate(large, seed, 0, 0); + int[] smallRingOne = candidate(small, seed, -1, -1); + AtomicBoolean visitedLaterSmallRing = new AtomicBoolean(); + + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locateInPlacementRings( + engine, STRUCTURE_KEY, 0, 0, 1, + (chunkX, chunkZ) -> { + if (chunkX == smallRingOne[0] && chunkZ == smallRingOne[1]) { + visitedLaterSmallRing.set(true); + } + return chunkX == largeRingZero[0] && chunkZ == largeRingZero[1]; + }); + + assertTrue(smallRingZero[0] != largeRingZero[0] + || smallRingZero[1] != largeRingZero[1]); + assertEquals(IrisStructureLocator.LocateStatus.FOUND, result.status()); + assertEquals(largeRingZero[0] << 4, result.originX()); + assertEquals(largeRingZero[1] << 4, result.originZ()); + assertFalse(visitedLaterSmallRing.get()); + } + + @Test + public void concentricPlacementsIgnoreTheRandomSpreadRingRadius() { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setPlacementId("concentric") + .setDistribution(StructureDistribution.CONCENTRIC_RINGS) + .setRingCount(1) + .setRingDistance(32) + .setRingSpread(1); + placement.getNativeStructures().add( + new IrisNativeStructure().setStructure(STRUCTURE_KEY)); + Engine engine = engine(placement); + + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locateInPlacementRings( + engine, STRUCTURE_KEY, 0, 0, 0, (chunkX, chunkZ) -> true); + + assertEquals(IrisStructureLocator.LocateStatus.FOUND, result.status()); + long chunkX = result.originX() >> 4; + long chunkZ = result.originZ() >> 4; + assertTrue(chunkX * chunkX + chunkZ * chunkZ >= 31L * 31L); + } + + @Test + public void sparseDensityPlacementRingSearchStopsAtTheCandidateBudget() { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setPlacementId("density") + .setDistribution(StructureDistribution.DENSITY) + .setDensity(1.0); + placement.getNativeStructures().add( + new IrisNativeStructure().setStructure(STRUCTURE_KEY)); + Engine engine = engine(placement); + + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locateInPlacementRings( + engine, STRUCTURE_KEY, 0, 0, 2048, + (chunkX, chunkZ) -> false); + + assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status()); + } + + @Test + public void concentricPlacementRingSearchStopsAtTheSharedCandidateBudget() { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setPlacementId("unbounded-concentric-rings") + .setDistribution(StructureDistribution.CONCENTRIC_RINGS) + .setRingCount(Integer.MAX_VALUE) + .setRingDistance(32) + .setRingSpread(1); + placement.getNativeStructures().add( + new IrisNativeStructure().setStructure(STRUCTURE_KEY)); + Engine engine = engine(placement); + + IrisStructureLocator.LocateResult result = IrisStructureLocator.locateInPlacementRings( + engine, STRUCTURE_KEY, 0, 0, 2048, (chunkX, chunkZ) -> false); + + assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status()); + } + + @Test + public void concentricChunkRadiusSearchStopsAtTheSharedCandidateBudget() { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setPlacementId("unbounded-concentric-chunks") + .setDistribution(StructureDistribution.CONCENTRIC_RINGS) + .setRingCount(Integer.MAX_VALUE) + .setRingDistance(32) + .setRingSpread(1); + placement.getNativeStructures().add( + new IrisNativeStructure().setStructure(STRUCTURE_KEY)); + Engine engine = engine(placement); + + IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( + engine, STRUCTURE_KEY, 0, 0, 2048, (chunkX, chunkZ) -> false); + + assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status()); + } + + private static int[] candidate( + IrisStructurePlacement placement, long seed, int cellX, int cellZ) { + return StructurePlacementGrid.randomSpreadCellChunk( + cellX, cellZ, placement.getSpacing(), placement.getSeparation(), + StructurePlacementGrid.placementSalt(placement), seed); + } + + private static IrisStructurePlacement randomPlacement( + String placementId, int spacing, int separation) { + IrisStructurePlacement placement = new IrisStructurePlacement() + .setPlacementId(placementId) + .setDistribution(StructureDistribution.RANDOM_SPREAD) + .setSpacing(spacing) + .setSeparation(separation); + placement.getNativeStructures().add( + new IrisNativeStructure().setStructure(STRUCTURE_KEY)); + return placement; + } + + private static Engine engine(IrisStructurePlacement... placements) { + IrisData data = mock(IrisData.class); + IrisDimension dimension = mock(IrisDimension.class); + Engine engine = mock(Engine.class); + KList configured = new KList<>(); + for (IrisStructurePlacement placement : placements) { + configured.add(placement); + } + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getSeedManager()).thenReturn(new SeedManager(1337L)); + when(engine.getComplex()).thenReturn(null); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + when(engine.getHeight(anyInt(), anyInt(), anyBoolean())).thenReturn(128); + when(dimension.getFluidHeight()).thenReturn(63); + when(dimension.getStructures()).thenReturn(configured); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); + return engine; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecordTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecordTest.java new file mode 100644 index 000000000..8041e2202 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecordTest.java @@ -0,0 +1,343 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisStructureCarveShape; +import art.arcane.iris.engine.object.IrisStructureStiltSettings; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class NativeStructureOwnershipRecordTest { + private static final String FINGERPRINT = "12".repeat(32); + + @Test + public void binaryRoundTripPreservesMultipleVersionedOwnershipRecords() throws Exception { + NativeStructureOwnershipRecord tavern = record("nova_structures:tavern_oak", 4, -7, 71L); + NativeStructureOwnershipRecord crypt = record("nova_structures:undead_crypt", 4, -7, 72L); + NativeStructureOwnershipBundle bundle = NativeStructureOwnershipBundle.empty() + .with(tavern) + .with(crypt); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + bundle.write(new DataOutputStream(encoded)); + NativeStructureOwnershipBundle restored = NativeStructureOwnershipBundle.read( + new DataInputStream(new ByteArrayInputStream(encoded.toByteArray()))); + + assertNotNull(restored); + assertEquals(tavern, restored.find("NOVA_STRUCTURES:TAVERN_OAK", 4, -7)); + assertEquals(crypt, restored.find("nova_structures:undead_crypt", 4, -7)); + } + + @Test + public void decisionSnapshotFreezesTerrainVegetationAndStiltSettings() { + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setHorizontalPadding(9) + .setCeilingPadding(3) + .setFloorPadding(2) + .setShape(IrisStructureCarveShape.ERODED) + .setErosionStrength(0.42D) + .setErosionFrequency(0.17D) + .setLobeFrequency(0.03D) + .setLobeStrength(0.61D); + IrisStructureStiltSettings stilt = new IrisStructureStiltSettings() + .setMaxDepth(91) + .setSpacing(4) + .setSupportNonOccluding(true); + IrisNativeStructureDecision source = new IrisNativeStructureDecision( + NativeStructureGenerationStatus.GENERATE_NATIVE, + 0, + null, + false, + true, + stilt, + terrain + ); + + NativeStructureOwnershipRecord.DecisionSnapshot snapshot = + NativeStructureOwnershipRecord.DecisionSnapshot.capture(source); + terrain.setHorizontalPadding(1); + stilt.setMaxDepth(2); + IrisNativeStructureDecision restored = snapshot.restore(); + + assertTrue(restored.generate()); + assertTrue(restored.clearVegetation()); + assertEquals(IrisStructureTerrainMode.FORCE_CARVE, restored.terrain().resolvedMode()); + assertEquals(IrisStructureCarveShape.ERODED, restored.terrain().resolvedShape()); + assertEquals(9, restored.terrain().getHorizontalPadding()); + assertEquals(0.42D, restored.terrain().getErosionStrength(), 0D); + assertNotNull(restored.stilt()); + assertEquals(91, restored.stilt().getMaxDepth()); + assertEquals(4, restored.stilt().getSpacing()); + assertTrue(restored.stilt().isSupportNonOccluding()); + } + + @Test + public void recordCarriesLocateAndReferenceEnvelopeState() { + NativeStructureOwnershipRecord record = record("nova_structures:tavern_oak", -3, 9, 83L); + + assertEquals(47, record.baseY()); + assertEquals(43, record.contentMinY()); + assertEquals(78, record.contentMaxY()); + assertEquals(58, record.locatorY()); + assertTrue(record.covers(-5, 11)); + assertFalse(record.covers(-6, 11)); + assertEquals(83L, record.placementIdentity()); + } + + @Test + public void ownershipRoundTripPreservesExactBoundsAndPriority() throws Exception { + NativeStructureOwnershipRecord ownership = record( + "nova_structures:tavern_oak", -3, 9, 83L); + NativeStructureOwnershipBundle bundle = NativeStructureOwnershipBundle.empty().with(ownership); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + bundle.write(new DataOutputStream(encoded)); + + NativeStructureOwnershipRecord restored = NativeStructureOwnershipBundle.read( + new DataInputStream(new ByteArrayInputStream(encoded.toByteArray()))) + .find(ownership.structureKey(), ownership.originChunkX(), ownership.originChunkZ()); + + assertNotNull(restored); + assertEquals(ownership.placementIdentity(), restored.placementIdentity()); + assertEquals(ownership.contentMinX(), restored.contentMinX()); + assertEquals(ownership.contentMinY(), restored.contentMinY()); + assertEquals(ownership.contentMinZ(), restored.contentMinZ()); + assertEquals(ownership.contentMaxX(), restored.contentMaxX()); + assertEquals(ownership.contentMaxY(), restored.contentMaxY()); + assertEquals(ownership.contentMaxZ(), restored.contentMaxZ()); + } + + @Test + public void invalidOrUnboundedPayloadsFailClosed() { + assertThrows(IllegalArgumentException.class, () -> new NativeStructureOwnershipRecord( + NativeStructureOwnershipRecord.CURRENT_SCHEMA, + "nova_structures:tavern_oak", + 0, + 0, + 1L, + 47, + 0, + 43, + 0, + 15, + 78, + 15, + 58, + -9, + 0, + 0, + 0, + FINGERPRINT, + decision() + )); + + Map records = + new LinkedHashMap<>(); + for (int i = 0; i <= NativeStructureOwnershipBundle.MAX_RECORDS; i++) { + NativeStructureOwnershipRecord record = record("test:structure_" + i, 0, 0, i); + records.put(record.ownershipKey(), record); + } + assertThrows(IllegalArgumentException.class, + () -> new NativeStructureOwnershipBundle(records)); + + Map oversized = + new LinkedHashMap<>(); + NativeStructureOwnershipRecord.DecisionSnapshot largeDecision = + new NativeStructureOwnershipRecord.DecisionSnapshot( + false, + "null", + "{\"unused\":\"" + "x".repeat(65_000) + "\"}" + ); + for (int i = 0; i < 33; i++) { + NativeStructureOwnershipRecord record = new NativeStructureOwnershipRecord( + NativeStructureOwnershipRecord.CURRENT_SCHEMA, + "test:large_" + i, + 0, + 0, + i, + 47, + 0, + 43, + 0, + 15, + 78, + 15, + 58, + -1, + 1, + -1, + 1, + FINGERPRINT, + largeDecision + ); + oversized.put(record.ownershipKey(), record); + } + assertThrows(IllegalArgumentException.class, + () -> new NativeStructureOwnershipBundle(oversized)); + } + + @Test + public void referenceDistanceChecksCannotBeBypassedByIntegerOverflow() { + assertThrows(IllegalArgumentException.class, () -> new NativeStructureOwnershipRecord( + NativeStructureOwnershipRecord.CURRENT_SCHEMA, + "test:overflow", + Integer.MAX_VALUE, + 0, + 1L, + 47, + 0, + 43, + 0, + 15, + 78, + 15, + 58, + Integer.MIN_VALUE, + Integer.MAX_VALUE, + 0, + 0, + FINGERPRINT, + decision() + )); + } + + @Test + public void contentBoundsMustRemainInsideThePersistedReferenceEnvelope() { + assertThrows(IllegalArgumentException.class, () -> new NativeStructureOwnershipRecord( + NativeStructureOwnershipRecord.CURRENT_SCHEMA, + "test:outside_envelope", + 0, + 0, + 1L, + 47, + -16, + 43, + -16, + 32, + 78, + 31, + 58, + -1, + 1, + -1, + 1, + FINGERPRINT, + decision() + )); + } + + @Test + public void displacedReferenceEnvelopeMayExcludeTheStartChunk() { + NativeStructureOwnershipRecord record = new NativeStructureOwnershipRecord( + NativeStructureOwnershipRecord.CURRENT_SCHEMA, + "test:displaced", + 0, + 0, + 1L, + 47, + 80, + 43, + 0, + 95, + 78, + 15, + 58, + 5, + 5, + 0, + 0, + FINGERPRINT, + decision() + ); + + assertFalse(record.covers(0, 0)); + assertTrue(record.covers(5, 0)); + } + + @Test + public void nullableStiltRoundTripsAsNull() { + NativeStructureOwnershipRecord record = record("test:no_stilt", 0, 0, 1L); + assertNull(record.restoredDecision().stilt()); + } + + @Test + public void malformedButSizedBinaryRecordsFailAsIoErrors() throws Exception { + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(encoded); + output.writeInt(NativeStructureOwnershipRecord.CURRENT_SCHEMA); + NativeStructureOwnershipRecord.writeString(output, " ", 512, "structure key"); + output.writeInt(0); + output.writeInt(0); + output.writeLong(1L); + output.writeInt(47); + output.writeInt(0); + output.writeInt(43); + output.writeInt(0); + output.writeInt(15); + output.writeInt(78); + output.writeInt(15); + output.writeInt(58); + output.writeInt(-1); + output.writeInt(1); + output.writeInt(-1); + output.writeInt(1); + NativeStructureOwnershipRecord.writeString(output, FINGERPRINT, 128, "content fingerprint"); + decision().write(output); + + assertThrows(IOException.class, () -> NativeStructureOwnershipRecord.read( + new DataInputStream(new ByteArrayInputStream(encoded.toByteArray())))); + } + + private static NativeStructureOwnershipRecord record(String key, int originX, int originZ, + long placementIdentity) { + return new NativeStructureOwnershipRecord( + NativeStructureOwnershipRecord.CURRENT_SCHEMA, + key, + originX, + originZ, + placementIdentity, + 47, + originX << 4, + 43, + originZ << 4, + (originX << 4) + 31, + 78, + (originZ << 4) + 31, + 58, + originX - 2, + originX + 2, + originZ - 2, + originZ + 2, + FINGERPRINT, + decision() + ); + } + + private static NativeStructureOwnershipRecord.DecisionSnapshot decision() { + return NativeStructureOwnershipRecord.DecisionSnapshot.capture( + new IrisNativeStructureDecision( + NativeStructureGenerationStatus.GENERATE_NATIVE, + 0, + null, + false, + true, + null, + new IrisStructureTerrain() + )); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java new file mode 100644 index 000000000..615cd5395 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java @@ -0,0 +1,518 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import org.junit.Test; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NativeStructureOwnershipStoreTest { + private static final String FINGERPRINT = "34".repeat(32); + + @Test + public void fullReferenceEnvelopeWritesOnlyItsOriginAuthority() { + Engine engine = engine(); + TestStorage storage = new TestStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record("test:origin_only", 4, -7, 91L, false); + + state.record(record); + + assertEquals(1, storage.chunks.size()); + assertEquals(record, storage.find( + NativeStructureOwnershipStore.pack(4, -7), record)); + assertNull(storage.find( + NativeStructureOwnershipStore.pack(4, -6), record)); + assertEquals(record, state.find( + 4, -6, record.structureKey(), record.originChunkX(), record.originChunkZ())); + } + + @Test + public void originAuthorityIgnoresAStaleTargetReplicaWhenOnlyPolicyChanged() { + Engine engine = engine(); + TestStorage storage = new TestStorage(); + NativeStructureOwnershipRecord stale = record("test:replacement", 2, 3, 11L, false); + NativeStructureOwnershipRecord current = record("test:replacement", 2, 3, 11L, true); + long origin = NativeStructureOwnershipStore.pack(2, 3); + long target = NativeStructureOwnershipStore.pack(3, 4); + storage.write(origin, current); + storage.write(target, stale); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + + NativeStructureOwnershipRecord resolved = state.find( + 3, 4, current.structureKey(), 2, 3); + + assertEquals(current, resolved); + assertTrue(resolved.restoredDecision().clearVegetation()); + assertEquals(stale.contentFingerprint(), resolved.contentFingerprint()); + assertEquals(stale, storage.find(target, stale)); + } + + @Test + public void persistedOriginAuthorityRemainsVisibleOutsideItsReferenceEnvelope() { + Engine engine = engine(); + TestStorage storage = new TestStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record( + "test:narrow_authority", 8, -3, 19L, false, 1); + state.record(record); + + assertNull(state.find( + 10, -3, record.structureKey(), record.originChunkX(), record.originChunkZ())); + assertEquals(record, state.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + } + + @Test + public void staleTargetReplicaCannotReplaceAMissingOriginAuthority() { + Engine engine = engine(); + TestStorage storage = new TestStorage(); + NativeStructureOwnershipRecord stale = record("test:deleted", -2, 5, 17L, false); + storage.write(NativeStructureOwnershipStore.pack(-1, 5), stale); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + + assertNull(state.find(-1, 5, stale.structureKey(), -2, 5)); + } + + @Test + public void denseOverlappingEnvelopesDoNotAmplifyIntoTheTargetChunk() { + Engine engine = engine(); + TestStorage storage = new TestStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + int records = 0; + for (int chunkX = -8; chunkX <= 8; chunkX++) { + for (int chunkZ = -8; chunkZ <= 8; chunkZ++) { + NativeStructureOwnershipRecord record = record( + "test:dense_" + chunkX + "_" + chunkZ, + chunkX, chunkZ, records, false); + state.record(record); + assertEquals(record, state.find( + 0, 0, record.structureKey(), chunkX, chunkZ)); + records++; + } + } + + assertEquals(289, records); + assertEquals(289, storage.chunks.size()); + NativeStructureOwnershipBundle target = storage.chunks.get( + NativeStructureOwnershipStore.pack(0, 0)); + assertEquals(1, target.records().size()); + } + + @Test + public void flushWaitsForAnOriginWriteAlreadyInFlight() throws Exception { + Engine engine = engine(); + BlockingStorage storage = new BlockingStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record("test:flush_race", -4, 8, 42L, false); + storage.blockedTarget = NativeStructureOwnershipStore.pack(-4, 8); + ExecutorService callers = Executors.newFixedThreadPool(2); + try { + Future recording = callers.submit(() -> state.record(record)); + assertTrue(storage.writeEntered.await(5, TimeUnit.SECONDS)); + Future flushing = callers.submit(state::flush); + + assertThrows(TimeoutException.class, + () -> flushing.get(100, TimeUnit.MILLISECONDS)); + assertEquals(0, storage.flushes); + storage.allowWrite.countDown(); + recording.get(5, TimeUnit.SECONDS); + flushing.get(5, TimeUnit.SECONDS); + assertEquals(1, storage.flushes); + } finally { + callers.shutdownNow(); + } + } + + @Test + public void closeWaitsForAnOriginWriteAlreadyInFlight() throws Exception { + Engine engine = engine(); + BlockingStorage storage = new BlockingStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record("test:close_race", 6, -9, 73L, false); + storage.blockedTarget = NativeStructureOwnershipStore.pack(6, -9); + ExecutorService callers = Executors.newFixedThreadPool(2); + try { + Future recording = callers.submit(() -> state.record(record)); + assertTrue(storage.writeEntered.await(5, TimeUnit.SECONDS)); + Future closing = callers.submit(state::close); + + assertThrows(TimeoutException.class, + () -> closing.get(100, TimeUnit.MILLISECONDS)); + storage.allowWrite.countDown(); + recording.get(5, TimeUnit.SECONDS); + closing.get(5, TimeUnit.SECONDS); + } finally { + callers.shutdownNow(); + } + } + + @Test + public void closingEngineAllowsFirstOwnershipWriteUntilExplicitClose() { + Engine engine = engine(); + TestStorage storage = new TestStorage(); + when(engine.isClosing()).thenReturn(true); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record( + "test:closing_session", -7, 12, 74L, false); + + state.record(record); + + assertEquals(record, state.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + state.close(); + assertThrows(IllegalStateException.class, () -> state.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + } + + @Test + public void explicitCloseCreatesAndRetainsAClosedOwnershipState() { + Engine engine = engine(); + when(engine.isClosing()).thenReturn(true); + + NativeStructureOwnershipStore.close(engine); + + assertThrows(IllegalStateException.class, () -> + NativeStructureOwnershipStore.findPersisted( + engine, "test:first_closing_use", 0, 0)); + } + + @Test + public void sameOriginWritesCannotPublishCacheAuthorityOutOfPersistenceOrder() throws Exception { + Engine engine = engine(); + PostWriteBlockingStorage storage = new PostWriteBlockingStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord first = record("test:same_origin", 3, -6, 1L, false); + NativeStructureOwnershipRecord second = record("test:same_origin", 3, -6, 2L, true); + ExecutorService callers = Executors.newFixedThreadPool(2); + try { + Future firstWrite = callers.submit(() -> state.record(first)); + assertTrue(storage.firstPersisted.await(5, TimeUnit.SECONDS)); + Future secondWrite = callers.submit(() -> state.record(second)); + + assertThrows(TimeoutException.class, + () -> secondWrite.get(100, TimeUnit.MILLISECONDS)); + storage.allowFirstReturn.countDown(); + firstWrite.get(5, TimeUnit.SECONDS); + secondWrite.get(5, TimeUnit.SECONDS); + + assertEquals(second, state.find( + 3, -6, second.structureKey(), + second.originChunkX(), second.originChunkZ())); + assertEquals(second, storage.find( + NativeStructureOwnershipStore.pack(3, -6), second)); + } finally { + callers.shutdownNow(); + } + } + + @Test + public void successfulAutosaveFlushSurvivesAbruptStateLoss() { + Engine engine = engine(); + CrashableStorage storage = new CrashableStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record( + "test:autosave_durable", -11, 14, 101L, true); + + state.record(record); + state.flush(); + storage.crash(); + + NativeStructureOwnershipStore.State recovered = + new NativeStructureOwnershipStore.State(engine, storage); + assertEquals(record, recovered.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + assertEquals(1, storage.flushes); + } + + @Test + public void failedFlushRemainsDirtyForSuccessfulRetry() { + Engine engine = engine(); + FailingFlushStorage storage = new FailingFlushStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record( + "test:flush_retry", 12, -15, 102L, false); + + state.record(record); + assertThrows(IllegalStateException.class, state::flush); + state.flush(); + storage.crash(); + + NativeStructureOwnershipStore.State recovered = + new NativeStructureOwnershipStore.State(engine, storage); + assertEquals(record, recovered.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + assertEquals(2, storage.flushes); + } + + @Test + public void cleanFlushPerformsNoStorageIo() { + Engine engine = engine(); + CrashableStorage storage = new CrashableStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record( + "test:clean_flush", 16, 17, 103L, false); + + state.flush(); + state.record(record); + state.flush(); + state.flush(); + + assertEquals(1, storage.flushes); + } + + @Test + public void discardedAuthorityRemainsAbsentAfterAutosaveAndAbruptStateLoss() { + Engine engine = engine(); + CrashableStorage storage = new CrashableStorage(); + NativeStructureOwnershipRecord record = record( + "test:discard_durable", -18, 19, 104L, false); + storage.write(NativeStructureOwnershipStore.pack(-18, 19), record); + storage.flush(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + + state.discard(record.structureKey(), record.originChunkX(), record.originChunkZ()); + state.flush(); + storage.crash(); + + NativeStructureOwnershipStore.State recovered = + new NativeStructureOwnershipStore.State(engine, storage); + assertNull(recovered.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + assertEquals(2, storage.flushes); + } + + @Test + public void successfulCloseFlushSurvivesAbruptStateLoss() { + Engine engine = engine(); + CrashableStorage storage = new CrashableStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record( + "test:close_durable", 20, -21, 105L, true); + + state.record(record); + state.close(); + storage.crash(); + + NativeStructureOwnershipStore.State recovered = + new NativeStructureOwnershipStore.State(engine, storage); + assertEquals(record, recovered.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + assertEquals(1, storage.flushes); + } + + @Test + public void failedCloseLeavesTheStoreOpenAndDirtyForRetry() { + Engine engine = engine(); + FailingFlushStorage storage = new FailingFlushStorage(); + NativeStructureOwnershipStore.State state = + new NativeStructureOwnershipStore.State(engine, storage); + NativeStructureOwnershipRecord record = record( + "test:close_retry", -22, 23, 106L, false); + + state.record(record); + assertThrows(IllegalStateException.class, state::close); + assertEquals(record, state.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + + state.close(); + storage.crash(); + NativeStructureOwnershipStore.State recovered = + new NativeStructureOwnershipStore.State(engine, storage); + assertEquals(record, recovered.findPersisted( + record.structureKey(), record.originChunkX(), record.originChunkZ())); + assertEquals(2, storage.flushes); + } + + private static Engine engine() { + Engine engine = mock(Engine.class); + when(engine.isClosing()).thenReturn(false); + when(engine.isClosed()).thenReturn(false); + return engine; + } + + private static NativeStructureOwnershipRecord record(String key, int originX, int originZ, + long placementIdentity, + boolean clearVegetation) { + return record(key, originX, originZ, placementIdentity, clearVegetation, 8); + } + + private static NativeStructureOwnershipRecord record(String key, int originX, int originZ, + long placementIdentity, + boolean clearVegetation, + int referenceRadius) { + return new NativeStructureOwnershipRecord( + NativeStructureOwnershipRecord.CURRENT_SCHEMA, + key, + originX, + originZ, + placementIdentity, + 47, + originX << 4, + 43, + originZ << 4, + (originX << 4) + 31, + 78, + (originZ << 4) + 31, + 58, + originX - referenceRadius, + originX + referenceRadius, + originZ - referenceRadius, + originZ + referenceRadius, + FINGERPRINT, + NativeStructureOwnershipRecord.DecisionSnapshot.capture( + new IrisNativeStructureDecision( + NativeStructureGenerationStatus.GENERATE_NATIVE, + 0, + null, + false, + clearVegetation, + null, + new IrisStructureTerrain() + )) + ); + } + + private static class TestStorage implements NativeStructureOwnershipStore.Storage { + protected final Map chunks = new ConcurrentHashMap<>(); + protected volatile int flushes; + + @Override + public NativeStructureOwnershipBundle read(int chunkX, int chunkZ) { + return chunks.get(NativeStructureOwnershipStore.pack(chunkX, chunkZ)); + } + + @Override + public void write(long target, NativeStructureOwnershipRecord record) { + chunks.compute(target, (ignored, bundle) -> + (bundle == null ? NativeStructureOwnershipBundle.empty() : bundle).with(record)); + } + + @Override + public void remove(long target, String structureKey, int originChunkX, int originChunkZ) { + chunks.computeIfPresent(target, (ignored, bundle) -> { + NativeStructureOwnershipBundle updated = bundle.without( + structureKey, originChunkX, originChunkZ); + return updated.records().isEmpty() ? null : updated; + }); + } + + @Override + public void flush() { + flushes++; + } + + NativeStructureOwnershipRecord find(long target, + NativeStructureOwnershipRecord record) { + NativeStructureOwnershipBundle bundle = chunks.get(target); + return bundle == null ? null : bundle.find(record.structureKey(), + record.originChunkX(), record.originChunkZ()); + } + } + + private static class CrashableStorage extends TestStorage { + protected final Map durable = new ConcurrentHashMap<>(); + + @Override + public void flush() { + super.flush(); + durable.clear(); + durable.putAll(chunks); + } + + void crash() { + chunks.clear(); + chunks.putAll(durable); + } + } + + private static final class FailingFlushStorage extends CrashableStorage { + private final AtomicBoolean fail = new AtomicBoolean(true); + + @Override + public void flush() { + flushes++; + if (fail.compareAndSet(true, false)) { + throw new IllegalStateException("Simulated ownership flush failure"); + } + durable.clear(); + durable.putAll(chunks); + } + } + + private static final class BlockingStorage extends TestStorage { + private final CountDownLatch writeEntered = new CountDownLatch(1); + private final CountDownLatch allowWrite = new CountDownLatch(1); + private final AtomicBoolean blocked = new AtomicBoolean(); + private volatile long blockedTarget; + + @Override + public void write(long target, NativeStructureOwnershipRecord record) { + if (target == blockedTarget && blocked.compareAndSet(false, true)) { + writeEntered.countDown(); + try { + if (!allowWrite.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting to release origin write"); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted waiting to release origin write", error); + } + } + super.write(target, record); + } + } + + private static final class PostWriteBlockingStorage extends TestStorage { + private final CountDownLatch firstPersisted = new CountDownLatch(1); + private final CountDownLatch allowFirstReturn = new CountDownLatch(1); + private final AtomicBoolean blocked = new AtomicBoolean(); + + @Override + public void write(long target, NativeStructureOwnershipRecord record) { + super.write(target, record); + if (!blocked.compareAndSet(false, true)) { + return; + } + firstPersisted.countDown(); + try { + if (!allowFirstReturn.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting to release first ownership write"); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Ownership write was interrupted", error); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java index 6bd1b3ea3..25423c09d 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java @@ -2,11 +2,14 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisNativeStructure; import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrainMode; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.StructureDistribution; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -38,6 +41,17 @@ public class NativeStructurePlacementPlannerTest { assertTrue(first.baseY() >= -45 && first.baseY() <= -20); } + @Test + public void undergroundBandOutsideWorldBoundsSkipsTheCandidate() { + Engine engine = engine(982374L, -64, 384, 90); + IrisStructurePlacement placement = nativePlacement() + .setUnderground(true) + .setMinHeight(500) + .setMaxHeight(600); + + assertNull(NativeStructurePlacementPlanner.planAt(engine, placement, 12, -7)); + } + @Test public void surfacePlanUsesIrisTerrainAndHonorsHeightGate() { Engine engine = engine(77L, -64, 384, 150); @@ -72,6 +86,69 @@ public class NativeStructurePlacementPlannerTest { assertSame(terrain, decision.terrain()); } + @Test + public void underwaterFlagRejectsSubmergedColumnsForSurfaceAndUndergroundPlans() { + Engine engine = engine(77L, -64, 384, 63); + IrisStructurePlacement placement = nativePlacement(); + + assertNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0)); + + placement.setUnderground(true).setMinHeight(-40).setMaxHeight(-20); + assertNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0)); + + placement.setUnderwater(true); + assertNotNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0)); + } + + @Test + public void fluidHeightBoundaryIsShoreForPlannerAndLocator() { + Engine engine = engine(77L, -64, 384, 64); + IrisStructurePlacement placement = nativePlacement(); + + assertTrue(!NativeStructurePlacementPlanner.isSubmerged(engine, 8, 8)); + assertNotNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0)); + + placement.setUnderground(true).setMinHeight(-40).setMaxHeight(-20); + assertNotNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0)); + } + + @Test + public void duplicateSelectedStructureUsesOneDeterministicPlan() { + Engine engine = engine(77L, -64, 384, 150); + IrisStructurePlacement first = nativePlacement().setPlacementId("first"); + IrisStructurePlacement second = nativePlacement().setPlacementId("second"); + bindPlacements(engine, first, second); + + KList plans = NativeStructurePlacementPlanner.plansAt(engine, 0, 0); + + IrisStructurePlacement expected = Long.compareUnsigned( + StructurePlacementGrid.placementIdentity(first), + StructurePlacementGrid.placementIdentity(second)) <= 0 ? first : second; + assertEquals(1, plans.size()); + assertSame(expected, plans.getFirst().placement()); + } + + @Test + public void weightedNativeSelectionPreservesAuthoredSourceOrder() { + KList first = new KList<>(); + first.add(new IrisNativeStructure().setStructure("test:a").setWeight(1)); + first.add(new IrisNativeStructure().setStructure("test:b").setWeight(3)); + KList reordered = new KList<>(); + reordered.add(first.get(1)); + reordered.add(first.get(0)); + + RNG selectsFirstWeightSlot = new RNG(0L) { + @Override + public int nextInt(int bound) { + return 0; + } + }; + assertEquals("test:a", NativeStructurePlacementPlanner + .selectSource(first, selectsFirstWeightSlot).getStructure()); + assertEquals("test:b", NativeStructurePlacementPlanner + .selectSource(reordered, selectsFirstWeightSlot).getStructure()); + } + @Test public void placementMustChooseExactlyOneBackend() { assertInvalid(new IrisStructurePlacement()); @@ -98,9 +175,23 @@ public class NativeStructurePlacementPlannerTest { when(engine.getHeight(org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.eq(true))) .thenReturn(terrainHeight); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getFluidHeight()).thenReturn(64); + when(engine.getDimension()).thenReturn(dimension); return engine; } + private void bindPlacements(Engine engine, IrisStructurePlacement... placements) { + IrisDimension dimension = engine.getDimension(); + KList configured = new KList<>(); + for (IrisStructurePlacement placement : placements) { + configured.add(placement); + } + when(dimension.getStructures()).thenReturn(configured); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); + } + private void assertInvalid(IrisStructurePlacement placement) { try { NativeStructurePlacementPlanner.validateBackend(placement); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java index a45775203..93e1a73b7 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java @@ -205,6 +205,23 @@ public class StructurePlacementGridTest { assertTrue(differs); } + @Test + public void anonymousIdentityPreservesAuthoredSourceOrder() { + IrisStructurePlacement first = placement(StructureDistribution.RANDOM_SPREAD); + first.getStructures().add("test:second"); + IrisStructurePlacement reordered = placement(StructureDistribution.RANDOM_SPREAD); + reordered.getStructures().clear(); + reordered.getStructures().add("test:second"); + reordered.getStructures().add("test:structure"); + + assertNotEquals(StructurePlacementGrid.placementIdentity(first), + StructurePlacementGrid.placementIdentity(reordered)); + assertNotEquals(StructurePlacementGrid.placementSalt(first), + StructurePlacementGrid.placementSalt(reordered)); + assertNotEquals(StructurePlacementGrid.placementRng(first, 7, -11, 123L).getSeed(), + StructurePlacementGrid.placementRng(reordered, 7, -11, 123L).getSeed()); + } + @Test public void blankIdsDeriveDistinctStableConcentricRingsFromContent() { IrisStructurePlacement first = placement(StructureDistribution.CONCENTRIC_RINGS); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisStructurePlacementCarveSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisStructurePlacementCarveSettingsTest.java index 2e61ca51d..e4d298ef2 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisStructurePlacementCarveSettingsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisStructurePlacementCarveSettingsTest.java @@ -13,10 +13,10 @@ import static org.junit.Assert.assertSame; public class IrisStructurePlacementCarveSettingsTest { @Test - public void defaultsPreserveTerrainWithBoxCarving() { + public void defaultsToSourceTerrainWithBoxCarving() { IrisStructureTerrain terrain = new IrisStructureTerrain(); - assertEquals(IrisStructureTerrainMode.PRESERVE, terrain.resolvedMode()); + assertEquals(IrisStructureTerrainMode.SOURCE, terrain.resolvedMode()); assertEquals(IrisStructureCarveShape.BOX, terrain.getShape()); assertEquals(IrisStructureCarveShape.BOX, terrain.resolvedShape()); assertEquals(0.8D, terrain.getErosionStrength(), 0D); diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformVersionTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformVersionTest.java new file mode 100644 index 000000000..af5fd8ae2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitPlatformVersionTest.java @@ -0,0 +1,23 @@ +package art.arcane.iris.platform.bukkit; + +import org.bukkit.Server; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +public class BukkitPlatformVersionTest { + private interface PaperLikeServer extends Server { + String getMinecraftVersion(); + } + + @Test + public void reportsCanonicalMinecraftVersionInsteadOfBukkitBuildVersion() { + PaperLikeServer server = mock(PaperLikeServer.class); + doReturn("26.2").when(server).getMinecraftVersion(); + doReturn("26.2.build.33-alpha").when(server).getBukkitVersion(); + + assertEquals("26.2", BukkitPlatform.minecraftVersion(server)); + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitRegistriesLootTableTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitRegistriesLootTableTest.java new file mode 100644 index 000000000..2cf4454e5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitRegistriesLootTableTest.java @@ -0,0 +1,21 @@ +package art.arcane.iris.platform.bukkit; + +import org.bukkit.loot.LootTables; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class BukkitRegistriesLootTableTest { + @Test + public void lootTableKeysDoNotRequireLiveServerRegistryAccess() { + List expected = new ArrayList<>(); + for (LootTables table : LootTables.values()) { + expected.add(table.getKey().toString()); + } + + assertEquals(expected, new BukkitRegistries().lootTableKeys()); + } +} diff --git a/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java b/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java new file mode 100644 index 000000000..b768432da --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/misc/WebCacheTest.java @@ -0,0 +1,136 @@ +package art.arcane.iris.util.common.misc; + +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.util.io.IO; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Answers; + +import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class WebCacheTest { + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + private IrisPlatform previousPlatform; + + @Before + public void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS); + when(platform.dataFolder()).thenReturn(temp.getRoot()); + when(platform.dataFile(any(String[].class))).thenAnswer(invocation -> { + File file = temp.getRoot(); + for (Object argument : invocation.getArguments()) { + file = new File(file, String.valueOf(argument)); + } + return file; + }); + IrisPlatforms.bind(platform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void declaredOversizeDoesNotReplaceThePreviousCacheEntry() throws Exception { + byte[] body = "archive-larger-than-limit".getBytes(StandardCharsets.UTF_8); + HttpServer server = server(body, true); + try { + String name = "declared-pack"; + String url = url(server); + File existing = cachedFile(name, url); + Files.createDirectories(existing.toPath().getParent()); + Files.writeString(existing.toPath(), "previous", StandardCharsets.UTF_8); + + File downloaded = WebCache.getNonCachedFile(name, url, 8L); + + assertNull(downloaded); + assertEquals("previous", Files.readString(existing.toPath(), StandardCharsets.UTF_8)); + } finally { + server.stop(0); + } + } + + @Test + public void streamedOversizeDoesNotPublishAPartialDownload() throws Exception { + byte[] body = "chunked-archive-larger-than-limit".getBytes(StandardCharsets.UTF_8); + HttpServer server = server(body, false); + try { + String name = "chunked-pack"; + String url = url(server); + File existing = cachedFile(name, url); + Files.createDirectories(existing.toPath().getParent()); + Files.writeString(existing.toPath(), "previous", StandardCharsets.UTF_8); + + File downloaded = WebCache.getNonCachedFile(name, url, 8L); + + assertNull(downloaded); + assertEquals("previous", Files.readString(existing.toPath(), StandardCharsets.UTF_8)); + } finally { + server.stop(0); + } + } + + @Test + public void boundedDownloadPublishesTheCompleteResponse() throws Exception { + byte[] body = "valid-archive".getBytes(StandardCharsets.UTF_8); + HttpServer server = server(body, true); + try { + String name = "valid-pack"; + String url = url(server); + + File downloaded = WebCache.getNonCachedFile(name, url, body.length); + + assertTrue(downloaded.isFile()); + assertEquals("valid-archive", Files.readString(downloaded.toPath(), StandardCharsets.UTF_8)); + } finally { + server.stop(0); + } + } + + private HttpServer server(byte[] body, boolean declareLength) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/pack", exchange -> respond(exchange, body, declareLength)); + server.start(); + return server; + } + + private void respond(HttpExchange exchange, byte[] body, boolean declareLength) throws IOException { + exchange.sendResponseHeaders(200, declareLength ? body.length : 0L); + exchange.getResponseBody().write(body); + exchange.close(); + } + + private String url(HttpServer server) { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/pack"; + } + + private File cachedFile(String name, String url) { + String hash = IO.hash(name + "*" + url); + return IrisPlatforms.get().dataFile("cache", hash.substring(0, 2), hash.substring(3, 5), hash); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index 8c75e4f9e..c722f1703 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -606,6 +606,11 @@ public final class StubPlatform implements IrisPlatform { return List.of(); } + @Override + public List lootTableKeys() { + return List.of(); + } + @Override public Map> blockStateProperties() { return Map.of(); diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java b/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java index 1e5ac0ccf..94a39fa1a 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java @@ -135,6 +135,8 @@ public interface PlatformRegistries { */ List potionEffectKeys(); + List lootTableKeys(); + /** * Block key to its declared state properties, used to generate pack schema enums and numeric ranges. * Keyed by material-level block key. Never null. diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformStructureHooks.java b/spi/src/main/java/art/arcane/iris/spi/PlatformStructureHooks.java index 705a81203..ff5dfa9e3 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformStructureHooks.java +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformStructureHooks.java @@ -51,6 +51,18 @@ public interface PlatformStructureHooks { return List.of(); } + default JigsawSourceMetadata jigsawSourceMetadata(String structureKey) { + throw new UnsupportedOperationException("The active platform does not expose registered jigsaw metadata"); + } + + default int templatePoolHorizontalSpan(String templatePoolKey) { + throw new UnsupportedOperationException("The active platform does not expose registered template pool spans"); + } + + default int jigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) { + return templatePoolHorizontalSpan(templatePoolKey); + } + /** * Every registered structure-set key, the placement grouping that decides structure spacing. Never null. */ @@ -99,4 +111,23 @@ public interface PlatformStructureHooks { * Check before offering structure capture; adapters without the required host access return false. */ boolean supportsStructurePlacement(); + + record JigsawSourceMetadata(int maxDistanceHorizontal, int referenceExpansion, + int maxStartElementHorizontalSpan) { + public JigsawSourceMetadata(int maxDistanceHorizontal, int referenceExpansion) { + this(maxDistanceHorizontal, referenceExpansion, 0); + } + + public JigsawSourceMetadata { + if (maxDistanceHorizontal < 1 || maxDistanceHorizontal > 128) { + throw new IllegalArgumentException("Jigsaw horizontal distance must be between 1 and 128"); + } + if (referenceExpansion < 0) { + throw new IllegalArgumentException("Jigsaw reference expansion must not be negative"); + } + if (maxStartElementHorizontalSpan < 0) { + throw new IllegalArgumentException("Jigsaw start element horizontal span must not be negative"); + } + } + } }