From 62e61970f842563e0d1d18d03fad2c30bc66ef82 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 9 Sep 2026 16:23:25 -0400 Subject: [PATCH] Production-readiness suite fixes Surface hydrology policy split, native terrain height cache, watched-edit hotload cancellation, world-save requestSave with tracked ownership flush, background-task drain that follows saves admitted mid-drain, peaceful spawn guard, cached-completion terminal state, IO drains off the owner thread. Studio pregen at scale: detached historical runtimes keep the configured noise cache instead of the Studio-wide cache; retired runtimes are no longer pinned by the volumetric fragment filter's per-thread scratch; pregen mantle cleanup and native region flushes run on per-run threads instead of the server thread and the shared Iris IO pool (the flush on the IO pool starved tectonic plate loads and wedged every Paper worker in Mantle.getChunks); background-task cancellation claims tasks atomically. --- .../core/nms/v26_2_R1/IrisChunkGenerator.java | 51 +-- .../iris/core/nms/v26_2_R1/NMSBinding.java | 18 +- .../nms/v26_2_R1/NmsSavedTerrainCapture.java | 174 +++++-- .../NativeStructureWorldgenAccessTest.java | 91 ++++ .../nms/v26_2_R1/NmsChunkIOFlushTest.java | 100 ++++ .../NmsEntitySpawnDifficultyTest.java | 103 +++++ .../v26_2_R1/NmsSavedTerrainSnapshotTest.java | 223 +++++++++ .../IrisChunkGeneratorHeightCacheTest.java | 130 ++++++ ...ucturePostProcessorSurfaceTerrainTest.java | 433 +++++++++++++++++- .../NativeTerrainHeightCacheTest.java | 129 ++++++ .../NativeStructureReferenceEnvelope.java | 1 + .../nativegen/NativeStructureReflection.java | 34 ++ .../NativeStructureSurfaceFitter.java | 350 +++++++++++++- .../NativeStructureSurfaceSupportBuilder.java | 6 + .../NativeStructureTerrainIntegrator.java | 2 +- .../nativegen/NativeTerrainHeightCache.java | 42 ++ .../iris/modded/IrisModdedChunkGenerator.java | 20 +- .../iris/modded/ModdedEntitySpawner.java | 10 +- .../modded/ModdedNativeStructureStage.java | 12 +- .../arcane/iris/modded/ModdedPlatform.java | 2 +- .../ModdedEntitySpawnDifficultyTest.java | 132 ++++++ ...ddedNativeStructureWorldgenAccessTest.java | 91 ++++ .../core/pack/PackHydrologyValidator.java | 7 + .../pack/PackStructurePlacementValidator.java | 8 +- .../iris/core/pack/PackValidationCache.java | 25 + .../core/pregenerator/IrisPregenerator.java | 15 +- .../methods/AsyncPregenMethod.java | 46 +- .../methods/MedievalPregenMethod.java | 14 +- .../methods/PregenSerialWorker.java | 114 +++++ .../core/service/ObjectStudioSaveService.java | 68 +++ .../art/arcane/iris/core/service/TreeSVC.java | 7 + .../iris/engine/DimensionTerrainContext.java | 31 +- .../iris/engine/EngineBackgroundTasks.java | 120 ++++- .../arcane/iris/engine/EngineHotloader.java | 2 +- .../iris/engine/EngineRuntimeBuilder.java | 4 +- .../art/arcane/iris/engine/IrisComplex.java | 145 ++++-- .../art/arcane/iris/engine/IrisEngine.java | 19 + .../iris/engine/StudioGenerationUpdate.java | 2 +- .../arcane/iris/engine/framework/Engine.java | 2 + .../framework/EngineAssignedWorldManager.java | 2 +- .../history/BoundaryColumnGeometry.java | 12 + .../history/GenerationKernelRegistry.java | 6 +- .../engine/history/GenerationKernelV1.java | 4 +- .../engine/history/SavedBiomeRuntime.java | 44 +- .../history/TransitionGeometryBlender.java | 167 ++++--- .../hydrology/HydrologyCrossTileResolver.java | 25 +- .../HydrologyCrossTileSurfaceAdmission.java | 13 +- .../HydrologyFeatureSitePlanner.java | 2 +- .../hydrology/HydrologyOutletPlanner.java | 89 +++- .../engine/hydrology/HydrologyPlanner.java | 18 +- .../hydrology/HydrologyPlannerSettings.java | 30 +- .../hydrology/HydrologyRouteGeometry.java | 81 +++- .../hydrology/HydrologySourcePlanner.java | 200 ++++++-- .../hydrology/HydrologySurfaceBudgets.java | 106 +++++ .../HydrologySurfaceCoursePlanner.java | 35 +- .../hydrology/HydrologySurfaceProfiles.java | 170 +++++++ .../hydrology/HydrologyTerrainSample.java | 63 ++- .../hydrology/HydrologyTributaryPlanner.java | 23 +- .../iris/engine/hydrology/RefinedEdgeKey.java | 3 +- .../iris/engine/hydrology/RouteCandidate.java | 2 + .../hydrology/SourceAdmissionSelection.java | 38 +- .../engine/hydrology/SourceSelection.java | 33 +- .../hydrology/StudioHydrologyTileStore.java | 2 +- .../policy/EffectiveRiverPolicy.java | 4 +- .../hydrology/policy/RiverPolicyResolver.java | 43 +- .../hydrology/policy/SurfaceRiverPolicy.java | 80 ++++ .../runtime/IrisHydrologyRuntime.java | 38 +- .../surface/ChannelProfileBuilder.java | 2 +- .../surface/SurfaceCourseBuilder.java | 8 +- .../surface/ValleyProfileSolver.java | 13 +- .../iris/engine/mantle/MatterGenerator.java | 17 +- .../iris/engine/object/IrisDimension.java | 30 ++ .../arcane/iris/engine/object/IrisRegion.java | 11 +- .../iris/engine/object/IrisRiverPolicy.java | 43 +- .../engine/object/IrisStructureTerrain.java | 13 +- .../object/IrisStructureTerrainMode.java | 3 + .../IrisVanillaStructureAdjustment.java | 2 +- .../arcane/iris/engine/object/StudioMode.java | 21 +- .../engine/platform/BukkitChunkGenerator.java | 201 +++----- .../platform/studio/BiomeBuffetLayout.java | 57 +++ .../platform/studio/StudioGenerator.java | 7 - .../generators/BiomeBuffetGenerator.java | 43 +- .../generators/ObjectStudioGenerator.java | 21 +- .../terrain/Terrain3DFragmentFilter.java | 25 +- .../iris/engine/terrain/Terrain3DRuntime.java | 23 +- .../PregeneratorJobTerminalProgressTest.java | 142 ++++++ .../core/pack/PackHydrologyValidatorTest.java | 47 ++ .../core/pack/PackValidationCacheTest.java | 67 +++ ...kValidatorStructureTerrainBackendTest.java | 26 +- .../AsyncPregenServerSaveOwnershipTest.java | 137 ++++++ .../MedievalPregenFlushOwnershipTest.java | 106 +++++ .../methods/PregenSerialWorkerTest.java | 111 +++++ .../project/SchemaBuilderIdentityTest.java | 2 +- .../core/project/SchemaBuilderParityTest.java | 20 +- .../ObjectStudioTileRestorationTest.java | 129 ++++++ .../core/service/TreeSVCLifecycleTest.java | 146 ++++++ .../EngineBackgroundTasksAdmissionTest.java | 304 ++++++++++++ .../iris/engine/EngineShutdownDrainTest.java | 2 +- .../engine/IrisComplexBiomeBuffetTest.java | 216 +++++++++ .../engine/IrisComplexStudioCacheTest.java | 20 +- .../IrisEngineGenerationRuntimeScopeTest.java | 10 +- .../iris/engine/IrisEngineWorldSaveTest.java | 209 +++++++++ .../engine/StudioGenerationUpdateTest.java | 77 ++++ .../DenseTransitionGeometryReference.java | 195 ++++++++ .../GenerationHistoryRecoveryTest.java | 4 +- ...tionHistoryRuntimeRouterPromotionTest.java | 2 +- ...GenerationHistoryRuntimeRouterSupport.java | 6 +- .../history/GenerationHistorySupport.java | 2 +- .../history/GenerationKernelRegistryTest.java | 4 +- .../engine/history/SavedBiomeRuntimeTest.java | 97 ++++ .../history/TransitionGeometryCursorTest.java | 148 ++++++ .../HydrologyCaveCourseFilterTest.java | 3 +- ...ydrologyCrossTileSurfaceAdmissionTest.java | 62 ++- .../HydrologyFootprintCompilerTest.java | 14 +- .../hydrology/HydrologyPlanHashTest.java | 14 +- .../HydrologyPlannerConfinementTest.java | 8 +- ...CrossTileCaveAdmissionIntegrationTest.java | 17 +- .../HydrologyPlannerGlobalInvariantTest.java | 17 +- .../HydrologyPlannerSeaCaveTest.java | 10 +- .../HydrologyPlannerSettingsTest.java | 21 +- .../HydrologyPlannerSurfacePoolTest.java | 3 +- .../hydrology/HydrologyPlannerTest.java | 64 ++- .../HydrologyPlannerTributaryTest.java | 22 +- .../HydrologyPlannerUndergroundKnobsTest.java | 20 +- .../HydrologyRegionalSurfacePolicyTest.java | 242 ++++++++++ .../HydrologyRouteTurnCacheTest.java | 5 +- .../HydrologyStyledGeometryTest.java | 11 +- .../HydrologySurfaceGeometryPolicyTest.java | 122 +++++ .../HydrologySurfaceProfilesTest.java | 294 ++++++++++++ .../HydrologySurfaceRouteGeometryTest.java | 96 ++++ .../hydrology/HydrologyTerrainSampleTest.java | 8 +- .../hydrology/HydrologyTileCacheTest.java | 11 +- .../StudioHydrologyTileStoreTest.java | 5 +- .../policy/RiverPolicyResolverTest.java | 75 ++- .../IrisHydrologyRuntimeSettingsTest.java | 55 +++ .../surface/ChannelProfileBuilderTest.java | 5 +- .../surface/ErosionFieldCompilerTest.java | 5 +- .../surface/SurfaceCourseBuilderTest.java | 24 +- .../MatterGeneratorConcurrencyTest.java | 190 ++++++++ .../MantleHydrologyComponentTest.java | 8 +- .../object/IrisDimensionFocusRegionTest.java | 119 +++++ .../BukkitChunkGeneratorCloseTest.java | 40 ++ ...ChunkGeneratorGenerationStageGateTest.java | 256 +++++------ .../BiomeBuffetGeneratorPreparationTest.java | 52 --- .../generators/ObjectStudioGeometryTest.java | 35 +- .../terrain/Terrain3DFragmentFilterTest.java | 288 ++---------- .../engine/terrain/Terrain3DRuntimeTest.java | 63 +++ .../arcane/iris/probe/HydrologyPackProbe.java | 22 +- .../iris/probe/HydrologyPlannerProbe.java | 23 +- .../iris/probe/HydrologyPackProbeTest.java | 19 + 150 files changed, 7892 insertions(+), 1341 deletions(-) create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsChunkIOFlushTest.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsEntitySpawnDifficultyTest.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainSnapshotTest.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeTerrainHeightCacheTest.java create mode 100644 adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeTerrainHeightCache.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnDifficultyTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/pregenerator/methods/PregenSerialWorker.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceBudgets.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceProfiles.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/policy/SurfaceRiverPolicy.java create mode 100644 core/src/main/java/art/arcane/iris/engine/platform/studio/BiomeBuffetLayout.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobTerminalProgressTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenFlushOwnershipTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenSerialWorkerTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/ObjectStudioTileRestorationTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/TreeSVCLifecycleTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/EngineBackgroundTasksAdmissionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/IrisComplexBiomeBuffetTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/IrisEngineWorldSaveTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/DenseTransitionGeometryReference.java create mode 100644 core/src/test/java/art/arcane/iris/engine/history/TransitionGeometryCursorTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalSurfacePolicyTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceGeometryPolicyTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceProfilesTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceRouteGeometryTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisDimensionFocusRegionTest.java delete mode 100644 core/src/test/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGeneratorPreparationTest.java 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 ff518b044..8f6c0690b 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 @@ -1,6 +1,7 @@ package art.arcane.iris.core.nms.v26_2_R1; import art.arcane.iris.nativegen.NativeTransitionColumn; +import art.arcane.iris.nativegen.NativeTerrainHeightCache; import art.arcane.iris.engine.history.TerrainBoundarySignature; import java.util.Optional; import art.arcane.iris.nativegen.NativeGenerationWriteGuard; @@ -124,6 +125,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.OptionalInt; import java.util.Set; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; @@ -155,6 +157,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred private final int runtimeSeaLevel; private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); private final ImportedFeatureStage importedFeatures; + private final NativeTerrainHeightCache terrainHeights = new NativeTerrainHeightCache(); private final AtomicReference retainedStudioStructureState = new AtomicReference<>(); private volatile ReachableStructureCache reachableStructureCache; private volatile StructureStepCache structureStepCache; @@ -184,6 +187,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred } private void evictRuntimeCaches(int runtimeId) { + terrainHeights.evictRuntime(runtimeId); importedFeatures.evictRuntime(runtimeId); mergedSpawnTables.keySet().removeIf(key -> key.runtimeId() == runtimeId); ReachableStructureCache reachable = reachableStructureCache; @@ -765,9 +769,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred @Override public CompletableFuture fillFromNoise(Blender blender, RandomState randomstate, StructureManager structuremanager, ChunkAccess ichunkaccess) { ChunkPos chunkPos = ichunkaccess.getPos(); - BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage( - chunkPos, - "bukkit_nms_chunk_pipeline"); + BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_chunk_pipeline"); GenerationHistoryRuntimeRouter.RuntimeRoute route; try { route = openHistoryRoute(chunkPos.x(), chunkPos.z(), "bukkit_nms_chunk_pipeline"); @@ -1124,10 +1126,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred } IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData()); Predicate protectedPosition = nativeStructureProtection(staticObjects); - WorldGenLevel boundedWorld = staticObjects.isEmpty() - && engine.getDimensionStackContext() == null - ? world - : NativeStructureWorldgenAccess.create( + WorldGenLevel boundedWorld = NativeStructureWorldgenAccess.create( world, chunkPos, hostWorldgenSurfaceHeight(), hostWorldgenFloorHeight(), engine.getDimensionStackContext() != null, protectedPosition); @@ -1139,11 +1138,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred "vegetation cleanup", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } - NativeStructureSurfaceFitter.VacuumFoundationPlan vacuumFoundationPlan; + NativeStructureSurfaceFitter.SurfaceTerrainPlan surfaceTerrainPlan; try { - vacuumFoundationPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( + surfaceTerrainPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( boundedWorld, area, terrainTargets, (x, z) -> Engine.hostHeight(engine, x, z, true) + engine.getMinHeight()); + surfaceTerrainPlan.primeHeightmaps(chunk); } catch (Throwable error) { throw NativeStructureGenerationException.failure( "terrain integration", nativeStructureBatchContext(placementGroups), @@ -1171,7 +1171,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred } try { NativeStructureSurfaceFitter.repairVacuumFoundations( - boundedWorld, area, vacuumFoundationPlan); + boundedWorld, area, surfaceTerrainPlan); } catch (Throwable error) { throw NativeStructureGenerationException.failure( "foundation repair", nativeStructureBatchContext(placementGroups), @@ -1422,21 +1422,31 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred i, j, "bukkit_nms_base_height"); GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_height"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - Optional resolved = engine.getComplex().resolvedTerrainColumn(i, j); + NativeTerrainHeightCache.Query query = new NativeTerrainHeightCache.Query( + engine.getCacheID(), i, j, heightmap_type, + levelheightaccessor.getMinY(), levelheightaccessor.getHeight()); + OptionalInt resolved = terrainHeights.resolvedHeight(query, + () -> resolvedBaseHeight(i, j, heightmap_type, levelheightaccessor)); if (resolved.isPresent()) { - return NativeTransitionColumn.height(resolved.get(), heightmap_type, levelheightaccessor); + return resolved.getAsInt(); } boolean ignoreFluid = !heightmap_type.isOpaque().test(Blocks.WATER.defaultBlockState()); int height = engine.getDimensionStackContext() == null ? engine.getHeight(i, j, ignoreFluid) : Engine.hostHeight(engine, i, j, ignoreFluid); - return levelheightaccessor.getMinY() - + height + 1; + return levelheightaccessor.getMinY() + height + 1; } catch (GenerationSessionException e) { throw new IllegalStateException("Iris base height query could not acquire its engine runtime.", e); } } + private OptionalInt resolvedBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor) { + Optional resolved = engine.getComplex().resolvedTerrainColumn(x, z); + return resolved.isPresent() + ? OptionalInt.of(NativeTransitionColumn.height(resolved.get(), type, heightAccessor)) + : OptionalInt.empty(); + } + @Override public NoiseColumn getBaseColumn(int i, int j, LevelHeightAccessor levelheightaccessor, RandomState randomstate) { if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) { @@ -1564,19 +1574,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred : platformGenerator.acquireGenerationStage(operation); } - private BukkitChunkGenerator.GenerationStagePermit requireNoiseGenerationStage( - ChunkPos chunkPos, - String operation - ) { - return platformGenerator == null - ? BukkitChunkGenerator.GenerationStagePermit.noop() - : platformGenerator.acquireNoiseGenerationStage( - engine, - chunkPos.x(), - chunkPos.z(), - operation); - } - @Override public Optional getTypeNameForDataFixer() { return delegate.getTypeNameForDataFixer(); 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 70cc3fa7b..5c6789d4d 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 @@ -120,6 +120,7 @@ import net.minecraft.world.level.levelgen.flat.FlatLayerInfo; import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; import org.bukkit.Bukkit; import org.bukkit.Chunk; +import org.bukkit.Difficulty; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -1155,12 +1156,8 @@ public class NMSBinding implements INMSBinding { @Override public void flushChunkIO(World world) { - try { - ServerLevel level = ((CraftWorld) world).getHandle(); - MoonriseRegionFileIO.flush(level); - } catch (Throwable e) { - IrisLogging.reportError(e); - } + ServerLevel level = ((CraftWorld) world).getHandle(); + MoonriseRegionFileIO.flush(level); } @Override @@ -1443,7 +1440,14 @@ public class NMSBinding implements INMSBinding { if (location == null || location.getWorld() == null || type == null || type.getEntityClass() == null) { return null; } - return ((CraftWorld) location.getWorld()).spawn(location, type.getEntityClass(), null, reason); + CraftWorld world = (CraftWorld) location.getWorld(); + if (world.getDifficulty() == Difficulty.PEACEFUL) { + EntityType nativeType = BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(type.getKey().toString())); + if (nativeType == null || !nativeType.isAllowedInPeaceful()) { + return null; + } + } + return world.spawn(location, type.getEntityClass(), null, reason); } @Override diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCapture.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCapture.java index 4a874481d..4a480abd5 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCapture.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainCapture.java @@ -3,6 +3,8 @@ package art.arcane.iris.core.nms.v26_2_R1; import art.arcane.iris.engine.history.NativeTerrainReceipt; import art.arcane.iris.engine.history.SavedTerrainChunk; import art.arcane.iris.util.common.scheduling.J; +import ca.spottedleaf.concurrentutil.completable.Completable; +import ca.spottedleaf.concurrentutil.executor.PrioritisedExecutor; import ca.spottedleaf.concurrentutil.util.Priority; import ca.spottedleaf.moonrise.common.PlatformHooks; import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; @@ -26,31 +28,34 @@ import java.util.ArrayList; import java.util.List; 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.locks.LockSupport; final class NmsSavedTerrainCapture { private static final long WRITE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(30L); + private static final int SNAPSHOT_BATCH_SIZE = 64; private NmsSavedTerrainCapture() { } static CompletableFuture capture(World world, int chunkX, int chunkZ, int minimumY, int height) { ServerLevel level = ((CraftWorld) world).getHandle(); - CompletableFuture snapshot = new CompletableFuture<>(); + CompletableFuture snapshot = new CompletableFuture<>(); J.runRegionFuture(world, chunkX, chunkZ, () -> { try { NewChunkHolder holder = level.moonrise$getChunkTaskScheduler().chunkHolderManager.getChunkHolder(chunkX, chunkZ); ChunkAccess chunk = holder == null ? null : holder.getCurrentChunk(); if (chunk == null) { - snapshot.complete(null); + snapshot.complete(new CapturedChunk(new ChunkPos(chunkX, chunkZ), CompletableFuture.completedFuture(null))); return; } String status = BuiltInRegistries.CHUNK_STATUS.getKey(chunk.getPersistedStatus()).toString(); if (!SavedTerrainChunk.hasTerrain(status)) { throw new IOException("Native chunk " + chunkX + "," + chunkZ + " has no completed terrain at " + status); } - snapshot.complete(saveSnapshot(level, chunk)); + snapshot.complete(scheduleSnapshot(level, chunk)); } catch (Throwable failure) { snapshot.completeExceptionally(failure); } @@ -59,10 +64,12 @@ final class NmsSavedTerrainCapture { snapshot.completeExceptionally(failure); } }); - return snapshot.thenApplyAsync(data -> { + return snapshot.thenApplyAsync(captured -> { try { + long deadline = System.nanoTime() + WRITE_TIMEOUT_NANOS; + CompoundTag data = awaitSnapshot(level, captured, deadline); Checkpoint checkpoint = checkpoint(chunkX, chunkZ, data); - awaitWrite(level, checkpoint.chunk(), System.nanoTime() + WRITE_TIMEOUT_NANOS); + awaitWrite(level, checkpoint.chunk(), deadline); if (data == null) { return SavedTerrainChunk.read(world.getWorldFolder().toPath(), chunkX, chunkZ, minimumY, height); } @@ -81,37 +88,73 @@ final class NmsSavedTerrainCapture { static CompletableFuture flush(World world) { ServerLevel level = ((CraftWorld) world).getHandle(); List holders = level.moonrise$getChunkTaskScheduler().chunkHolderManager.getChunkHolders(); - List> saves = new ArrayList<>(holders.size()); - for (NewChunkHolder holder : holders) { - CompletableFuture saved = new CompletableFuture<>(); - saves.add(saved); - J.runRegionFuture(world, holder.chunkX, holder.chunkZ, () -> { - try { - ChunkAccess chunk = holder.getCurrentChunk(); - saved.complete(checkpoint(holder.chunkX, holder.chunkZ, - chunk != null && chunk.isUnsaved() ? saveSnapshot(level, chunk) : null)); - } catch (Throwable failure) { - saved.completeExceptionally(failure); - } - }).whenComplete((ignored, failure) -> { - if (failure != null) { - saved.completeExceptionally(failure); - } - }); - } - return CompletableFuture.allOf(saves.toArray(CompletableFuture[]::new)).thenRunAsync(() -> { + return CompletableFuture.runAsync(() -> { try { - List checkpoints = new ArrayList<>(saves.size()); - for (CompletableFuture future : saves) { - checkpoints.add(future.join()); - } - finishCheckpoint(level, world.getWorldFolder().toPath(), checkpoints); + flushSnapshots(world, level, holders); } catch (IOException failure) { throw new CompletionException(failure); } }); } + static void flushSnapshots(World world, ServerLevel level, List holders) throws IOException { + long deadline = System.nanoTime() + WRITE_TIMEOUT_NANOS; + List checkpoints = new ArrayList<>(holders.size()); + for (int offset = 0; offset < holders.size(); offset += SNAPSHOT_BATCH_SIZE) { + int end = Math.min(holders.size(), offset + SNAPSHOT_BATCH_SIZE); + List> snapshots = requestSnapshots(world, level, holders.subList(offset, end)); + awaitSnapshots(snapshots, deadline); + for (CompletableFuture future : snapshots) { + CapturedChunk captured = future.join(); + CompoundTag data = awaitSnapshot(level, captured, deadline); + checkpoints.add(checkpoint(captured.chunk().x(), captured.chunk().z(), data)); + } + for (int index = offset; index < end; index++) { + awaitWrite(level, checkpoints.get(index).chunk(), deadline); + } + } + finishCheckpoint(level, world.getWorldFolder().toPath(), checkpoints); + } + + private static List> requestSnapshots( + World world, ServerLevel level, List holders) { + List> snapshots = new ArrayList<>(holders.size()); + for (NewChunkHolder original : holders) { + ChunkPos position = new ChunkPos(original.chunkX, original.chunkZ); + CompletableFuture snapshot = new CompletableFuture<>(); + snapshots.add(snapshot); + J.runRegionFuture(world, position.x(), position.z(), () -> { + try { + NewChunkHolder holder = level.moonrise$getChunkTaskScheduler().chunkHolderManager + .getChunkHolder(position.x(), position.z()); + ChunkAccess chunk = holder == null ? null : holder.getCurrentChunk(); + snapshot.complete(chunk != null && chunk.isUnsaved() ? scheduleSnapshot(level, chunk) + : new CapturedChunk(position, CompletableFuture.completedFuture(null))); + } catch (Throwable failure) { + snapshot.completeExceptionally(failure); + } + }).whenComplete((ignored, failure) -> { + if (failure != null) { + snapshot.completeExceptionally(failure); + } + }); + } + return snapshots; + } + + private static void awaitSnapshots(List> snapshots, long deadlineNanos) + throws IOException { + try { + CompletableFuture.allOf(snapshots.toArray(CompletableFuture[]::new)) + .get(Math.max(0L, deadlineNanos - System.nanoTime()), TimeUnit.NANOSECONDS); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while copying terrain checkpoint chunks.", failure); + } catch (ExecutionException | TimeoutException failure) { + throw new IOException("Unable to copy terrain checkpoint chunks.", failure); + } + } + static void finishCheckpoint(ServerLevel level, Path worldFolder, List checkpoints) throws IOException { long deadline = System.nanoTime() + WRITE_TIMEOUT_NANOS; for (Checkpoint checkpoint : checkpoints) { @@ -147,31 +190,72 @@ final class NmsSavedTerrainCapture { static void awaitWrite(ServerLevel level, ChunkPos chunk, long deadlineNanos) throws IOException { ChunkTaskScheduler scheduler = level.moonrise$getChunkTaskScheduler(); while (true) { - if (Thread.currentThread().isInterrupted()) { - throw new IOException("Interrupted while saving terrain checkpoint chunk " + chunk); - } - if (System.nanoTime() - deadlineNanos >= 0L) { - throw new IOException("Timed out saving terrain checkpoint chunk " + chunk); - } + checkWriteWait(chunk, deadlineNanos); if (MoonriseRegionFileIO.getPriority(level, chunk.x(), chunk.z(), MoonriseRegionFileIO.RegionFileType.CHUNK_DATA) == Priority.COMPLETING) { return; } - boolean helped = scheduler.saveExecutor.executeTask(); - helped |= scheduler.compressionExecutor.executeTask(); - if (!helped) { - LockSupport.parkNanos(1_000_000L); - } + helpSaveTasks(scheduler); } } - private static CompoundTag saveSnapshot(ServerLevel level, ChunkAccess chunk) { + static CapturedChunk scheduleSnapshot(ServerLevel level, ChunkAccess chunk) { SerializableChunkData snapshot = SerializableChunkData.copyOf(level, chunk); PlatformHooks.get().chunkSyncSave(level, chunk, snapshot); - CompoundTag data = snapshot.write(); - MoonriseRegionFileIO.scheduleSave(level, chunk.getPos().x(), chunk.getPos().z(), data, - MoonriseRegionFileIO.RegionFileType.CHUNK_DATA); - return data; + Completable nativeData = new Completable<>(); + CompletableFuture data = new CompletableFuture<>(); + PrioritisedExecutor.PrioritisedTask task = level.moonrise$getChunkTaskScheduler().saveExecutor + .createTask(() -> serializeSnapshot(snapshot, nativeData, data)); + task.queue(); + ChunkPos position = chunk.getPos(); + MoonriseRegionFileIO.scheduleSave(level, position.x(), position.z(), nativeData, task, + MoonriseRegionFileIO.RegionFileType.CHUNK_DATA, Priority.NORMAL); + return new CapturedChunk(position, data); + } + + static CompoundTag awaitSnapshot(ServerLevel level, CapturedChunk captured, long deadlineNanos) throws IOException { + ChunkTaskScheduler scheduler = level.moonrise$getChunkTaskScheduler(); + while (!captured.data().isDone()) { + checkWriteWait(captured.chunk(), deadlineNanos); + helpSaveTasks(scheduler); + } + try { + return captured.data().join(); + } catch (CompletionException failure) { + throw new IOException("Unable to serialize terrain checkpoint chunk " + captured.chunk(), failure.getCause()); + } + } + + private static void serializeSnapshot(SerializableChunkData snapshot, Completable nativeData, + CompletableFuture data) { + try { + CompoundTag serialized = snapshot.write(); + nativeData.complete(serialized); + data.complete(serialized); + } catch (Throwable failure) { + nativeData.completeExceptionally(failure); + data.completeExceptionally(failure); + } + } + + private static void checkWriteWait(ChunkPos chunk, long deadlineNanos) throws IOException { + if (Thread.currentThread().isInterrupted()) { + throw new IOException("Interrupted while saving terrain checkpoint chunk " + chunk); + } + if (System.nanoTime() - deadlineNanos >= 0L) { + throw new IOException("Timed out saving terrain checkpoint chunk " + chunk); + } + } + + private static void helpSaveTasks(ChunkTaskScheduler scheduler) { + boolean helped = scheduler.saveExecutor.executeTask(); + helped |= scheduler.compressionExecutor.executeTask(); + if (!helped) { + LockSupport.parkNanos(1_000_000L); + } + } + + record CapturedChunk(ChunkPos chunk, CompletableFuture data) { } record Checkpoint(ChunkPos chunk, Verification verification) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java index 99974602e..89105bb97 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java @@ -1,9 +1,14 @@ package art.arcane.iris.core.nms.v26_2_R1; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; +import art.arcane.iris.nativegen.NativeStructurePostProcessor; +import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator; import net.minecraft.SharedConstants; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; import net.minecraft.server.Bootstrap; import net.minecraft.world.entity.Entity; import net.minecraft.world.level.ChunkPos; @@ -12,10 +17,25 @@ import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeManager; 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.status.ChunkStatus; import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.Heightmap; +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.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.DesertPyramidStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.GravityProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; import net.minecraft.world.level.material.Fluid; import net.minecraft.world.level.material.FluidState; import net.minecraft.world.phys.AABB; @@ -30,11 +50,13 @@ import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.ArrayList; 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.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class NativeStructureWorldgenAccessTest { @@ -49,6 +71,69 @@ public class NativeStructureWorldgenAccessTest { Bootstrap.bootStrap(); } + @Test + public void ordinaryWorldFlattenPreparationHandlesReferencedVillagePiecesBeyondEightChunks() { + int farX = (GENERATION_CENTER_X - 9) << 4; + int farZ = (GENERATION_CENTER_Z + 4) << 4; + BoundingBox bounds = new BoundingBox(farX, 64, farZ, farX + 4, 90, farZ + 4); + PoolElementStructurePiece piece = new PoolElementStructurePiece( + null, StructurePoolElement.single("minecraft:empty").apply(StructureTemplatePool.Projection.RIGID), + new BlockPos(farX, 64, farZ), 1, Rotation.NONE, bounds, LiquidSettings.APPLY_WATERLOGGING); + Structure structure = new DesertPyramidStructure(new Structure.StructureSettings( + HolderSet.empty(), Map.of(), GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.BEARD_BOX)); + StructureStart start = new StructureStart(structure, generationCenter(), 0, new PiecesContainer(List.of(piece))); + List targets = List.of( + new NativeStructureTerrainIntegrator.TerrainTarget("minecraft:village_plains", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.FLATTEN))); + BoundingBox area = new BoundingBox(generationCenter().getMinBlockX(), -64, generationCenter().getMinBlockZ(), + generationCenter().getMaxBlockX(), 319, generationCenter().getMaxBlockZ()); + RecordingDelegate unbounded = new RecordingDelegate(); + unbounded.rejectDistantTerrainReads = true; + + assertThrows(IllegalStateException.class, () -> NativeStructurePostProcessor.prepareTerrain( + unbounded.world(), area, targets, (palette, rng, x, y, z) -> Blocks.STONE.defaultBlockState())); + + RecordingDelegate recording = new RecordingDelegate(); + recording.rejectDistantTerrainReads = true; + NativeStructureWorldgenAccess access = access(recording); + NativeStructurePostProcessor.prepareTerrain( + access, area, targets, (palette, rng, x, y, z) -> Blocks.STONE.defaultBlockState()); + + assertEquals(0, recording.heightReads); + assertEquals(0, recording.chunkReads); + assertEquals(0, recording.terrainReads); + assertEquals(0, recording.mutations); + assertEquals(91, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG, + generationCenter().getMinBlockX(), generationCenter().getMinBlockZ())); + assertEquals(1, recording.heightReads); + } + + @Test + public void projectedTemplatePrefiltersUseRemoteTerrainAndRetainLocalHeightmaps() { + RecordingDelegate recording = new RecordingDelegate(); + recording.rejectDistantTerrainReads = true; + NativeStructureWorldgenAccess access = access(recording); + GravityProcessor gravity = new GravityProcessor(Heightmap.Types.WORLD_SURFACE_WG, -1); + BlockPos remote = new BlockPos((GENERATION_CENTER_X - 9) << 4, 64, (GENERATION_CENTER_Z + 4) << 4); + StructureTemplate.StructureBlockInfo raw = new StructureTemplate.StructureBlockInfo( + new BlockPos(0, 4, 0), Blocks.DIRT_PATH.defaultBlockState(), null); + StructurePlaceSettings settings = new StructurePlaceSettings().addProcessor(gravity); + + StructureTemplate.StructureBlockInfo projected = StructureTemplate.processBlockInfos( + access, remote, BlockPos.ZERO, settings, List.of(raw)).getFirst(); + + assertEquals(remote.atY(SURFACE_FIRST_FREE_Y + 3), projected.pos()); + assertEquals(0, recording.heightReads); + BlockPos local = generationCenter().getMiddleBlockPosition(64); + StructureTemplate.StructureBlockInfo localBlock = new StructureTemplate.StructureBlockInfo( + BlockPos.ZERO, Blocks.DIRT_PATH.defaultBlockState(), null); + StructureTemplate.StructureBlockInfo localProjection = StructureTemplate.processBlockInfos( + access, local, BlockPos.ZERO, settings, List.of(localBlock)).getFirst(); + assertEquals(local.atY(90), localProjection.pos()); + assertEquals(1, recording.heightReads); + assertEquals(0, recording.chunkReads); + } + @Test public void distanceTwoTerrainReadsNeverReachTheDelegate() { RecordingDelegate recording = new RecordingDelegate(); @@ -253,6 +338,7 @@ public class NativeStructureWorldgenAccessTest { private int statusChunkReads; private int mutations; private int events; + private boolean rejectDistantTerrainReads; private RecordingDelegate() { this.biome = Holder.direct((Biome) null); @@ -293,6 +379,11 @@ public class NativeStructureWorldgenAccessTest { return 384; } if (name.equals("getHeight")) { + if (rejectDistantTerrainReads + && (Math.abs(((int) arguments[1] >> 4) - GENERATION_CENTER_X) > 1 + || Math.abs(((int) arguments[2] >> 4) - GENERATION_CENTER_Z) > 1)) { + throw new IllegalStateException("Requested chunk unavailable during world generation"); + } heightReads++; return 91; } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsChunkIOFlushTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsChunkIOFlushTest.java new file mode 100644 index 000000000..88552a7d0 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsChunkIOFlushTest.java @@ -0,0 +1,100 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO.RegionDataController; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import org.bukkit.craftbukkit.CraftWorld; +import org.junit.BeforeClass; +import org.junit.Test; + +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.atomic.AtomicBoolean; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NmsChunkIOFlushTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void nativeDrainCanWaitOffOwnerUntilChunkPoiAndEntityIoFinish() throws Exception { + Fixture fixture = new Fixture(); + AtomicBoolean chunkPending = new AtomicBoolean(true); + AtomicBoolean poiPending = new AtomicBoolean(true); + AtomicBoolean entityPending = new AtomicBoolean(true); + CountDownLatch chunkDrain = new CountDownLatch(1); + CountDownLatch poiDrain = new CountDownLatch(1); + CountDownLatch entityDrain = new CountDownLatch(1); + when(fixture.chunks.hasTasks()).thenAnswer(invocation -> { + chunkDrain.countDown(); + return chunkPending.get(); + }); + when(fixture.pois.hasTasks()).thenAnswer(invocation -> { + poiDrain.countDown(); + return poiPending.get(); + }); + when(fixture.entities.hasTasks()).thenAnswer(invocation -> { + entityDrain.countDown(); + return entityPending.get(); + }); + ExecutorService io = Executors.newSingleThreadExecutor(); + try { + Future drain = io.submit(() -> fixture.binding.flushChunkIO(fixture.world)); + assertTrue(chunkDrain.await(5L, TimeUnit.SECONDS)); + assertFalse(drain.isDone()); + chunkPending.set(false); + assertTrue(poiDrain.await(5L, TimeUnit.SECONDS)); + assertFalse(drain.isDone()); + poiPending.set(false); + assertTrue(entityDrain.await(5L, TimeUnit.SECONDS)); + assertFalse(drain.isDone()); + entityPending.set(false); + drain.get(5L, TimeUnit.SECONDS); + } finally { + chunkPending.set(false); + poiPending.set(false); + entityPending.set(false); + io.shutdownNow(); + assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test + public void nativeDrainFailurePropagatesToItsCompletionReceipt() { + Fixture fixture = new Fixture(); + IllegalStateException failure = new IllegalStateException("native IO failed"); + when(fixture.chunks.hasTasks()).thenThrow(failure); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> fixture.binding.flushChunkIO(fixture.world))); + } + + private static final class Fixture { + private final NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS); + private final CraftWorld world = mock(CraftWorld.class); + private final ServerLevel level = mock(ServerLevel.class); + private final RegionDataController chunks = mock(RegionDataController.class); + private final RegionDataController pois = mock(RegionDataController.class); + private final RegionDataController entities = mock(RegionDataController.class); + + private Fixture() { + when(world.getHandle()).thenReturn(level); + when(level.moonrise$getChunkDataController()).thenReturn(chunks); + when(level.moonrise$getPoiChunkDataController()).thenReturn(pois); + when(level.moonrise$getEntityChunkDataController()).thenReturn(entities); + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsEntitySpawnDifficultyTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsEntitySpawnDifficultyTest.java new file mode 100644 index 000000000..a16e4aa8d --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsEntitySpawnDifficultyTest.java @@ -0,0 +1,103 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import net.minecraft.SharedConstants; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import org.bukkit.Difficulty; +import org.bukkit.Location; +import org.bukkit.craftbukkit.CraftWorld; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class NmsEntitySpawnDifficultyTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void peacefulRejectsEveryNativeForbiddenTypeBeforeBukkitCreatesIt() { + CraftWorld world = mock(CraftWorld.class); + when(world.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS); + Location location = new Location(world, 0.5, 80, 0.5); + int rejected = 0; + + for (EntityType type : EntityType.values()) { + if (type.getEntityClass() == null) { + continue; + } + if (BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(type.getKey().toString())).isAllowedInPeaceful()) { + continue; + } + assertNull(type.name(), binding.spawnEntity(location, type, CreatureSpawnEvent.SpawnReason.NATURAL)); + rejected++; + } + + assertTrue(rejected > 20); + verify(world, never()).spawn(any(Location.class), any(Class.class), isNull(), any(CreatureSpawnEvent.SpawnReason.class)); + } + + @Test + public void peacefulPreservesPassiveAndNativeMonsterCategoryExceptions() { + CraftWorld world = mock(CraftWorld.class); + when(world.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS); + Location location = new Location(world, 0.5, 80, 0.5); + Entity created = mock(Entity.class); + doReturn(created).when(world).spawn(eq(location), any(Class.class), isNull(), eq(CreatureSpawnEvent.SpawnReason.NATURAL)); + + for (EntityType type : List.of(EntityType.COW, EntityType.VILLAGER, EntityType.WOLF, EntityType.PIGLIN, EntityType.SHULKER)) { + assertSame(type.name(), created, binding.spawnEntity(location, type, CreatureSpawnEvent.SpawnReason.NATURAL)); + } + } + + @Test + public void nonPeacefulDifficultiesPreserveHostileSpawns() { + CraftWorld world = mock(CraftWorld.class); + NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS); + Location location = new Location(world, 0.5, 80, 0.5); + Entity created = mock(Entity.class); + doReturn(created).when(world).spawn(eq(location), any(Class.class), isNull(), eq(CreatureSpawnEvent.SpawnReason.NATURAL)); + + for (Difficulty difficulty : List.of(Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD)) { + when(world.getDifficulty()).thenReturn(difficulty); + assertSame(created, binding.spawnEntity(location, EntityType.ZOMBIE, CreatureSpawnEvent.SpawnReason.NATURAL)); + } + } + + @Test + public void unrelatedBukkitSpawnFailuresStillPropagate() { + CraftWorld world = mock(CraftWorld.class); + when(world.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS); + Location location = new Location(world, 0.5, 80, 0.5); + IllegalStateException failure = new IllegalStateException("Spawn rejected by platform"); + doThrow(failure).when(world).spawn(eq(location), any(Class.class), isNull(), eq(CreatureSpawnEvent.SpawnReason.NATURAL)); + + assertSame(failure, assertThrows(IllegalStateException.class, + () -> binding.spawnEntity(location, EntityType.COW, CreatureSpawnEvent.SpawnReason.NATURAL))); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainSnapshotTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainSnapshotTest.java new file mode 100644 index 000000000..4e38ebed1 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NmsSavedTerrainSnapshotTest.java @@ -0,0 +1,223 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.util.common.scheduling.J; +import ca.spottedleaf.concurrentutil.completable.Completable; +import ca.spottedleaf.concurrentutil.executor.PrioritisedExecutor; +import ca.spottedleaf.concurrentutil.executor.thread.BalancedPrioritisedThreadPool; +import ca.spottedleaf.concurrentutil.util.Priority; +import ca.spottedleaf.moonrise.common.PlatformHooks; +import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; +import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager; +import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler; +import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.NewChunkHolder; +import net.minecraft.SharedConstants; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.storage.SerializableChunkData; +import org.bukkit.World; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +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.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.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +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 NmsSavedTerrainSnapshotTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void ownerReservesSaveOrderBeforeOffOwnerSerializationAndLaterAutosave() throws Exception { + try (SaveFixture fixture = new SaveFixture(); + ExecutorService checkpoint = Executors.newSingleThreadExecutor(); + MockedStatic snapshots = mockStatic(SerializableChunkData.class); + MockedStatic hooks = mockStatic(PlatformHooks.class); + MockedStatic io = mockStatic(MoonriseRegionFileIO.class)) { + Thread owner = Thread.currentThread(); + ChunkAccess chunk = mock(ChunkAccess.class); + SerializableChunkData snapshot = mock(SerializableChunkData.class); + PlatformHooks platform = mock(PlatformHooks.class); + CompoundTag capturedData = new CompoundTag(); + CompoundTag autosave = new CompoundTag(); + List submissions = new ArrayList<>(); + when(chunk.getPos()).thenReturn(new ChunkPos(4, -9)); + snapshots.when(() -> SerializableChunkData.copyOf(fixture.level, chunk)).thenAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + return snapshot; + }); + hooks.when(PlatformHooks::get).thenReturn(platform); + doAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + return null; + }).when(platform).chunkSyncSave(fixture.level, chunk, snapshot); + when(snapshot.write()).thenAnswer(invocation -> { + assertNotSame(owner, Thread.currentThread()); + assertEquals(List.of("checkpoint", "autosave"), submissions); + return capturedData; + }); + io.when(() -> MoonriseRegionFileIO.scheduleSave(eq(fixture.level), eq(4), eq(-9), + any(Completable.class), any(PrioritisedExecutor.PrioritisedTask.class), + eq(MoonriseRegionFileIO.RegionFileType.CHUNK_DATA), eq(Priority.NORMAL))) + .thenAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + submissions.add("checkpoint"); + return null; + }); + io.when(() -> MoonriseRegionFileIO.scheduleSave(fixture.level, 4, -9, autosave, + MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)).thenAnswer(invocation -> { + submissions.add("autosave"); + return null; + }); + + NmsSavedTerrainCapture.CapturedChunk captured = NmsSavedTerrainCapture.scheduleSnapshot(fixture.level, chunk); + + assertEquals(List.of("checkpoint"), submissions); + assertFalse(captured.data().isDone()); + verify(snapshot, never()).write(); + verify(platform).chunkSyncSave(fixture.level, chunk, snapshot); + MoonriseRegionFileIO.scheduleSave(fixture.level, 4, -9, autosave, + MoonriseRegionFileIO.RegionFileType.CHUNK_DATA); + Future result = checkpoint.submit(() -> NmsSavedTerrainCapture.awaitSnapshot( + fixture.level, captured, System.nanoTime() + TimeUnit.SECONDS.toNanos(3))); + + assertSame(capturedData, result.get(5, TimeUnit.SECONDS)); + verify(snapshot).write(); + } + } + + @Test + public void failedOffOwnerSerializationCannotSucceedAsACheckpoint() throws Exception { + try (SaveFixture fixture = new SaveFixture(); + ExecutorService checkpoint = Executors.newSingleThreadExecutor(); + MockedStatic snapshots = mockStatic(SerializableChunkData.class); + MockedStatic hooks = mockStatic(PlatformHooks.class); + MockedStatic io = mockStatic(MoonriseRegionFileIO.class)) { + ChunkAccess chunk = mock(ChunkAccess.class); + SerializableChunkData snapshot = mock(SerializableChunkData.class); + IllegalStateException failure = new IllegalStateException("serialization failed"); + when(chunk.getPos()).thenReturn(new ChunkPos(4, -9)); + snapshots.when(() -> SerializableChunkData.copyOf(fixture.level, chunk)).thenReturn(snapshot); + hooks.when(PlatformHooks::get).thenReturn(mock(PlatformHooks.class)); + when(snapshot.write()).thenThrow(failure); + NmsSavedTerrainCapture.CapturedChunk captured = NmsSavedTerrainCapture.scheduleSnapshot(fixture.level, chunk); + + Future result = checkpoint.submit(() -> assertThrows(IOException.class, + () -> NmsSavedTerrainCapture.awaitSnapshot(fixture.level, captured, + System.nanoTime() + TimeUnit.SECONDS.toNanos(3)))); + + assertSame(failure, result.get(5, TimeUnit.SECONDS).getCause()); + assertTrue(captured.data().isCompletedExceptionally()); + } + } + + @Test + public void checkpointBoundsOwnerRequestsUntilPriorNativeWritesFinish() throws Exception { + try (SaveFixture fixture = new SaveFixture(); + ExecutorService owner = Executors.newSingleThreadExecutor(); + ExecutorService checkpoint = Executors.newSingleThreadExecutor()) { + World world = mock(World.class); + when(world.getWorldFolder()).thenReturn(new File("checkpoint-world")); + List holders = new ArrayList<>(129); + for (int index = 0; index < 129; index++) { + NewChunkHolder holder = mock(NewChunkHolder.class); + setField(holder, "chunkX", index); + holders.add(holder); + } + AtomicInteger requested = new AtomicInteger(); + AtomicBoolean writesBlocked = new AtomicBoolean(true); + CountDownLatch firstBatchWaiting = new CountDownLatch(1); + Future result = checkpoint.submit(() -> { + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic io = mockStatic(MoonriseRegionFileIO.class)) { + scheduling.when(() -> J.runRegionFuture(eq(world), anyInt(), anyInt(), any(Runnable.class))) + .thenAnswer(invocation -> { + requested.incrementAndGet(); + Runnable action = invocation.getArgument(3); + return CompletableFuture.runAsync(action, owner); + }); + io.when(() -> MoonriseRegionFileIO.getPriority(eq(fixture.level), anyInt(), anyInt(), + eq(MoonriseRegionFileIO.RegionFileType.CHUNK_DATA))).thenAnswer(invocation -> { + firstBatchWaiting.countDown(); + return writesBlocked.get() ? Priority.NORMAL : Priority.COMPLETING; + }); + NmsSavedTerrainCapture.flushSnapshots(world, fixture.level, holders); + io.verify(() -> MoonriseRegionFileIO.getPriority(fixture.level, 128, 0, + MoonriseRegionFileIO.RegionFileType.CHUNK_DATA), times(2)); + return null; + } + }); + try { + assertTrue(firstBatchWaiting.await(5, TimeUnit.SECONDS)); + assertEquals(64, requested.get()); + assertTrue(owner.submit(() -> true).get(5, TimeUnit.SECONDS)); + assertFalse(result.isDone()); + writesBlocked.set(false); + result.get(5, TimeUnit.SECONDS); + assertEquals(129, requested.get()); + } finally { + writesBlocked.set(false); + } + } + } + + private static void setField(Object instance, String name, Object value) throws Exception { + Field field = instance.getClass().getField(name); + field.setAccessible(true); + field.set(instance, value); + } + + private static final class SaveFixture implements AutoCloseable { + private final BalancedPrioritisedThreadPool pool = new BalancedPrioritisedThreadPool( + TimeUnit.MILLISECONDS.toNanos(1), Executors.defaultThreadFactory()); + private final ServerLevel level = mock(ServerLevel.class); + + private SaveFixture() throws Exception { + BalancedPrioritisedThreadPool.OrderedStreamGroup group = pool.createOrderedStreamGroup(); + ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class); + setField(scheduler, "saveExecutor", group.createExecutor()); + setField(scheduler, "compressionExecutor", group.createExecutor()); + setField(scheduler, "chunkHolderManager", mock(ChunkHolderManager.class)); + when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler); + } + + @Override + public void close() throws Exception { + pool.halt(true); + assertTrue(pool.joinInterruptable(5_000)); + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java new file mode 100644 index 000000000..65d60c5a9 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/IrisChunkGeneratorHeightCacheTest.java @@ -0,0 +1,130 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.core.nms.v26_2_R1.IrisChunkGenerator; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.history.BoundaryColumnGeometry; +import art.arcane.iris.engine.history.TerrainBoundarySignature; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.levelgen.Heightmap; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public final class IrisChunkGeneratorHeightCacheTest { + @Rule + public final PlatformLeakGuard leakGuard = PlatformLeakGuard.clean(); + + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception { + Fixture fixture = fixture(); + LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); + assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); + when(fixture.complex().resolvedTerrainColumn(-1, -33)) + .thenThrow(new IllegalStateException("Full terrain was evicted")); + + for (int index = 0; index < 100; index++) { + assertEquals(-11, fixture.generator().getBaseHeight( + -1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); + } + verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33); + + when(fixture.engine().getCacheID()).thenReturn(2); + doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33); + assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null)); + verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33); + } + + @Test + public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception { + Fixture fixture = fixture(); + LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); + when(fixture.complex().resolvedTerrainColumn(0, 0)) + .thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature())); + when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25); + + assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + verify(fixture.engine(), times(2)).getHeight(0, 0, false); + verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0); + } + + @Test + public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception { + Fixture fixture = fixture(); + LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32); + for (int repeat = 0; repeat < 2; repeat++) { + assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null)); + assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null)); + assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null)); + assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null)); + assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, + LevelHeightAccessor.create(-16, 2), null)); + assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, + LevelHeightAccessor.create(-14, 3), null)); + } + verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0); + } + + private static Fixture fixture() throws Exception { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(engine.getCacheID()).thenReturn(1); + when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop()); + when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature())); + IrisChunkGenerator generator = mock(IrisChunkGenerator.class, CALLS_REAL_METHODS); + set(generator, "engine", engine); + set(generator, "terrainHeights", new NativeTerrainHeightCache()); + return new Fixture(generator, engine, complex); + } + + private static void set(IrisChunkGenerator generator, String name, Object value) throws Exception { + Field field = IrisChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + field.set(generator, value); + } + + private static TerrainBoundarySignature signature() { + BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-16, List.of( + new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false), + new BoundaryColumnGeometry.Voxel("minecraft:water", BoundaryColumnGeometry.Phase.FLUID, "minecraft:water", false), + new BoundaryColumnGeometry.Voxel("minecraft:oak_leaves[persistent=true]", BoundaryColumnGeometry.Phase.SOLID, "", false), + new BoundaryColumnGeometry.Voxel("minecraft:short_grass", BoundaryColumnGeometry.Phase.SOLID, "", false), + new BoundaryColumnGeometry.Voxel("minecraft:light[level=15]", BoundaryColumnGeometry.Phase.SOLID, "", false))); + return new TerrainBoundarySignature( + new TerrainBoundarySignature.Column(0, 0, 4, 4, OptionalInt.of(1), OptionalInt.empty()), + new TerrainBoundarySignature.Samples(new TerrainBoundarySignature.VerticalLayout(-16, 5, 1), + new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry); + } + + private record Fixture(IrisChunkGenerator generator, Engine engine, IrisComplex complex) { + } +} 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 1a53c790a..0e013e8e0 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 @@ -16,6 +16,7 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.server.Bootstrap; import net.minecraft.util.RandomSource; import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelAccessor; import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.biome.Biome; @@ -30,7 +31,9 @@ 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.chunk.status.ChunkStatus; import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.Structure; @@ -51,6 +54,9 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.ProcessorRule import net.minecraft.world.level.levelgen.structure.templatesystem.RuleProcessor; 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.OceanMonumentStructure; +import net.minecraft.world.level.levelgen.structure.structures.ShipwreckStructure; +import net.minecraft.world.level.levelgen.structure.structures.StrongholdStructure; import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessor; @@ -99,6 +105,410 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { Bootstrap.bootStrap(); } + @Test + public void flattenCutsAndFillsSymmetricallyAcrossItsConfiguredBlend() { + NativeStructureSurfaceFitter.FlattenAnchor anchor = new NativeStructureSurfaceFitter.FlattenAnchor( + new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 80, 2), 24, 48); + List anchors = List.of(anchor); + assertEquals(80, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 0, 0, 128).targetY()); + assertEquals(80, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 0, 0, 32).targetY()); + assertEquals(152, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 0, 0, 200).targetY()); + assertEquals(48, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 0, 0, 0).targetY()); + int previousCut = 80; + for (int x = 1; x <= 24; x++) { + int cut = NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, x, 0, 128).targetY(); + int fill = NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, x, 0, 32).targetY(); + assertEquals(160, cut + fill); + assertTrue(cut >= previousCut); + assertTrue(cut - previousCut <= 3); + previousCut = cut; + } + assertEquals(128, previousCut); + assertEquals(0, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 24, 0, 128).range()); + } + + @Test + public void flattenBlendsNeighboringFoundationsWithoutOrderDependentRidges() { + NativeStructureSurfaceFitter.FlattenAnchor left = new NativeStructureSurfaceFitter.FlattenAnchor( + new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 80, 2), 24, 64); + NativeStructureSurfaceFitter.FlattenAnchor right = new NativeStructureSurfaceFitter.FlattenAnchor( + new NativeStructureSurfaceFitter.SurfaceAnchor(8, 8, 0, 0, 80, 2), 24, 64); + int previous = 80; + for (int x = 0; x <= 8; x++) { + NativeStructureSurfaceFitter.FlattenResolution forward = NativeStructureSurfaceFitter.resolveFlattenSurface( + List.of(left, right), x, 0, 104); + assertEquals(forward, NativeStructureSurfaceFitter.resolveFlattenSurface( + List.of(right, left), x, 0, 104)); + assertTrue(forward.targetY() <= 82); + assertTrue(Math.abs(forward.targetY() - previous) <= 1); + previous = forward.targetY(); + } + } + + @Test + public void flattenLevelsNonJigsawFoundationsAndRemovesDetachedRoofs() { + StructureStart start = desertStart(); + int groundY = start.getBoundingBox().minY() - 1; + BoundingBox area = new BoundingBox(0, 0, 0, 3, 127, 0); + Map blocks = new HashMap<>(); + for (int x = 0; x <= 3; x++) { + int sourceY = x < 2 ? groundY - 32 : groundY + 32; + put(blocks, x, 12, 0, Blocks.STONE.defaultBlockState()); + put(blocks, x, sourceY - 1, 0, Blocks.DIRT.defaultBlockState()); + put(blocks, x, sourceY, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + } + assertTrue(NativeStructureSurfaceFitter.requiresFlattenTerrain( + surfaceTarget(start, IrisStructureTerrainMode.FLATTEN))); + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), + (x, z) -> x < 2 ? groundY - 32 : groundY + 32); + + for (int x = 0; x <= 3; x++) { + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, x, groundY, 0)); + for (int y = groundY + 1; y <= groundY + 32; y++) { + assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir()); + } + for (int y = 12; y < groundY; y++) { + assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid()); + } + } + } + + @Test + public void flattenRestoresAnUnchangedFoundationSurfaceOverADeepAirGap() { + StructureStart start = desertStart(); + int groundY = start.getBoundingBox().minY() - 1; + BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0); + Map blocks = new HashMap<>(); + put(blocks, 0, groundY - 19, 0, Blocks.DIRT.defaultBlockState()); + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), (x, z) -> groundY); + + for (int y = groundY - 19; y <= groundY; y++) { + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, y, 0)); + } + assertTrue(state(blocks, 0, groundY + 1, 0).isAir()); + } + + @Test + public void flattenLeavesBuriedAndSubmergedInstancesUntouched() { + StructureStart start = desertStart(); + int groundY = start.getBoundingBox().minY() - 1; + BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0); + Map buried = flatTerrain(area, 100); + Map originalBuried = new HashMap<>(buried); + Map submerged = flatTerrain(area, groundY - 8); + put(submerged, 0, groundY - 7, 0, Blocks.WATER.defaultBlockState()); + Map originalSubmerged = new HashMap<>(submerged); + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(buried), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), (x, z) -> 100); + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(submerged), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), (x, z) -> groundY - 8); + + assertEquals(originalBuried, buried); + assertEquals(originalSubmerged, submerged); + } + + @Test + public void flattenExemptsUndergroundAndSubmergedStructureTypes() { + Structure.StructureSettings surface = new Structure.StructureSettings( + HolderSet.empty(), Map.of(), GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.NONE); + Structure.StructureSettings underground = new Structure.StructureSettings( + HolderSet.empty(), Map.of(), GenerationStep.Decoration.UNDERGROUND_STRUCTURES, TerrainAdjustment.NONE); + List exempt = List.of(new StrongholdStructure(surface), new OceanMonumentStructure(surface), + new ShipwreckStructure(surface, false), new DesertPyramidStructure(underground)); + for (Structure structure : exempt) { + StructureStart start = new StructureStart(structure, new ChunkPos(0, 0), 0, + new PiecesContainer(desertStart().getPieces())); + assertFalse(NativeStructureSurfaceFitter.requiresFlattenTerrain( + surfaceTarget(start, IrisStructureTerrainMode.FLATTEN))); + } + assertFalse(NativeStructureSurfaceFitter.requiresFlattenTerrain( + surfaceTarget(desertStart(TerrainAdjustment.BURY), IrisStructureTerrainMode.FLATTEN))); + assertFalse(NativeStructureSurfaceFitter.requiresFlattenTerrain( + surfaceTarget(desertStart(TerrainAdjustment.ENCAPSULATE), IrisStructureTerrainMode.FLATTEN))); + } + + @Test + public void flattenFoundationReadsAndWritesStayInsideConfiguredVerticalRange() throws Exception { + PoolElementStructurePiece piece = rigidTemplatePiece(new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 128, 0, 0, 136, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece)); + NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN); + target.terrain().setFlattenRange(32); + BoundingBox area = new BoundingBox(0, 0, 0, 0, 255, 0); + Map blocks = new HashMap<>(); + put(blocks, 0, 94, 0, Blocks.GOLD_BLOCK.defaultBlockState()); + WorldGenLevel delegate = world(blocks); + WorldGenLevel bounded = (WorldGenLevel) Proxy.newProxyInstance(WorldGenLevel.class.getClassLoader(), + new Class[]{WorldGenLevel.class}, (proxy, method, arguments) -> { + if (method.getName().equals("getBlockState") || method.getName().equals("setBlock")) { + BlockPos position = (BlockPos) arguments[0]; + assertTrue(position.toString(), position.getY() >= 95 && position.getY() <= 159); + } + return method.invoke(delegate, arguments); + }); + + NativeStructureSurfaceFitter.prepareSurfaceStructures(bounded, area, List.of(target), (x, z) -> 127); + + assertEquals(Blocks.GOLD_BLOCK.defaultBlockState(), state(blocks, 0, 94, 0)); + assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 95, 0)); + assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 127, 0)); + assertTrue(state(blocks, 0, 128, 0).isAir()); + } + + @Test + public void flattenReferencesIncludeTheMaximumConfiguredFeather() throws Exception { + PoolElementStructurePiece piece = rigidTemplatePiece(new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 64, 0, 0, 70, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece)); + IrisStructureTerrain terrain = new IrisStructureTerrain().setMode(IrisStructureTerrainMode.FLATTEN) + .setHorizontalPadding(128).setFlattenRange(128); + + BoundingBox references = NativeStructureReferenceEnvelope.referenceBounds(start, start.getStructure(), terrain); + + assertEquals(-128, references.minX()); + assertEquals(128, references.maxX()); + assertEquals(-128, references.minZ()); + assertEquals(128, references.maxZ()); + NativeStructureSurfaceFitter.FlattenAnchor anchor = new NativeStructureSurfaceFitter.FlattenAnchor( + new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 64, 2), 128, 128); + assertTrue(NativeStructureSurfaceFitter.resolveFlattenSurface(List.of(anchor), 127, 0, 100).range() > 0); + assertEquals(0, NativeStructureSurfaceFitter.resolveFlattenSurface(List.of(anchor), 128, 0, 100).range()); + } + + @Test + public void flattenPreservesNativeVillageSupportBridges() throws Exception { + PoolElementStructurePiece lower = rigidTemplatePiece(new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece(new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 64, 0, 0, 70, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0); + Map blocks = supportTerrain(0, 0); + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport(world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), written); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, 63, 0)); + } + + @Test + public void flattenDoesNotBridgeACompletelyBuriedVillage() throws Exception { + PoolElementStructurePiece lower = rigidTemplatePiece(new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece(new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 64, 0, 0, 70, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 90, 0); + Map blocks = supportTerrain(0, 0); + put(blocks, 0, 80, 0, Blocks.STONE.defaultBlockState()); + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport(world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertTrue(written.isEmpty()); + assertTrue(state(blocks, 0, 63, 0).isAir()); + } + + @Test + public void flattenKeepsPyramidPlacementAboveItsPreparedGround() { + FlattenDesertPiece piece = new FlattenDesertPiece(); + StructureStart start = new StructureStart(desertStart().getStructure(), new ChunkPos(0, 0), 0, + new PiecesContainer(List.of(piece))); + BoundingBox footprint = piece.getBoundingBox(); + NativeStructureVerticalPlacer.applyVerticalPlacement(start, "minecraft:desert_pyramid", 0, + 63, 0, 256, false, false, null, (x, z) -> x == footprint.minX() ? 80 : 112); + BoundingBox area = new BoundingBox(footprint.minX(), 0, footprint.minZ(), + footprint.maxX(), 160, footprint.maxZ()); + Map blocks = new HashMap<>(); + for (int x = footprint.minX(); x <= footprint.maxX(); x++) { + for (int z = footprint.minZ(); z <= footprint.maxZ(); z++) { + int surfaceY = x == footprint.minX() ? 80 : 112; + put(blocks, x, surfaceY - 1, z, Blocks.DIRT.defaultBlockState()); + put(blocks, x, surfaceY, z, Blocks.GRASS_BLOCK.defaultBlockState()); + } + } + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), + (x, z) -> x == footprint.minX() ? 80 : 112); + + assertEquals(81, piece.getBoundingBox().minY()); + assertTrue(piece.attemptVanillaAlignment()); + assertEquals(81, piece.getBoundingBox().minY()); + assertTrue(state(blocks, footprint.maxX(), 80, footprint.maxZ()).isSolid()); + assertTrue(state(blocks, footprint.maxX(), 81, footprint.maxZ()).isAir()); + } + + @Test + public void flattenLetsTerrainMatchingPathsFollowThePreparedVillageGrade() throws Exception { + StructureTemplate foundation = template(List.of(block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece left = rigidTemplatePiece(new InlineSinglePoolElement(foundation), + new BoundingBox(0, 65, 0, 0, 75, 0), 0, Rotation.NONE); + PoolElementStructurePiece right = rigidTemplatePiece(new InlineSinglePoolElement(foundation), + new BoundingBox(8, 65, 0, 8, 75, 0), 0, Rotation.NONE); + PoolElementStructurePiece path = rigidTemplatePiece(new InlineSinglePoolElement( + template(List.of(block(0, 0, 0, Blocks.DIRT_PATH.defaultBlockState()))), + List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING), + new BoundingBox(4, 65, 0, 4, 75, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(left, path, right)); + NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN); + target.terrain().setHorizontalPadding(24); + BoundingBox area = new BoundingBox(0, 0, 0, 8, 128, 0); + Map blocks = flatTerrain(area, 96); + for (int x : List.of(0, 8)) { + blocks.remove(new BlockPos(x, 95, 0)); + blocks.remove(new BlockPos(x, 96, 0)); + put(blocks, x, 63, 0, Blocks.DIRT.defaultBlockState()); + put(blocks, x, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + } + WorldGenLevel world = world(blocks); + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world, area, List.of(target), + (x, z) -> x == 0 || x == 8 ? 64 : 96); + NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve( + world, path, BlockPos.ZERO, area, + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager, + area::isInside, cells -> {}); + Map pathGround = + NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells()); + + assertTrue(state(blocks, 4, 96, 0).isAir()); + assertEquals(1, pathGround.size()); + int pathY = pathGround.values().iterator().next().y(); + assertTrue("Path Y " + pathY, pathY >= 64 && pathY <= 67); + } + + @Test + public void flattenPublishesCutAndFillHeightsBeforeNativePathProjectionAtFeatures() throws Exception { + ProtoChunk chunk = new ProtoChunk(new ChunkPos(0, 0), UpgradeData.EMPTY, + LevelHeightAccessor.create(0, 256), containerFactory(), null); + chunk.setPersistedStatus(ChunkStatus.CARVERS); + for (int x = 0; x <= 8; x++) { + int originalY = x < 4 ? 96 : 32; + write(chunk, x, originalY - 1, 0, Blocks.DIRT.defaultBlockState()); + write(chunk, x, originalY, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + } + WorldgenTerrainHeightmaps.primeTerrain(chunk, + (x, z) -> x == 15 ? 81 : x < 4 ? 97 : 33, + (x, z) -> x == 15 ? 61 : x < 4 ? 97 : 33); + StructureTemplate foundation = template(List.of(block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece left = rigidTemplatePiece(new InlineSinglePoolElement(foundation), + new BoundingBox(0, 65, 0, 0, 75, 0), 0, Rotation.NONE); + PoolElementStructurePiece right = rigidTemplatePiece(new InlineSinglePoolElement(foundation), + new BoundingBox(8, 65, 0, 8, 75, 0), 0, Rotation.NONE); + PoolElementStructurePiece path = rigidTemplatePiece(new InlineSinglePoolElement( + template(List.of(block(0, 0, 0, Blocks.DIRT_PATH.defaultBlockState()), + block(8, 0, 0, Blocks.DIRT_PATH.defaultBlockState()))), + List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING), + new BoundingBox(0, 65, 0, 8, 75, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(left, path, right)); + NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN); + target.terrain().setHorizontalPadding(24); + BoundingBox area = new BoundingBox(0, 0, 0, 8, 127, 0); + WorldGenLevel world = world(chunk); + + NativeStructureSurfaceFitter.SurfaceTerrainPlan plan = NativeStructureSurfaceFitter.prepareSurfaceStructures( + world, area, List.of(target), (x, z) -> x < 4 ? 96 : 32); + + assertEquals(96, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 0, 0)); + assertEquals(32, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 0)); + plan.primeHeightmaps(chunk); + chunk.setBlockState(new BlockPos(0, 85, 0), Blocks.OAK_PLANKS.defaultBlockState(), 2); + NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve( + world, path, BlockPos.ZERO, area, + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager, + area::isInside, cells -> {}); + Map pathGround = + NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells()); + + assertEquals(2, pathGround.size()); + for (NativeStructureTemplateOccupancy.LowestCell cell : pathGround.values()) { + assertEquals(64, cell.y()); + chunk.setBlockState(new BlockPos(cell.x(), cell.y(), cell.z()), cell.occupancy().state(), 2); + assertTrue(chunk.getBlockState(new BlockPos(cell.x(), 63, cell.z())).isSolid()); + assertTrue(chunk.getBlockState(new BlockPos(cell.x(), 65, cell.z())).isAir()); + assertTrue(chunk.getBlockState(new BlockPos(cell.x(), 66, cell.z())).isAir()); + assertEquals(64, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, cell.x(), cell.z())); + assertEquals(64, chunk.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, cell.x(), cell.z())); + } + assertEquals(80, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 15, 0)); + assertEquals(60, chunk.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, 15, 0)); + } + + @Test + public void flattenSupportsProjectedRoadsAcrossRoofCavitiesWithoutFillingAdjacentColumns() throws Exception { + ProtoChunk chunk = new ProtoChunk(new ChunkPos(0, 0), UpgradeData.EMPTY, + LevelHeightAccessor.create(0, 256), containerFactory(), null); + chunk.setPersistedStatus(ChunkStatus.CARVERS); + for (int x = 0; x <= 8; x++) { + int originalY = x == 0 || x == 8 ? 64 : 96; + write(chunk, x, 20, 0, Blocks.STONE.defaultBlockState()); + write(chunk, x, originalY - 1, 0, Blocks.DIRT.defaultBlockState()); + write(chunk, x, originalY, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + } + write(chunk, 6, 50, 0, Blocks.WATER.defaultBlockState()); + WorldgenTerrainHeightmaps.primeTerrain(chunk, + (x, z) -> x == 0 || x == 8 ? 65 : 97, + (x, z) -> x == 0 || x == 8 ? 65 : 97); + StructureTemplate foundation = template(List.of(block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece left = rigidTemplatePiece(new InlineSinglePoolElement(foundation), + new BoundingBox(0, 65, 0, 0, 75, 0), 0, Rotation.NONE); + PoolElementStructurePiece right = rigidTemplatePiece(new InlineSinglePoolElement(foundation), + new BoundingBox(8, 65, 0, 8, 75, 0), 0, Rotation.NONE); + PoolElementStructurePiece path = rigidTemplatePiece(new InlineSinglePoolElement( + template(List.of(block(0, 0, 0, Blocks.DIRT_PATH.defaultBlockState()), + block(1, 0, 0, Blocks.AIR.defaultBlockState()), + block(2, 0, 0, Blocks.DIRT_PATH.defaultBlockState()))), + List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING), + new BoundingBox(4, 65, 0, 6, 75, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(left, path, right)); + NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN); + target.terrain().setHorizontalPadding(24).setFlattenRange(32); + BoundingBox area = new BoundingBox(0, 0, 0, 8, 127, 0); + WorldGenLevel world = world(chunk); + + NativeStructureSurfaceFitter.SurfaceTerrainPlan plan = NativeStructureSurfaceFitter.prepareSurfaceStructures( + world, area, List.of(target), (x, z) -> x == 0 || x == 8 ? 64 : 96); + plan.primeHeightmaps(chunk); + NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve( + world, path, BlockPos.ZERO, area, + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager, + area::isInside, cells -> {}); + Map pathGround = + NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells()); + + assertEquals(2, pathGround.size()); + for (NativeStructureTemplateOccupancy.LowestCell cell : pathGround.values()) { + write(chunk, cell.x(), cell.y(), cell.z(), cell.occupancy().state()); + assertTrue(cell.y() > 64 && cell.y() < 72); + assertTrue(chunk.getBlockState(new BlockPos(cell.x(), cell.y() - 1, cell.z())).isSolid()); + assertTrue(chunk.getBlockState(new BlockPos(cell.x(), cell.y() + 1, cell.z())).isAir()); + } + int roadY = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 4, 0); + for (int y = roadY - 32; y < roadY; y++) { + assertTrue("Missing road support at " + y, chunk.getBlockState(new BlockPos(4, y, 0)).isSolid()); + } + assertTrue(chunk.getBlockState(new BlockPos(4, roadY - 33, 0)).isAir()); + int adjacentY = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 5, 0); + assertTrue(chunk.getBlockState(new BlockPos(5, adjacentY - 1, 0)).isAir()); + assertEquals(Blocks.WATER.defaultBlockState(), chunk.getBlockState(new BlockPos(6, 50, 0))); + assertTrue(chunk.getBlockState(new BlockPos(6, 49, 0)).isAir()); + } + @Test public void sourceBeardAdjustmentsPrepareOnlyAtTheSurfaceStructuresStep() { assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( @@ -474,7 +884,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { BoundingBox area = new BoundingBox(0, 54, 0, 3, 74, 3); Map blocks = flatTerrain(area, 60); - NativeStructureSurfaceFitter.VacuumFoundationPlan plan = + NativeStructureSurfaceFitter.SurfaceTerrainPlan plan = NativeStructureSurfaceFitter.prepareSurfaceStructures( world(blocks), area, List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), @@ -514,7 +924,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { BoundingBox area = new BoundingBox(0, 54, 0, 3, 72, 3); Map blocks = flatTerrain(area, 60); - NativeStructureSurfaceFitter.VacuumFoundationPlan plan = + NativeStructureSurfaceFitter.SurfaceTerrainPlan plan = NativeStructureSurfaceFitter.prepareSurfaceStructures( world(blocks), area, List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), @@ -546,13 +956,13 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { Map wideBlocks = flatTerrain(wideArea, 60); Map splitBlocks = flatTerrain(wideArea, 60); - NativeStructureSurfaceFitter.VacuumFoundationPlan widePlan = + NativeStructureSurfaceFitter.SurfaceTerrainPlan widePlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( world(wideBlocks), wideArea, List.of(target), (x, z) -> 60); - NativeStructureSurfaceFitter.VacuumFoundationPlan westPlan = + NativeStructureSurfaceFitter.SurfaceTerrainPlan westPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( world(splitBlocks), westArea, List.of(target), (x, z) -> 60); - NativeStructureSurfaceFitter.VacuumFoundationPlan eastPlan = + NativeStructureSurfaceFitter.SurfaceTerrainPlan eastPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( world(splitBlocks), eastArea, List.of(target), (x, z) -> 60); for (int x = 15; x <= 16; x++) { @@ -2203,6 +2613,16 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { return template; } + private static final class FlattenDesertPiece extends DesertPyramidPiece { + private FlattenDesertPiece() { + super(RandomSource.create(29L), 0, 0); + } + + private boolean attemptVanillaAlignment() { + return updateHeightPositionToLowestGroundHeight((LevelAccessor) null, -2); + } + } + private static final class InlineSinglePoolElement extends SinglePoolElement { private InlineSinglePoolElement(StructureTemplate template) { this(template, List.of()); @@ -2319,6 +2739,9 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { if (methodName.equals("getSeed")) { return TEST_SEED; } + if (methodName.equals("getHeight") && arguments != null && arguments.length == 3) { + return chunk.getHeight((Heightmap.Types) arguments[0], (int) arguments[1], (int) arguments[2]) + 1; + } if (methodName.equals("getLevel")) { return null; } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeTerrainHeightCacheTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeTerrainHeightCacheTest.java new file mode 100644 index 000000000..f0e99a84e --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeTerrainHeightCacheTest.java @@ -0,0 +1,129 @@ +package art.arcane.iris.nativegen; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.levelgen.Heightmap; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.OptionalInt; +import java.util.function.IntSupplier; +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.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class NativeTerrainHeightCacheTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void coordinatesRuntimePredicatesAndBoundsHaveSeparateEntries() { + NativeTerrainHeightCache cache = new NativeTerrainHeightCache(); + AtomicInteger calls = new AtomicInteger(); + NativeTerrainHeightCache.Query[] queries = { + query(1, -1, 1), + query(1, 1, -1), + query(2, -1, 1), + new NativeTerrainHeightCache.Query(1, -1, 1, Heightmap.Types.OCEAN_FLOOR, -256, 768), + new NativeTerrainHeightCache.Query(1, -1, 1, Heightmap.Types.WORLD_SURFACE, -64, 768), + new NativeTerrainHeightCache.Query(1, -1, 1, Heightmap.Types.WORLD_SURFACE, -256, 256) + }; + for (int index = 0; index < queries.length; index++) { + assertEquals(index + 1, cached(cache, queries[index], calls::incrementAndGet)); + } + for (int index = 0; index < queries.length; index++) { + assertEquals(index + 1, cached(cache, queries[index], calls::incrementAndGet)); + } + assertEquals(queries.length, calls.get()); + cache.evictRuntime(1); + assertEquals(3, cached(cache, queries[2], calls::incrementAndGet)); + assertEquals(7, cached(cache, queries[0], calls::incrementAndGet)); + } + + @Test + public void allIntegerResultsAreCacheable() { + NativeTerrainHeightCache cache = new NativeTerrainHeightCache(); + NativeTerrainHeightCache.Query query = query(1, 0, 0); + assertEquals(Integer.MIN_VALUE, cached(cache, query, () -> Integer.MIN_VALUE)); + assertEquals(Integer.MIN_VALUE, cached(cache, query, () -> 0)); + } + + @Test + public void failedResolutionCanBeRetried() { + NativeTerrainHeightCache cache = new NativeTerrainHeightCache(); + NativeTerrainHeightCache.Query query = query(1, 0, 0); + assertThrows(IllegalStateException.class, () -> cached(cache, query, () -> { + throw new IllegalStateException("terrain unavailable"); + })); + assertEquals(42, cached(cache, query, () -> 42)); + } + + @Test + public void coldResolutionDoesNotHoldCacheMonitor() throws Exception { + NativeTerrainHeightCache cache = new NativeTerrainHeightCache(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future slow = executor.submit(() -> cached(cache, query(1, 0, 0), () -> { + entered.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interruption); + } + return 9; + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Future independent = executor.submit(() -> cached(cache, query(1, 1, 0), () -> 17)); + assertEquals(Integer.valueOf(17), independent.get(1, TimeUnit.SECONDS)); + release.countDown(); + assertEquals(Integer.valueOf(9), slow.get(5, TimeUnit.SECONDS)); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void boundedCacheKeepsRecentlyReadCoordinates() { + NativeTerrainHeightCache cache = new NativeTerrainHeightCache(); + for (int x = 0; x < 65_536; x++) { + cached(cache, query(1, x, 0), () -> 8); + } + assertEquals(8, cached(cache, query(1, 0, 0), () -> 99)); + cached(cache, query(1, 65_536, 0), () -> 9); + assertEquals(8, cached(cache, query(1, 0, 0), () -> 99)); + assertEquals(99, cached(cache, query(1, 1, 0), () -> 99)); + } + + @Test + public void unavailableResolvedHeightIsNotCached() { + NativeTerrainHeightCache cache = new NativeTerrainHeightCache(); + NativeTerrainHeightCache.Query query = query(1, 0, 0); + assertTrue(cache.resolvedHeight(query, OptionalInt::empty).isEmpty()); + assertEquals(42, cache.resolvedHeight(query, () -> OptionalInt.of(42)).orElseThrow()); + assertEquals(42, cache.resolvedHeight(query, OptionalInt::empty).orElseThrow()); + } + + private static int cached(NativeTerrainHeightCache cache, NativeTerrainHeightCache.Query query, + IntSupplier resolver) { + return cache.resolvedHeight(query, () -> OptionalInt.of(resolver.getAsInt())).orElseThrow(); + } + + private static NativeTerrainHeightCache.Query query(int runtimeId, int x, int z) { + return new NativeTerrainHeightCache.Query(runtimeId, x, z, Heightmap.Types.WORLD_SURFACE, -256, 768); + } +} 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 1c0bb0b97..102fd21b5 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 @@ -63,6 +63,7 @@ public final class NativeStructureReferenceEnvelope { boolean usesEnvelope = mode == IrisStructureTerrainMode.BORE || mode == IrisStructureTerrainMode.FORCE_CARVE || mode == IrisStructureTerrainMode.VACUUM + || mode == IrisStructureTerrainMode.FLATTEN || mode == IrisStructureTerrainMode.ENCASE; int horizontalPadding = mode == IrisStructureTerrainMode.VACUUM ? NativeStructureSurfaceFitter.surfaceTerrainRadius() diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java index 8a7a41c3d..96641037f 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java @@ -12,6 +12,7 @@ import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; +import net.minecraft.world.level.levelgen.structure.structures.RuinedPortalPiece; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; @@ -52,6 +53,32 @@ final class NativeStructureReflection { return resolved; } + static RuinedPortalPiece.VerticalPlacement ruinedPortalVerticalPlacement(RuinedPortalPiece piece) { + try { + return (RuinedPortalPiece.VerticalPlacement) RuinedPortalPlacementAccess.FIELD.get(piece); + } catch (IllegalAccessException error) { + throw new IllegalStateException("Cannot read native ruined portal vertical placement", error); + } + } + + private static Field resolveRuinedPortalPlacementField() { + Field resolved = null; + for (Field field : RuinedPortalPiece.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) + || field.getType() != RuinedPortalPiece.VerticalPlacement.class) { + continue; + } + if (resolved != null) { + throw new IllegalStateException("RuinedPortalPiece has multiple vertical-placement fields"); + } + resolved = field; + } + if (resolved == null || !resolved.trySetAccessible()) { + throw new IllegalStateException("RuinedPortalPiece vertical-placement field is inaccessible"); + } + return resolved; + } + private static Field resolveMonumentChildPiecesField() { Field resolved = null; for (Field field : OceanMonumentPieces.MonumentBuilding.class.getDeclaredFields()) { @@ -308,6 +335,13 @@ final class NativeStructureReflection { } } + private static final class RuinedPortalPlacementAccess { + private static final Field FIELD = resolveRuinedPortalPlacementField(); + + private RuinedPortalPlacementAccess() { + } + } + static final class ScatteredHeightPositionAccess { static final Field FIELD = resolveScatteredHeightPositionField(); 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 8e54eea10..3aa5c7595 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 @@ -8,7 +8,9 @@ import net.minecraft.core.BlockPos; import net.minecraft.world.level.WorldGenLevel; 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.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.StructurePiece; @@ -16,6 +18,15 @@ 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.structures.IglooPieces; +import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure; +import net.minecraft.world.level.levelgen.structure.structures.OceanRuinStructure; +import net.minecraft.world.level.levelgen.structure.structures.RuinedPortalPiece; +import net.minecraft.world.level.levelgen.structure.structures.ShipwreckStructure; +import net.minecraft.world.level.levelgen.structure.structures.StrongholdStructure; +import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; +import net.minecraft.world.level.levelgen.structure.structures.WoodlandMansionPieces; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; import java.util.ArrayList; import java.util.Collections; @@ -39,11 +50,13 @@ public final class NativeStructureSurfaceFitter { ConcurrentHashMap.newKeySet(); private static final Set WARNED_VACUUM_BUDGET = ConcurrentHashMap.newKeySet(); + private static final Set WARNED_FLATTEN_BUDGET = + ConcurrentHashMap.newKeySet(); private NativeStructureSurfaceFitter() { } - public static VacuumFoundationPlan prepareSurfaceStructures( + public static SurfaceTerrainPlan prepareSurfaceStructures( WorldGenLevel world, BoundingBox area, List targets, IntBinaryOperator surfaceHeight) { @@ -51,12 +64,12 @@ public final class NativeStructureSurfaceFitter { world, area, targets, surfaceHeight, MAX_SURFACE_TEMPLATE_CELLS); } - static VacuumFoundationPlan prepareSurfaceStructures( + static SurfaceTerrainPlan prepareSurfaceStructures( WorldGenLevel world, BoundingBox area, List targets, IntBinaryOperator surfaceHeight, int maximumTemplateCells) { if (targets == null || targets.isEmpty()) { - return VacuumFoundationPlan.empty(); + return SurfaceTerrainPlan.empty(); } Objects.requireNonNull(surfaceHeight, "Surface structure terrain fitting requires an Iris height resolver"); List anchors = collectSourceSurfaceAnchors( @@ -64,18 +77,22 @@ public final class NativeStructureSurfaceFitter { if (!anchors.isEmpty()) { fitSurfaceTerrain(world, area, anchors, surfaceHeight); } + FlattenFootprint flatten = collectFlattenFootprint( + world, area, targets, surfaceHeight, maximumTemplateCells); + Map flattenedHeights = flatten.anchors().isEmpty() && flatten.projectedSupport().isEmpty() ? Map.of() + : fitFlattenTerrain(world, area, flatten, surfaceHeight); VacuumFootprint vacuum = collectVacuumFootprint( world, area, targets, maximumTemplateCells); if (!vacuum.anchors().isEmpty()) { fitVacuumTerrain(world, area, vacuum, surfaceHeight); } - return VacuumFoundationPlan.create( - vacuum.foundationBases(), vacuum.occupiedCells(), area); + return SurfaceTerrainPlan.create( + vacuum, area, flattenedHeights); } public static void repairVacuumFoundations( WorldGenLevel world, BoundingBox area, - VacuumFoundationPlan plan) { + SurfaceTerrainPlan plan) { if (plan == null || plan.foundationBases.isEmpty()) { return; } @@ -484,6 +501,7 @@ public final class NativeStructureSurfaceFitter { } IrisStructureTerrainMode mode = target.terrain().resolvedMode(); return mode == IrisStructureTerrainMode.VACUUM + || requiresFlattenTerrain(target) || requiresSourceSurfaceTerrain(target); } @@ -528,6 +546,268 @@ public final class NativeStructureSurfaceFitter { } } + static boolean requiresFlattenTerrain(NativeStructureTerrainIntegrator.TerrainTarget target) { + if (target == null || target.terrain() == null + || target.terrain().resolvedMode() != IrisStructureTerrainMode.FLATTEN + || target.terrain().resolvedFlattenRange() == 0 + || target.start() == null || !target.start().isValid()) { + return false; + } + StructureStart start = target.start(); + return start.getStructure().step() == GenerationStep.Decoration.SURFACE_STRUCTURES + && start.getStructure().terrainAdaptation() != TerrainAdjustment.BURY + && start.getStructure().terrainAdaptation() != TerrainAdjustment.ENCAPSULATE + && !(start.getStructure() instanceof StrongholdStructure) + && !(start.getStructure() instanceof OceanMonumentStructure) + && !(start.getStructure() instanceof OceanRuinStructure) + && (!(start.getStructure() instanceof ShipwreckStructure) + || "minecraft:shipwreck_beached".equals(target.structureId())); + } + + private static FlattenFootprint collectFlattenFootprint( + WorldGenLevel world, BoundingBox area, + List targets, + IntBinaryOperator surfaceHeight, int maximumTemplateCells) { + List anchors = new ArrayList<>(); + Map projectedSupport = new HashMap<>(); + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { + if (!requiresFlattenTerrain(target) || !seen.add(target.start())) { + continue; + } + int radius = Math.max(0, Math.min(128, target.terrain().getHorizontalPadding())); + int range = target.terrain().resolvedFlattenRange(); + BoundingBox influenceArea = new BoundingBox( + area.minX() - radius, area.minY(), area.minZ() - radius, + area.maxX() + radius, area.maxY(), area.maxZ() + radius); + StructureStart start = target.start(); + if (!hasExposedFlattenPiece(start, surfaceHeight, range)) { + continue; + } + BoundingBox first = start.getPieces().getFirst().getBoundingBox(); + BlockPos reference = new BlockPos(first.getCenter().getX(), first.minY(), first.getCenter().getZ()); + TemplateCellBudget budget = new TemplateCellBudget(maximumTemplateCells); + List sources = new ArrayList<>(); + Set projectedColumns = new HashSet<>(); + try { + for (StructurePiece piece : start.getPieces()) { + BoundingBox bounds = piece.getBoundingBox(); + if (!intersectsHorizontally(bounds, influenceArea) || !flattenPieceAllowed(start, piece)) { + continue; + } + if (piece instanceof PoolElementStructurePiece poolPiece) { + if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) { + addProcessedSurfaceAnchors(world, influenceArea, poolPiece, reference, + StructureTemplatePool.Projection.RIGID, budget, sources); + } else { + addProjectedFlattenSupport(world, area, poolPiece, reference, budget, projectedColumns); + } + for (JigsawJunction junction : poolPiece.getJunctions()) { + sources.add(new SurfaceAnchor(junction.getSourceX(), junction.getSourceX(), + junction.getSourceZ(), junction.getSourceZ(), junction.getSourceGroundY() - 1, 1)); + } + } else { + sources.add(new SurfaceAnchor(bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), + flattenGroundY(start, piece, surfaceHeight), 2)); + } + } + } catch (TemplateBudgetExceeded ignored) { + String structureId = target.structureId() == null + ? start.getStructure().getClass().getName() : target.structureId(); + if (WARNED_FLATTEN_BUDGET.add(structureId)) { + IrisLogging.warn("Native structure FLATTEN fitting for '" + structureId + + "' exceeded its bounded template budget; skipping this start"); + } + continue; + } + for (SurfaceAnchor source : sources) { + anchors.add(new FlattenAnchor(source, radius, range)); + } + for (long column : projectedColumns) { + projectedSupport.merge(column, range, Math::max); + } + } + return new FlattenFootprint(List.copyOf(anchors), Map.copyOf(projectedSupport)); + } + + private static void addProjectedFlattenSupport( + WorldGenLevel world, BoundingBox area, PoolElementStructurePiece piece, + BlockPos reference, TemplateCellBudget budget, Set columns) { + NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve( + world, piece, reference, area, () -> world.getLevel().getStructureManager(), + area::isInside, budget::consume); + for (Map.Entry entry : occupancy.cells().entrySet()) { + NativeStructureTemplateOccupancy.OccupancyCell cell = entry.getValue(); + if (cell.blocker() || !NativeStructureTemplateOccupancy.isSolidBase(cell.state())) { + continue; + } + BlockPos position = BlockPos.of(entry.getKey()); + columns.add(NativeStructureTemplateOccupancy.columnKey(position.getX(), position.getZ())); + } + } + + private static boolean flattenPieceAllowed(StructureStart start, StructurePiece piece) { + if (piece instanceof RuinedPortalPiece portal) { + RuinedPortalPiece.VerticalPlacement placement = NativeStructureReflection.ruinedPortalVerticalPlacement(portal); + return placement == RuinedPortalPiece.VerticalPlacement.ON_LAND_SURFACE + || placement == RuinedPortalPiece.VerticalPlacement.IN_NETHER; + } + if (piece instanceof IglooPieces.IglooPiece) { + int highest = Integer.MIN_VALUE; + for (StructurePiece candidate : start.getPieces()) { + if (candidate instanceof IglooPieces.IglooPiece) { + highest = Math.max(highest, candidate.getBoundingBox().minY()); + } + } + return piece.getBoundingBox().minY() == highest; + } + return true; + } + + static boolean hasExposedFlattenPiece( + StructureStart start, IntBinaryOperator surfaceHeight, int range) { + for (StructurePiece piece : start.getPieces()) { + if (!flattenPieceAllowed(start, piece)) { + continue; + } + BoundingBox bounds = piece.getBoundingBox(); + int groundY = flattenGroundY(start, piece, surfaceHeight); + int roofY = piece instanceof IglooPieces.IglooPiece || piece instanceof SwampHutPiece + ? groundY + bounds.getYSpan() : bounds.maxY(); + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + int surfaceY = surfaceHeight.applyAsInt(x, z); + if (surfaceY <= roofY && (long) groundY - surfaceY <= range) { + return true; + } + } + } + } + return false; + } + + private static int flattenGroundY( + StructureStart start, StructurePiece piece, IntBinaryOperator surfaceHeight) { + BoundingBox bounds = piece.getBoundingBox(); + if (piece instanceof PoolElementStructurePiece poolPiece) { + return bounds.minY() + poolPiece.getGroundLevelDelta() - 1; + } + if (piece instanceof IglooPieces.IglooPiece igloo) { + BlockPos entrance = igloo.templatePosition().offset( + StructureTemplate.calculateRelativePosition(igloo.placeSettings(), new BlockPos(3, 0, 0))); + return surfaceHeight.applyAsInt(entrance.getX(), entrance.getZ()); + } + if (piece instanceof SwampHutPiece || start.getStructure() instanceof ShipwreckStructure) { + long sum = 0; + int minimum = Integer.MAX_VALUE; + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + int surfaceY = surfaceHeight.applyAsInt(x, z); + sum += surfaceY; + minimum = Math.min(minimum, surfaceY); + } + } + return piece instanceof SwampHutPiece + ? (int) Math.floorDiv(sum, (long) bounds.getXSpan() * bounds.getZSpan()) : minimum; + } + if (piece instanceof WoodlandMansionPieces.WoodlandMansionPiece) { + return start.getBoundingBox().minY() - 1; + } + return bounds.minY() - 1; + } + + static FlattenResolution resolveFlattenSurface( + List anchors, int x, int z, int originalY) { + FlattenAnchor selected = null; + long weightedY = 0; + long totalWeight = 0; + long maximumInfluence = 0; + int range = 0; + for (FlattenAnchor anchor : anchors) { + SurfaceAnchor source = anchor.source(); + int outX = IrisObjectVacuum.outset(x, source.minX(), source.maxX()); + int outZ = IrisObjectVacuum.outset(z, source.minZ(), source.maxZ()); + if (outX == 0 && outZ == 0) { + if (selected == null || precedes(source, selected.source())) { + selected = anchor; + } + continue; + } + double distance = Math.sqrt((double) outX * outX + (double) outZ * outZ); + if (anchor.radius() == 0 || distance >= anchor.radius()) { + continue; + } + double progress = distance / anchor.radius(); + double factor = 1D - progress * progress * (3D - 2D * progress); + long influence = Math.round(factor * SURFACE_TERRAIN_INFLUENCE_SCALE); + long weight = influence * source.strength(); + int targetY = originalY + Math.max(-anchor.range(), Math.min(anchor.range(), source.meetY() - originalY)); + weightedY += weight * targetY; + totalWeight += weight; + maximumInfluence = Math.max(maximumInfluence, influence); + range = Math.max(range, anchor.range()); + } + if (selected != null) { + int targetY = originalY + Math.max(-selected.range(), + Math.min(selected.range(), selected.source().meetY() - originalY)); + return new FlattenResolution(targetY, selected.range(), selected.source().strength() > 1); + } + if (totalWeight == 0) { + return new FlattenResolution(originalY, 0, false); + } + int targetY = blendSurfaceTarget(originalY, weightedY / (double) totalWeight, + maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE); + return new FlattenResolution(targetY, range, false); + } + + private static Map fitFlattenTerrain( + WorldGenLevel world, BoundingBox area, FlattenFootprint footprint, + IntBinaryOperator surfaceHeight) { + Map heights = new HashMap<>(); + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (int x = area.minX(); x <= area.maxX(); x++) { + for (int z = area.minZ(); z <= area.maxZ(); z++) { + int originalY = Math.max(area.minY(), Math.min(area.maxY(), surfaceHeight.applyAsInt(x, z))); + long column = NativeStructureTemplateOccupancy.columnKey(x, z); + FlattenResolution resolution = resolveFlattenSurface(footprint.anchors(), x, z, originalY); + int projectedRange = footprint.projectedSupport().getOrDefault(column, 0); + int range = Math.max(resolution.range(), projectedRange); + if (range == 0 || originalY < area.maxY() + && !world.getBlockState(position.set(x, originalY + 1, z)).getFluidState().isEmpty()) { + continue; + } + int targetY = Math.max(area.minY(), Math.min(area.maxY(), resolution.targetY())); + int minimumY = Math.max(area.minY(), targetY - range); + applySurfaceColumn(world, position, x, z, originalY, targetY, + minimumY, Math.min(area.maxY(), originalY + range)); + if (resolution.foundation() || projectedRange > 0) { + fillFlattenFoundation(world, position, x, z, targetY, minimumY); + } + heights.put(column, targetY + 1); + } + } + return Map.copyOf(heights); + } + + private static void fillFlattenFoundation( + WorldGenLevel world, BlockPos.MutableBlockPos position, + int x, int z, int targetY, int minimumY) { + SurfaceMaterials materials = resolveSurfaceMaterials(world, position, x, z, targetY, minimumY); + if (!isTerrainBlock(world.getBlockState(position.set(x, targetY, z)))) { + world.setBlock(position, materials.surface(), 2); + } + for (int y = targetY - 1; y >= minimumY; y--) { + BlockState existing = world.getBlockState(position.set(x, y, z)); + if (!existing.getFluidState().isEmpty() + || NativeStructureVegetationClearer.isTreeBlock(existing)) { + break; + } + if (!isTerrainBlock(existing)) { + world.setBlock(position, materials.subsurface(), 2); + } + } + } + private static void fitVacuumTerrain( WorldGenLevel world, BoundingBox area, VacuumFootprint footprint, IntBinaryOperator surfaceHeight) { @@ -821,6 +1101,15 @@ public final class NativeStructureSurfaceFitter { record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) { } + record FlattenAnchor(SurfaceAnchor source, int radius, int range) { + } + + record FlattenResolution(int targetY, int range, boolean foundation) { + } + + private record FlattenFootprint(List anchors, Map projectedSupport) { + } + private record SurfaceMaterials(BlockState surface, BlockState subsurface) { } @@ -835,27 +1124,25 @@ public final class NativeStructureSurfaceFitter { private record VacuumAnchor(int surfaceY, int strength) { } - public static final class VacuumFoundationPlan { - private static final VacuumFoundationPlan EMPTY = - new VacuumFoundationPlan(List.of(), Set.of()); + public record SurfaceTerrainPlan( + List foundationBases, Set occupiedCells, Map flattenedHeights) { + private static final SurfaceTerrainPlan EMPTY = + new SurfaceTerrainPlan(List.of(), Set.of(), Map.of()); - private final List foundationBases; - private final Set occupiedCells; - - private VacuumFoundationPlan( - List foundationBases, Set occupiedCells) { - this.foundationBases = foundationBases; - this.occupiedCells = occupiedCells; + public SurfaceTerrainPlan { + foundationBases = List.copyOf(foundationBases); + occupiedCells = Set.copyOf(occupiedCells); + flattenedHeights = Map.copyOf(flattenedHeights); } - private static VacuumFoundationPlan empty() { + private static SurfaceTerrainPlan empty() { return EMPTY; } - private static VacuumFoundationPlan create( - Set packedBases, Set packedOccupancy, - BoundingBox area) { - if (packedBases.isEmpty()) { + private static SurfaceTerrainPlan create( + VacuumFootprint vacuum, BoundingBox area, Map flattenedHeights) { + Set packedBases = vacuum.foundationBases(); + if (packedBases.isEmpty() && flattenedHeights.isEmpty()) { return EMPTY; } List bases = new ArrayList<>(packedBases.size()); @@ -869,7 +1156,7 @@ public final class NativeStructureSurfaceFitter { columns.add(NativeStructureTemplateOccupancy.columnKey( base.getX(), base.getZ())); } - if (bases.isEmpty()) { + if (bases.isEmpty() && flattenedHeights.isEmpty()) { return EMPTY; } bases.sort((first, second) -> { @@ -878,15 +1165,28 @@ public final class NativeStructureSurfaceFitter { return yOrder == 0 ? Long.compare(first, second) : yOrder; }); Set occupancy = new HashSet<>(); - for (long packedCell : packedOccupancy) { + for (long packedCell : vacuum.occupiedCells()) { BlockPos cell = BlockPos.of(packedCell); if (columns.contains(NativeStructureTemplateOccupancy.columnKey( cell.getX(), cell.getZ()))) { occupancy.add(packedCell); } } - return new VacuumFoundationPlan( - List.copyOf(bases), Set.copyOf(occupancy)); + return new SurfaceTerrainPlan( + bases, occupancy, flattenedHeights); + } + + public void primeHeightmaps(ChunkAccess chunk) { + if (flattenedHeights.isEmpty()) { + return; + } + Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); + Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); + WorldgenTerrainHeightmaps.primeTerrain(chunk, + (x, z) -> flattenedHeights.getOrDefault(NativeStructureTemplateOccupancy.columnKey(x, z), + surface.getFirstAvailable(x & 15, z & 15)), + (x, z) -> flattenedHeights.getOrDefault(NativeStructureTemplateOccupancy.columnKey(x, z), + floor.getFirstAvailable(x & 15, z & 15))); } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceSupportBuilder.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceSupportBuilder.java index 27e929c11..10dd1ca98 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceSupportBuilder.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceSupportBuilder.java @@ -1,8 +1,10 @@ package art.arcane.iris.nativegen; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; import net.minecraft.core.BlockPos; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.StructurePiece; @@ -44,6 +46,10 @@ final class NativeStructureSurfaceSupportBuilder { for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { if (target == null || !NativeStructureSurfaceFitter.requiresSurfaceTerrain(target) + || target.terrain().resolvedMode() == IrisStructureTerrainMode.FLATTEN + && !NativeStructureSurfaceFitter.hasExposedFlattenPiece( + target.start(), (x, z) -> world.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - 1, + target.terrain().resolvedFlattenRange()) || !seenStarts.add(target.start())) { continue; } 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 47eeaddb3..fca513b1b 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 @@ -118,7 +118,7 @@ public final class NativeStructureTerrainIntegrator { if (mode == IrisStructureTerrainMode.PRESERVE) { return; } - if (mode == IrisStructureTerrainMode.VACUUM) { + if (mode == IrisStructureTerrainMode.VACUUM || mode == IrisStructureTerrainMode.FLATTEN) { return; } if (mode == IrisStructureTerrainMode.ENCASE) { diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeTerrainHeightCache.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeTerrainHeightCache.java new file mode 100644 index 000000000..01a9e6e4b --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeTerrainHeightCache.java @@ -0,0 +1,42 @@ +package art.arcane.iris.nativegen; + +import it.unimi.dsi.fastutil.objects.Object2IntLinkedOpenHashMap; +import net.minecraft.world.level.levelgen.Heightmap; + +import java.util.OptionalInt; +import java.util.function.Supplier; + +public final class NativeTerrainHeightCache { + private static final int MAXIMUM_CACHED_QUERIES = 65_536; + + private final Object2IntLinkedOpenHashMap heights = new Object2IntLinkedOpenHashMap<>(); + + public OptionalInt resolvedHeight(Query query, Supplier resolver) { + synchronized (heights) { + if (heights.containsKey(query)) { + return OptionalInt.of(heights.getAndMoveToFirst(query)); + } + } + OptionalInt resolved = resolver.get(); + if (resolved.isEmpty()) { + return resolved; + } + int height = resolved.getAsInt(); + synchronized (heights) { + heights.putAndMoveToFirst(query, height); + while (heights.size() > MAXIMUM_CACHED_QUERIES) { + heights.removeLastInt(); + } + } + return resolved; + } + + public void evictRuntime(int runtimeId) { + synchronized (heights) { + heights.keySet().removeIf(query -> query.runtimeId() == runtimeId); + } + } + + public record Query(int runtimeId, int x, int z, Heightmap.Types type, int minimumY, int height) { + } +} 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 c0900874b..5358fcd60 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 @@ -19,6 +19,7 @@ package art.arcane.iris.modded; import art.arcane.iris.nativegen.NativeTransitionColumn; +import art.arcane.iris.nativegen.NativeTerrainHeightCache; import art.arcane.iris.engine.history.TerrainBoundarySignature; import art.arcane.iris.engine.history.NativeBiomeSpawnSelection; import java.util.Optional; @@ -107,6 +108,7 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.OptionalInt; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.CancellationException; @@ -145,6 +147,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private final ModdedNativeStructureStage nativeStructures = new ModdedNativeStructureStage(this); private final ModdedSpawnTableMerger spawnTables = new ModdedSpawnTableMerger(this); private final ModdedImportedFeatureStage importedFeatures; + private final NativeTerrainHeightCache terrainHeights = new NativeTerrainHeightCache(); private final AtomicBoolean announced = new AtomicBoolean(false); private final IntConsumer generationRuntimeRetirementListener = this::retireGenerationRuntimeCaches; private volatile boolean unloading; @@ -1381,15 +1384,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor, RandomState randomState) { Engine current = requireDataQueryEngine("base height"); - boolean ignoreFluid = !type.isOpaque().test(Blocks.WATER.defaultBlockState()); try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope( current, x, z, "modded_base_height"); GenerationSessionLease lease = current.acquireGenerationLease("modded_base_height"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { - Optional resolved = current.getComplex().resolvedTerrainColumn(x, z); + NativeTerrainHeightCache.Query query = new NativeTerrainHeightCache.Query( + current.getCacheID(), x, z, type, heightAccessor.getMinY(), heightAccessor.getHeight()); + OptionalInt resolved = terrainHeights.resolvedHeight(query, + () -> resolvedBaseHeight(current, x, z, type, heightAccessor)); if (resolved.isPresent()) { - return NativeTransitionColumn.height(resolved.get(), type, heightAccessor); + return resolved.getAsInt(); } + boolean ignoreFluid = !type.isOpaque().test(Blocks.WATER.defaultBlockState()); int height = current.getDimensionStackContext() == null ? current.getHeight(x, z, ignoreFluid) : Engine.hostHeight(current, x, z, ignoreFluid); @@ -1399,6 +1405,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + private OptionalInt resolvedBaseHeight(Engine current, int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor) { + Optional resolved = current.getComplex().resolvedTerrainColumn(x, z); + return resolved.isPresent() + ? OptionalInt.of(NativeTransitionColumn.height(resolved.get(), type, heightAccessor)) + : OptionalInt.empty(); + } + @Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState randomState) { int minY = heightAccessor.getMinY(); @@ -1482,6 +1495,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private void retireGenerationRuntimeCaches(int runtimeIdentity) { + terrainHeights.evictRuntime(runtimeIdentity); structureBiomeSource.evictRuntime(runtimeIdentity); importedFeatures.evictRuntime(runtimeIdentity); spawnTables.evictRuntime(runtimeIdentity); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java index 29a0403e0..60160389e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java @@ -46,6 +46,7 @@ import net.minecraft.server.level.ServerPlayer; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundSource; +import net.minecraft.world.Difficulty; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntitySpawnReason; import net.minecraft.world.entity.EntityType; @@ -120,13 +121,20 @@ public final class ModdedEntitySpawner { return null; } - Entity entity = type.spawn(level, BlockPos.containing(x, y, z), reasonFor(irisEntity.getReason())); + Entity entity = spawnNative(type, level, BlockPos.containing(x, y, z), reasonFor(irisEntity.getReason())); if (entity != null) { entity.snapTo(x, y, z, 0F, 0F); } return entity; } + static Entity spawnNative(EntityType type, ServerLevel level, BlockPos position, EntitySpawnReason reason) { + if (level.getDifficulty() == Difficulty.PEACEFUL && !type.isAllowedInPeaceful()) { + return null; + } + return type.spawn(level, position, reason); + } + private static void applyConfig(Engine engine, IrisEntity irisEntity, Entity entity, ServerLevel level, int blockX, int blockY, int blockZ, RNG rng, boolean riseEffectActive) { String customName = irisEntity.getCustomName(); if (customName != null) { 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 9814681f4..0659c02d3 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 @@ -398,10 +398,7 @@ final class ModdedNativeStructureStage { } IrisStaticObjectLayer staticObjects = current.getDimension().getStaticObjectLayer(current.getData()); Predicate protectedPosition = nativeStructureProtection(current, staticObjects); - WorldGenLevel boundedWorld = staticObjects.isEmpty() - && current.getDimensionStackContext() == null - ? world - : ModdedNativeStructureWorldgenAccess.create( + WorldGenLevel boundedWorld = ModdedNativeStructureWorldgenAccess.create( world, chunkPos, worldgenSurfaceHeight(current, world.getMinY()), worldgenFloorHeight(current, world.getMinY()), current.getDimensionStackContext() != null, protectedPosition); @@ -413,11 +410,12 @@ final class ModdedNativeStructureStage { "vegetation cleanup", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } - NativeStructureSurfaceFitter.VacuumFoundationPlan vacuumFoundationPlan; + NativeStructureSurfaceFitter.SurfaceTerrainPlan surfaceTerrainPlan; try { - vacuumFoundationPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( + surfaceTerrainPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( boundedWorld, area, terrainTargets, (x, z) -> Engine.hostHeight(current, x, z, true) + current.getMinHeight()); + surfaceTerrainPlan.primeHeightmaps(chunk); } catch (Throwable error) { throw NativeStructureGenerationException.failure( "terrain integration", nativeStructureBatchContext(placementGroups), @@ -445,7 +443,7 @@ final class ModdedNativeStructureStage { } try { NativeStructureSurfaceFitter.repairVacuumFoundations( - boundedWorld, area, vacuumFoundationPlan); + boundedWorld, area, surfaceTerrainPlan); } catch (Throwable error) { throw NativeStructureGenerationException.failure( "foundation repair", nativeStructureBatchContext(placementGroups), diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index cdb1b3e1c..e546898b6 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -210,7 +210,7 @@ public final class ModdedPlatform implements IrisPlatform { } EntityType type = (EntityType) resolved.nativeHandle(); BlockPos pos = BlockPos.containing(x, y, z); - Entity entity = type.spawn(level, pos, EntitySpawnReason.COMMAND); + Entity entity = ModdedEntitySpawner.spawnNative(type, level, pos, EntitySpawnReason.COMMAND); return entity != null; } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnDifficultyTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnDifficultyTest.java new file mode 100644 index 000000000..e6cd331cd --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnDifficultyTest.java @@ -0,0 +1,132 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisEntity; +import art.arcane.volmlib.util.math.RNG; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.Difficulty; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.level.chunk.LevelChunk; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedEntitySpawnDifficultyTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void sharedPlatformSpawnReturnsFalseForPeacefulHostiles() { + ServerLevel level = mock(ServerLevel.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + ModdedPlatform platform = new ModdedPlatform(mock(ModdedLoader.class)); + + assertFalse(platform.spawnEntity(new ModdedPlatformWorld(level), "minecraft:zombie", 0.5, 80, 0.5)); + + verify(level, never()).addFreshEntity(any(Entity.class)); + verify(level, never()).enabledFeatures(); + } + + @Test + public void ambientSpawnReturnsNullForPeacefulHostilesBeforeApplyingConfiguration() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunks = mock(ServerChunkCache.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + when(level.getChunkSource()).thenReturn(chunks); + when(chunks.getChunkNow(anyInt(), anyInt())).thenReturn(mock(LevelChunk.class)); + IrisEntity configured = new IrisEntity().setType("zombie").setSpawnEffectRiseOutOfGround(false); + + assertNull(ModdedEntitySpawner.spawn(mock(Engine.class), configured, level, 0, 80, 0, new RNG(1L))); + + verify(level, never()).addFreshEntity(any(Entity.class)); + verify(level, never()).enabledFeatures(); + } + + @Test + public void peacefulRejectsEveryNativeForbiddenTypeBeforeCreation() { + ServerLevel level = mock(ServerLevel.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + int rejected = 0; + + for (EntityType registered : BuiltInRegistries.ENTITY_TYPE) { + if (registered.isAllowedInPeaceful()) { + continue; + } + EntityType type = spy(registered); + assertNull(ModdedEntitySpawner.spawnNative(type, level, BlockPos.ZERO, EntitySpawnReason.NATURAL)); + verify(type, never()).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL); + rejected++; + } + + assertTrue(rejected > 20); + } + + @Test + public void peacefulPreservesPassiveAndNativeMonsterCategoryExceptions() { + ServerLevel level = mock(ServerLevel.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + Entity created = mock(Entity.class); + + for (String key : List.of("cow", "villager", "wolf", "piglin", "shulker")) { + EntityType type = spy(BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse("minecraft:" + key))); + doReturn(created).when(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL); + + assertSame(key, created, ModdedEntitySpawner.spawnNative(type, level, BlockPos.ZERO, EntitySpawnReason.NATURAL)); + verify(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL); + } + } + + @Test + public void nonPeacefulDifficultiesPreserveHostileSpawns() { + ServerLevel level = mock(ServerLevel.class); + Entity created = mock(Entity.class); + + for (Difficulty difficulty : List.of(Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD)) { + EntityType type = spy(BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse("minecraft:zombie"))); + when(level.getDifficulty()).thenReturn(difficulty); + doReturn(created).when(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL); + + assertSame(created, ModdedEntitySpawner.spawnNative(type, level, BlockPos.ZERO, EntitySpawnReason.NATURAL)); + verify(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL); + } + } + + @Test + public void unrelatedNativeSpawnFailuresStillPropagate() { + ServerLevel level = mock(ServerLevel.class); + when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL); + EntityType type = spy(BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse("minecraft:cow"))); + IllegalStateException failure = new IllegalStateException("Spawn rejected by platform"); + doThrow(failure).when(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL); + + assertSame(failure, assertThrows(IllegalStateException.class, + () -> ModdedEntitySpawner.spawnNative(type, level, BlockPos.ZERO, EntitySpawnReason.NATURAL))); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java index c4807bca2..1544cb40e 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java @@ -1,9 +1,14 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; +import art.arcane.iris.nativegen.NativeStructurePostProcessor; +import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator; import net.minecraft.SharedConstants; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; import net.minecraft.server.Bootstrap; import net.minecraft.world.entity.Entity; import net.minecraft.world.level.ChunkPos; @@ -12,10 +17,25 @@ import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeManager; 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.status.ChunkStatus; import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.Heightmap; +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.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.DesertPyramidStructure; +import net.minecraft.world.level.levelgen.structure.templatesystem.GravityProcessor; +import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; import net.minecraft.world.level.material.Fluid; import net.minecraft.world.level.material.FluidState; import net.minecraft.world.phys.AABB; @@ -30,11 +50,13 @@ import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.ArrayList; 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.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class ModdedNativeStructureWorldgenAccessTest { @@ -49,6 +71,69 @@ public class ModdedNativeStructureWorldgenAccessTest { Bootstrap.bootStrap(); } + @Test + public void ordinaryWorldFlattenPreparationHandlesReferencedVillagePiecesBeyondEightChunks() { + int farX = (GENERATION_CENTER_X - 9) << 4; + int farZ = (GENERATION_CENTER_Z + 4) << 4; + BoundingBox bounds = new BoundingBox(farX, 64, farZ, farX + 4, 90, farZ + 4); + PoolElementStructurePiece piece = new PoolElementStructurePiece( + null, StructurePoolElement.single("minecraft:empty").apply(StructureTemplatePool.Projection.RIGID), + new BlockPos(farX, 64, farZ), 1, Rotation.NONE, bounds, LiquidSettings.APPLY_WATERLOGGING); + Structure structure = new DesertPyramidStructure(new Structure.StructureSettings( + HolderSet.empty(), Map.of(), GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.BEARD_BOX)); + StructureStart start = new StructureStart(structure, generationCenter(), 0, new PiecesContainer(List.of(piece))); + List targets = List.of( + new NativeStructureTerrainIntegrator.TerrainTarget("minecraft:village_plains", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.FLATTEN))); + BoundingBox area = new BoundingBox(generationCenter().getMinBlockX(), -64, generationCenter().getMinBlockZ(), + generationCenter().getMaxBlockX(), 319, generationCenter().getMaxBlockZ()); + RecordingDelegate unbounded = new RecordingDelegate(); + unbounded.rejectDistantTerrainReads = true; + + assertThrows(IllegalStateException.class, () -> NativeStructurePostProcessor.prepareTerrain( + unbounded.world(), area, targets, (palette, rng, x, y, z) -> Blocks.STONE.defaultBlockState())); + + RecordingDelegate recording = new RecordingDelegate(); + recording.rejectDistantTerrainReads = true; + ModdedNativeStructureWorldgenAccess access = access(recording); + NativeStructurePostProcessor.prepareTerrain( + access, area, targets, (palette, rng, x, y, z) -> Blocks.STONE.defaultBlockState()); + + assertEquals(0, recording.heightReads); + assertEquals(0, recording.chunkReads); + assertEquals(0, recording.terrainReads); + assertEquals(0, recording.mutations); + assertEquals(91, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG, + generationCenter().getMinBlockX(), generationCenter().getMinBlockZ())); + assertEquals(1, recording.heightReads); + } + + @Test + public void projectedTemplatePrefiltersUseRemoteTerrainAndRetainLocalHeightmaps() { + RecordingDelegate recording = new RecordingDelegate(); + recording.rejectDistantTerrainReads = true; + ModdedNativeStructureWorldgenAccess access = access(recording); + GravityProcessor gravity = new GravityProcessor(Heightmap.Types.WORLD_SURFACE_WG, -1); + BlockPos remote = new BlockPos((GENERATION_CENTER_X - 9) << 4, 64, (GENERATION_CENTER_Z + 4) << 4); + StructureTemplate.StructureBlockInfo raw = new StructureTemplate.StructureBlockInfo( + new BlockPos(0, 4, 0), Blocks.DIRT_PATH.defaultBlockState(), null); + StructurePlaceSettings settings = new StructurePlaceSettings().addProcessor(gravity); + + StructureTemplate.StructureBlockInfo projected = StructureTemplate.processBlockInfos( + access, remote, BlockPos.ZERO, settings, List.of(raw)).getFirst(); + + assertEquals(remote.atY(SURFACE_FIRST_FREE_Y + 3), projected.pos()); + assertEquals(0, recording.heightReads); + BlockPos local = generationCenter().getMiddleBlockPosition(64); + StructureTemplate.StructureBlockInfo localBlock = new StructureTemplate.StructureBlockInfo( + BlockPos.ZERO, Blocks.DIRT_PATH.defaultBlockState(), null); + StructureTemplate.StructureBlockInfo localProjection = StructureTemplate.processBlockInfos( + access, local, BlockPos.ZERO, settings, List.of(localBlock)).getFirst(); + assertEquals(local.atY(90), localProjection.pos()); + assertEquals(1, recording.heightReads); + assertEquals(0, recording.chunkReads); + } + @Test public void distanceTwoTerrainReadsNeverReachTheDelegate() { RecordingDelegate recording = new RecordingDelegate(); @@ -253,6 +338,7 @@ public class ModdedNativeStructureWorldgenAccessTest { private int statusChunkReads; private int mutations; private int events; + private boolean rejectDistantTerrainReads; private RecordingDelegate() { this.biome = Holder.direct((Biome) null); @@ -293,6 +379,11 @@ public class ModdedNativeStructureWorldgenAccessTest { return 384; } if (name.equals("getHeight")) { + if (rejectDistantTerrainReads + && (Math.abs(((int) arguments[1] >> 4) - GENERATION_CENTER_X) > 1 + || Math.abs(((int) arguments[2] >> 4) - GENERATION_CENTER_Z) > 1)) { + throw new IllegalStateException("Requested chunk unavailable during world generation"); + } heightReads++; return 91; } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java index fc4b8210d..60a8233ee 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java @@ -1011,6 +1011,13 @@ final class PackHydrologyValidator { validateNullableEnum(path, policy, "placement", PLACEMENT_MODES, errors); validateNullableEnum(path, policy, "routing", ROUTING_MODES, errors); validateNullableBoolean(path, policy, "outletAdmission", errors); + validateNullableDouble(path, policy, "surfaceSourceDensity", 0D, 64D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceSourceSpacing", 0, 8192, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceTributaries", 0, 4, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceInlandOutlets", 0, 256, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceCoastalOutlets", 0, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceMinimumCourseLength", 16, 4096, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceMaximumIncision", 1, 32, errors); validatePolicyProfiles(path, policy, errors); validatePolicyPools(path, policy, errors); for (String biomeField : POLICY_BIOME_FIELDS) { 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 871012bcf..cd820a12b 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 @@ -40,7 +40,7 @@ import java.util.Set; final class PackStructurePlacementValidator { private static final Set TERRAIN_ENVELOPE_MODES = Set.of( - "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"); + "BORE", "FORCE_CARVE", "VACUUM", "FLATTEN", "ENCASE"); private PackStructurePlacementValidator() { } @@ -633,11 +633,13 @@ final class PackStructurePlacementValidator { return; } PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "mode", - Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), blockingErrors); + Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "FLATTEN", "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, + "flattenRange", 0, 128, blockingErrors); PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain, "ceilingPadding", 0, 128, blockingErrors); PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain, @@ -663,7 +665,7 @@ final class PackStructurePlacementValidator { return; } String mode = terrain.optString("mode", "SOURCE"); - if ("VACUUM".equals(mode) || "ENCASE".equals(mode)) { + if ("VACUUM".equals(mode) || "FLATTEN".equals(mode) || "ENCASE".equals(mode)) { blockingErrors.add(path + ".terrain.mode " + mode + " cannot target editable Iris structures; use nativeStructures or " + "importedStructures.adjustments for native terrain preparation."); diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java index f820fd19e..b7f6cf473 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java @@ -4,6 +4,8 @@ import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.compat.CompatAction; import art.arcane.iris.core.compat.CompatFinding; import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.engine.history.GenerationKernelRegistry; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformRegistries; @@ -46,12 +48,20 @@ public final class PackValidationCache { if (!IrisPlatforms.isBound()) { return ""; } + return contextFingerprint(ImplementationIdentity.FINGERPRINT); + } + + static String contextFingerprint(String implementationFingerprint) { + if (!IrisPlatforms.isBound() || implementationFingerprint == null || implementationFingerprint.isBlank()) { + return ""; + } try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); IrisPlatform platform = IrisPlatforms.get(); update(digest, platform.platformName()); update(digest, platform.minecraftVersion()); update(digest, Integer.toString(platform.irisVersionNumber())); + update(digest, implementationFingerprint); update(digest, Boolean.toString(ContentKeyValidator.strictContent())); PlatformRegistries registries = Objects.requireNonNull( @@ -230,6 +240,17 @@ public final class PackValidationCache { } } + private static String implementationFingerprint() { + try { + GenerationKernelRegistry kernels = GenerationKernelRegistry.standard(); + return kernels.requireSupported(kernels.current()).implementationFingerprint(); + } catch (IOException | RuntimeException | LinkageError failure) { + IrisLogging.reportError( + "Cannot identify the Iris validation implementation; persisted pack validation is disabled.", failure); + return ""; + } + } + private static void update(MessageDigest digest, String value) { byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8); digest.update((byte) (bytes.length >>> 24)); @@ -246,6 +267,10 @@ public final class PackValidationCache { private List results; } + private static final class ImplementationIdentity { + private static final String FINGERPRINT = implementationFingerprint(); + } + private static final class CachedResult { private String packName; private List blockingErrors; diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java index 185ef7a95..bc5cc8437 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java @@ -77,6 +77,7 @@ public class IrisPregenerator { private final AtomicLong failed; private final IrisPackBenchmarking benchmarking; private volatile PregenRates rates; + private boolean progressClosed; public IrisPregenerator(PregenTask task, PregeneratorMethod generator, PregenListener listener) { this.jobId = JOB_SEQUENCE.incrementAndGet(); @@ -123,7 +124,10 @@ public class IrisPregenerator { return Double.isFinite(d) && d != INVALID ? (long) d : 0; } - private void publishProgress(long now, boolean logProgress) { + private synchronized void publishProgress(long now, boolean logProgress) { + if (progressClosed) { + return; + } long generatedCount = generated.get(); long total = totalChunks.get(); rates = rateTracker.sample(generatedCount, now); @@ -173,6 +177,14 @@ public class IrisPregenerator { } } + private synchronized void publishFinalProgress() { + try { + publishProgress(M.ms(), false); + } finally { + progressClosed = true; + } + } + private void sampleHistory(long now, long generatedCount, long cachedCount) { long previousTime = historyLastMillis.getAndSet(now); long previousGenerated = historyLastGenerated.getAndSet(generatedCount); @@ -297,6 +309,7 @@ public class IrisPregenerator { shutdownStep("generator", generator::close); Thread.interrupted(); shutdownStep("ticker", ticker::interrupt); + shutdownStep("progress", this::publishFinalProgress); shutdownStep("mantle", () -> { Mantle mantle = getMantle(); if (mantle != null) { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index 53218d1c7..194b9feaa 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -29,6 +29,7 @@ import art.arcane.iris.core.pregenerator.PregenMantleBackpressure; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.INMSBinding; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.volmlib.util.collection.KSet; @@ -68,6 +69,8 @@ public class AsyncPregenMethod implements PregeneratorMethod { private static final int ADAPTIVE_RECOVERY_INTERVAL = 8; private static final long CLOSE_DRAIN_WARNING_SECONDS = 60L; private static final long FLUSH_TIMEOUT_SECONDS = 120L; + private static final long MANTLE_CLEANUP_DRAIN_SECONDS = 60L; + private static final long CHUNK_FLUSH_DRAIN_SECONDS = 30L; private static final long ADMISSION_WAIT_BOUND_MS = 500L; private final World world; private final IrisRuntimeSchedulerMode runtimeSchedulerMode; @@ -83,6 +86,9 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final String chunkAccessMode; private final ChunkRequestExecutor executor; private final Executor slowRequestExecutor; + private final Executor chunkIoExecutor; + private final PregenSerialWorker chunkFlush; + private final PregenSerialWorker mantleCleanup; private final PregenAdmissionGate admission; private final int threads; private final int slowRequestWarningSeconds; @@ -164,6 +170,12 @@ public class AsyncPregenMethod implements PregeneratorMethod { this.admission = new PregenAdmissionGate(this.threads, ADMISSION_WAIT_BOUND_MS, M::ms); this.slowRequestWarningSeconds = pregen.getChunkLoadTimeoutSeconds(); this.slowRequestExecutor = CompletableFuture.delayedExecutor(this.slowRequestWarningSeconds, TimeUnit.SECONDS); + // Never the shared Iris IO pool: the mantle loads tectonic plates on that pool, and a + // Moonrise flush parks until the level's pending IO settles. Filling the pool with parked + // flushes left generation waiting on plate loads that could never run. + this.chunkFlush = new PregenSerialWorker("Iris Pregen Chunk Flush", world.getName()); + this.chunkIoExecutor = chunkFlush.executor(); + this.mantleCleanup = new PregenSerialWorker("Iris Pregen Mantle Cleanup", world.getName()); this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs(); this.urgent = false; this.regionPending = new ConcurrentHashMap<>(); @@ -383,41 +395,32 @@ public class AsyncPregenMethod implements PregeneratorMethod { unloadChunkSafely(chunk.getX(), chunk.getZ()); } } - - INMS.get().flushChunkIO(world); - }); + }).thenCompose(ignored -> flushChunkIOAsync()); } return trackEviction(eviction); } private CompletableFuture evictFoliaRegion(Queue chunks) { List> unloads = new ArrayList<>(chunks.size()); - Chunk anchor = null; for (Chunk chunk : chunks) { if (chunk == null) { continue; } - if (anchor == null) { - anchor = chunk; - } - int chunkX = chunk.getX(); int chunkZ = chunk.getZ(); unloads.add(J.runRegionFuture(world, chunkX, chunkZ, () -> unloadChunkSafely(chunkX, chunkZ))); } CompletableFuture unloaded = CompletableFuture.allOf(unloads.toArray(CompletableFuture[]::new)); - if (anchor == null) { + if (unloads.isEmpty()) { return unloaded; } + return unloaded.thenCompose(ignored -> flushChunkIOAsync()); + } - int anchorX = anchor.getX(); - int anchorZ = anchor.getZ(); - return unloaded.thenCompose(ignored -> J.runRegionFuture( - world, - anchorX, - anchorZ, - () -> INMS.get().flushChunkIO(world))); + private CompletableFuture flushChunkIOAsync() { + INMSBinding binding = INMS.get(); + return CompletableFuture.runAsync(() -> binding.flushChunkIO(world), chunkIoExecutor); } private CompletableFuture trackEviction(CompletableFuture eviction) { @@ -466,7 +469,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { ? settledEvictions : settledEvictions.thenCompose(ignored -> IrisToolbelt.isServerStopping() ? CompletableFuture.completedFuture(null) - : J.sfut(() -> INMS.get().flushChunkIO(world))); + : flushChunkIOAsync()); long started = System.nanoTime(); long timeoutNanos = TimeUnit.SECONDS.toNanos(FLUSH_TIMEOUT_SECONDS); @@ -520,7 +523,11 @@ public class AsyncPregenMethod implements PregeneratorMethod { listener.onChunkFailed(x, z); } else { listener.onChunkGenerated(x, z); - cleanupMantleChunksCoveredBy(x, z, listener); + // Paper-like chunk futures complete on the server thread; the covered-neighbour scan + // (and any plate it has to load back) belongs on the cleanup thread, not in the tick. + if (!mantleCleanup.submit(() -> cleanupMantleChunksCoveredBy(x, z, listener))) { + cleanupMantleChunksCoveredBy(x, z, listener); + } onChunkCompleted(x, z, chunk); success = true; } @@ -691,6 +698,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { + " submitted=" + submitted.get() + " completed=" + completed.get() + " failed=" + failed.get() + + " cleanupBacklog=" + mantleCleanup.pending() + " stalledForMs=" + stalledFor; } @@ -820,7 +828,9 @@ public class AsyncPregenMethod implements PregeneratorMethod { () -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot()) ); + mantleCleanup.close(MANTLE_CLEANUP_DRAIN_SECONDS, TimeUnit.SECONDS); flushAllRemainingChunks(); + chunkFlush.close(CHUNK_FLUSH_DRAIN_SECONDS, TimeUnit.SECONDS); executor.shutdown(); if (holdsWorkerBoost.compareAndSet(true, false)) { releaseWorkerThreadBoost(); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java index 54f24d0ee..9fb85b254 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenMethod.java @@ -20,6 +20,7 @@ package art.arcane.iris.core.pregenerator.methods; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.INMSBinding; import art.arcane.iris.core.pregenerator.PregenDiagnostics; import art.arcane.iris.core.pregenerator.PregenListener; import art.arcane.iris.core.pregenerator.PregeneratorMethod; @@ -42,6 +43,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -58,11 +60,15 @@ public class MedievalPregenMethod implements PregeneratorMethod { private final AtomicBoolean directAsyncDisabled; private final AtomicBoolean prefetchDisabled; private final ExecutorService prefetchPool; + private final Executor chunkIoExecutor; + private final PregenSerialWorker chunkFlush; private volatile Engine cachedEngine; private volatile boolean engineResolutionAttempted; public MedievalPregenMethod(World world) { this.world = world; + this.chunkFlush = new PregenSerialWorker("Iris Pregen Chunk Flush", world == null ? "unknown" : world.getName()); + this.chunkIoExecutor = chunkFlush.executor(); futures = new KList<>(); this.lastUse = new ConcurrentHashMap<>(); int configuredThreads = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()); @@ -145,10 +151,11 @@ public class MedievalPregenMethod implements PregeneratorMethod { lastUse.remove(i); } } - if (flushChunkIo) { - INMS.get().flushChunkIO(world); - } }); + if (flushChunkIo && world != null) { + INMSBinding binding = INMS.get(); + unload = unload.thenRunAsync(() -> binding.flushChunkIO(world), chunkIoExecutor); + } try { unload.get(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -176,6 +183,7 @@ public class MedievalPregenMethod implements PregeneratorMethod { prefetchPool.shutdownNow(); } unloadAndSaveAllChunks(true); + chunkFlush.close(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS); } finally { if (interrupted) { Thread.currentThread().interrupt(); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/PregenSerialWorker.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/PregenSerialWorker.java new file mode 100644 index 000000000..cde76f7b4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/PregenSerialWorker.java @@ -0,0 +1,114 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pregenerator.methods; + +import art.arcane.iris.spi.IrisLogging; + +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Single-thread worker owned by one pregen run. Used for mantle cleanup (Paper-like chunk futures + * complete on the server thread, so the covered-neighbour scan must leave the tick) and for native + * region flushes (a Moonrise flush parks until the level's pending IO settles; parking the shared + * Iris IO pool starved the mantle plate loads that generation was waiting on, wedging every worker). + */ +final class PregenSerialWorker { + private final ExecutorService executor; + private final AtomicInteger pending = new AtomicInteger(); + private final String role; + private final String worldName; + private final BiConsumer failureReporter; + private final Consumer warningReporter; + + PregenSerialWorker(String role, String worldName) { + this(role, worldName, IrisLogging::reportError, IrisLogging::warn); + } + + PregenSerialWorker(String role, + String worldName, + BiConsumer failureReporter, + Consumer warningReporter) { + this.role = Objects.requireNonNull(role, "role"); + this.worldName = Objects.requireNonNull(worldName, "world name"); + this.failureReporter = Objects.requireNonNull(failureReporter, "failure reporter"); + this.warningReporter = Objects.requireNonNull(warningReporter, "warning reporter"); + this.executor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, role + " " + worldName); + thread.setDaemon(true); + return thread; + }); + } + + /** The serial executor itself, for callers that need a completion future per task. */ + Executor executor() { + return executor; + } + + boolean submit(Runnable cleanup) { + Objects.requireNonNull(cleanup, "cleanup"); + pending.incrementAndGet(); + try { + executor.execute(() -> { + try { + cleanup.run(); + } catch (Throwable failure) { + failureReporter.accept(role + " failed in world " + worldName + ".", failure); + } finally { + pending.decrementAndGet(); + } + }); + return true; + } catch (RejectedExecutionException rejected) { + pending.decrementAndGet(); + return false; + } + } + + int pending() { + return pending.get(); + } + + boolean close(long timeout, TimeUnit unit) { + executor.shutdown(); + boolean drained = false; + boolean interrupted = false; + try { + drained = executor.awaitTermination(timeout, unit); + } catch (InterruptedException interruption) { + interrupted = true; + } + if (!drained) { + int abandoned = executor.shutdownNow().size(); + warningReporter.accept(role + " for " + worldName + " did not drain in " + unit.toSeconds(timeout) + + "s; " + abandoned + " queued task(s) were abandoned."); + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + return drained; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java index d741f4f5e..0ce5c9f50 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java +++ b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java @@ -28,11 +28,14 @@ import art.arcane.iris.core.runtime.ObjectStudioLayout.GridCell; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator; +import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.ChunkTiles; +import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.PlacedTile; import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.iris.util.common.scheduling.J; import io.papermc.lib.PaperLib; import org.bukkit.Bukkit; +import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; @@ -42,10 +45,12 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.block.Action; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.world.WorldUnloadEvent; +import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.inventory.EquipmentSlot; import java.io.ByteArrayOutputStream; import java.io.File; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -58,6 +63,7 @@ public class ObjectStudioSaveService implements IrisService { private static ObjectStudioSaveService INSTANCE; private final Map studios = new ConcurrentHashMap<>(); + private final Map> pendingTiles = new ConcurrentHashMap<>(); public static ObjectStudioSaveService get() { ObjectStudioSaveService svc = INSTANCE; @@ -74,6 +80,7 @@ public class ObjectStudioSaveService implements IrisService { @Override public void onDisable() { studios.clear(); + pendingTiles.clear(); INSTANCE = null; } @@ -114,6 +121,7 @@ public class ObjectStudioSaveService implements IrisService { public void unregister(World world) { if (world == null) return; + pendingTiles.remove(world.getUID()); ActiveStudio removed = studios.remove(world.getUID()); if (removed != null) { if (removed.packKey != null) { @@ -128,6 +136,50 @@ public class ObjectStudioSaveService implements IrisService { unregister(event.getWorld()); } + public void queueTiles(Engine engine, ChunkTiles tiles) { + World world = BukkitWorldBinding.world(engine.getTarget().getWorld()); + if (world == null) { + throw new IllegalStateException("Object Studio tile data has no target world."); + } + pendingTiles.computeIfAbsent(world.getUID(), ignored -> new ConcurrentHashMap<>()) + .put(chunkKey(tiles.chunkX(), tiles.chunkZ()), tiles); + } + + @EventHandler + public void onChunkLoad(ChunkLoadEvent event) { + Chunk chunk = event.getChunk(); + Map pending = pendingTiles.get(chunk.getWorld().getUID()); + if (pending == null) { + return; + } + long key = chunkKey(chunk.getX(), chunk.getZ()); + ChunkTiles tiles = pending.get(key); + if (tiles == null) { + return; + } + int restored = 0; + try { + for (PlacedTile tile : tiles.tiles()) { + Block block = chunk.getBlock(tile.x(), tile.y(), tile.z()); + if (!tile.data().isApplicable(block.getBlockData()) || !tile.data().toBukkitTry(block)) { + throw new IllegalStateException("Object Studio tile data could not be restored at " + + block.getX() + "," + block.getY() + "," + block.getZ() + "."); + } + restored++; + } + } catch (Throwable failure) { + IrisLogging.reportError("Failed to restore Object Studio tile data in " + chunk.getWorld().getName() + + " at chunk " + chunk.getX() + "," + chunk.getZ() + ".", failure); + } finally { + if (restored == tiles.tiles().size()) { + pending.remove(key, tiles); + } else if (restored > 0) { + pending.replace(key, tiles, new ChunkTiles(tiles.chunkX(), tiles.chunkZ(), + List.copyOf(tiles.tiles().subList(restored, tiles.tiles().size())))); + } + } + } + @EventHandler(ignoreCancelled = true) public void onPlayerInteract(PlayerInteractEvent event) { if (event.getHand() != EquipmentSlot.HAND) return; @@ -158,6 +210,10 @@ public class ObjectStudioSaveService implements IrisService { }); } + private static long chunkKey(int x, int z) { + return ((long) x << 32) | (z & 0xffffffffL); + } + private static GridCell findCellNear(ActiveStudio studio, int x, int z) { GridCell inside = studio.layout.findAt(x, z); if (inside != null) return inside; @@ -218,6 +274,17 @@ public class ObjectStudioSaveService implements IrisService { if (!allChunksLoaded(world, cell)) { return; } + Map pending = pendingTiles.get(world.getUID()); + if (pending != null) { + for (int x = cell.chunkMinX(); x <= cell.chunkMaxX(); x++) { + for (int z = cell.chunkMinZ(); z <= cell.chunkMaxZ(); z++) { + if (pending.containsKey(chunkKey(x, z))) { + throw new IllegalStateException("Object Studio tile data is not ready for " + + cell.pack() + "/" + cell.key() + "."); + } + } + } + } IrisObject snapshot = studio.generator.createCapture(cell); int originX = cell.originX(); @@ -271,6 +338,7 @@ public class ObjectStudioSaveService implements IrisService { parent.mkdirs(); } snapshot.write(targetFile); + IrisData.invalidateLoadedAuthoringResources(studio.objectsDirs.get(cell.pack()).getParentFile()); IrisLogging.debug("Object Studio saved: %s/%s (%dx%dx%d)", cell.pack(), cell.key(), cell.w(), cell.h(), cell.d()); if (notify != null) { diff --git a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java index 6bdaf5b9c..00caacb9c 100644 --- a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java @@ -117,6 +117,13 @@ public class TreeSVC implements IrisService { return; } + if (!EngineLifecycleTasks.run(engine, "bukkit_tree_grow", + () -> growTree(event, worldAccess, engine))) { + event.setCancelled(true); + } + } + + private void growTree(StructureGrowEvent event, PlatformChunkGenerator worldAccess, Engine engine) { IrisDimension dimension = engine.getDimension(); if (dimension == null) { diff --git a/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java b/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java index 488ead5ea..97f850f7c 100644 --- a/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java +++ b/core/src/main/java/art/arcane/iris/engine/DimensionTerrainContext.java @@ -22,8 +22,6 @@ import art.arcane.iris.util.project.stream.interpolation.Interpolated; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; -import java.io.File; -import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.EnumMap; import java.util.HashMap; @@ -31,7 +29,6 @@ import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; -import java.util.UUID; public final class DimensionTerrainContext implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); @@ -171,7 +168,7 @@ public final class DimensionTerrainContext implements DataProvider { : configuredFocusBiome.withInferredType(InferredType.LAND); IrisRegion focusRegion = focusBiome == null ? loadFocusRegion(dimension, dimensionData) - : findFocusRegion(focusBiome, dimension, dataProvider, dimensionData); + : dimension.resolveFocusRegion(focusBiome, dataProvider); Map> generators = new HashMap<>(); Set allBiomes = Collections.newSetFromMap(new IdentityHashMap<>()); @@ -593,32 +590,6 @@ public final class DimensionTerrainContext implements DataProvider { return region == null || region.isCompatExcluded() ? null : region; } - private static IrisRegion findFocusRegion( - IrisBiome focusBiome, - IrisDimension dimension, - DataProvider dataProvider, - IrisData data - ) { - for (IrisRegion region : dimension.getAllRegions(dataProvider)) { - if (region.getAllBiomeIds().contains(focusBiome.getLoadKey())) { - return region; - } - } - String key = UUID.nameUUIDFromBytes((dimension.getLoadKey() + "\u0000" + focusBiome.getLoadKey()) - .getBytes(StandardCharsets.UTF_8)).toString(); - IrisRegion region = new IrisRegion(); - region.getLandBiomes().add(focusBiome.getLoadKey()); - region.getSeaBiomes().add(focusBiome.getLoadKey()); - region.getShoreBiomes().add(focusBiome.getLoadKey()); - region.setLoadKey(key); - region.setLoader(data); - region.setLoadFile(new File( - data.getDataFolder(), - data.getRegionLoader().getFolderName() + "/" + key + ".json" - )); - return region; - } - private static IrisBiome implode( IrisBiome biome, double x, diff --git a/core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java b/core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java index 951d0354f..5536d8a65 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineBackgroundTasks.java @@ -19,7 +19,10 @@ package art.arcane.iris.engine; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionManager; import art.arcane.iris.util.common.scheduling.J; +import art.arcane.iris.util.project.context.IrisContext; import java.util.ArrayList; import java.util.Collections; @@ -31,7 +34,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static art.arcane.iris.engine.EngineShutdownSequence.appendFailure; import static art.arcane.iris.engine.EngineShutdownSequence.propagate; @@ -47,19 +50,41 @@ final class EngineBackgroundTasks { private final Object backgroundTaskLock = new Object(); private final List backgroundTasks = new ArrayList<>(); private boolean backgroundTaskAdmission; + private int activeDrains; boolean scheduleTrackedTask(Runnable task) { + return scheduleTrackedTask(task, false); + } + + void scheduleAdmittedTask(Engine engine, Runnable task) { + IrisContext context = IrisContext.get(); + GenerationSessionManager sessions = engine.getGenerationSessions(); + if (context == null || context.getEngine() != engine || context.getGenerationSessionId() == 0L + || context.getGenerationSessionId() != engine.getGenerationSessionId() + || sessions == null || sessions.activeLeases() == 0) { + throw new IllegalStateException("Iris background continuation requires an active generation lease."); + } + scheduleTrackedTask(task, true); + } + + private boolean scheduleTrackedTask(Runnable task, boolean admitted) { synchronized (backgroundTaskLock) { // A finished task is not outstanding work, however it finished. Retaining failed // entries made the NEXT transition's drain re-report a long-settled failure and // blocked close() from ever marking the engine closed. - backgroundTasks.removeIf(tracked -> tracked.completion.isDone()); - if (!backgroundTaskAdmission) { + if (activeDrains == 0) { + backgroundTasks.removeIf(tracked -> tracked.completion.isDone()); + } + if (!backgroundTaskAdmission && !admitted) { return false; } TrackedBackgroundTask tracked = new TrackedBackgroundTask(); Future future = J.a(() -> { - tracked.started.set(true); + // The pool behind J.a cannot stop a callable it already dequeued, so the claim is + // decided here: a cancellation that won the claim first means this body never runs. + if (!tracked.claim.compareAndSet(TrackedBackgroundTask.QUEUED, TrackedBackgroundTask.RUNNING)) { + return null; + } try { task.run(); tracked.completion.complete(null); @@ -83,21 +108,69 @@ final class EngineBackgroundTasks { } BackgroundTaskDrain drainBackgroundTasks(String reason) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS); + Set observed = Collections.newSetFromMap(new IdentityHashMap<>()); + Set settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>()); List tasks; synchronized (backgroundTaskLock) { + activeDrains++; tasks = List.copyOf(backgroundTasks); - } - long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS); - Throwable failure = null; - // Settled-set snapshot taken ONCE at drain entry: a per-iteration isDone() sample - // would also suppress tasks that failed while the drain was blocked on an earlier - // task, letting a real in-flight failure pass the transition unreported. - Set settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>()); - for (TrackedBackgroundTask task : tasks) { - if (task.completion.isDone()) { - settledAtEntry.add(task); + for (TrackedBackgroundTask task : tasks) { + if (task.completion.isDone()) { + settledAtEntry.add(task); + } } } + Throwable failure = null; + try { + while (true) { + failure = appendFailure(failure, drainTaskBatch(tasks, settledAtEntry, deadline, reason)); + observed.addAll(tasks); + synchronized (backgroundTaskLock) { + tasks = new ArrayList<>(); + for (TrackedBackgroundTask task : backgroundTasks) { + if (!observed.contains(task)) { + tasks.add(task); + } + } + if (tasks.isEmpty()) { + return new BackgroundTaskDrain(failure, allTasksComplete()); + } + } + if (System.nanoTime() >= deadline) { + for (TrackedBackgroundTask task : tasks) { + cancelBackgroundTask(task, reason); + } + failure = appendFailure(failure, new TimeoutException( + "Timed out waiting for Iris background tasks during " + reason + ".")); + synchronized (backgroundTaskLock) { + return new BackgroundTaskDrain(failure, allTasksComplete()); + } + } + } + } finally { + synchronized (backgroundTaskLock) { + activeDrains--; + if (activeDrains == 0) { + backgroundTasks.removeIf(tracked -> tracked.completion.isDone()); + } + } + } + } + + private boolean allTasksComplete() { + for (TrackedBackgroundTask task : backgroundTasks) { + if (!task.completion.isDone()) { + return false; + } + } + return true; + } + + private Throwable drainTaskBatch(List tasks, + Set settledAtEntry, + long deadline, String reason) { + Throwable failure = null; for (TrackedBackgroundTask task : tasks) { long remaining = deadline - System.nanoTime(); if (remaining <= 0L) { @@ -123,17 +196,16 @@ final class EngineBackgroundTasks { } } } - boolean complete; - synchronized (backgroundTaskLock) { - backgroundTasks.removeIf(tracked -> tracked.completion.isDone()); - complete = backgroundTasks.isEmpty(); - } - return new BackgroundTaskDrain(failure, complete); + return failure; } private void cancelBackgroundTask(TrackedBackgroundTask task, String reason) { + boolean claimed = task.claim.compareAndSet(TrackedBackgroundTask.QUEUED, TrackedBackgroundTask.CANCELLED); Future future = task.future; - if (future != null && future.cancel(true) && !task.started.get()) { + if (future != null) { + future.cancel(true); + } + if (claimed) { task.completion.completeExceptionally( new IllegalStateException("Iris background task was cancelled before starting during " + reason + ".")); } @@ -162,7 +234,11 @@ final class EngineBackgroundTasks { } private static final class TrackedBackgroundTask { - private final AtomicBoolean started = new AtomicBoolean(); + private static final int QUEUED = 0; + private static final int RUNNING = 1; + private static final int CANCELLED = 2; + + private final AtomicInteger claim = new AtomicInteger(QUEUED); private final CompletableFuture completion = new CompletableFuture<>(); private volatile Future future; } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java index baeff05cf..f4f6fd6bb 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java @@ -82,7 +82,7 @@ final class EngineHotloader { engine.runtimeAssembly.set(assembly); EngineRuntime next; try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { - assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan); + assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan, false); assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine); assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine); BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes(); diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java index 0bc69eb32..305e52329 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java @@ -108,7 +108,7 @@ final class EngineRuntimeBuilder { } IrisLogging.debug("[IrisEngine timing] new IrisEngineMantle=" + (M.ms() - started) + "ms"); started = M.ms(); - assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan); + assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan, false); IrisLogging.debug("[IrisEngine timing] complex=" + (M.ms() - started) + "ms"); started = M.ms(); assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine); @@ -179,7 +179,7 @@ final class EngineRuntimeBuilder { IrisLogging.debug("Setup Detached Generation Runtime " + assembly.cacheId); assembly.mantle = assembly.runtimeKernel.createMantle(engine, assembly.mantleStorageDirectory); assembly.ownsMantle = true; - assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan); + assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan, true); assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine); assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine); assembly.hash32 = CompletableFuture.completedFuture(computePackHash(runtimeTarget.getData())); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 59138aee8..dcf043bf1 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.platform.studio.BiomeBuffetLayout; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.history.GenerationBlend; import art.arcane.iris.engine.history.TransitionGenerationPlan; @@ -37,6 +38,7 @@ import art.arcane.iris.engine.image.IrisImageMapRuntime; import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeGeneratorLink; import art.arcane.iris.engine.object.IrisRaritySelection; import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; @@ -74,7 +76,6 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; -import java.io.File; import java.lang.ref.WeakReference; import java.util.Arrays; import java.util.ArrayList; @@ -181,6 +182,9 @@ public class IrisComplex implements DataProvider { private ProceduralStream rockStream; private ProceduralStream fluidStream; private Map> hydrologyFluidStreams; + private final BiomeBuffetLayout biomeBuffet; + @Getter(AccessLevel.NONE) + private final Map biomeBuffetGenerators; private IrisBiome focusBiome; private IrisRegion focusRegion; private Map> generatorBounds; @@ -194,22 +198,22 @@ public class IrisComplex implements DataProvider { private final Object childSelectionPlanLock = new Object(); public IrisComplex(Engine engine) { - this(engine, false, null); + this(engine, false, null, false); } public IrisComplex(Engine engine, boolean simple) { - this(engine, simple, null); + this(engine, simple, null, false); } - public IrisComplex(Engine engine, TransitionGenerationPlan transitionGenerationPlan) { - this(engine, false, transitionGenerationPlan); + public IrisComplex(Engine engine, TransitionGenerationPlan transitionGenerationPlan, boolean detached) { + this(engine, false, transitionGenerationPlan, detached); } - IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan) { + IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan, boolean detached) { terrainEngine = engine; this.transitionGenerationPlan = transitionGenerationPlan; this.resolvedTerrain = new ResolvedTerrainProvider(engine); - int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize()); + int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize(), detached); IrisBiome emptyBiome = new IrisBiome().setInferredType(InferredType.CAVE); UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes()); this.rng = new RNG(engine.getSeedManager().getComplex()); @@ -220,18 +224,39 @@ public class IrisComplex implements DataProvider { generators = new HashMap<>(); generatorBiomes = Collections.newSetFromMap(new IdentityHashMap<>()); // A registrant the version-content gate excluded never enters a pool, focus included. - focusBiome = compatUsable(engine.getFocus()); - focusRegion = compatUsable(engine.getFocusRegion()); + biomeBuffet = engine.isStudio() && "bukkit".equals(IrisPlatforms.get().platformName()) + && engine.getDimension().getStudioMode().biomeSizeChunks() > 0 + ? new BiomeBuffetLayout(engine.getDimension(), engine) : null; + boolean buffetActive = biomeBuffet != null && !biomeBuffet.cells().isEmpty(); + focusBiome = buffetActive ? null : compatUsable(engine.getFocus()); + focusRegion = buffetActive ? null : compatUsable(engine.getFocusRegion()); Map> inferredStreams = new HashMap<>(); KList preparedRegions = new KList<>(); if (focusBiome != null) { focusBiome = focusBiome.withInferredType(InferredType.LAND); - focusRegion = findRegion(focusBiome, engine); + focusRegion = engine.getDimension().resolveFocusRegion(focusBiome, engine); } + ProceduralStream focusedBiomes = buffetActive + ? ProceduralStream.of((x, z) -> biomeBuffet.terrain(x, z).biome(), + Interpolated.of(value -> 0D, value -> biomeBuffet.cells().getFirst().biome())) + : focusBiome == null ? null : ProceduralStream.of((x, z) -> focusBiome, + Interpolated.of(value -> 0D, value -> focusBiome)); + ProceduralStream focusedRegions = buffetActive + ? ProceduralStream.of((x, z) -> biomeBuffet.terrain(x, z).region(), + Interpolated.of(value -> 0D, value -> biomeBuffet.cells().getFirst().region())) + : focusRegion == null ? null : ProceduralStream.of((x, z) -> focusRegion, + Interpolated.of(value -> 0D, value -> focusRegion)); + //@builder - if (focusRegion != null) { + if (buffetActive) { + for (BiomeBuffetLayout.Cell cell : biomeBuffet.cells()) { + prepareInferredBiomes(cell.region(), preparedRegions); + cell.region().getNaturalBiomes(this).forEach(this::registerGenerators); + registerGenerators(cell.biome()); + } + } else if (focusRegion != null) { prepareInferredBiomes(focusRegion, preparedRegions); focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators); } else { @@ -263,6 +288,7 @@ public class IrisComplex implements DataProvider { frozenGenerators[frozenIndex] = generatorGroups[frozenIndex].generators(); } generatorBounds = buildGeneratorBounds(engine); + biomeBuffetGenerators = compileBuffetGeneratorGroups(); KList overlayNoise = engine.getDimension().getOverlayNoise(); overlayStream = overlayNoise.isEmpty() ? ProceduralStream.ofDouble((x, z) -> 0.0D) @@ -283,13 +309,11 @@ public class IrisComplex implements DataProvider { regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream() .zoom(engine.getDimension().getRegionZoom()); regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE); - ProceduralStream proceduralRegionStream = focusRegion != null ? - ProceduralStream.of((x, z) -> focusRegion, - Interpolated.of(a -> 0D, a -> focusRegion)) + ProceduralStream proceduralRegionStream = focusedRegions != null ? focusedRegions : regionStyleStream .selectRarity(compatRegionPool(engine)) .cache2D("regionStream", engine, cacheSize); - regionStream = focusRegion != null ? proceduralRegionStream : proceduralRegionStream + regionStream = focusedRegions != null ? proceduralRegionStream : proceduralRegionStream .convertAware2D((region, x, z) -> { IrisRegion mapped = imageMapRuntime.sampleRegion(x, z); return mapped == null ? region : mapped; @@ -315,18 +339,16 @@ public class IrisComplex implements DataProvider { .convert((r) -> createInferredBiomeStream(r, InferredType.SHORE)) .convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.SHORE, shoreBiomeStream); - bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(), - Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) : + bridgeStream = focusedBiomes != null ? focusedBiomes.convert(IrisBiome::getInferredType) : engine.getDimension().getContinentalStyle().create(rng.nextParallelRNG(234234565), getData()) .bake().scale(1D / engine.getDimension().getContinentZoom()).bake().stream() .convert((v) -> v >= engine.getDimension().getLandChance() ? InferredType.SEA : InferredType.LAND) .cache2D("bridgeStream", engine, cacheSize); - ProceduralStream proceduralBaseBiomeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome, - Interpolated.of(a -> 0D, a -> focusBiome)) : + ProceduralStream proceduralBaseBiomeStream = focusedBiomes != null ? focusedBiomes : bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z)) .convertAware2D(this::implode) .cache2D("baseBiomeStream", engine, cacheSize); - baseBiomeStream = focusBiome != null ? proceduralBaseBiomeStream : proceduralBaseBiomeStream + baseBiomeStream = focusedBiomes != null ? proceduralBaseBiomeStream : proceduralBaseBiomeStream .convertAware2D((biome, x, z) -> { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); return mapped == null ? biome : mapped; @@ -354,8 +376,7 @@ public class IrisComplex implements DataProvider { .cache2DDouble("unblendedNaturalHeightStream", engine, cacheSize) : baseTerrainHeightStream; naturalHeightStream = unblendedNaturalHeightStream; - naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, - b -> focusBiome)) + naturalTrueBiomeStream = focusedBiomes != null ? focusedBiomes .cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream .convertAware2D((h, x, z) -> { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); @@ -364,7 +385,7 @@ public class IrisComplex implements DataProvider { : mapped; }) .cache2D("naturalTrueBiomeStream", engine, cacheSize); - unblendedNaturalTrueBiomeStream = transitionGenerationPlan == null || focusBiome != null + unblendedNaturalTrueBiomeStream = transitionGenerationPlan == null || focusedBiomes != null ? naturalTrueBiomeStream : unblendedNaturalHeightStream.convertAware2D((h, x, z) -> { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); @@ -396,8 +417,7 @@ public class IrisComplex implements DataProvider { slopeStream = placementHeightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .slope(3); naturalSlopeStream = naturalHeightStream.slope(3).cache2DDouble("naturalSlopeStream", engine, cacheSize); - trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, - b -> focusBiome)) + trueBiomeStream = focusedBiomes != null ? focusedBiomes .cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream .convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z)) .cache2D("trueBiomeStream", engine, cacheSize); @@ -441,8 +461,13 @@ public class IrisComplex implements DataProvider { //@done } - static int noiseCacheSize(Engine engine, int configuredSize) { - return engine.isStudio() ? Math.max(configuredSize, STUDIO_NOISE_CACHE_SIZE) : configuredSize; + /** + * The Studio-wide cache exists for the runtime a player edits against. Detached runtimes serve + * historical activations (transition bands, saved-biome queries) and keep the configured size, or + * every hotload would add another full set of Studio caches to the resident set. + */ + static int noiseCacheSize(Engine engine, int configuredSize, boolean detached) { + return engine.isStudio() && !detached ? Math.max(configuredSize, STUDIO_NOISE_CACHE_SIZE) : configuredSize; } void enableStudioHydrologyCache(String runtimeIdentity, Path persistentRoot) { @@ -469,11 +494,12 @@ public class IrisComplex implements DataProvider { int z, double naturalHeight ) { - InferredType inferredType = resolveNaturalInferredType(bridgeStream, focusBiome, x, z); + IrisBiome focused = focusedBiomeAt(x, z); + InferredType inferredType = resolveNaturalInferredType(bridgeStream, focused, x, z); IrisRegion region = regionStream.get(x, z); - IrisBiome biome = focusBiome == null + IrisBiome biome = focused == null ? sampleNaturalBiome(inferredType, region, naturalHeight, x, z) - : focusBiome; + : focused; return new IrisHydrologyNaturalSample( naturalHeight, inferredType == InferredType.SEA, @@ -526,9 +552,15 @@ public class IrisComplex implements DataProvider { return column.shaped() ? column.topY() : column.baseHeight(); } + private IrisBiome focusedBiomeAt(double x, double z) { + BiomeBuffetLayout.Cell cell = biomeBuffet == null ? null : biomeBuffet.terrain(x, z); + return cell == null ? focusBiome : cell.biome(); + } + private IrisBiome sampleTerrain3DBiome(int x, int z) { - if (focusBiome != null) { - return focusBiome; + IrisBiome focused = focusedBiomeAt(x, z); + if (focused != null) { + return focused; } IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); return mapped == null @@ -1111,24 +1143,6 @@ public class IrisComplex implements DataProvider { return getHeight(engine, worldX, worldZ, engine.getSeedManager().getHeight()); } - private IrisRegion findRegion(IrisBiome focus, Engine engine) { - for (IrisRegion i : engine.getDimension().getAllRegions(engine)) { - if (i.getAllBiomeIds().contains(focus.getLoadKey())) { - return i; - } - } - - String key = UUID.randomUUID().toString(); - IrisRegion region = new IrisRegion(); - region.getLandBiomes().add(focus.getLoadKey()); - region.getSeaBiomes().add(focus.getLoadKey()); - region.getShoreBiomes().add(focus.getLoadKey()); - region.setLoadKey(key); - region.setLoader(data); - region.setLoadFile(new File(data.getDataFolder(), data.getRegionLoader().getFolderName() + "/" + key + ".json")); - return region; - } - private IrisDecorator decorateFor(IrisBiome b, double x, double z, IrisDecorationPart part) { RNG rngc = new RNG(Cache.key(((int) x), ((int) z))); @@ -1250,6 +1264,9 @@ public class IrisComplex implements DataProvider { } private NoiseBounds gridSampleBounds(Engine engine, IrisInterpolator interpolator, int interpolatorIndex, IrisGenerator[] generators, double x, double z) { + if (biomeBuffet != null && !biomeBuffet.cells().isEmpty()) { + return generatorBounds.get(interpolator).get(focusedBiomeAt(x, z)).noiseBounds; + } int grid = HEIGHT_BOUNDS_GRID; GridBoundsCache cache = gridBoundsCache.get(); if (grid <= 1) { @@ -1365,6 +1382,14 @@ public class IrisComplex implements DataProvider { private double getInterpolatedHeight(Engine engine, double x, double z, long seed) { double h = 0; + if (!biomeBuffetGenerators.isEmpty()) { + IrisBiome biome = focusedBiomeAt(x, z); + for (GeneratorGroup group : biomeBuffetGenerators.get(biome)) { + NoiseBounds bounds = generatorBounds.get(group.interpolator()).get(biome).noiseBounds; + h += averageGeneratorHeights(group.generators(), bounds.min(), bounds.max(), x, z, seed + 239945); + } + return h; + } for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) { h += interpolateGenerators(engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, frozenGenerators[interpolatorIndex], x, z, seed); @@ -1482,6 +1507,26 @@ public class IrisComplex implements DataProvider { return registrant == null || registrant.isCompatExcluded() ? null : registrant; } + private Map compileBuffetGeneratorGroups() { + if (biomeBuffet == null || biomeBuffet.cells().isEmpty()) { + return Map.of(); + } + Map cells = new IdentityHashMap<>(); + for (BiomeBuffetLayout.Cell cell : biomeBuffet.cells()) { + Map> groups = new HashMap<>(); + KList biomes = new KList<>(cell.region().getNaturalBiomes(this)); + biomes.add(cell.biome()); + for (IrisBiome biome : biomes) { + for (IrisBiomeGeneratorLink link : biome.getGenerators()) { + IrisGenerator generator = link.getCachedGenerator(this); + groups.computeIfAbsent(generator.getInterpolator(), ignored -> new HashSet<>()).add(generator); + } + } + cells.put(cell.biome(), freezeGeneratorGroups(groups)); + } + return cells; + } + private void registerGenerators(IrisBiome biome) { generatorBiomes.add(biome); biome.getGenerators().forEach(c -> registerGenerator(c.getCachedGenerator(this))); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 4bbef2de6..1921af3c5 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -34,6 +34,8 @@ import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.framework.EngineLifecycleTasks; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.framework.GenerationSessionManager; import art.arcane.iris.engine.history.GenerationBoundarySignatureSampler; import art.arcane.iris.engine.history.GenerationHistory; @@ -1547,6 +1549,23 @@ public class IrisEngine implements Engine { return generationSessions; } + @Override + public boolean requestSave() { + IrisContext context = IrisContext.get(); + if (context != null && context.getEngine() == this && context.getGenerationSessionId() != 0L) { + scheduleWorldSave(); + return true; + } + return EngineLifecycleTasks.run(this, "world_save_request", this::scheduleWorldSave); + } + + private void scheduleWorldSave() { + backgroundTasks.scheduleAdmittedTask(this, () -> NativeStructureOwnershipStore.flush(this)); + getMantle().save(); + getWorldManager().onSave(); + saveEngineData(); + } + @Override public void saveEngineData() { engineDataStore.saveEngineData(); diff --git a/core/src/main/java/art/arcane/iris/engine/StudioGenerationUpdate.java b/core/src/main/java/art/arcane/iris/engine/StudioGenerationUpdate.java index 4929787e8..0002faaa6 100644 --- a/core/src/main/java/art/arcane/iris/engine/StudioGenerationUpdate.java +++ b/core/src/main/java/art/arcane/iris/engine/StudioGenerationUpdate.java @@ -26,7 +26,7 @@ record StudioGenerationUpdate( GenerationRegistryContract registryContract ) implements AutoCloseable { static StudioGenerationUpdate prepare(IrisEngine engine, GenerationHistory history) throws IOException { - Path source = engine.getStudioGenerationSource(); + Path source = engine.getStudioGenerationSource().toRealPath(); IrisData.invalidateLoadedAuthoringResources(source.toFile()); String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); GenerationEpoch active = history.activeEpoch(); 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 3bd66544c..e44901698 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 @@ -270,6 +270,8 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, saveEngineData(); } + boolean requestSave(); + default void saveNow() { NativeStructureOwnershipStore.flush(this); getMantle().saveAllNow(); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java index f2d4bc105..1dcb02c8b 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java @@ -106,7 +106,7 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent public void on(WorldSaveEvent e) { runManagerTask("bukkit_world_manager_save", () -> { if (e.getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { - getEngine().save(); + getEngine().requestSave(); } }); } diff --git a/core/src/main/java/art/arcane/iris/engine/history/BoundaryColumnGeometry.java b/core/src/main/java/art/arcane/iris/engine/history/BoundaryColumnGeometry.java index 8f13e97fd..e33d45b7e 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/BoundaryColumnGeometry.java +++ b/core/src/main/java/art/arcane/iris/engine/history/BoundaryColumnGeometry.java @@ -92,6 +92,18 @@ public final class BoundaryColumnGeometry { return paletteIndices.clone(); } + int runCount() { + return runEnds.length; + } + + int runEnd(int run) { + return runEnds[run]; + } + + Voxel runVoxel(int run) { + return palette.get(paletteIndices[run]); + } + public Voxel voxelAt(int worldY) { long offset = (long) worldY - minimumY; if (offset < 0 || offset >= height()) { diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java index 3becc925b..23c2cff38 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelRegistry.java @@ -164,7 +164,7 @@ public final class GenerationKernelRegistry { @FunctionalInterface public interface RuntimeFactory { - IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan); + IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan, boolean detached); default SeedManager createSeedManager(long worldSeed) { throw new UnsupportedOperationException("Generation kernel does not own seed derivation."); @@ -209,9 +209,9 @@ public final class GenerationKernelRegistry { Objects.requireNonNull(factory, "factory"); } - public IrisComplex createComplex(IrisEngine engine, TransitionGenerationPlan transitionPlan) { + public IrisComplex createComplex(IrisEngine engine, TransitionGenerationPlan transitionPlan, boolean detached) { return Objects.requireNonNull( - factory.create(Objects.requireNonNull(engine, "engine"), transitionPlan), + factory.create(Objects.requireNonNull(engine, "engine"), transitionPlan, detached), "generation kernel complex" ); } diff --git a/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java index eaabb2f5a..ffd06633d 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java +++ b/core/src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java @@ -33,8 +33,8 @@ final class GenerationKernelV1 implements GenerationKernelRegistry.RuntimeFactor } @Override - public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan) { - return new IrisComplex(engine, transitionPlan); + public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan, boolean detached) { + return new IrisComplex(engine, transitionPlan, detached); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java index 308e034f2..c76658250 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/history/SavedBiomeRuntime.java @@ -447,7 +447,8 @@ public final class SavedBiomeRuntime implements AutoCloseable { if (dimension == null) { throw new IOException("Historical dimension is no longer supported: " + epoch.dimensionContract().dimensionKey()); } - Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data)); + Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data), + resolveFocusRegions(data, dimension)); definitions.put(epochId, loaded); return loaded; } catch (Throwable failure) { @@ -464,6 +465,41 @@ public final class SavedBiomeRuntime implements AutoCloseable { } } + private static Map resolveFocusRegions(IrisData data, IrisDimension dimension) { + Map regions = new HashMap<>(); + Set dimensions = new HashSet<>(); + dimensions.add(dimension.getLoadKey()); + if (dimension.hasUpperDimension()) { + dimensions.add(dimension.getUpperDimension()); + } + if (dimension.hasDimensionStack()) { + dimensions.addAll(dimension.getDimensionStack().getDimensions()); + } + for (String key : dimensions) { + IrisDimension source = key.equals(dimension.getLoadKey()) ? dimension : data.getDimensionLoader().load(key); + if (source == null || source.isCompatExcluded()) { + continue; + } + if (source.getStudioMode().biomeSizeChunks() > 0) { + for (IrisBiome biome : source.getAllBiomes(() -> data)) { + if (!biome.isCompatExcluded()) { + IrisRegion region = source.resolveFocusRegion(biome, () -> data); + regions.put(region.getLoadKey(), region); + } + } + } + if (source.getFocus() == null || source.getFocus().isBlank()) { + continue; + } + IrisBiome focus = data.getBiomeLoader().load(source.getFocus()); + if (focus != null && !focus.isCompatExcluded()) { + IrisRegion region = source.resolveFocusRegion(focus, () -> data); + regions.put(region.getLoadKey(), region); + } + } + return Map.copyOf(regions); + } + private static long key(int chunkX, int chunkZ) { return ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL); } @@ -483,10 +519,14 @@ public final class SavedBiomeRuntime implements AutoCloseable { SURFACE, CAVE_BASE, VOLUME } - private record Definitions(IrisData data, IrisDimension dimension, Map nativeDerivatives) { + private record Definitions(IrisData data, IrisDimension dimension, Map nativeDerivatives, + Map focusRegions) { private BiomeEnvironment environment(SavedBiomeChunk.Cell cell) { IrisBiome biome = data.getBiomeLoader().load(cell.biomeKey()); IrisRegion region = data.getRegionLoader().load(cell.regionKey()); + if (region == null) { + region = focusRegions.get(cell.regionKey()); + } if (biome == null || region == null) { throw new SavedBiomeUnavailableException("Historical biome " + cell.biomeKey() + " in region " + cell.regionKey() + " is no longer supported for generation " + cell.activationId() diff --git a/core/src/main/java/art/arcane/iris/engine/history/TransitionGeometryBlender.java b/core/src/main/java/art/arcane/iris/engine/history/TransitionGeometryBlender.java index 9e42e6a7f..510837ea1 100644 --- a/core/src/main/java/art/arcane/iris/engine/history/TransitionGeometryBlender.java +++ b/core/src/main/java/art/arcane/iris/engine/history/TransitionGeometryBlender.java @@ -52,7 +52,7 @@ public final class TransitionGeometryBlender { influence.newTerrainWeight(), influence.openingWeight(), GenerationBlend.usesHistoricalMaterial(blockX, currentGeometry.minimumY() + offset, blockZ, influence.newTerrainWeight())); result.add(value); - changed |= !value.equals(current.voxels.get(offset)); + changed |= !value.equals(current.voxelAt(offset)); } return changed ? BoundaryColumnGeometry.fromVoxels(currentGeometry.minimumY(), result) : currentGeometry; } @@ -65,7 +65,7 @@ public final class TransitionGeometryBlender { double openingWeight, boolean historicalMaterial ) { - BoundaryColumnGeometry.Voxel currentVoxel = current.voxels.get(offset); + BoundaryColumnGeometry.Voxel currentVoxel = current.voxelAt(offset); if (currentVoxel.protectedContent()) { return currentVoxel; } @@ -79,20 +79,20 @@ public final class TransitionGeometryBlender { BoundaryColumnGeometry.Voxel oldFluid = null; for (WeightedProfile contribution : historical) { ColumnProfile profile = contribution.profile(); - if (profile.voxels.get(offset).protectedContent()) { + if (profile.voxelAt(offset).protectedContent()) { continue; } - historicalOpening |= profile.enclosedOpenings[offset]; + historicalOpening |= profile.enclosedOpeningAt(offset); double weight = contribution.weight(); total += weight; - solid += profile.solidDistances[offset] * weight; - fluid += profile.fluidDistances[offset] * weight; - if (profile.solidMaterials[offset] != null && weight > solidMaterialWeight) { - oldSolid = profile.solidMaterials[offset]; + solid += profile.solid.distanceAt(offset) * weight; + fluid += profile.fluid.distanceAt(offset) * weight; + if (profile.solid.materialAt(offset) != null && weight > solidMaterialWeight) { + oldSolid = profile.solid.materialAt(offset); solidMaterialWeight = weight; } - if (profile.fluidMaterials[offset] != null && weight > fluidMaterialWeight) { - oldFluid = profile.fluidMaterials[offset]; + if (profile.fluid.materialAt(offset) != null && weight > fluidMaterialWeight) { + oldFluid = profile.fluid.materialAt(offset); fluidMaterialWeight = weight; } } @@ -100,14 +100,14 @@ public final class TransitionGeometryBlender { return currentVoxel; } double blendedSolid = GenerationBlend.interpolate(solid / total, - current.solidDistances[offset], historicalOpening ? openingWeight : currentWeight); + current.solid.distanceAt(offset), historicalOpening ? openingWeight : currentWeight); if (blendedSolid > 0D) { - return selectMaterial(oldSolid, current.solidMaterials[offset], historicalMaterial); + return selectMaterial(oldSolid, current.solid.materialAt(offset), historicalMaterial); } double blendedFluid = GenerationBlend.interpolate(fluid / total, - current.fluidDistances[offset], historicalOpening ? openingWeight : currentWeight); + current.fluid.distanceAt(offset), historicalOpening ? openingWeight : currentWeight); if (blendedFluid > 0D) { - BoundaryColumnGeometry.Voxel newFluid = current.fluidMaterials[offset]; + BoundaryColumnGeometry.Voxel newFluid = current.fluid.materialAt(offset); if (oldFluid != null && newFluid != null && !fluidFamily(oldFluid).equals(fluidFamily(newFluid))) { return FLUID_BARRIER; } @@ -133,70 +133,87 @@ public final class TransitionGeometryBlender { return current == null || historicalMaterial ? historical : current; } - private static BoundaryColumnGeometry.Voxel[] nearestMaterials( - List voxels, - BoundaryColumnGeometry.Phase phase - ) { - BoundaryColumnGeometry.Voxel[] materials = new BoundaryColumnGeometry.Voxel[voxels.size()]; - int[] distances = new int[voxels.size()]; - int last = -voxels.size() - 1; - BoundaryColumnGeometry.Voxel nearest = null; - for (int index = 0; index < voxels.size(); index++) { - BoundaryColumnGeometry.Voxel voxel = voxels.get(index); - if (voxel.phase() == phase && !voxel.protectedContent()) { - nearest = voxel; - last = index; - } - materials[index] = nearest; - distances[index] = index - last; - } - last = voxels.size() * 2; - nearest = null; - for (int index = voxels.size() - 1; index >= 0; index--) { - BoundaryColumnGeometry.Voxel voxel = voxels.get(index); - if (voxel.phase() == phase && !voxel.protectedContent()) { - nearest = voxel; - last = index; - } - if (nearest != null && last - index < distances[index]) { - materials[index] = nearest; - } - } - return materials; - } - - private static boolean[] enclosedOpenings(List voxels) { - boolean[] openings = new boolean[voxels.size()]; - int lowerSolid = -1; - for (int index = 0; index < voxels.size(); index++) { - BoundaryColumnGeometry.Voxel voxel = voxels.get(index); - if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) { - if (lowerSolid >= 0 && index - lowerSolid - 1 <= MAXIMUM_OPENING_HEIGHT) { - for (int opening = lowerSolid + 1; opening < index; opening++) { - openings[opening] = !voxels.get(opening).protectedContent(); - } - } - lowerSolid = index; - } - } - return openings; - } - private static final class ColumnProfile { - private final List voxels; - private final boolean[] enclosedOpenings; - private final double[] solidDistances; - private final double[] fluidDistances; - private final BoundaryColumnGeometry.Voxel[] solidMaterials; - private final BoundaryColumnGeometry.Voxel[] fluidMaterials; + private final BoundaryColumnGeometry geometry; + private final PhaseCursor solid; + private final PhaseCursor fluid; + private int run; private ColumnProfile(BoundaryColumnGeometry geometry) { - voxels = geometry.voxels(); - enclosedOpenings = enclosedOpenings(voxels); - solidDistances = geometry.solidDistances(); - fluidDistances = geometry.fluidDistances(); - solidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.SOLID); - fluidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.FLUID); + this.geometry = geometry; + solid = new PhaseCursor(geometry, BoundaryColumnGeometry.Phase.SOLID); + fluid = new PhaseCursor(geometry, BoundaryColumnGeometry.Phase.FLUID); + } + + private BoundaryColumnGeometry.Voxel voxelAt(int offset) { + while (offset >= geometry.runEnd(run)) { + run++; + } + return geometry.runVoxel(run); + } + + private boolean enclosedOpeningAt(int offset) { + solid.advanceTo(offset); + return !solid.occupied && solid.start > 0 && solid.end < geometry.height() + && solid.end - solid.start <= MAXIMUM_OPENING_HEIGHT + && !voxelAt(offset).protectedContent(); + } + } + + private static final class PhaseCursor { + private final BoundaryColumnGeometry geometry; + private final BoundaryColumnGeometry.Phase phase; + private int start; + private int end; + private int nextRun; + private int materialRun; + private boolean occupied; + private BoundaryColumnGeometry.Voxel lowerMaterial; + private BoundaryColumnGeometry.Voxel upperMaterial; + + private PhaseCursor(BoundaryColumnGeometry geometry, BoundaryColumnGeometry.Phase phase) { + this.geometry = geometry; + this.phase = phase; + advanceTo(0); + } + + private void advanceTo(int offset) { + while (offset >= end) { + start = end; + occupied = matches(geometry.runVoxel(nextRun)); + lowerMaterial = start == 0 ? null : geometry.runVoxel(nextRun - 1); + do { + end = geometry.runEnd(nextRun++); + } while (nextRun < geometry.runCount() && matches(geometry.runVoxel(nextRun)) == occupied); + upperMaterial = nextRun < geometry.runCount() ? geometry.runVoxel(nextRun) : null; + } + } + + private double distanceAt(int offset) { + advanceTo(offset); + double lowerDistance = start == 0 ? geometry.height() + offset + 1.5D : offset - start + 0.5D; + double upperDistance = end == geometry.height() + ? geometry.height() * 2D - offset + 0.5D : end - offset - 0.5D; + return Math.min(lowerDistance, upperDistance) * (occupied ? 1D : -1D); + } + + private BoundaryColumnGeometry.Voxel materialAt(int offset) { + advanceTo(offset); + if (occupied) { + while (offset >= geometry.runEnd(materialRun)) { + materialRun++; + } + return geometry.runVoxel(materialRun); + } + if (lowerMaterial == null) { + return upperMaterial; + } + return upperMaterial != null && end - offset < offset - start + 1 + ? upperMaterial : lowerMaterial; + } + + private boolean matches(BoundaryColumnGeometry.Voxel voxel) { + return voxel.phase() == phase && !voxel.protectedContent(); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileResolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileResolver.java index 04db49fa4..2d1d573b8 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileResolver.java @@ -517,7 +517,7 @@ final class HydrologyCrossTileResolver { } } } - int sourceReach = Math.max(0, planner.settings.surface().sources().minimumSpacing() - 1); + int sourceReach = Math.max(0, planner.settings.maximumSurfaceSourceSpacing() - 1); for (HydrologyCrossTileSurfaceAdmission.Claim claim : surfaceClaims(result)) { HydrologyPoint source = claim.centerline().getFirst(); int minimumTileX = ownerCoordinate((long) source.x() - sourceReach, tileSize); @@ -605,12 +605,26 @@ final class HydrologyCrossTileResolver { centerline.getLast(), reachesOutlet, maximumWidth, - centerline + centerline, + sourceSpacing(course, result.nodes()) )); } return List.copyOf(claims); } + private int sourceSpacing(RiverCourse course, List nodes) { + int fallback = planner.settings.surface().sources().minimumSpacing(); + if (course.sourceNodeId().isPresent()) { + long sourceId = course.sourceNodeId().getAsLong(); + for (DrainageNode node : nodes) { + if (node.id() == sourceId) { + return node.terrain().surfacePolicy().sourceSpacing(fallback); + } + } + } + return fallback; + } + Map coursesById(List courses) { LinkedHashMap indexed = new LinkedHashMap<>(courses.size()); for (RiverCourse course : courses) { @@ -711,7 +725,8 @@ final class HydrologyCrossTileResolver { surfaceRouting, surfaceContributions, undergroundContributions, - refinedEdges + refinedEdges, + true ); if (planner.settings.surface().enabled()) { planner.surfaceCourses.compileSurfaceCourses( @@ -769,6 +784,7 @@ final class HydrologyCrossTileResolver { graph = planner.sourcePlanner.mergeGraphs(surfaceGraph, undergroundGraph); } } + HydrologySurfaceProfiles.rejectExcludedWetFootprints(footprintCompiler, courses, diagnostics); List normalizedTrunkCourses = planner.tributaries.normalizeSharedTrunks(courses, diagnostics); List normalizedOutletCourses = planner.tributaries.normalizeOutletContinuations(normalizedTrunkCourses); courses.clear(); @@ -845,7 +861,8 @@ final class HydrologyCrossTileResolver { routing, surface ? contributions : new int[contributions.length], surface ? new int[contributions.length] : contributions, - refinedEdges + refinedEdges, + surface ); ArrayList courses = new ArrayList<>(); ArrayList diagnostics = new ArrayList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java index c9a6eb2ae..fd945fc02 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java @@ -10,14 +10,13 @@ final class HydrologyCrossTileSurfaceAdmission { static Result admit( List currentClaims, - List blockers, - int minimumSeparation + List blockers ) { ArrayList rejections = new ArrayList<>(); for (Claim current : currentClaims) { for (RankedClaim ranked : blockers) { Claim blocker = ranked.claim(); - if (!conflicts(current, blocker, minimumSeparation)) { + if (!conflicts(current, blocker)) { continue; } rejections.add(new Rejection(current, blocker.courseId())); @@ -27,10 +26,11 @@ final class HydrologyCrossTileSurfaceAdmission { return new Result(List.copyOf(rejections)); } - private static boolean conflicts(Claim first, Claim second, int minimumSeparation) { + private static boolean conflicts(Claim first, Claim second) { if (first.outletId() == second.outletId()) { return true; } + int minimumSeparation = Math.max(first.sourceSpacing(), second.sourceSpacing()); long minimumSeparationSquared = (long) minimumSeparation * minimumSeparation; if (first.centerline().getFirst().distanceSquared2D(second.centerline().getFirst()) < minimumSeparationSquared) { @@ -131,12 +131,13 @@ final class HydrologyCrossTileSurfaceAdmission { HydrologyPoint terminal, boolean reachesOutlet, int maximumWidth, - List centerline + List centerline, + int sourceSpacing ) { Claim { Objects.requireNonNull(terminal, "terminal"); centerline = List.copyOf(Objects.requireNonNull(centerline, "centerline")); - if (maximumWidth < 1 || centerline.size() < 2) { + if (maximumWidth < 1 || centerline.size() < 2 || sourceSpacing < 0 || sourceSpacing > 8192) { throw new IllegalArgumentException("Surface claims require a positive width and centerline."); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureSitePlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureSitePlanner.java index 161c0923a..601f93e36 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureSitePlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureSitePlanner.java @@ -253,7 +253,7 @@ final class HydrologyFeatureSitePlanner { || !terrain.outletAllowed()) { continue; } - HydrologyGridNode ocean = planner.outletPlanner.firstOceanNeighbor(grid, land); + HydrologyGridNode ocean = planner.outletPlanner.firstOceanNeighbor(grid, land, false); if (ocean == null) { continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOutletPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOutletPlanner.java index d7a454af0..4062b77a4 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOutletPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOutletPlanner.java @@ -6,6 +6,9 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Objects; +import java.util.LinkedHashMap; +import java.util.Map; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; final class HydrologyOutletPlanner { private final HydrologyPlanner planner; @@ -98,20 +101,26 @@ final class HydrologyOutletPlanner { sortOutletCandidates(key, grid, surface, oceanCandidates); // Sea outlets and inland outlets are budgeted separately: a coast never starves the // sinkholes behind it and a sinkhole never displaces a mouth. - List selectedOcean = limitOutletsByType( - oceanCandidates, - planner.settings.outlets().maximumCoastalPerTile() - ); + List selectedOcean = surface + ? limitSurfaceOutlets(key, grid, oceanCandidates, true) + : limitOutletsByType(oceanCandidates, planner.settings.outlets().maximumCoastalPerTile()); addRejectedOutlets(oceanCandidates, selectedOcean, diagnostics); if (!planner.settings.outlets().inlandGrotto().enabled()) { return selectedOcean; } + if (surface) { + HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); + if (budgets.overridden()) { + oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true)); + } + } boolean[] oceanReachable = outletReachable(grid, oceanCandidates, surface); ArrayList inlandCandidates = inlandOutletCandidates(grid, oceanReachable); sortInlandOutletCandidates(grid, inlandCandidates); List selectedInland = styledInlandOutlets( grid, - limitOutlets(inlandCandidates, planner.settings.outlets().maximumPerTile()), + surface ? limitSurfaceOutlets(key, grid, inlandCandidates, false) + : limitOutlets(inlandCandidates, planner.settings.outlets().maximumPerTile()), surface ); addRejectedOutlets(inlandCandidates, selectedInland, diagnostics); @@ -127,7 +136,7 @@ final class HydrologyOutletPlanner { if (land.terrain().ocean() || !land.terrain().transitAllowed() || !land.terrain().outletAllowed()) { continue; } - HydrologyGridNode ocean = firstOceanNeighbor(grid, land); + HydrologyGridNode ocean = firstOceanNeighbor(grid, land, surface); if (ocean == null || !land.terrain().drainsInto(ocean.terrain())) { // A confined shore only reaches a sea that belongs to its own area. continue; @@ -135,7 +144,11 @@ final class HydrologyOutletPlanner { HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean); if (boundary == null || !boundary.landwardTerrain().transitAllowed() - || !boundary.landwardTerrain().outletAllowed()) { + || !boundary.landwardTerrain().outletAllowed() + || surface && !HydrologySurfaceProfiles.sharesProfile( + boundary.landwardTerrain(), + planner.sampleBasisWithoutSlope(boundary.oceanPoint().x(), boundary.oceanPoint().z()) + )) { continue; } HydrologyFeatureType type = coastalOutletType(boundary.landwardTerrain()); @@ -174,6 +187,12 @@ final class HydrologyOutletPlanner { } sortSurfaceFallbackOutletCandidates(grid, oceanCandidates); sortSurfaceFallbackOutletCandidates(grid, inlandCandidates); + HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); + if (budgets.overridden()) { + HydrologyTileKey key = HydrologyTileKey.fromBlock(grid.ownerMinimumX(), grid.ownerMinimumZ(), grid.ownerSize()); + oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true)); + inlandCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, false)); + } // Trials alternate sea and inland outlets, sea first: a coast with more mouths than trials // still gives a sinkhole its turn, and a rejected mouth is retried at the next coast. ArrayList candidates = new ArrayList<>(oceanCandidates.size() + inlandCandidates.size()); @@ -196,6 +215,41 @@ final class HydrologyOutletPlanner { return limitOutlets(candidates, maximumTrials); } + private boolean surfaceOutletHasBudget(HydrologyTileKey key, HydrologySampledGrid grid, + HydrologySurfaceBudgets budgets, OutletCandidate candidate, boolean coastal) { + HydrologySurfaceBudgets.Area area = budgets.area(grid.node(candidate.landIndex()).terrain().surfacePolicy()); + return area != null && budgets.outletTarget(area, coastal, planner, key) > 0; + } + + List limitSurfaceOutlets(HydrologyTileKey key, HydrologySampledGrid grid, + List candidates, boolean coastal) { + HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); + if (!budgets.overridden()) { + return coastal ? limitOutletsByType(candidates, planner.settings.outlets().maximumCoastalPerTile()) + : limitOutlets(candidates, planner.settings.outlets().maximumPerTile()); + } + LinkedHashMap> grouped = new LinkedHashMap<>(); + for (OutletCandidate candidate : candidates) { + SurfaceRiverPolicy policy = grid.node(candidate.landIndex()).terrain().surfacePolicy(); + if (budgets.area(policy) != null) { + grouped.computeIfAbsent(policy.budget(), ignored -> new ArrayList<>()).add(candidate); + } + } + ArrayList selected = new ArrayList<>(); + long spacingSquared = outletSpacingSquared(); + for (Map.Entry> entry : grouped.entrySet()) { + int target = budgets.outletTarget(budgets.area(entry.getKey()), coastal, planner, key); + List local = coastal ? limitOutletsByType(entry.getValue(), target) + : limitOutlets(entry.getValue(), target); + for (OutletCandidate candidate : local) { + if (!withinOutletSpacing(candidate, selected, spacingSquared)) { + selected.add(candidate); + } + } + } + return List.copyOf(selected); + } + ArrayList inlandOutletCandidates( HydrologySampledGrid grid, boolean[] oceanReachable @@ -287,7 +341,6 @@ final class HydrologyOutletPlanner { } boolean[] outletReachable(HydrologySampledGrid grid, List outlets, boolean surface) { - int maximumRise = surface ? planner.sourcePlanner.maximumSurfaceEdgeRise() : Integer.MAX_VALUE; boolean[] reachable = new boolean[grid.nodes().size()]; int[] queue = new int[reachable.length]; int readIndex = 0; @@ -305,7 +358,9 @@ final class HydrologyOutletPlanner { HydrologyGridNode neighbor = grid.nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z()); if (neighbor == null || reachable[neighbor.index()] || neighbor.terrain().ocean() || !neighbor.terrain().transitAllowed() - || node.terrain().naturalHeight() - neighbor.terrain().naturalHeight() > maximumRise) { + || surface && !HydrologySurfaceProfiles.sharesProfile(neighbor.terrain(), node.terrain()) + || surface && node.terrain().naturalHeight() - neighbor.terrain().naturalHeight() + > planner.sourcePlanner.maximumSurfaceEdgeRise(node.terrain())) { continue; } reachable[neighbor.index()] = true; @@ -324,11 +379,15 @@ final class HydrologyOutletPlanner { for (OutletCandidate candidate : candidates) { HydrologyGridNode node = grid.node(candidate.landIndex()); RiverOutlet outlet = candidate.outlet(); - HydrologyPoint landward = planner.routeGeometry.routeAnchor(node); + HydrologyPoint landward = planner.routeGeometry.routeAnchor(node, surface); HydrologyTerrainSample landwardTerrain = Objects.requireNonNull( planner.sampleDetailed(landward.x(), landward.z()), "Hydrology inland outlet anchor left sampled terrain" ); + if (surface && (!HydrologySurfaceProfiles.sharesProfile(node.terrain(), landwardTerrain) + || !node.terrain().surfacePolicy().areaKey().equals(landwardTerrain.surfacePolicy().areaKey()))) { + continue; + } int connectionOffsetX = Integer.compare(outlet.connectionPoint().x(), outlet.landwardPoint().x()); int connectionOffsetZ = Integer.compare(outlet.connectionPoint().z(), outlet.landwardPoint().z()); HydrologyPoint connection = new HydrologyPoint( @@ -404,11 +463,12 @@ final class HydrologyOutletPlanner { ); } - HydrologyGridNode firstOceanNeighbor(HydrologySampledGrid grid, HydrologyGridNode land) { + HydrologyGridNode firstOceanNeighbor(HydrologySampledGrid grid, HydrologyGridNode land, boolean surface) { ArrayList oceans = new ArrayList<>(); for (HydrologyGridOffset offset : CARDINAL_OFFSETS) { HydrologyGridNode neighbor = grid.nodeAt(land.gridX() + offset.x(), land.gridZ() + offset.z()); - if (neighbor != null && neighbor.terrain().ocean()) { + if (neighbor != null && neighbor.terrain().ocean() + && (!surface || HydrologySurfaceProfiles.sharesProfile(land.terrain(), neighbor.terrain()))) { oceans.add(neighbor); } } @@ -500,7 +560,7 @@ final class HydrologyOutletPlanner { && downstream.terrain().surfaceSourceAllowed() && downstream.terrain().naturalHeight() >= planner.settings.surface().sources().minimumElevation() && distances[downstream.index()] - >= planner.settings.routing().minimumSurfaceCourseLength()) { + >= planner.sourcePlanner.minimumCourseLength(downstream.terrain(), true)) { capacity = Math.addExact( capacity, 1_000_000L @@ -516,7 +576,8 @@ final class HydrologyOutletPlanner { if (upstream == null || visited[upstream.index()] || upstream.terrain().ocean() - || !upstream.terrain().transitAllowed()) { + || !upstream.terrain().transitAllowed() + || !HydrologySurfaceProfiles.sharesProfile(upstream.terrain(), downstream.terrain())) { continue; } visited[upstream.index()] = true; diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java index d26be25b7..809a62644 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -51,7 +51,6 @@ public final class HydrologyPlanner { final SimplexNoise routeWormPrimary; final SimplexNoise routeWormDetail; final SurfaceCourseBuilder surfaceCourseBuilder; - final SurfaceCourseBuilder tributaryCourseBuilder; final HydrologyCrossTileResolver crossTile; final HydrologyOutletPlanner outletPlanner; final HydrologySourcePlanner sourcePlanner; @@ -182,17 +181,9 @@ public final class HydrologyPlanner { settings.surface(), this::sampleBasisWithoutSlope, geometrySampler, - settings.seaLevel(), - settings.routing().minimumSurfaceCourseLength() + settings.seaLevel() ); - // A tributary only has to be a real reach before it joins its stem: half the course minimum. - this.tributaryCourseBuilder = new SurfaceCourseBuilder( - settings.surface(), - this::sampleBasisWithoutSlope, - geometrySampler, - settings.seaLevel(), - settings.routing().minimumSurfaceCourseLength() / 2 - ); this.crossTile = new HydrologyCrossTileResolver(this); + this.crossTile = new HydrologyCrossTileResolver(this); this.outletPlanner = new HydrologyOutletPlanner(this); this.sourcePlanner = new HydrologySourcePlanner(this); this.routeGeometry = new HydrologyRouteGeometry(this); @@ -638,6 +629,7 @@ public final class HydrologyPlanner { final HashMap> radialOffsets; final HashMap undergroundSegmentCaps; final HashMap routeAnchors; + final HashMap surfaceRouteAnchors; final IdentityHashMap>> surfaceRoutes; final HydrologyCaveCourseFilter.CandidateCache caveCandidates; final HydrologyCaveContainmentPlanner.ValidationCache caveValidations; @@ -652,6 +644,7 @@ public final class HydrologyPlanner { this.radialOffsets = new HashMap<>(); this.undergroundSegmentCaps = new HashMap<>(); this.routeAnchors = new HashMap<>(); + this.surfaceRouteAnchors = new HashMap<>(); this.surfaceRoutes = new IdentityHashMap<>(); this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache(); this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache(); @@ -769,8 +762,7 @@ public final class HydrologyPlanner { HydrologyCrossTileSurfaceAdmission.Result surfaceAdmission = HydrologyCrossTileSurfaceAdmission.admit( crossTile.surfaceClaims(current), - blockers.surfaceClaims(), - settings.surface().sources().minimumSpacing() + blockers.surfaceClaims() ); Map currentCourses = crossTile.coursesById(current.courses()); HashSet rejectedSurfaceOutlets = new HashSet<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java index 591869862..0376ad68e 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java @@ -17,7 +17,8 @@ public record HydrologyPlannerSettings( List deepFluids, List surfacePools, double widestShoreBiomeWidth, - SeaCaves seaCaves + SeaCaves seaCaves, + SurfacePolicyBounds surfacePolicyBounds ) { private static final long PLAN_FORMAT_REVISION = 6L; private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4; @@ -38,7 +39,9 @@ public record HydrologyPlannerSettings( if (seaCaves == null) { throw new IllegalArgumentException("Sea cave settings are required."); } - int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves); + surfacePolicyBounds = Objects.requireNonNull(surfacePolicyBounds); + int publicationRadius = Math.max(surfacePolicyBounds.maximumSourceSpacing(), + publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves)); if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) { throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period."); } @@ -68,7 +71,8 @@ public record HydrologyPlannerSettings( List.of(), List.of(), 0D, - SeaCaves.disabled() + SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -91,11 +95,29 @@ public record HydrologyPlannerSettings( for (SurfacePool pool : surfacePools) { hash = HydrologyHash.mix(hash, pool.hashCode(), HydrologyHash.text(pool.id())); } + if (surfacePolicyBounds.fingerprint() != 0L) { + hash = HydrologyHash.mix(hash, surfacePolicyBounds.fingerprint(), surfacePolicyBounds.maximumSourceSpacing()); + } return hash; } public int publicationRadius() { - return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves); + return Math.max(surfacePolicyBounds.maximumSourceSpacing(), + publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves)); + } + + public int maximumSurfaceSourceSpacing() { + return Math.max(surface.sources().minimumSpacing(), surfacePolicyBounds.maximumSourceSpacing()); + } + + public record SurfacePolicyBounds(long fingerprint, int maximumSourceSpacing) { + public static final SurfacePolicyBounds NONE = new SurfacePolicyBounds(0L, 0); + + public SurfacePolicyBounds { + if (maximumSourceSpacing < 0 || maximumSourceSpacing > 8192) { + throw new IllegalArgumentException("Maximum surface policy source spacing must be between 0 and 8192."); + } + } } int crossTileColorPeriod() { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRouteGeometry.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRouteGeometry.java index 71b75b944..0ceb1627d 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRouteGeometry.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRouteGeometry.java @@ -2,6 +2,7 @@ package art.arcane.iris.engine.hydrology; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -33,11 +34,12 @@ final class HydrologyRouteGeometry { HydrologySampledGrid grid, HydrologyRoutingPlan routing, HydrologyGridNode downstream, - RiverOutlet outlet + RiverOutlet outlet, + boolean surface ) { int continuationIndex = routing.parent()[downstream.index()]; if (continuationIndex >= 0) { - return routeAnchor(grid.node(continuationIndex)); + return routeAnchor(grid.node(continuationIndex), surface); } HydrologyPoint landward = outlet.landwardPoint(); if (landward.x() != downstream.x() || landward.z() != downstream.z()) { @@ -46,10 +48,12 @@ final class HydrologyRouteGeometry { return outlet.connectionPoint(); } - HydrologyPoint routeAnchor(HydrologyGridNode node) { + HydrologyPoint routeAnchor(HydrologyGridNode node, boolean surface) { HydrologyPlanner.PlanningSamples samples = planner.planningSamples.get(); + HashMap anchors = samples == null ? null + : surface ? samples.surfaceRouteAnchors : samples.routeAnchors; if (samples != null) { - HydrologyPoint cached = samples.routeAnchors.get(node.id()); + HydrologyPoint cached = anchors.get(node.id()); if (cached != null) { return cached; } @@ -112,12 +116,13 @@ final class HydrologyRouteGeometry { selected.z(), desiredX, desiredZ, - maximumOffsetRatio + maximumOffsetRatio, + surface ); for (int[] offset : offsets) { int x = desiredX + offset[0]; int z = desiredZ + offset[1]; - double score = anchorScore(node, x, z, desiredX, desiredZ, maximumOffsetRatio); + double score = anchorScore(node, x, z, desiredX, desiredZ, maximumOffsetRatio, surface); if (score < selectedScore) { HydrologyTerrainSample terrain = planner.sampleLandBasis(x, z); selected = new HydrologyPoint(x, terrain.naturalHeight(), z); @@ -125,7 +130,7 @@ final class HydrologyRouteGeometry { } } if (samples != null) { - samples.routeAnchors.put(node.id(), selected); + anchors.put(node.id(), selected); } return selected; } @@ -136,7 +141,8 @@ final class HydrologyRouteGeometry { int z, int desiredX, int desiredZ, - double maximumOffsetRatio + double maximumOffsetRatio, + boolean surface ) { double distanceFromNode = StrictMath.hypot(x - node.x(), z - node.z()); if (distanceFromNode > planner.settings.routing().sampleSpacing() * maximumOffsetRatio) { @@ -146,6 +152,17 @@ final class HydrologyRouteGeometry { if (terrain == null || !terrain.transitAllowed() || !withinConfines(terrain, node.terrain().confinesKey())) { return Double.POSITIVE_INFINITY; } + if (surface) { + int maximumDrop = Math.max(0, Math.min(planner.sourcePlanner.permittedSurfaceIncision(node.terrain()), + planner.sourcePlanner.permittedSurfaceIncision(terrain)) + - planner.settings.surface().banks().sink() - planner.settings.surface().minimumDepth()); + int drop = node.terrain().naturalHeight() - terrain.naturalHeight(); + if (drop < 0 || drop > maximumDrop + || !HydrologySurfaceProfiles.sharesProfile(node.terrain(), terrain) + || !node.terrain().surfacePolicy().areaKey().equals(terrain.surfacePolicy().areaKey())) { + return Double.POSITIVE_INFINITY; + } + } if (!planner.routePaths.traversableHop(node.naturalPoint(), new HydrologyPoint(x, terrain.naturalHeight(), z))) { return Double.POSITIVE_INFINITY; } @@ -166,7 +183,8 @@ final class HydrologyRouteGeometry { HydrologyPoint upstream, HydrologyPoint downstream, HydrologyPoint continuation, - int transverseCandidates + int transverseCandidates, + boolean surface ) { int refinement = planner.settings.routing().refinementSpacing(); int effectiveTransverseCandidates = transverseCandidates; @@ -222,7 +240,7 @@ final class HydrologyRouteGeometry { nominal, progress, refinement, - elevationTransition ? 0 : effectiveTransverseCandidates, + elevationTransition && !surface ? 0 : effectiveTransverseCandidates, upstreamTerrain == null ? null : upstreamTerrain.confinesKey() ); if (candidates.isEmpty()) { @@ -331,6 +349,8 @@ final class HydrologyRouteGeometry { if (terrain == null || !terrain.transitAllowed()) { return new RouteCandidate( point, + point.x(), + point.z(), 0D, Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, @@ -341,6 +361,8 @@ final class HydrologyRouteGeometry { double terrainScore = routeTerrainScore(terrain); return new RouteCandidate( new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()), + point.x(), + point.z(), 0D, terrainScore, terrainScore, @@ -457,6 +479,8 @@ final class HydrologyRouteGeometry { + HydrologyHash.unit(HydrologyHash.mix(planner.worldSeed, upstreamId, downstreamId, x, z)) * 1.0E-6D; RouteCandidate candidate = new RouteCandidate( new HydrologyPoint(x, terrain.naturalHeight(), z), + x, + z, offset, localScore, routeTerrainScore(terrain), @@ -491,6 +515,8 @@ final class HydrologyRouteGeometry { )) * 1.0E-6D; RouteCandidate fallback = new RouteCandidate( new HydrologyPoint(fallbackX, fallbackTerrain.naturalHeight(), fallbackZ), + fallbackX, + fallbackZ, fallbackOffset, localScore, routeTerrainScore(fallbackTerrain), @@ -535,6 +561,8 @@ final class HydrologyRouteGeometry { )) * 1.0E-6D; RouteCandidate candidate = new RouteCandidate( new HydrologyPoint(x, estimate.height(), z), + nominal.x() - nominal.tangent().z() * offset, + nominal.z() + nominal.tangent().x() * offset, offset, localScore, estimate.terrainScore(), @@ -590,6 +618,8 @@ final class HydrologyRouteGeometry { if (terrain == null || !terrain.transitAllowed()) { return new RouteCandidate( point, + candidate.continuousX(), + candidate.continuousZ(), candidate.offset(), Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY, @@ -601,6 +631,8 @@ final class HydrologyRouteGeometry { double exactLocalScore = candidate.localScore() - candidate.terrainScore() + terrainScore; return new RouteCandidate( new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()), + candidate.continuousX(), + candidate.continuousZ(), candidate.offset(), exactLocalScore, terrainScore, @@ -887,10 +919,10 @@ final class HydrologyRouteGeometry { } double turnPenalty = candidateTurns[beforeIndex]; if (Double.isNaN(turnPenalty)) { - double turn = HydrologyRoutePath.routeTurnDegrees( - beforeLayer.get(beforeIndex).point(), - previous.point(), - current.point() + double turn = continuousTurnDegrees( + beforeLayer.get(beforeIndex), + previous, + current ); turnPenalty = turn > maximumTurn ? Double.POSITIVE_INFINITY @@ -1028,16 +1060,25 @@ final class HydrologyRouteGeometry { return excess * excess * turnCost / maximumTurn; } + double continuousTurnDegrees(RouteCandidate before, RouteCandidate center, RouteCandidate after) { + double incomingX = center.continuousX() - before.continuousX(); + double incomingZ = center.continuousZ() - before.continuousZ(); + double outgoingX = after.continuousX() - center.continuousX(); + double outgoingZ = after.continuousZ() - center.continuousZ(); + double length = StrictMath.hypot(incomingX, incomingZ) * StrictMath.hypot(outgoingX, outgoingZ); + if (length <= 0D) { + return 0D; + } + double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) / length; + return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine)))); + } + double surfaceRouteTransitionPenalty( RouteCandidate start, RouteCandidate end ) { int minimumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().minimumWidth()); int maximumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().maximumWidth()); - int maximumBankRise = Math.addExact( - planner.settings.surface().maximumIncision(), - (int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D) - ); RouteDirection tangent = planner.routePaths.direction(start.point().x(), start.point().z(), end.point().x(), end.point().z()); int endpointCeiling = Math.max(start.point().y(), end.point().y()); double penalty = 0D; @@ -1068,7 +1109,7 @@ final class HydrologyRouteGeometry { minimumBankDistance, maximumBankDistance, head, - maximumBankRise + planner.surfaceCourses.maximumSurfaceBankRise(terrain) ); } if ((raster.size() - 1) % sampleStride != 0) { @@ -1094,7 +1135,7 @@ final class HydrologyRouteGeometry { minimumBankDistance, maximumBankDistance, head, - maximumBankRise + planner.surfaceCourses.maximumSurfaceBankRise(terrain) ); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySourcePlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySourcePlanner.java index ef52cdaa0..1de16bcc2 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySourcePlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySourcePlanner.java @@ -11,6 +11,7 @@ import java.util.Map; import java.util.Objects; import java.util.PriorityQueue; import java.util.function.IntPredicate; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; final class HydrologySourcePlanner { private final HydrologyPlanner planner; @@ -64,7 +65,6 @@ final class HydrologySourcePlanner { ) { // A surface river never climbs: any rise along its route becomes a cut of at least that rise, // so lattice edges that rise more than a small saddle are not drainage at all. - int maximumRise = surface ? maximumSurfaceEdgeRise() : Integer.MAX_VALUE; int nodeCount = grid.nodes().size(); double[] potential = new double[nodeCount]; int[] parent = new int[nodeCount]; @@ -93,12 +93,14 @@ final class HydrologySourcePlanner { for (HydrologyGridOffset offset : ROUTING_OFFSETS) { HydrologyGridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z()); if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed() - || !upstream.terrain().drainsInto(downstream.terrain())) { + || !upstream.terrain().drainsInto(downstream.terrain()) + || surface && !HydrologySurfaceProfiles.sharesProfile(upstream.terrain(), downstream.terrain())) { // Confined ground only drains into its own area, so every course that starts or // arrives there keeps to that area up to and including its outlet. continue; } - if (downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight() > maximumRise) { + if (surface && downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight() + > maximumSurfaceEdgeRise(downstream.terrain())) { continue; } double edgeCost = routeCost( @@ -140,11 +142,16 @@ final class HydrologySourcePlanner { * Largest rise a surface drainage edge may take. Heads only fall, so a rise along a route becomes a cut * of at least that rise; the lattice may climb no more than the cut the valley solver still accepts. */ - int maximumSurfaceEdgeRise() { - int permitted = planner.settings.surface().maximumIncision() - planner.settings.surface().banks().sink() - planner.settings.surface().minimumDepth(); + int maximumSurfaceEdgeRise(HydrologyTerrainSample terrain) { + int permitted = permittedSurfaceIncision(terrain) - planner.settings.surface().banks().sink() - planner.settings.surface().minimumDepth(); return Math.max(1, permitted); } + int minimumCourseLength(HydrologyTerrainSample source, boolean surface) { + int fallback = planner.settings.routing().minimumCourseLength(surface); + return surface ? source.surfacePolicy().minimumCourseLength(fallback) : fallback; + } + HydrologyRoutingPlan requireOrganicSurface(HydrologyRoutingPlan routing) { return new HydrologyRoutingPlan( routing.potential(), @@ -158,7 +165,7 @@ final class HydrologySourcePlanner { int permittedSurfaceIncision(HydrologyTerrainSample terrain) { return permittedSurfaceIncision( - planner.settings.surface().maximumIncision(), + terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()), terrain.incisionMultiplier() ); } @@ -219,6 +226,7 @@ final class HydrologySourcePlanner { return SourceSelection.empty(surface); } long sourceSalt = surface ? SURFACE_SOURCE_SALT : UNDERGROUND_SOURCE_SALT; + HydrologySurfaceBudgets surfaceBudgets = surface ? HydrologySurfaceBudgets.sample(grid, sourceSettings) : null; ArrayList candidates = new ArrayList<>(); boolean hasRequiredCandidate = false; for (HydrologyGridNode node : grid.nodes()) { @@ -228,7 +236,8 @@ final class HydrologySourcePlanner { HydrologyTerrainSample terrain = node.terrain(); boolean allowed = surface ? terrain.surfaceSourceAllowed() : terrain.undergroundSourceAllowed(); boolean required = surface ? terrain.surfaceSourceRequired() : terrain.undergroundSourceRequired(); - if (!allowed || surface && terrain.naturalHeight() < sourceSettings.minimumElevation()) { + if (!allowed || surface && (terrain.naturalHeight() < sourceSettings.minimumElevation() + || terrain.surfacePolicy().sourceDensity() != null && terrain.surfacePolicy().sourceDensity() == 0D)) { continue; } double weight = surface ? terrain.surfaceSourceWeight() : terrain.undergroundSourceWeight(); @@ -246,7 +255,7 @@ final class HydrologySourcePlanner { : HydrologyCandidateRejection.NO_DRAINAGE_PATH, diagnostics); continue; } - if (!sourceOutletAllowed(node, routing, surface)) { + if (!sourceOutletAllowed(node, grid, routing, surface)) { addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.NO_LEGAL_OUTLET, diagnostics); continue; } @@ -259,7 +268,7 @@ final class HydrologySourcePlanner { addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.ROUTE_LIMIT, diagnostics); continue; } - if (routeLength < planner.settings.routing().minimumCourseLength(surface)) { + if (routeLength < minimumCourseLength(terrain, surface)) { addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.COURSE_TOO_SHORT, diagnostics); continue; } @@ -272,6 +281,9 @@ final class HydrologySourcePlanner { candidates.add(new SourceCandidate(node.index(), stable, score, required)); hasRequiredCandidate |= required; } + if (surfaceBudgets != null && surfaceBudgets.overridden()) { + return selectLocalSurfaceSources(key, grid, routing, candidates, surfaceBudgets, enforceGlobalSpacing, routingContexts); + } int target = expectedCount(sourceSettings.density(), HydrologyHash.mix( planner.worldSeed, sourceSalt, @@ -334,11 +346,98 @@ final class HydrologySourcePlanner { surface, candidates, admission, - admittedGuaranteed, + new int[]{admittedGuaranteed}, maximumOptionalRejections ); } + private SourceSelection selectLocalSurfaceSources( + HydrologyTileKey key, + HydrologySampledGrid grid, + HydrologyRoutingPlan routing, + List candidates, + HydrologySurfaceBudgets budgets, + boolean enforceGlobalSpacing, + Map routingContexts + ) { + candidates.sort(Comparator.comparing(SourceCandidate::required).reversed() + .thenComparing(Comparator.comparingDouble(SourceCandidate::score).reversed()) + .thenComparingLong(SourceCandidate::stableId)); + prioritizeInlandSource(candidates, routing); + List areas = budgets.areas(); + Map areaIndices = new HashMap<>(); + ArrayList> areaOutlets = new ArrayList<>(areas.size()); + for (int index = 0; index < areas.size(); index++) { + areaIndices.put(areas.get(index).policy.budget(), index); + areaOutlets.add(new HashSet<>()); + } + int count = candidates.size(); + int[] candidateAreas = new int[count]; + int[] candidateOutlets = new int[count]; + int[] outletLimits = new int[count]; + int[] requiredCandidates = new int[areas.size()]; + for (int index = 0; index < count; index++) { + HydrologyGridNode node = grid.node(candidates.get(index).nodeIndex()); + SurfaceRiverPolicy policy = node.terrain().surfacePolicy(); + int area = areaIndices.get(policy.budget()); + int outlet = routing.outletIndex()[node.index()]; + candidateAreas[index] = area; + candidateOutlets[index] = outlet; + outletLimits[index] = 1 + policy.tributaries(planner.settings.routing().tributaries()); + areaOutlets.get(area).add(outlet); + if (candidates.get(index).required()) { + requiredCandidates[area]++; + } + } + int[] areaLimits = new int[areas.size()]; + int[] requiredMinimums = new int[areas.size()]; + int target = 0; + for (int index = 0; index < areas.size(); index++) { + requiredMinimums[index] = Math.min(requiredCandidates[index], Math.max(1, + planner.settings.surface().sources().minimumPerTile())); + areaLimits[index] = Math.max(requiredMinimums[index], + budgets.sourceTarget(areas.get(index), areaOutlets.get(index).size(), planner, key)); + target = Math.addExact(target, areaLimits[index]); + } + boolean[] selected = new boolean[count]; + boolean[] spacingRejected = new boolean[count]; + boolean[] evaluated = new boolean[count]; + IntPredicate admitted = candidateIndex -> !enforceGlobalSpacing || globallyAdmittedSource( + grid.node(candidates.get(candidateIndex).nodeIndex()), planner.settings.surface().sources(), + SURFACE_SOURCE_SALT, true, routingContexts); + ArrayList selectedIndices = new ArrayList<>(Math.min(count, target)); + int[] selectedByArea = new int[areas.size()]; + int[] selectedRequiredByArea = new int[areas.size()]; + int[] selectedByOutlet = new int[routing.outlets().size()]; + for (int index = 0; index < count && selectedIndices.size() < target; index++) { + int area = candidateAreas[index]; + int outlet = candidateOutlets[index]; + if (selectedByArea[area] >= areaLimits[area] || selectedByOutlet[outlet] >= outletLimits[index]) { + continue; + } + evaluated[index] = true; + spacingRejected[index] = !admitted.test(index); + if (spacingRejected[index]) { + continue; + } + selected[index] = true; + selectedIndices.add(index); + selectedByArea[area]++; + selectedByOutlet[outlet]++; + if (candidates.get(index).required()) { + selectedRequiredByArea[area]++; + } + } + for (int area = 0; area < areas.size(); area++) { + requiredMinimums[area] = Math.min(requiredMinimums[area], selectedRequiredByArea[area]); + } + SourceAdmissionSelection admission = new SourceAdmissionSelection(target, selectedIndices, selected, + spacingRejected, evaluated, admitted, + new SourceAdmissionSelection.Quotas(candidateOutlets, outletLimits, candidateAreas, areaLimits)); + return new SourceSelection(true, candidates, admission, requiredMinimums, + Math.max(SURFACE_OPTIONAL_SOURCE_REJECTIONS_PER_TARGET, target * SURFACE_OPTIONAL_SOURCE_REJECTIONS_PER_TARGET)); + } + static int effectiveSourceTarget(boolean outletBounded, int requestedTarget, int outletCount) { return effectiveSourceTarget(outletBounded, requestedTarget, outletCount, 1); } @@ -407,8 +506,7 @@ final class HydrologySourcePlanner { spacingRejectedCandidates, evaluatedCandidates, globallyAdmitted, - outletIndices, - maximumCoursesPerOutlet + SourceAdmissionSelection.Quotas.uniform(outletIndices, target, maximumCoursesPerOutlet) ); } @@ -483,8 +581,7 @@ final class HydrologySourcePlanner { spacingRejectedCandidates, evaluatedCandidates, globallyAdmitted, - new int[candidateCount], - Integer.MAX_VALUE + SourceAdmissionSelection.Quotas.uniform(new int[candidateCount], target, Integer.MAX_VALUE) ); } @@ -495,16 +592,19 @@ final class HydrologySourcePlanner { boolean surface, Map routingContexts ) { - int minimumSpacing = sourceSettings.minimumSpacing(); + int minimumSpacing = surface + ? candidate.terrain().surfacePolicy().sourceSpacing(sourceSettings.minimumSpacing()) + : sourceSettings.minimumSpacing(); boolean required = surface ? candidate.terrain().surfaceSourceRequired() : candidate.terrain().undergroundSourceRequired(); - if (minimumSpacing <= 0 || required) { + if ((!surface && minimumSpacing <= 0) || required) { return true; } SourcePriority candidatePriority = sourcePriority(candidate.x(), candidate.z(), candidate.terrain(), sourceSalt, surface); int sampleSpacing = planner.settings.routing().sampleSpacing(); - int latticeRadius = (int) StrictMath.ceil(minimumSpacing / (double) sampleSpacing); + int searchSpacing = surface ? Math.max(minimumSpacing, planner.settings.maximumSurfaceSourceSpacing()) : minimumSpacing; + int latticeRadius = (int) StrictMath.ceil(searchSpacing / (double) sampleSpacing); for (int offsetZ = -latticeRadius; offsetZ <= latticeRadius; offsetZ++) { for (int offsetX = -latticeRadius; offsetX <= latticeRadius; offsetX++) { if (offsetX == 0 && offsetZ == 0) { @@ -512,7 +612,8 @@ final class HydrologySourcePlanner { } long deltaX = (long) offsetX * sampleSpacing; long deltaZ = (long) offsetZ * sampleSpacing; - if (StrictMath.hypot(deltaX, deltaZ) >= minimumSpacing) { + double separation = StrictMath.hypot(deltaX, deltaZ); + if (separation >= searchSpacing) { continue; } long neighborX = (long) candidate.x() + deltaX; @@ -530,6 +631,12 @@ final class HydrologySourcePlanner { throw new IllegalStateException("Source coordinate is absent from its owner routing lattice."); } HydrologyTerrainSample terrain = neighbor.terrain(); + int pairSpacing = surface + ? Math.max(minimumSpacing, terrain.surfacePolicy().sourceSpacing(sourceSettings.minimumSpacing())) + : minimumSpacing; + if (separation >= pairSpacing) { + continue; + } if (!rawSourceEligible(terrain, sourceSettings, surface)) { continue; } @@ -569,20 +676,24 @@ final class HydrologySourcePlanner { if (routing.outlets().isEmpty() || !Double.isFinite(routing.potential()[node.index()]) || routing.parent()[node.index()] < 0 - || !sourceOutletAllowed(node, routing, surface)) { + || !sourceOutletAllowed(node, context.grid(), routing, surface)) { return false; } int routeLength = routing.routeLengths()[node.index()]; - return routeLength >= planner.settings.routing().minimumCourseLength(surface) + return routeLength >= minimumCourseLength(node.terrain(), surface) && routeLength <= planner.settings.routing().maximumRouteLength(); } - boolean sourceOutletAllowed(HydrologyGridNode node, HydrologyRoutingPlan routing, boolean surface) { + boolean sourceOutletAllowed(HydrologyGridNode node, HydrologySampledGrid grid, HydrologyRoutingPlan routing, boolean surface) { int outletIndex = routing.outletIndex()[node.index()]; if (outletIndex < 0 || outletIndex >= routing.outlets().size()) { return false; } - RiverOutlet outlet = routing.outlets().get(outletIndex).outlet(); + OutletCandidate candidate = routing.outlets().get(outletIndex); + if (surface && !HydrologySurfaceProfiles.sharesProfile(node.terrain(), grid.node(candidate.landIndex()).terrain())) { + return false; + } + RiverOutlet outlet = candidate.outlet(); return !surface || outlet.type() != HydrologyFeatureType.INLAND_GROTTO || planner.settings.outlets().surfaceSinkholesEnabled(); @@ -598,7 +709,8 @@ final class HydrologySourcePlanner { } boolean allowed = surface ? terrain.surfaceSourceAllowed() : terrain.undergroundSourceAllowed(); boolean required = surface ? terrain.surfaceSourceRequired() : terrain.undergroundSourceRequired(); - if (!allowed || surface && terrain.naturalHeight() < sourceSettings.minimumElevation()) { + if (!allowed || surface && (terrain.naturalHeight() < sourceSettings.minimumElevation() + || terrain.surfacePolicy().sourceDensity() != null && terrain.surfacePolicy().sourceDensity() == 0D)) { return false; } if (!surface && planner.settings.underground().connectToExistingCaves() && !terrain.caveAvailable()) { @@ -729,7 +841,8 @@ final class HydrologySourcePlanner { HydrologyRoutingPlan routing, int[] surfaceContributions, int[] undergroundContributions, - Map> refinedEdges + Map> refinedEdges, + boolean surface ) { HashSet includedNodeIndices = new HashSet<>(); ArrayList edges = new ArrayList<>(); @@ -747,12 +860,13 @@ final class HydrologySourcePlanner { HydrologyGridNode downstream = grid.node(downstreamIndex); RiverOutlet outlet = routing.outlets().get(routing.outletIndex()[upstream.index()]).outlet(); long edgeId = HydrologyHash.mix(planner.worldSeed, EDGE_SALT, upstream.id(), downstream.id(), outlet.id()); - List centerline = refinedEdges.get(edgeId); + long refinementId = surface ? HydrologyHash.mix(edgeId, SURFACE_SOURCE_SALT) : edgeId; + List centerline = refinedEdges.get(refinementId); int transverseCandidates = HydrologyRouteGeometry.ROUTE_TRANSVERSE_CANDIDATES; if (centerline == null) { - HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream); - HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream); - HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet); + HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface); + HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface); + HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface); RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey( upstream.id(), downstream.id(), @@ -762,7 +876,8 @@ final class HydrologySourcePlanner { downstreamAnchor.z(), continuation.x(), continuation.z(), - transverseCandidates + transverseCandidates, + surface ); centerline = planner.refinedEdgeCache.get( refinedEdgeKey, @@ -772,15 +887,16 @@ final class HydrologySourcePlanner { upstreamAnchor, downstreamAnchor, continuation, - transverseCandidates + transverseCandidates, + surface ) ); - refinedEdges.put(edgeId, centerline); + refinedEdges.put(refinementId, centerline); } if (centerline.isEmpty()) { - HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream); - HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream); - HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet); + HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface); + HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface); + HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface); RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey( upstream.id(), downstream.id(), @@ -790,7 +906,8 @@ final class HydrologySourcePlanner { downstreamAnchor.z(), continuation.x(), continuation.z(), - HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES + HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES, + surface ); centerline = planner.refinedEdgeCache.get( refinedEdgeKey, @@ -800,17 +917,18 @@ final class HydrologySourcePlanner { upstreamAnchor, downstreamAnchor, continuation, - HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES + HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES, + surface ) ); - refinedEdges.put(edgeId, centerline); + refinedEdges.put(refinementId, centerline); } if (centerline.isEmpty()) { centerline = planner.routeGeometry.constrainedTerrainFallbackEdge( - planner.routeGeometry.routeAnchor(upstream), - planner.routeGeometry.routeAnchor(downstream) + planner.routeGeometry.routeAnchor(upstream, surface), + planner.routeGeometry.routeAnchor(downstream, surface) ); - refinedEdges.put(edgeId, centerline); + refinedEdges.put(refinementId, centerline); } if (centerline.isEmpty()) { continue; @@ -838,7 +956,7 @@ final class HydrologySourcePlanner { } int outletIndex = routing.outletIndex()[node.index()]; RiverOutlet outlet = routing.outlets().get(outletIndex).outlet(); - HydrologyPoint anchor = planner.routeGeometry.routeAnchor(node); + HydrologyPoint anchor = planner.routeGeometry.routeAnchor(node, surface); HydrologyTerrainSample terrain = Objects.requireNonNull( planner.sampleLandBasis(anchor.x(), anchor.z()), "Hydrology route anchor left natural land" diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceBudgets.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceBudgets.java new file mode 100644 index 000000000..6a042409c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceBudgets.java @@ -0,0 +1,106 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class HydrologySurfaceBudgets { + private static final long SOURCE_SALT = 0x41524541534f5552L; + private static final long INLAND_SALT = 0x41524541494e4c44L; + private static final long COASTAL_SALT = 0x41524541434f4153L; + + private final int ownedCells; + private final Map areas; + private final boolean overridden; + + private HydrologySurfaceBudgets(Sampling sampling) { + ownedCells = sampling.ownedCells(); + areas = sampling.areas(); + overridden = sampling.overridden(); + } + + static HydrologySurfaceBudgets sample(HydrologySampledGrid grid, HydrologyPlannerSettings.Source sources) { + LinkedHashMap areas = new LinkedHashMap<>(); + int ownedCells = 0; + boolean overridden = false; + for (HydrologyGridNode node : grid.nodes()) { + if (!grid.owns(node.x(), node.z())) { + continue; + } + ownedCells++; + HydrologyTerrainSample terrain = node.terrain(); + if (terrain.ocean() || !terrain.transitAllowed()) { + continue; + } + SurfaceRiverPolicy policy = terrain.surfacePolicy(); + overridden |= policy.overridden(); + Area area = areas.computeIfAbsent(policy.budget(), ignored -> new Area(policy)); + area.landCells++; + if (terrain.surfaceSourceAllowed() && terrain.naturalHeight() >= sources.minimumElevation() + && (terrain.surfaceSourceWeight() > 0D || terrain.surfaceSourceRequired())) { + area.sourceCells++; + } + } + return new HydrologySurfaceBudgets(new Sampling(ownedCells, areas, overridden)); + } + + boolean overridden() { + return overridden; + } + + List areas() { + return List.copyOf(areas.values()); + } + + Area area(SurfaceRiverPolicy policy) { + return area(policy.budget()); + } + + Area area(SurfaceRiverPolicy.Budget budget) { + return areas.get(budget); + } + + int sourceTarget(Area area, int outlets, HydrologyPlanner planner, HydrologyTileKey tile) { + double density = area.policy.sourceDensity(planner.settings.surface().sources().density()); + if (density <= 0D || area.sourceCells == 0) { + return 0; + } + int tributaries = area.policy.tributaries(planner.settings.routing().tributaries()); + double expected = (Math.min(64D, density) + outlets * tributaries) * fraction(area.sourceCells); + return count(expected, planner.worldSeed, tile, area.policy, SOURCE_SALT); + } + + int outletTarget(Area area, boolean coastal, HydrologyPlanner planner, HydrologyTileKey tile) { + int maximum = coastal + ? area.policy.coastalOutlets(planner.settings.outlets().maximumCoastalPerTile()) + : area.policy.inlandOutlets(planner.settings.outlets().maximumPerTile()); + return count(maximum * fraction(area.landCells), planner.worldSeed, tile, area.policy, + coastal ? COASTAL_SALT : INLAND_SALT); + } + + private double fraction(int cells) { + return ownedCells == 0 ? 0D : cells / (double) ownedCells; + } + + private static int count(double expected, long seed, HydrologyTileKey tile, SurfaceRiverPolicy policy, long salt) { + int whole = (int) StrictMath.floor(expected); + long stable = HydrologyHash.mix(seed, salt, tile.tileX(), tile.tileZ(), + HydrologyHash.text(policy.areaKey()), policy.budget().hashCode()); + return whole + (HydrologyHash.unit(stable) < expected - whole ? 1 : 0); + } + + static final class Area { + final SurfaceRiverPolicy policy; + int landCells; + int sourceCells; + + private Area(SurfaceRiverPolicy policy) { + this.policy = policy; + } + } + + private record Sampling(int ownedCells, Map areas, boolean overridden) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceCoursePlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceCoursePlanner.java index 20db4d9de..7c1904c12 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceCoursePlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceCoursePlanner.java @@ -59,12 +59,12 @@ final class HydrologySurfaceCoursePlanner { continue; } long courseId = HydrologyHash.mix(sourceCourseId, path.outlet().id()); - HydrologyPoint trunkPoint = path.points().getFirst(); - HydrologyTerrainSample trunkTerrain = Objects.requireNonNull( - planner.sampleDetailed(trunkPoint.x(), trunkPoint.z()), - "Hydrology surface trunk left sampled terrain" - ); - String profileKey = planner.segments.chooseProfile(trunkTerrain, path.outlet().id()); + String profileKey = HydrologySurfaceProfiles.chooseProfile(planner, path, sourceNode.terrain()); + if (profileKey == null) { + planner.undergroundCourses.addCompiledSourceDiagnostic(sourceNode, true, sourceCourseId, + HydrologyCandidateRejection.POLICY_EXCLUDED, diagnostics); + continue; + } draftsByOutlet.computeIfAbsent(path.outlet().id(), (Long ignored) -> new ArrayList<>()) .add(new SurfaceCourseDraft(sourceNode, courseId, profileKey, path)); } @@ -80,8 +80,9 @@ final class HydrologySurfaceCoursePlanner { HashMap mainRejections = new HashMap<>(); int tributaries = 0; for (SurfaceCourseDraft draft : outletDrafts) { - if (mainCourse != null && tributaries >= planner.settings.routing().tributaries()) { - break; + int tributaryLimit = draft.source().terrain().surfacePolicy().tributaries(planner.settings.routing().tributaries()); + if (mainCourse != null && tributaries >= tributaryLimit) { + continue; } if (mainCourse == null) { SurfaceCourseBuild candidate = buildSurfaceCourse( @@ -156,7 +157,8 @@ final class HydrologySurfaceCoursePlanner { profileKey, path.points(), terminal, - terminalHead + terminalHead, + planner.sourcePlanner.minimumCourseLength(source.terrain(), true) ); if (!result.accepted()) { return SurfaceCourseBuild.rejected(result.rejection(), result.rejectionDetail()); @@ -597,6 +599,8 @@ final class HydrologySurfaceCoursePlanner { ArrayList expanded = new ArrayList<>(layer); expanded.add(new RouteCandidate( new HydrologyPoint(fallbackX, estimate.height(), fallbackZ), + basePoint.x(), + basePoint.z(), offset, localScore, estimate.terrainScore(), @@ -646,10 +650,6 @@ final class HydrologySurfaceCoursePlanner { double surfaceRouteCandidateBankPenalty(RouteCandidate candidate) { int minimumBankDistance = surfaceBankDistance(planner.settings.surface().minimumWidth()); int maximumBankDistance = surfaceBankDistance(planner.settings.surface().maximumWidth()); - int maximumBankRise = Math.addExact( - planner.settings.surface().maximumIncision(), - (int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D) - ); HydrologyPoint point = candidate.point(); HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z()); if (terrain == null) { @@ -668,7 +668,14 @@ final class HydrologySurfaceCoursePlanner { minimumBankDistance, maximumBankDistance, head, - maximumBankRise + maximumSurfaceBankRise(terrain) + ); + } + + int maximumSurfaceBankRise(HydrologyTerrainSample terrain) { + return Math.addExact( + terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()), + (int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D) ); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceProfiles.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceProfiles.java new file mode 100644 index 000000000..eae4deaee --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologySurfaceProfiles.java @@ -0,0 +1,170 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.surface.SurfaceFootprint; +import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; + +final class HydrologySurfaceProfiles { + private HydrologySurfaceProfiles() { + } + + static boolean sharesProfile(HydrologyTerrainSample first, HydrologyTerrainSample second) { + if (first == null || second == null) { + return false; + } + for (String profile : first.preferredProfileKeys()) { + if (second.preferredProfileKeys().contains(profile)) { + return true; + } + } + return false; + } + + static String chooseProfile( + HydrologyPlanner planner, + HydrologyCoursePath path, + HydrologyTerrainSample source + ) { + if (path.points().isEmpty()) { + return null; + } + HydrologyPoint anchor = path.points().getFirst(); + HydrologyTerrainSample terrain = planner.sampleBasisWithoutSlope(anchor.x(), anchor.z()); + if (terrain == null) { + return null; + } + ArrayList profiles = new ArrayList<>(terrain.preferredProfileKeys()); + profiles.retainAll(source.preferredProfileKeys()); + if (!retainPathProfiles(planner, path.points(), profiles) + || !retainOutletProfiles(planner, path, profiles)) { + return null; + } + int index = HydrologyHash.between( + HydrologyHash.mix(planner.worldSeed, path.outlet().id(), HydrologyHash.text("profile")), + 0, + profiles.size() - 1 + ); + return profiles.get(index); + } + + static boolean allowsProfile(HydrologyPlanner planner, HydrologyCoursePath path, String profile) { + ArrayList profiles = new ArrayList<>(List.of(profile)); + return retainPathProfiles(planner, path.points(), profiles) + && retainOutletProfiles(planner, path, profiles); + } + + static boolean allowsProfile(HydrologyPlanner planner, List points, String profile) { + return retainPathProfiles(planner, points, new ArrayList<>(List.of(profile))); + } + + static void rejectExcludedWetFootprints( + HydrologyFootprintCompiler compiler, + List courses, + List diagnostics + ) { + HashSet rejectedOutlets = new HashSet<>(); + Iterator iterator = courses.iterator(); + while (iterator.hasNext()) { + RiverCourse course = iterator.next(); + if (course.type() != RiverCourseType.SURFACE) { + continue; + } + SurfaceFootprint footprint = compiler.surfaceFootprint(course); + int excluded = 0; + for (SurfaceLayerColumn column : footprint.columns()) { + HydrologyColumnLayer layer = column.layer(); + if (layer.fluidOwned() && layer.bedY() < layer.fluidHeadY() + && !column.terrain().preferredProfileKeys().contains(course.profileKey())) { + excluded++; + } + } + if (excluded == 0) { + continue; + } + iterator.remove(); + HydrologyFeatureType terminal = course.segments().getLast().type(); + if (terminal == HydrologyFeatureType.MOUTH || terminal == HydrologyFeatureType.COASTAL_GROTTO + || terminal == HydrologyFeatureType.INLAND_GROTTO) { + rejectedOutlets.add(course.outletId().orElseThrow()); + } + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(course.id(), HydrologySourcePlanner.DIAGNOSTIC_SALT, + HydrologyCandidateRejection.POLICY_EXCLUDED.ordinal()), + HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, + course.segments().getFirst().start(), + HydrologyCandidateRejection.POLICY_EXCLUDED, + excluded + )); + } + if (rejectedOutlets.isEmpty()) { + return; + } + iterator = courses.iterator(); + while (iterator.hasNext()) { + RiverCourse course = iterator.next(); + if (course.type() != RiverCourseType.SURFACE + || !rejectedOutlets.contains(course.outletId().orElseThrow())) { + continue; + } + iterator.remove(); + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(course.id(), HydrologySourcePlanner.DIAGNOSTIC_SALT, + HydrologyCandidateRejection.NO_DRAINAGE_PATH.ordinal()), + HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, + course.segments().getFirst().start(), + HydrologyCandidateRejection.NO_DRAINAGE_PATH, + 0 + )); + } + } + + private static boolean retainPathProfiles( + HydrologyPlanner planner, + List points, + ArrayList profiles + ) { + if (points.isEmpty() || !retainPointProfiles(planner, points.getFirst(), profiles)) { + return false; + } + for (int pointIndex = 1; pointIndex < points.size(); pointIndex++) { + for (HydrologyPoint point : planner.segments.rasterLine(points.get(pointIndex - 1), points.get(pointIndex))) { + if (!retainPointProfiles(planner, point, profiles)) { + return false; + } + } + } + return true; + } + + private static boolean retainOutletProfiles( + HydrologyPlanner planner, + HydrologyCoursePath path, + ArrayList profiles + ) { + if (!path.reachesOutlet()) { + return true; + } + RiverOutlet outlet = path.outlet(); + return retainPointProfiles(planner, outlet.landwardPoint(), profiles) + && (!outlet.directOcean() || retainPointProfiles(planner, outlet.connectionPoint(), profiles)); + } + + private static boolean retainPointProfiles( + HydrologyPlanner planner, + HydrologyPoint point, + ArrayList profiles + ) { + HydrologyTerrainSample terrain = planner.sampleBasisWithoutSlope(point.x(), point.z()); + if (terrain == null) { + return false; + } + profiles.retainAll(terrain.preferredProfileKeys()); + return !profiles.isEmpty(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java index 2382bb75a..adfd3ffe2 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java @@ -1,6 +1,9 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import java.util.List; +import java.util.Objects; public record HydrologyTerrainSample( int naturalHeight, @@ -34,7 +37,8 @@ public record HydrologyTerrainSample( double shoreBiomeWidth, String confinesKey, double shoreWidth, - boolean erosion + boolean erosion, + SurfaceRiverPolicy surfacePolicy ) { /** * {@code shoreBiomeWidth} is the width in blocks of the shore biome band beside a surface river at this @@ -45,6 +49,7 @@ public record HydrologyTerrainSample( * shore width applies; {@code erosion} is whether the ground beyond that bench is eroded into a valley here. */ public HydrologyTerrainSample { + surfacePolicy = Objects.requireNonNull(surfacePolicy); requireFiniteNonNegative(slope, "slope"); requireFiniteNonNegative(routingCost, "routingCost"); requireFiniteNonNegative(surfaceSourceWeight, "surfaceSourceWeight"); @@ -122,7 +127,8 @@ public record HydrologyTerrainSample( Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -159,7 +165,8 @@ public record HydrologyTerrainSample( Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -196,7 +203,8 @@ public record HydrologyTerrainSample( shoreBiomeWidth, confinesKey, shoreWidth, - erosion + erosion, + surfacePolicy ); } @@ -233,7 +241,8 @@ public record HydrologyTerrainSample( shoreBiomeWidth, confinesKey, shoreWidth, - erosion + erosion, + surfacePolicy ); } @@ -270,7 +279,8 @@ public record HydrologyTerrainSample( shoreBiomeWidth, confinesKey, replacementShoreWidth, - erosion + erosion, + surfacePolicy ); } @@ -307,7 +317,46 @@ public record HydrologyTerrainSample( shoreBiomeWidth, confinesKey, shoreWidth, - replacementErosion + replacementErosion, + surfacePolicy + ); + } + + public HydrologyTerrainSample withSurfacePolicy(SurfaceRiverPolicy replacementPolicy) { + return new HydrologyTerrainSample( + naturalHeight, + slope, + ocean, + caveAvailable, + caveFloorY, + caveFluidY, + transitAllowed, + outletAllowed, + surfaceSourceAllowed, + surfaceSourceRequired, + undergroundSourceAllowed, + undergroundSourceRequired, + routingCost, + surfaceSourceWeight, + undergroundSourceWeight, + widthMultiplier, + depthMultiplier, + incisionMultiplier, + routingMultiplier, + bankMultiplier, + parentBiomeKey, + surfaceBiomeKey, + mouthBiomeKey, + shoreBiomeKey, + bankBiomeKey, + floodedCaveBiomeKey, + preferredProfileKeys, + surfacePoolKeys, + shoreBiomeWidth, + confinesKey, + shoreWidth, + erosion, + replacementPolicy ); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTributaryPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTributaryPlanner.java index dc9539326..b8688b249 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTributaryPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTributaryPlanner.java @@ -44,7 +44,9 @@ final class HydrologyTributaryPlanner { } OutletCourseKey key = new OutletCourseKey(course.outletId().getAsLong()); RiverCourse canonicalCourse = canonicalCourses.get(key); - if (canonicalCourse == null || canonicalCourse.id() == course.id()) { + if (canonicalCourse == null || canonicalCourse.id() == course.id() + || (course.type() == RiverCourseType.SURFACE || canonicalCourse.type() == RiverCourseType.SURFACE) + && !course.profileKey().equals(canonicalCourse.profileKey())) { normalized.add(course); continue; } @@ -197,6 +199,13 @@ final class HydrologyTributaryPlanner { ) { HydrologyCoursePath path = draft.path(); HydrologyPoint sourcePoint = path.points().getFirst(); + if (!draft.source().terrain().preferredProfileKeys().contains(stem.profileKey()) + || !HydrologySurfaceProfiles.allowsProfile(planner, path, stem.profileKey())) { + addTributaryDiagnostic(draft.courseId(), sourcePoint, + HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0, diagnostics); + return null; + } + draft = new SurfaceCourseDraft(draft.source(), draft.courseId(), stem.profileKey(), path); HashSet stemEdges = new HashSet<>(); for (DrainageEdge edge : stem.drainageEdges()) { stemEdges.add(edge.id()); @@ -349,7 +358,6 @@ final class HydrologyTributaryPlanner { */ void levelApproach(List segments, int level) { int run = Math.max(1, planner.settings.surface().banks().cascadeRun()); - int maximumIncision = planner.settings.surface().maximumIncision(); double distance = 0D; HydrologyPoint previous = null; int downstreamHead = Integer.MIN_VALUE; @@ -369,7 +377,8 @@ final class HydrologyTributaryPlanner { settled = true; break; } - int floor = planner.sampleBasisWithoutSlope(point.x(), point.z()).naturalHeight() - maximumIncision; + HydrologyTerrainSample terrain = planner.sampleBasisWithoutSlope(point.x(), point.z()); + int floor = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain); int lowered = Math.min(point.y(), Math.max(Math.max(allowed, floor), downstreamHead)); if (lowered < point.y()) { centerline.set(pointIndex, new HydrologyPoint(point.x(), lowered, point.z())); @@ -444,6 +453,9 @@ final class HydrologyTributaryPlanner { if (last.x() != joinStation.x() || last.z() != joinStation.z()) { points.add(new HydrologyPoint(joinStation.x(), last.y(), joinStation.z())); } + if (!HydrologySurfaceProfiles.allowsProfile(planner, points, draft.profileKey())) { + return TributaryBuild.rejected(HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0); + } LinkedHashSet ownedEdges = new LinkedHashSet<>(); for (DrainageEdge edge : path.pairEdges().subList(0, joinIndex)) { if (!stemEdges.contains(edge.id())) { @@ -454,13 +466,14 @@ final class HydrologyTributaryPlanner { return TributaryBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, joinIndex); } int stemHead = joinStation.y(); - SurfaceCourseResult result = planner.tributaryCourseBuilder.build( + SurfaceCourseResult result = planner.surfaceCourseBuilder.build( planner.worldSeed, draft.courseId(), draft.profileKey(), List.copyOf(points), SurfaceTerminal.TRIBUTARY, - stemHead + stemHead, + planner.sourcePlanner.minimumCourseLength(draft.source().terrain(), true) / 2 ); if (!result.accepted()) { return TributaryBuild.rejected(result.rejection(), result.rejectionDetail()); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RefinedEdgeKey.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RefinedEdgeKey.java index 64cc2eaaf..e40630215 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/RefinedEdgeKey.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RefinedEdgeKey.java @@ -9,6 +9,7 @@ record RefinedEdgeKey( int downstreamZ, int continuationX, int continuationZ, - int transverseCandidates + int transverseCandidates, + boolean surface ) { } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RouteCandidate.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RouteCandidate.java index af7ca4ab9..743030251 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/RouteCandidate.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RouteCandidate.java @@ -2,6 +2,8 @@ package art.arcane.iris.engine.hydrology; record RouteCandidate( HydrologyPoint point, + double continuousX, + double continuousZ, double offset, double localScore, double terrainScore, diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/SourceAdmissionSelection.java b/core/src/main/java/art/arcane/iris/engine/hydrology/SourceAdmissionSelection.java index 012cd4d01..b42a4754b 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/SourceAdmissionSelection.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/SourceAdmissionSelection.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.hydrology; import java.util.List; +import java.util.Arrays; import java.util.function.IntPredicate; final class SourceAdmissionSelection { @@ -10,8 +11,7 @@ final class SourceAdmissionSelection { final boolean[] spacingRejectedCandidates; final boolean[] evaluatedCandidates; final IntPredicate globallyAdmitted; - final int[] outletIndices; - final int maximumCoursesPerOutlet; + final Quotas quotas; SourceAdmissionSelection( int targetCount, @@ -20,10 +20,9 @@ final class SourceAdmissionSelection { boolean[] spacingRejectedCandidates, boolean[] evaluatedCandidates, IntPredicate globallyAdmitted, - int[] outletIndices, - int maximumCoursesPerOutlet + Quotas quotas ) { - if (outletIndices.length != selectedCandidates.length || maximumCoursesPerOutlet < 1) { + if (quotas.outletIndices().length != selectedCandidates.length) { throw new IllegalArgumentException("Source outlet admission bounds are invalid."); } this.targetCount = targetCount; @@ -32,8 +31,7 @@ final class SourceAdmissionSelection { this.spacingRejectedCandidates = spacingRejectedCandidates; this.evaluatedCandidates = evaluatedCandidates; this.globallyAdmitted = globallyAdmitted; - this.outletIndices = outletIndices; - this.maximumCoursesPerOutlet = maximumCoursesPerOutlet; + this.quotas = quotas; } List selectedCandidateIndices() { @@ -61,13 +59,33 @@ final class SourceAdmissionSelection { } boolean outletQuotaAvailable(int candidateIndex, List selectedIndices) { - int outletIndex = outletIndices[candidateIndex]; + int outletIndex = quotas.outletIndices()[candidateIndex]; + int areaIndex = quotas.areaIndices()[candidateIndex]; int selectedForOutlet = 0; + int selectedForArea = 0; for (int selectedIndex : selectedIndices) { - if (outletIndices[selectedIndex] == outletIndex) { + if (quotas.outletIndices()[selectedIndex] == outletIndex) { selectedForOutlet++; } + if (quotas.areaIndices()[selectedIndex] == areaIndex) { + selectedForArea++; + } + } + return selectedForOutlet < quotas.outletLimits()[candidateIndex] + && selectedForArea < quotas.areaLimits()[areaIndex]; + } + + record Quotas(int[] outletIndices, int[] outletLimits, int[] areaIndices, int[] areaLimits) { + Quotas { + if (outletIndices.length != outletLimits.length || outletIndices.length != areaIndices.length) { + throw new IllegalArgumentException("Source quota arrays must match candidate count."); + } + } + + static Quotas uniform(int[] outlets, int target, int maximumCoursesPerOutlet) { + int[] limits = new int[outlets.length]; + Arrays.fill(limits, maximumCoursesPerOutlet); + return new Quotas(outlets, limits, new int[outlets.length], new int[]{target}); } - return selectedForOutlet < maximumCoursesPerOutlet; } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/SourceSelection.java b/core/src/main/java/art/arcane/iris/engine/hydrology/SourceSelection.java index 53fd811b6..79585a911 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/SourceSelection.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/SourceSelection.java @@ -9,7 +9,7 @@ final class SourceSelection { final boolean surface; final List candidates; final SourceAdmissionSelection admission; - private final int guaranteedMinimum; + private final int[] requiredMinimums; final int maximumOptionalRejections; final int targetCount; final ArrayList selectedCandidateIndices; @@ -21,11 +21,16 @@ final class SourceSelection { boolean surface, List candidates, SourceAdmissionSelection admission, - int guaranteedMinimum, + int[] requiredMinimums, int maximumOptionalRejections ) { - if (guaranteedMinimum < 0 || guaranteedMinimum > admission.selectedCandidateIndices().size()) { - throw new IllegalArgumentException("Guaranteed source minimum is outside the admission bounds."); + if (requiredMinimums.length != admission.quotas.areaLimits().length) { + throw new IllegalArgumentException("Required source areas must match the admission bounds."); + } + for (int area = 0; area < requiredMinimums.length; area++) { + if (requiredMinimums[area] < 0 || requiredMinimums[area] > admission.quotas.areaLimits()[area]) { + throw new IllegalArgumentException("Required source minimum is outside the area bounds."); + } } if (maximumOptionalRejections < 0) { throw new IllegalArgumentException("Maximum optional source rejections cannot be negative."); @@ -33,7 +38,7 @@ final class SourceSelection { this.surface = surface; this.candidates = List.copyOf(candidates); this.admission = admission; - this.guaranteedMinimum = guaranteedMinimum; + this.requiredMinimums = requiredMinimums.clone(); this.maximumOptionalRejections = maximumOptionalRejections; this.selectedCandidateIndices = new ArrayList<>(admission.selectedCandidateIndices()); this.targetCount = admission.targetCount(); @@ -55,10 +60,9 @@ final class SourceSelection { new boolean[0], new boolean[0], (int candidateIndex) -> false, - new int[0], - Integer.MAX_VALUE + SourceAdmissionSelection.Quotas.uniform(new int[0], 0, Integer.MAX_VALUE) ), - 0, + new int[]{0}, 0 ); } @@ -85,7 +89,6 @@ final class SourceSelection { boolean advanceAfterPublication(List acceptedCourses, HydrologySampledGrid grid) { Set acceptedSourceNodeIds = acceptedSourceNodeIds(acceptedCourses); - int acceptedRequired = 0; boolean changed = false; int selectedPosition = 0; while (selectedPosition < selectedCandidateIndices.size()) { @@ -95,7 +98,6 @@ final class SourceSelection { if (acceptedSourceNodeIds.contains(sourceNodeId)) { if (candidate.required()) { attemptedCandidates[candidateIndex] = true; - acceptedRequired++; } selectedPosition++; continue; @@ -108,8 +110,7 @@ final class SourceSelection { selectedCandidates[candidateIndex] = false; changed = true; } - int requiredVacancies = Math.max(0, guaranteedMinimum - acceptedRequired); - for (int vacancy = 0; vacancy < requiredVacancies; vacancy++) { + while (true) { int replacement = nextRequiredCandidate(); if (replacement < 0) { break; @@ -142,9 +143,17 @@ final class SourceSelection { } private int nextRequiredCandidate() { + int[] selectedRequired = new int[requiredMinimums.length]; + for (int candidateIndex : selectedCandidateIndices) { + if (candidates.get(candidateIndex).required()) { + selectedRequired[admission.quotas.areaIndices()[candidateIndex]]++; + } + } for (int candidateIndex = 0; candidateIndex < candidates.size(); candidateIndex++) { SourceCandidate candidate = candidates.get(candidateIndex); + int area = admission.quotas.areaIndices()[candidateIndex]; if (candidate.required() + && selectedRequired[area] < requiredMinimums[area] && !selectedCandidates[candidateIndex] && !attemptedCandidates[candidateIndex] && admission.outletQuotaAvailable(candidateIndex, selectedCandidateIndices)) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java b/core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java index 5b05d4a74..3bdefe475 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStore.java @@ -41,7 +41,7 @@ import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; final class StudioHydrologyTileStore { - private static final int SCHEMA_VERSION = 1; + private static final int SCHEMA_VERSION = 2; private static final long MAXIMUM_COMPRESSED_BYTES = 128L * 1024L * 1024L; private static final long MAXIMUM_DECOMPRESSED_BYTES = 512L * 1024L * 1024L; private static final Gson GSON = new GsonBuilder() diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java index 51ce7e31d..f4cdadb25 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java @@ -25,12 +25,14 @@ public record EffectiveRiverPolicy( Double shoreBiomeWidth, RiverConfinement confinement, Double shoreWidth, - Boolean erosion + Boolean erosion, + SurfaceRiverPolicy surfacePolicy ) { public EffectiveRiverPolicy { placement = Objects.requireNonNull(placement); routing = Objects.requireNonNull(routing); confinement = Objects.requireNonNull(confinement); + surfacePolicy = Objects.requireNonNull(surfacePolicy); if (shoreBiomeWidth != null && (!Double.isFinite(shoreBiomeWidth) || shoreBiomeWidth < 0D)) { throw new IllegalArgumentException("shoreBiomeWidth must be finite and non-negative."); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java index cca4f0806..afc920597 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java @@ -16,15 +16,17 @@ public final class RiverPolicyResolver { } public static EffectiveRiverPolicy resolve(IrisDimension dimension, IrisRegion region, IrisBiome biome) { - return resolve(dimension == null ? null : dimension.getRiverPolicy(), + return resolveState(dimension == null ? null : dimension.getRiverPolicy(), region == null ? null : region.getRiverPolicy(), - biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension)); + biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension), false, + AreaKeys.of(region, biome)).build(); } public static Resolution resolveWithStatus(IrisDimension dimension, IrisRegion region, IrisBiome biome) { State state = resolveState(dimension == null ? null : dimension.getRiverPolicy(), region == null ? null : region.getRiverPolicy(), - biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension), true); + biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension), true, + AreaKeys.of(region, biome)); return new Resolution(state.build(), state.complete); } @@ -46,15 +48,16 @@ public final class RiverPolicyResolver { IrisRiverPolicy biomePolicy, IrisData data ) { - return resolveState(dimensionPolicy, regionPolicy, biomePolicy, data, false).build(); + return resolveState(dimensionPolicy, regionPolicy, biomePolicy, data, false, + new AreaKeys("region", "biome")).build(); } private static State resolveState(IrisRiverPolicy dimensionPolicy, IrisRiverPolicy regionPolicy, - IrisRiverPolicy biomePolicy, IrisData data, boolean trackCompleteness) { + IrisRiverPolicy biomePolicy, IrisData data, boolean trackCompleteness, AreaKeys areas) { State state = new State(trackCompleteness); - state.apply(dimensionPolicy, RiverConfinement.REGION, data); - state.apply(regionPolicy, RiverConfinement.REGION, data); - state.apply(biomePolicy, RiverConfinement.BIOME, data); + state.apply(dimensionPolicy, RiverConfinement.REGION, data, ""); + state.apply(regionPolicy, RiverConfinement.REGION, data, areas.region()); + state.apply(biomePolicy, RiverConfinement.BIOME, data, areas.biome()); return state; } @@ -71,6 +74,13 @@ public final class RiverPolicyResolver { public record Resolution(EffectiveRiverPolicy policy, boolean complete) { } + private record AreaKeys(String region, String biome) { + static AreaKeys of(IrisRegion region, IrisBiome biome) { + return new AreaKeys("region:" + (region == null ? "" : region.getLoadKey()), + "biome:" + (biome == null ? "" : biome.getLoadKey())); + } + } + private static final class State { private final Runnable unresolvedReference; private boolean complete = true; @@ -93,6 +103,7 @@ public final class RiverPolicyResolver { private RiverConfinement confinement = RiverConfinement.NONE; private Double shoreWidth = null; private Boolean erosion = null; + private SurfaceRiverPolicy surfacePolicy = SurfaceRiverPolicy.INHERIT; private State(boolean trackCompleteness) { unresolvedReference = trackCompleteness ? this::unresolvedReference : null; @@ -102,10 +113,21 @@ public final class RiverPolicyResolver { * {@code scope} is the confinement a {@code confined: true} at this level binds courses to; {@code data} is the * pack whose version-content gate filters the biome selections (null skips filtering). */ - private void apply(IrisRiverPolicy policy, RiverConfinement scope, IrisData data) { + private void apply(IrisRiverPolicy policy, RiverConfinement scope, IrisData data, String areaKey) { if (policy == null) { return; } + SurfaceRiverPolicy declared = policy.surfacePolicy(areaKey); + if (declared.configured()) { + surfacePolicy = new SurfaceRiverPolicy(declared.overridden() ? areaKey : surfacePolicy.areaKey(), + declared.sourceDensity() == null ? surfacePolicy.sourceDensity() : declared.sourceDensity(), + declared.sourceSpacing() == null ? surfacePolicy.sourceSpacing() : declared.sourceSpacing(), + declared.tributaries() == null ? surfacePolicy.tributaries() : declared.tributaries(), + declared.inlandOutlets() == null ? surfacePolicy.inlandOutlets() : declared.inlandOutlets(), + declared.coastalOutlets() == null ? surfacePolicy.coastalOutlets() : declared.coastalOutlets(), + declared.minimumCourseLength() == null ? surfacePolicy.minimumCourseLength() : declared.minimumCourseLength(), + declared.maximumIncision() == null ? surfacePolicy.maximumIncision() : declared.maximumIncision()); + } if (policy.getShoreBiomeWidth() != null) { shoreBiomeWidth = policy.getShoreBiomeWidth(); } @@ -189,7 +211,8 @@ public final class RiverPolicyResolver { shoreBiomeWidth, confinement, shoreWidth, - erosion + erosion, + surfacePolicy ); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/SurfaceRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/SurfaceRiverPolicy.java new file mode 100644 index 000000000..a34aa9b01 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/SurfaceRiverPolicy.java @@ -0,0 +1,80 @@ +package art.arcane.iris.engine.hydrology.policy; + +import java.util.Objects; + +public record SurfaceRiverPolicy( + String areaKey, + Double sourceDensity, + Integer sourceSpacing, + Integer tributaries, + Integer inlandOutlets, + Integer coastalOutlets, + Integer minimumCourseLength, + Integer maximumIncision +) { + public static final SurfaceRiverPolicy INHERIT = new SurfaceRiverPolicy("", null, null, null, null, null, null, null); + + public SurfaceRiverPolicy { + areaKey = Objects.requireNonNull(areaKey); + if (sourceDensity != null && (!Double.isFinite(sourceDensity) || sourceDensity < 0D || sourceDensity > 64D)) { + throw new IllegalArgumentException("surfaceSourceDensity must be finite and between 0 and 64."); + } + requireRange(sourceSpacing, 0, 8192, "surfaceSourceSpacing"); + requireRange(tributaries, 0, 4, "surfaceTributaries"); + requireRange(inlandOutlets, 0, 256, "surfaceInlandOutlets"); + requireRange(coastalOutlets, 0, 64, "surfaceCoastalOutlets"); + requireRange(minimumCourseLength, 16, 4096, "surfaceMinimumCourseLength"); + requireRange(maximumIncision, 1, 32, "surfaceMaximumIncision"); + } + + public boolean overridden() { + return sourceDensity != null || sourceSpacing != null || tributaries != null + || inlandOutlets != null || coastalOutlets != null; + } + + public boolean configured() { + return overridden() || minimumCourseLength != null || maximumIncision != null; + } + + public Budget budget() { + return new Budget(areaKey, sourceDensity, sourceSpacing, tributaries, inlandOutlets, coastalOutlets); + } + + public int minimumCourseLength(int fallback) { + return minimumCourseLength == null ? fallback : minimumCourseLength; + } + + public int maximumIncision(int fallback) { + return maximumIncision == null ? fallback : maximumIncision; + } + + public double sourceDensity(double fallback) { + return sourceDensity == null ? fallback : sourceDensity; + } + + public int sourceSpacing(int fallback) { + return sourceSpacing == null ? fallback : sourceSpacing; + } + + public int tributaries(int fallback) { + return tributaries == null ? fallback : tributaries; + } + + public int inlandOutlets(int fallback) { + return inlandOutlets == null ? fallback : inlandOutlets; + } + + public int coastalOutlets(int fallback) { + return coastalOutlets == null ? fallback : coastalOutlets; + } + + private static void requireRange(Integer value, int minimum, int maximum, String name) { + if (value != null && (value < minimum || value > maximum)) { + throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum + "."); + } + } + + public record Budget(String areaKey, Double sourceDensity, Integer sourceSpacing, Integer tributaries, + Integer inlandOutlets, Integer coastalOutlets) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index ec30098e3..ef511859a 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -1,5 +1,8 @@ package art.arcane.iris.engine.hydrology.runtime; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; +import art.arcane.iris.engine.hydrology.HydrologyHash; + import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; @@ -73,6 +76,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.TreeMap; import java.nio.file.Path; import java.util.function.IntConsumer; import java.util.function.Predicate; @@ -645,7 +649,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable { policy.shoreBiomeWidth() == null ? Double.NaN : policy.shoreBiomeWidth(), confinesKey(policy.confinement(), region, biome), policy.shoreWidth() == null ? Double.NaN : policy.shoreWidth(), - policy.erosion() == null || policy.erosion() + policy.erosion() == null || policy.erosion(), + policy.surfacePolicy() ); return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(resolvedNaturalHeight, terrain); } @@ -1005,10 +1010,39 @@ public final class IrisHydrologyRuntime implements AutoCloseable { deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()), surfacePools(hydrology.getSurfacePools()), widestShoreBiomeWidth(dimension, data, banks.getShoreWidth()), - seaCaves(coastal) + seaCaves(coastal), + surfacePolicyBounds(dimension, data) ); } + static HydrologyPlannerSettings.SurfacePolicyBounds surfacePolicyBounds(IrisDimension dimension, DataProvider data) { + TreeMap policies = new TreeMap<>(); + addSurfacePolicy(policies, "dimension", dimension.getRiverPolicy()); + for (IrisRegion region : dimension.getAllRegions(data)) { + addSurfacePolicy(policies, "region:" + region.getLoadKey(), region.getRiverPolicy()); + } + for (IrisBiome biome : dimension.getAllBiomes(data)) { + addSurfacePolicy(policies, "biome:" + biome.getLoadKey(), biome.getRiverPolicy()); + } + long fingerprint = 0L; + int maximumSpacing = 0; + for (SurfaceRiverPolicy policy : policies.values()) { + fingerprint = HydrologyHash.mix(fingerprint, policy.hashCode(), HydrologyHash.text(policy.areaKey())); + maximumSpacing = Math.max(maximumSpacing, policy.sourceSpacing(0)); + } + return new HydrologyPlannerSettings.SurfacePolicyBounds(fingerprint, maximumSpacing); + } + + private static void addSurfacePolicy(Map policies, String key, IrisRiverPolicy policy) { + if (policy == null) { + return; + } + SurfaceRiverPolicy surface = policy.surfacePolicy(key); + if (surface.configured()) { + policies.put(key, surface); + } + } + /** * The widest shore band any policy of the dimension asks for, whether as a shore biome band or as a * geometric shore width, never below the dimension's geometric shore width, so the publication diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java index cb79baeeb..9b86aacf9 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java @@ -118,7 +118,7 @@ public final class ChannelProfileBuilder { } } double drop = center.naturalHeight() - lowest; - double allowance = Math.max(1D, surface.maximumIncision() / 2D); + double allowance = Math.max(1D, center.surfacePolicy().maximumIncision(surface.maximumIncision()) / 2D); return Math.max(0D, Math.min(1D, 1D - drop / allowance)); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java index 0df14d9af..c68d5905f 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java @@ -15,20 +15,17 @@ public final class SurfaceCourseBuilder { private final HydrologyTerrainSampler sampler; private final HydrologyGeometrySampler geometry; private final int seaLevel; - private final int minimumCourseLength; public SurfaceCourseBuilder( HydrologyPlannerSettings.Surface surface, HydrologyTerrainSampler sampler, HydrologyGeometrySampler geometry, - int seaLevel, - int minimumCourseLength + int seaLevel ) { this.surface = Objects.requireNonNull(surface, "surface"); this.sampler = Objects.requireNonNull(sampler, "sampler"); this.geometry = Objects.requireNonNull(geometry, "geometry"); this.seaLevel = seaLevel; - this.minimumCourseLength = minimumCourseLength; } public SurfaceCourseResult build( @@ -37,7 +34,8 @@ public final class SurfaceCourseBuilder { String profileKey, List path, SurfaceTerminal terminal, - int terminalHead + int terminalHead, + int minimumCourseLength ) { SurfaceCenterline centerline = SurfaceCenterline.densify(path); boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH; diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java index 7c94e3221..5b9de3b4f 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java @@ -36,6 +36,7 @@ public final class ValleyProfileSolver { int[] crossMin = new int[count]; int[] crossMax = new int[count]; int[] centerNatural = new int[count]; + int[] channelIncision = new int[count]; double[] incisionMultiplier = new double[count]; double roughness = surface.banks().roughness(); int exposed = count; @@ -52,6 +53,7 @@ public final class ValleyProfileSolver { break; } centerNatural[station] = center.naturalHeight(); + channelIncision[station] = center.surfacePolicy().maximumIncision(surface.maximumIncision()); incisionMultiplier[station] = center.incisionMultiplier(); double normalX = centerline.normalX(station); double normalZ = centerline.normalZ(station); @@ -127,14 +129,13 @@ public final class ValleyProfileSolver { // headwater keeps its natural head. // Every head set here is at or below the terrain-supported head, so the course stays non-rising. HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet(); - int channelIncision = surface.maximumIncision(); - int inletIncision = Math.max(channelIncision, inlet.maximumIncision()); int rampStart = exposed; if (terminal == SurfaceTerminal.OCEAN_MOUTH && inlet.length() > 0) { int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposed * inlet.courseFraction())); int inletStart = exposed; while (inletStart > 0 && exposed - inletStart < reach - && cutFits(inletStart - 1, seaLevel, channel, centerNatural, incisionMultiplier, inletIncision)) { + && cutFits(inletStart - 1, seaLevel, channel, centerNatural, incisionMultiplier, + Math.max(channelIncision[inletStart - 1], inlet.maximumIncision()))) { inletStart--; } for (int station = inletStart; station < exposed; station++) { @@ -148,7 +149,8 @@ public final class ValleyProfileSolver { if (head[station] <= target) { continue; } - if (!cutFits(station, target, channel, centerNatural, incisionMultiplier, inletIncision)) { + if (!cutFits(station, target, channel, centerNatural, incisionMultiplier, + Math.max(channelIncision[station], inlet.maximumIncision()))) { break; } head[station] = target; @@ -163,7 +165,8 @@ public final class ValleyProfileSolver { int deepestCut = 0; boolean rejected = false; for (int station = 0; station < exposed; station++) { - int maximumIncision = station >= rampStart ? inletIncision : channelIncision; + int maximumIncision = station >= rampStart + ? Math.max(channelIncision[station], inlet.maximumIncision()) : channelIncision[station]; int cut = cut(station, head[station], channel, centerNatural); deepestCut = Math.max(deepestCut, cut); rejected |= cut > permitted(station, incisionMultiplier, maximumIncision); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java index a5f2ec507..07581504c 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java @@ -29,6 +29,7 @@ import java.util.concurrent.TimeoutException; public interface MatterGenerator { MultiBurst DISPATCHER = MultiBurst.burst; ConcurrentHashMap IN_FLIGHT_COMPONENTS = new ConcurrentHashMap<>(); + int COMPONENT_SUBMISSION_BATCH_SIZE = Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors())); long COMPONENT_TASK_POLL_MS = 1000L; long COMPONENT_TASK_TIMEOUT_MS = Long.getLong("iris.mantle.componentTimeout", 120000L); @@ -103,6 +104,9 @@ public interface MatterGenerator { // close() releases the cached chunks, even when a pass throws, or detached pool threads // write into released chunks. List outstandingTasks = null; + List submittedTasks = multicore && !DISPATCHER.ownsCurrentThread() + ? new ArrayList<>(COMPONENT_SUBMISSION_BATCH_SIZE) + : null; try { for (MatterPassPlan passPlan : passPlans) { @@ -223,7 +227,11 @@ public interface MatterGenerator { if (asyncComponents) { for (int componentIndex = 0; componentIndex < eligibleComponentCount; componentIndex++) { MantleComponent component = eligibleComponents[componentIndex]; - outstandingTasks.add(runComponentAsync(chunk, component, writer, finalPassX, finalPassZ, context)); + outstandingTasks.add(runComponentAsync( + chunk, component, writer, finalPassX, finalPassZ, context, submittedTasks)); + if (submittedTasks != null && submittedTasks.size() >= COMPONENT_SUBMISSION_BATCH_SIZE) { + awaitComponentTasks(submittedTasks); + } } } else { for (int componentIndex = 0; componentIndex < eligibleComponentCount; componentIndex++) { @@ -236,6 +244,9 @@ public interface MatterGenerator { if (asyncComponents) { awaitComponentTasks(outstandingTasks); + if (submittedTasks != null) { + submittedTasks.clear(); + } } } @@ -365,7 +376,8 @@ public interface MatterGenerator { MantleWriter writer, int chunkX, int chunkZ, - ChunkContext context + ChunkContext context, + List submittedTasks ) { MantleFlag flag = component.getFlag(); MatterTaskKey key = new MatterTaskKey(getMantle(), chunkX, chunkZ, flag); @@ -397,6 +409,7 @@ public interface MatterGenerator { chunkZ, context, callerContext))); + submittedTasks.add(task); return task; } catch (Throwable throwable) { task.cancel(throwable); 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 8b1e27629..93d639085 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 @@ -76,6 +76,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeSet; +import java.util.UUID; import java.util.regex.Pattern; @Accessors(chain = true) @@ -499,6 +500,35 @@ public class IrisDimension extends IrisRegistrant { return focusRegion; } + public IrisRegion resolveFocusRegion(IrisBiome biome, DataProvider provider) { + String biomeKey = Objects.requireNonNull(biome, "focus biome").getLoadKey(); + KList available = getAllRegions(provider); + for (IrisRegion region : available) { + if (region.getAllBiomeIds().contains(biomeKey)) { + return region; + } + } + for (IrisRegion region : available) { + for (IrisBiome member : region.getAllBiomes(provider)) { + if (biomeKey.equals(member.getLoadKey())) { + return region; + } + } + } + + String key = "iris-focus/" + UUID.nameUUIDFromBytes((getLoadKey() + "\u0000" + biomeKey) + .getBytes(StandardCharsets.UTF_8)); + IrisData data = provider.getData(); + IrisRegion region = new IrisRegion(); + region.getLandBiomes().add(biomeKey); + region.getSeaBiomes().add(biomeKey); + region.getShoreBiomes().add(biomeKey); + region.setLoadKey(key); + region.setLoader(data); + region.setLoadFile(new File(data.getDataFolder(), data.getRegionLoader().getFolderName() + "/" + key + ".json")); + return region; + } + public double sinRotate() { return sinr.aquire(() -> Math.sin(getDimensionAngle())); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index bddb10265..843c7d390 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -301,20 +301,20 @@ public class IrisRegion extends IrisRegistrant implements IRare { } public KList getAllBiomes(DataProvider g) { - return resolveBiomes(g, getAllBiomeIds()); + return resolveBiomes(g, getAllBiomeIds(), true); } public KList getNaturalBiomes(DataProvider g) { - return resolveBiomes(g, getNaturalBiomeIds()); + return resolveBiomes(g, getNaturalBiomeIds(), false); } - private KList resolveBiomes(DataProvider g, KSet biomeIds) { + private KList resolveBiomes(DataProvider g, KSet biomeIds, boolean includeRiverBiomes) { KMap b = new KMap<>(); KSet names = biomeIds.copy(); while (!names.isEmpty()) { for (String i : new KList<>(names)) { - if (b.containsKey(i)) { + if (i == null || i.isBlank() || b.containsKey(i)) { names.remove(i); continue; } @@ -334,6 +334,9 @@ public class IrisRegion extends IrisRegistrant implements IRare { names.add(biome.getCarvingBiome()); b.put(biome.getLoadKey(), biome); names.addAll(biome.getChildren()); + if (includeRiverBiomes && biome.getRiverPolicy() != null) { + names.addAll(biome.getRiverPolicy().getAllBiomeIds()); + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java index ebacc571f..c4ef51753 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java @@ -5,6 +5,7 @@ import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; import art.arcane.iris.engine.object.annotations.RegistryListResource; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; @@ -17,6 +18,41 @@ import lombok.experimental.Accessors; @Desc("Nullable river policy values inherited from dimension to region to biome.") @Data public class IrisRiverPolicy { + @MinNumber(0) + @MaxNumber(64) + @Desc("Expected surface headwaters per full tile of eligible policy area. Null inherits the dimension source density.") + private Double surfaceSourceDensity = null; + + @MinNumber(0) + @MaxNumber(8192) + @Desc("Minimum surface headwater separation in blocks. Two areas use the larger separation at their boundary.") + private Integer surfaceSourceSpacing = null; + + @MinNumber(0) + @MaxNumber(4) + @Desc("Extra surface tributaries admitted to one outlet from this policy area.") + private Integer surfaceTributaries = null; + + @MinNumber(0) + @MaxNumber(256) + @Desc("Maximum inland surface outlets per full tile of eligible policy area. Null inherits the dimension outlet limit.") + private Integer surfaceInlandOutlets = null; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Maximum coastal surface outlets per full tile of eligible policy area. Null inherits the dimension coastal outlet limit.") + private Integer surfaceCoastalOutlets = null; + + @MinNumber(16) + @MaxNumber(4096) + @Desc("Minimum surface river course length in blocks for headwaters in this area. Null inherits the dimension routing minimum.") + private Integer surfaceMinimumCourseLength = null; + + @MinNumber(1) + @MaxNumber(32) + @Desc("Maximum surface river incision in blocks in this area, before the incision multiplier. Null inherits the dimension channel limit.") + private Integer surfaceMaximumIncision = null; + @Desc("Controls source and transit admission in this area.") private IrisRiverPlacementMode placement = null; @@ -27,7 +63,7 @@ public class IrisRiverPolicy { private Boolean outletAdmission = null; @ArrayType(type = String.class) - @Desc("Preferred dimension-owned river profile identifiers. An empty list explicitly clears inherited profiles.") + @Desc("Permitted dimension-owned profiles along surface routes and preferred profiles for underground rivers. An empty list clears inheritance and resolves to the first configured dimension profile.") private KList profiles = null; @RegistryListResource(IrisBiome.class) @@ -100,6 +136,11 @@ public class IrisRiverPolicy { @Desc("Rivers that start in this area stay inside it: their whole course and their outlet lie in this region (when set on a region) or this biome (when set on a biome), and a river that flows into the area never leaves it. A source with no reachable outlet inside the area is rejected. Null inherits, false releases the area.") private Boolean confined = null; + public SurfaceRiverPolicy surfacePolicy(String areaKey) { + return new SurfaceRiverPolicy(areaKey, surfaceSourceDensity, surfaceSourceSpacing, + surfaceTributaries, surfaceInlandOutlets, surfaceCoastalOutlets, surfaceMinimumCourseLength, surfaceMaximumIncision); + } + /** * A declared river biome selection with biomes the version-content gate excluded removed. A declared-but-now-empty * selection keeps its "explicitly clears the inherited selection" meaning; null (undeclared) stays null. 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 bddedbb26..eb4dedb48 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,14 +22,19 @@ public class IrisStructureTerrain { private static final double MAX_EROSION_FREQUENCY = 1D; private static final double MAX_LOBE_FREQUENCY = 1D; - @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. VACUUM raises terrain from processed rigid-template foundations at or below each authored ground plane with a 12-block falloff without lowering existing ground. BORE and FORCE_CARVE clear the requested envelope, while ENCASE fills it before placement so native shells are not lost to pre-carved air.") + @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. FLATTEN cuts and fills exposed native foundations within flattenRange, blending across horizontalPadding blocks. VACUUM raises terrain from processed rigid-template foundations with a fixed 12-block falloff without lowering ground. BORE and FORCE_CARVE clear the requested envelope, while ENCASE fills it before placement.") private IrisStructureTerrainMode mode = IrisStructureTerrainMode.SOURCE; @MinNumber(0) @MaxNumber(128) - @Desc("Horizontal clearance around the assembled pieces for BORE, FORCE_CARVE, and ENCASE. VACUUM uses its fixed 12-block terrain falloff.") + @Desc("Horizontal clearance for BORE, FORCE_CARVE, and ENCASE, or the terrain blend distance for FLATTEN, in blocks. VACUUM uses its fixed 12-block terrain falloff.") private int horizontalPadding = 0; + @MinNumber(0) + @MaxNumber(128) + @Desc("Maximum vertical cut, fill, and foundation support depth for FLATTEN, in blocks. Other terrain modes ignore this setting.") + private int flattenRange = 64; + @MinNumber(0) @MaxNumber(128) @Desc("Air clearance above the assembled pieces.") @@ -74,6 +79,10 @@ public class IrisStructureTerrain { return shape == null ? IrisStructureCarveShape.BOX : shape; } + public int resolvedFlattenRange() { + return Math.max(0, Math.min(128, flattenRange)); + } + public double resolvedErosionStrength() { if (!Double.isFinite(erosionStrength)) { return DEFAULT_EROSION_STRENGTH; 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 1cd27a61e..38ed17e59 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 @@ -19,6 +19,9 @@ public enum IrisStructureTerrainMode { @Desc("Raises surface terrain from processed solid rigid-template foundations at or below each authored ground plane with a 12-block falloff, even when the registered structure has no terrain adaptation. Existing higher terrain and authored air remain untouched.") VACUUM, + @Desc("Cuts and fills exposed native structure foundations within flattenRange, blending into surrounding terrain across horizontalPadding blocks. Buried and submerged pieces retain their terrain.") + FLATTEN, + @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.") ENCASE } 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 708ff1656..b66f29c31 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 @@ -54,7 +54,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. VACUUM raises surface terrain from processed solid rigid-template foundations at or below each authored ground plane with a 12-block falloff without lowering existing ground. BORE and FORCE_CARVE clear every intersecting chunk before native pieces are placed, while ENCASE fills it with solid blocks instead. Left unset, SOURCE replays the registered structure's authored terrain adaptation, including surface fitting, burial, and encapsulation.") + @Desc("Optional terrain integration override. FLATTEN cuts and fills exposed surface foundations within flattenRange, blending across horizontalPadding blocks while preserving buried and submerged structures. VACUUM raises processed rigid-template foundations with a fixed 12-block falloff without lowering ground. BORE and FORCE_CARVE clear every intersecting chunk before native placement, while ENCASE fills it. Left unset, SOURCE replays the registered structure's authored terrain adaptation.") private IrisStructureTerrain terrain = null; public boolean matches(String key) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java b/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java index b96769fd8..706b8e5da 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/StudioMode.java @@ -52,15 +52,24 @@ public enum StudioMode { @Desc("Replaces terrain with the object studio: a flat polished-deepslate floor laying every pack object out on framed, end-rod-marked grid plinths. Bukkit studio worlds only.") OBJECT_BUFFET; + public int biomeSizeChunks() { + return switch (this) { + case BIOME_BUFFET_1x1 -> 1; + case BIOME_BUFFET_3x3 -> 3; + case BIOME_BUFFET_5x5 -> 5; + case BIOME_BUFFET_9x9 -> 9; + case BIOME_BUFFET_18x18 -> 18; + case BIOME_BUFFET_36x36 -> 36; + default -> 0; + }; + } + public void inject(BukkitChunkGenerator c) { switch (this) { case NORMAL, REGION_BUFFET -> c.setStudioGenerator(null); - case BIOME_BUFFET_1x1 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 1)); - case BIOME_BUFFET_3x3 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 3)); - case BIOME_BUFFET_5x5 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 5)); - case BIOME_BUFFET_9x9 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 9)); - case BIOME_BUFFET_18x18 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 18)); - case BIOME_BUFFET_36x36 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 36)); + case BIOME_BUFFET_1x1, BIOME_BUFFET_3x3, BIOME_BUFFET_5x5, + BIOME_BUFFET_9x9, BIOME_BUFFET_18x18, BIOME_BUFFET_36x36 -> + c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine())); case OBJECT_BUFFET -> c.setStudioGenerator(new ObjectStudioGenerator(c.getEngine())); } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index dc00cf9dc..798ff2615 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -103,7 +103,9 @@ import java.util.Locale; import java.util.Objects; import java.util.Random; import java.util.UUID; +import java.util.concurrent.CancellationException; 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; @@ -111,7 +113,6 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BooleanSupplier; -import java.util.function.Supplier; @EqualsAndHashCode(callSuper = true) @Data @@ -136,6 +137,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private final CompletableFuture initialSpawnReady = new CompletableFuture<>(); private final AtomicCache targetCache = new AtomicCache<>(); private final AtomicReference> closeFuture = new AtomicReference<>(); + private final AtomicReference> watcherHotload = new AtomicReference<>(); private volatile Engine engine; private volatile Looper hotloader; private volatile StudioMode lastMode; @@ -749,6 +751,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return HOTLOAD_LOOP_DELAY_MS; } + + @Override + protected void onError(Throwable failure) { + if (failure instanceof CancellationException && (closing || isInterrupted())) { + return; + } + super.onError(failure); + } } : null; if (hotloader != null) { @@ -822,6 +832,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun boolean alreadyClosing = closing; closing = true; + cancelWatcherHotload(); CompletableFuture operation; try { operation = withExclusiveControlFuture(() -> { @@ -873,6 +884,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public void quiesceForServerShutdown() { + cancelWatcherHotload(); Looper activeHotloader = hotloader; hotloader = null; if (activeHotloader != null) { @@ -944,7 +956,36 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { return; } - withExclusiveControlFuture(() -> getEngine().hotload(), 30L, TimeUnit.SECONDS).join(); + CompletableFuture pending = new CompletableFuture<>(); + watcherHotload.set(pending); + try { + if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive) + || Thread.currentThread().isInterrupted()) { + pending.cancel(true); + } else { + J.a(() -> completeExclusiveControlFuture(loadLock, () -> { + if (shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + getEngine().hotload(); + } + }, pending)); + } + pending.get(); + } catch (InterruptedException failure) { + pending.cancel(true); + Thread.currentThread().interrupt(); + throw new CancellationException("Studio hotload watcher interrupted."); + } catch (ExecutionException failure) { + throw new CompletionException(failure.getCause()); + } finally { + watcherHotload.compareAndSet(pending, null); + } + } + + private void cancelWatcherHotload() { + CompletableFuture pending = watcherHotload.getAndSet(null); + if (pending != null) { + pending.cancel(true); + } } @Override @@ -964,80 +1005,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return loadLock.acquireStage(operation); } - public GenerationStagePermit acquireNoiseGenerationStage( - Engine expectedEngine, - int chunkX, - int chunkZ, - String operation - ) { - Engine activeEngine = Objects.requireNonNull(expectedEngine, "Noise generation engine"); - return acquirePreparedGenerationStage( - loadLock, - operation, - () -> resolveStudioGeneratorForNoise(activeEngine), - activeEngine, - chunkX, - chunkZ); - } - - static GenerationStagePermit acquirePreparedGenerationStage( - GenerationStageGate gate, - String operation, - Supplier resolver, - Engine engine, - int chunkX, - int chunkZ - ) { - GenerationStageGate activeGate = Objects.requireNonNull(gate, "Generation stage gate"); - Supplier activeResolver = Objects.requireNonNull(resolver, "Studio generator resolver"); - Engine activeEngine = Objects.requireNonNull(engine, "Prepared generation engine"); - while (true) { - GenerationStagePermit stage = activeGate.acquireStage(operation); - StudioGenerator selected; - boolean retained = false; - try { - selected = activeResolver.get(); - if (selected == null || !selected.requiresPreSessionPreparation()) { - if (activeResolver.get() == selected) { - retained = true; - return stage; - } - continue; - } - } finally { - if (!retained) { - stage.close(); - } - } - - GenerationStageExclusivePermit exclusive = activeGate.acquireExclusiveStage(operation); - try { - StudioGenerator active = activeResolver.get(); - if (active != selected) { - continue; - } - active.prepareChunkBeforeSession(activeEngine, chunkX, chunkZ); - return exclusive.downgradeToStage(); - } catch (WrongEngineBroException e) { - throw new IllegalStateException("Iris generation stage " + operation - + " could not prepare its Studio generator.", e); - } finally { - exclusive.close(); - } - } - } - - private StudioGenerator resolveStudioGeneratorForNoise(Engine expectedEngine) { - if (engine != expectedEngine) { - throw new IllegalStateException("Iris noise generation belongs to a replaced engine runtime."); - } - StudioGenerator selected = computeStudioGenerator(); - if (engine != expectedEngine) { - throw new IllegalStateException("Iris noise generation changed engine runtime during Studio resolution."); - } - return selected; - } - public void withExclusiveControl(Runnable r) { J.a(() -> { boolean acquired = false; @@ -1087,15 +1054,34 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun GenerationStageGate activeGate = Objects.requireNonNull(gate, "Exclusive control gate"); Runnable activeOperation = Objects.requireNonNull(operation, "Exclusive control operation"); CompletableFuture outward = Objects.requireNonNull(future, "Exclusive control future"); + if (outward.isCancelled()) { + return; + } + ExclusiveControlAcquisition acquisition = new ExclusiveControlAcquisition(Thread.currentThread()); + outward.whenComplete((ignored, completionFailure) -> { + if (outward.isCancelled()) { + acquisition.cancel(); + } + }); boolean acquired = false; Throwable failure = null; try { - activeGate.acquireExclusive(); - acquired = true; - activeOperation.run(); + try { + activeGate.acquireExclusive(); + acquired = true; + } finally { + acquisition.stopWaiting(); + } + if (!outward.isCancelled()) { + activeOperation.run(); + } } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - failure = e; + if (!acquisition.interruptedByCancellation()) { + Thread.currentThread().interrupt(); + } + if (!outward.isCancelled()) { + failure = e; + } } catch (Throwable e) { failure = e; } finally { @@ -1106,8 +1092,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (failure == null) { outward.complete(null); - } else { - outward.completeExceptionally(failure); + } else if (!outward.completeExceptionally(failure)) { + IrisLogging.reportError(failure); } } @@ -1436,25 +1422,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return new GenerationStagePermit(permits); } - GenerationStageExclusivePermit acquireExclusiveStage(String operation) { - String activeOperation = Objects.requireNonNull(operation, "Generation stage operation"); - if (closing.getAsBoolean()) { - throw rejected(activeOperation); - } - try { - permits.acquire(permitCount); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Iris generation stage " + activeOperation - + " was interrupted while waiting for exclusive engine access.", e); - } - if (closing.getAsBoolean()) { - permits.release(permitCount); - throw rejected(activeOperation); - } - return new GenerationStageExclusivePermit(permits, permitCount); - } - void acquireExclusive() throws InterruptedException { permits.acquire(permitCount); } @@ -1513,36 +1480,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } - static final class GenerationStageExclusivePermit implements AutoCloseable { - private final Semaphore permits; - private final int permitCount; - private final AtomicBoolean released; - - private GenerationStageExclusivePermit(Semaphore permits, int permitCount) { - this.permits = permits; - this.permitCount = permitCount; - this.released = new AtomicBoolean(false); - } - - GenerationStagePermit downgradeToStage() { - if (!released.compareAndSet(false, true)) { - throw new IllegalStateException("Exclusive Iris generation stage was already released."); - } - GenerationStagePermit stage = new GenerationStagePermit(permits); - if (permitCount > 1) { - permits.release(permitCount - 1); - } - return stage; - } - - @Override - public void close() { - if (released.compareAndSet(false, true)) { - permits.release(permitCount); - } - } - } - public static final class GenerationStagePermit implements AutoCloseable { private static final GenerationStagePermit NOOP = new GenerationStagePermit(null); diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/BiomeBuffetLayout.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/BiomeBuffetLayout.java new file mode 100644 index 000000000..eff99f50d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/BiomeBuffetLayout.java @@ -0,0 +1,57 @@ +package art.arcane.iris.engine.platform.studio; + +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.util.common.data.DataProvider; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class BiomeBuffetLayout { + private final List cells; + private final int width; + private final int biomeSizeChunks; + + public BiomeBuffetLayout(IrisDimension dimension, DataProvider provider) { + biomeSizeChunks = dimension.getStudioMode().biomeSizeChunks(); + if (biomeSizeChunks == 0) { + throw new IllegalArgumentException("The dimension does not select a biome buffet."); + } + List biomes = new ArrayList<>(dimension.getAllBiomes(provider)); + biomes.removeIf(IrisBiome::isCompatExcluded); + biomes.sort(Comparator.comparing(IrisBiome::getLoadKey)); + List prepared = new ArrayList<>(biomes.size()); + for (IrisBiome biome : biomes) { + IrisBiome focused = biome.withInferredType(InferredType.LAND); + prepared.add(new Cell(focused, dimension.resolveFocusRegion(focused, provider))); + } + cells = List.copyOf(prepared); + width = Math.max((int) Math.sqrt(cells.size()), 1); + } + + public List cells() { + return cells; + } + + public Cell chunk(int chunkX, int chunkZ) { + int x = Math.floorDiv(chunkX, biomeSizeChunks); + int z = Math.floorDiv(chunkZ, biomeSizeChunks); + if (x < 0 || x >= width || z < 0) { + return null; + } + long index = (long) z * width + x; + return index < cells.size() ? cells.get((int) index) : null; + } + + public Cell terrain(double blockX, double blockZ) { + Cell cell = chunk(Math.floorDiv((int) Math.floor(blockX), 16), + Math.floorDiv((int) Math.floor(blockZ), 16)); + return cell == null && !cells.isEmpty() ? cells.getFirst() : cell; + } + + public record Cell(IrisBiome biome, IrisRegion region) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java index 13fc9bb8b..bda46bed0 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java @@ -23,12 +23,5 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.WrongEngineBroException; public interface StudioGenerator { - default boolean requiresPreSessionPreparation() { - return false; - } - - default void prepareChunkBeforeSession(Engine engine, int x, int z) throws WrongEngineBroException { - } - void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException; } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java index d5f0b17a5..6dbae6fb9 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java @@ -18,52 +18,27 @@ package art.arcane.iris.engine.platform.studio.generators; -import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.WrongEngineBroException; -import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.platform.studio.BiomeBuffetLayout; import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator; -import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.BoundBlockState; import art.arcane.iris.util.project.context.IrisContext; -import java.util.Objects; - public class BiomeBuffetGenerator extends EnginedStudioGenerator { private static final BoundBlockState FLOOR = BoundBlockState.of("BARRIER"); - private final IrisBiome[] biomes; - private final int width; - private final int biomeSize; + private final BiomeBuffetLayout layout; - public BiomeBuffetGenerator(Engine engine, int biomeSize) { + public BiomeBuffetGenerator(Engine engine) { super(engine); - this.biomeSize = biomeSize; - biomes = engine.getDimension().getAllBiomes(engine).toArray(new IrisBiome[0]); - width = Math.max((int) Math.sqrt(biomes.length), 1); + layout = new BiomeBuffetLayout(engine.getDimension(), engine); } @Override - public boolean requiresPreSessionPreparation() { - return true; - } - - @Override - public synchronized void prepareChunkBeforeSession(Engine engine, int x, int z) { - IrisBiome biome = biomeAt(x, z); - if (biome == null || Objects.equals(engine.getDimension().getFocus(), biome.getLoadKey())) { - return; - } - - engine.getDimension().setFocus(biome.getLoadKey()); - engine.hotloadComplex(); - } - - @Override - public synchronized void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException { - IrisBiome biome = biomeAt(x, z); - if (biome == null) { + public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException { + if (layout.chunk(x, z) == null) { try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_biome_buffet_stage"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { tc.setRegion(0, 0, 0, 16, 1, 16, FLOOR.get()); @@ -71,16 +46,10 @@ public class BiomeBuffetGenerator extends EnginedStudioGenerator { return; } - prepareChunkBeforeSession(engine, x, z); - try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_biome_buffet_stage"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { engine.generate(x << 4, z << 4, tc, true); } } - private IrisBiome biomeAt(int x, int z) { - int id = Cache.to1D(x / biomeSize, 0, z / biomeSize, width, 1); - return id < 0 || id >= biomes.length ? null : biomes[id]; - } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java index 64d1038f7..024e7032f 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java @@ -28,6 +28,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.TileData; import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator; import art.arcane.iris.platform.bukkit.BukkitBiome; import art.arcane.iris.platform.bukkit.BukkitBlockState; @@ -42,7 +43,9 @@ import art.arcane.iris.util.common.math.IrisBlockVector; import art.arcane.iris.util.project.context.IrisContext; import java.io.File; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -118,6 +121,7 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator { int maxHeight = engine.getMaxHeight(); int plinthY = floorY + 1; + List tiles = new ArrayList<>(); for (GridCell cell : currentLayout.cells()) { int frameMinX = cell.originX() - 1; @@ -132,9 +136,12 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator { DisplayObject object = loadObject(cell); if (object != null) { - placeSlice(object, cell, tc, chunkWorldX, chunkWorldZ, minHeight, maxHeight); + placeSlice(object, cell, tc, chunkWorldX, chunkWorldZ, minHeight, maxHeight, tiles); } } + if (!tiles.isEmpty()) { + ObjectStudioSaveService.get().queueTiles(engine, new ChunkTiles(x, z, List.copyOf(tiles))); + } } private void paintFrame(GridCell cell, TerrainChunk tc, int plinthY, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) { @@ -196,7 +203,7 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator { } } - private void placeSlice(DisplayObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) { + private void placeSlice(DisplayObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight, List tiles) { VectorMap blocks = object.source().getBlocks(); if (blocks == null || blocks.isEmpty()) return; @@ -218,6 +225,10 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator { if (data == null) continue; tc.setBlock(worldX - chunkWorldX, worldY, worldZ - chunkWorldZ, data); + TileData tile = object.source().getStates().get(signed); + if (tile != null) { + tiles.add(new PlacedTile(worldX - chunkWorldX, worldY, worldZ - chunkWorldZ, tile.clone())); + } } } @@ -311,6 +322,12 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator { return new IrisBlockVector(maxX, 0, maxZ); } + public record PlacedTile(int x, int y, int z, TileData data) { + } + + public record ChunkTiles(int chunkX, int chunkZ, List tiles) { + } + private record DisplayObject(IrisObject source, int minimumX, int minimumY, int minimumZ) { private static DisplayObject of(IrisObject object) { int minimumX = Integer.MAX_VALUE; diff --git a/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilter.java b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilter.java index b2924a5d8..850e3f29f 100644 --- a/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilter.java +++ b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilter.java @@ -13,8 +13,11 @@ final class Terrain3DFragmentFilter { private static final int KEEP = 1; private static final int REMOVE = 2; + // Static on purpose: a per-instance ThreadLocal whose value was an inner class kept every + // retired filter (and the runtime and stream caches behind it) reachable from each worker + // thread that had ever classified for it. The scratch holds primitives only between calls. + private static final ThreadLocal TRAVERSALS = ThreadLocal.withInitial(Traversal::new); private final ColumnSource source; - private final ThreadLocal traversals = ThreadLocal.withInitial(Traversal::new); Terrain3DFragmentFilter(ColumnSource source) { this.source = Objects.requireNonNull(source, "Raw density columns"); @@ -30,7 +33,7 @@ final class Terrain3DFragmentFilter { int removed = 0; for (int span = 1; span < raw.spanCount(); span++) { if (density.state(span) == UNKNOWN) { - traversals.get().classify(x, z, density, span); + TRAVERSALS.get().classify(this, x, z, density, span); } if (density.state(span) == REMOVE) { removed++; @@ -42,7 +45,7 @@ final class Terrain3DFragmentFilter { } void clear() { - traversals.remove(); + TRAVERSALS.remove(); } private Terrain3DColumn retainSpans(DensityColumn density, int removed) { @@ -79,7 +82,7 @@ final class Terrain3DFragmentFilter { } } - private final class Traversal { + private static final class Traversal { private final int[] xs = new int[MAXIMUM_FRAGMENT_VOLUME + 1]; private final int[] zs = new int[MAXIMUM_FRAGMENT_VOLUME + 1]; private final int[] spans = new int[MAXIMUM_FRAGMENT_VOLUME + 1]; @@ -90,17 +93,17 @@ final class Terrain3DFragmentFilter { private int volume; private int decision; - private void classify(int x, int z, DensityColumn density, int span) { + private void classify(Terrain3DFragmentFilter filter, int x, int z, DensityColumn density, int span) { try { visit(x, z, density, span); for (int cursor = 0; cursor < count && decision == UNKNOWN; cursor++) { Terrain3DColumn raw = columns[cursor].raw; int bottom = raw.ceiling(spans[cursor]); int top = raw.floor(spans[cursor]); - inspect((long) xs[cursor] - 1, zs[cursor], bottom, top); - inspect((long) xs[cursor] + 1, zs[cursor], bottom, top); - inspect(xs[cursor], (long) zs[cursor] - 1, bottom, top); - inspect(xs[cursor], (long) zs[cursor] + 1, bottom, top); + inspect(filter, (long) xs[cursor] - 1, zs[cursor], bottom, top); + inspect(filter, (long) xs[cursor] + 1, zs[cursor], bottom, top); + inspect(filter, xs[cursor], (long) zs[cursor] - 1, bottom, top); + inspect(filter, xs[cursor], (long) zs[cursor] + 1, bottom, top); } int result = decision == UNKNOWN ? REMOVE : decision; for (int index = 0; index < count; index++) { @@ -117,7 +120,7 @@ final class Terrain3DFragmentFilter { } } - private void inspect(long x, long z, int bottom, int top) { + private void inspect(Terrain3DFragmentFilter filter, long x, long z, int bottom, int top) { if (decision != UNKNOWN) { return; } @@ -126,7 +129,7 @@ final class Terrain3DFragmentFilter { decision = KEEP; return; } - DensityColumn density = source.column((int) x, (int) z); + DensityColumn density = filter.source.column((int) x, (int) z); Terrain3DColumn raw = density.raw; for (int span = 0; span < raw.spanCount(); span++) { if (raw.ceiling(span) > top || decision != UNKNOWN) { diff --git a/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DRuntime.java b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DRuntime.java index 07570e362..8e6f8fb3a 100644 --- a/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/terrain/Terrain3DRuntime.java @@ -105,11 +105,14 @@ public final class Terrain3DRuntime { if (amplitude + crackDepth <= 0D) { return Terrain3DColumn.unshaped(baseHeight, options.height()); } + double densityHeight = baseHeight + interpolate(northWest.heightOffset(baseHeight), + northEast.heightOffset(baseHeight), southWest.heightOffset(baseHeight), + southEast.heightOffset(baseHeight), dx, dz); double crackWidth = interpolate(northWest.profile.crackWidth, northEast.profile.crackWidth, southWest.profile.crackWidth, southEast.profile.crackWidth, dx, dz); int minimum = Math.max(Math.max(1, (int) Math.floor(options.fluidHeight()) + 1), - (int) Math.floor(baseHeight - amplitude - crackDepth)); - int maximum = Math.min(options.height() - 1, (int) Math.ceil(baseHeight + amplitude)); + (int) Math.floor(densityHeight - amplitude - crackDepth)); + int maximum = Math.min(options.height() - 1, (int) Math.ceil(densityHeight + amplitude)); if (minimum > maximum) { return Terrain3DColumn.unshaped(baseHeight, options.height()); } @@ -148,7 +151,7 @@ public final class Terrain3DRuntime { double ridge = Math.max(0D, 1D - distance / crackWidth); fissure = lerp(lowerDepth, upperDepth, dy) * ridge * ridge; } - boolean solid = baseHeight + 0.5D - y + displacement - fissure >= 0D; + boolean solid = densityHeight + 0.5D - y + displacement - fissure >= 0D; if (solid != previousSolid) { if (count == boundaries.length) { boundaries = Arrays.copyOf(boundaries, count * 2); @@ -177,13 +180,13 @@ public final class Terrain3DRuntime { IrisBiome biome = sources.biomes().sample(x, z); CompiledProfile profile = profile(biome == null ? null : biome.getTerrain3D()); if (profile == DISABLED) { - return new Anchor(x, z, profile, 0D); + return new Anchor(x, z, profile, 0D, 0D); } double height = baseHeight(x, z); double elevationStrength = smooth((height - options.fluidHeight() - profile.fluidClearance) / profile.fluidFade); if (elevationStrength <= 0D) { - return new Anchor(x, z, profile, 0D); + return new Anchor(x, z, profile, height, 0D); } double slopeStrength = 1D; if (profile.minimumSlope > 0D) { @@ -192,7 +195,7 @@ public final class Terrain3DRuntime { double slope = StrictMath.sqrt(east * east + south * south); slopeStrength = smooth((slope - profile.minimumSlope) / profile.slopeFade); } - return new Anchor(x, z, profile, elevationStrength * slopeStrength); + return new Anchor(x, z, profile, height, elevationStrength * slopeStrength); } private CompiledProfile profile(IrisTerrain3D config) { @@ -322,13 +325,15 @@ public final class Terrain3DRuntime { private final int x; private final int z; private final CompiledProfile profile; + private final double height; private final double strength; private final AtomicReferenceArray samples; - private Anchor(int x, int z, CompiledProfile profile, double strength) { + private Anchor(int x, int z, CompiledProfile profile, double height, double strength) { this.x = x; this.z = z; this.profile = profile; + this.height = height; this.strength = strength; samples = strength == 0D ? null : new AtomicReferenceArray<>(Math.ceilDiv(options.height(), STEP) + 1); @@ -342,6 +347,10 @@ public final class Terrain3DRuntime { return profile.crackDepth * strength; } + private double heightOffset(double baseHeight) { + return (height - baseHeight) * strength; + } + private NodeSample sample(int y) { if (samples == null) { return EMPTY_SAMPLE; diff --git a/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobTerminalProgressTest.java b/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobTerminalProgressTest.java new file mode 100644 index 000000000..6183f1707 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobTerminalProgressTest.java @@ -0,0 +1,142 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.pregenerator.PregenApiPhase; +import art.arcane.iris.core.pregenerator.PregenApiSink; +import art.arcane.iris.core.pregenerator.PregenListener; +import art.arcane.iris.core.pregenerator.PregenTask; +import art.arcane.iris.core.pregenerator.PregeneratorMethod; +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.util.math.Position2; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PregeneratorJobTerminalProgressTest { + @Test + public void finalApiEventIncludesEveryChunkCompletedDuringClose() throws Exception { + List events = run(new Completion(false, false, false)); + + assertCompleted(events); + } + + @Test + public void cachedCompletionsDrainedDuringCloseReachTheSameTerminalTotal() throws Exception { + List events = run(new Completion(true, false, false)); + + assertCompleted(events); + } + + @Test + public void failedFinalRequestRemainsIncompleteWithCurrentFailureCount() throws Exception { + List events = run(new Completion(false, true, false)); + ProgressEvent terminal = events.getLast(); + + assertEquals(PregenApiPhase.CANCELLED, terminal.phase()); + assertEquals(8L, terminal.progress().generated()); + assertEquals(9L, terminal.progress().totalChunks()); + assertEquals(1L, terminal.progress().chunksRemaining()); + assertEquals(1L, terminal.progress().failed()); + assertFalse(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.COMPLETED)); + } + + @Test + public void explicitCancellationPublishesItsDrainedPartialCount() throws Exception { + List events = run(new Completion(false, false, true)); + ProgressEvent terminal = events.getLast(); + + assertEquals(PregenApiPhase.CANCELLED, terminal.phase()); + assertEquals(1L, terminal.progress().generated()); + assertEquals(9L, terminal.progress().totalChunks()); + assertEquals(8L, terminal.progress().chunksRemaining()); + assertEquals(0L, terminal.progress().failed()); + } + + private static void assertCompleted(List events) { + ProgressEvent terminal = events.getLast(); + assertEquals(PregenApiPhase.COMPLETED, terminal.phase()); + assertEquals(9L, terminal.progress().generated()); + assertEquals(9L, terminal.progress().totalChunks()); + assertEquals(0L, terminal.progress().chunksRemaining()); + assertEquals(0L, terminal.progress().failed()); + assertEquals(100D, terminal.progress().percent(), 0D); + assertTrue(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.SAVING + && event.progress().generated() == 0L)); + assertFalse(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.CANCELLED)); + assertEquals(1L, events.stream().filter(event -> event.phase() == PregenApiPhase.COMPLETED).count()); + } + + private static List run(Completion completion) throws Exception { + IrisSettings previousSettings = IrisSettings.settings; + PregenApiSink previousSink = IrisServices.getOrNull(PregenApiSink.class); + IrisSettings.settings = new IrisSettings(); + CopyOnWriteArrayList events = new CopyOnWriteArrayList<>(); + IrisServices.register(PregenApiSink.class, + (PregenApiSink) (phase, progress) -> events.add(new ProgressEvent(phase, progress))); + PregeneratorMethod method = mock(PregeneratorMethod.class); + when(method.getMethod(anyInt(), anyInt())).thenReturn("terminal-progress-test"); + ArrayList pending = new ArrayList<>(); + doAnswer(invocation -> { + pending.add(invocation.getArgument(2)); + if (completion.cancel()) { + PregeneratorJob.shutdownInstance(); + } + return null; + }).when(method).generateChunk(anyInt(), anyInt(), any(PregenListener.class)); + doAnswer(invocation -> { + for (int index = 0; index < pending.size(); index++) { + PregenListener listener = pending.get(index); + if (completion.failLast() && index == pending.size() - 1) { + listener.onChunkFailed(0, 0); + } else { + listener.onChunkGenerated(0, 0, completion.cached()); + } + } + return null; + }).when(method).close(); + PregenTask task = PregenTask.builder().center(new Position2(0, 0)) + .radiusX(1).radiusZ(1).gui(false).build(); + Thread worker = null; + try { + assertNull(PregeneratorJob.getInstance()); + PregeneratorJob job = new PregeneratorJob(new PregeneratorJob.Configuration(task, method, null, () -> {})); + Field workerField = PregeneratorJob.class.getDeclaredField("worker"); + workerField.setAccessible(true); + worker = (Thread) workerField.get(job); + worker.join(10_000L); + assertFalse(worker.isAlive()); + assertNull(PregeneratorJob.getInstance()); + return List.copyOf(events); + } finally { + if (worker != null && worker.isAlive()) { + PregeneratorJob.shutdownInstance(); + worker.join(5_000L); + } + IrisSettings.settings = previousSettings; + if (previousSink == null) { + IrisServices.remove(PregenApiSink.class); + } else { + IrisServices.register(PregenApiSink.class, previousSink); + } + } + } + + private record Completion(boolean cached, boolean failLast, boolean cancel) { + } + + private record ProgressEvent(PregenApiPhase phase, PregeneratorJob.PregenProgress progress) { + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java index 7e2438233..61564561e 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java @@ -17,6 +17,53 @@ public class PackHydrologyValidatorTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void acceptsNullableRegionalSurfaceControlsAtTheirBounds() throws Exception { + File pack = pack(riversDimension("{}", """ + "surfaceSourceDensity": null, + "surfaceSourceSpacing": null, + "surfaceTributaries": null, + "surfaceInlandOutlets": null, + "surfaceCoastalOutlets": null, + "surfaceMinimumCourseLength": null, + "surfaceMaximumIncision": null + """)); + write(pack, "regions/region.json", """ + {"landBiomes":["biome"],"riverPolicy":{ + "surfaceSourceDensity":64,"surfaceSourceSpacing":8192,"surfaceTributaries":4, + "surfaceInlandOutlets":256,"surfaceCoastalOutlets":64, + "surfaceMinimumCourseLength":4096,"surfaceMaximumIncision":32}} + """); + write(pack, "biomes/biome.json", """ + {"name":"Biome","derivative":"minecraft:plains","riverPolicy":{ + "surfaceSourceDensity":0,"surfaceSourceSpacing":0,"surfaceTributaries":0, + "surfaceInlandOutlets":0,"surfaceCoastalOutlets":0, + "surfaceMinimumCourseLength":16,"surfaceMaximumIncision":1}} + """); + + PackHydrologyValidator.Validation validation = validate(pack); + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + } + + @Test + public void rejectsInvalidRegionalSurfaceControlNumbers() throws Exception { + List invalidEntries = List.of( + "\"surfaceSourceDensity\":-1", "\"surfaceSourceDensity\":65", "\"surfaceSourceDensity\":\"NaN\"", + "\"surfaceSourceDensity\":true", "\"surfaceSourceSpacing\":8193", "\"surfaceSourceSpacing\":1.5", + "\"surfaceTributaries\":5", "\"surfaceTributaries\":1.5", "\"surfaceInlandOutlets\":-1", + "\"surfaceInlandOutlets\":257", "\"surfaceCoastalOutlets\":65", "\"surfaceCoastalOutlets\":\"4\"", + "\"surfaceMinimumCourseLength\":15", "\"surfaceMinimumCourseLength\":4097", + "\"surfaceMinimumCourseLength\":128.5", "\"surfaceMinimumCourseLength\":true", + "\"surfaceMaximumIncision\":0", "\"surfaceMaximumIncision\":33", + "\"surfaceMaximumIncision\":2.5", "\"surfaceMaximumIncision\":\"NaN\""); + for (String entry : invalidEntries) { + File pack = pack(riversDimension("{}")); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"],\"riverPolicy\":{" + entry + "}}"); + PackHydrologyValidator.Validation validation = validate(pack); + assertContains(validation.errors(), entry.substring(1, entry.indexOf('"', 1))); + } + } + @Test public void acceptsCanonicalHydrologyAndRecursivePolicyClosure() throws Exception { File pack = pack(canonicalDimension()); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java index c9ebb8c97..6f1fde023 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java @@ -249,4 +249,71 @@ public class PackValidationCacheTest { } } } + + @Test + public void sameVersionImplementationChangeInvalidatesSuccessfulAndFailedResults() throws Exception { + bindValidationPlatform(); + try { + String original = PackValidationCache.contextFingerprint("a".repeat(64)); + String changed = PackValidationCache.contextFingerprint("b".repeat(64)); + Path cache = temporaryFolder.newFolder("implementation-change").toPath().resolve("validation.json"); + PackValidationCache.save(cache, "content", original, List.of( + new PackValidationResult("valid", List.of(), List.of(), 1L), + new PackValidationResult("invalid", List.of("unsupported enum"), List.of(), 1L))); + + assertNotEquals(original, changed); + assertTrue(PackValidationCache.load(cache, "content", original, List.of("valid", "invalid")).isPresent()); + assertTrue(PackValidationCache.load(cache, "content", changed, List.of("valid", "invalid")).isEmpty()); + } finally { + IrisPlatforms.unbind(); + } + } + + @Test + public void currentBuildIdentityPreservesCacheHits() throws Exception { + bindValidationPlatform(); + try { + String context = PackValidationCache.contextFingerprint(); + Path cache = temporaryFolder.newFolder("current-implementation").toPath().resolve("validation.json"); + PackValidationCache.save(cache, "content", context, List.of( + new PackValidationResult("overworld", List.of(), List.of(), 1L))); + + assertFalse(context.isBlank()); + assertEquals(context, PackValidationCache.contextFingerprint()); + assertTrue(PackValidationCache.load( + cache, "content", PackValidationCache.contextFingerprint(), List.of("overworld")).isPresent()); + } finally { + IrisPlatforms.unbind(); + } + } + + @Test + public void unavailableImplementationIdentityDisablesCacheReuseAndPersistence() throws Exception { + bindValidationPlatform(); + try { + Path cache = temporaryFolder.newFolder("missing-implementation").toPath().resolve("validation.json"); + String missing = PackValidationCache.contextFingerprint(""); + assertEquals("", missing); + assertEquals("", PackValidationCache.contextFingerprint(null)); + PackValidationCache.save(cache, "content", missing, List.of( + new PackValidationResult("overworld", List.of(), List.of(), 1L))); + assertFalse(Files.exists(cache)); + + PackValidationCache.save(cache, "content", "previous", List.of( + new PackValidationResult("overworld", List.of(), List.of(), 1L))); + assertTrue(PackValidationCache.load(cache, "content", missing, List.of("overworld")).isEmpty()); + } finally { + IrisPlatforms.unbind(); + } + } + + private static void bindValidationPlatform() { + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.platformName()).thenReturn("Paper"); + when(platform.minecraftVersion()).thenReturn("26.2"); + when(platform.irisVersionNumber()).thenReturn(40100); + when(platform.registries()).thenReturn(mock(PlatformRegistries.class)); + when(platform.structureHooks()).thenReturn(mock(PlatformStructureHooks.class)); + IrisPlatforms.bind(platform); + } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java index b64fb0859..76d127a55 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java @@ -112,7 +112,7 @@ public class PackValidatorStructureTerrainBackendTest { @Test public void rejectsNativeOnlyTerrainModesForEditablePlacementsAcrossEveryHost() throws Exception { - for (String mode : List.of("VACUUM", "ENCASE")) { + for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) { String folderName = "editable-" + mode.toLowerCase(Locale.ROOT); File pack = temporaryFolder.newFolder(folderName); writePlacement(pack, "dimensions/main.json", "dimension-" + folderName, @@ -138,7 +138,7 @@ public class PackValidatorStructureTerrainBackendTest { @Test public void acceptsNativeOnlyTerrainModesForNativeStructurePlacements() throws Exception { - for (String mode : List.of("VACUUM", "ENCASE")) { + for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) { File pack = temporaryFolder.newFolder("native-" + mode.toLowerCase(Locale.ROOT)); writePlacement(pack, "dimensions/main.json", "native-" + mode, "minecraft:village_plains", true, mode); @@ -167,7 +167,7 @@ public class PackValidatorStructureTerrainBackendTest { @Test public void acceptsNativeOnlyTerrainModesForImportedStructureAdjustments() { - for (String mode : List.of("VACUUM", "ENCASE")) { + for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) { JSONObject policy = new JSONObject().put("adjustments", new JSONArray().put( new JSONObject() .put("match", new JSONArray().put("towns_and_towers:")) @@ -182,6 +182,26 @@ public class PackValidatorStructureTerrainBackendTest { } } + @Test + public void flattenRequiresIntegralBoundedRangeAndBlendSettings() { + for (String field : List.of("flattenRange", "horizontalPadding")) { + for (Object value : List.of(0, 128, -1, 129, 1.5D, "64")) { + List errors = new ArrayList<>(); + JSONObject terrain = new JSONObject().put("mode", "FLATTEN").put(field, value); + + PackStructurePlacementValidator.validateNativeTerrain("placement", + new JSONObject().put("terrain", terrain), errors); + + if (value.equals(0) || value.equals(128)) { + assertTrue(value + ": " + errors, errors.isEmpty()); + } else { + assertEquals(value.toString(), 1, errors.size()); + assertTrue(errors.getFirst(), errors.getFirst().contains("terrain." + field)); + } + } + } + } + private void writePlacement(File pack, String relativePath, String placementId, String structureKey, boolean nativeStructure, String terrainMode) throws Exception { diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenServerSaveOwnershipTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenServerSaveOwnershipTest.java index 1e7dba2a1..e2695a7ee 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenServerSaveOwnershipTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenServerSaveOwnershipTest.java @@ -4,6 +4,7 @@ import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.INMSBinding; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KSet; import org.bukkit.Chunk; @@ -16,24 +17,34 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Queue; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executor; 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 java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.times; @@ -42,6 +53,104 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public class AsyncPregenServerSaveOwnershipTest { + @Test + public void ownerReturnsAfterUnloadWhileEvictionTracksTheOffOwnerIoDrain() throws Exception { + Fixture fixture = new Fixture(); + ExecutorService io = Executors.newSingleThreadExecutor(); + fixture.set("chunkIoExecutor", io); + Thread ownerThread = Thread.currentThread(); + CountDownLatch draining = new CountDownLatch(1); + CountDownLatch finishDrain = new CountDownLatch(1); + AtomicReference ioThread = new AtomicReference<>(); + doAnswer(invocation -> { + assertSame(ownerThread, Thread.currentThread()); + return true; + }).when(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3); + doAnswer(invocation -> { + ioThread.set(Thread.currentThread()); + draining.countDown(); + assertTrue(finishDrain.await(5L, TimeUnit.SECONDS)); + return null; + }).when(fixture.binding).flushChunkIO(fixture.world); + try (SchedulingContext context = new SchedulingContext(fixture)) { + CompletableFuture eviction = (CompletableFuture) invoke(fixture.method, "evictRegion", 0L); + assertTrue(draining.await(5L, TimeUnit.SECONDS)); + assertNotSame(ownerThread, ioThread.get()); + assertFalse(eviction.isDone()); + verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3); + finishDrain.countDown(); + eviction.get(5L, TimeUnit.SECONDS); + } finally { + finishDrain.countDown(); + io.shutdownNow(); + assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test + public void foliaFlushWaitsForEveryOwningRegionAndRunsOffOwner() throws Exception { + Fixture fixture = new Fixture(); + fixture.set("foliaRuntime", true); + ExecutorService io = Executors.newSingleThreadExecutor(); + fixture.set("chunkIoExecutor", io); + Chunk second = mock(Chunk.class); + when(second.getX()).thenReturn(17); + when(second.getZ()).thenReturn(3); + fixture.chunks.get(0L).add(second); + when(fixture.binding.saveAndUnloadChunk(fixture.world, 17, 3)).thenReturn(true); + List owners = new ArrayList<>(); + Thread ownerThread = Thread.currentThread(); + CountDownLatch draining = new CountDownLatch(1); + CountDownLatch finishDrain = new CountDownLatch(1); + doAnswer(invocation -> { + assertNotSame(ownerThread, Thread.currentThread()); + verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3); + verify(fixture.binding).saveAndUnloadChunk(fixture.world, 17, 3); + draining.countDown(); + assertTrue(finishDrain.await(5L, TimeUnit.SECONDS)); + return null; + }).when(fixture.binding).flushChunkIO(fixture.world); + try (SchedulingContext context = new SchedulingContext(fixture)) { + context.scheduling.when(() -> J.runRegionFuture(eq(fixture.world), anyInt(), anyInt(), any(Runnable.class))) + .thenAnswer(invocation -> { + CompletableFuture completion = new CompletableFuture<>(); + owners.add(new OwnedTask(invocation.getArgument(1), invocation.getArgument(2), + invocation.getArgument(3), completion)); + return completion; + }); + CompletableFuture eviction = (CompletableFuture) invoke(fixture.method, "evictRegion", 0L); + assertEquals(2, owners.size()); + assertEquals(2, owners.get(0).chunkX()); + assertEquals(17, owners.get(1).chunkX()); + owners.get(0).run(); + assertEquals(1L, draining.getCount()); + assertFalse(eviction.isDone()); + owners.get(1).run(); + assertTrue(draining.await(5L, TimeUnit.SECONDS)); + assertFalse(eviction.isDone()); + finishDrain.countDown(); + eviction.get(5L, TimeUnit.SECONDS); + } finally { + finishDrain.countDown(); + io.shutdownNow(); + assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test + public void failedIoDrainFailsTheEvictionReceiptAndReportsTheFailure() throws Exception { + Fixture fixture = new Fixture(); + IllegalStateException failure = new IllegalStateException("native IO failure"); + doThrow(failure).when(fixture.binding).flushChunkIO(fixture.world); + try (SchedulingContext context = new SchedulingContext(fixture); + MockedStatic logging = mockStatic(IrisLogging.class)) { + CompletableFuture eviction = (CompletableFuture) invoke(fixture.method, "evictRegion", 0L); + assertTrue(eviction.isCompletedExceptionally()); + assertSame(failure, assertThrows(CompletionException.class, eviction::join).getCause()); + logging.verify(() -> IrisLogging.reportError(any(Throwable.class))); + } + } + @Test public void serverStopRetainsChunksForNativeSaveWithoutSchedulingEviction() throws Exception { Fixture fixture = new Fixture(); @@ -151,6 +260,26 @@ public class AsyncPregenServerSaveOwnershipTest { } } + @Test + public void nativeFlushRunsOnThePregenFlushWorkerNotTheSharedIoPool() throws Exception { + Fixture fixture = new Fixture(); + PregenSerialWorker flush = new PregenSerialWorker("Iris Pregen Chunk Flush", "world"); + fixture.set("chunkIoExecutor", flush.executor()); + AtomicReference ioThread = new AtomicReference<>(); + doAnswer(invocation -> { + ioThread.set(Thread.currentThread()); + return null; + }).when(fixture.binding).flushChunkIO(fixture.world); + try (SchedulingContext context = new SchedulingContext(fixture)) { + CompletableFuture eviction = (CompletableFuture) invoke(fixture.method, "evictRegion", 0L); + eviction.get(5L, TimeUnit.SECONDS); + assertTrue(ioThread.get().getName().startsWith("Iris Pregen Chunk Flush world")); + assertFalse(ioThread.get().getName().startsWith("Iris IO")); + } finally { + assertTrue(flush.close(5L, TimeUnit.SECONDS)); + } + } + private static Object invoke(AsyncPregenMethod target, String name, Object... arguments) throws Exception { Method method = arguments.length == 0 ? AsyncPregenMethod.class.getDeclaredMethod(name) @@ -182,6 +311,7 @@ public class AsyncPregenServerSaveOwnershipTest { set("regionPending", new ConcurrentHashMap<>()); set("pendingEvictions", evictions); set("evictedRegions", new KSet<>()); + set("chunkIoExecutor", (Executor) Runnable::run); when(chunk.getX()).thenReturn(2); when(chunk.getZ()).thenReturn(3); when(binding.saveAndUnloadChunk(world, 2, 3)).thenReturn(true); @@ -218,4 +348,11 @@ public class AsyncPregenServerSaveOwnershipTest { toolbelt.close(); } } + + private record OwnedTask(int chunkX, int chunkZ, Runnable action, CompletableFuture completion) { + private void run() { + action.run(); + completion.complete(null); + } + } } diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenFlushOwnershipTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenFlushOwnershipTest.java new file mode 100644 index 000000000..74c5aa497 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/MedievalPregenFlushOwnershipTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.core.pregenerator.methods; + +import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.nms.INMSBinding; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +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.atomic.AtomicReference; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class MedievalPregenFlushOwnershipTest { + @Test + public void closeWaitsForOffOwnerIoAfterTheOwnedUnloadReturns() throws Exception { + MedievalPregenMethod method = mock(MedievalPregenMethod.class, CALLS_REAL_METHODS); + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + INMSBinding binding = mock(INMSBinding.class); + Map lastUse = new ConcurrentHashMap<>(); + lastUse.put(chunk, 0L); + ExecutorService io = Executors.newSingleThreadExecutor(); + ExecutorService closing = Executors.newSingleThreadExecutor(); + set(method, "world", world); + set(method, "lastUse", lastUse); + set(method, "chunkIoExecutor", io); + CountDownLatch scheduled = new CountDownLatch(1); + CountDownLatch draining = new CountDownLatch(1); + CountDownLatch finishDrain = new CountDownLatch(1); + AtomicReference ownerAction = new AtomicReference<>(); + CompletableFuture unloaded = new CompletableFuture<>(); + Thread owner = Thread.currentThread(); + doAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + return true; + }).when(chunk).unload(true); + doAnswer(invocation -> { + assertNotSame(owner, Thread.currentThread()); + verify(chunk).unload(true); + draining.countDown(); + assertTrue(finishDrain.await(5L, TimeUnit.SECONDS)); + return null; + }).when(binding).flushChunkIO(world); + try { + Future closed = closing.submit(() -> { + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic nativeAccess = mockStatic(INMS.class)) { + nativeAccess.when(INMS::get).thenReturn(binding); + scheduling.when(() -> J.sfut(any(Runnable.class))).thenAnswer(invocation -> { + ownerAction.set(invocation.getArgument(0)); + scheduled.countDown(); + return unloaded; + }); + Method flush = MedievalPregenMethod.class.getDeclaredMethod("unloadAndSaveAllChunks", boolean.class); + flush.setAccessible(true); + flush.invoke(method, true); + } + return null; + }); + assertTrue(scheduled.await(5L, TimeUnit.SECONDS)); + verifyNoInteractions(binding); + ownerAction.get().run(); + unloaded.complete(null); + assertTrue(draining.await(5L, TimeUnit.SECONDS)); + assertFalse(closed.isDone()); + assertTrue(lastUse.isEmpty()); + finishDrain.countDown(); + closed.get(5L, TimeUnit.SECONDS); + } finally { + unloaded.complete(null); + finishDrain.countDown(); + io.shutdownNow(); + closing.shutdownNow(); + assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS)); + assertTrue(closing.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + private static void set(MedievalPregenMethod method, String name, Object value) throws Exception { + Field field = MedievalPregenMethod.class.getDeclaredField(name); + field.setAccessible(true); + field.set(method, value); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenSerialWorkerTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenSerialWorkerTest.java new file mode 100644 index 000000000..5bf1de610 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenSerialWorkerTest.java @@ -0,0 +1,111 @@ +package art.arcane.iris.core.pregenerator.methods; + +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +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.assertTrue; + +public class PregenSerialWorkerTest { + @Test + public void submitRunsOffTheCallerThreadAndCloseDrainsTheBacklog() throws Exception { + Reports reports = new Reports(); + PregenSerialWorker queue = reports.queue(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference runner = new AtomicReference<>(); + AtomicBoolean second = new AtomicBoolean(); + try { + assertTrue(queue.submit(() -> { + runner.set(Thread.currentThread()); + entered.countDown(); + await(release); + })); + assertTrue(queue.submit(() -> second.set(true))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertNotSame(Thread.currentThread(), runner.get()); + assertTrue(runner.get().getName().startsWith("Iris Pregen Mantle Cleanup world")); + assertEquals(2, queue.pending()); + assertFalse(second.get()); + release.countDown(); + assertTrue(queue.close(5, TimeUnit.SECONDS)); + assertTrue(second.get()); + assertEquals(0, queue.pending()); + assertTrue(reports.failures.isEmpty()); + assertTrue(reports.warnings.isEmpty()); + } finally { + release.countDown(); + queue.close(1, TimeUnit.SECONDS); + } + } + + @Test + public void closeAbandonsABlockedBacklogAfterItsTimeout() throws Exception { + Reports reports = new Reports(); + PregenSerialWorker queue = reports.queue(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean second = new AtomicBoolean(); + try { + assertTrue(queue.submit(() -> { + entered.countDown(); + await(release); + })); + assertTrue(queue.submit(() -> second.set(true))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertFalse(queue.close(50, TimeUnit.MILLISECONDS)); + assertFalse(queue.submit(() -> {})); + assertEquals(1, reports.warnings.size()); + assertTrue(reports.warnings.get(0).contains("1 queued task(s)")); + } finally { + release.countDown(); + } + assertFalse(second.get()); + } + + @Test + public void aFailingCleanupIsReportedAndDoesNotStopLaterCleanups() throws Exception { + Reports reports = new Reports(); + PregenSerialWorker queue = reports.queue(); + AtomicBoolean second = new AtomicBoolean(); + IllegalStateException failure = new IllegalStateException("plate read failed"); + assertTrue(queue.submit(() -> { + throw failure; + })); + assertTrue(queue.submit(() -> second.set(true))); + assertTrue(queue.close(5, TimeUnit.SECONDS)); + assertTrue(second.get()); + assertEquals(1, reports.failures.size()); + assertSame(failure, reports.failures.get(0)); + assertEquals(0, queue.pending()); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(30, TimeUnit.SECONDS)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interruption); + } + } + + private static final class Reports { + private final List failures = new CopyOnWriteArrayList<>(); + private final List warnings = new CopyOnWriteArrayList<>(); + + private PregenSerialWorker queue() { + return new PregenSerialWorker("Iris Pregen Mantle Cleanup", "world", + (message, failure) -> failures.add(failure), + warnings::add); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java index d9244869e..ac62362d8 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java @@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "ddfc51ba19c02dd50737a0d1ba30ee24cdb6cbbe5fed40c875adac69960ad81d"; + private static final String EXPECTED_SCHEMA_DIGEST = "b205ce33025fa36115f9ddb38c704803875043950967e31449e2b08ae364a7fe"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, 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 cca89d561..68e7486a1 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 @@ -28,6 +28,8 @@ import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisStructure; 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.IrisVanillaStructureAdjustment; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -162,6 +164,8 @@ public class SchemaBuilderParityTest { .getJSONObject(terrain.getString("$ref").substring("#/definitions/".length())); JSONObject terrainProperties = terrainDefinition.getJSONObject("properties"); JSONObject terrainMode = terrainProperties.getJSONObject("mode"); + JSONObject flattenRange = terrainProperties.getJSONObject("flattenRange"); + JSONObject horizontalPadding = terrainProperties.getJSONObject("horizontalPadding"); JSONObject carveShape = terrainProperties.getJSONObject("shape"); JSONObject erosionStrength = terrainProperties.getJSONObject("erosionStrength"); JSONObject erosionFrequency = terrainProperties.getJSONObject("erosionFrequency"); @@ -178,8 +182,18 @@ public class SchemaBuilderParityTest { assertTrue(terrain.getString("description").contains( "The editable structures backend supports SOURCE, PRESERVE, BORE, and FORCE_CARVE. " + "The nativeStructures backend supports every terrain mode.")); - assertEquals(List.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), + assertEquals(List.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "FLATTEN", "ENCASE"), oneOfValues(schema.getJSONObject("definitions"), terrainModeDefinition)); + assertEquals("integer", flattenRange.getString("type")); + assertEquals(0, flattenRange.getInt("minimum")); + assertEquals(128, flattenRange.getInt("maximum")); + assertEquals("integer", horizontalPadding.getString("type")); + assertEquals(0, horizontalPadding.getInt("minimum")); + assertEquals(128, horizontalPadding.getInt("maximum")); + IrisStructureTerrain terrainDefaults = new IrisStructureTerrain(); + assertEquals(IrisStructureTerrainMode.SOURCE, terrainDefaults.resolvedMode()); + assertEquals(64, terrainDefaults.getFlattenRange()); + assertEquals(0, terrainDefaults.getHorizontalPadding()); assertEquals(List.of("BOX", "ROUNDED", "ERODED"), oneOfValues( schema.getJSONObject("definitions"), carveShapeDefinition)); assertEquals(0D, erosionStrength.getDouble("minimum"), 0D); @@ -226,6 +240,10 @@ public class SchemaBuilderParityTest { assertTrue(terrainProperties.has("lobeFrequency")); assertTrue(terrainProperties.has("lobeStrength")); assertTrue(terrainProperties.has("encasePalette")); + assertTrue(oneOfValues(definitions, definitionKey(terrainProperties.getJSONObject("mode"))) + .contains("FLATTEN")); + assertEquals(0, terrainProperties.getJSONObject("flattenRange").getInt("minimum")); + assertEquals(128, terrainProperties.getJSONObject("flattenRange").getInt("maximum")); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/service/ObjectStudioTileRestorationTest.java b/core/src/test/java/art/arcane/iris/core/service/ObjectStudioTileRestorationTest.java new file mode 100644 index 000000000..7e531dda9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/ObjectStudioTileRestorationTest.java @@ -0,0 +1,129 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.ChunkTiles; +import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.PlacedTile; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.spi.IrisLogging; +import org.bukkit.Chunk; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.event.world.ChunkLoadEvent; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class ObjectStudioTileRestorationTest { + @Test + public void restoresGeneratedTileOnlyAfterChunkLoadAndKeepsLaterEdits() { + Fixture fixture = fixture(); + queue(fixture); + + verifyNoInteractions(fixture.tile()); + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true)); + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false)); + + verify(fixture.tile(), times(1)).toBukkitTry(fixture.block()); + } + + @Test + public void failedRestorationRemainsPendingForTheNextOwnedLoad() { + Fixture fixture = fixture(); + when(fixture.tile().toBukkitTry(fixture.block())).thenReturn(false, true); + queue(fixture); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true)); + } + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false)); + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false)); + + verify(fixture.tile(), times(2)).toBukkitTry(fixture.block()); + } + + @Test + public void retriesOnlyUnrestoredTilesAfterPartialFailure() { + Fixture fixture = fixture(); + TileData second = mock(TileData.class); + Block secondBlock = mock(Block.class); + BlockData secondState = mock(BlockData.class); + when(fixture.chunk().getBlock(15, 161, 1)).thenReturn(secondBlock); + when(secondBlock.getBlockData()).thenReturn(secondState); + when(second.isApplicable(secondState)).thenReturn(true); + when(second.toBukkitTry(secondBlock)).thenReturn(false, true); + try (MockedStatic binding = mockStatic(BukkitWorldBinding.class)) { + binding.when(() -> BukkitWorldBinding.world(fixture.irisWorld())).thenReturn(fixture.world()); + fixture.service().queueTiles(fixture.engine(), new ChunkTiles(-2, 3, List.of( + new PlacedTile(14, 161, 1, fixture.tile()), new PlacedTile(15, 161, 1, second)))); + } + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true)); + } + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false)); + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false)); + + verify(fixture.tile(), times(1)).toBukkitTry(fixture.block()); + verify(second, times(2)).toBukkitTry(secondBlock); + } + + @Test + public void worldUnregistrationDiscardsPendingTiles() { + Fixture fixture = fixture(); + queue(fixture); + + fixture.service().unregister(fixture.world()); + fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true)); + + verifyNoInteractions(fixture.tile()); + } + + private static void queue(Fixture fixture) { + try (MockedStatic binding = mockStatic(BukkitWorldBinding.class)) { + binding.when(() -> BukkitWorldBinding.world(fixture.irisWorld())).thenReturn(fixture.world()); + fixture.service().queueTiles(fixture.engine(), new ChunkTiles(-2, 3, + List.of(new PlacedTile(14, 161, 1, fixture.tile())))); + } + } + + private static Fixture fixture() { + ObjectStudioSaveService service = new ObjectStudioSaveService(); + Engine engine = mock(Engine.class); + EngineTarget target = mock(EngineTarget.class); + IrisWorld irisWorld = mock(IrisWorld.class); + when(engine.getTarget()).thenReturn(target); + when(target.getWorld()).thenReturn(irisWorld); + World world = mock(World.class); + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.getName()).thenReturn("object-studio"); + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + when(chunk.getX()).thenReturn(-2); + when(chunk.getZ()).thenReturn(3); + Block block = mock(Block.class); + BlockData state = mock(BlockData.class); + when(chunk.getBlock(14, 161, 1)).thenReturn(block); + when(block.getBlockData()).thenReturn(state); + TileData tile = mock(TileData.class); + when(tile.isApplicable(state)).thenReturn(true); + when(tile.toBukkitTry(block)).thenReturn(true); + return new Fixture(service, engine, irisWorld, world, chunk, block, tile); + } + + private record Fixture(ObjectStudioSaveService service, Engine engine, IrisWorld irisWorld, World world, + Chunk chunk, Block block, TileData tile) { + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/TreeSVCLifecycleTest.java b/core/src/test/java/art/arcane/iris/core/service/TreeSVCLifecycleTest.java new file mode 100644 index 000000000..432382ed3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/TreeSVCLifecycleTest.java @@ -0,0 +1,146 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.framework.GenerationTransitionGate; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.data.Cuboid; +import org.bukkit.Location; +import org.bukkit.TreeType; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.event.world.StructureGrowEvent; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class TreeSVCLifecycleTest { + @Test + public void growthDuringCutoverLeavesOwnerFreeForCheckpointAndRetriesAfterward() throws Exception { + ExecutorService owner = Executors.newSingleThreadExecutor(); + ExecutorService reloader = Executors.newSingleThreadExecutor(); + Fixture fixture = new Fixture(); + AtomicInteger checkpoints = new AtomicInteger(); + try { + StructureGrowEvent first = fixture.event(); + owner.submit(() -> fixture.grow(first)).get(2L, TimeUnit.SECONDS); + assertFalse(first.isCancelled()); + assertEquals(1, fixture.biomeReads.get()); + + StructureGrowEvent during = fixture.event(); + CompletableFuture cutover = CompletableFuture.runAsync(() -> { + try (GenerationTransitionGate.Transition ignored = fixture.sessions.transitionGate().beginTransition(2_000L)) { + fixture.sessions.sealAndAwait("Studio generation cutover", 2_000L); + Future growth = owner.submit(() -> fixture.grow(during)); + owner.submit(checkpoints::incrementAndGet).get(2L, TimeUnit.SECONDS); + growth.get(2L, TimeUnit.SECONDS); + assertTrue(during.isCancelled()); + assertEquals(1, fixture.biomeReads.get()); + fixture.sessions.activateNextSession(); + } catch (Exception failure) { + throw new IllegalStateException(failure); + } + }, reloader); + cutover.get(4L, TimeUnit.SECONDS); + + StructureGrowEvent after = fixture.event(); + owner.submit(() -> fixture.grow(after)).get(2L, TimeUnit.SECONDS); + assertFalse(after.isCancelled()); + assertEquals(2, fixture.biomeReads.get()); + assertEquals(1, checkpoints.get()); + assertEquals(0, fixture.sessions.activeLeases()); + } finally { + owner.shutdownNow(); + reloader.shutdownNow(); + assertTrue(owner.awaitTermination(3L, TimeUnit.SECONDS)); + assertTrue(reloader.awaitTermination(3L, TimeUnit.SECONDS)); + } + } + + @Test + public void disabledTreeOverridesLeaveNormalGrowthUntouched() { + Fixture fixture = new Fixture(); + fixture.dimension.getTreeSettings().setEnabled(false); + StructureGrowEvent event = fixture.event(); + + fixture.grow(event); + + assertFalse(event.isCancelled()); + assertEquals(0, fixture.biomeReads.get()); + assertEquals(0, fixture.sessions.activeLeases()); + } + + private static final class Fixture { + private final GenerationSessionManager sessions = new GenerationSessionManager(true); + private final Engine engine = mock(Engine.class); + private final World world = mock(World.class); + private final IrisDimension dimension = new IrisDimension(); + private final PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + private final TreeSVC service = spy(new TreeSVC()); + private final AtomicInteger biomeReads = new AtomicInteger(); + + private Fixture() { + dimension.getTreeSettings().setEnabled(true); + EngineTarget target = mock(EngineTarget.class); + IrisWorld irisWorld = mock(IrisWorld.class); + Block block = mock(Block.class); + BlockData data = mock(BlockData.class); + when(engine.getGenerationSessions()).thenReturn(sessions); + when(engine.getDimension()).thenReturn(dimension); + when(generator.getEngine()).thenReturn(engine); + when(generator.getTarget()).thenReturn(target); + when(target.getWorld()).thenReturn(irisWorld); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block); + when(world.getBlockAt(any(Location.class))).thenReturn(block); + when(block.getBlockData()).thenReturn(data); + when(data.clone()).thenReturn(data); + doReturn(mock(Cuboid.class)).when(service).getSaplings(any(), any(), any()); + when(engine.getBiome(8, 80, 8)).thenAnswer(invocation -> { + try (GenerationTransitionGate.Participation ignored = sessions.transitionGate().enter()) { + biomeReads.incrementAndGet(); + return new IrisBiome(); + } + }); + when(engine.getRegion(8, 80, 8)).thenReturn(new IrisRegion()); + } + + private StructureGrowEvent event() { + return new StructureGrowEvent(new Location(world, 8, 80, 8), TreeType.TREE, false, null, List.of()); + } + + private void grow(StructureGrowEvent event) { + try (MockedStatic toolbelt = mockStatic(IrisToolbelt.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true); + toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator); + service.on(event); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/EngineBackgroundTasksAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/EngineBackgroundTasksAdmissionTest.java new file mode 100644 index 000000000..ffe4dc654 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/EngineBackgroundTasksAdmissionTest.java @@ -0,0 +1,304 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineLifecycleTasks; +import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.scheduling.J; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +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 java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class EngineBackgroundTasksAdmissionTest { + @Test + public void preliminaryDrainWaitsForSaveAdmittedAfterItsInitialSnapshot() throws Exception { + Fixture fixture = fixture(); + CountDownLatch firstEntered = new CountDownLatch(1); + CountDownLatch firstRelease = new CountDownLatch(1); + CountDownLatch saveEntered = new CountDownLatch(1); + CountDownLatch saveRelease = new CountDownLatch(1); + try (ExecutorService workers = Executors.newFixedThreadPool(2); + ExecutorService closer = Executors.newSingleThreadExecutor(); + MockedStatic scheduler = scheduler(workers, new AtomicReference<>())) { + try { + fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease)); + assertTrue(firstEntered.await(5, TimeUnit.SECONDS)); + fixture.tasks().closeBackgroundTaskAdmission(); + Future drain = startDrain(closer, fixture.tasks()); + assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () -> + fixture.tasks().scheduleAdmittedTask(fixture.engine(), + () -> waitForRelease(saveEntered, saveRelease)))); + assertTrue(saveEntered.await(5, TimeUnit.SECONDS)); + firstRelease.countDown(); + assertThrows(TimeoutException.class, () -> drain.get(100, TimeUnit.MILLISECONDS)); + saveRelease.countDown(); + drain.get(5, TimeUnit.SECONDS).requireComplete("Studio cutover"); + } finally { + firstRelease.countDown(); + saveRelease.countDown(); + } + } + } + + @Test + public void newlyAdmittedFailureSurvivesCompletionAndLaterTaskSubmission() throws Exception { + Fixture fixture = fixture(); + CountDownLatch firstEntered = new CountDownLatch(1); + CountDownLatch firstRelease = new CountDownLatch(1); + CountDownLatch failed = new CountDownLatch(1); + IllegalStateException failure = new IllegalStateException("ownership write failed"); + AtomicReference logged = new AtomicReference<>(); + try (ExecutorService workers = Executors.newFixedThreadPool(2); + ExecutorService closer = Executors.newSingleThreadExecutor(); + MockedStatic scheduler = scheduler(workers, logged)) { + try { + fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease)); + assertTrue(firstEntered.await(5, TimeUnit.SECONDS)); + fixture.tasks().closeBackgroundTaskAdmission(); + Future drain = startDrain(closer, fixture.tasks()); + assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () -> + fixture.tasks().scheduleAdmittedTask(fixture.engine(), () -> { + failed.countDown(); + throw failure; + }))); + assertTrue(failed.await(5, TimeUnit.SECONDS)); + awaitLogged(logged, failure); + assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () -> + fixture.tasks().scheduleAdmittedTask(fixture.engine(), () -> {}))); + firstRelease.countDown(); + EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(5, TimeUnit.SECONDS); + assertTrue(result.complete()); + assertNotNull(result.failure()); + assertSame(failure, result.failure().getCause()); + assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover")); + } finally { + firstRelease.countDown(); + } + } + } + + @Test + public void failureSettledBeforeDrainDoesNotPoisonTheNextTransition() throws Exception { + Fixture fixture = fixture(); + IllegalStateException failure = new IllegalStateException("previous storage failure"); + AtomicReference logged = new AtomicReference<>(); + try (ExecutorService workers = Executors.newSingleThreadExecutor(); + MockedStatic scheduler = scheduler(workers, logged)) { + fixture.tasks().scheduleTrackedTask(() -> { + throw failure; + }); + workers.submit(() -> {}).get(5, TimeUnit.SECONDS); + assertSame(failure, logged.get()); + fixture.tasks().closeBackgroundTaskAdmission(); + fixture.tasks().drainBackgroundTasks("next transition").requireComplete("next transition"); + assertFalse(fixture.tasks().scheduleTrackedTask(() -> {})); + } + } + + @Test + public void continuationsShareTheOriginalDrainDeadline() throws Exception { + Fixture fixture = fixture(); + CountDownLatch firstEntered = new CountDownLatch(1); + CountDownLatch firstRelease = new CountDownLatch(1); + CountDownLatch saveEntered = new CountDownLatch(1); + CountDownLatch saveRelease = new CountDownLatch(1); + try (ExecutorService workers = Executors.newFixedThreadPool(2); + ExecutorService closer = Executors.newSingleThreadExecutor(); + MockedStatic scheduler = scheduler(workers, new AtomicReference<>())) { + try { + fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease)); + assertTrue(firstEntered.await(5, TimeUnit.SECONDS)); + fixture.tasks().closeBackgroundTaskAdmission(); + Future drain = startDrain(closer, fixture.tasks()); + assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () -> + fixture.tasks().scheduleAdmittedTask(fixture.engine(), + () -> waitForRelease(saveEntered, saveRelease)))); + assertTrue(saveEntered.await(5, TimeUnit.SECONDS)); + TimeUnit.SECONDS.sleep(7); + firstRelease.countDown(); + EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(10, TimeUnit.SECONDS); + assertTrue(result.failure() instanceof TimeoutException); + assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover")); + } finally { + firstRelease.countDown(); + saveRelease.countDown(); + } + } + } + + @Test + public void cancellationClaimsAQueuedTaskBeforeItCanStart() throws Exception { + Fixture fixture = fixture(); + CountDownLatch gate = new CountDownLatch(1); + AtomicBoolean ran = new AtomicBoolean(); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try (MockedStatic scheduler = mockStatic(J.class)) { + // Mirrors the ForkJoinPool behind J.a: cancel(true) reports success yet cannot stop a + // callable the pool has already dequeued, so the claim must be decided by the task itself. + scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> { + Callable task = invocation.getArgument(0); + CompletableFuture handle = new CompletableFuture<>(); + worker.execute(() -> { + try { + assertTrue(gate.await(5, TimeUnit.SECONDS)); + task.call(); + handle.complete(null); + } catch (Throwable failure) { + handle.completeExceptionally(failure); + } + }); + return handle; + }); + assertTrue(fixture.tasks().scheduleTrackedTask(() -> ran.set(true))); + fixture.tasks().cancelBackgroundTasks("close"); + gate.countDown(); + worker.submit(() -> {}).get(5, TimeUnit.SECONDS); + assertFalse(ran.get()); + fixture.tasks().closeBackgroundTaskAdmission(); + EngineBackgroundTasks.BackgroundTaskDrain drain = fixture.tasks().drainBackgroundTasks("close"); + assertTrue(drain.complete()); + } finally { + worker.shutdownNow(); + } + } + + @Test + public void cancellationOfARunningTaskLeavesItsCompletionToTheTask() throws Exception { + Fixture fixture = fixture(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (ExecutorService workers = Executors.newSingleThreadExecutor(); + MockedStatic scheduler = scheduler(workers, new AtomicReference<>())) { + assertTrue(fixture.tasks().scheduleTrackedTask(() -> waitForReleaseIgnoringInterrupts(entered, release))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + fixture.tasks().cancelBackgroundTasks("close"); + fixture.tasks().closeBackgroundTaskAdmission(); + ExecutorService closer = Executors.newSingleThreadExecutor(); + try { + Future drain = closer.submit( + () -> fixture.tasks().drainBackgroundTasks("close")); + assertThrows(TimeoutException.class, () -> drain.get(200, TimeUnit.MILLISECONDS)); + release.countDown(); + EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(5, TimeUnit.SECONDS); + assertTrue(result.complete()); + result.requireComplete("close"); + } finally { + release.countDown(); + closer.shutdownNow(); + } + } + } + + private static Future startDrain( + ExecutorService closer, EngineBackgroundTasks tasks) { + AtomicReference drainingThread = new AtomicReference<>(); + Future drain = closer.submit(() -> { + drainingThread.set(Thread.currentThread()); + return tasks.drainBackgroundTasks("Studio cutover"); + }); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + Thread thread = drainingThread.get(); + if (thread != null && thread.getState() == Thread.State.TIMED_WAITING) { + for (StackTraceElement frame : thread.getStackTrace()) { + if (frame.getClassName().equals(EngineBackgroundTasks.class.getName()) + && frame.getMethodName().equals("drainBackgroundTasks")) { + return drain; + } + } + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + throw new AssertionError("Background drain did not await the initial task"); + } + + private static void awaitLogged(AtomicReference logged, Throwable failure) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + if (logged.get() == failure) { + return; + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + throw new AssertionError("Background task did not log its failure"); + } + + private static void waitForReleaseIgnoringInterrupts(CountDownLatch entered, CountDownLatch release) { + entered.countDown(); + boolean interrupted = false; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (true) { + try { + assertTrue(release.await(Math.max(1L, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)); + break; + } catch (InterruptedException interruption) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + private static void waitForRelease(CountDownLatch entered, CountDownLatch release) { + entered.countDown(); + try { + assertTrue(release.await(30, TimeUnit.SECONDS)); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interruption); + } + } + + @SuppressWarnings("unchecked") + private static MockedStatic scheduler(ExecutorService workers, AtomicReference logged) { + MockedStatic scheduler = mockStatic(J.class); + scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> { + Callable task = invocation.getArgument(0); + return workers.submit(() -> { + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + logging.when(() -> IrisLogging.reportError(any(Throwable.class))).thenAnswer(call -> { + logged.set(call.getArgument(0)); + return null; + }); + return task.call(); + } + }); + }); + return scheduler; + } + + private static Fixture fixture() { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + GenerationSessionManager sessions = new GenerationSessionManager(true); + Engine engine = mock(Engine.class); + when(engine.getGenerationSessions()).thenReturn(sessions); + when(engine.getGenerationSessionId()).thenAnswer(invocation -> sessions.currentSessionId()); + return new Fixture(tasks, engine); + } + + private record Fixture(EngineBackgroundTasks tasks, Engine engine) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java index d65ad2e62..8e1e6706b 100644 --- a/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/engine/EngineShutdownDrainTest.java @@ -641,7 +641,7 @@ public class EngineShutdownDrainTest { private GenerationKernelRegistry.RuntimeKernel prepareComplexHotload(IrisComplex replacement) throws Exception { GenerationKernelRegistry.RuntimeKernel kernel = mock(GenerationKernelRegistry.RuntimeKernel.class); - when(kernel.createComplex(engine, null)).thenReturn(replacement); + when(kernel.createComplex(engine, null, false)).thenReturn(replacement); when(generation.runtimeKernel()).thenReturn(kernel); GenerationRuntime nextGeneration = generation(target, mantle); when(nextGeneration.complex()).thenReturn(replacement); diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexBiomeBuffetTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexBiomeBuffetTest.java new file mode 100644 index 000000000..8d310c4bc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexBiomeBuffetTest.java @@ -0,0 +1,216 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.StudioMode; +import art.arcane.iris.engine.platform.studio.BiomeBuffetLayout; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.iris.util.common.data.B; +import org.junit.ClassRule; +import org.junit.Before; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class IrisComplexBiomeBuffetTest { + @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisSettings previous; + + @Before + public void prepareRuntime() { + previous = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class)); + } + + @After + public void releaseRuntime() { + IrisPlatforms.unbind(); + IrisServices.remove(PreservationRegistry.class); + IrisSettings.settings = previous; + } + + @Test + public void neighboringCellsUseTheirOwnRealBiomeStreamsWithoutChangingAuthoredFocus() throws Exception { + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.platformName()).thenReturn("bukkit"); + IrisPlatforms.bind(platform); + Path pack = fixture(); + IrisData data = IrisData.openRuntime(pack.toFile()); + try (MockedStatic blocks = mockStatic(B.class)) { + PlatformBlockState block = mock(PlatformBlockState.class); + when(block.isFluid()).thenReturn(true); + blocks.when(() -> B.getState(anyString())).thenReturn(block); + blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block); + IrisDimension dimension = data.getDimensionLoader().load("main"); + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + doReturn(data).when(engine).getData(); + doReturn(dimension).when(engine).getDimension(); + doReturn(data.getBiomeLoader().load("alpha")).when(engine).getFocus(); + doReturn(true).when(engine).isStudio(); + doReturn(384).when(engine).getHeight(); + doReturn(-64).when(engine).getMinHeight(); + doReturn(320).when(engine).getMaxHeight(); + doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks(); + doReturn(new SeedManager(1337L)).when(engine).getSeedManager(); + IrisComplex complex = new IrisComplex(engine); + doReturn(complex).when(engine).getComplex(); + try { + assertEquals("alpha", complex.getTrueBiomeStream().get(15, 0).getLoadKey()); + assertEquals("beta", complex.getTrueBiomeStream().get(16, 0).getLoadKey()); + assertEquals("gamma", complex.getTrueBiomeStream().get(0, 16).getLoadKey()); + assertEquals(137D, complex.getBaseTerrainHeightStream().getDouble(15, 0), 0D); + assertEquals(147D, complex.getBaseTerrainHeightStream().getDouble(16, 0), 0D); + assertEquals(157D, complex.getBaseTerrainHeightStream().getDouble(0, 16), 0D); + assertEquals("owned", complex.getRegionStream().get(16, 0).getLoadKey()); + assertEquals("iris-focus/", complex.getRegionStream().get(0, 16).getLoadKey().substring(0, 11)); + assertEquals("alpha", dimension.getFocus()); + try (ExecutorService readers = Executors.newFixedThreadPool(2)) { + List> queries = List.of( + () -> complex.getTrueBiomeStream().get(15, 0).getLoadKey(), + () -> complex.getTrueBiomeStream().get(16, 0).getLoadKey()); + List> values = readers.invokeAll(queries); + assertEquals("alpha", values.get(0).get()); + assertEquals("beta", values.get(1).get()); + } + doReturn(false).when(engine).isStudio(); + IrisComplex production = new IrisComplex(engine); + try { + assertNull(production.getBiomeBuffet()); + assertEquals("alpha", production.getTrueBiomeStream().get(16, 0).getLoadKey()); + } finally { + production.close(); + } + } finally { + complex.close(); + } + } finally { + data.close(); + } + } + + @Test + public void everyBuffetSizeHasStableCellsAndNoWrappedOrNegativeCells() throws Exception { + IrisData data = IrisData.openRuntime(fixture().toFile()); + try { + IrisDimension dimension = data.getDimensionLoader().load("main"); + for (StudioMode mode : StudioMode.values()) { + int size = mode.biomeSizeChunks(); + if (size == 0) { + continue; + } + dimension.setStudioMode(mode); + BiomeBuffetLayout layout = new BiomeBuffetLayout(dimension, () -> data); + assertEquals("alpha", layout.chunk(size - 1, size - 1).biome().getLoadKey()); + assertEquals("beta", layout.chunk(size, 0).biome().getLoadKey()); + assertEquals("gamma", layout.chunk(0, size).biome().getLoadKey()); + assertNotNull(layout.chunk(size, size)); + assertNull(layout.chunk(size * 2, 0)); + assertNull(layout.chunk(0, size * 2)); + assertNull(layout.chunk(-1, 0)); + assertNull(layout.chunk(0, -1)); + } + } finally { + data.close(); + } + } + + @Test + public void unrelatedBuffetBiomesDoNotChangeTheFocusedBiomesGeneratorNoise() throws Exception { + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.platformName()).thenReturn("bukkit"); + IrisPlatforms.bind(platform); + Path pack = fixture(); + Files.writeString(pack.resolve("biomes/alpha.json"), + "{\"children\":[\"beta\"],\"generators\":[{\"generator\":\"flat\",\"min\":0,\"max\":40}]}"); + Files.writeString(pack.resolve("biomes/gamma.json"), + "{\"generators\":[{\"generator\":\"unrelated\",\"min\":0,\"max\":40}]}"); + Files.writeString(pack.resolve("generators/unrelated.json"), + "{\"composite\":[{\"enabled\":false,\"offsetY\":1}]}"); + IrisData data = IrisData.openRuntime(pack.toFile()); + try (MockedStatic blocks = mockStatic(B.class)) { + PlatformBlockState block = mock(PlatformBlockState.class); + when(block.isFluid()).thenReturn(true); + blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block); + IrisDimension dimension = data.getDimensionLoader().load("main"); + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + doReturn(data).when(engine).getData(); + doReturn(dimension).when(engine).getDimension(); + doReturn(data.getBiomeLoader().load("alpha")).when(engine).getFocus(); + doReturn(true).when(engine).isStudio(); + doReturn(384).when(engine).getHeight(); + doReturn(-64).when(engine).getMinHeight(); + doReturn(320).when(engine).getMaxHeight(); + doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks(); + doReturn(new SeedManager(1337L)).when(engine).getSeedManager(); + IrisComplex buffet = new IrisComplex(engine); + doReturn(false).when(engine).isStudio(); + IrisComplex focused = new IrisComplex(engine); + try { + assertEquals(127D, focused.getBaseTerrainHeightStream().getDouble(0, 0), 0D); + assertEquals(focused.getBaseTerrainHeightStream().getDouble(0, 0), + buffet.getBaseTerrainHeightStream().getDouble(0, 0), 0D); + assertEquals(167D, buffet.getBaseTerrainHeightStream().getDouble(0, 16), 0D); + } finally { + focused.close(); + buffet.close(); + } + } finally { + data.close(); + } + } + + private Path fixture() throws Exception { + Path pack = temporaryFolder.newFolder().toPath(); + Files.createDirectories(pack.resolve("dimensions")); + Files.createDirectories(pack.resolve("regions")); + Files.createDirectories(pack.resolve("biomes")); + Files.createDirectories(pack.resolve("generators")); + Files.writeString(pack.resolve("dimensions/main.json"), """ + {"focus":"alpha","regions":["owned"],"studioMode":"BIOME_BUFFET_1x1", + "landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}} + """); + Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"alpha\"]}"); + Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}"); + String[] names = {"alpha", "beta", "gamma", "zeta"}; + for (int index = 0; index < names.length; index++) { + int height = (index + 1) * 10; + Files.writeString(pack.resolve("biomes/" + names[index] + ".json"), + "{\"generators\":[{\"generator\":\"flat\",\"min\":" + height + ",\"max\":" + height + "}]" + + (index == 0 ? ",\"children\":[\"beta\"]" : "") + "}"); + } + return pack; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexStudioCacheTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexStudioCacheTest.java index a1b748297..46ed155f0 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexStudioCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexStudioCacheTest.java @@ -11,12 +11,22 @@ public class IrisComplexStudioCacheTest { @Test public void studioUsesWideNoiseCacheWithoutChangingConfiguredLiveWorldSize() { Engine engine = mock(Engine.class); - when(engine.isStudio()).thenReturn(false); - assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024)); - + assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, false)); when(engine.isStudio()).thenReturn(true); - assertEquals(32_768, IrisComplex.noiseCacheSize(engine, 1_024)); - assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536)); + assertEquals(32_768, IrisComplex.noiseCacheSize(engine, 1_024, false)); + assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536, false)); + } + + @Test + public void detachedRuntimesKeepTheConfiguredCacheSizeInStudio() { + // Historical activation runtimes only answer transition-band and saved-biome queries; every + // one of them carrying the Studio-wide cache multiplied resident memory per hotload. + Engine engine = mock(Engine.class); + when(engine.isStudio()).thenReturn(true); + assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, true)); + assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536, true)); + when(engine.isStudio()).thenReturn(false); + assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, true)); } } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java index 5124af86e..627396239 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineGenerationRuntimeScopeTest.java @@ -345,7 +345,7 @@ public class IrisEngineGenerationRuntimeScopeTest { "1".repeat(64), Map.of( new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, transitionPlan) -> complexOne + (engine, transitionPlan, detached) -> complexOne ) ), new GenerationKernelRegistry.Kernel( @@ -353,7 +353,7 @@ public class IrisEngineGenerationRuntimeScopeTest { "2".repeat(64), Map.of( new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, transitionPlan) -> complexTwo + (engine, transitionPlan, detached) -> complexTwo ) ) ) @@ -362,9 +362,9 @@ public class IrisEngineGenerationRuntimeScopeTest { GenerationKernelRegistry.RuntimeKernel kernelTwo = kernels.select(versionTwo); IrisEngine factoryContext = mock(IrisEngine.class); RuntimeFixture first = runtime( - 1, 1D, 1D, 1D, kernelOne, kernelOne.createComplex(factoryContext, null)); + 1, 1D, 1D, 1D, kernelOne, kernelOne.createComplex(factoryContext, null, false)); RuntimeFixture second = runtime( - 2, 1D, 1D, 1D, kernelTwo, kernelTwo.createComplex(factoryContext, null)); + 2, 1D, 1D, 1D, kernelTwo, kernelTwo.createComplex(factoryContext, null, false)); IrisEngine engine = engine(first.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class)); IrisEngine.GenerationRuntimeBinding firstBinding = new IrisEngine.GenerationRuntimeBinding( engine, first.runtime); @@ -1006,7 +1006,7 @@ public class IrisEngineGenerationRuntimeScopeTest { return new GenerationKernelRegistry.RuntimeKernel( version, "f".repeat(64), - (engine, transitionPlan) -> mock(IrisComplex.class) + (engine, transitionPlan, detached) -> mock(IrisComplex.class) ); } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEngineWorldSaveTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEngineWorldSaveTest.java new file mode 100644 index 000000000..895763952 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisEngineWorldSaveTest.java @@ -0,0 +1,209 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.EngineLifecycleTasks; +import art.arcane.iris.engine.framework.EngineWorldManager; +import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.util.common.scheduling.J; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.concurrent.Callable; +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.AtomicReference; +import java.util.concurrent.locks.LockSupport; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisEngineWorldSaveTest { + @Test + public void ownerReturnsWhileStorageFlushesAndShutdownDrainWaits() throws Exception { + Fixture fixture = fixture(); + Thread owner = Thread.currentThread(); + EngineMantle mantle = fixture.engine().getMantle(); + EngineWorldManager worldManager = fixture.engine().getWorldManager(); + doAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + return null; + }).when(mantle).save(); + doAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + return null; + }).when(worldManager).onSave(); + doAnswer(invocation -> { + assertSame(owner, Thread.currentThread()); + return null; + }).when(fixture.engine()).saveEngineData(); + try (ExecutorService io = Executors.newSingleThreadExecutor(); + ExecutorService closer = Executors.newSingleThreadExecutor(); + MockedStatic scheduler = schedule(fixture, io)) { + try { + assertTrue(fixture.engine().requestSave()); + assertTrue(fixture.entered().await(5, TimeUnit.SECONDS)); + assertFalse(owner == fixture.flushThread().get()); + verify(fixture.engine().getWorldManager()).onSave(); + verify(fixture.engine()).saveEngineData(); + fixture.tasks().closeBackgroundTaskAdmission(); + fixture.sessions().sealAndAwait("close", 1_000L, true); + Future close = closer.submit( + () -> fixture.tasks().drainBackgroundTasks("close")); + assertThrows(TimeoutException.class, () -> close.get(100, TimeUnit.MILLISECONDS)); + fixture.release().countDown(); + close.get(5, TimeUnit.SECONDS).requireComplete("close"); + assertEquals(0, fixture.sessions().activeLeases()); + } finally { + fixture.release().countDown(); + } + } + } + + @Test + public void alreadyAdmittedSaveSurvivesConcurrentAdmissionClosure() throws Exception { + Fixture fixture = fixture(); + try (ExecutorService io = Executors.newSingleThreadExecutor(); + MockedStatic scheduler = schedule(fixture, io)) { + try { + assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save_event", () -> { + fixture.tasks().closeBackgroundTaskAdmission(); + when(fixture.engine().isClosing()).thenReturn(true); + assertTrue(fixture.engine().requestSave()); + })); + assertTrue(fixture.entered().await(5, TimeUnit.SECONDS)); + assertFalse(fixture.tasks().scheduleTrackedTask(() -> { + throw new AssertionError("New background task entered after admission closed"); + })); + fixture.release().countDown(); + fixture.tasks().drainBackgroundTasks("close").requireComplete("close"); + } finally { + fixture.release().countDown(); + } + } + } + + @Test + public void storageFailureLogsOriginalCauseAndFailsAnActiveShutdownDrain() throws Exception { + Fixture fixture = fixture(); + IllegalStateException failure = new IllegalStateException("ownership storage write failed"); + fixture.failure().set(failure); + AtomicReference closeThread = new AtomicReference<>(); + try (ExecutorService io = Executors.newSingleThreadExecutor(); + ExecutorService closer = Executors.newSingleThreadExecutor(); + MockedStatic scheduler = schedule(fixture, io)) { + try { + assertTrue(fixture.engine().requestSave()); + assertTrue(fixture.entered().await(5, TimeUnit.SECONDS)); + Future close = closer.submit(() -> { + closeThread.set(Thread.currentThread()); + return fixture.tasks().drainBackgroundTasks("close"); + }); + awaitDrainWaiter(closeThread); + fixture.release().countDown(); + EngineBackgroundTasks.BackgroundTaskDrain drain = close.get(5, TimeUnit.SECONDS); + assertNotNull(drain.failure()); + assertSame(failure, drain.failure().getCause()); + assertThrows(IllegalStateException.class, () -> drain.requireComplete("close")); + io.submit(() -> {}).get(5, TimeUnit.SECONDS); + assertSame(failure, fixture.logged().get()); + } finally { + fixture.release().countDown(); + } + } + } + + @Test + public void continuationRequiresAnActiveLifecycleLease() throws Exception { + Fixture fixture = fixture(); + assertThrows(IllegalStateException.class, + () -> fixture.tasks().scheduleAdmittedTask(fixture.engine(), () -> {})); + fixture.sessions().sealAndAwait("close", 1_000L, true); + when(fixture.engine().isClosing()).thenReturn(true); + assertFalse(fixture.engine().requestSave()); + } + + @SuppressWarnings("unchecked") + private static MockedStatic schedule(Fixture fixture, ExecutorService io) { + MockedStatic scheduler = mockStatic(J.class); + scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> { + Callable task = invocation.getArgument(0); + return io.submit(() -> { + try (MockedStatic storage = mockStatic(NativeStructureOwnershipStore.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + storage.when(() -> NativeStructureOwnershipStore.flush(fixture.engine())).thenAnswer(call -> { + fixture.flushThread().set(Thread.currentThread()); + fixture.entered().countDown(); + assertTrue(fixture.release().await(5, TimeUnit.SECONDS)); + if (fixture.failure().get() != null) { + throw fixture.failure().get(); + } + return null; + }); + logging.when(() -> IrisLogging.reportError(any(Throwable.class))).thenAnswer(call -> { + fixture.logged().set(call.getArgument(0)); + return null; + }); + return task.call(); + } + }); + }); + return scheduler; + } + + private static Fixture fixture() throws Exception { + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + GenerationSessionManager sessions = new GenerationSessionManager(true); + IrisEngine engine = mock(IrisEngine.class); + when(engine.getGenerationSessions()).thenReturn(sessions); + when(engine.getGenerationSessionId()).thenAnswer(invocation -> sessions.currentSessionId()); + when(engine.getMantle()).thenReturn(mock(EngineMantle.class)); + when(engine.getWorldManager()).thenReturn(mock(EngineWorldManager.class)); + Field background = IrisEngine.class.getDeclaredField("backgroundTasks"); + background.setAccessible(true); + background.set(engine, tasks); + doCallRealMethod().when(engine).requestSave(); + return new Fixture(engine, tasks, sessions, new CountDownLatch(1), new CountDownLatch(1), + new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>()); + } + + private static void awaitDrainWaiter(AtomicReference reference) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline) { + Thread thread = reference.get(); + if (thread != null && thread.getState() == Thread.State.TIMED_WAITING) { + for (StackTraceElement frame : thread.getStackTrace()) { + if (frame.getClassName().equals(EngineBackgroundTasks.class.getName()) + && frame.getMethodName().equals("drainBackgroundTasks")) { + return; + } + } + } + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + throw new AssertionError("Shutdown did not wait for its ownership storage flush"); + } + + private record Fixture(IrisEngine engine, EngineBackgroundTasks tasks, GenerationSessionManager sessions, + CountDownLatch entered, CountDownLatch release, AtomicReference flushThread, + AtomicReference failure, AtomicReference logged) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/StudioGenerationUpdateTest.java b/core/src/test/java/art/arcane/iris/engine/StudioGenerationUpdateTest.java index efb36dbce..308f54c52 100644 --- a/core/src/test/java/art/arcane/iris/engine/StudioGenerationUpdateTest.java +++ b/core/src/test/java/art/arcane/iris/engine/StudioGenerationUpdateTest.java @@ -11,6 +11,7 @@ import org.junit.rules.TemporaryFolder; import java.io.IOException; 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; @@ -20,6 +21,7 @@ import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -47,6 +49,81 @@ public final class StudioGenerationUpdateTest { verifyNoMoreInteractions(history); } + @Test + public void unchangedAuthoringRootAliasNeedsNoSnapshot() throws Exception { + Path source = temporary.newFolder("unchanged-alias-target").toPath(); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/test.json"), "{}"); + Path alias = temporary.getRoot().toPath().resolve("unchanged-alias"); + Files.createSymbolicLink(alias, source); + String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); + IrisEngine engine = mock(IrisEngine.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationEpoch active = mock(GenerationEpoch.class); + when(engine.getStudioGenerationSource()).thenReturn(alias); + when(history.activeEpoch()).thenReturn(active); + when(active.packFingerprintVersion()).thenReturn(GenerationPackFingerprint.CURRENT_VERSION); + when(active.packFingerprint()).thenReturn(fingerprint); + + assertNull(StudioGenerationUpdate.prepare(engine, history)); + + assertThrows(IOException.class, () -> GenerationPackFingerprint.compute(alias, GenerationPackFingerprint.CURRENT_VERSION)); + verify(history).activeEpoch(); + verifyNoMoreInteractions(history); + } + + @Test + public void changedAuthoringRootAliasReachesValidationAndCleansRejectedSnapshot() throws Exception { + Path source = temporary.newFolder("changed-alias-target").toPath(); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/test.json"), "{}"); + Path alias = temporary.getRoot().toPath().resolve("changed-alias"); + Files.createSymbolicLink(alias, source); + GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(temporary.newFolder("alias-world").toPath()); + Files.createDirectories(paths.generationRoot()); + Files.writeString(paths.manifest(), "unchanged manifest"); + IrisEngine engine = mock(IrisEngine.class); + when(engine.getStudioGenerationSource()).thenReturn(alias); + GenerationHistory history = mock(GenerationHistory.class); + GenerationEpoch active = mock(GenerationEpoch.class); + when(history.activeEpoch()).thenReturn(active); + when(history.paths()).thenReturn(paths); + when(active.packFingerprintVersion()).thenReturn(GenerationPackFingerprint.CURRENT_VERSION); + when(active.packFingerprint()).thenReturn("0".repeat(64)); + + IOException failure = assertThrows(IOException.class, () -> StudioGenerationUpdate.prepare(engine, history)); + + assertTrue(failure.getMessage(), failure.getMessage().contains( + "Studio pack update is invalid: Dimension 'test' declares no regions.")); + assertEquals("unchanged manifest", Files.readString(paths.manifest())); + try (Stream entries = Files.list(paths.generationRoot())) { + assertEquals(1L, entries.count()); + } + verify(history, times(2)).activeEpoch(); + verify(history).paths(); + verifyNoMoreInteractions(history); + } + + @Test + public void authoringRootAliasStillRejectsInternalFileAndDirectoryLinks() throws Exception { + for (boolean directory : List.of(false, true)) { + Path source = temporary.newFolder("internal-link-pack-" + directory).toPath(); + Path outside = directory ? temporary.newFolder("external-directory").toPath() + : temporary.newFile("external-file.json").toPath(); + Files.createSymbolicLink(source.resolve("linked-resource"), outside); + Path alias = temporary.getRoot().toPath().resolve("unsafe-alias-" + directory); + Files.createSymbolicLink(alias, source); + IrisEngine engine = mock(IrisEngine.class); + when(engine.getStudioGenerationSource()).thenReturn(alias); + GenerationHistory history = mock(GenerationHistory.class); + + IOException failure = assertThrows(IOException.class, () -> StudioGenerationUpdate.prepare(engine, history)); + + assertTrue(failure.getMessage(), failure.getMessage().contains("rejected symbolic link")); + verifyNoInteractions(history); + } + } + @Test public void invalidPackLeavesManifestUntouchedAndRemovesItsTemporarySnapshot() throws Exception { Path source = temporary.newFolder("invalid-pack").toPath(); diff --git a/core/src/test/java/art/arcane/iris/engine/history/DenseTransitionGeometryReference.java b/core/src/test/java/art/arcane/iris/engine/history/DenseTransitionGeometryReference.java new file mode 100644 index 000000000..6b0bd2b00 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/DenseTransitionGeometryReference.java @@ -0,0 +1,195 @@ +package art.arcane.iris.engine.history; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class DenseTransitionGeometryReference { + private static final int MAXIMUM_OPENING_HEIGHT = 64; + private static final BoundaryColumnGeometry.Voxel AIR = new BoundaryColumnGeometry.Voxel( + "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); + private static final BoundaryColumnGeometry.Voxel FLUID_BARRIER = new BoundaryColumnGeometry.Voxel( + "minecraft:obsidian", BoundaryColumnGeometry.Phase.SOLID, "", false); + + private DenseTransitionGeometryReference() { + } + + public static BoundaryColumnGeometry blendColumn( + BoundaryGeometryInfluence influence, + int blockX, + int blockZ, + BoundaryColumnGeometry currentGeometry + ) { + Objects.requireNonNull(influence, "Boundary influence"); + Objects.requireNonNull(currentGeometry, "Current geometry"); + if (influence.newTerrainWeight() == 1D || influence.contributions().isEmpty() + || currentGeometry.height() == 0) { + return currentGeometry; + } + ColumnProfile current = new ColumnProfile(currentGeometry); + ArrayList historical = new ArrayList<>(influence.contributions().size()); + for (BoundaryGeometryInfluence.Contribution contribution : influence.contributions()) { + BoundaryColumnGeometry geometry = contribution.geometry(); + if (geometry.minimumY() != currentGeometry.minimumY() || geometry.height() != currentGeometry.height()) { + throw new IllegalArgumentException("Boundary geometry does not match the current vertical layout"); + } + historical.add(new WeightedProfile(new ColumnProfile(geometry), contribution.weight())); + } + ArrayList result = new ArrayList<>(currentGeometry.height()); + boolean changed = false; + for (int offset = 0; offset < currentGeometry.height(); offset++) { + BoundaryColumnGeometry.Voxel value = blendVoxel(current, historical, offset, + influence.newTerrainWeight(), influence.openingWeight(), GenerationBlend.usesHistoricalMaterial(blockX, + currentGeometry.minimumY() + offset, blockZ, influence.newTerrainWeight())); + result.add(value); + changed |= !value.equals(current.voxels.get(offset)); + } + return changed ? BoundaryColumnGeometry.fromVoxels(currentGeometry.minimumY(), result) : currentGeometry; + } + + private static BoundaryColumnGeometry.Voxel blendVoxel( + ColumnProfile current, + List historical, + int offset, + double currentWeight, + double openingWeight, + boolean historicalMaterial + ) { + BoundaryColumnGeometry.Voxel currentVoxel = current.voxels.get(offset); + if (currentVoxel.protectedContent()) { + return currentVoxel; + } + boolean historicalOpening = false; + double solid = 0D; + double fluid = 0D; + double total = 0D; + double solidMaterialWeight = -1D; + double fluidMaterialWeight = -1D; + BoundaryColumnGeometry.Voxel oldSolid = null; + BoundaryColumnGeometry.Voxel oldFluid = null; + for (WeightedProfile contribution : historical) { + ColumnProfile profile = contribution.profile(); + if (profile.voxels.get(offset).protectedContent()) { + continue; + } + historicalOpening |= profile.enclosedOpenings[offset]; + double weight = contribution.weight(); + total += weight; + solid += profile.solidDistances[offset] * weight; + fluid += profile.fluidDistances[offset] * weight; + if (profile.solidMaterials[offset] != null && weight > solidMaterialWeight) { + oldSolid = profile.solidMaterials[offset]; + solidMaterialWeight = weight; + } + if (profile.fluidMaterials[offset] != null && weight > fluidMaterialWeight) { + oldFluid = profile.fluidMaterials[offset]; + fluidMaterialWeight = weight; + } + } + if (total == 0D) { + return currentVoxel; + } + double blendedSolid = GenerationBlend.interpolate(solid / total, + current.solidDistances[offset], historicalOpening ? openingWeight : currentWeight); + if (blendedSolid > 0D) { + return selectMaterial(oldSolid, current.solidMaterials[offset], historicalMaterial); + } + double blendedFluid = GenerationBlend.interpolate(fluid / total, + current.fluidDistances[offset], historicalOpening ? openingWeight : currentWeight); + if (blendedFluid > 0D) { + BoundaryColumnGeometry.Voxel newFluid = current.fluidMaterials[offset]; + if (oldFluid != null && newFluid != null && !fluidFamily(oldFluid).equals(fluidFamily(newFluid))) { + return FLUID_BARRIER; + } + return selectMaterial(oldFluid, newFluid, currentWeight <= 0.5D); + } + return AIR; + } + + private static String fluidFamily(BoundaryColumnGeometry.Voxel voxel) { + String key = voxel.fluidStateKey(); + int properties = key.indexOf('['); + return properties < 0 ? key : key.substring(0, properties); + } + + private static BoundaryColumnGeometry.Voxel selectMaterial( + BoundaryColumnGeometry.Voxel historical, + BoundaryColumnGeometry.Voxel current, + boolean historicalMaterial + ) { + if (historical == null) { + return current == null ? AIR : current; + } + return current == null || historicalMaterial ? historical : current; + } + + private static BoundaryColumnGeometry.Voxel[] nearestMaterials( + List voxels, + BoundaryColumnGeometry.Phase phase + ) { + BoundaryColumnGeometry.Voxel[] materials = new BoundaryColumnGeometry.Voxel[voxels.size()]; + int[] distances = new int[voxels.size()]; + int last = -voxels.size() - 1; + BoundaryColumnGeometry.Voxel nearest = null; + for (int index = 0; index < voxels.size(); index++) { + BoundaryColumnGeometry.Voxel voxel = voxels.get(index); + if (voxel.phase() == phase && !voxel.protectedContent()) { + nearest = voxel; + last = index; + } + materials[index] = nearest; + distances[index] = index - last; + } + last = voxels.size() * 2; + nearest = null; + for (int index = voxels.size() - 1; index >= 0; index--) { + BoundaryColumnGeometry.Voxel voxel = voxels.get(index); + if (voxel.phase() == phase && !voxel.protectedContent()) { + nearest = voxel; + last = index; + } + if (nearest != null && last - index < distances[index]) { + materials[index] = nearest; + } + } + return materials; + } + + private static boolean[] enclosedOpenings(List voxels) { + boolean[] openings = new boolean[voxels.size()]; + int lowerSolid = -1; + for (int index = 0; index < voxels.size(); index++) { + BoundaryColumnGeometry.Voxel voxel = voxels.get(index); + if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) { + if (lowerSolid >= 0 && index - lowerSolid - 1 <= MAXIMUM_OPENING_HEIGHT) { + for (int opening = lowerSolid + 1; opening < index; opening++) { + openings[opening] = !voxels.get(opening).protectedContent(); + } + } + lowerSolid = index; + } + } + return openings; + } + + private static final class ColumnProfile { + private final List voxels; + private final boolean[] enclosedOpenings; + private final double[] solidDistances; + private final double[] fluidDistances; + private final BoundaryColumnGeometry.Voxel[] solidMaterials; + private final BoundaryColumnGeometry.Voxel[] fluidMaterials; + + private ColumnProfile(BoundaryColumnGeometry geometry) { + voxels = geometry.voxels(); + enclosedOpenings = enclosedOpenings(voxels); + solidDistances = geometry.solidDistances(); + fluidDistances = geometry.fluidDistances(); + solidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.SOLID); + fluidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.FLUID); + } + } + + private record WeightedProfile(ColumnProfile profile, double weight) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRecoveryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRecoveryTest.java index 1f13bfba8..b38d3bdd0 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRecoveryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRecoveryTest.java @@ -52,7 +52,7 @@ public class GenerationHistoryRecoveryTest extends GenerationHistorySupport { "1".repeat(64), Map.of( new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, transitionPlan) -> { + (engine, transitionPlan, detached) -> { throw new AssertionError("History-only kernel factory was invoked."); } ) @@ -62,7 +62,7 @@ public class GenerationHistoryRecoveryTest extends GenerationHistorySupport { "2".repeat(64), Map.of( new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, transitionPlan) -> { + (engine, transitionPlan, detached) -> { throw new AssertionError("History-only kernel factory was invoked."); } ) diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterPromotionTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterPromotionTest.java index 8d3625096..bd2049db6 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterPromotionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterPromotionTest.java @@ -233,7 +233,7 @@ public final class GenerationHistoryRuntimeRouterPromotionTest extends Generatio GenerationKernelRegistry upgraded = new GenerationKernelRegistry(version, List.of( new GenerationKernelRegistry.Kernel(1, "9".repeat(64), Map.of( new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, plan) -> { throw new AssertionError("Mock runtime factory owns this test."); })))); + (engine, plan, detached) -> { throw new AssertionError("Mock runtime factory owns this test."); })))); GenerationHistory history = GenerationHistory.open(world, upgraded); IrisEngine engine = mock(IrisEngine.class); FakeRuntimeFactory runtimes = new FakeRuntimeFactory(); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterSupport.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterSupport.java index ca5dcaa50..ec0488ddd 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterSupport.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistoryRuntimeRouterSupport.java @@ -158,7 +158,7 @@ abstract class GenerationHistoryRuntimeRouterSupport extends GenerationHistorySu "1".repeat(64), Map.of( new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, transitionPlan) -> { + (engine, transitionPlan, detached) -> { throw new AssertionError("Mock runtime factory owns this test."); } ) @@ -168,7 +168,7 @@ abstract class GenerationHistoryRuntimeRouterSupport extends GenerationHistorySu "2".repeat(64), Map.of( new GenerationKernelRegistry.AlgorithmVersion(1, 1), - (engine, transitionPlan) -> { + (engine, transitionPlan, detached) -> { throw new AssertionError("Mock runtime factory owns this test."); } ) @@ -270,7 +270,7 @@ abstract class GenerationHistoryRuntimeRouterSupport extends GenerationHistorySu when(binding.runtimeKernel()).thenReturn(new GenerationKernelRegistry.RuntimeKernel( version, epoch.kernelImplementationFingerprint(), - (engine, transitionPlan) -> mock(IrisComplex.class) + (engine, transitionPlan, detached) -> mock(IrisComplex.class) )); when(binding.mantleStorageDirectory()).thenReturn(mantle); when(binding.transitionPlan()).thenReturn(plan); diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistorySupport.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistorySupport.java index 56bfa8d2a..164296acf 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationHistorySupport.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationHistorySupport.java @@ -28,7 +28,7 @@ public abstract class GenerationHistorySupport { return new GenerationKernelRegistry(version, List.of(new GenerationKernelRegistry.Kernel( version.generatorAbi(), fingerprint.repeat(64), Map.of(new GenerationKernelRegistry.AlgorithmVersion(version.rngVersion(), version.seedDerivationVersion()), - (engine, transition) -> { + (engine, transition, detached) -> { throw new AssertionError("Saved terrain capture invoked a generator factory"); })))); } diff --git a/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelRegistryTest.java b/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelRegistryTest.java index 9aca7794a..84b02e728 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelRegistryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/GenerationKernelRegistryTest.java @@ -78,7 +78,7 @@ public class GenerationKernelRegistryTest { version.rngVersion(), version.seedDerivationVersion() ), - (engine, transitionPlan) -> mock(IrisComplex.class) + (engine, transitionPlan, detached) -> mock(IrisComplex.class) ) )) ); @@ -87,7 +87,7 @@ public class GenerationKernelRegistryTest { private static GenerationKernelRegistry.RuntimeFactory seedFactory(SeedManager seedManager) { return new GenerationKernelRegistry.RuntimeFactory() { @Override - public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan) { + public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan, boolean detached) { return mock(IrisComplex.class); } diff --git a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java index ca821d85e..031b4b8c7 100644 --- a/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/history/SavedBiomeRuntimeTest.java @@ -1,9 +1,13 @@ package art.arcane.iris.engine.history; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.BiomeEnvironment; import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.testsupport.Await; import art.arcane.iris.testsupport.PlatformLeakGuard; @@ -235,6 +239,99 @@ public class SavedBiomeRuntimeTest { } } + @Test + public void archivedFocusedChildResolvesSurfaceCaveAndFloodedRulesWithItsAuthoredRegion() throws Exception { + assertFocusedEnvironments(true, false, false); + } + + @Test + public void archivedUnownedFocusReconstructsItsRegionWithoutUsingCurrentFocusOrRemappingMissingRegions() throws Exception { + assertFocusedEnvironments(false, false, false); + } + + @Test + public void archivedStackFocusReconstructsTheReferencedDimensionsRegion() throws Exception { + assertFocusedEnvironments(false, true, false); + } + + @Test + public void archivedBuffetResolvesUnownedCellsWithoutAnAuthoredFocusOrNewGeneration() throws Exception { + assertFocusedEnvironments(false, false, true); + } + + private void assertFocusedEnvironments(boolean authoredOwner, boolean stacked, boolean buffet) throws Exception { + String epochId = "a".repeat(64); + Path pack = history.paths().packRoot(epochId); + Files.createDirectories(pack.resolve("dimensions")); + Files.createDirectories(pack.resolve("biomes")); + Files.createDirectories(pack.resolve("regions")); + String focusedDimension = buffet + ? "{\"regions\":[\"tropical\"],\"studioMode\":\"BIOME_BUFFET_3x3\"}" + : "{\"regions\":[\"tropical\"],\"focus\":\"highlands\"}"; + Files.writeString(pack.resolve("dimensions/main.json"), stacked + ? "{\"regions\":[\"tropical\"],\"dimensionStack\":{\"dimensions\":[\"lower\",\"main\"]}}" + : focusedDimension); + if (stacked) { + Files.writeString(pack.resolve("dimensions/lower.json"), focusedDimension); + } + Files.writeString(pack.resolve("regions/tropical.json"), "{\"landBiomes\":[\"wilds\"]}"); + Files.writeString(pack.resolve("biomes/wilds.json"), authoredOwner ? "{\"children\":[\"highlands\"]}" : "{}"); + Files.writeString(pack.resolve("biomes/highlands.json"), """ + {"carvingBiome":"chalk-gardens","riverPolicy":{"floodedCaveBiomes":["flooded"]}} + """); + Files.writeString(pack.resolve("biomes/chalk-gardens.json"), "{}"); + Files.writeString(pack.resolve("biomes/flooded.json"), "{}"); + GenerationEpoch epoch = history.manifest().epoch(epochId).orElseThrow(); + GenerationEpoch.DimensionContract contract = mock(GenerationEpoch.DimensionContract.class); + when(epoch.dimensionContract()).thenReturn(contract); + when(epoch.registryContract()).thenReturn(GenerationRegistryContract.empty()); + when(contract.dimensionKey()).thenReturn("main"); + when(history.packRoot(1L)).thenReturn(pack); + when(engine.getDimension()).thenReturn(new IrisDimension().setFocus("current-unrelated-focus")); + IrisData capturedData = IrisData.openRuntime(pack.toFile()); + String regionKey; + try { + IrisDimension dimension = capturedData.getDimensionLoader().load(stacked ? "lower" : "main"); + IrisBiome biome = capturedData.getBiomeLoader().load("highlands"); + IrisRegion region = dimension.resolveFocusRegion(biome, () -> capturedData); + regionKey = region.getLoadKey(); + } finally { + capturedData.close(); + } + assertEquals(authoredOwner, regionKey.equals("tropical")); + SavedBiomeChunk.Cell surface = new SavedBiomeChunk.Cell(1L, "highlands", regionKey); + SavedBiomeChunk.Cell cave = new SavedBiomeChunk.Cell(1L, "chalk-gardens", regionKey); + SavedBiomeChunk.Cell flooded = new SavedBiomeChunk.Cell(1L, "flooded", regionKey); + SavedBiomeChunk.Cell missing = new SavedBiomeChunk.Cell(1L, "highlands", "missing-region"); + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 1L, 0, 128)); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + builder.column(x, z, new SavedBiomeChunk.Column(x == 15 ? missing : surface, cave, + List.of(new SavedBiomeChunk.Span(0, 64, cave), new SavedBiomeChunk.Span(64, 128, flooded)))); + } + } + when(store.get(0, 0)).thenReturn(Optional.of(builder.build())); + try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) { + assertTrue(assertThrows(SavedBiomeUnavailableException.class, () -> runtime.prepareChunk(0, 0)).isLoading()); + awaitIdle(runtime); + BiomeEnvironment surfaceEnvironment = runtime.resolve(0, 100, 0, true).orElseThrow(); + BiomeEnvironment caveEnvironment = runtime.resolveCaveBase(0, 0).orElseThrow(); + BiomeEnvironment floodedEnvironment = runtime.resolve(0, 80, 0, false).orElseThrow(); + assertEquals("highlands", surfaceEnvironment.biome().getLoadKey()); + assertEquals("chalk-gardens", caveEnvironment.biome().getLoadKey()); + assertEquals("flooded", floodedEnvironment.biome().getLoadKey()); + for (BiomeEnvironment environment : List.of(surfaceEnvironment, caveEnvironment, floodedEnvironment)) { + assertEquals(1L, environment.activationId()); + assertEquals(regionKey, environment.region().getLoadKey()); + assertEquals(pack.toFile(), environment.data().getDataFolder()); + } + SavedBiomeUnavailableException unavailable = assertThrows(SavedBiomeUnavailableException.class, + () -> runtime.resolve(15, 100, 0, true)); + assertEquals(false, unavailable.isLoading()); + assertTrue(unavailable.getMessage().contains("missing-region")); + } + } + @Test public void backgroundPreviewWaitsForTheReadWithoutRepeatingTheQuery() throws Exception { allowUnownedChunks(); diff --git a/core/src/test/java/art/arcane/iris/engine/history/TransitionGeometryCursorTest.java b/core/src/test/java/art/arcane/iris/engine/history/TransitionGeometryCursorTest.java new file mode 100644 index 000000000..936f2e50c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/history/TransitionGeometryCursorTest.java @@ -0,0 +1,148 @@ +package art.arcane.iris.engine.history; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +public class TransitionGeometryCursorTest { + private static final BoundaryColumnGeometry.Voxel AIR = voxel("air", BoundaryColumnGeometry.Phase.AIR, false); + private static final BoundaryColumnGeometry.Voxel STONE = voxel("stone", BoundaryColumnGeometry.Phase.SOLID, false); + private static final BoundaryColumnGeometry.Voxel DIRT = voxel("dirt", BoundaryColumnGeometry.Phase.SOLID, false); + private static final BoundaryColumnGeometry.Voxel WATER = voxel("water[level=0]", BoundaryColumnGeometry.Phase.FLUID, false); + private static final List STATES = List.of(AIR, STONE, DIRT, WATER, + voxel("cave_air", BoundaryColumnGeometry.Phase.AIR, false), + voxel("water[level=7]", BoundaryColumnGeometry.Phase.FLUID, false), + voxel("lava[level=0]", BoundaryColumnGeometry.Phase.FLUID, false), + voxel("stone", BoundaryColumnGeometry.Phase.SOLID, true), + voxel("water[level=0]", BoundaryColumnGeometry.Phase.FLUID, true), + voxel("light", BoundaryColumnGeometry.Phase.AIR, true)); + + @Test + public void matchesDenseBlendForRandomMaterialsProtectionWeightsAndRunLengths() { + Random random = new Random(0x72AB490EL); + int[] heights = {1, 2, 63, 64, 65, 128, 768, 4096}; + for (int sample = 0; sample < 320; sample++) { + int height = heights[sample % heights.length]; + int minimumY = switch (sample % 3) { + case 0 -> Integer.MIN_VALUE; + case 1 -> Integer.MAX_VALUE - height + 1; + default -> -256; + }; + BoundaryColumnGeometry current = randomGeometry(random, minimumY, height, sample % 2 == 0); + ArrayList historical = new ArrayList<>(); + for (int index = 0; index < 1 + sample % 4; index++) { + historical.add(randomGeometry(random, minimumY, height, sample % 2 == 0)); + } + assertMatches(current, historical, random.nextDouble(), random.nextDouble(), random); + } + } + + @Test + public void matchesUniformColumnsAtBothHeightLimitsAndCoordinateLimits() { + Random random = new Random(44); + for (int height : new int[]{1, BoundaryColumnGeometry.MAXIMUM_HEIGHT}) { + for (BoundaryColumnGeometry.Voxel current : STATES) { + ArrayList historical = new ArrayList<>(); + for (BoundaryColumnGeometry.Voxel old : List.of(AIR, STONE, WATER)) { + historical.add(geometry(Integer.MAX_VALUE - height + 1, Collections.nCopies(height, old))); + } + assertMatches(geometry(Integer.MAX_VALUE - height + 1, Collections.nCopies(height, current)), + historical, 0.5D, 0.125D, random); + } + } + } + + @Test + public void matchesOpeningCutoffAndProtectedVoxelsInsideOpenings() { + Random random = new Random(1234); + for (int opening : new int[]{1, 63, 64, 65, 66}) { + for (boolean protectedMiddle : new boolean[]{false, true}) { + ArrayList old = new ArrayList<>(); + old.add(STONE); + old.addAll(Collections.nCopies(opening, AIR)); + old.add(DIRT); + if (protectedMiddle) { + old.set(opening / 2 + 1, STATES.get(7)); + } + BoundaryColumnGeometry current = geometry(-256, Collections.nCopies(old.size(), STONE)); + for (double weight : new double[]{0D, 0.25D, 0.5D, 0.75D, 1D}) { + assertMatches(current, List.of(geometry(-256, old)), weight, 0D, random); + } + } + } + } + + @Test + public void keepsLowerMaterialOnEquidistantTiesAndPreservesSingleVoxelPhases() { + Random random = new Random(77); + BoundaryColumnGeometry current = geometry(0, List.of(STONE, STONE, STONE, STONE, STONE)); + BoundaryColumnGeometry old = geometry(0, List.of(STONE, AIR, AIR, AIR, DIRT)); + BoundaryGeometryInfluence influence = influence(List.of(old), 0.25D, 1D); + + assertEquals(STONE, TransitionGeometryBlender.blendColumn(influence, 0, 0, current).voxelAt(2)); + assertMatches(current, List.of(old), 0.25D, 1D, random); + for (BoundaryColumnGeometry.Voxel middle : STATES) { + assertMatches(geometry(0, List.of(AIR, WATER, STONE, WATER, AIR)), + List.of(geometry(0, List.of(STONE, AIR, middle, AIR, DIRT))), 0.5D, 0.125D, random); + } + } + + @Test + public void retainsIdentityForUnchangedAndEmptyColumns() { + BoundaryColumnGeometry empty = BoundaryColumnGeometry.empty(); + assertSame(empty, TransitionGeometryBlender.blendColumn(BoundaryGeometryInfluence.none(), 0, 0, empty)); + BoundaryColumnGeometry current = geometry(0, List.of(STONE, AIR, WATER)); + assertSame(current, TransitionGeometryBlender.blendColumn(influence(List.of(current), 0.5D, 0.5D), + 0, 0, current)); + } + + private static BoundaryColumnGeometry randomGeometry(Random random, int minimumY, int height, boolean singleVoxelRuns) { + ArrayList voxels = new ArrayList<>(height); + while (voxels.size() < height) { + BoundaryColumnGeometry.Voxel voxel = STATES.get(random.nextInt(STATES.size())); + int end = Math.min(height, voxels.size() + (singleVoxelRuns ? 1 : random.nextInt(128) + 1)); + while (voxels.size() < end) { + voxels.add(voxel); + } + } + return geometry(minimumY, voxels); + } + + private static void assertMatches(BoundaryColumnGeometry current, List historical, + double currentWeight, double openingWeight, Random random) { + BoundaryGeometryInfluence influence = influence(historical, currentWeight, openingWeight); + int x = random.nextInt(); + int z = random.nextInt(); + BoundaryColumnGeometry expected = DenseTransitionGeometryReference.blendColumn(influence, x, z, current); + BoundaryColumnGeometry actual = TransitionGeometryBlender.blendColumn(influence, x, z, current); + assertEquals(expected, actual); + if (expected == current) { + assertSame(current, actual); + } + } + + private static BoundaryGeometryInfluence influence(List historical, + double currentWeight, double openingWeight) { + ArrayList contributions = new ArrayList<>(); + double total = historical.size() * (historical.size() + 1) / 2D; + for (int index = 0; index < historical.size(); index++) { + contributions.add(new BoundaryGeometryInfluence.Contribution(historical.get(index), (index + 1) / total)); + } + return new BoundaryGeometryInfluence(currentWeight, openingWeight, contributions); + } + + private static BoundaryColumnGeometry geometry(int minimumY, List voxels) { + return BoundaryColumnGeometry.fromVoxels(minimumY, voxels); + } + + private static BoundaryColumnGeometry.Voxel voxel(String key, BoundaryColumnGeometry.Phase phase, boolean protection) { + return new BoundaryColumnGeometry.Voxel("minecraft:" + key, phase, + phase == BoundaryColumnGeometry.Phase.FLUID ? "minecraft:" + key : "", protection); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java index b1e5bc4a0..d0e095f19 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java @@ -1216,7 +1216,8 @@ public class HydrologyCaveCourseFilterTest { base.deepFluids(), base.surfacePools(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmissionTest.java index 48bb8fd8e..daffd3674 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmissionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmissionTest.java @@ -14,13 +14,15 @@ public class HydrologyCrossTileSurfaceAdmissionTest { 10L, 100L, List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 63, 0)), - true + true, + 128 ); HydrologyCrossTileSurfaceAdmission.Claim blocker = claim( 20L, 200L, List.of(new HydrologyPoint(0, 70, 48), new HydrologyPoint(112, 63, 24)), - true + true, + 128 ); HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit( @@ -29,8 +31,7 @@ public class HydrologyCrossTileSurfaceAdmissionTest { new HydrologyTileKey(0, 0), 0, blocker - )), - 128 + )) ); assertEquals(1, result.rejections().size()); @@ -43,13 +44,15 @@ public class HydrologyCrossTileSurfaceAdmissionTest { 10L, 100L, List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 65, 100)), - false + false, + 64 ); HydrologyCrossTileSurfaceAdmission.Claim blocker = claim( 20L, 200L, List.of(new HydrologyPoint(0, 70, 100), new HydrologyPoint(100, 65, 0)), - false + false, + 64 ); HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit( @@ -58,8 +61,7 @@ public class HydrologyCrossTileSurfaceAdmissionTest { new HydrologyTileKey(0, 0), 0, blocker - )), - 64 + )) ); assertEquals(1, result.rejections().size()); @@ -71,13 +73,15 @@ public class HydrologyCrossTileSurfaceAdmissionTest { 10L, 100L, List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(512, 63, 0)), - true + true, + 192 ); HydrologyCrossTileSurfaceAdmission.Claim blocker = claim( 20L, 200L, List.of(new HydrologyPoint(0, 70, 128), new HydrologyPoint(-512, 63, 128)), - true + true, + 192 ); HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit( @@ -86,8 +90,7 @@ public class HydrologyCrossTileSurfaceAdmissionTest { new HydrologyTileKey(0, 0), 0, blocker - )), - 192 + )) ); assertEquals(1, result.rejections().size()); @@ -99,13 +102,15 @@ public class HydrologyCrossTileSurfaceAdmissionTest { 10L, 100L, List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 63, 0)), - true + true, + 128 ); HydrologyCrossTileSurfaceAdmission.Claim blocker = claim( 20L, 200L, List.of(new HydrologyPoint(0, 70, 256), new HydrologyPoint(100, 63, 256)), - true + true, + 128 ); HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit( @@ -114,18 +119,38 @@ public class HydrologyCrossTileSurfaceAdmissionTest { new HydrologyTileKey(0, 0), 0, blocker - )), - 128 + )) ); assertTrue(result.rejections().isEmpty()); } + @Test + public void localSpacingKeepsDenseNetworksButHonorsTheNeighboringAreaInEitherOrder() { + HydrologyCrossTileSurfaceAdmission.Claim tropical = claim(10L, 100L, + List.of(new HydrologyPoint(-64, 70, 0), new HydrologyPoint(800, 63, 0)), true, 160); + HydrologyCrossTileSurfaceAdmission.Claim otherTropical = claim(20L, 200L, + List.of(new HydrologyPoint(-64, 70, 200), new HydrologyPoint(800, 63, 200)), true, 160); + HydrologyCrossTileSurfaceAdmission.Claim neighboringRegion = claim(30L, 300L, + List.of(new HydrologyPoint(-64, 70, 200), new HydrologyPoint(800, 63, 200)), true, 384); + + assertTrue(HydrologyCrossTileSurfaceAdmission.admit(List.of(tropical), List.of( + new HydrologyCrossTileSurfaceAdmission.RankedClaim(new HydrologyTileKey(-1, 0), 0, otherTropical))) + .rejections().isEmpty()); + assertEquals(1, HydrologyCrossTileSurfaceAdmission.admit(List.of(tropical), List.of( + new HydrologyCrossTileSurfaceAdmission.RankedClaim(new HydrologyTileKey(-1, 0), 0, neighboringRegion))) + .rejections().size()); + assertEquals(1, HydrologyCrossTileSurfaceAdmission.admit(List.of(neighboringRegion), List.of( + new HydrologyCrossTileSurfaceAdmission.RankedClaim(new HydrologyTileKey(-1, 0), 0, tropical))) + .rejections().size()); + } + private HydrologyCrossTileSurfaceAdmission.Claim claim( long courseId, long outletId, List centerline, - boolean reachesOutlet + boolean reachesOutlet, + int sourceSpacing ) { return new HydrologyCrossTileSurfaceAdmission.Claim( courseId, @@ -133,7 +158,8 @@ public class HydrologyCrossTileSurfaceAdmissionTest { centerline.getLast(), reachesOutlet, 4, - centerline + centerline, + sourceSpacing ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java index 619466e65..3577174c7 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java @@ -315,7 +315,8 @@ public class HydrologyFootprintCompilerTest { HydrologyPlannerSettings.Geometry.defaults(), List.of(deepFluid), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); HydraulicSegment segment = new HydraulicSegment( 15L, @@ -2163,7 +2164,9 @@ public class HydrologyFootprintCompilerTest { return new HydrologyPlannerSettings( base.seaLevel(), base.routing(), base.surface(), base.hydraulics(), base.underground(), base.outlets(), geometry, base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(), - base.seaCaves()); + base.seaCaves(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE + ); } private static HydrologyPlannerSettings withUndergroundShape(HydrologyPlannerSettings.ChannelShape shape) { @@ -2193,7 +2196,9 @@ public class HydrologyFootprintCompilerTest { return new HydrologyPlannerSettings( base.seaLevel(), base.routing(), tunedSurface, base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(), - base.seaCaves()); + base.seaCaves(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE + ); } private static HydrologyPlannerSettings seaCaveSettings(int maximumOceanApron, int chamberRadius) { @@ -2227,7 +2232,8 @@ public class HydrologyFootprintCompilerTest { base.deepFluids(), base.surfacePools(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlanHashTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlanHashTest.java index 9c27e2032..76ccc0ace 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlanHashTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlanHashTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; import org.junit.Test; @@ -15,7 +17,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class HydrologyPlanHashTest { - private static final String EXPECTED_PLAN_DIGEST = "de765d4be6c95954f8df344ee2d1dd72c97e8640fc28c92c3ec4717cff0d88e4"; + private static final String EXPECTED_PLAN_DIGEST = "4ff5293a3d4110f54ffa82630ac95efa98cd7d51cf57911475f466c66f1433c8"; private static final List TILES = List.of( new HydrologyTileKey(0, 0), @@ -138,7 +140,8 @@ public class HydrologyPlanHashTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -175,7 +178,8 @@ public class HydrologyPlanHashTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -222,6 +226,8 @@ public class HydrologyPlanHashTest { List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled()); + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE + ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerConfinementTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerConfinementTest.java index 59197bc2c..ba0739ed0 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerConfinementTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerConfinementTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import org.junit.Test; import java.util.ArrayList; @@ -101,7 +103,8 @@ public class HydrologyPlannerConfinementTest { !ocean, !ocean, !ocean, false, false, false, 0D, ocean ? 0D : 1D, 0D, 1D, 1D, 1D, 1D, 1D, "land", "land", "land", "land", "land", "land", - List.of("default"), List.of(), Double.NaN, confines, Double.NaN, true + List.of("default"), List.of(), Double.NaN, confines, Double.NaN, true, + SurfaceRiverPolicy.INHERIT ); } @@ -126,7 +129,8 @@ public class HydrologyPlannerConfinementTest { HydrologyPlannerSettings.Geometry.defaults().drops()), List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java index d5d00edc0..300612280 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.CaveVoxel; import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; @@ -339,7 +341,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { stableGeometry(), List.of(deepFluid), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -417,7 +420,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { base.geometry(), base.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -463,7 +467,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); }; } @@ -508,7 +513,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); }; } @@ -545,7 +551,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java index c39f6422c..c55c96d7f 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.CaveVoxel; import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; @@ -710,7 +712,8 @@ public class HydrologyPlannerGlobalInvariantTest { HydrologyPlannerSettings.Geometry.defaults(), deepFluids, List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -761,7 +764,8 @@ public class HydrologyPlannerGlobalInvariantTest { HydrologyPlannerSettings.Geometry.defaults(), base.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -958,7 +962,8 @@ public class HydrologyPlannerGlobalInvariantTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -1013,7 +1018,8 @@ public class HydrologyPlannerGlobalInvariantTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -1066,7 +1072,8 @@ public class HydrologyPlannerGlobalInvariantTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSeaCaveTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSeaCaveTest.java index 46278a35c..a8fa77a46 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSeaCaveTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSeaCaveTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; import org.junit.Test; @@ -283,12 +285,13 @@ public class HydrologyPlannerSeaCaveTest { "shore", "dry", "flooded", - List.of("beta", "alpha"), + List.of("default"), List.of(), Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -327,7 +330,8 @@ public class HydrologyPlannerSeaCaveTest { List.of(), List.of(), surface.shoreWidth(), - seaCaves + seaCaves, + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java index 984a2b501..a4dda6f87 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java @@ -66,7 +66,9 @@ public class HydrologyPlannerSettingsTest { HydrologyPlannerSettings.Geometry.defaults(), List.of(overlongChannel), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled()) + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE + ) ); } @@ -209,7 +211,9 @@ public class HydrologyPlannerSettingsTest { new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), surface.maximumWidth(), surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), flared), - base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), 0D, HydrologyPlannerSettings.SeaCaves.disabled()); + base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), 0D, HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE + ); assertEquals(1.6D, banks.mouthFlareRatio(), 0D); int widened = (int) StrictMath.ceil(surface.maximumWidth() * (4D - banks.mouthFlareRatio()) / 2D); @@ -372,7 +376,8 @@ public class HydrologyPlannerSettingsTest { base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(), - seaCaves + seaCaves, + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); assertEquals(seaCaves, withCaves.seaCaves()); assertTrue(base.fingerprint() != withCaves.fingerprint()); @@ -397,7 +402,8 @@ public class HydrologyPlannerSettingsTest { List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); HydrologyPlannerSettings withCaves = new HydrologyPlannerSettings( base.seaLevel(), @@ -410,7 +416,8 @@ public class HydrologyPlannerSettingsTest { List.of(), List.of(), surface.shoreWidth(), - HydrologyPlannerSettings.SeaCaves.of(true, 1, 64, 8, 12) + HydrologyPlannerSettings.SeaCaves.of(true, 1, 64, 8, 12), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); int alignedHalo = Math.min(base.routing().maximumRouteLength(), base.routing().sampleSpacing() * 2) @@ -709,7 +716,9 @@ public class HydrologyPlannerSettingsTest { HydrologyPlannerSettings.SeaCaves seaCaves ) { return new HydrologyPlannerSettings(base.seaLevel(), base.routing(), surface, base.hydraulics(), underground, outlets, - geometry, base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(), seaCaves); + geometry, base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(), seaCaves, + HydrologyPlannerSettings.SurfacePolicyBounds.NONE + ); } private static HydrologyPlannerSettings.Surface surfaceWith(HydrologyPlannerSettings.Surface surface, HydrologyPlannerSettings.Banks banks) { diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java index 603875466..61fc91420 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java @@ -115,7 +115,8 @@ public class HydrologyPlannerSurfacePoolTest { List.of(new HydrologyPlannerSettings.SurfacePool( "lava_pool", true, density, spacing, minimumRadius, maximumRadius, depth, 64, null)), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java index d5fdccf39..82cf23821 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.CaveVoxel; import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; @@ -415,7 +417,9 @@ public class HydrologyPlannerTest { routing.valleyPreference(), routing.uphillPenalty(), routing.slopePenalty(), routing.confluenceAttraction(), routing.lengthPreference(), routing.tributaries()), settings.surface(), settings.hydraulics(), settings.underground(), settings.outlets(), settings.geometry(), - settings.deepFluids(), settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves()); + settings.deepFluids(), settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE + ); } private static Set cachedOwnerKeys(HydrologyPlanner planner) throws Exception { @@ -1124,7 +1128,8 @@ public class HydrologyPlannerTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); }; HydrologyTile tile = new HydrologyPlanner(812L, settings, terrain, solidCaveView()).plan(TILE); @@ -1200,7 +1205,8 @@ public class HydrologyPlannerTest { base.geometry(), base.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); HydrologyTerrainSampler repeatedRidges = (int x, int z) -> { if (x >= 112) { @@ -1737,7 +1743,8 @@ public class HydrologyPlannerTest { HydrologyPlannerSettings.Geometry.defaults(), base.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); HydrologyPlannerSettings settings = withOutlets(shaped, HydrologyPlannerSettings.Outlets.of( true, @@ -2014,7 +2021,8 @@ public class HydrologyPlannerTest { base.geometry(), base.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); HydrologyTerrainSampler coast = (int x, int z) -> { if (x >= 112) { @@ -2214,7 +2222,8 @@ public class HydrologyPlannerTest { base.geometry(), base.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); HydrologyTerrainSampler terrain = (int x, int z) -> { if (x >= 112) { @@ -2606,7 +2615,8 @@ public class HydrologyPlannerTest { stableGeometry(), deepFluids, List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -2666,7 +2676,8 @@ public class HydrologyPlannerTest { HydrologyPlannerSettings.Geometry.defaults(), List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -2739,7 +2750,8 @@ public class HydrologyPlannerTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); }; } @@ -2808,7 +2820,8 @@ public class HydrologyPlannerTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -2844,7 +2857,8 @@ public class HydrologyPlannerTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -2889,7 +2903,8 @@ public class HydrologyPlannerTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -2925,7 +2940,8 @@ public class HydrologyPlannerTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -2961,7 +2977,8 @@ public class HydrologyPlannerTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -2989,7 +3006,8 @@ public class HydrologyPlannerTest { HydrologyPlannerSettings.Geometry.defaults(), settings.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -3021,7 +3039,8 @@ public class HydrologyPlannerTest { HydrologyPlannerSettings.Geometry.defaults(), settings.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -3054,7 +3073,8 @@ public class HydrologyPlannerTest { HydrologyPlannerSettings.Geometry.defaults(), settings.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -3086,7 +3106,8 @@ public class HydrologyPlannerTest { HydrologyPlannerSettings.Geometry.defaults(), settings.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -3148,7 +3169,8 @@ public class HydrologyPlannerTest { HydrologyPlannerSettings.Geometry.defaults(), settings.deepFluids(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -3430,9 +3452,9 @@ public class HydrologyPlannerTest { constructor.setAccessible(true); Object node = constructor.newInstance(0, 0, 0, 0, 0, 719L, terrain); Method method = HydrologyRouteGeometry.class.getDeclaredMethod("anchorScore", nodeType, - int.class, int.class, int.class, int.class, double.class); + int.class, int.class, int.class, int.class, double.class, boolean.class); method.setAccessible(true); - return (double) method.invoke(planner.routeGeometry, node, x, z, 0, 0, 0.3D); + return (double) method.invoke(planner.routeGeometry, node, x, z, 0, 0, 0.3D, false); } private List routeCandidates(HydrologyPlanner planner) throws Exception { diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java index 52faae3fd..7dad992ba 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import org.junit.Test; import java.util.ArrayList; @@ -194,7 +196,8 @@ public class HydrologyPlannerTributaryTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -260,7 +263,8 @@ public class HydrologyPlannerTributaryTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -299,7 +303,8 @@ public class HydrologyPlannerTributaryTest { List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @Test @@ -407,7 +412,9 @@ public class HydrologyPlannerTributaryTest { 54, 0D, true, false, 30, 32, false, false, false, false, false, false, 0D, 0D, 0D, 1D, 1D, 1D, 1D, 1D, "ocean_parent", "surface", "mouth", "shore", "dry", "flooded", - List.of("default"), List.of(), Double.NaN, null, Double.NaN, true); + List.of("default"), List.of(), Double.NaN, null, Double.NaN, true, + SurfaceRiverPolicy.INHERIT + ); } int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); boolean source = x >= 0 && x <= 24 && (z >= 8 && z <= 40 || z >= 72 && z <= 104); @@ -415,7 +422,9 @@ public class HydrologyPlannerTributaryTest { height, 1D, false, true, 70, 74, true, true, false, false, source, false, 0D, 0D, source ? 1D : 0D, 1D, 1D, 1D, 1D, 1D, "parent", "surface", "mouth", "shore", "dry", "flooded", - List.of("default"), List.of(), Double.NaN, null, Double.NaN, true); + List.of("default"), List.of(), Double.NaN, null, Double.NaN, true, + SurfaceRiverPolicy.INHERIT + ); } private static HydrologyPlannerSettings undergroundSettings(int tributaries) { @@ -449,7 +458,8 @@ public class HydrologyPlannerTributaryTest { List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerUndergroundKnobsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerUndergroundKnobsTest.java index da845dd5b..6e4de568d 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerUndergroundKnobsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerUndergroundKnobsTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.CaveVoxel; import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; @@ -324,7 +326,8 @@ public class HydrologyPlannerUndergroundKnobsTest { settings.deepFluids(), settings.surfacePools(), settings.widestShoreBiomeWidth(), - settings.seaCaves() + settings.seaCaves(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -357,7 +360,8 @@ public class HydrologyPlannerUndergroundKnobsTest { settings.deepFluids(), settings.surfacePools(), settings.widestShoreBiomeWidth(), - settings.seaCaves() + settings.seaCaves(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -385,7 +389,8 @@ public class HydrologyPlannerUndergroundKnobsTest { settings.deepFluids(), settings.surfacePools(), settings.widestShoreBiomeWidth(), - settings.seaCaves() + settings.seaCaves(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -468,7 +473,8 @@ public class HydrologyPlannerUndergroundKnobsTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @@ -603,7 +609,8 @@ public class HydrologyPlannerUndergroundKnobsTest { List.of(), List.of(), surface.shoreWidth(), - seaCaves + seaCaves, + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -673,7 +680,8 @@ public class HydrologyPlannerUndergroundKnobsTest { List.of(), List.of(), 0D, - seaCaves + seaCaves, + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalSurfacePolicyTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalSurfacePolicyTest.java new file mode 100644 index 000000000..7561331e8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRegionalSurfacePolicyTest.java @@ -0,0 +1,242 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class HydrologyRegionalSurfacePolicyTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + private static final SurfaceRiverPolicy TROPICAL = new SurfaceRiverPolicy("region:tropical", 8D, 160, 0, 3, 4, null, null); + + @Test + public void sourcesAndOutletsUseOnlyTheirOwnedEligibleArea() { + HydrologySampledGrid grid = grid((x, z) -> land(x < 512 ? TROPICAL : SurfaceRiverPolicy.INHERIT, false)); + HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults()); + HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); + + assertEquals(4, budgets.sourceTarget(budgets.area(TROPICAL), 0, planner, TILE)); + assertEquals(2, budgets.outletTarget(budgets.area(TROPICAL), true, planner, TILE)); + int inland = budgets.outletTarget(budgets.area(TROPICAL), false, planner, TILE); + assertTrue(inland == 1 || inland == 2); + assertTrue(budgets.sourceTarget(budgets.area(SurfaceRiverPolicy.INHERIT), 0, planner, TILE) <= 1); + + HydrologySampledGrid tiny = grid((x, z) -> land(x == 0 && z == 0 ? TROPICAL : SurfaceRiverPolicy.INHERIT, false)); + HydrologySurfaceBudgets tinyBudgets = HydrologySurfaceBudgets.sample(tiny, planner.settings.surface().sources()); + int selected = 0; + for (int seed = 0; seed < 256; seed++) { + int target = tinyBudgets.sourceTarget(tinyBudgets.area(TROPICAL), 0, + planner(seed, planner.settings), TILE); + assertTrue(target <= 1); + selected += target; + } + assertTrue("tiny areas acquired a per-tile minimum: " + selected, selected > 0 && selected < 32); + } + + @Test + public void geometrySubareasShareSourceAndOutletBudgets() { + SurfaceRiverPolicy narrow = new SurfaceRiverPolicy("region:tropical", 8D, 160, 0, 3, 4, 128, 24); + HydrologySampledGrid mixed = grid((x, z) -> land(x < 512 ? TROPICAL : narrow, false)); + HydrologySampledGrid uniform = grid((x, z) -> land(TROPICAL, false)); + HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults()); + HydrologySurfaceBudgets mixedBudgets = HydrologySurfaceBudgets.sample(mixed, planner.settings.surface().sources()); + HydrologySurfaceBudgets uniformBudgets = HydrologySurfaceBudgets.sample(uniform, planner.settings.surface().sources()); + + assertEquals(1, mixedBudgets.areas().size()); + assertEquals(mixedBudgets.area(TROPICAL), mixedBudgets.area(narrow)); + assertEquals(8, mixedBudgets.sourceTarget(mixedBudgets.area(narrow), 0, planner, TILE)); + assertEquals(uniformBudgets.outletTarget(uniformBudgets.area(TROPICAL), true, planner, TILE), + mixedBudgets.outletTarget(mixedBudgets.area(narrow), true, planner, TILE)); + assertEquals(select(planner, uniform, routing(uniform, false), true).selectedNodeIndices(), + select(planner, mixed, routing(mixed, false), true).selectedNodeIndices()); + assertEquals(planner.outletPlanner.limitSurfaceOutlets(TILE, uniform, routing(uniform, false).outlets(), true), + planner.outletPlanner.limitSurfaceOutlets(TILE, mixed, routing(mixed, false).outlets(), true)); + } + + @Test + public void aRegionCannotSpendAnotherRegionsUnusedSourceBudget() { + HydrologySampledGrid grid = grid((x, z) -> land(x < 512 ? TROPICAL : SurfaceRiverPolicy.INHERIT, false)); + HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults()); + HydrologyRoutingPlan routing = routing(grid, false); + SourceSelection both = select(planner, grid, routing, true); + assertEquals(4, selectedIn(both, grid, TROPICAL)); + assertTrue(selectedIn(both, grid, SurfaceRiverPolicy.INHERIT) <= 1); + + for (HydrologyGridNode node : grid.nodes()) { + if (node.terrain().surfacePolicy().equals(TROPICAL)) { + routing.potential()[node.index()] = Double.POSITIVE_INFINITY; + } + } + SourceSelection onlyOrdinary = select(planner, grid, routing, true); + assertEquals(0, selectedIn(onlyOrdinary, grid, TROPICAL)); + assertTrue(onlyOrdinary.selectedNodeIndices().size() <= 1); + assertEquals(onlyOrdinary.selectedNodeIndices(), select(planner(1337L, planner.settings), grid, routing, true).selectedNodeIndices()); + } + + @Test + public void localTributaryCapsApplyToAdmissionAndReplacement() { + SurfaceRiverPolicy branching = new SurfaceRiverPolicy("region:tropical", 8D, 160, 3, 3, 4, null, null); + HydrologySampledGrid grid = grid((x, z) -> land(branching, false)); + HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults()); + SourceSelection selected = select(planner, grid, routing(grid, true), true); + assertEquals(4, selected.selectedNodeIndices().size()); + Set original = new HashSet<>(selected.selectedNodeIndices()); + + assertTrue(selected.advanceAfterPublication(List.of(), grid)); + assertEquals(4, selected.selectedNodeIndices().size()); + assertTrue(selected.selectedNodeIndices().stream().noneMatch(original::contains)); + + HydrologySampledGrid unbranched = grid((x, z) -> land(TROPICAL, false)); + assertEquals(1, select(planner, unbranched, routing(unbranched, true), true).selectedNodeIndices().size()); + } + + @Test + public void requiredHeadwatersKeepTheirOwnMinimumWhenExpectedDensityIsZero() { + SurfaceRiverPolicy requiredArea = new SurfaceRiverPolicy("biome:headwater", null, 160, null, null, null, null, null); + SurfaceRiverPolicy disabledArea = new SurfaceRiverPolicy("region:disabled", 0D, 160, null, null, null, null, null); + HydrologySampledGrid grid = grid((x, z) -> land(x == 0 && z < 256 ? requiredArea : disabledArea, true)); + HydrologyPlanner planner = planner(7L, withSources(new HydrologyPlannerSettings.Source(true, 0D, 88, 2, 2, 384))); + SourceSelection selected = select(planner, grid, routing(grid, false), true); + assertEquals(2, selected.selectedNodeIndices().size()); + assertEquals(2, selectedIn(selected, grid, requiredArea)); + Set original = new HashSet<>(selected.selectedNodeIndices()); + + assertTrue(selected.advanceAfterPublication(List.of(), grid)); + assertEquals(2, selectedIn(selected, grid, requiredArea)); + assertTrue(selected.selectedNodeIndices().stream().noneMatch(original::contains)); + assertEquals(0, selectedIn(selected, grid, disabledArea)); + + HydrologySampledGrid inherited = grid((x, z) -> land(x == 0 && z < 256 ? SurfaceRiverPolicy.INHERIT : disabledArea, true)); + assertEquals(2, selectedIn(select(planner, inherited, routing(inherited, false), true), + inherited, SurfaceRiverPolicy.INHERIT)); + } + + @Test + public void localSurfaceControlsDoNotAlterUndergroundAdmission() { + HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults()); + HydrologySampledGrid ordinary = grid((x, z) -> land(SurfaceRiverPolicy.INHERIT, false)); + HydrologySampledGrid tropical = grid((x, z) -> land(TROPICAL, false)); + + assertEquals(select(planner, ordinary, routing(ordinary, true), false).selectedNodeIndices(), + select(planner, tropical, routing(tropical, true), false).selectedNodeIndices()); + } + + @Test + public void coastalBudgetsStayLocalAndCanBeExplicitlyDisabled() { + SurfaceRiverPolicy volcanic = new SurfaceRiverPolicy("biome:volcanic", 6D, 128, 2, 3, 0, null, null); + HydrologySampledGrid grid = grid((x, z) -> land(x < 512 ? TROPICAL : volcanic, false)); + HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults()); + List candidates = routing(grid, false).outlets(); + List selected = planner.outletPlanner.limitSurfaceOutlets(TILE, grid, candidates, true); + + assertEquals(2, selected.size()); + for (OutletCandidate outlet : selected) { + assertEquals(TROPICAL, grid.node(outlet.landIndex()).terrain().surfacePolicy()); + } + assertEquals(selected, planner.outletPlanner.limitSurfaceOutlets(TILE, grid, candidates, true)); + } + + @Test + public void disabledLocalCoastTargetsDoNotSuppressInlandOutlets() { + SurfaceRiverPolicy inlandOnly = new SurfaceRiverPolicy("region:inland", 8D, 160, 3, 3, 0, null, null); + HydrologyTerrainSampler localTerrain = (x, z) -> x >= 512 + ? HydrologyTerrainSample.ocean(50, "ocean") : land(inlandOnly, false); + HydrologyTerrainSampler ordinaryTerrain = (x, z) -> x >= 512 + ? HydrologyTerrainSample.ocean(50, "ocean") : land(SurfaceRiverPolicy.INHERIT, false); + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Outlets outlets = base.outlets(); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(), base.surface(), + base.hydraulics(), base.underground(), HydrologyPlannerSettings.Outlets.of(outlets.oceanEnabled(), + outlets.coastalGrotto(), outlets.inlandGrotto(), true, outlets.coastalCliffMinimumHeight(), + outlets.mouthLevelingDistance(), outlets.maximumOceanApron(), outlets.maximumPerTile(), + outlets.maximumCoastalPerTile()), base.geometry(), base.deepFluids(), base.surfacePools(), + base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds()); + HydrologyPlanner local = new HydrologyPlanner(1337L, settings, localTerrain); + HydrologyPlanner ordinary = new HydrologyPlanner(1337L, settings, ordinaryTerrain); + + List localOutlets = local.outletPlanner.resolveOutlets(TILE, grid(localTerrain), true, new ArrayList<>()); + assertTrue(localOutlets.toString(), !localOutlets.isEmpty()); + for (OutletCandidate candidate : localOutlets) { + assertEquals(HydrologyFeatureType.INLAND_GROTTO, candidate.outlet().type()); + } + List ordinaryOutlets = ordinary.outletPlanner.resolveOutlets(TILE, grid(ordinaryTerrain), true, new ArrayList<>()); + assertTrue(ordinaryOutlets.toString(), !ordinaryOutlets.isEmpty()); + for (OutletCandidate candidate : ordinaryOutlets) { + assertTrue(candidate.outlet().type() != HydrologyFeatureType.INLAND_GROTTO); + } + assertEquals(ordinary.outletPlanner.resolveOutlets(TILE, grid(ordinaryTerrain), false, new ArrayList<>()), + local.outletPlanner.resolveOutlets(TILE, grid(localTerrain), false, new ArrayList<>())); + } + + private static SourceSelection select(HydrologyPlanner planner, HydrologySampledGrid grid, + HydrologyRoutingPlan routing, boolean surface) { + return planner.sourcePlanner.selectSources(TILE, grid, routing, surface, false, new ArrayList<>(), new HashMap<>()); + } + + private static int selectedIn(SourceSelection selection, HydrologySampledGrid grid, SurfaceRiverPolicy policy) { + int count = 0; + for (int index : selection.selectedNodeIndices()) { + if (grid.node(index).terrain().surfacePolicy().equals(policy)) { + count++; + } + } + return count; + } + + private static HydrologyPlanner planner(long seed, HydrologyPlannerSettings settings) { + return new HydrologyPlanner(seed, settings, (x, z) -> land(SurfaceRiverPolicy.INHERIT, false)); + } + + private static HydrologySampledGrid grid(HydrologyTerrainSampler terrain) { + ArrayList nodes = new ArrayList<>(256); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + int index = z * 16 + x; + nodes.add(new HydrologyGridNode(index, x, z, x * 64, z * 64, index + 1L, terrain.sample(x * 64, z * 64))); + } + } + return new HydrologySampledGrid(0, 0, 0, 0, 1024, 16, 64, List.copyOf(nodes)); + } + + private static HydrologyRoutingPlan routing(HydrologySampledGrid grid, boolean sharedOutlet) { + int count = grid.nodes().size(); + double[] potential = new double[count]; + int[] parent = new int[count]; + int[] outlets = new int[count]; + int[] lengths = new int[count]; + Arrays.fill(potential, 100D); + Arrays.fill(parent, 0); + Arrays.fill(lengths, 512); + ArrayList candidates = new ArrayList<>(count); + for (HydrologyGridNode node : grid.nodes()) { + outlets[node.index()] = sharedOutlet ? 0 : node.index(); + HydrologyPoint point = new HydrologyPoint(node.x(), 63, node.z()); + candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L, + HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true))); + } + return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false); + } + + private static HydrologyPlannerSettings withSources(HydrologyPlannerSettings.Source sources) { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(true, sources, 4, 8, 2, 4, 10, + 1.5D, HydrologyPlannerSettings.Banks.defaults()); + return new HydrologyPlannerSettings(base.seaLevel(), base.routing(), surface, base.hydraulics(), base.underground(), + base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(), + base.seaCaves(), base.surfacePolicyBounds()); + } + + private static HydrologyTerrainSample land(SurfaceRiverPolicy policy, boolean required) { + return new HydrologyTerrainSample(200, 0D, false, true, 160, 162, true, true, true, required, true, false, + 0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D, "land", "land", "land", "land", "land", "land", + List.of("default"), List.of(), Double.NaN, null, Double.NaN, true, policy); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteTurnCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteTurnCacheTest.java index ee37443bc..1babcb80b 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteTurnCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteTurnCacheTest.java @@ -207,7 +207,7 @@ public class HydrologyRouteTurnCacheTest { tangent = directionConstructor.newInstance(1D, 0D); Class candidate = RouteCandidate.class; candidateConstructor = candidate.getDeclaredConstructor(HydrologyPoint.class, double.class, double.class, - double.class, direction, boolean.class); + double.class, double.class, double.class, direction, boolean.class); candidateConstructor.setAccessible(true); solve = HydrologyRouteGeometry.class.getDeclaredMethod("selectCurvatureAwareTerrainRoute", List.class, double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class); @@ -221,7 +221,8 @@ public class HydrologyRouteTurnCacheTest { for (int layer = 0; layer < fixture.points().length; layer++) { ArrayList candidates = new ArrayList<>(); for (int candidate = 0; candidate < fixture.points()[layer].length; candidate++) { - candidates.add(candidateConstructor.newInstance(fixture.points()[layer][candidate], 0D, + HydrologyPoint point = fixture.points()[layer][candidate]; + candidates.add(candidateConstructor.newInstance(point, point.x(), point.z(), 0D, fixture.scores()[layer][candidate], 0D, tangent, true)); } layers.add(candidates); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java index 138100100..6fbd84155 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.CaveVoxel; import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; @@ -235,7 +237,8 @@ public class HydrologyStyledGeometryTest { HydrologyPlannerSettings.Geometry.defaults(), deepFluids, List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -272,7 +275,8 @@ public class HydrologyStyledGeometryTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); @@ -308,7 +312,8 @@ public class HydrologyStyledGeometryTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceGeometryPolicyTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceGeometryPolicyTest.java new file mode 100644 index 000000000..918432eba --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceGeometryPolicyTest.java @@ -0,0 +1,122 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; +import art.arcane.iris.engine.hydrology.surface.ChannelProfile; +import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline; +import art.arcane.iris.engine.hydrology.surface.SurfaceTerminal; +import art.arcane.iris.engine.hydrology.surface.ValleyProfile; +import art.arcane.iris.engine.hydrology.surface.ValleyProfileSolver; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class HydrologySurfaceGeometryPolicyTest { + private static final SurfaceRiverPolicy LOCAL = new SurfaceRiverPolicy("region:tropical", null, null, + null, null, null, 64, 24); + + @Test + public void aShortCourseUsesItsSourcePolicyAfterTheAnchorMoves() { + HydrologyPlanner planner = planner((x, z) -> land(100, SurfaceRiverPolicy.INHERIT)); + HydrologyCoursePath path = new HydrologyCoursePath( + List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(96, 100, 0)), + List.of(), List.of(), outlet(), false, false, null); + HydrologyGridNode ordinary = node(0, land(100, SurfaceRiverPolicy.INHERIT)); + HydrologyGridNode local = node(0, land(100, LOCAL)); + + assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT, + planner.surfaceCourses.buildSurfaceCourseAtPath(8L, "default", ordinary, path).rejection()); + assertNotNull(planner.surfaceCourses.buildSurfaceCourseAtPath(8L, "default", local, path).course()); + assertEquals(planner.settings.routing().minimumUndergroundCourseLength(), + planner.sourcePlanner.minimumCourseLength(local.terrain(), false)); + } + + @Test + public void anUphillEdgeUsesTheDownstreamCutLimit() { + HydrologyPlanner planner = planner((x, z) -> land(100, SurfaceRiverPolicy.INHERIT)); + HydrologySampledGrid localDownstream = grid(SurfaceRiverPolicy.INHERIT, LOCAL); + HydrologySampledGrid ordinaryDownstream = grid(LOCAL, SurfaceRiverPolicy.INHERIT); + List outlets = List.of(new OutletCandidate(1, -1, outlet())); + + assertEquals(1, planner.sourcePlanner.buildRouting(localDownstream, outlets, true).parent()[0]); + assertEquals(-1, planner.sourcePlanner.buildRouting(ordinaryDownstream, outlets, true).parent()[0]); + assertEquals(1, planner.sourcePlanner.buildRouting(ordinaryDownstream, outlets, false).parent()[0]); + assertTrue(planner.outletPlanner.outletReachable(localDownstream, outlets, true)[0]); + } + + @Test + public void geometryOverridesDoNotPreventAnchorsWithinOneBudgetArea() { + SurfaceRiverPolicy neighbor = new SurfaceRiverPolicy("region:tropical", null, null, + null, null, null, 128, 16); + HydrologyPlanner planner = planner((x, z) -> land(99, neighbor)); + + assertTrue(Double.isFinite(planner.routeGeometry.anchorScore(node(0, land(100, LOCAL)), + 2, 0, 2, 0, 0.3D, true))); + } + + @Test + public void localIncisionCanContainATwentyBlockCut() { + ValleyProfile ordinary = valley(false); + ValleyProfile local = valley(true); + + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, ordinary.rejection()); + assertEquals(20, ordinary.rejectionDetail()); + assertNull(local.rejection()); + } + + @Test + public void aLocalIncisionLimitDoesNotCarryIntoOrdinaryGround() { + HydrologyTerrainSampler sampler = (x, z) -> land(x >= 32 && x <= 64 && z != 0 ? 82 : 100, + x < 72 ? LOCAL : SurfaceRiverPolicy.INHERIT); + + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, solve(sampler).rejection()); + } + + private ValleyProfile valley(boolean local) { + return solve((x, z) -> land(x >= 32 && x <= 64 && z != 0 ? 82 : 100, + local ? LOCAL : SurfaceRiverPolicy.INHERIT)); + } + + private ValleyProfile solve(HydrologyTerrainSampler sampler) { + SurfaceCenterline line = SurfaceCenterline.densify( + List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(96, 100, 0))); + double[] widths = new double[line.size()]; + double[] depths = new double[line.size()]; + double[] banks = new double[line.size()]; + Arrays.fill(widths, 2D); + Arrays.fill(depths, 2D); + Arrays.fill(banks, 1D); + return new ValleyProfileSolver(HydrologyPlannerSettings.defaults().surface(), sampler, 63, 64) + .solve(line, new ChannelProfile(widths, depths, banks), SurfaceTerminal.SINKHOLE, 40); + } + + private HydrologySampledGrid grid(SurfaceRiverPolicy upstream, SurfaceRiverPolicy downstream) { + return new HydrologySampledGrid(0, 0, 0, 0, 128, 2, 64, + List.of(node(0, land(100, upstream)), node(1, land(120, downstream)), + node(2, HydrologyTerrainSample.ocean(50, "ocean")), + node(3, HydrologyTerrainSample.ocean(50, "ocean")))); + } + + private HydrologyGridNode node(int index, HydrologyTerrainSample terrain) { + return new HydrologyGridNode(index, index % 2, index / 2, index % 2 * 64, index / 2 * 64, + index + 1L, terrain); + } + + private RiverOutlet outlet() { + return new RiverOutlet(4L, HydrologyFeatureType.INLAND_GROTTO, 2L, + new HydrologyPoint(64, 120, 0), new HydrologyPoint(64, 40, 0), 63, false); + } + + private HydrologyTerrainSample land(int height, SurfaceRiverPolicy policy) { + return HydrologyTerrainSample.openLand(height, 0D, "land").withSurfacePolicy(policy); + } + + private HydrologyPlanner planner(HydrologyTerrainSampler sampler) { + return new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), sampler); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceProfilesTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceProfilesTest.java new file mode 100644 index 000000000..b621b42c8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceProfilesTest.java @@ -0,0 +1,294 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; +import art.arcane.iris.engine.hydrology.surface.SurfaceFootprint; +import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalLong; + +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.assertTrue; + +public class HydrologySurfaceProfilesTest { + @Test + public void volcanicParentAndChildShareTheirExclusiveProfile() { + HydrologyTerrainSample plains = sample(List.of("volcanic_lava"), false, "volcanic-plains"); + HydrologyTerrainSample volcano = sample(List.of("volcanic_lava"), false, "volcanoes"); + + assertTrue(HydrologySurfaceProfiles.sharesProfile(plains, volcano)); + assertTrue(HydrologySurfaceProfiles.sharesProfile(volcano, plains)); + assertFalse(HydrologySurfaceProfiles.sharesProfile(plains, sample(List.of("water"), false, "land"))); + assertFalse(HydrologySurfaceProfiles.sharesProfile(plains, sample(List.of("lava"), false, "land"))); + assertFalse(HydrologySurfaceProfiles.sharesProfile(plains, sample(List.of("default"), false, "land"))); + assertFalse(HydrologySurfaceProfiles.sharesProfile(plains, null)); + } + + @Test + public void unchangedPermissionsKeepTheExistingDeterministicProfileChoice() { + HydrologyTerrainSample terrain = sample(List.of("first", "second"), false, "land"); + HydrologyPlanner planner = planner((x, z) -> terrain); + HydrologyCoursePath path = inlandPath(List.of(point(0), point(16))); + + assertEquals(planner.segments.chooseProfile(terrain, path.outlet().id()), + HydrologySurfaceProfiles.chooseProfile(planner, path, terrain)); + } + + @Test + public void sourcePermissionsRemainEffectiveWhenItsAnchorMoves() { + HydrologyTerrainSample anchor = sample(List.of("volcanic_lava", "water"), false, "land"); + HydrologyTerrainSample source = sample(List.of("volcanic_lava"), false, "volcanoes"); + HydrologyPlanner planner = planner((x, z) -> anchor); + + assertEquals("volcanic_lava", HydrologySurfaceProfiles.chooseProfile( + planner, inlandPath(List.of(point(0), point(16))), source)); + } + + @Test + public void profileIntersectionCoversTheWholeRoute() { + HydrologyTerrainSample first = sample(List.of("a", "b"), false, "first"); + HydrologyTerrainSample middle = sample(List.of("b", "c"), false, "middle"); + HydrologyTerrainSample last = sample(List.of("a", "c"), false, "last"); + HydrologyPlanner planner = planner((x, z) -> x < 4 ? first : x < 8 ? middle : last); + + assertTrue(HydrologySurfaceProfiles.sharesProfile(first, middle)); + assertTrue(HydrologySurfaceProfiles.sharesProfile(middle, last)); + assertNull(HydrologySurfaceProfiles.chooseProfile( + planner, inlandPath(List.of(point(0), point(12))), first)); + } + + @Test + public void anExcludedColumnBetweenRouteStationsRejectsTheCrossing() { + HydrologyTerrainSample water = sample(List.of("water"), false, "land"); + HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes"); + HydrologyPlanner planner = planner((x, z) -> x == 7 ? volcanic : water); + HydrologyCoursePath path = inlandPath(List.of(point(0), point(16))); + + assertNull(HydrologySurfaceProfiles.chooseProfile(planner, path, water)); + assertFalse(HydrologySurfaceProfiles.allowsProfile(planner, path, "water")); + } + + @Test + public void directOceanOutletsMustPermitTheChosenProfile() { + HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes"); + HydrologyPlanner planner = planner((x, z) -> x >= 17 + ? sample(List.of("water"), true, "ocean") : volcanic); + List points = List.of(point(0), point(16)); + RiverOutlet outlet = new RiverOutlet(99L, HydrologyFeatureType.MOUTH, 1L, + point(16), new HydrologyPoint(17, 63, 0), 63, true); + HydrologyCoursePath path = new HydrologyCoursePath(points, List.of(), List.of(), outlet, true, false, null); + + assertNull(HydrologySurfaceProfiles.chooseProfile(planner, path, volcanic)); + } + + @Test + public void oceanNeighborSelectionCanSkipAnIncompatibleOcean() { + HydrologyTerrainSample land = sample(List.of("water"), false, "land"); + HydrologyGridNode source = node(0, 0, 0, land); + HydrologyGridNode incompatible = node(1, 1, 0, sample(List.of("volcanic_lava"), true, "ocean")); + HydrologyGridNode compatible = node(2, 0, 1, sample(List.of("water"), true, "ocean")); + HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 32, 2, 16, + List.of(source, incompatible, compatible, node(3, 1, 1, land))); + HydrologyPlanner planner = planner((x, z) -> land); + + assertEquals(compatible, planner.outletPlanner.firstOceanNeighbor(grid, source, true)); + assertEquals(incompatible, planner.outletPlanner.firstOceanNeighbor(grid, source, false)); + } + + @Test + public void refinedCoastProfilesRejectSurfaceMouthsWithoutChangingUndergroundAdmission() { + HydrologyTerrainSample land = sample(List.of("water"), false, "land"); + HydrologyTerrainSample coarseOcean = sample(List.of("water"), true, "ocean"); + HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 32, 2, 16, List.of( + node(0, 0, 0, land), node(1, 1, 0, coarseOcean), + node(2, 0, 1, land), node(3, 1, 1, coarseOcean))); + HydrologyPlanner planner = planner((x, z) -> x >= 8 + ? sample(List.of("volcanic_lava"), true, "ocean") : land); + + assertTrue(planner.outletPlanner.oceanOutletCandidates(grid, true).isEmpty()); + assertFalse(planner.outletPlanner.oceanOutletCandidates(grid, false).isEmpty()); + } + + @Test + public void aTributaryUsesThePermittedStemProfileAtTheJoin() { + HydrologyTerrainSample terrain = sample(List.of("volcanic_lava", "water"), false, "land"); + HydrologyPlanner planner = planner((x, z) -> terrain); + SurfaceCourseDraft draft = draft(terrain); + ArrayList diagnostics = new ArrayList<>(); + + RiverCourse tributary = planner.tributaries.buildSurfaceTributary(draft, stem("volcanic_lava"), diagnostics); + + assertNotNull(diagnostics.toString(), tributary); + assertEquals("volcanic_lava", tributary.profileKey()); + assertEquals(256, tributary.segments().getLast().end().x()); + } + + @Test + public void aJoinCannotReplaceTheSourcesExclusiveProfile() { + HydrologyTerrainSample terrain = sample(List.of("volcanic_lava", "water"), false, "land"); + HydrologyPlanner planner = planner((x, z) -> terrain); + SurfaceCourseDraft draft = draft(sample(List.of("water"), false, "source")); + ArrayList diagnostics = new ArrayList<>(); + + assertNull(planner.tributaries.buildSurfaceTributary(draft, stem("volcanic_lava"), diagnostics)); + assertEquals(HydrologyCandidateRejection.NO_DRAINAGE_PATH, diagnostics.getFirst().rejection()); + } + + @Test + public void wetChannelWidthCannotEscapeThePermittedProfile() { + HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes"); + HydrologyTerrainSample ambient = sample(List.of("water"), false, "land"); + HydrologyTerrainSampler sampler = (x, z) -> z == 0 ? volcanic : ambient; + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler); + ArrayList courses = new ArrayList<>(List.of(stem("volcanic_lava"))); + ArrayList diagnostics = new ArrayList<>(); + + HydrologySurfaceProfiles.rejectExcludedWetFootprints(compiler, courses, diagnostics); + + assertTrue(courses.isEmpty()); + assertEquals(HydrologyCandidateRejection.POLICY_EXCLUDED, diagnostics.getFirst().rejection()); + assertTrue(diagnostics.getFirst().detail() > 0); + } + + @Test + public void dryValleyBanksMayExtendBeyondThePermittedProfile() { + HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes"); + HydrologyTerrainSample ambient = sample(List.of("water"), false, "land"); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + RiverCourse course = stem("volcanic_lava"); + SurfaceFootprint reference = new HydrologyFootprintCompiler(settings, (x, z) -> volcanic).surfaceFootprint(course); + int wetRadius = 0; + for (SurfaceLayerColumn column : reference.columns()) { + if (column.layer().fluidOwned() && column.layer().bedY() < column.layer().fluidHeadY()) { + wetRadius = Math.max(wetRadius, Math.abs(column.z())); + } + } + int permittedRadius = wetRadius; + HydrologyTerrainSampler sampler = (x, z) -> Math.abs(z) <= permittedRadius ? volcanic : ambient; + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(settings, sampler); + assertTrue(compiler.surfaceFootprint(course).columns().stream() + .anyMatch(column -> !column.layer().fluidOwned() && column.terrain().equals(ambient))); + ArrayList courses = new ArrayList<>(List.of(course)); + ArrayList diagnostics = new ArrayList<>(); + + HydrologySurfaceProfiles.rejectExcludedWetFootprints(compiler, courses, diagnostics); + + assertEquals(List.of(course), courses); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void rejectingTheMainStemAlsoRemovesItsDependentTributary() { + HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes"); + HydrologyTerrainSample ambient = sample(List.of("water"), false, "land"); + HydrologyTerrainSampler sampler = (x, z) -> x < 600 && z != 0 ? ambient : volcanic; + RiverCourse main = new RiverCourse(30L, RiverCourseType.SURFACE, + OptionalLong.of(1L), OptionalLong.of(99L), "volcanic_lava", 1, List.of(), List.of( + new HydraulicSegment(31L, 30L, HydrologyFeatureType.MOUTH, + 84, 84, 4, 2, false, false, List.of(point(256), point(512))))); + RiverCourse tributary = new RiverCourse(40L, RiverCourseType.SURFACE, + OptionalLong.of(2L), OptionalLong.of(99L), "volcanic_lava", 1, List.of(), List.of( + new HydraulicSegment(41L, 40L, HydrologyFeatureType.SURFACE_POOL, + 84, 84, 4, 2, false, false, List.of(point(800), point(1024))))); + ArrayList courses = new ArrayList<>(List.of(tributary, main)); + ArrayList diagnostics = new ArrayList<>(); + + HydrologySurfaceProfiles.rejectExcludedWetFootprints( + new HydrologyFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler), courses, diagnostics); + + assertTrue(courses.isEmpty()); + assertEquals(2, diagnostics.size()); + assertTrue(diagnostics.stream().anyMatch(candidate -> candidate.rejection() == HydrologyCandidateRejection.NO_DRAINAGE_PATH)); + } + + @Test + public void independentStandingPoolsKeepTheirOwnProfilePolicy() { + RiverCourse stem = stem("volcanic_pool"); + RiverCourse pool = new RiverCourse(stem.id(), RiverCourseType.SURFACE_POOL, + OptionalLong.empty(), OptionalLong.empty(), stem.profileKey(), stem.discharge(), + List.of(), stem.segments()); + HydrologyTerrainSampler sampler = (x, z) -> sample(List.of("water"), false, "land"); + ArrayList courses = new ArrayList<>(List.of(pool)); + ArrayList diagnostics = new ArrayList<>(); + + HydrologySurfaceProfiles.rejectExcludedWetFootprints( + new HydrologyFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler), courses, diagnostics); + + assertEquals(List.of(pool), courses); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void surfaceOutletNormalizationDoesNotAdoptAnUndergroundFluidsProfile() { + HydrologyPoint lip = point(256); + HydrologyPoint pool = new HydrologyPoint(256, 70, 0); + RiverCourse surface = new RiverCourse(30L, RiverCourseType.SURFACE, + OptionalLong.of(1L), OptionalLong.of(99L), "volcanic_lava", 1, List.of(), List.of( + new HydraulicSegment(31L, 30L, HydrologyFeatureType.SURFACE_POOL, + 84, 84, 4, 2, false, false, List.of(point(0), lip)), + new HydraulicSegment(32L, 30L, HydrologyFeatureType.SINKHOLE, + 84, 70, 4, 2, true, true, List.of(lip, pool)), + new HydraulicSegment(33L, 30L, HydrologyFeatureType.INLAND_GROTTO, + 70, 70, 4, 2, false, true, List.of(pool)))); + RiverCourse underground = new RiverCourse(40L, RiverCourseType.UNDERGROUND, + OptionalLong.of(2L), OptionalLong.of(99L), "water", 1, List.of(), List.of( + new HydraulicSegment(41L, 40L, HydrologyFeatureType.INLAND_GROTTO, + 70, 70, 4, 2, false, true, List.of(pool)))); + HydrologyPlanner planner = planner((x, z) -> sample(List.of("volcanic_lava"), false, "volcanoes")); + + assertEquals(List.of(surface, underground), + planner.tributaries.normalizeOutletContinuations(List.of(surface, underground))); + } + + private static HydrologyPlanner planner(HydrologyTerrainSampler sampler) { + return new HydrologyPlanner(19L, HydrologyPlannerSettings.defaults(), sampler); + } + + private static HydrologyTerrainSample sample(List profiles, boolean ocean, String biome) { + int height = ocean ? 60 : 84; + return new HydrologyTerrainSample( + height, 0D, ocean, false, height - 32, height - 30, + !ocean, !ocean, !ocean, false, false, false, + 0D, ocean ? 0D : 1D, 0D, 1D, 1D, 1D, 1D, 1D, + biome, biome, biome, biome, biome, biome, + profiles, List.of(), Double.NaN, null, Double.NaN, true, + SurfaceRiverPolicy.INHERIT + ); + } + + private static HydrologyPoint point(int x) { + return new HydrologyPoint(x, 84, 0); + } + + private static HydrologyGridNode node(int index, int gridX, int gridZ, HydrologyTerrainSample terrain) { + return new HydrologyGridNode(index, gridX, gridZ, gridX * 16, gridZ * 16, index, terrain); + } + + private static HydrologyCoursePath inlandPath(List points) { + RiverOutlet outlet = new RiverOutlet(99L, HydrologyFeatureType.INLAND_GROTTO, 1L, + points.getLast(), points.getLast(), 63, false); + ArrayList edges = new ArrayList<>(); + for (int index = 1; index < points.size(); index++) { + edges.add(new DrainageEdge(index, index, index + 1, outlet.id(), 1D, 1, 0, + List.of(points.get(index - 1), points.get(index)))); + } + return new HydrologyCoursePath(points, edges, edges, outlet, true, false, null); + } + + private static SurfaceCourseDraft draft(HydrologyTerrainSample terrain) { + return new SurfaceCourseDraft(node(1, 0, 0, terrain), 20L, "water", + inlandPath(List.of(point(0), point(128), point(256), point(512)))); + } + + private static RiverCourse stem(String profile) { + HydraulicSegment segment = new HydraulicSegment(31L, 30L, HydrologyFeatureType.SURFACE_POOL, + 84, 84, 4, 2, false, false, List.of(point(256), point(512))); + return new RiverCourse(30L, RiverCourseType.SURFACE, OptionalLong.of(2L), OptionalLong.of(99L), + profile, 1, List.of(), List.of(segment)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceRouteGeometryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceRouteGeometryTest.java new file mode 100644 index 000000000..239b64326 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologySurfaceRouteGeometryTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.engine.hydrology; + +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 HydrologySurfaceRouteGeometryTest { + @Test + public void surfaceAnchorKeepsTheCoarseValleyWhenSmoothGroundIsMuchHigher() { + HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand( + x == 0 && z == 0 ? 84 : 137, x == 0 && z == 0 ? 100D : 0D, "land"); + HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), sampler); + HydrologyGridNode node = new HydrologyGridNode(0, 0, 0, 0, 0, 719L, sampler.sample(0, 0)); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + + HydrologyPoint caveAnchor = planner.routeGeometry.routeAnchor(node, false); + HydrologyPoint surfaceAnchor = planner.routeGeometry.routeAnchor(node, true); + + assertEquals(137, caveAnchor.y()); + assertEquals(node.naturalPoint(), surfaceAnchor); + assertEquals(caveAnchor, planner.routeGeometry.routeAnchor(node, false)); + assertEquals(surfaceAnchor, planner.routeGeometry.routeAnchor(node, true)); + } + + @Test + public void surfaceAnchorReliefFitsTheExistingIncisionBudget() { + HydrologyTerrainSample coarse = HydrologyTerrainSample.openLand(84, 0D, "land"); + HydrologyGridNode node = new HydrologyGridNode(0, 0, 0, 0, 0, 719L, coarse); + for (int candidateHeight : List.of(85, 84, 80, 75)) { + HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(candidateHeight, 0D, "land"); + HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), sampler); + double score = planner.routeGeometry.anchorScore(node, 2, 0, 2, 0, 0.3D, true); + + assertEquals("candidate height=" + candidateHeight, + candidateHeight <= 84 && candidateHeight >= 76, Double.isFinite(score)); + } + } + + @Test + public void steepSurfaceEdgesCanDetourAroundAContainedTerrainPit() { + HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand( + 128 - Math.floorDiv(x, 2) - (x >= 30 && x <= 34 && Math.abs(z) <= 1 ? 14 : 0), + 0D, "land"); + HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), sampler); + HydrologyPoint start = new HydrologyPoint(0, 128, 0); + HydrologyPoint end = new HydrologyPoint(64, 96, 0); + HydrologyPoint continuation = new HydrologyPoint(96, 80, 0); + + List cave = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, false); + List surface = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, true); + + assertTrue(cave.isEmpty()); + assertFalse(surface.isEmpty()); + assertTrue(surface.stream().anyMatch(point -> Math.abs(point.z()) > 1)); + assertTrue(surface.stream().allMatch(point -> Math.abs(point.z()) <= 16)); + assertTrue(planner.routePaths.traversableRoute(surface)); + assertFalse(planner.routePaths.containsTerrainPit(surface)); + } + + @Test + public void curvatureUsesTheContinuousCurveBeforeBlockRounding() { + HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), + (x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land")); + RouteCandidate first = candidate(0D, 0D); + RouteCandidate middle = candidate(3.6D, 0.49D); + RouteCandidate last = candidate(7D, 2.1D); + + assertTrue(HydrologyRoutePath.routeTurnDegrees(first.point(), middle.point(), last.point()) > 20D); + assertTrue(planner.routeGeometry.continuousTurnDegrees(first, middle, last) < 20D); + RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute( + List.of(List.of(first), List.of(middle), List.of(last)), 4D, 20D, 2.5D); + + assertEquals(3, route.length); + } + + @Test + public void continuousCurvatureStillRejectsASharpTurn() { + HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), + (x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land")); + RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute( + List.of(List.of(candidate(0D, 0D)), List.of(candidate(4D, 0D)), List.of(candidate(4D, 4D))), + 4D, 20D, 2.5D); + + assertEquals(0, route.length); + } + + private RouteCandidate candidate(double x, double z) { + return new RouteCandidate( + new HydrologyPoint((int) StrictMath.round(x), 84, (int) StrictMath.round(z)), + x, z, 0D, 0D, 0D, new RouteDirection(1D, 0D), true); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java index bf9cacdb7..56f3649f7 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import org.junit.Test; import java.util.ArrayList; @@ -84,7 +86,8 @@ public final class HydrologyTerrainSampleTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } @Test @@ -122,7 +125,8 @@ public final class HydrologyTerrainSampleTest { 80, 0D, false, false, 48, 50, true, true, true, false, false, false, 0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D, "parent", "parent", "parent", "parent", "parent", "parent", - profiles, List.of(), shoreBiomeWidth, confinesKey, shoreWidth, erosion + profiles, List.of(), shoreBiomeWidth, confinesKey, shoreWidth, erosion, + SurfaceRiverPolicy.INHERIT ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java index d8f194306..ce14e8720 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.testsupport.Await; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.volmlib.util.cache.CacheKey; @@ -641,7 +643,8 @@ public class HydrologyTileCacheTest { HydrologyPlannerSettings.Geometry.defaults(), List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -691,7 +694,8 @@ public class HydrologyTileCacheTest { HydrologyPlannerSettings.Geometry.defaults(), List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -737,7 +741,8 @@ public class HydrologyTileCacheTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStoreTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStoreTest.java index f3895d6fd..eab062f88 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStoreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/StudioHydrologyTileStoreTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -80,7 +82,8 @@ public class StudioHydrologyTileStoreTest { } private HydrologyTile tile() { - HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0.25D, "plains"); + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0.25D, "plains") + .withSurfacePolicy(new SurfaceRiverPolicy("region:tropical", 8D, 160, 3, 3, 4, 128, 24)); DrainageNode node = new DrainageNode(1L, 0, 0, terrain, 10D, 2L); RiverOutlet outlet = new RiverOutlet( 2L, diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java index c06804f88..712cbee59 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java @@ -18,6 +18,78 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class RiverPolicyResolverTest { + @Test + public void surfaceBudgetsInheritFieldsAndFollowTheDeclaringArea() { + IrisRiverPolicy dimension = new IrisRiverPolicy().setSurfaceSourceDensity(2D).setSurfaceTributaries(1); + IrisRiverPolicy region = new IrisRiverPolicy().setSurfaceSourceDensity(8D).setSurfaceSourceSpacing(160) + .setSurfaceTributaries(3).setSurfaceInlandOutlets(3).setSurfaceCoastalOutlets(4); + IrisRiverPolicy biome = new IrisRiverPolicy().setSurfaceSourceDensity(6D).setSurfaceCoastalOutlets(0); + SurfaceRiverPolicy inherited = RiverPolicyResolver.resolve(dimension, region, new IrisRiverPolicy()).surfacePolicy(); + SurfaceRiverPolicy local = RiverPolicyResolver.resolve(dimension, region, biome).surfacePolicy(); + + assertEquals(new SurfaceRiverPolicy("region", 8D, 160, 3, 3, 4, null, null), inherited); + assertEquals(new SurfaceRiverPolicy("biome", 6D, 160, 3, 3, 0, null, null), local); + assertEquals(SurfaceRiverPolicy.INHERIT, RiverPolicyResolver.resolve( + (IrisRiverPolicy) null, null, null).surfacePolicy()); + } + + @Test + public void geometryOverridesInheritWithoutChangingBudgetOwnership() { + IrisRiverPolicy dimension = new IrisRiverPolicy().setSurfaceMinimumCourseLength(384).setSurfaceMaximumIncision(16); + IrisRiverPolicy region = new IrisRiverPolicy().setSurfaceSourceDensity(8D).setSurfaceMinimumCourseLength(128); + IrisRiverPolicy biome = new IrisRiverPolicy().setSurfaceMaximumIncision(24); + SurfaceRiverPolicy regional = RiverPolicyResolver.resolve(dimension, region, null).surfacePolicy(); + SurfaceRiverPolicy local = RiverPolicyResolver.resolve(dimension, region, biome).surfacePolicy(); + + assertEquals(128, local.minimumCourseLength(384)); + assertEquals(24, local.maximumIncision(16)); + assertEquals("region", local.areaKey()); + assertEquals(regional.budget(), local.budget()); + assertTrue(local.overridden()); + SurfaceRiverPolicy geometryOnly = RiverPolicyResolver.resolve(dimension, null, biome).surfacePolicy(); + assertFalse(geometryOnly.overridden()); + assertTrue(geometryOnly.configured()); + assertEquals(SurfaceRiverPolicy.INHERIT.budget(), geometryOnly.budget()); + assertEquals(384, SurfaceRiverPolicy.INHERIT.minimumCourseLength(384)); + assertEquals(16, SurfaceRiverPolicy.INHERIT.maximumIncision(16)); + } + + @Test + public void geometryOverrideBoundsValidateBeforeSampling() { + for (int length : new int[]{15, 4097}) { + assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve( + null, new IrisRiverPolicy().setSurfaceMinimumCourseLength(length), null)); + } + for (int incision : new int[]{0, 33}) { + assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve( + null, null, new IrisRiverPolicy().setSurfaceMaximumIncision(incision))); + } + SurfaceRiverPolicy lower = RiverPolicyResolver.resolve(null, null, new IrisRiverPolicy() + .setSurfaceMinimumCourseLength(16).setSurfaceMaximumIncision(1)).surfacePolicy(); + SurfaceRiverPolicy upper = RiverPolicyResolver.resolve(null, null, new IrisRiverPolicy() + .setSurfaceMinimumCourseLength(4096).setSurfaceMaximumIncision(32)).surfacePolicy(); + assertEquals(16, lower.minimumCourseLength(384)); + assertEquals(1, lower.maximumIncision(16)); + assertEquals(4096, upper.minimumCourseLength(384)); + assertEquals(32, upper.maximumIncision(16)); + } + + @Test + public void rejectsInvalidSurfaceBudgetsBeforeSampling() { + for (double density : new double[]{Double.NaN, Double.POSITIVE_INFINITY, -1D, 65D}) { + assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve( + null, new IrisRiverPolicy().setSurfaceSourceDensity(density), null)); + } + assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve( + null, new IrisRiverPolicy().setSurfaceSourceSpacing(8193), null)); + assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve( + null, null, new IrisRiverPolicy().setSurfaceTributaries(5))); + assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve( + null, null, new IrisRiverPolicy().setSurfaceInlandOutlets(-1))); + assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve( + null, null, new IrisRiverPolicy().setSurfaceCoastalOutlets(65))); + } + @Test public void suppliesCanonicalDefaultsWithoutConfiguredPolicies() { EffectiveRiverPolicy policy = RiverPolicyResolver.resolve( @@ -237,7 +309,8 @@ public class RiverPolicyResolverTest { policy.shoreBiomeWidth(), policy.confinement(), shoreWidth, - policy.erosion() + policy.erosion(), + SurfaceRiverPolicy.INHERIT ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java index feb16c4d0..4009544c5 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java @@ -21,12 +21,67 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; 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 IrisHydrologyRuntimeSettingsTest { + @Test + @SuppressWarnings("unchecked") + public void regionalSurfaceControlsFingerprintAllScopesAndExpandSpacingReach() { + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("tropical")); + IrisRegion tropical = new IrisRegion().setRiverPolicy(new IrisRiverPolicy().setSurfaceSourceDensity(8D) + .setSurfaceSourceSpacing(160).setSurfaceTributaries(3).setSurfaceInlandOutlets(3).setSurfaceCoastalOutlets(4)); + tropical.setLoadKey("tropical"); + IrisBiome volcanic = new IrisBiome().setRiverPolicy(new IrisRiverPolicy().setSurfaceCoastalOutlets(0)); + volcanic.setLoadKey("volcanic"); + IrisData data = mock(IrisData.class); + ResourceLoader regions = mock(ResourceLoader.class); + ResourceLoader biomes = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regions); + when(data.getBiomeLoader()).thenReturn(biomes); + when(regions.load("tropical")).thenReturn(tropical); + when(biomes.getPossibleKeys()).thenReturn(new String[]{"volcanic"}); + when(biomes.loadAll(any(String[].class))).thenReturn(new KList<>(volcanic)); + + HydrologyPlannerSettings baseline = settings(dimension); + HydrologyPlannerSettings regional = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data); + assertEquals(HydrologyPlannerSettings.SurfacePolicyBounds.NONE, baseline.surfacePolicyBounds()); + assertNotEquals(baseline.fingerprint(), regional.fingerprint()); + assertEquals(384, regional.maximumSurfaceSourceSpacing()); + + volcanic.getRiverPolicy().setSurfaceSourceSpacing(1600); + HydrologyPlannerSettings widened = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data); + assertNotEquals(regional.fingerprint(), widened.fingerprint()); + assertEquals(1600, widened.maximumSurfaceSourceSpacing()); + assertTrue(widened.publicationRadius() >= 1600); + assertEquals(widened.fingerprint(), IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data).fingerprint()); + + dimension.getRiverPolicy().setSurfaceInlandOutlets(7); + assertNotEquals(widened.fingerprint(), IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data).fingerprint()); + } + + @Test + public void geometryOnlyPoliciesInvalidateThePlanWithoutChangingDimensionLimits() { + IrisDimension dimension = new IrisDimension(); + HydrologyPlannerSettings baseline = settings(dimension); + dimension.getRiverPolicy().setSurfaceMinimumCourseLength(128); + HydrologyPlannerSettings shortened = settings(dimension); + assertNotEquals(baseline.fingerprint(), shortened.fingerprint()); + assertEquals(baseline.routing().minimumSurfaceCourseLength(), shortened.routing().minimumSurfaceCourseLength()); + assertEquals(baseline.maximumSurfaceSourceSpacing(), shortened.maximumSurfaceSourceSpacing()); + + dimension.getRiverPolicy().setSurfaceMaximumIncision(24); + HydrologyPlannerSettings deeper = settings(dimension); + assertNotEquals(shortened.fingerprint(), deeper.fingerprint()); + assertEquals(baseline.surface().maximumIncision(), deeper.surface().maximumIncision()); + assertEquals(deeper.fingerprint(), settings(dimension).fingerprint()); + dimension.getRiverPolicy().setSurfaceMinimumCourseLength(null).setSurfaceMaximumIncision(null); + assertEquals(baseline.fingerprint(), settings(dimension).fingerprint()); + } + @Test public void deepChannelsRemainLocalWhenConfiguredSpacingIsLarge() { IrisDeepFluidConfig deepFluid = new IrisDeepFluidConfig() diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java index 260d137cf..6ab722ca4 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology.surface; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; import art.arcane.iris.engine.hydrology.HydrologyPoint; @@ -206,7 +208,8 @@ public class ChannelProfileBuilderTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java index 38280c408..dcae056bc 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology.surface; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; import art.arcane.iris.engine.hydrology.HydrologyPoint; @@ -611,7 +613,8 @@ public class ErosionFieldCompilerTest { open.surfaceSourceWeight(), open.undergroundSourceWeight(), open.widthMultiplier(), open.depthMultiplier(), open.incisionMultiplier(), open.routingMultiplier(), open.bankMultiplier(), open.parentBiomeKey(), open.surfaceBiomeKey(), open.mouthBiomeKey(), open.shoreBiomeKey(), open.bankBiomeKey(), - open.floodedCaveBiomeKey(), open.preferredProfileKeys(), open.surfacePoolKeys(), shoreBiomeWidth, null, Double.NaN, true + open.floodedCaveBiomeKey(), open.preferredProfileKeys(), open.surfacePoolKeys(), shoreBiomeWidth, null, Double.NaN, true, + SurfaceRiverPolicy.INHERIT ); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java index bbac910f1..1ae45b78d 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java @@ -36,7 +36,8 @@ public class SurfaceCourseBuilderTest { "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(140, 0, 0), new HydrologyPoint(280, 0, 0)), SurfaceTerminal.OCEAN_MOUTH, - SEA_LEVEL + SEA_LEVEL, + 64 ); assertNull(result.rejection()); @@ -73,7 +74,8 @@ public class SurfaceCourseBuilderTest { "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, - 40 + 40, + 64 ); assertNull(result.rejection()); @@ -92,7 +94,8 @@ public class SurfaceCourseBuilderTest { "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, - 40 + 40, + 64 ); assertNull(result.rejection()); @@ -107,10 +110,10 @@ public class SurfaceCourseBuilderTest { public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() { HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land"); SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40); + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64); HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land"); SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40); + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64); assertNull(gentleResult.rejection()); assertNull(steepResult.rejection()); @@ -128,7 +131,8 @@ public class SurfaceCourseBuilderTest { "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.OCEAN_MOUTH, - SEA_LEVEL + SEA_LEVEL, + 64 ); assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT, result.rejection()); @@ -143,9 +147,9 @@ public class SurfaceCourseBuilderTest { List path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(241, 0, 0)); HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet(); - SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64); SurfaceCourseResult plain = builder(defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none()), sampler) - .build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + .build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64); assertNull(result.rejection()); assertEquals(SEA_LEVEL, result.lastHead()); @@ -163,7 +167,7 @@ public class SurfaceCourseBuilderTest { } private static SurfaceCourseBuilder builder(HydrologyTerrainSampler sampler) { - return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64); + return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL); } private static SurfaceCourseBuilder builder(HydrologyPlannerSettings.Banks banks, HydrologyTerrainSampler sampler) { @@ -171,6 +175,6 @@ public class SurfaceCourseBuilderTest { HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( defaults.enabled(), defaults.sources(), defaults.minimumWidth(), defaults.maximumWidth(), defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(), banks); - return new SurfaceCourseBuilder(surface, sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64); + return new SurfaceCourseBuilder(surface, sampler, CONSTANT_GEOMETRY, SEA_LEVEL); } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java index 7e9b6488a..29b014462 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorConcurrencyTest.java @@ -19,7 +19,9 @@ import org.junit.Test; import art.arcane.iris.util.common.parallel.MultiBurst; import java.time.Duration; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; @@ -28,6 +30,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.Future; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -67,6 +70,193 @@ public class MatterGeneratorConcurrencyTest { IrisSettings.settings = previousSettings; } + @Test + public void externalWindowsBoundManagedBlockedComponentSubmissions() throws Exception { + GeneratorFixture fixture = new GeneratorFixture(true); + int maximum = 2 * MatterGenerator.COMPONENT_SUBMISSION_BATCH_SIZE; + CountDownLatch admitted = new CountDownLatch(maximum); + CountDownLatch excess = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger started = new AtomicInteger(); + AtomicInteger completed = new AtomicInteger(); + RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 64) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + if (started.incrementAndGet() > maximum) { + excess.countDown(); + } + admitted.countDown(); + awaitManaged(release); + completed.incrementAndGet(); + } + }; + TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 4, 0))); + CompletableFuture first = CompletableFuture.runAsync( + () -> generator.generateMatter(0, 0, true, fixture.context)); + CompletableFuture second = CompletableFuture.runAsync( + () -> generator.generateMatter(32, 0, true, fixture.context)); + try { + await(admitted); + assertFalse(excess.await(300L, TimeUnit.MILLISECONDS)); + assertEquals(maximum, started.get()); + assertEquals(maximum, activeClaims(fixture, 0, 4) + activeClaims(fixture, 32, 4)); + verify(fixture.mantle.getChunk(-4, -4), never()).release(); + } finally { + release.countDown(); + first.get(10L, TimeUnit.SECONDS); + second.get(10L, TimeUnit.SECONDS); + } + assertEquals(162, completed.get()); + assertEquals(0, activeClaims(fixture, 0, 4) + activeClaims(fixture, 32, 4)); + verify(fixture.mantle.getChunk(-4, -4)).release(); + } + + @Test + public void externalWindowDefersSharedClaimsUntilItsOwnBatchesComplete() throws Exception { + GeneratorFixture fixture = new GeneratorFixture(true); + List shared = new ArrayList<>(); + for (int z = -4; z < -4 + MatterGenerator.COMPONENT_SUBMISSION_BATCH_SIZE; z++) { + MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey( + fixture.mantle, -4, z, ReservedFlag.OBJECT); + MatterGenerator.MatterComponentTask task = new MatterGenerator.MatterComponentTask(key); + assertTrue(task.start()); + assertNull(MatterGenerator.IN_FLIGHT_COMPONENTS.putIfAbsent(key, task)); + shared.add(task); + } + AtomicInteger completed = new AtomicInteger(); + RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 64) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + completed.incrementAndGet(); + } + }; + TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 4, 0))); + CompletableFuture generation = CompletableFuture.runAsync( + () -> generator.generateMatter(0, 0, true, fixture.context)); + try { + Await.until("all privately owned component batches", Duration.ofSeconds(10L), + () -> completed.get() == 81 - shared.size()); + assertFalse(generation.isDone()); + } finally { + for (MatterGenerator.MatterComponentTask task : shared) { + task.finish(null); + } + generation.get(10L, TimeUnit.SECONDS); + } + } + + @Test + public void failedSubmissionBatchDrainsItsWritersBeforeReturning() throws Exception { + GeneratorFixture fixture = new GeneratorFixture(true); + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch started = new CountDownLatch(MatterGenerator.COMPONENT_SUBMISSION_BATCH_SIZE - 1); + CountDownLatch release = new CountDownLatch(1); + IllegalStateException failure = new IllegalStateException("Component rejected its input"); + RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 64) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + if (x == -4 && z == -4) { + failed.countDown(); + throw failure; + } + started.countDown(); + awaitManaged(release); + } + }; + TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 4, 0))); + CompletableFuture generation = CompletableFuture.runAsync( + () -> generator.generateMatter(0, 0, true, fixture.context)); + try { + await(failed); + await(started); + if (MatterGenerator.COMPONENT_SUBMISSION_BATCH_SIZE > 1) { + assertFalse(generation.isDone()); + verify(fixture.mantle.getChunk(-4, -4), never()).release(); + } + } finally { + release.countDown(); + } + ExecutionException observed = assertThrows(ExecutionException.class, + () -> generation.get(10L, TimeUnit.SECONDS)); + assertSame(failure, observed.getCause()); + verify(fixture.mantle.getChunk(-4, -4)).release(); + assertEquals(0, activeClaims(fixture, 0, 4)); + } + + @Test + public void boundedAndReentrantGenerationMatchSerialComponentResults() throws Exception { + Map serial = componentResults(false, false); + assertEquals(81, serial.size()); + assertEquals(serial, componentResults(true, false)); + assertEquals(serial, componentResults(true, true)); + } + + private static Map componentResults(boolean multicore, boolean reentrant) throws Exception { + GeneratorFixture fixture = new GeneratorFixture(true); + Map values = new ConcurrentHashMap<>(); + RecordingComponent first = new RecordingComponent(ReservedFlag.CARVED, 0, 32) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + long key = (((long) x) << 32) ^ (z & 0xffffffffL); + values.put(key, ((long) x * 341873128712L) ^ ((long) z * 132897987541L)); + } + }; + RecordingComponent second = new RecordingComponent(ReservedFlag.OBJECT, 1, 32) { + @Override + public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { + assertEquals(81, values.size()); + long key = (((long) x) << 32) ^ (z & 0xffffffffL); + values.compute(key, (ignored, value) -> value * 31L + 17L); + } + }; + TestMatterGenerator generator = fixture.generator(List.of( + new MantlePass(List.of(first), 4, 32), + new MantlePass(List.of(second), 2, 0))); + if (reentrant) { + MultiBurst.burst.completeValueAsync(() -> { + generator.generateMatter(0, 0, multicore, fixture.context); + return null; + }).get(10L, TimeUnit.SECONDS); + } else { + generator.generateMatter(0, 0, multicore, fixture.context); + } + return values; + } + + private static int activeClaims(GeneratorFixture fixture, int centerX, int radius) { + int active = 0; + for (int x = centerX - radius; x <= centerX + radius; x++) { + for (int z = -radius; z <= radius; z++) { + MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey( + fixture.mantle, x, z, ReservedFlag.OBJECT); + if (MatterGenerator.IN_FLIGHT_COMPONENTS.containsKey(key)) { + active++; + } + } + } + return active; + } + + private static void awaitManaged(CountDownLatch latch) { + try { + ForkJoinPool.managedBlock(new ForkJoinPool.ManagedBlocker() { + @Override + public boolean block() { + await(latch); + return true; + } + + @Override + public boolean isReleasable() { + return latch.getCount() == 0L; + } + }); + } catch (InterruptedException interruption) { + Thread.currentThread().interrupt(); + throw new AssertionError(interruption); + } + } + @Test public void multicoreChunksOverlapAndCompleteBeforeTheNextPass() { GeneratorFixture fixture = new GeneratorFixture(); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java index cd9202f68..914998852 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.render.IrisRenderer; @@ -1645,7 +1647,8 @@ public class MantleHydrologyComponentTest { HydrologyPlannerSettings.Geometry.defaults(), List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -1686,7 +1689,8 @@ public class MantleHydrologyComponentTest { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); } diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionFocusRegionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionFocusRegionTest.java new file mode 100644 index 000000000..e867b282f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionFocusRegionTest.java @@ -0,0 +1,119 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.util.Set; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisDimensionFocusRegionTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + private IrisData data; + private ResourceLoader biomes; + private ResourceLoader regions; + private IrisDimension dimension; + + @Before + @SuppressWarnings("unchecked") + public void preparePack() { + data = mock(IrisData.class); + biomes = mock(ResourceLoader.class); + regions = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(biomes); + when(data.getRegionLoader()).thenReturn(regions); + when(data.getDataFolder()).thenReturn(temporary.getRoot()); + when(regions.getFolderName()).thenReturn("regions"); + dimension = new IrisDimension().setRegions(new KList<>("tropical")); + dimension.setLoadKey("overworld"); + } + + @Test(timeout = 2000L) + public void resolvesNestedChildrenCarvingAndFloodedCavesThroughCycles() { + IrisRegion tropical = region("tropical").setLandBiomes(new KList<>("wilds")); + biome("wilds").setChildren(new KList<>("highlands")); + IrisBiome highlands = biome("highlands").setChildren(new KList<>("wilds")) + .setCarvingBiome("chalk-gardens") + .setRiverPolicy(new IrisRiverPolicy().setFloodedCaveBiomes(new KList<>("flooded"))); + IrisBiome chalk = biome("chalk-gardens").setChildren(new KList<>("chalk-child")); + IrisBiome chalkChild = biome("chalk-child"); + IrisBiome flooded = biome("flooded").setChildren(new KList<>("flooded-child")); + IrisBiome floodedChild = biome("flooded-child").setCarvingBiome("wilds"); + + for (IrisBiome focus : List.of(highlands, chalk, chalkChild, flooded, floodedChild)) { + assertSame(tropical, dimension.resolveFocusRegion(focus, () -> data)); + } + Set natural = tropical.getNaturalBiomes(() -> data).stream() + .map(IrisBiome::getLoadKey).collect(Collectors.toSet()); + assertEquals(Set.of("wilds", "highlands", "chalk-gardens", "chalk-child"), natural); + } + + @Test + public void directCaveAndRegionFloodedReferencesKeepTheirOwner() { + IrisRegion tropical = region("tropical").setCaveBiomes(new KList<>("cave")) + .setRiverPolicy(new IrisRiverPolicy().setFloodedCaveBiomes(new KList<>("flooded"))); + + assertSame(tropical, dimension.resolveFocusRegion(biome("cave"), () -> data)); + assertSame(tropical, dimension.resolveFocusRegion(biome("flooded"), () -> data)); + } + + @Test + public void directOwnerTakesPrecedenceOverAnotherRegionsChildReference() { + region("tropical").setLandBiomes(new KList<>("wilds")); + biome("wilds").setChildren(new KList<>("highlands")); + IrisRegion direct = region("direct").setLandBiomes(new KList<>("highlands")); + dimension.setRegions(new KList<>("tropical", "direct")); + + assertSame(direct, dimension.resolveFocusRegion(biome("highlands"), () -> data)); + } + + @Test + public void unownedFocusIsNeutralAndStableAcrossDimensionInstances() { + region("tropical").setLandBiomes(new KList<>("unrelated")); + biome("unrelated"); + IrisBiome focus = biome("standalone"); + IrisRegion first = dimension.resolveFocusRegion(focus, () -> data); + IrisDimension reopened = new IrisDimension().setRegions(new KList<>("tropical")); + reopened.setLoadKey("overworld"); + IrisRegion second = reopened.resolveFocusRegion(focus, () -> data); + + assertEquals(first.getLoadKey(), second.getLoadKey()); + assertEquals(new KList<>("standalone"), first.getLandBiomes()); + assertEquals(new KList<>("standalone"), first.getSeaBiomes()); + assertEquals(new KList<>("standalone"), first.getShoreBiomes()); + assertEquals(new KList<>(), first.getCaveBiomes()); + assertSame(data, first.getLoader()); + assertFalse(first.getLoadFile().exists()); + reopened.setLoadKey("underworld"); + assertNotEquals(first.getLoadKey(), reopened.resolveFocusRegion(focus, () -> data).getLoadKey()); + assertNotEquals(first.getLoadKey(), dimension.resolveFocusRegion(biome("other"), () -> data).getLoadKey()); + } + + private IrisBiome biome(String key) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + when(biomes.load(key)).thenReturn(biome); + return biome; + } + + private IrisRegion region(String key) { + IrisRegion region = new IrisRegion(); + region.setLoadKey(key); + when(regions.load(key)).thenReturn(region); + return region; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java index d7012b1ca..1594ca4d8 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorCloseTest.java @@ -3,6 +3,7 @@ package art.arcane.iris.engine.platform; import art.arcane.iris.engine.framework.Engine; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.ReactiveFolder; +import art.arcane.volmlib.util.scheduling.Looper; import org.bukkit.generator.BlockPopulator; import org.junit.Test; @@ -14,6 +15,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -75,6 +77,42 @@ public class BukkitChunkGeneratorCloseTest { assertFalse(fixture.generator.isClosing()); } + @Test + public void closeCancelsPendingWatcherBeforeQueuingExclusiveCleanup() throws Exception { + CloseFixture fixture = new CloseFixture(); + CompletableFuture pending = new CompletableFuture<>(); + fixture.watcherHotload.set(pending); + CompletableFuture cleanup = new CompletableFuture<>(); + doAnswer(invocation -> { + assertTrue(fixture.generator.isClosing()); + assertTrue(pending.isCancelled()); + assertNull(fixture.watcherHotload.get()); + return cleanup; + }).when(fixture.generator).withExclusiveControlFuture(any(Runnable.class)); + + CompletableFuture closed = fixture.generator.closeAsync(); + + assertFalse(closed.isDone()); + cleanup.complete(null); + closed.join(); + } + + @Test + public void shutdownQuiesceCancelsPendingWatcherWithoutClosingGeneration() throws Exception { + CloseFixture fixture = new CloseFixture(); + CompletableFuture pending = new CompletableFuture<>(); + fixture.watcherHotload.set(pending); + Looper watcher = mock(Looper.class); + setField(fixture.generator, "hotloader", watcher); + + fixture.generator.quiesceForServerShutdown(); + + assertTrue(pending.isCancelled()); + assertNull(fixture.watcherHotload.get()); + assertFalse(fixture.generator.isClosing()); + verify(watcher).interrupt(); + } + private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception { Field field = BukkitChunkGenerator.class.getDeclaredField(name); field.setAccessible(true); @@ -85,10 +123,12 @@ public class BukkitChunkGeneratorCloseTest { private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); private final Engine engine = mock(Engine.class); private final AtomicReference operation = new AtomicReference<>(); + private final AtomicReference> watcherHotload = new AtomicReference<>(); private final ConcurrentLinkedQueue> operations = new ConcurrentLinkedQueue<>(); private CloseFixture() throws Exception { setField(generator, "closeFuture", new AtomicReference>()); + setField(generator, "watcherHotload", watcherHotload); setField(generator, "startupReady", CompletableFuture.completedFuture(null)); setField(generator, "engine", engine); setField(generator, "folder", mock(ReactiveFolder.class)); diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java index 719f4303c..0e7eba2c5 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java @@ -1,10 +1,8 @@ package art.arcane.iris.engine.platform; -import art.arcane.iris.engine.data.chunk.TerrainChunk; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.framework.WrongEngineBroException; -import art.arcane.iris.engine.platform.studio.StudioGenerator; +import art.arcane.iris.spi.IrisLogging; import org.junit.Test; +import org.mockito.MockedStatic; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -21,7 +19,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; -import java.util.function.Supplier; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -29,7 +26,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.mockStatic; public class BukkitChunkGeneratorGenerationStageGateTest { @Test @@ -154,103 +151,6 @@ public class BukkitChunkGeneratorGenerationStageGateTest { } } - @Test - public void exclusiveGenerationStageDowngradesWithoutReleasingItsRetainedPermit() { - AtomicBoolean closing = new AtomicBoolean(false); - BukkitChunkGenerator.GenerationStageGate gate = - new BukkitChunkGenerator.GenerationStageGate(3, closing::get); - BukkitChunkGenerator.GenerationStageExclusivePermit exclusive = - gate.acquireExclusiveStage("prepare"); - - assertEquals(0, gate.availablePermits()); - BukkitChunkGenerator.GenerationStagePermit stage = exclusive.downgradeToStage(); - - assertEquals(2, gate.availablePermits()); - exclusive.close(); - assertEquals(2, gate.availablePermits()); - stage.close(); - assertEquals(3, gate.availablePermits()); - } - - @Test - public void preparedGenerationRetriesChangedGeneratorWhileHoldingExclusiveAdmission() { - AtomicBoolean closing = new AtomicBoolean(false); - BukkitChunkGenerator.GenerationStageGate gate = - new BukkitChunkGenerator.GenerationStageGate(2, closing::get); - PreparingStudioGenerator first = new PreparingStudioGenerator(gate, false); - PreparingStudioGenerator second = new PreparingStudioGenerator(gate, false); - AtomicInteger resolutions = new AtomicInteger(); - Supplier resolver = () -> resolutions.getAndIncrement() == 0 ? first : second; - Engine engine = mock(Engine.class); - - BukkitChunkGenerator.GenerationStagePermit stage = - BukkitChunkGenerator.acquirePreparedGenerationStage( - gate, - "prepared", - resolver, - engine, - 4, - 7); - - assertEquals(0, first.preparations()); - assertEquals(1, second.preparations()); - assertEquals(0, second.observedPermits()); - assertTrue(resolutions.get() >= 4); - assertEquals(1, gate.availablePermits()); - verifyNoInteractions(engine); - stage.close(); - assertEquals(2, gate.availablePermits()); - } - - @Test - public void failedPreparationReleasesEveryExclusivePermit() { - AtomicBoolean closing = new AtomicBoolean(false); - BukkitChunkGenerator.GenerationStageGate gate = - new BukkitChunkGenerator.GenerationStageGate(2, closing::get); - PreparingStudioGenerator generator = new PreparingStudioGenerator(gate, true); - Engine engine = mock(Engine.class); - - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> BukkitChunkGenerator.acquirePreparedGenerationStage( - gate, - "prepared-failure", - () -> generator, - engine, - 2, - 3)); - - assertTrue(failure.getMessage().contains("could not prepare")); - assertTrue(failure.getCause() instanceof WrongEngineBroException); - assertEquals(1, generator.preparations()); - assertEquals(0, generator.observedPermits()); - assertEquals(2, gate.availablePermits()); - verifyNoInteractions(engine); - } - - @Test - public void defaultStudioGeneratorUsesOneOrdinaryGenerationPermit() { - AtomicBoolean closing = new AtomicBoolean(false); - BukkitChunkGenerator.GenerationStageGate gate = - new BukkitChunkGenerator.GenerationStageGate(2, closing::get); - StudioGenerator generator = new DefaultStudioGenerator(); - Engine engine = mock(Engine.class); - - BukkitChunkGenerator.GenerationStagePermit stage = - BukkitChunkGenerator.acquirePreparedGenerationStage( - gate, - "ordinary", - () -> generator, - engine, - 0, - 0); - - assertEquals(1, gate.availablePermits()); - verifyNoInteractions(engine); - stage.close(); - assertEquals(2, gate.availablePermits()); - } - @Test public void exclusiveControlSuccessReleasesGateBeforeSynchronousCompletionDependent() throws Exception { AtomicBoolean closing = new AtomicBoolean(false); @@ -409,6 +309,108 @@ public class BukkitChunkGeneratorGenerationStageGateTest { } } + @Test + public void untimedExclusiveCancellationRemovesWaiterWithoutLeakingInterruptOrPermits() throws Exception { + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, () -> false); + BukkitChunkGenerator.GenerationStagePermit active = gate.acquireStage("active"); + CompletableFuture pending = new CompletableFuture<>(); + AtomicBoolean operationRan = new AtomicBoolean(); + AtomicBoolean interrupted = new AtomicBoolean(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future worker = executor.submit(() -> { + BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> operationRan.set(true), pending); + interrupted.set(Thread.currentThread().isInterrupted()); + }); + awaitQueueLength(gate, 1); + + assertTrue(pending.cancel(true)); + worker.get(2L, TimeUnit.SECONDS); + + assertFalse(operationRan.get()); + assertFalse(interrupted.get()); + assertEquals(0, gate.queueLength()); + assertEquals(0, gate.availablePermits()); + active.close(); + assertEquals(1, gate.availablePermits()); + } finally { + active.close(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + + @Test + public void untimedCancellationAfterAdmissionDoesNotInterruptCutover() throws Exception { + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, () -> false); + CompletableFuture pending = new CompletableFuture<>(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future worker = executor.submit(() -> BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> { + entered.countDown(); + try { + assertTrue(release.await(2L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + interrupted.set(true); + Thread.currentThread().interrupt(); + } + }, pending)); + assertTrue(entered.await(2L, TimeUnit.SECONDS)); + + assertTrue(pending.cancel(true)); + assertEquals(0, gate.availablePermits()); + release.countDown(); + worker.get(2L, TimeUnit.SECONDS); + + assertFalse(interrupted.get()); + assertEquals(1, gate.availablePermits()); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(2L, TimeUnit.SECONDS)); + } + } + + @Test + public void preCancelledUntimedControlDoesNotQueueOrRun() { + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, () -> false); + CompletableFuture pending = new CompletableFuture<>(); + AtomicBoolean operationRan = new AtomicBoolean(); + pending.cancel(true); + + BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> operationRan.set(true), pending); + + assertFalse(operationRan.get()); + assertEquals(0, gate.queueLength()); + assertEquals(1, gate.availablePermits()); + } + + @Test + public void cancelledUntimedControlReportsAnAdmittedCutoverFailure() { + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, () -> false); + CompletableFuture pending = new CompletableFuture<>(); + IllegalStateException failure = new IllegalStateException("Admitted cutover failed"); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> { + pending.cancel(true); + throw failure; + }, pending); + + logging.verify(() -> IrisLogging.reportError(failure)); + } + assertTrue(pending.isCancelled()); + assertFalse(Thread.currentThread().isInterrupted()); + assertEquals(1, gate.availablePermits()); + } + @Test public void cancelledExclusiveControlStopsWaitingWithoutReleasingAStagePermit() throws Exception { AtomicBoolean closing = new AtomicBoolean(false); @@ -604,52 +606,4 @@ public class BukkitChunkGeneratorGenerationStageGateTest { assertTrue("Expected at least " + expected + " queued gate threads", gate.queueLength() >= expected); } - private static final class DefaultStudioGenerator implements StudioGenerator { - @Override - public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) { - } - } - - private static final class PreparingStudioGenerator implements StudioGenerator { - private final BukkitChunkGenerator.GenerationStageGate gate; - private final boolean fail; - private final AtomicInteger preparations; - private int observedPermits; - - private PreparingStudioGenerator( - BukkitChunkGenerator.GenerationStageGate gate, - boolean fail - ) { - this.gate = gate; - this.fail = fail; - this.preparations = new AtomicInteger(); - this.observedPermits = -1; - } - - @Override - public boolean requiresPreSessionPreparation() { - return true; - } - - @Override - public void prepareChunkBeforeSession(Engine engine, int x, int z) throws WrongEngineBroException { - preparations.incrementAndGet(); - observedPermits = gate.availablePermits(); - if (fail) { - throw new WrongEngineBroException("prepared failure"); - } - } - - @Override - public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) { - } - - private int preparations() { - return preparations.get(); - } - - private int observedPermits() { - return observedPermits; - } - } } diff --git a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGeneratorPreparationTest.java b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGeneratorPreparationTest.java deleted file mode 100644 index 7d5a9275b..000000000 --- a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGeneratorPreparationTest.java +++ /dev/null @@ -1,52 +0,0 @@ -package art.arcane.iris.engine.platform.studio.generators; - -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.iris.util.common.data.B; -import art.arcane.volmlib.util.collection.KList; -import org.junit.Test; -import org.mockito.MockedStatic; - -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -public class BiomeBuffetGeneratorPreparationTest { - @Test - public void focusHotloadPreparationIsOptedInAndIdempotent() { - Engine engine = mock(Engine.class); - IrisDimension dimension = mock(IrisDimension.class); - IrisBiome biome = mock(IrisBiome.class); - PlatformBlockState barrier = mock(PlatformBlockState.class); - AtomicReference focus = new AtomicReference<>(""); - when(engine.getDimension()).thenReturn(dimension); - when(dimension.getAllBiomes(engine)).thenReturn(new KList<>(biome)); - when(dimension.getFocus()).thenAnswer(invocation -> focus.get()); - when(dimension.setFocus(anyString())).thenAnswer(invocation -> { - focus.set(invocation.getArgument(0, String.class)); - return dimension; - }); - when(biome.getLoadKey()).thenReturn("prepared_biome"); - - try (MockedStatic blocks = mockStatic(B.class)) { - blocks.when(() -> B.getState("BARRIER")).thenReturn(barrier); - BiomeBuffetGenerator generator = new BiomeBuffetGenerator(engine, 1); - - assertTrue(generator.requiresPreSessionPreparation()); - generator.prepareChunkBeforeSession(engine, 0, 0); - generator.prepareChunkBeforeSession(engine, 0, 0); - generator.prepareChunkBeforeSession(engine, 2, 0); - } - - verify(dimension, times(1)).setFocus("prepared_biome"); - verify(engine, times(1)).hotloadComplex(); - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGeometryTest.java b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGeometryTest.java index 1e04459d5..8d01f8942 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/ObjectStudioGeometryTest.java @@ -42,6 +42,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -139,9 +140,17 @@ public class ObjectStudioGeometryTest { position.y() - fixture.cell().originY(), position.z() - fixture.cell().originZ(), entry.getValue()); } - captured.setUnsignedTile(0, 0, 0, tile); + Position placedTile = new Position(fixture.cell().originX(), fixture.cell().originY(), fixture.cell().originZ()); + assertEquals(1, fixture.tiles().size()); + assertEquals(tile, fixture.tiles().get(placedTile)); + assertNotSame(tile, fixture.tiles().get(placedTile)); + for (Map.Entry entry : fixture.tiles().entrySet()) { + Position position = entry.getKey(); + captured.setUnsignedTile(position.x() - fixture.cell().originX(), + position.y() - fixture.cell().originY(), position.z() - fixture.cell().originZ(), entry.getValue()); + } - assertSame(tile, captured.getStates().get(new IrisBlockVector(minimum.x(), minimum.y(), minimum.z()))); + assertEquals(tile, captured.getStates().get(new IrisBlockVector(minimum.x(), minimum.y(), minimum.z()))); assertEquals(serialized(source), serialized(captured)); } } @@ -209,13 +218,24 @@ public class ObjectStudioGeometryTest { mock(PlatformBlockState.class), mock(PlatformBlockState.class), mock(PlatformBlockState.class)); TerrainChunk initial = mock(TerrainChunk.class); when(initial.getMaxHeight()).thenReturn(320); + Map tiles = new HashMap<>(); + ObjectStudioSaveService service = mock(ObjectStudioSaveService.class); + doAnswer(invocation -> { + ObjectStudioGenerator.ChunkTiles queued = invocation.getArgument(1); + for (ObjectStudioGenerator.PlacedTile tile : queued.tiles()) { + assertTrue(tile.x() >= 0 && tile.x() < 16 && tile.z() >= 0 && tile.z() < 16); + tiles.put(new Position((queued.chunkX() << 4) + tile.x(), tile.y(), + (queued.chunkZ() << 4) + tile.z()), tile.data()); + } + return null; + }).when(service).queueTiles(any(), any()); try (MockedStatic saves = mockStatic(ObjectStudioSaveService.class)) { - saves.when(ObjectStudioSaveService::get).thenReturn(mock(ObjectStudioSaveService.class)); + saves.when(ObjectStudioSaveService::get).thenReturn(service); generator.generateChunk(engine, initial, cell.chunkMinX(), cell.chunkMinZ()); } ObjectStudioLayout.GridCell placed = generator.getLayout().get("jungleclutt6"); assertNotNull(placed); - return new Fixture(generator, engine, placed, source); + return new Fixture(generator, engine, placed, source, tiles, service); } private static Map render(Fixture fixture) throws Exception { @@ -238,7 +258,10 @@ public class ObjectStudioGeometryTest { } return null; }).when(chunk).setBlock(anyInt(), anyInt(), anyInt(), any()); - fixture.generator().generateChunk(fixture.engine(), chunk, chunkX, chunkZ); + try (MockedStatic saves = mockStatic(ObjectStudioSaveService.class)) { + saves.when(ObjectStudioSaveService::get).thenReturn(fixture.service()); + fixture.generator().generateChunk(fixture.engine(), chunk, chunkX, chunkZ); + } } } return rendered; @@ -300,7 +323,7 @@ public class ObjectStudioGeometryTest { } private record Fixture(ObjectStudioGenerator generator, Engine engine, ObjectStudioLayout.GridCell cell, - IrisObject source) { + IrisObject source, Map tiles, ObjectStudioSaveService service) { } private record SerializedObject(int width, int height, int depth, Map blocks, diff --git a/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilterTest.java b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilterTest.java index 5da8fefac..84c8e64f3 100644 --- a/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilterTest.java +++ b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DFragmentFilterTest.java @@ -2,279 +2,43 @@ package art.arcane.iris.engine.terrain; import org.junit.Test; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; +import java.lang.ref.WeakReference; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -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.assertTrue; public class Terrain3DFragmentFilterTest { @Test - public void removesAnEntire512BlockIslandAcrossChunkBoundaries() { - Set blocks = new HashSet<>(); - for (int x = 12; x <= 19; x++) { - for (int z = -20; z <= -13; z++) { - for (int y = 24; y <= 31; y++) { - blocks.add(new Voxel(x, y, z)); - } + public void perThreadTraversalScratchDoesNotPinTheFilterAfterItIsDropped() throws Exception { + // A hotload retires the runtime that owns the filter; the worker threads that classified + // fragments for it keep living. Their per-thread scratch must not keep the retired runtime + // (and the stream caches behind it) reachable. + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + WeakReference dropped = worker.submit(this::classifyAndDrop) + .get(10, TimeUnit.SECONDS); + assertNotNull(dropped); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (dropped.get() != null && System.nanoTime() < deadline) { + System.gc(); + Thread.sleep(20); } - } - Fixture fixture = new Fixture(blocks, true); - - for (Voxel block : blocks) { - Terrain3DColumn column = fixture.filter.column(block.x(), block.z()); - assertFalse(column.isSolid(block.y())); - assertEquals(8, column.topY()); - assertEquals(1, column.spanCount()); - assertTrue(column.isSolid(8)); + assertNull("Retired fragment filter is still reachable from a worker thread", dropped.get()); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); } } - @Test - public void preservesThinShelvesConnectedSidewaysAroundACorner() { - Set blocks = new HashSet<>(); - for (int x = 0; x <= 15; x++) { - blocks.add(new Voxel(x, 24, 0)); - } - for (int z = 1; z <= 15; z++) { - blocks.add(new Voxel(15, 24, z)); - } - for (int y = 9; y < 24; y++) { - blocks.add(new Voxel(15, y, 15)); - } - Fixture fixture = new Fixture(blocks, true); - - for (Voxel block : blocks) { - assertTrue(fixture.filter.column(block.x(), block.z()).isSolid(block.y())); - } - assertFalse(fixture.filter.column(0, 0).isSolid(23)); - assertEquals(24, fixture.filter.column(0, 0).surfaceY(24)); - } - - @Test - public void aDiagonalContactDoesNotKeepAnOtherwiseDetachedBlock() { - Set blocks = new HashSet<>(); - for (int y = 9; y <= 24; y++) { - blocks.add(new Voxel(0, y, 0)); - } - blocks.add(new Voxel(1, 24, 1)); - Fixture fixture = new Fixture(blocks, true); - - assertTrue(fixture.filter.column(0, 0).isSolid(24)); - assertFalse(fixture.filter.column(1, 1).isSolid(24)); - } - - @Test - public void preservesAll513BlocksAfterAPartialComponentClassification() { - Set blocks = new HashSet<>(); - for (int x = 0; x < 513; x++) { - blocks.add(new Voxel(x, 24, 0)); - } - Fixture fixture = new Fixture(blocks, true); - - assertTrue(fixture.filter.column(0, 0).isSolid(24)); - assertTrue(fixture.lookups.get() <= 1 + 4 * 512); - for (int x = 512; x >= 0; x--) { - assertTrue(fixture.filter.column(x, 0).isSolid(24)); - } - } - - @Test - public void aLargeVerticalIslandNeedsNoNeighbourSampling() { - Set blocks = new HashSet<>(); - for (int y = 24; y < 537; y++) { - blocks.add(new Voxel(0, y, 0)); - } - Fixture fixture = new Fixture(blocks, true); - - assertEquals(536, fixture.filter.column(0, 0).topY()); - assertEquals(1, fixture.lookups.get()); - } - - @Test - public void completeCacheEvictionAndConcurrentQueriesPreserveDecisions() throws Exception { - Set blocks = new HashSet<>(); - for (int x = -18; x <= -15; x++) { - for (int z = 14; z <= 17; z++) { - for (int y = 24; y <= 27; y++) { - blocks.add(new Voxel(x, y, z)); - } - } - } - for (int x = 14; x <= 526; x++) { - blocks.add(new Voxel(x, 24, 16)); - } - Fixture evicted = new Fixture(blocks, false); - Fixture cached = new Fixture(blocks, true); - int[] coordinates = {-18, -17, -16, -15, 14, 15, 16, 526}; - try (ExecutorService executor = Executors.newFixedThreadPool(4)) { - List> results = new ArrayList<>(); - for (int x : coordinates) { - results.add(executor.submit(() -> evicted.filter.column(x, 16))); - } - for (int index = results.size() - 1; index >= 0; index--) { - assertEquals(cached.filter.column(coordinates[index], 16), - results.get(index).get(10, TimeUnit.SECONDS)); - } - } - } - - @Test - public void sharedConcurrentQueriesPublishWholeRemovedAndKeptComponents() throws Exception { - Set blocks = new HashSet<>(); - for (int x = 12; x <= 19; x++) { - for (int z = -20; z <= -13; z++) { - for (int y = 24; y <= 31; y++) { - blocks.add(new Voxel(x, y, z)); - } - } - } - for (int x = 32; x <= 544; x++) { - blocks.add(new Voxel(x, 24, 16)); - } - Fixture fixture = new Fixture(blocks, true); - CountDownLatch start = new CountDownLatch(1); - try (ExecutorService executor = Executors.newFixedThreadPool(8)) { - List> removed = new ArrayList<>(); - List> kept = new ArrayList<>(); - for (int index = 0; index < 64; index++) { - int removedX = 12 + index % 8; - int removedZ = -20 + index / 8; - int keptX = 32 + index * 8; - removed.add(executor.submit(() -> { - assertTrue(start.await(10, TimeUnit.SECONDS)); - return fixture.filter.column(removedX, removedZ); - })); - kept.add(executor.submit(() -> { - assertTrue(start.await(10, TimeUnit.SECONDS)); - return fixture.filter.column(keptX, 16); - })); - } - start.countDown(); - for (int index = 63; index >= 0; index--) { - assertEquals(8, removed.get(index).get(10, TimeUnit.SECONDS).topY()); - assertTrue(kept.get(index).get(10, TimeUnit.SECONDS).isSolid(24)); - } - } - } - - @Test - public void spanFilteringMatchesAnIndependentVoxelComponentOracle() { - Random random = new Random(74921); - for (int sample = 0; sample < 18; sample++) { - Set blocks = new HashSet<>(); - double probability = 0.12D + sample % 6 * 0.14D; - for (int x = -3; x <= 3; x++) { - for (int z = -3; z <= 3; z++) { - for (int y = 9; y <= 20; y++) { - if (random.nextDouble() < probability) { - blocks.add(new Voxel(x, y, z)); - } - } - } - } - Set retained = retainedBlocks(blocks); - Fixture fixture = new Fixture(blocks, true); - for (int x = 3; x >= -3; x--) { - for (int z = -3; z <= 3; z++) { - Terrain3DColumn column = fixture.filter.column(x, z); - for (int y = 9; y <= 20; y++) { - Voxel block = new Voxel(x, y, z); - assertEquals("Sample " + sample + " at " + block, - retained.contains(block), column.isSolid(y)); - } - } - } - } - } - - private static Set retainedBlocks(Set blocks) { - Set remaining = new HashSet<>(blocks); - Set retained = new HashSet<>(); - while (!remaining.isEmpty()) { - Voxel start = remaining.iterator().next(); - remaining.remove(start); - Set component = new HashSet<>(); - ArrayDeque pending = new ArrayDeque<>(); - pending.add(start); - boolean grounded = false; - while (!pending.isEmpty()) { - Voxel block = pending.removeFirst(); - component.add(block); - grounded |= block.y() == 9; - for (Voxel neighbour : List.of(new Voxel(block.x() - 1, block.y(), block.z()), - new Voxel(block.x() + 1, block.y(), block.z()), - new Voxel(block.x(), block.y() - 1, block.z()), - new Voxel(block.x(), block.y() + 1, block.z()), - new Voxel(block.x(), block.y(), block.z() - 1), - new Voxel(block.x(), block.y(), block.z() + 1))) { - if (remaining.remove(neighbour)) { - pending.addLast(neighbour); - } - } - } - if (grounded || component.size() > 512) { - retained.addAll(component); - } - } - return retained; - } - - private record Voxel(int x, int y, int z) { - } - - private static final class Fixture { - private final Set blocks; - private final boolean cache; - private final int maximumY; - private final Map columns = new ConcurrentHashMap<>(); - private final AtomicInteger lookups = new AtomicInteger(); - private final Terrain3DFragmentFilter filter = new Terrain3DFragmentFilter(this::column); - - private Fixture(Set blocks, boolean cache) { - this.blocks = Set.copyOf(blocks); - this.cache = cache; - int highest = 9; - for (Voxel block : blocks) { - highest = Math.max(highest, block.y()); - } - maximumY = highest + 1; - } - - private Terrain3DFragmentFilter.DensityColumn column(int x, int z) { - lookups.incrementAndGet(); - long key = (long) x << 32 ^ z & 0xffffffffL; - return cache ? columns.computeIfAbsent(key, ignored -> createColumn(x, z)) : createColumn(x, z); - } - - private Terrain3DFragmentFilter.DensityColumn createColumn(int x, int z) { - int[] boundaries = new int[maximumY * 2]; - boundaries[0] = 0; - int count = 1; - boolean previousSolid = true; - for (int y = 9; y <= maximumY; y++) { - boolean solid = blocks.contains(new Voxel(x, y, z)); - if (solid != previousSolid) { - boundaries[count++] = solid ? y : y - 1; - previousSolid = solid; - } - } - return new Terrain3DFragmentFilter.DensityColumn( - new Terrain3DColumn(8, 9, true, Arrays.copyOf(boundaries, count))); - } + private WeakReference classifyAndDrop() { + Terrain3DColumn raw = new Terrain3DColumn(64D, 0, false, new int[]{0, 40, 50, 60}); + Terrain3DFragmentFilter.DensityColumn density = new Terrain3DFragmentFilter.DensityColumn(raw); + Terrain3DFragmentFilter filter = new Terrain3DFragmentFilter((x, z) -> density); + assertNotNull(filter.column(0, 0)); + return new WeakReference<>(filter); } } diff --git a/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DRuntimeTest.java index 55c95931d..3f14221e3 100644 --- a/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/terrain/Terrain3DRuntimeTest.java @@ -84,6 +84,69 @@ public class Terrain3DRuntimeTest { } } + @Test + public void subLatticeHeightPeaksDoNotJumpBetweenSeparateDensityLedges() { + IrisBiome biome = new IrisBiome().setTerrain3D(profile()); + Terrain3DRuntime.NoiseSource noise = (x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D); + Terrain3DRuntime reference = runtime(profile(), noise); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> Math.floorMod(x, 4) == 2 + && Math.floorMod(z, 4) == 2 ? 114D : 96D, (x, z) -> biome), + options(true), (style, seed) -> noise); + + for (int x = -18; x <= 18; x++) { + Terrain3DColumn expected = reference.column(x, -14); + Terrain3DColumn actual = runtime.column(x, -14); + assertEquals(Math.floorMod(x, 4) == 2 ? 114D : 96D, actual.baseHeight(), 0D); + assertEquals(expected.topY(), actual.topY()); + assertTrue(actual.spanCount() > 1); + for (int y = 1; y < 160; y++) { + assertEquals("at " + x + "," + y + ",-14", expected.isSolid(y), actual.isSolid(y)); + } + } + Terrain3DColumn beforeClear = runtime.column(-14, -14); + runtime.clear(); + assertEquals(beforeClear, runtime.column(-14, -14)); + } + + @Test + public void baseHeightCorrectionFadesIntoAnOmittedProfile() { + IrisBiome shaped = new IrisBiome().setTerrain3D(profile()); + IrisBiome unchanged = new IrisBiome(); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> Math.floorMod(x, 4) == 0 ? 96D : 116D, + (x, z) -> x < 16 ? shaped : unchanged), + options(true), (style, seed) -> (x, y, z) -> 0D); + + assertEquals(96, runtime.column(12, 0).topY()); + assertEquals(101, runtime.column(13, 0).topY()); + assertEquals(106, runtime.column(14, 0).topY()); + assertEquals(111, runtime.column(15, 0).topY()); + assertEquals(96, runtime.column(16, 0).topY()); + assertEquals(116, runtime.column(17, 0).topY()); + assertFalse(runtime.column(17, 0).shaped()); + } + + @Test + public void baseHeightCorrectionUsesTheExistingWaterAndSlopeFade() { + IrisBiome shore = new IrisBiome().setTerrain3D(profile().setFluidClearance(8).setFluidFade(16)); + Terrain3DRuntime water = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> Math.floorMod(x, 4) == 0 ? 48D : 64D, + (x, z) -> shore), + new Terrain3DRuntime.Options(12, 256, 32, null, true, 4096), + (style, seed) -> (x, y, z) -> 0D); + assertEquals(56, water.column(2, 0).topY()); + assertEquals(64D, water.column(2, 0).baseHeight(), 0D); + + IrisBiome slope = new IrisBiome().setTerrain3D(profile().setMinimumSlope(0.25D).setSlopeFade(0.5D)); + Terrain3DRuntime terrain = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96D + x * 0.5D + + (Math.floorMod(x, 4) == 0 ? 0D : 16D), (x, z) -> slope), + options(true), (style, seed) -> (x, y, z) -> 0D); + assertEquals(105, terrain.column(2, 0).topY()); + assertEquals(113D, terrain.column(2, 0).baseHeight(), 0D); + } + @Test public void isolatedDensityPeakIsRemovedFromEveryColumnQuery() { Terrain3DRuntime runtime = runtime(profile(), diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java index 9aeb0311d..07db3519c 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java @@ -2996,7 +2996,7 @@ public final class HydrologyPackProbe { throw new IllegalStateException("Dimension '" + configuration.dimensionKey() + "' has no active hydrology runtime."); } - validateRequiredProfiles(runtime, configuration.requiredCoverage()); + validateRequiredProfiles(runtime.settings(), runtime.profileKeys(), configuration.requiredCoverage()); requiredConfiguredCoverage.addAll(configuredCoverage(runtime)); validateTileBlockBounds(runtime, configuration); for (long tileZ = configuration.minimumTileZ(); tileZ <= configuration.maximumTileZ(); tileZ++) { @@ -3285,22 +3285,32 @@ public final class HydrologyPackProbe { }; } - private static void validateRequiredProfiles( - IrisHydrologyRuntime runtime, + static void validateRequiredProfiles( + HydrologyPlannerSettings settings, + Set riverProfiles, List selectors ) { - Set riverProfiles = runtime.profileKeys(); LinkedHashSet deepProfiles = new LinkedHashSet<>(); - for (HydrologyPlannerSettings.DeepFluid deepFluid : runtime.settings().deepFluids()) { + for (HydrologyPlannerSettings.DeepFluid deepFluid : settings.deepFluids()) { if (deepFluid.enabled()) { deepProfiles.add(deepFluid.id()); } } + LinkedHashSet poolProfiles = new LinkedHashSet<>(); + for (HydrologyPlannerSettings.SurfacePool pool : settings.surfacePools()) { + if (pool.enabled()) { + poolProfiles.add(pool.id()); + } + } for (CoverageSelector selector : selectors) { if (selector.profileKey().equals("*")) { continue; } - Set configuredProfiles = selector.type().isDeepFluid() ? deepProfiles : riverProfiles; + Set configuredProfiles = switch (selector.type()) { + case STANDING_POOL -> poolProfiles; + case DEEP_POOL, DEEP_CHANNEL -> deepProfiles; + default -> riverProfiles; + }; if (!configuredProfiles.contains(selector.profileKey())) { throw new IllegalArgumentException("Required coverage selector " + selector.label() + " references a profile not configured for that feature family."); diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java index 7510a349a..0dfbd430f 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java @@ -1,5 +1,7 @@ package art.arcane.iris.probe; +import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy; + import art.arcane.iris.engine.hydrology.HydrologyColumnSample; import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; import art.arcane.iris.engine.hydrology.HydrologyFeatureType; @@ -272,7 +274,8 @@ public final class HydrologyPlannerProbe { HydrologyPlannerSettings.Geometry.defaults(), List.of(), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -314,7 +317,9 @@ public final class HydrologyPlannerProbe { List.of("water"), List.of() , Double.NaN, - null, Double.NaN, true); + null, Double.NaN, true, + SurfaceRiverPolicy.INHERIT + ); }; } @@ -367,7 +372,9 @@ public final class HydrologyPlannerProbe { List.of("water"), List.of() , Double.NaN, - null, Double.NaN, true); + null, Double.NaN, true, + SurfaceRiverPolicy.INHERIT + ); }; } @@ -493,7 +500,8 @@ public final class HydrologyPlannerProbe { HydrologyPlannerSettings.Geometry.defaults(), List.of(deepFluid), List.of(), 0D, - HydrologyPlannerSettings.SeaCaves.disabled() + HydrologyPlannerSettings.SeaCaves.disabled(), + HydrologyPlannerSettings.SurfacePolicyBounds.NONE ); } @@ -542,7 +550,8 @@ public final class HydrologyPlannerProbe { Double.NaN, null, Double.NaN, - true + true, + SurfaceRiverPolicy.INHERIT ); }; } @@ -578,7 +587,9 @@ public final class HydrologyPlannerProbe { List.of("water"), List.of() , Double.NaN, - null, Double.NaN, true); + null, Double.NaN, true, + SurfaceRiverPolicy.INHERIT + ); } private static long[] parseSeeds(String[] arguments) { diff --git a/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java index db4ae470e..aba0cdf13 100644 --- a/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java @@ -6,6 +6,7 @@ import art.arcane.iris.engine.hydrology.HydraulicSegment; import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; import art.arcane.iris.engine.hydrology.HydrologyTile; import art.arcane.iris.engine.hydrology.HydrologyTileKey; import art.arcane.iris.engine.hydrology.RiverFootprint; @@ -43,6 +44,24 @@ public final class HydrologyPackProbeTest { @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void standingPoolSelectorsUsePoolProfilesInsteadOfRiverProfiles() { + HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings( + defaults.seaLevel(), defaults.routing(), defaults.surface(), defaults.hydraulics(), + defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(), + List.of(new HydrologyPlannerSettings.SurfacePool("tropical_lake", true, 8D, 160, 8, 16, 3, 8, null)), + defaults.widestShoreBiomeWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds()); + + HydrologyPackProbe.validateRequiredProfiles(settings, Set.of("water"), List.of( + HydrologyPackProbe.CoverageSelector.parse("STANDING_POOL@tropical_lake"), + HydrologyPackProbe.CoverageSelector.parse("SURFACE_POOL@water"))); + assertThrows(IllegalArgumentException.class, () -> HydrologyPackProbe.validateRequiredProfiles( + settings, Set.of("water"), List.of(HydrologyPackProbe.CoverageSelector.parse("STANDING_POOL@water")))); + assertThrows(IllegalArgumentException.class, () -> HydrologyPackProbe.validateRequiredProfiles( + settings, Set.of("water"), List.of(HydrologyPackProbe.CoverageSelector.parse("SURFACE_POOL@tropical_lake")))); + } + @Test public void parsesExplicitBoundedCoverageInputs() { HydrologyPackProbe.ProbeConfiguration configuration = HydrologyPackProbe.ProbeConfiguration.parse(