From 1e1eaa96aa908e5e0356cd205fcb79caad1fb12b Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 15 Sep 2026 23:01:56 -0400 Subject: [PATCH] Hydrology Fix, and V+ --- .../src/main/java/art/arcane/iris/Iris.java | 10 +- .../world/PendingWorldReplacementManager.java | 5 +- adapters/fabric/build.gradle | 2 +- adapters/forge/build.gradle | 2 +- .../iris/modded/ModdedEngineBootstrap.java | 8 +- adapters/neoforge/build.gradle | 2 +- build.gradle | 2 +- .../iris/generation/biome/IrisBiome.java | 2 + .../biome/IrisBiomeLayerGenerator.java | 19 +- .../biome/IrisSurfaceLayerFallback.java | 30 ++ .../generation/concurrent/MultiBurst.java | 20 +- .../hydrology/HydrologyChannelGeometry.java | 3 + .../hydrology/HydrologyCrossTileResolver.java | 168 +++++++-- .../hydrology/HydrologyOutletPlanner.java | 128 +++++-- .../hydrology/HydrologyPlanner.java | 30 +- .../HydrologyRegionalCoarsePlan.java | 34 ++ .../hydrology/HydrologyRegionalEnvelope.java | 43 +++ .../hydrology/HydrologyRegionalPlanner.java | 328 ++++++++++++---- .../hydrology/HydrologyRegionalRoute.java | 85 ++++- .../HydrologyRegionalTerrainRefiner.java | 152 +++++--- .../hydrology/HydrologyRouteGeometry.java | 7 +- .../hydrology/HydrologySourcePlanner.java | 127 ++++++- .../HydrologySurfaceCoursePlanner.java | 29 +- .../generation/hydrology/HydrologyTile.java | 37 +- .../hydrology/HydrologyTileCache.java | 154 +++++++- .../hydrology/StudioHydrologyTileStore.java | 17 +- .../IrisHydrologyRoutingTerrainSampler.java | 161 +++++--- .../runtime/IrisHydrologyRuntime.java | 9 +- .../runtime/IrisHydrologyRuntimeContext.java | 4 +- .../mantle/MantleHydrologyComponent.java | 3 + .../iris/generation/noise/IrisGenerator.java | 24 +- .../runtime/DimensionTerrainContext.java | 5 +- .../runtime/GenerationCacheWarmer.java | 39 +- .../runtime/HydrologyNoiseCacheBudget.java | 61 +++ .../iris/generation/runtime/IrisComplex.java | 155 +++++++- .../iris/generation/runtime/IrisEngine.java | 39 +- .../generation/runtime/PreservationSVC.java | 74 +++- .../generation/stage/IrisPostModifier.java | 4 +- .../stage/IrisTerrainNormalActuator.java | 13 +- .../generation/stream/CachedStream2D.java | 4 + .../generation/terrain/IrisDimension.java | 3 + .../iris/generation/terrain/IrisRegion.java | 4 + .../pack/datapack/ServerConfigurator.java | 8 +- .../generation/BukkitChunkGenerator.java | 153 +++++++- .../generation/PlatformChunkGenerator.java | 4 + .../art/arcane/iris/studio/StudioSVC.java | 23 +- .../art/arcane/iris/world/IrisCreator.java | 37 +- .../arcane/iris/world/IrisWorldCreator.java | 43 ++- .../arcane/iris/world/IrisWorldStorage.java | 6 +- .../art/arcane/iris/world/IrisWorlds.java | 19 +- .../iris/world/history/GenerationHistory.java | 46 +-- .../history/GenerationPackFingerprint.java | 199 ++++++++-- .../history/GenerationPackRepository.java | 207 +++++++++- .../iris/world/history/SavedBiomeStore.java | 9 +- .../pregen/PregenPerformanceProfile.java | 18 +- .../PaperLikeRuntimeControlBackend.java | 2 +- .../biome/IrisBiomeLayerSlopeSourceTest.java | 14 +- .../biome/IrisSurfaceLayerFallbackTest.java | 137 +++++++ .../concurrent/HydrologyBurstTest.java | 112 ++++++ .../HydrologyCaveCourseFilterTest.java | 6 +- .../HydrologyDiagnosticCacheTest.java | 248 ++++++++++++ .../HydrologyFallbackDiagnosticsTest.java | 188 +++++++++ .../HydrologyFallbackParallelismTest.java | 225 +++++++++++ .../hydrology/HydrologyModelTest.java | 1 + .../hydrology/HydrologyPlanHashTest.java | 4 +- .../HydrologyPlannerConfinementTest.java | 6 +- ...CrossTileCaveAdmissionIntegrationTest.java | 4 +- .../HydrologyPlannerGlobalInvariantTest.java | 2 +- .../HydrologyPlannerSeaCaveTest.java | 20 +- .../HydrologyPlannerSurfacePoolTest.java | 2 +- .../hydrology/HydrologyPlannerTest.java | 76 ++-- .../HydrologyPlannerTributaryTest.java | 16 +- .../HydrologyPlannerUndergroundKnobsTest.java | 14 +- .../HydrologyRegionalBankSupportTest.java | 19 +- .../hydrology/HydrologyRegionalCacheTest.java | 28 ++ .../HydrologyRegionalConcurrencyTest.java | 208 ++++++++++ ...HydrologyRegionalCorridorSamplingTest.java | 135 +++++++ .../HydrologyRegionalDemandTest.java | 242 ++++++++++++ .../hydrology/HydrologyRegionalFallsTest.java | 51 ++- .../HydrologyRegionalPlannerTest.java | 98 ++++- .../HydrologyRegionalSamplingTest.java | 272 +++++++++++++ .../HydrologyRegionalShorelineTest.java | 163 ++++++++ ...ydrologyRegionalSourceParallelismTest.java | 246 ++++++++++++ .../HydrologyRegionalTerrainRefinerTest.java | 68 ++++ .../HydrologyRegionalTributariesTest.java | 3 +- .../hydrology/HydrologySourceSpacingTest.java | 225 +++++++++++ .../HydrologySurfaceBankProbeTest.java | 85 +++++ .../HydrologySurfaceDropRasterTest.java | 3 +- .../hydrology/HydrologyTileCacheTest.java | 37 +- .../StudioHydrologyTileStoreTest.java | 31 +- .../IrisHydrologyPhysicalOceanTest.java | 2 +- .../runtime/IrisHydrologyPolicyCacheTest.java | 2 +- ...sHydrologyRoutingCacheConcurrencyTest.java | 122 ++++++ ...risHydrologyRoutingTerrainSamplerTest.java | 37 ++ .../mantle/MantleHydrologyComponentTest.java | 30 +- .../noise/IrisGeneratorSurfaceDetailTest.java | 103 +++++ .../runtime/EngineShutdownDrainTest.java | 4 + .../HydrologyNoiseCacheBudgetTest.java | 96 +++++ .../IrisComplexHydrologyNoiseCacheTest.java | 356 ++++++++++++++++++ .../runtime/IrisComplexSurfaceBiomeTest.java | 152 +++++++- ...java => IrisEngineEntryHydrologyTest.java} | 26 +- .../IrisEngineGenerationCacheWarmTest.java | 178 +++++++++ .../runtime/PreservationSVCTest.java | 101 +++++ .../stage/IrisPostModifierWallShoreTest.java | 138 +++++++ ...risTerrainNormalActuatorHydrologyTest.java | 4 +- ...risTerrainNormalActuatorStrataCutTest.java | 194 ++++++++++ ...erConfiguratorDatapackFingerprintTest.java | 43 +++ .../schema/SchemaBuilderIdentityTest.java | 2 +- .../BukkitSpiConformanceForkHygieneTest.java | 120 ++++++ .../bukkit/BukkitSpiConformanceTest.java | 3 + .../BukkitChunkGeneratorCloseTest.java | 47 ++- ...BukkitChunkGeneratorHistoryTargetTest.java | 152 ++++++++ .../BukkitChunkGeneratorInitialSpawnTest.java | 181 +++++++++ ...tChunkGeneratorInitializationModeTest.java | 28 +- ...ukkitChunkGeneratorSpawnHydrologyTest.java | 51 ++- .../studio/StudioSVCWorldPackPublishTest.java | 39 ++ .../IrisCreatorDatapackPreparationTest.java | 84 ++++- .../world/IrisWorldCreatorHistoryTest.java | 159 ++++++++ .../iris/world/IrisWorldStorageTest.java | 33 ++ .../art/arcane/iris/world/IrisWorldsTest.java | 105 ++++++ .../GenerationHistoryAdmissionTest.java | 15 + .../world/history/GenerationHistoryTest.java | 38 ++ .../GenerationPackCopyConcurrencyTest.java | 319 ++++++++++++++++ .../GenerationPackDirectorySyncTest.java | 148 ++++++++ ...GenerationPackFingerprintPrefetchTest.java | 334 ++++++++++++++++ .../history/GenerationPackRepositoryTest.java | 22 ++ .../history/SavedBiomeStoreBatchTest.java | 77 +++- .../WorldLifecycleRuntimeLevelStemTest.java | 8 + .../pregen/PregenPerformanceProfileTest.java | 31 +- ...aperLikeRuntimeControlBackendTimeTest.java | 82 ++++ gradle.properties | 2 +- probe/build.gradle | 1 + .../arcane/iris/probe/HydrologyPackProbe.java | 6 +- .../iris/probe/HydrologyPlannerProbe.java | 2 +- .../arcane/iris/probe/RiverTransectProbe.java | 16 +- .../iris/probe/HydrologyPackProbeTest.java | 47 ++- .../iris/probe/HydrologyPlannerProbeTest.java | 4 +- 137 files changed, 8842 insertions(+), 711 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/generation/biome/IrisSurfaceLayerFallback.java create mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCoarsePlan.java create mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalEnvelope.java create mode 100644 core/src/main/java/art/arcane/iris/generation/runtime/HydrologyNoiseCacheBudget.java create mode 100644 core/src/test/java/art/arcane/iris/generation/biome/IrisSurfaceLayerFallbackTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/concurrent/HydrologyBurstTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackDiagnosticsTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConcurrencyTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCorridorSamplingTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalDemandTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSamplingTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalShorelineTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingCacheConcurrencyTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/HydrologyNoiseCacheBudgetTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java rename core/src/test/java/art/arcane/iris/generation/runtime/{IrisEngineStudioEntryHydrologyTest.java => IrisEngineEntryHydrologyTest.java} (94%) create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationCacheWarmTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierWallShoreTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java create mode 100644 core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceForkHygieneTest.java create mode 100644 core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorHistoryTargetTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/IrisWorldCreatorHistoryTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/history/GenerationPackCopyConcurrencyTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/history/GenerationPackDirectorySyncTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/history/GenerationPackFingerprintPrefetchTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/runtime/PaperLikeRuntimeControlBackendTimeTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 219e6778a..25ab06965 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -640,6 +640,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { generatorDrainCompleted = false; deferredShutdownGenerators.clear(); MultiBurst.burst.reopen(); + MultiBurst.hydrology.reopen(); MultiBurst.ioBurst.reopen(); IrisLanguage.initialize(); debugDump = BukkitDebugDump.create(this, new BukkitDebugDump.Options( @@ -1106,6 +1107,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { runtimeTeardownFailed.set(true); Iris.reportError("Failed to close Iris generation workers.", failure); } + try { + MultiBurst.hydrology.close(); + } catch (Throwable failure) { + runtimeTeardownFailed.set(true); + Iris.reportError("Failed to close Iris hydrology workers.", failure); + } try { MultiBurst.ioBurst.close(); } catch (Throwable failure) { @@ -1273,7 +1280,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } private boolean hasActiveShutdownResources() { - if (!MultiBurst.burst.isTerminated() || !MultiBurst.ioBurst.isTerminated()) { + if (!MultiBurst.burst.isTerminated() || !MultiBurst.hydrology.isTerminated() + || !MultiBurst.ioBurst.isTerminated()) { return true; } return services != null diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/PendingWorldReplacementManager.java index 55657aa49..ff1ecb5c6 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/PendingWorldReplacementManager.java @@ -176,15 +176,16 @@ public final class PendingWorldReplacementManager implements Listener { seedSelection ); progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK); - IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld( + StudioSVC.GenerationPublication publication = Iris.service(StudioSVC.class).installIntoWorld( requiredSender, requiredDimension, paths.stage().toFile(), effectiveSeed ); - if (installed == null) { + if (publication == null) { throw new IOException("Iris could not stage the dimension pack."); } + IrisDimension installed = publication.dimension(); requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) { progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PLAYERS); diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index e3fd0c27a..524177339 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -59,7 +59,7 @@ apply from: file('../../gradle/modded-generation-build-revision.gradle') Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } -String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2')) +String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.1-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3')) String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')) diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index 3b6e34538..bb9af0f4f 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -59,7 +59,7 @@ apply from: file('../../gradle/modded-generation-build-revision.gradle') Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } -String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2')) +String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.1-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.1.1')) String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')) diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index b8b4170d3..309eed744 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -102,6 +102,7 @@ public final class ModdedEngineBootstrap { // closed, MultiBurst falls back to a same-thread executor, so a second world load // without reopen() would silently run every burst inline. MultiBurst.burst.reopen(); + MultiBurst.hydrology.reopen(); MultiBurst.ioBurst.reopen(); ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { @@ -189,10 +190,9 @@ public final class ModdedEngineBootstrap { failure = runStopStage(failure, "level snapshot", ModdedServerLevels::forget); } failure = runStopStage(failure, "generation pool", IrisModdedChunkGenerator::shutdownGenPool); - failure = runStopStage(failure, "burst pools", () -> { - MultiBurst.burst.close(); - MultiBurst.ioBurst.close(); - }); + failure = runStopStage(failure, "generation burst pool", MultiBurst.burst::close); + failure = runStopStage(failure, "hydrology burst pool", MultiBurst.hydrology::close); + failure = runStopStage(failure, "I/O burst pool", MultiBurst.ioBurst::close); failure = runStopStage(failure, "startup state", ModdedStartup::reset); failure = runStopStage(failure, "server state", () -> { currentServer = null; diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 8f80fe229..c4d75e6c0 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -59,7 +59,7 @@ apply from: file('../../gradle/modded-generation-build-revision.gradle') Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } -String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2')) +String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.1-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.59')) String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT')) diff --git a/build.gradle b/build.gradle index e25d665fd..334517085 100644 --- a/build.gradle +++ b/build.gradle @@ -67,7 +67,7 @@ interface IrisFileOperations { IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations) group = 'art.arcane' -version = providers.gradleProperty('irisVersion').getOrElse('4.1.0-26.2') +version = providers.gradleProperty('irisVersion').getOrElse('4.1.1-26.2') project(':core') { group = 'art.arcane' diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java index 934931787..eec461d9d 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java @@ -206,6 +206,8 @@ public class IrisBiome extends IrisRegistrant implements Rarity { @ArrayType(type = IrisBiomePaletteLayer.class) @Description("This defines the layers of materials in this biome. Each layer has a palette and min/max height and some other properties. Usually a grassy/sandy layer then a dirt layer then a stone layer. Iris will fill in the remaining blocks below your layers with stone.") private KList layers = new KList().qadd(new IrisBiomePaletteLayer()); + @Description("What this biome's columns surface with when every layer is rejected, usually because all of them are slope gated and the column is too steep. Omit to inherit the dimension setting.") + private IrisSurfaceLayerFallback surfaceLayerFallback = null; @ArrayType(type = IrisBiomePaletteLayer.class) @Description("Layers of materials placed on cave ceilings in this biome, indexed upward from the ceiling surface. Must not have more entries than layers, whose height generators it reuses. Omitting this leaves cave ceilings unchanged.") private KList caveCeilingLayers = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java index ca6a26420..0d49710b9 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java @@ -56,7 +56,9 @@ final class IrisBiomeLayerGenerator { private static KList generateLayers(IrisBiome biome, IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex, ProceduralStream slopeStream) { if (biome.isLockLayers()) { - return generateLockedLayers(biome, wx, wz, random, maxDepth, height, rdata, complex, slopeStream); + KList data = generateLockedLayers(biome, wx, wz, random, maxDepth, height, rdata, complex, slopeStream); + appendSurfaceFallback(data, biome, dim, wx, wz, random, Math.min(maxDepth, biome.getLockLayersMax()), rdata); + return data; } KList data = new KList<>(); @@ -114,9 +116,24 @@ final class IrisBiomeLayerGenerator { } } + appendSurfaceFallback(data, biome, dim, wx, wz, random, maxDepth, rdata); return data; } + private static void appendSurfaceFallback(KList data, IrisBiome biome, IrisDimension dim, + double wx, double wz, RNG random, int maxDepth, IrisData rdata) { + if (!data.isEmpty() || maxDepth <= 0 || biome.getLayers().isEmpty()) { + return; + } + IrisSurfaceLayerFallback fallback = biome.getSurfaceLayerFallback(); + if (fallback == null) { + fallback = dim.getSurfaceLayerFallback(); + } + if (fallback == IrisSurfaceLayerFallback.TOP_LAYER) { + appendLayer(data, biome.getLayers().get(0), 0, 1, wx, wz, random, maxDepth, rdata); + } + } + static KList generateCeilingLayers(IrisBiome biome, IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex) { KList data = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisSurfaceLayerFallback.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisSurfaceLayerFallback.java new file mode 100644 index 000000000..7a34b9d8c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisSurfaceLayerFallback.java @@ -0,0 +1,30 @@ +/* + * 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.generation.biome; + +import art.arcane.volmlib.util.documentation.Description; + +@Description("What a column surfaces with when every biome layer is rejected, usually because all of them are slope gated and the column is too steep.") +public enum IrisSurfaceLayerFallback { + @Description("Leave the column without layers so it surfaces with the dimension rock palette. Use this for biomes meant to be bare rock where they get steep.") + ROCK, + + @Description("Cap the column with a single block of the first layer's palette, ignoring that layer's slope condition. Keeps steep coastlines and cliff faces from turning into bare stone.") + TOP_LAYER +} diff --git a/core/src/main/java/art/arcane/iris/generation/concurrent/MultiBurst.java b/core/src/main/java/art/arcane/iris/generation/concurrent/MultiBurst.java index d356c978e..0969ba03d 100644 --- a/core/src/main/java/art/arcane/iris/generation/concurrent/MultiBurst.java +++ b/core/src/main/java/art/arcane/iris/generation/concurrent/MultiBurst.java @@ -10,6 +10,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinWorkerThread; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.IntSupplier; @@ -21,7 +22,7 @@ public class MultiBurst extends MultiBurstSupport { * Long-running hydrology tile planning. Kept off {@link #burst} so a handful of twenty-second * plans never pin the workers that chunk generation's short stage and mantle tasks need. */ - public static final MultiBurst hydrology = new MultiBurst("Iris Hydrology", () -> Math.max(2, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()))); + public static final MultiBurst hydrology = new HydrologyBurst(); private final AtomicInteger parallelismBaseline = new AtomicInteger(); public MultiBurst() { @@ -136,4 +137,21 @@ public class MultiBurst extends MultiBurstSupport { public static void close(ExecutorService service) { MultiBurstSupport.close(service, M::ms, IrisLogging::info, IrisLogging::warn, IrisLogging::reportError, TIMEOUT); } + + static final class HydrologyBurst extends MultiBurst { + HydrologyBurst() { + super("Iris Hydrology", () -> Math.max(2, + IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()))); + } + + @Override + protected ForkJoinPool createPool( + int parallelism, + ForkJoinPool.ForkJoinWorkerThreadFactory factory, + Thread.UncaughtExceptionHandler handler + ) { + return new ForkJoinPool(parallelism, factory, handler, true, + 0, Integer.MAX_VALUE, parallelism, null, 60L, TimeUnit.SECONDS); + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChannelGeometry.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChannelGeometry.java index 00647143d..c9ca51c8f 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChannelGeometry.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChannelGeometry.java @@ -49,6 +49,9 @@ final class HydrologyChannelGeometry { compiler.settings.surface().banks().maximumBlendWidth() ); if (type == HydrologyFeatureType.WATERFALL) { + // A plunge has no floodplain: the shore bench is cut level with the bank top, so a wide one + // terraces the cliff the water falls down. Deliberate saturation, paired with the grading cap + // below - shoreWidth stops tracking the knob past 2.5. shoreWidth = Math.min(2.5D, shoreWidth); gradingWidth = Math.min(gradingWidth, Math.max(8D, channelRadius * 4D)); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java index d6bad6060..858762125 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCrossTileResolver.java @@ -1,5 +1,7 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.generation.hydrology.cave.CavePosition; import art.arcane.iris.generation.hydrology.cave.CaveVoxelView; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate; @@ -17,8 +19,10 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.Callable; final class HydrologyCrossTileResolver { + private static final int FALLBACK_TRIAL_PARALLELISM = 16; private final HydrologyPlanner planner; HydrologyCrossTileResolver(HydrologyPlanner planner) { @@ -114,7 +118,6 @@ final class HydrologyCrossTileResolver { ) ); seedRegionalSurface(key, footprintCompiler); - diagnostics.addAll(footprintCompiler.regionalNetwork.diagnostics()); HashMap> refinedEdges = new HashMap<>(); HashMap sourceCompilations = new HashMap<>(); settleSelection( @@ -147,6 +150,8 @@ final class HydrologyCrossTileResolver { ? List.of() : planner.outletPlanner.resolveSurfaceFallbackOutlets(grid); int fallbackOutletIndex = 0; + int fallbackBatchStart = 0; + List fallbackTrials = List.of(); while (true) { int maximumPublicationPasses = Math.addExact( 1, @@ -225,41 +230,27 @@ final class HydrologyCrossTileResolver { } boolean fallbackSelected = false; while (fallbackOutletIndex < fallbackOutlets.size()) { - OutletCandidate fallbackOutlet = fallbackOutlets.get(fallbackOutletIndex++); - HydrologyRoutingPlan fallbackRouting = planner.sourcePlanner.requireOrganicSurface(planner.sourcePlanner.buildRouting(grid, List.of(fallbackOutlet), true)); - HashMap fallbackContexts = new HashMap<>(); - fallbackContexts.put(key, new SourceRoutingContext( - grid, - fallbackRouting, - undergroundRouting, - List.of() - )); - SourceSelection fallbackSelection = planner.sourcePlanner.selectSources( - key, - grid, - fallbackRouting, - true, - false, - diagnostics, - fallbackContexts - ); - settleSelection( - key, - grid, - fallbackRouting, - undergroundRouting, - fallbackSelection, - true, - footprintCompiler, - refinedEdges, - sourceCompilations, - diagnostics - ); - if (!fallbackSelection.hasAcceptedSelection()) { + if (fallbackOutletIndex >= fallbackBatchStart + fallbackTrials.size()) { + fallbackBatchStart = fallbackOutletIndex; + fallbackTrials = compileFallbackTrials(new FallbackContext( + key, grid, undergroundRouting, footprintCompiler, refinedEdges, sourceCompilations + ), fallbackOutlets, fallbackBatchStart); + } + FallbackTrial trial = fallbackTrials.get(fallbackOutletIndex++ - fallbackBatchStart); + if (trial.failure() != null) { + throw HydrologyPlanner.propagateOwnerFailure(trial.failure()); + } + refinedEdges.putAll(trial.refinedEdges()); + sourceCompilations.putAll(trial.sourceCompilations()); + if (!trial.selection().hasAcceptedSelection()) { continue; } - surfaceRouting = fallbackRouting; - surfaceSelection = fallbackSelection; + // Every trial re-selects over the whole owned lattice, so a discarded one invents a full set of + // per-node rejections for a plan that was never published. Only the adopted trial may report. + diagnostics.addAll(trial.diagnostics()); + surfaceRouting = trial.routing(); + surfaceSelection = trial.selection(); + footprintCompiler = trial.footprintCompiler(); fallbackSelected = true; break; } @@ -287,6 +278,104 @@ final class HydrologyCrossTileResolver { return draft; } + private List compileFallbackTrials( + FallbackContext context, + List outlets, + int start + ) { + int end = Math.min(outlets.size(), start + FALLBACK_TRIAL_PARALLELISM); + ArrayList> tasks = new ArrayList<>(end - start); + for (int index = start; index < end; index++) { + OutletCandidate outlet = outlets.get(index); + tasks.add(() -> compileFallbackTrial(context, outlet)); + } + List trials = HydrologyForkJoin.invokeAll(tasks, + IrisPlatforms.isBound() ? MultiBurst.hydrology : null); + DraftProfile ownerProfile = planner.currentDraftProfile(); + for (FallbackTrial trial : trials) { + DraftProfile trialProfile = trial.profile(); + ownerProfile.routeCalls += trialProfile.routeCalls; + ownerProfile.routeSolves += trialProfile.routeSolves; + ownerProfile.routeSolveNanos += trialProfile.routeSolveNanos; + ownerProfile.rasterCalls += trialProfile.rasterCalls; + ownerProfile.rasterNanos += trialProfile.rasterNanos; + ownerProfile.filterCalls += trialProfile.filterCalls; + ownerProfile.filterNanos += trialProfile.filterNanos; + } + return trials; + } + + private FallbackTrial compileFallbackTrial(FallbackContext context, OutletCandidate outlet) { + HydrologyPlanner.PlanningSamples previousSamples = planner.planningSamples.get(); + DraftProfile previousProfile = planner.draftProfiles.get(); + DraftProfile profile = new DraftProfile(); + ArrayList diagnostics = new ArrayList<>(); + HashMap> refinedEdges = new HashMap<>(context.refinedEdges()); + HashMap sourceCompilations = new HashMap<>(context.sourceCompilations()); + HydrologyFootprintCompiler footprints = null; + try { + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + planner.draftProfiles.set(profile); + footprints = new HydrologyFootprintCompiler( + planner.settings, + new HydrologyFootprintCompiler.Sampling(planner.sampler, planner.geometrySampler, planner.naturalSampler) + ); + footprints.regionalNetwork = context.footprintCompiler().regionalNetwork; + footprints.regionalSurfaceFootprints.putAll(context.footprintCompiler().regionalSurfaceFootprints); + footprints.regionalDropRasters.putAll(context.footprintCompiler().regionalDropRasters); + HydrologyRoutingPlan routing = planner.sourcePlanner.requireOrganicSurface( + planner.sourcePlanner.buildRouting(context.grid(), List.of(outlet), true) + ); + HashMap routingContexts = new HashMap<>(); + routingContexts.put(context.key(), new SourceRoutingContext( + context.grid(), routing, context.undergroundRouting(), List.of() + )); + SourceSelection selection = planner.sourcePlanner.selectSources( + context.key(), context.grid(), routing, true, false, diagnostics, routingContexts + ); + settleSelection(context.key(), context.grid(), routing, context.undergroundRouting(), selection, + true, footprints, refinedEdges, sourceCompilations, diagnostics); + return new FallbackTrial(routing, selection, List.copyOf(diagnostics), footprints, + refinedEdges, sourceCompilations, profile, null); + } catch (Throwable failure) { + return new FallbackTrial(null, null, List.copyOf(diagnostics), footprints, + refinedEdges, sourceCompilations, profile, failure); + } finally { + if (previousSamples == null) { + planner.planningSamples.remove(); + } else { + planner.planningSamples.set(previousSamples); + } + if (previousProfile == null) { + planner.draftProfiles.remove(); + } else { + planner.draftProfiles.set(previousProfile); + } + } + } + + private record FallbackContext( + HydrologyTileKey key, + HydrologySampledGrid grid, + HydrologyRoutingPlan undergroundRouting, + HydrologyFootprintCompiler footprintCompiler, + Map> refinedEdges, + Map sourceCompilations + ) { + } + + private record FallbackTrial( + HydrologyRoutingPlan routing, + SourceSelection selection, + List diagnostics, + HydrologyFootprintCompiler footprintCompiler, + Map> refinedEdges, + Map sourceCompilations, + DraftProfile profile, + Throwable failure + ) { + } + HydrologyTile materializeAcceptedTile(CrossTileResolution resolution) { HydrologyCaveCourseFilter.Result result = resolution.draft().result(); ArrayList diagnostics = new ArrayList<>(resolution.draft().diagnostics()); @@ -324,6 +413,16 @@ final class HydrologyCrossTileResolver { if (footprintCompiler.fullMaterializationCount() < 1) { throw new IllegalStateException("Hydrology publication did not materialize its full footprint."); } + HashSet regionalIds = new HashSet<>(); + for (RiverCourse course : footprintCompiler.regionalNetwork.courses()) { + regionalIds.add(course.id()); + } + HashSet publishedRegionalIds = new HashSet<>(); + for (RiverCourse course : result.courses()) { + if (regionalIds.contains(course.id())) { + publishedRegionalIds.add(course.id()); + } + } return new HydrologyTile( resolution.draft().key(), planner.worldSeed, @@ -333,6 +432,7 @@ final class HydrologyCrossTileResolver { result.edges(), result.outlets(), result.courses(), + publishedRegionalIds, result.cavePlans(), planner.sourcePlanner.uniqueDiagnostics(diagnostics), footprint diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOutletPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOutletPlanner.java index 7486dd0f5..444a9b65f 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOutletPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOutletPlanner.java @@ -1,16 +1,21 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.concurrent.MultiBurst; +import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import art.arcane.iris.spi.IrisPlatforms; + import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; -import java.util.List; -import java.util.Objects; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; -import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import java.util.Objects; +import java.util.concurrent.Callable; final class HydrologyOutletPlanner { + private static final int REGIONAL_SHORELINE_WINDOW = 4; private final HydrologyPlanner planner; HydrologyOutletPlanner(HydrologyPlanner planner) { @@ -133,45 +138,95 @@ final class HydrologyOutletPlanner { ArrayList oceanOutletCandidates(HydrologySampledGrid grid, boolean surface) { ArrayList oceanCandidates = new ArrayList<>(); for (HydrologyGridNode land : grid.nodes()) { - if (land.terrain().ocean() || !land.terrain().transitAllowed() || !land.terrain().outletAllowed()) { + Shoreline shoreline = shoreline(grid, land, surface); + if (shoreline == null) { continue; } - 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; + OutletCandidate candidate = oceanOutletCandidate(shoreline.land(), shoreline.ocean(), surface); + if (candidate != null) { + oceanCandidates.add(candidate); } - HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean); - if (boundary == null) { - continue; - } - HydrologyTerrainSample receiving = planner.sampleBasisWithoutSlope(boundary.oceanPoint().x(), boundary.oceanPoint().z()); - if (receiving == null || !boundary.landwardTerrain().drainsInto(receiving) - || !boundary.landwardTerrain().transitAllowed() - || !boundary.landwardTerrain().outletAllowed() - || surface && !HydrologySurfaceProfiles.sharesProfile(boundary.landwardTerrain(), receiving)) { - continue; - } - HydrologyFeatureType type = coastalOutletType(boundary.landwardTerrain()); - if (type == null) { - continue; - } - long outletId = HydrologyHash.mix(planner.worldSeed, OUTLET_SALT, land.id(), ocean.id(), type.ordinal()); - int seaLevel = planner.settingsSeaLevel(ocean.terrain()); - RiverOutlet outlet = new RiverOutlet( - outletId, - type, - land.id(), - HydrologyPlanner.withY(boundary.landwardPoint(), seaLevel), - HydrologyPlanner.withY(boundary.oceanPoint(), seaLevel), - seaLevel, - true - ); - oceanCandidates.add(new OutletCandidate(land.index(), ocean.index(), outlet)); } return oceanCandidates; } + ArrayList regionalOceanOutletCandidates(HydrologySampledGrid grid) { + ArrayList oceanCandidates = new ArrayList<>(); + int index = 0; + while (index < grid.nodes().size()) { + ArrayList> tasks = new ArrayList<>(REGIONAL_SHORELINE_WINDOW); + while (index < grid.nodes().size() && tasks.size() < REGIONAL_SHORELINE_WINDOW) { + Shoreline shoreline = shoreline(grid, grid.nodes().get(index++), true); + if (shoreline != null) { + tasks.add(() -> regionalOceanOutletCandidate(shoreline)); + } + } + for (OutletCandidate candidate : HydrologyForkJoin.invokeAll(tasks, + IrisPlatforms.isBound() ? MultiBurst.hydrology : null)) { + if (candidate != null) { + oceanCandidates.add(candidate); + } + } + } + return oceanCandidates; + } + + private OutletCandidate regionalOceanOutletCandidate(Shoreline shoreline) { + HydrologyPlanner.PlanningSamples previous = planner.planningSamples.get(); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + try { + return oceanOutletCandidate(shoreline.land(), shoreline.ocean(), true); + } finally { + if (previous == null) { + planner.planningSamples.remove(); + } else { + planner.planningSamples.set(previous); + } + } + } + + private Shoreline shoreline(HydrologySampledGrid grid, HydrologyGridNode land, boolean surface) { + if (land.terrain().ocean() || !land.terrain().transitAllowed() || !land.terrain().outletAllowed()) { + return null; + } + 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. + return null; + } + return new Shoreline(land, ocean); + } + + private OutletCandidate oceanOutletCandidate(HydrologyGridNode land, HydrologyGridNode ocean, boolean surface) { + HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean); + if (boundary == null) { + return null; + } + HydrologyTerrainSample receiving = planner.sampleBasisWithoutSlope(boundary.oceanPoint().x(), boundary.oceanPoint().z()); + if (receiving == null || !boundary.landwardTerrain().drainsInto(receiving) + || !boundary.landwardTerrain().transitAllowed() + || !boundary.landwardTerrain().outletAllowed() + || surface && !HydrologySurfaceProfiles.sharesProfile(boundary.landwardTerrain(), receiving)) { + return null; + } + HydrologyFeatureType type = coastalOutletType(boundary.landwardTerrain()); + if (type == null) { + return null; + } + long outletId = HydrologyHash.mix(planner.worldSeed, OUTLET_SALT, land.id(), ocean.id(), type.ordinal()); + int seaLevel = planner.settingsSeaLevel(ocean.terrain()); + RiverOutlet outlet = new RiverOutlet( + outletId, + type, + land.id(), + HydrologyPlanner.withY(boundary.landwardPoint(), seaLevel), + HydrologyPlanner.withY(boundary.oceanPoint(), seaLevel), + seaLevel, + true + ); + return new OutletCandidate(land.index(), ocean.index(), outlet); + } + List resolveSurfaceFallbackOutlets(HydrologySampledGrid grid) { ArrayList oceanCandidates = oceanOutletCandidates(grid, true); ArrayList inlandCandidates = new ArrayList<>(); @@ -728,4 +783,7 @@ final class HydrologyOutletPlanner { record InlandConnection(HydrologyPoint point, HydrologyTerrainSample terrain) { } + + private record Shoreline(HydrologyGridNode land, HydrologyGridNode ocean) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java index 309110f9f..90d1ade4d 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.hydrology; import art.arcane.iris.generation.hydrology.cave.CaveVoxelView; +import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveContainmentPlanner; import art.arcane.iris.generation.hydrology.surface.SurfaceCourseBuilder; import art.arcane.iris.spi.IrisLogging; @@ -220,6 +221,7 @@ public final class HydrologyPlanner { List.of(), List.of(), List.of(), + Set.of(), List.of(), List.of(), RiverFootprint.empty() @@ -246,6 +248,15 @@ public final class HydrologyPlanner { return tile; } + public List diagnosticCandidates(HydrologyTile tile) { + Objects.requireNonNull(tile, "tile"); + ArrayList diagnostics = new ArrayList<>(tile.localDiagnosticCandidates()); + diagnostics.addAll(regional.diagnosticsIn(regional.ownerBounds(tile.key()))); + ArrayList unique = new ArrayList<>(sourcePlanner.uniqueDiagnostics(diagnostics)); + unique.sort(Comparator.comparingLong(HydrologyDiagnosticCandidate::id)); + return List.copyOf(unique); + } + void clearOwnerDrafts() { resolvedOwners.invalidateAll(); regional.clear(); @@ -258,18 +269,29 @@ public final class HydrologyPlanner { || tile.tileSize() != settings.routing().tileSize()) { throw new IllegalArgumentException("Hydrology tile does not match this planner."); } + ArrayList localCourses = new ArrayList<>(tile.courses().size()); + for (RiverCourse course : tile.courses()) { + if (!tile.regionalCourseIds().contains(course.id())) { + localCourses.add(course); + } + } + ArrayList localPlans = new ArrayList<>(tile.cavePlans().size()); + for (HydrologyCavePlan plan : tile.cavePlans()) { + if (!tile.regionalCourseIds().contains(plan.source().sourceId())) { + localPlans.add(plan); + } + } HydrologyCaveCourseFilter.Result result = new HydrologyCaveCourseFilter.Result( tile.nodes(), tile.edges(), tile.outlets(), - tile.courses(), - tile.cavePlans() + localCourses, + localPlans ); - result = regional.withoutRegional(result, tile.key()); HydrologyOwnerDraft draft = new HydrologyOwnerDraft( tile.key(), result, - tile.diagnosticCandidates(), + tile.localDiagnosticCandidates(), null ); resolvedOwners.put(tile.key(), new CrossTileResolvedOwner(draft, List.of())); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCoarsePlan.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCoarsePlan.java new file mode 100644 index 000000000..3ada89387 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCoarsePlan.java @@ -0,0 +1,34 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; + +import java.util.List; + +record HydrologyRegionalCoarsePlan( + HydrologySampledGrid grid, + List outlets, + HydrologyRegionalGraph.Tree tree, + List sources, + List envelopes, + double longest, + boolean coastal, + int weight +) { + HydrologyRegionalCoarsePlan { + outlets = List.copyOf(outlets); + sources = List.copyOf(sources); + envelopes = List.copyOf(envelopes); + } + + boolean intersects(SurfaceBounds bounds) { + if (coastal) { + return true; + } + for (HydrologyRegionalEnvelope envelope : envelopes) { + if (envelope.intersects(bounds)) { + return true; + } + } + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalEnvelope.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalEnvelope.java new file mode 100644 index 000000000..6ee83398c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalEnvelope.java @@ -0,0 +1,43 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; + +import java.util.List; + +record HydrologyRegionalEnvelope(long minimumX, long minimumZ, long maximumX, long maximumZ) { + private static final HydrologyRegionalEnvelope UNBOUNDED = new HydrologyRegionalEnvelope( + Long.MIN_VALUE, Long.MIN_VALUE, Long.MAX_VALUE, Long.MAX_VALUE); + + static HydrologyRegionalEnvelope of(List guide, HydrologyPlannerSettings settings, int footprintRadius) { + long minimumX = Long.MAX_VALUE; + long minimumZ = Long.MAX_VALUE; + long maximumX = Long.MIN_VALUE; + long maximumZ = Long.MIN_VALUE; + for (HydrologyPoint point : guide) { + minimumX = Math.min(minimumX, point.x()); + minimumZ = Math.min(minimumZ, point.z()); + maximumX = Math.max(maximumX, point.x()); + maximumZ = Math.max(maximumZ, point.z()); + } + if (guide.isEmpty()) { + return UNBOUNDED; + } + int spacing = settings.routing().regional().sampleSpacing(); + double commonPadding = spacing + spacing * settings.geometry().meanders().maximumOffsetRatio() + + 256D + 2D + Math.max(footprintRadius, (long) settings.publicationRadius() + 1L); + double paddingX = StrictMath.ceil(commonPadding + (maximumX - minimumX + 2D * spacing) / 8D); + double paddingZ = StrictMath.ceil(commonPadding + (maximumZ - minimumZ + 2D * spacing) / 8D); + if (!Double.isFinite(paddingX) || !Double.isFinite(paddingZ) + || minimumX - paddingX < Integer.MIN_VALUE || maximumX + paddingX > Integer.MAX_VALUE + || minimumZ - paddingZ < Integer.MIN_VALUE || maximumZ + paddingZ > Integer.MAX_VALUE) { + return UNBOUNDED; + } + return new HydrologyRegionalEnvelope(minimumX - (long) paddingX, minimumZ - (long) paddingZ, + maximumX + (long) paddingX, maximumZ + (long) paddingZ); + } + + boolean intersects(SurfaceBounds bounds) { + return maximumX >= bounds.minimumX() && minimumX <= bounds.maximumX() + && maximumZ >= bounds.minimumZ() && minimumZ <= bounds.maximumZ(); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java index 0da1fb1e4..41ad6bec0 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java @@ -1,5 +1,6 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline; @@ -10,11 +11,13 @@ import art.arcane.iris.generation.hydrology.surface.SurfaceFootprint; import art.arcane.iris.generation.hydrology.surface.SurfaceFootprintCompiler; import art.arcane.iris.generation.hydrology.surface.SurfaceLayerColumn; import art.arcane.iris.generation.hydrology.surface.SurfaceTerminal; +import art.arcane.iris.spi.IrisPlatforms; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -22,12 +25,14 @@ import java.util.Map; import java.util.OptionalLong; import java.util.OptionalInt; import java.util.Set; +import java.util.concurrent.Callable; final class HydrologyRegionalPlanner { private static final long BASIN_SALT = 0x524547424153494eL; private static final long COURSE_SALT = 0x524547434f555253L; private static final long NODE_SALT = 0x5245474e4f444553L; private static final int MAXIMUM_SOURCE_TRIALS = 24; + private static final int SOURCE_TRIAL_WINDOW = 16; private static final int MAXIMUM_DIAGNOSTICS = 64; private final HydrologyPlanner planner; @@ -35,6 +40,11 @@ final class HydrologyRegionalPlanner { private final HydrologyRegionalGraph graph; private final HydrologyRegionalRoute routes; private final Cache drafts; + private final Cache coarsePlans; + private final Map> preparingCoarsePlans = new HashMap<>(); + private final Map> compilingDrafts = new HashMap<>(); + private final Object draftPublicationLock = new Object(); + private long draftEpoch; HydrologyRegionalPlanner(HydrologyPlanner planner) { this.planner = planner; @@ -46,6 +56,10 @@ final class HydrologyRegionalPlanner { .maximumWeight(settings.maximumCachedStations()) .weigher((HydrologyTileKey key, HydrologyRegionalNetwork network) -> Math.max(minimumWeight, network.stations())) .build(); + this.coarsePlans = Caffeine.newBuilder() + .maximumWeight(settings.maximumCachedStations()) + .weigher((HydrologyTileKey key, HydrologyRegionalCoarsePlan plan) -> Math.max(minimumWeight, plan.weight())) + .build(); } boolean enabled() { @@ -55,8 +69,14 @@ final class HydrologyRegionalPlanner { } void clear() { - drafts.invalidateAll(); - routes.clear(); + synchronized (draftPublicationLock) { + draftEpoch++; + compilingDrafts.clear(); + preparingCoarsePlans.clear(); + coarsePlans.invalidateAll(); + drafts.invalidateAll(); + routes.clear(); + } } private HydrologyTerrainSample sample(int x, int z) { @@ -73,30 +93,56 @@ final class HydrologyRegionalPlanner { int maximumX = Math.floorDiv(bounds.maximumX() + reach, basinSize); int minimumZ = Math.floorDiv(bounds.minimumZ() - reach, basinSize); int maximumZ = Math.floorDiv(bounds.maximumZ() + reach, basinSize); - HydrologyTileKey primary = HydrologyTileKey.fromBlock( - (int) (((long) bounds.minimumX() + bounds.maximumX()) / 2L), - (int) (((long) bounds.minimumZ() + bounds.maximumZ()) / 2L), basinSize); - ArrayList networks = new ArrayList<>(); + ArrayList keys = new ArrayList<>(); + ArrayList> tasks = new ArrayList<>(); for (int z = minimumZ; z <= maximumZ; z++) { for (int x = minimumX; x <= maximumX; x++) { HydrologyTileKey key = new HydrologyTileKey(x, z); - HydrologyRegionalNetwork draft = draft(key); - ArrayList accepted = new ArrayList<>(); - for (RiverCourse course : draft.courses()) { - if (intersects(course, bounds, footprintRadius()) && admitted(key, course)) { - accepted.add(course); - } - } - if (!accepted.isEmpty() || key.equals(primary)) { - HydrologyRegionalNetwork selected = select(draft, accepted); - networks.add(new HydrologyRegionalNetwork(selected.nodes(), selected.edges(), selected.outlets(), - selected.courses(), key.equals(primary) ? draft.diagnostics() : List.of(), selected.cavePlans())); + keys.add(key); + tasks.add(() -> demandedDraft(key, bounds)); + } + } + List loaded = HydrologyForkJoin.invokeAll(tasks, + IrisPlatforms.isBound() ? MultiBurst.hydrology : null); + ArrayList networks = new ArrayList<>(loaded.size()); + for (int index = 0; index < loaded.size(); index++) { + HydrologyTileKey key = keys.get(index); + HydrologyRegionalNetwork draft = loaded.get(index); + ArrayList accepted = new ArrayList<>(); + for (RiverCourse course : draft.courses()) { + if (intersects(course, bounds, footprintRadius()) && admitted(key, course)) { + accepted.add(course); } } + if (!accepted.isEmpty()) { + HydrologyRegionalNetwork selected = select(draft, accepted); + networks.add(new HydrologyRegionalNetwork(selected.nodes(), selected.edges(), selected.outlets(), + selected.courses(), List.of(), selected.cavePlans())); + } } return merge(networks); } + List diagnosticsIn(SurfaceBounds bounds) { + if (!enabled()) { + return List.of(); + } + int basinSize = settings.basinSize(planner.settings.routing()); + HydrologyTileKey primary = HydrologyTileKey.fromBlock( + (int) (((long) bounds.minimumX() + bounds.maximumX()) / 2L), + (int) (((long) bounds.minimumZ() + bounds.maximumZ()) / 2L), basinSize); + return draft(primary).diagnostics(); + } + + private HydrologyRegionalNetwork demandedDraft(HydrologyTileKey key, SurfaceBounds bounds) { + HydrologyRegionalNetwork cached = drafts.getIfPresent(key); + if (cached != null) { + return cached; + } + PreparedCoarse prepared = prepareCoarse(key); + return prepared.plan().intersects(bounds) ? draft(key, prepared) : HydrologyRegionalNetwork.EMPTY; + } + RiverFootprint materialize(HydrologyRegionalNetwork network, SurfaceBounds bounds) { if (network.courses().isEmpty()) { return RiverFootprint.empty(); @@ -125,24 +171,142 @@ final class HydrologyRegionalPlanner { } HydrologyRegionalNetwork draft(HydrologyTileKey key) { - return drafts.get(key, this::compile); + HydrologyRegionalNetwork cached = drafts.getIfPresent(key); + return cached != null ? cached : draft(key, prepareCoarse(key)); } - private HydrologyRegionalNetwork compile(HydrologyTileKey key) { + private HydrologyRegionalNetwork draft(HydrologyTileKey key, PreparedCoarse prepared) { + HydrologyForkJoin.Task task; + synchronized (draftPublicationLock) { + boolean current = prepared.epoch() == draftEpoch; + HydrologyRegionalNetwork cached = current ? drafts.getIfPresent(key) : null; + if (cached != null) { + return cached; + } + task = current ? compilingDrafts.get(key) : null; + if (task == null) { + task = new HydrologyForkJoin.Task<>(() -> compileDraft(key, prepared)); + if (current) { + compilingDrafts.put(key, task); + } + } + } + return task.await(); + } + + private PreparedCoarse prepareCoarse(HydrologyTileKey key) { + long epoch; + HydrologyForkJoin.Task task; + synchronized (draftPublicationLock) { + epoch = draftEpoch; + HydrologyRegionalCoarsePlan cached = coarsePlans.getIfPresent(key); + if (cached != null) { + return new PreparedCoarse(epoch, cached); + } + task = preparingCoarsePlans.get(key); + if (task == null) { + task = new HydrologyForkJoin.Task<>(() -> compileCoarse(key, epoch)); + preparingCoarsePlans.put(key, task); + } + } + return new PreparedCoarse(epoch, task.await()); + } + + private HydrologyRegionalCoarsePlan compileCoarse(HydrologyTileKey key, long epoch) { + try { + HydrologyRegionalCoarsePlan plan = coarseSetup(key); + synchronized (draftPublicationLock) { + if (epoch == draftEpoch) { + coarsePlans.put(key, plan); + } + } + return plan; + } finally { + synchronized (draftPublicationLock) { + if (epoch == draftEpoch) { + preparingCoarsePlans.remove(key); + } + } + } + } + + private HydrologyRegionalNetwork compileDraft(HydrologyTileKey key, PreparedCoarse prepared) { + try { + HydrologyRegionalNetwork network = compile(key, prepared.plan()); + synchronized (draftPublicationLock) { + if (prepared.epoch() == draftEpoch) { + drafts.put(key, network); + } + } + return network; + } finally { + synchronized (draftPublicationLock) { + if (prepared.epoch() == draftEpoch) { + compilingDrafts.remove(key); + } + } + } + } + + private HydrologyRegionalCoarsePlan coarseSetup(HydrologyTileKey key) { HydrologySampledGrid grid = sample(key); - ArrayList diagnostics = new ArrayList<>(); - HydrologyGridNode center = grid.nodes().get(grid.nodes().size() / 2); - ArrayList outlets = planner.outletPlanner.oceanOutletCandidates(grid, true); + ArrayList outlets = planner.outletPlanner.regionalOceanOutletCandidates(grid); outlets.removeIf(outlet -> outlet.outlet().type() != HydrologyFeatureType.MOUTH); outlets.sort(Comparator.comparingLong(outlet -> HydrologyHash.mix(planner.worldSeed, BASIN_SALT, key.tileX(), key.tileZ(), outlet.outlet().id()))); + long basinSeed = HydrologyHash.mix(planner.worldSeed, BASIN_SALT, key.tileX(), key.tileZ()); + boolean coastal = settings.coastalChannels() && HydrologyHash.unit(basinSeed) < settings.coastalChannelChance(); + if (outlets.isEmpty() || coastal) { + return new HydrologyRegionalCoarsePlan(grid, outlets, null, List.of(), List.of(), 0D, coastal, grid.nodes().size()); + } + return routeCoarse(grid, outlets); + } + + private HydrologyRegionalCoarsePlan routeCoarse(HydrologySampledGrid grid, List outlets) { + ArrayList roots = spacedOutlets(outlets, settings.maximumTrunks() * 2); + HydrologyRegionalGraph.Tree tree = graph.route(grid, roots, false); + ArrayList sources = new ArrayList<>(); + ArrayList envelopes = new ArrayList<>(); + double longest = 0D; + for (HydrologyGridNode node : grid.nodes()) { + if (!grid.owns(node.x(), node.z()) || tree.root(node.index()) < 0) { + continue; + } + longest = Math.max(longest, tree.length(node.index())); + if (tree.length(node.index()) < settings.minimumLength() + || !planner.sourcePlanner.rawSourceEligible(node.terrain(), planner.settings.surface().sources(), true)) { + continue; + } + sources.add(node.index()); + ArrayList guide = new ArrayList<>(); + for (int index : graph.path(tree, node.index())) { + guide.add(grid.node(index).naturalPoint()); + } + RiverOutlet outlet = tree.outlets().get(tree.root(node.index())).outlet(); + guide.add(outlet.landwardPoint()); + guide.add(outlet.connectionPoint()); + envelopes.add(HydrologyRegionalEnvelope.of(guide, planner.settings, footprintRadius())); + } + int weight = Math.addExact(Math.addExact(grid.nodes().size(), tree.selected().length), + Math.addExact(Math.ceilDiv(tree.accumulation().length, 16), envelopes.size())); + return new HydrologyRegionalCoarsePlan(grid, outlets, tree, sources, envelopes, longest, false, weight); + } + + private record PreparedCoarse(long epoch, HydrologyRegionalCoarsePlan plan) { + } + + private HydrologyRegionalNetwork compile(HydrologyTileKey key, HydrologyRegionalCoarsePlan coarse) { + HydrologySampledGrid grid = coarse.grid(); + ArrayList diagnostics = new ArrayList<>(); + HydrologyGridNode center = grid.nodes().get(grid.nodes().size() / 2); + List outlets = coarse.outlets(); if (outlets.isEmpty()) { diagnostic(diagnostics, center.id(), center.naturalPoint(), false, HydrologyCandidateRejection.NO_LEGAL_OUTLET, 0); return new HydrologyRegionalNetwork(List.of(), List.of(), List.of(), List.of(), diagnostics, List.of()); } ArrayList accepted = new ArrayList<>(); long basinSeed = HydrologyHash.mix(planner.worldSeed, BASIN_SALT, key.tileX(), key.tileZ()); - if (settings.coastalChannels() && HydrologyHash.unit(basinSeed) < settings.coastalChannelChance()) { + if (coarse.coastal()) { ArrayList coastalDiagnostics = new ArrayList<>(); HydrologyRegionalNetwork coastal = coastalChannel(grid, outlets, basinSeed, coastalDiagnostics); diagnostics.addAll(coastalDiagnostics.subList(0, Math.min(16, coastalDiagnostics.size()))); @@ -150,23 +314,11 @@ final class HydrologyRegionalPlanner { accepted.add(coastal); } } - ArrayList roots = spacedOutlets(outlets, settings.maximumTrunks() * 2); - HydrologyRegionalGraph.Tree tree = graph.route(grid, roots, false); - ArrayList sources = new ArrayList<>(); - for (HydrologyGridNode node : grid.nodes()) { - if (grid.owns(node.x(), node.z()) && tree.root(node.index()) >= 0 - && tree.length(node.index()) >= settings.minimumLength() - && planner.sourcePlanner.rawSourceEligible(node.terrain(), planner.settings.surface().sources(), true)) { - sources.add(node.index()); - } - } + HydrologyRegionalCoarsePlan routed = coarse.tree() == null ? routeCoarse(grid, outlets) : coarse; + HydrologyRegionalGraph.Tree tree = routed.tree(); + List sources = routed.sources(); if (sources.isEmpty()) { - double longest = 0D; - for (HydrologyGridNode node : grid.nodes()) { - if (grid.owns(node.x(), node.z()) && tree.root(node.index()) >= 0) { - longest = Math.max(longest, tree.length(node.index())); - } - } + double longest = routed.longest(); diagnostic(diagnostics, center.id(), center.naturalPoint(), false, longest == 0D ? HydrologyCandidateRejection.NO_DRAINAGE_PATH : longest < settings.minimumLength() ? HydrologyCandidateRejection.COURSE_TOO_SHORT @@ -179,26 +331,70 @@ final class HydrologyRegionalPlanner { } } HydrologyRegionalSourceTrials sourceTrials = new HydrologyRegionalSourceTrials(grid, tree, sources); - for (int trials = 0; trials < MAXIMUM_SOURCE_TRIALS; trials++) { - if (accepted.size() >= settings.maximumTrunks()) { + SourceBuildContext sourceContext = new SourceBuildContext(grid, tree, basinSeed); + int trials = 0; + while (trials < MAXIMUM_SOURCE_TRIALS && accepted.size() < settings.maximumTrunks()) { + int window = Math.min(SOURCE_TRIAL_WINDOW, MAXIMUM_SOURCE_TRIALS - trials); + ArrayList> tasks = new ArrayList<>(window); + for (int index = 0; index < window; index++) { + OptionalInt selected = sourceTrials.next(usedOutlets); + if (selected.isEmpty()) { + break; + } + int source = selected.getAsInt(); + tasks.add(() -> buildSourceTrial(sourceContext, source)); + } + if (tasks.isEmpty()) { break; } - OptionalInt selected = sourceTrials.next(usedOutlets); - if (selected.isEmpty()) { - break; - } - int source = selected.getAsInt(); - OutletCandidate outlet = tree.outlets().get(tree.root(source)); - HydrologyRegionalNetwork candidate = build(grid, tree, source, null, basinSeed, diagnostics); - if (!candidate.courses().isEmpty() && !conflicts(candidate.courses().getFirst(), merge(accepted).courses())) { - accepted.add(candidate); - usedOutlets.add(outlet.outlet().id()); + List completed = HydrologyForkJoin.invokeAll(tasks, + IrisPlatforms.isBound() ? MultiBurst.hydrology : null); + for (SourceTrial trial : completed) { + if (accepted.size() >= settings.maximumTrunks()) { + break; + } + if (usedOutlets.contains(trial.outletId())) { + continue; + } + trials++; + if (trial.failure() instanceof Error error) { + throw error; + } + if (trial.failure() instanceof RuntimeException failure) { + throw failure; + } + int retained = Math.min(MAXIMUM_DIAGNOSTICS - diagnostics.size(), trial.diagnostics().size()); + diagnostics.addAll(trial.diagnostics().subList(0, retained)); + HydrologyRegionalNetwork candidate = trial.network(); + if (!candidate.courses().isEmpty() && !conflicts(candidate.courses().getFirst(), merge(accepted).courses())) { + accepted.add(candidate); + usedOutlets.add(trial.outletId()); + } } } HydrologyRegionalNetwork network = merge(accepted); return new HydrologyRegionalNetwork(network.nodes(), network.edges(), network.outlets(), network.courses(), diagnostics, network.cavePlans()); } + private SourceTrial buildSourceTrial(SourceBuildContext context, int source) { + long outletId = context.tree().outlets().get(context.tree().root(source)).outlet().id(); + ArrayList diagnostics = new ArrayList<>(); + HydrologyPlanner.PlanningSamples previous = planner.planningSamples.get(); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + try { + HydrologyRegionalNetwork network = build(context.grid(), context.tree(), source, null, context.basinSeed(), diagnostics); + return new SourceTrial(outletId, network, List.copyOf(diagnostics), null); + } catch (RuntimeException | Error failure) { + return new SourceTrial(outletId, null, List.of(), failure); + } finally { + if (previous == null) { + planner.planningSamples.remove(); + } else { + planner.planningSamples.set(previous); + } + } + } + private HydrologyRegionalNetwork coastalChannel(HydrologySampledGrid grid, List outlets, long basinSeed, List diagnostics) { int maximum = Math.min(8, outlets.size()); @@ -536,29 +732,6 @@ final class HydrologyRegionalPlanner { combined.courses(), combined.cavePlans()); } - HydrologyCaveCourseFilter.Result withoutRegional(HydrologyCaveCourseFilter.Result result, HydrologyTileKey key) { - if (!enabled()) { - return result; - } - Set regionalIds = new HashSet<>(); - for (RiverCourse course : coursesIn(ownerBounds(key)).courses()) { - regionalIds.add(course.id()); - } - ArrayList local = new ArrayList<>(); - for (RiverCourse course : result.courses()) { - if (!regionalIds.contains(course.id())) { - local.add(course); - } - } - ArrayList plans = new ArrayList<>(); - for (HydrologyCavePlan plan : result.cavePlans()) { - if (!regionalIds.contains(plan.source().sourceId())) { - plans.add(plan); - } - } - return new HydrologyCaveCourseFilter.Result(result.nodes(), result.edges(), result.outlets(), local, plans); - } - private int footprintRadius() { return (int) StrictMath.ceil(planner.settings.surface().maximumWidth() * 2D + Math.max(planner.settings.surface().shoreWidth(), planner.settings.widestShoreBiomeWidth()) @@ -749,6 +922,13 @@ final class HydrologyRegionalPlanner { new ArrayList<>(outlets.values()), new ArrayList<>(courses.values()), new ArrayList<>(diagnostics.values()), new ArrayList<>(plans.values())); } + private record SourceBuildContext(HydrologySampledGrid grid, HydrologyRegionalGraph.Tree tree, long basinSeed) { + } + + private record SourceTrial(long outletId, HydrologyRegionalNetwork network, + List diagnostics, Throwable failure) { + } + private record BuildContext(HydrologyGridNode source, OutletCandidate origin, long basinSeed, RiverOutlet outlet, String profile, HydrologyRegionalFlow flow, HydrologyRegionalMorphology morphology) { } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalRoute.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalRoute.java index 87ab8f0f1..6190856a2 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalRoute.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalRoute.java @@ -3,14 +3,19 @@ package art.arcane.iris.generation.hydrology; import art.arcane.volmlib.util.noise.SimplexNoise; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ForkJoinWorkerThread; final class HydrologyRegionalRoute { private static final double SAMPLE_SPACING = 4D; private static final double SIMPLEX_COORDINATE_SCALE = 100D; private static final int MAXIMUM_RECEIVER_SAMPLES = 65536; + private static final int CORRIDOR_SAMPLE_BATCH = 128; + private static final int MAXIMUM_CORRIDOR_WORKERS = 8; private final HydrologyPlanner planner; private final HydrologyRegionalTerrainRefiner terrainRefiner; @@ -333,6 +338,7 @@ final class HydrologyRegionalRoute { } private Refinement validateCorridor(List points, String profile, boolean coastal) { + CorridorSamples samples = new CorridorSamples(points); Set visited = new HashSet<>(); HydrologyTerrainSample previousTerrain = null; HydrologyPoint previousPoint = null; @@ -345,7 +351,7 @@ final class HydrologyRegionalRoute { double progress = steps == 0 ? 0D : step / (double) steps; int x = (int) StrictMath.round(first.x() + (second.x() - first.x()) * progress); int z = (int) StrictMath.round(first.z() + (second.z() - first.z()) * progress); - HydrologyTerrainSample terrain = terrainRefiner.sample(x, z); + HydrologyTerrainSample terrain = samples.next(index, step, x, z); HydrologyPoint failure = new HydrologyPoint(x, terrain == null ? first.y() : terrain.naturalHeight(), z); if (!visited.add(RiverFootprint.pack(x, z))) { return new Refinement(List.of(), failure, HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, 2, null); @@ -568,6 +574,83 @@ final class HydrologyRegionalRoute { + (-before + 3D * start - 3D * end + after) * progress * progress * progress); } + private final class CorridorSamples { + private final List points; + private final int workers; + private final int[] xs; + private final int[] zs; + private final HydrologyTerrainSample[] terrain; + private final RuntimeException[] failures; + private int count; + private int cursor; + + private CorridorSamples(List points) { + this.points = points; + workers = Thread.currentThread() instanceof ForkJoinWorkerThread worker + ? Math.min(MAXIMUM_CORRIDOR_WORKERS, worker.getPool().getParallelism()) : 1; + xs = workers > 1 ? new int[CORRIDOR_SAMPLE_BATCH] : null; + zs = workers > 1 ? new int[CORRIDOR_SAMPLE_BATCH] : null; + terrain = workers > 1 ? new HydrologyTerrainSample[CORRIDOR_SAMPLE_BATCH] : null; + failures = workers > 1 ? new RuntimeException[CORRIDOR_SAMPLE_BATCH] : null; + } + + private HydrologyTerrainSample next(int segment, int step, int x, int z) { + if (workers == 1) { + return terrainRefiner.sample(x, z); + } + if (cursor == count) { + fill(segment, step); + } + int index = cursor++; + if (failures[index] != null) { + throw failures[index]; + } + return terrain[index]; + } + + private void fill(int segment, int firstStep) { + count = 0; + cursor = 0; + Arrays.fill(failures, null); + for (int index = segment; index + 1 < points.size() && count < CORRIDOR_SAMPLE_BATCH; index++) { + HydrologyPoint first = points.get(index); + HydrologyPoint second = points.get(index + 1); + int steps = Math.max(Math.abs(second.x() - first.x()), Math.abs(second.z() - first.z())); + for (int step = index == segment ? firstStep : 1; step <= steps && count < CORRIDOR_SAMPLE_BATCH; step++) { + double progress = steps == 0 ? 0D : step / (double) steps; + xs[count] = (int) StrictMath.round(first.x() + (second.x() - first.x()) * progress); + zs[count++] = (int) StrictMath.round(first.z() + (second.z() - first.z()) * progress); + } + } + int taskCount = Math.min(workers, Math.ceilDiv(count, 16)); + if (taskCount < 2) { + sampleRange(0, count); + return; + } + int width = Math.ceilDiv(count, taskCount); + ArrayList> tasks = new ArrayList<>(taskCount); + for (int index = 0; index < taskCount; index++) { + int start = index * width; + int end = Math.min(count, start + width); + tasks.add(() -> { + sampleRange(start, end); + return null; + }); + } + HydrologyForkJoin.invokeAll(tasks, null); + } + + private void sampleRange(int start, int end) { + for (int index = start; index < end; index++) { + try { + terrain[index] = terrainRefiner.sample(xs[index], zs[index]); + } catch (RuntimeException failure) { + failures[index] = failure; + } + } + } + } + private final class CandidateSelection { private final CandidateAdmission admission; private final OceanEntry resolved; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java index fb333824c..0f1028f98 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java @@ -1,43 +1,54 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.volmlib.util.cache.CacheKey; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.PriorityQueue; +import java.util.concurrent.ConcurrentHashMap; final class HydrologyRegionalTerrainRefiner { private static final int GRID_SPACING = 16; private static final int MAXIMUM_EXPANSIONS = 4096; private static final int MAXIMUM_SAMPLES = 65536; + private static final int MAXIMUM_CACHED_SAMPLES = 4194304; private static final int MAXIMUM_SKIPPED_WAYPOINTS = 2; private static final int[][] NEIGHBOURS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}, {1, 1}, {-1, 1}, {-1, -1}, {1, -1}}; private final HydrologyPlanner planner; - private final Cache edges; private final HydrologyRegionalHydraulics hydraulics; - private final Cache terrain; + private volatile CacheState caches; HydrologyRegionalTerrainRefiner(HydrologyPlanner planner) { this.planner = planner; this.hydraulics = new HydrologyRegionalHydraulics(planner.settings); - this.edges = Caffeine.newBuilder().maximumSize(128).build(); - this.terrain = Caffeine.newBuilder().maximumSize(MAXIMUM_SAMPLES).build(); + this.caches = new CacheState(); } void clear() { - edges.invalidateAll(); - terrain.invalidateAll(); + caches = new CacheState(); } HydrologyTerrainSample sample(int x, int z) { - return terrain.get(RiverFootprint.pack(x, z), key -> new Terrain(planner.naturalSampler == null - ? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z))).sample(); + return sample(caches.terrain, x, z); + } + + private HydrologyTerrainSample sample(Cache cache, int x, int z) { + long key = CacheKey.mix(RiverFootprint.pack(x, z)); + Terrain cached = cache.getIfPresent(key); + if (cached != null) { + return cached.sample(); + } + Terrain sampled = new Terrain(planner.naturalSampler == null + ? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z)); + Terrain existing = cache.asMap().putIfAbsent(key, sampled); + return (existing == null ? sampled : existing).sample(); } List refine(List guide, String profile, boolean coastal, HydrologyTerrainSampler receiver) { @@ -61,7 +72,8 @@ final class HydrologyRegionalTerrainRefiner { double length = 0D; ArrayList refined = new ArrayList<>(); refined.add(guide.getFirst()); - Map stations = new HashMap<>(); + Long2IntOpenHashMap stations = new Long2IntOpenHashMap(); + stations.defaultReturnValue(-1); stations.put(RiverFootprint.pack(guide.getFirst().x(), guide.getFirst().z()), 0); for (int index = 1; index < guide.size(); index++) { int start = index - 1; @@ -70,7 +82,7 @@ final class HydrologyRegionalTerrainRefiner { for (int candidate = index; candidate < guide.size() && candidate <= index + MAXIMUM_SKIPPED_WAYPOINTS; candidate++) { Edge edge = new Edge(guide.get(start), guide.get(candidate), profile, coastal, requiredHead[candidate], availableHead, terminal); - reach = edges.get(edge, this::route); + reach = edge(edge); if (!reach.points().isEmpty()) { selected = candidate; break; @@ -88,8 +100,8 @@ final class HydrologyRegionalTerrainRefiner { for (int point = 1; point < points.size(); point++) { HydrologyPoint next = points.get(point); long key = RiverFootprint.pack(next.x(), next.z()); - Integer previous = stations.get(key); - if (previous != null) { + int previous = stations.get(key); + if (previous >= 0) { while (refined.size() > previous + 1) { HydrologyPoint removed = refined.removeLast(); stations.remove(RiverFootprint.pack(removed.x(), removed.z())); @@ -158,7 +170,7 @@ final class HydrologyRegionalTerrainRefiner { private List simplify(List points, String profile, boolean coastal, Terminal terminal) { double maximumReach = Math.min(512D, planner.settings.routing().regional().sampleSpacing() * 2D); - Map samples = new HashMap<>(); + Samples samples = new Samples(); ArrayList simplified = new ArrayList<>(); int current = 0; int[] requiredHead = requiredHeads(points, terminal, 1); @@ -203,7 +215,7 @@ final class HydrologyRegionalTerrainRefiner { } private Reach route(Edge edge) { - Map samples = new HashMap<>(); + Samples samples = new Samples(); Traversal direct = traverse(edge.start(), edge.end(), edge, samples, edge.maximumHead()); if (direct.cost() == 0D) { return new Reach(List.of(edge.start(), edge.end()), direct.availableHead()); @@ -213,7 +225,7 @@ final class HydrologyRegionalTerrainRefiner { int maximumX = Math.max(edge.start().x(), edge.end().x()) + margin; int minimumZ = Math.min(edge.start().z(), edge.end().z()) - margin; int maximumZ = Math.max(edge.start().z(), edge.end().z()) + margin; - Map> visited = new HashMap<>(); + Long2ObjectOpenHashMap> visited = new Long2ObjectOpenHashMap<>(); PriorityQueue pending = new PriorityQueue<>(Comparator.comparingDouble(Pending::estimate) .thenComparingLong(Pending::key) .thenComparing(Comparator.comparingInt(Pending::availableHead).reversed())); @@ -249,10 +261,35 @@ final class HydrologyRegionalTerrainRefiner { return new Reach(List.of(), edge.maximumHead()); } + private Reach edge(Edge edge) { + CacheState state = caches; + Reach cached = state.edges.getIfPresent(edge); + if (cached != null) { + return cached; + } + HydrologyForkJoin.Task task = new HydrologyForkJoin.Task<>(() -> { + try { + Reach present = state.edges.getIfPresent(edge); + Reach result = present == null ? route(edge) : present; + state.edges.put(edge, result); + return result; + } finally { + state.loading.remove(edge); + } + }); + HydrologyForkJoin.Task existing = state.loading.putIfAbsent(edge, task); + return (existing == null ? task : existing).await(); + } + private void offer(Search search, Node current, HydrologyPoint target) { - HydrologyTerrainSample terrain = sample(target.x(), target.z(), search.samples()); + applyOffer(search, current, evaluateOffer(search, current, target)); + } + + private Offer evaluateOffer(Search search, Node current, HydrologyPoint target) { + Samples samples = search.samples(); + HydrologyTerrainSample terrain = samples.sample(target.x(), target.z()); if (!allowed(terrain, search.edge())) { - return; + return null; } HydrologyPoint point = new HydrologyPoint(target.x(), terrain.naturalHeight(), target.z()); double cost = current.cost() + distance(current.point(), point) @@ -261,30 +298,39 @@ final class HydrologyRegionalTerrainRefiner { long key = RiverFootprint.pack(point.x(), point.z()); List labels = search.visited().get(key); if (dominated(labels, cost, current.availableHead())) { - return; + return null; } - Traversal traversal = traverse(current.point(), point, search.edge(), search.samples(), current.availableHead()); + Traversal traversal = traverse(current.point(), point, search.edge(), samples, current.availableHead()); cost += traversal.cost(); if (!Double.isFinite(cost) || dominated(labels, cost, traversal.availableHead())) { - return; + return null; } - cost += clearanceCost(point, search.edge(), search.samples()); + cost += clearanceCost(point, search.edge(), samples); if (dominated(labels, cost, traversal.availableHead())) { + return null; + } + return new Offer(point, cost, traversal.availableHead()); + } + + private void applyOffer(Search search, Node current, Offer offer) { + if (offer == null) { return; } + long key = RiverFootprint.pack(offer.point().x(), offer.point().z()); + List labels = search.visited().get(key); if (labels == null) { labels = new ArrayList<>(2); search.visited().put(key, labels); } for (int index = labels.size() - 1; index >= 0; index--) { Node previous = labels.get(index); - if (cost <= previous.cost() && traversal.availableHead() >= previous.availableHead()) { + if (offer.cost() <= previous.cost() && offer.availableHead() >= previous.availableHead()) { labels.remove(index); } } - Node retained = new Node(point, cost, traversal.availableHead(), current); + Node retained = new Node(offer.point(), offer.cost(), offer.availableHead(), current); labels.add(retained); - search.pending().add(new Pending(key, retained, cost + distance(point, search.edge().end()))); + search.pending().add(new Pending(key, retained, offer.cost() + distance(offer.point(), search.edge().end()))); } private static boolean dominated(List labels, double cost, int availableHead) { @@ -309,26 +355,25 @@ final class HydrologyRegionalTerrainRefiner { } private Traversal traverse(HydrologyPoint start, HydrologyPoint end, Edge edge, - Map samples, int availableHead) { + Samples samples, int availableHead) { int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); HydrologyTerrainSample previous = null; double distance = distance(start, end); double tangentX = distance == 0D ? 1D : (end.x() - start.x()) / distance; double tangentZ = distance == 0D ? 0D : (end.z() - start.z()) / distance; - HydrologyTerrainSampler bankSampler = (x, z) -> sample(x, z, samples); double cost = 0D; for (int step = 0; step <= steps; step++) { double progress = steps == 0 ? 0D : step / (double) steps; int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress); int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress); - HydrologyTerrainSample terrain = sample(x, z, samples); + HydrologyTerrainSample terrain = samples.sample(x, z); if (!allowed(terrain, edge) || previous != null && (!previous.drainsInto(terrain) || edge.coastal() && !terrain.drainsInto(previous))) { return new Traversal(Double.POSITIVE_INFINITY, availableHead); } if (!edge.coastal()) { availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation( - x, z, tangentX, tangentZ, terrain, availableHead), bankSampler); + x, z, tangentX, tangentZ, terrain, availableHead), samples); if (availableHead < Math.max(edge.minimumHead(), minimumHead(terrain, x, z, edge.terminal()))) { return new Traversal(Double.POSITIVE_INFINITY, availableHead); } @@ -356,7 +401,7 @@ final class HydrologyRegionalTerrainRefiner { return Math.max(0D, excess) * Math.max(1D, planner.settings.routing().uphillPenalty()); } - private double clearanceCost(HydrologyPoint point, Edge edge, Map samples) { + private double clearanceCost(HydrologyPoint point, Edge edge, Samples samples) { double radius = planner.settings.surface().maximumWidth() / 2D * (1D + planner.settings.surface().banks().roughness()) + 1D; int blocked = 0; @@ -364,7 +409,7 @@ final class HydrologyRegionalTerrainRefiner { double scale = offset[0] != 0 && offset[1] != 0 ? radius / StrictMath.sqrt(2D) : radius; int x = point.x() + (int) StrictMath.round(offset[0] * scale); int z = point.z() + (int) StrictMath.round(offset[1] * scale); - if (!allowed(sample(x, z, samples), edge)) { + if (!allowed(samples.sample(x, z), edge)) { blocked++; } } @@ -379,17 +424,6 @@ final class HydrologyRegionalTerrainRefiner { <= planner.settings.routing().regional().maximumCoastalIncision()); } - private HydrologyTerrainSample sample(int x, int z, Map samples) { - long key = RiverFootprint.pack(x, z); - HydrologyTerrainSample sampled = samples.get(key); - if (sampled != null || samples.containsKey(key) || samples.size() >= MAXIMUM_SAMPLES) { - return sampled; - } - sampled = sample(x, z); - samples.put(key, sampled); - return sampled; - } - private static List path(Node current) { ArrayList points = new ArrayList<>(); while (current != null) { @@ -404,6 +438,36 @@ final class HydrologyRegionalTerrainRefiner { return StrictMath.sqrt(first.distanceSquared2D(second)); } + final class Samples implements HydrologyTerrainSampler { + private final Long2ObjectOpenHashMap values = new Long2ObjectOpenHashMap<>(); + private final Cache terrainCache = caches.terrain; + + @Override + public HydrologyTerrainSample sample(int x, int z) { + long key = RiverFootprint.pack(x, z); + HydrologyTerrainSample sampled = values.get(key); + if (sampled != null || values.containsKey(key)) { + return sampled; + } + if (values.size() >= MAXIMUM_SAMPLES) { + return null; + } + sampled = HydrologyRegionalTerrainRefiner.this.sample(terrainCache, x, z); + values.put(key, sampled); + return sampled; + } + } + + private static final class CacheState { + private final Cache edges = Caffeine.newBuilder().maximumSize(128).build(); + private final ConcurrentHashMap> loading = new ConcurrentHashMap<>(); + private final Cache terrain = Caffeine.newBuilder().maximumSize(Math.max(MAXIMUM_SAMPLES, + Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))).build(); + } + + private record Offer(HydrologyPoint point, double cost, int availableHead) { + } + private record Edge(HydrologyPoint start, HydrologyPoint end, String profile, boolean coastal, int minimumHead, int maximumHead, Terminal terminal) { } @@ -420,7 +484,7 @@ final class HydrologyRegionalTerrainRefiner { private record Terrain(HydrologyTerrainSample sample) { } - private record Search(Edge edge, Map samples, Map> visited, + private record Search(Edge edge, Samples samples, Long2ObjectOpenHashMap> visited, PriorityQueue pending) { } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java index fe2104596..801f017f0 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java @@ -1077,7 +1077,6 @@ final class HydrologyRouteGeometry { RouteCandidate start, RouteCandidate end ) { - int minimumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().minimumWidth()); int maximumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().maximumWidth()); 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()); @@ -1103,10 +1102,9 @@ final class HydrologyRouteGeometry { terrain.naturalHeight(), planner.settings.surface().banks().sink() ); - penalty += planner.surfaceCourses.surfaceRouteBankPenalty( + penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty( point, tangent, - minimumBankDistance, maximumBankDistance, head, planner.surfaceCourses.maximumSurfaceBankRise(terrain) @@ -1129,10 +1127,9 @@ final class HydrologyRouteGeometry { terrain.naturalHeight(), planner.settings.surface().banks().sink() ); - penalty += planner.surfaceCourses.surfaceRouteBankPenalty( + penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty( point, tangent, - minimumBankDistance, maximumBankDistance, head, planner.surfaceCourses.maximumSurfaceBankRise(terrain) diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java index e9389b211..d434fd45f 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java @@ -1,5 +1,6 @@ package art.arcane.iris.generation.hydrology; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -281,8 +282,10 @@ final class HydrologySourcePlanner { candidates.add(new SourceCandidate(node.index(), stable, score, required)); hasRequiredCandidate |= required; } + HashMap admissions = new HashMap<>(); if (surfaceBudgets != null && surfaceBudgets.overridden()) { - return selectLocalSurfaceSources(key, grid, routing, candidates, surfaceBudgets, enforceGlobalSpacing, routingContexts); + return selectLocalSurfaceSources(key, grid, routing, candidates, surfaceBudgets, enforceGlobalSpacing, + routingContexts, admissions); } int target = expectedCount(sourceSettings.density(), HydrologyHash.mix( planner.worldSeed, @@ -330,7 +333,8 @@ final class HydrologySourcePlanner { sourceSettings, sourceSalt, surface, - routingContexts + routingContexts, + admissions ); } ); @@ -358,7 +362,8 @@ final class HydrologySourcePlanner { List candidates, HydrologySurfaceBudgets budgets, boolean enforceGlobalSpacing, - Map routingContexts + Map routingContexts, + Map admissions ) { candidates.sort(Comparator.comparing(SourceCandidate::required).reversed() .thenComparing(Comparator.comparingDouble(SourceCandidate::score).reversed()) @@ -404,7 +409,7 @@ final class HydrologySourcePlanner { 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); + SURFACE_SOURCE_SALT, true, routingContexts, admissions); ArrayList selectedIndices = new ArrayList<>(Math.min(count, target)); int[] selectedByArea = new int[areas.size()]; int[] selectedRequiredByArea = new int[areas.size()]; @@ -585,7 +590,73 @@ final class HydrologySourcePlanner { ); } + /** + * Greedy min-separation admission over the lattice. A candidate yields its slot only to a higher-priority + * neighbour that is itself admitted, so a contender that loses its own contest costs nobody a source; under the + * old strict local maximum a doomed contender still suppressed everything beneath it. Contenders outrank the + * node they contest, so the walk is acyclic; {@code admissions} memoizes every decision by packed source + * coordinate and belongs to one surface or underground selection pass. + */ boolean globallyAdmittedSource( + HydrologyGridNode candidate, + HydrologyPlannerSettings.Source sourceSettings, + long sourceSalt, + boolean surface, + Map routingContexts, + Map admissions + ) { + long candidateKey = RiverFootprint.pack(candidate.x(), candidate.z()); + Boolean decided = admissions.get(candidateKey); + if (decided != null) { + return decided; + } + ArrayDeque contests = new ArrayDeque<>(); + contests.push(sourceContest(candidate, sourceSettings, sourceSalt, surface, routingContexts)); + while (!contests.isEmpty()) { + SourceContest contest = contests.peek(); + boolean descended = false; + boolean suppressed = false; + while (contest.cursor < contest.contenders.size()) { + HydrologyGridNode contender = contest.contenders.get(contest.cursor); + Boolean contenderAdmitted = admissions.get(RiverFootprint.pack(contender.x(), contender.z())); + if (contenderAdmitted == null) { + contests.push(sourceContest(contender, sourceSettings, sourceSalt, surface, routingContexts)); + descended = true; + break; + } + contest.cursor++; + if (contenderAdmitted) { + suppressed = true; + break; + } + } + if (descended) { + continue; + } + admissions.put(contest.key, !suppressed); + contests.pop(); + } + return admissions.get(candidateKey); + } + + private SourceContest sourceContest( + HydrologyGridNode node, + HydrologyPlannerSettings.Source sourceSettings, + long sourceSalt, + boolean surface, + Map routingContexts + ) { + return new SourceContest( + RiverFootprint.pack(node.x(), node.z()), + higherPrioritySources(node, sourceSettings, sourceSalt, surface, routingContexts) + ); + } + + /** + * Every eligible lattice source inside {@code candidate}'s spacing that outranks it, best first. Required heads + * and unspaced underground sources contest nothing and so are admitted outright. + */ + private List higherPrioritySources( HydrologyGridNode candidate, HydrologyPlannerSettings.Source sourceSettings, long sourceSalt, @@ -599,12 +670,13 @@ final class HydrologySourcePlanner { ? candidate.terrain().surfaceSourceRequired() : candidate.terrain().undergroundSourceRequired(); if ((!surface && minimumSpacing <= 0) || required) { - return true; + return List.of(); } SourcePriority candidatePriority = sourcePriority(candidate.x(), candidate.z(), candidate.terrain(), sourceSalt, surface); int sampleSpacing = planner.settings.routing().sampleSpacing(); int searchSpacing = surface ? Math.max(minimumSpacing, planner.settings.maximumSurfaceSourceSpacing()) : minimumSpacing; int latticeRadius = (int) StrictMath.ceil(searchSpacing / (double) sampleSpacing); + ArrayList contenders = new ArrayList<>(); for (int offsetZ = -latticeRadius; offsetZ <= latticeRadius; offsetZ++) { for (int offsetX = -latticeRadius; offsetX <= latticeRadius; offsetX++) { if (offsetX == 0 && offsetZ == 0) { @@ -624,8 +696,7 @@ final class HydrologySourcePlanner { } int x = (int) neighborX; int z = (int) neighborZ; - HydrologyTileKey owner = HydrologyTileKey.fromBlock(x, z, planner.settings.routing().tileSize()); - SourceRoutingContext context = routingContexts.computeIfAbsent(owner, planner::sourceRoutingContext); + SourceRoutingContext context = sourceContext(x, z, routingContexts); HydrologyGridNode neighbor = context.grid().nodeAtWorld(x, z); if (neighbor == null) { throw new IllegalStateException("Source coordinate is absent from its owner routing lattice."); @@ -645,11 +716,28 @@ final class HydrologySourcePlanner { } SourcePriority neighborPriority = sourcePriority(x, z, terrain, sourceSalt, surface); if (compareSourcePriority(neighborPriority, candidatePriority) > 0) { - return false; + contenders.add(new SourceContender(neighbor, neighborPriority)); } } } - return true; + // Best first: the strongest contender is the likeliest to hold its own slot and end the walk at depth one. + contenders.sort((first, second) -> compareSourcePriority(second.priority(), first.priority())); + ArrayList ordered = new ArrayList<>(contenders.size()); + for (SourceContender contender : contenders) { + ordered.add(contender.node()); + } + return ordered; + } + + private SourceRoutingContext sourceContext( + int x, + int z, + Map routingContexts + ) { + return routingContexts.computeIfAbsent( + HydrologyTileKey.fromBlock(x, z, planner.settings.routing().tileSize()), + planner::sourceRoutingContext + ); } SourceRoutingContext compileSourceRoutingContext(HydrologyTileKey key) { @@ -720,6 +808,12 @@ final class HydrologySourcePlanner { return required || weight > 0D; } + /** + * Terrain only, never the routing plan: route length and potential are measured against the outlets of the + * tile that owns the node, so scoring the contest with them ranks neighbours either side of a tile seam + * against different outlet sets and the winner flips with ownership. Ordering ends in x then z, so distinct + * lattice nodes never tie and the contest graph stays acyclic. + */ SourcePriority sourcePriority( int x, int z, @@ -1028,4 +1122,19 @@ final class HydrologySourcePlanner { int z ) { } + + private record SourceContender(HydrologyGridNode node, SourcePriority priority) { + } + + /** One node's unresolved spacing contest: its contenders, and how many of them already answered. */ + private static final class SourceContest { + private final long key; + private final List contenders; + private int cursor; + + private SourceContest(long key, List contenders) { + this.key = key; + this.contenders = contenders; + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java index 2d7e67dbc..e016ecd47 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java @@ -653,7 +653,6 @@ final class HydrologySurfaceCoursePlanner { } double surfaceRouteCandidateBankPenalty(RouteCandidate candidate) { - int minimumBankDistance = surfaceBankDistance(planner.settings.surface().minimumWidth()); int maximumBankDistance = surfaceBankDistance(planner.settings.surface().maximumWidth()); HydrologyPoint point = candidate.point(); HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z()); @@ -667,10 +666,9 @@ final class HydrologySurfaceCoursePlanner { terrain.naturalHeight(), planner.settings.surface().banks().sink() ); - return surfaceRouteBankPenalty( + return surfaceRouteBankBandPenalty( point, candidate.tangent(), - minimumBankDistance, maximumBankDistance, head, maximumSurfaceBankRise(terrain) @@ -684,16 +682,16 @@ final class HydrologySurfaceCoursePlanner { ); } - double surfaceRouteBankPenalty( + double surfaceRouteBankBandPenalty( HydrologyPoint point, RouteDirection tangent, - int minimumDistance, int maximumDistance, int head, int maximumBankRise ) { + int innerDistance = surfaceBankInnerDistance(); double penalty = 0D; - for (int distance = minimumDistance; distance <= maximumDistance; distance++) { + for (int distance = innerDistance; distance <= maximumDistance; distance++) { penalty += surfaceRouteBankPenalty(point, tangent, distance, head, maximumBankRise); penalty += surfaceRouteBankPenalty(point, tangent, -distance, head, maximumBankRise); } @@ -720,6 +718,25 @@ final class HydrologySurfaceCoursePlanner { return (double) excess * excess * 128D; } + /** + * Innermost probed ring, in blocks out from the centerline. It reads {@code surface.maximumWidth()} and + * nothing else - no {@code shoreWidth}, no candidate, no terrain - so raising the shore knob widens the + * probe band instead of sliding it. That is the whole guarantee. + * + *

The ring sits one block outside half the nominal maximum width, which is the widest channel the + * width sampler will draw, not the widest channel that gets cut: {@code ChannelProfileBuilder} clamps a + * station to {@code maximumWidth * 2} after the terrain width multiplier, then scales it again by + * {@code banks().springWidthRatio()} over the spring reach and {@code banks().mouthFlareRatio()} through + * the inlet flare. Routing runs before any profile exists, so no ring can promise to clear the channel. + */ + int surfaceBankInnerDistance() { + return (int) StrictMath.ceil(planner.settings.surface().maximumWidth() / 2D) + 1; + } + + /** + * Outermost probed ring: where the bank of the widest channel is expected once the shore is cut. It + * gains a ring per block of {@code shoreWidth}, which is what widens the band walked by the penalty. + */ int surfaceBankDistance(int width) { int channelRadius = (int) StrictMath.ceil(width / 2D); return Math.max( diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTile.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTile.java index 724ea64b4..262cc882b 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTile.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTile.java @@ -26,8 +26,9 @@ public final class HydrologyTile { private final List edges; private final List outlets; private final List courses; + private final Set regionalCourseIds; private final List cavePlans; - private final List diagnosticCandidates; + private final List localDiagnosticCandidates; private final RiverFootprint footprint; private final Map nodesById; private final Map outletsById; @@ -43,8 +44,9 @@ public final class HydrologyTile { List edges, List outlets, List courses, + Set regionalCourseIds, List cavePlans, - List diagnosticCandidates, + List localDiagnosticCandidates, RiverFootprint footprint ) { this.key = Objects.requireNonNull(key, "key"); @@ -58,12 +60,13 @@ public final class HydrologyTile { this.edges = sortedCopy(edges, Comparator.comparingLong(DrainageEdge::id)); this.outlets = sortedCopy(outlets, Comparator.comparingLong(RiverOutlet::id)); this.courses = sortedCopy(courses, Comparator.comparingLong(RiverCourse::id)); + this.regionalCourseIds = Set.copyOf(Objects.requireNonNull(regionalCourseIds, "regionalCourseIds")); this.cavePlans = sortedCopy( cavePlans, Comparator.comparingLong((HydrologyCavePlan plan) -> plan.source().sourceId()) ); - this.diagnosticCandidates = sortedCopy( - diagnosticCandidates, + this.localDiagnosticCandidates = sortedCopy( + localDiagnosticCandidates, Comparator.comparingLong(HydrologyDiagnosticCandidate::id) ); this.footprint = Objects.requireNonNull(footprint, "footprint"); @@ -107,12 +110,16 @@ public final class HydrologyTile { return courses; } + public Set regionalCourseIds() { + return regionalCourseIds; + } + public List cavePlans() { return cavePlans; } - public List diagnosticCandidates() { - return diagnosticCandidates; + public List localDiagnosticCandidates() { + return localDiagnosticCandidates; } public RiverFootprint footprint() { @@ -139,13 +146,13 @@ public final class HydrologyTile { return footprint.renderSample(x, z); } - public HydrologyDiagnosticRenderSample diagnosticRenderAt(int x, int z, int maximumDistance) { + public HydrologyDiagnosticRenderSample localDiagnosticRenderAt(int x, int z, int maximumDistance) { if (maximumDistance < 0) { throw new IllegalArgumentException("maximumDistance cannot be negative."); } long maximumDistanceSquared = (long) maximumDistance * maximumDistance; ArrayList selected = new ArrayList<>(); - for (HydrologyDiagnosticCandidate candidate : diagnosticCandidates) { + for (HydrologyDiagnosticCandidate candidate : localDiagnosticCandidates) { long distance = candidate.point().distanceSquared2D(new HydrologyPoint(x, candidate.point().y(), z)); if (distance <= maximumDistanceSquared) { selected.add(candidate); @@ -266,6 +273,12 @@ public final class HydrologyTile { throw new IllegalArgumentException("Every cave-bearing course must have one accepted containment plan."); } } + for (long courseId : regionalCourseIds) { + RiverCourse course = coursesById.get(courseId); + if (course == null || course.type() != RiverCourseType.SURFACE) { + throw new IllegalArgumentException("Regional ownership must reference an accepted surface course."); + } + } validateFootprintCourses(coursesById); } @@ -614,8 +627,9 @@ public final class HydrologyTile { && edges.equals(tile.edges) && outlets.equals(tile.outlets) && courses.equals(tile.courses) + && regionalCourseIds.equals(tile.regionalCourseIds) && cavePlans.equals(tile.cavePlans) - && diagnosticCandidates.equals(tile.diagnosticCandidates) + && localDiagnosticCandidates.equals(tile.localDiagnosticCandidates) && footprint.equals(tile.footprint); } @@ -630,8 +644,9 @@ public final class HydrologyTile { edges, outlets, courses, + regionalCourseIds, cavePlans, - diagnosticCandidates, + localDiagnosticCandidates, footprint ); } @@ -639,6 +654,6 @@ public final class HydrologyTile { @Override public String toString() { return "HydrologyTile[key=" + key + ", courses=" + courses.size() + ", candidates=" - + diagnosticCandidates.size() + ", columns=" + footprint.size() + "]"; + + localDiagnosticCandidates.size() + ", columns=" + footprint.size() + "]"; } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java index 4cc193d43..bf3dd7827 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java @@ -44,6 +44,7 @@ public final class HydrologyTileCache implements AutoCloseable { private final int maximumEntries; private final Cache tiles; private final Cache composedChunks; + private final Cache> diagnostics; private final AtomicLong cacheEpoch; private final AtomicBoolean closed; private final ThreadLocal localChunkColumns; @@ -55,11 +56,14 @@ public final class HydrologyTileCache implements AutoCloseable { private final ConcurrentHashMap, CompletableFuture> planning; private final ConcurrentHashMap, PendingLoad> loading; private final ConcurrentHashMap, PendingLoad> composing; + private final ConcurrentHashMap, PendingLoad>> diagnosing; + private final ConcurrentHashMap, PendingLoad>> loadingDiagnostics; private final Object publicationLock = new Object(); private final BooleanSupplier waitingForbidden; private volatile SharedCacheScope sharedCacheScope; private volatile StudioHydrologyTileStore persistentStore; private volatile boolean neighbourPrefetchEnabled; + private volatile Runnable terrainPreparation; private int demandBatches; public HydrologyTileCache(HydrologyPlanner planner) { @@ -105,6 +109,8 @@ public final class HydrologyTileCache implements AutoCloseable { this.planning = new ConcurrentHashMap<>(); this.loading = new ConcurrentHashMap<>(); this.composing = new ConcurrentHashMap<>(); + this.diagnosing = new ConcurrentHashMap<>(); + this.loadingDiagnostics = new ConcurrentHashMap<>(); if (maximumEntries < 1) { throw new IllegalArgumentException("maximumEntries must be positive."); } @@ -119,6 +125,9 @@ public final class HydrologyTileCache implements AutoCloseable { this.composedChunks = Caffeine.newBuilder() .maximumSize(MAXIMUM_COMPOSED_CHUNKS) .build(); + this.diagnostics = Caffeine.newBuilder() + .maximumSize(maximumEntries) + .build(); this.cacheEpoch = new AtomicLong(); this.closed = new AtomicBoolean(); this.localChunkColumns = new ThreadLocal<>(); @@ -142,6 +151,123 @@ public final class HydrologyTileCache implements AutoCloseable { return awaitPlan(planDemandAsync(key)); } + public List diagnosticCandidates(HydrologyTileKey key) { + Objects.requireNonNull(key, "key"); + requireOpen(); + List present = diagnostics.getIfPresent(key); + if (present != null) { + return present; + } + if (waitsForbidden()) { + throw new IllegalStateException("Cold hydrology diagnostics cannot run on a thread that may not wait."); + } + long epoch = beginDemandBatch(); + if (prefetchExecutor == null || plansInline()) { + try { + return loadDiagnostics(new CacheLoadKey<>(epoch, key)); + } finally { + finishDemandBatch(epoch); + } + } + CompletableFuture> future; + try { + future = diagnoseAsync(new CacheLoadKey<>(epoch, key)); + } catch (Throwable failure) { + finishDemandBatch(epoch); + throw failure; + } + future.whenComplete((result, failure) -> finishDemandBatch(epoch)); + return awaitPlan(future); + } + + private List loadDiagnostics(CacheLoadKey loadKey) { + HydrologyTileKey key = loadKey.key(); + List present = diagnostics.getIfPresent(key); + if (present != null) { + return present; + } + PendingLoad> owned = + new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>()); + PendingLoad> existing; + synchronized (publicationLock) { + requireOpen(); + existing = loadingDiagnostics.putIfAbsent(loadKey, owned); + } + if (existing != null) { + return awaitLoad(existing); + } + try { + present = diagnostics.getIfPresent(key); + if (present == null) { + prepareTerrain(); + } + List result = present == null + ? List.copyOf(planner.diagnosticCandidates(planInline(key, loadKey.epoch()))) : present; + synchronized (publicationLock) { + if (!closed.get() && cacheEpoch.get() == loadKey.epoch()) { + diagnostics.put(key, result); + } + } + owned.future().complete(result); + return result; + } catch (Throwable failure) { + owned.future().completeExceptionally(failure); + throw failure; + } finally { + loadingDiagnostics.remove(loadKey, owned); + } + } + + private CompletableFuture> diagnoseAsync(CacheLoadKey loadKey) { + List present = diagnostics.getIfPresent(loadKey.key()); + if (present != null) { + return CompletableFuture.completedFuture(present); + } + PendingLoad> queued = new PendingLoad<>(null, new CompletableFuture<>()); + PendingLoad> existing; + synchronized (publicationLock) { + if (closed.get()) { + return CompletableFuture.failedFuture(new CancellationException("Hydrology tile cache is closed.")); + } + existing = diagnosing.putIfAbsent(loadKey, queued); + } + if (existing != null) { + return existing.future(); + } + try { + prefetchExecutor.execute(() -> runDiagnostics(loadKey, queued)); + } catch (RuntimeException | Error rejected) { + diagnosing.remove(loadKey, queued); + queued.future().completeExceptionally(rejected); + } + return queued.future(); + } + + private void runDiagnostics(CacheLoadKey loadKey, + PendingLoad> queued) { + PendingLoad> active = + new PendingLoad<>(Thread.currentThread(), queued.future()); + boolean started; + synchronized (publicationLock) { + started = !closed.get() && diagnosing.replace(loadKey, queued, active); + } + if (!started) { + queued.future().completeExceptionally(new CancellationException("Hydrology tile cache is closed.")); + diagnosing.remove(loadKey, queued); + return; + } + try { + active.future().complete(loadDiagnostics(loadKey)); + } catch (Throwable failure) { + active.future().completeExceptionally(failure); + if (!(failure instanceof CancellationException) || !closed.get()) { + IrisLogging.reportError(failure); + } + } finally { + diagnosing.remove(loadKey, active); + } + } + private boolean plansInline() { if (prefetchExecutor instanceof MultiBurst burst) { return burst.ownsCurrentThread(); @@ -212,6 +338,7 @@ public final class HydrologyTileCache implements AutoCloseable { return persisted; } } + prepareTerrain(); try { HydrologyTile planned = planner.plan(key); synchronized (publicationLock) { @@ -352,6 +479,7 @@ public final class HydrologyTileCache implements AutoCloseable { planner.clearOwnerDrafts(); tiles.invalidateAll(); composedChunks.invalidateAll(); + diagnostics.invalidateAll(); } prefetchQueue.clear(); queuedPrefetches.clear(); @@ -412,7 +540,7 @@ public final class HydrologyTileCache implements AutoCloseable { @Override public void close() { ArrayList> active = new ArrayList<>(); - ArrayList> queued = new ArrayList<>(); + ArrayList> queued = new ArrayList<>(); synchronized (publicationLock) { for (PendingLoad load : loading.values()) { collectActiveLoad(active, load); @@ -420,6 +548,16 @@ public final class HydrologyTileCache implements AutoCloseable { for (PendingLoad load : composing.values()) { collectActiveLoad(active, load); } + for (PendingLoad> load : loadingDiagnostics.values()) { + collectActiveLoad(active, load); + } + for (PendingLoad> load : diagnosing.values()) { + if (load.owner() == null) { + queued.add(load.future()); + } else { + collectActiveLoad(active, load); + } + } closed.set(true); planning.forEach((key, future) -> { if (!loading.containsKey(key)) { @@ -427,16 +565,28 @@ public final class HydrologyTileCache implements AutoCloseable { } }); } - for (CompletableFuture future : queued) { + for (CompletableFuture future : queued) { future.completeExceptionally(new CancellationException("Hydrology tile cache is closed.")); } CompletableFuture.allOf(active.toArray(CompletableFuture[]::new)) .handle((ignored, failure) -> null) .join(); planning.clear(); + diagnosing.clear(); clear(); } + public void setTerrainPreparation(Runnable preparation) { + terrainPreparation = Objects.requireNonNull(preparation, "terrainPreparation"); + } + + private void prepareTerrain() { + Runnable preparation = terrainPreparation; + if (preparation != null) { + preparation.run(); + } + } + private static void collectActiveLoad(List> active, PendingLoad load) { if (load.owner() == Thread.currentThread()) { throw new IllegalStateException("Hydrology tile cache cannot close from an active load."); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStore.java b/core/src/main/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStore.java index f21a6df08..4b07e4682 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStore.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStore.java @@ -37,11 +37,12 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.OptionalLong; +import java.util.Set; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; final class StudioHydrologyTileStore { - private static final int SCHEMA_VERSION = 3; + private static final int SCHEMA_VERSION = 5; 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() @@ -168,8 +169,9 @@ final class StudioHydrologyTileStore { List edges, List outlets, List courses, + List regionalCourseIds, List cavePlans, - List diagnosticCandidates, + List localDiagnosticCandidates, List columns ) { private static PersistedTile from(HydrologyTile tile) { @@ -177,6 +179,8 @@ final class StudioHydrologyTileStore { for (RiverCourse course : tile.courses()) { courses.add(PersistedCourse.from(course)); } + ArrayList regionalCourseIds = new ArrayList<>(tile.regionalCourseIds()); + regionalCourseIds.sort(Long::compareTo); ArrayList cavePlans = new ArrayList<>(tile.cavePlans().size()); for (HydrologyCavePlan cavePlan : tile.cavePlans()) { cavePlans.add(PersistedCavePlan.from(cavePlan)); @@ -191,8 +195,9 @@ final class StudioHydrologyTileStore { tile.edges(), tile.outlets(), courses, + regionalCourseIds, cavePlans, - tile.diagnosticCandidates(), + tile.localDiagnosticCandidates(), new ArrayList<>(tile.footprint().columns().values()) ); } @@ -202,8 +207,9 @@ final class StudioHydrologyTileStore { requireCollection(edges, "edges"); requireCollection(outlets, "outlets"); requireCollection(courses, "courses"); + requireCollection(regionalCourseIds, "regionalCourseIds"); requireCollection(cavePlans, "cavePlans"); - requireCollection(diagnosticCandidates, "diagnosticCandidates"); + requireCollection(localDiagnosticCandidates, "localDiagnosticCandidates"); requireCollection(columns, "columns"); ArrayList restoredCourses = new ArrayList<>(courses.size()); for (PersistedCourse course : courses) { @@ -231,8 +237,9 @@ final class StudioHydrologyTileStore { edges, outlets, restoredCourses, + Set.copyOf(regionalCourseIds), restoredCavePlans, - diagnosticCandidates, + localDiagnosticCandidates, new RiverFootprint(restoredColumns) ); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java index c26c41faf..2d3bcab17 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.hydrology.runtime; import art.arcane.iris.configuration.IrisSettings; +import art.arcane.volmlib.util.cache.CacheKey; import art.arcane.iris.generation.hydrology.HydrologyNaturalTerrainSampler; import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler; import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; @@ -18,17 +19,15 @@ import java.util.concurrent.Executor; final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrainSampler, AutoCloseable { private static final int MINIMUM_PARALLEL_BASIS_SAMPLES = 64; private static final int COORDINATE_MIX = 0x9E3779B9; + private static final int CACHE_STRIPES = 16; + private static final int MINIMUM_STRIPE_ENTRIES = 256; private final BasisProvider basisProvider; private final IrisHydrologyNaturalHeightProvider heightProvider; private final IrisHydrologyNaturalOceanClassifier oceanClassifier; private final int seaLevel; - private final int maximumEntries; private final SamplingOptions samplingOptions; - private final Object lock; - private final Long2ObjectLinkedOpenHashMap bases; - private final Long2DoubleLinkedOpenHashMap naturalHeights; - private final Long2ObjectLinkedOpenHashMap oceanClassifications; + private final CacheStripe[] stripes; IrisHydrologyRoutingTerrainSampler(Sources sources, SamplingOptions samplingOptions) { Objects.requireNonNull(sources, "sources"); @@ -38,12 +37,12 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai this.oceanClassifier = Objects.requireNonNull(sources.oceanClassifier(), "oceanClassifier"); this.seaLevel = sources.seaLevel(); int maximumEntries = samplingOptions.maximumEntries(); - this.maximumEntries = maximumEntries; - this.lock = new Object(); - this.bases = new Long2ObjectLinkedOpenHashMap<>(maximumEntries); - this.naturalHeights = new Long2DoubleLinkedOpenHashMap(maximumEntries); - this.naturalHeights.defaultReturnValue(Double.NaN); - this.oceanClassifications = new Long2ObjectLinkedOpenHashMap<>(maximumEntries); + int stripeCount = Math.min(CACHE_STRIPES, Integer.highestOneBit(Math.max(1, maximumEntries / MINIMUM_STRIPE_ENTRIES))); + this.stripes = new CacheStripe[stripeCount]; + for (int index = 0; index < stripeCount; index++) { + int capacity = maximumEntries / stripeCount + (index < maximumEntries % stripeCount ? 1 : 0); + stripes[index] = new CacheStripe(capacity); + } } @Override @@ -78,19 +77,20 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai @Override public NaturalClassification classifyNatural(int blockX, int blockZ) { long packed = pack(blockX, blockZ); - synchronized (lock) { - TerrainBasis basis = bases.getAndMoveToLast(packed); + CacheStripe stripe = stripe(packed); + synchronized (stripe) { + TerrainBasis basis = stripe.bases.getAndMoveToLast(packed); if (basis != null) { return basis.terrain().ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; } - NaturalClassification cached = oceanClassifications.getAndMoveToLast(packed); + NaturalClassification cached = stripe.oceanClassifications.getAndMoveToLast(packed); if (cached != null) { return cached; } } NaturalClassification sampled = NaturalClassification.LAND; if (oceanClassifier.isOcean(blockX, blockZ)) { - double height = naturalHeight(packed, blockX, blockZ); + double height = naturalHeight(stripe, packed, blockX, blockZ); if (!Double.isFinite(height)) { return NaturalClassification.UNAVAILABLE; } @@ -98,17 +98,17 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai sampled = NaturalClassification.OCEAN; } } - synchronized (lock) { - TerrainBasis basis = bases.getAndMoveToLast(packed); + synchronized (stripe) { + TerrainBasis basis = stripe.bases.getAndMoveToLast(packed); if (basis != null) { return basis.terrain().ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; } - NaturalClassification existing = oceanClassifications.getAndMoveToLast(packed); + NaturalClassification existing = stripe.oceanClassifications.getAndMoveToLast(packed); if (existing != null) { return existing; } - oceanClassifications.putAndMoveToLast(packed, sampled); - evictOldest(oceanClassifications); + stripe.oceanClassifications.putAndMoveToLast(packed, sampled); + stripe.evictOldest(stripe.oceanClassifications); } return sampled; } @@ -127,55 +127,70 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai TerrainBasis basis(int blockX, int blockZ) { long packed = pack(blockX, blockZ); - synchronized (lock) { - TerrainBasis cached = bases.getAndMoveToLast(packed); + CacheStripe stripe = stripe(packed); + synchronized (stripe) { + TerrainBasis cached = stripe.bases.getAndMoveToLast(packed); if (cached != null) { return cached; } } - double naturalHeight = naturalHeight(packed, blockX, blockZ); + double naturalHeight = naturalHeight(stripe, packed, blockX, blockZ); TerrainBasis sampled = basisProvider.sample(blockX, blockZ, naturalHeight); if (sampled == null) { throw new NullPointerException( "Hydrology terrain basis provider returned null at " + blockX + "," + blockZ ); } - synchronized (lock) { - TerrainBasis existing = bases.getAndMoveToLast(packed); + synchronized (stripe) { + TerrainBasis existing = stripe.bases.getAndMoveToLast(packed); if (existing != null) { return existing; } - bases.putAndMoveToLast(packed, sampled); - oceanClassifications.remove(packed); - evictOldest(bases); + stripe.bases.putAndMoveToLast(packed, sampled); + stripe.oceanClassifications.remove(packed); + stripe.evictOldest(stripe.bases); } return sampled; } int basisCacheSize() { - synchronized (lock) { - return bases.size(); + int size = 0; + for (CacheStripe stripe : stripes) { + synchronized (stripe) { + size += stripe.bases.size(); + } } + return size; } int naturalHeightCacheSize() { - synchronized (lock) { - return naturalHeights.size(); + int size = 0; + for (CacheStripe stripe : stripes) { + synchronized (stripe) { + size += stripe.naturalHeights.size(); + } } + return size; } int oceanClassificationCacheSize() { - synchronized (lock) { - return oceanClassifications.size(); + int size = 0; + for (CacheStripe stripe : stripes) { + synchronized (stripe) { + size += stripe.oceanClassifications.size(); + } } + return size; } @Override public void close() { - synchronized (lock) { - bases.clear(); - naturalHeights.clear(); - oceanClassifications.clear(); + for (CacheStripe stripe : stripes) { + synchronized (stripe) { + stripe.bases.clear(); + stripe.naturalHeights.clear(); + stripe.oceanClassifications.clear(); + } } } @@ -275,30 +290,21 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai } - private void evictOldest(Long2ObjectLinkedOpenHashMap cache) { - if (cache.size() <= maximumEntries) { - return; - } - cache.removeFirst(); - } - - private void evictOldest(Long2DoubleLinkedOpenHashMap cache) { - if (cache.size() <= maximumEntries) { - return; - } - cache.removeFirstDouble(); - } - private double naturalHeight(int blockX, int blockZ) { - return naturalHeight(pack(blockX, blockZ), blockX, blockZ); + long packed = pack(blockX, blockZ); + return naturalHeight(stripe(packed), packed, blockX, blockZ); } - private double naturalHeight(long packed, int blockX, int blockZ) { - synchronized (lock) { - double cached = naturalHeights.getAndMoveToLast(packed); - if (!Double.isNaN(cached) || naturalHeights.containsKey(packed)) { + private double naturalHeight(CacheStripe stripe, long packed, int blockX, int blockZ) { + synchronized (stripe) { + double cached = stripe.naturalHeights.getAndMoveToLast(packed); + if (!Double.isNaN(cached) || stripe.naturalHeights.containsKey(packed)) { return cached; } + TerrainBasis cachedBasis = stripe.bases.getAndMoveToLast(packed); + if (cachedBasis != null) { + return cachedBasis.naturalHeight(); + } } double sampled = heightProvider.sample(blockX, blockZ); if (!Double.isFinite(sampled)) { @@ -306,22 +312,53 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai // later sample of the same column gets a fresh chance instead of the cached failure. return sampled; } - synchronized (lock) { - double existing = naturalHeights.getAndMoveToLast(packed); - if (!Double.isNaN(existing) || naturalHeights.containsKey(packed)) { + synchronized (stripe) { + double existing = stripe.naturalHeights.getAndMoveToLast(packed); + if (!Double.isNaN(existing) || stripe.naturalHeights.containsKey(packed)) { return existing; } - naturalHeights.putAndMoveToLast(packed, sampled); - evictOldest(naturalHeights); + stripe.naturalHeights.putAndMoveToLast(packed, sampled); + stripe.evictOldest(stripe.naturalHeights); } return sampled; } + private CacheStripe stripe(long packed) { + return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)]; + } + private static long pack(int blockX, int blockZ) { int mixedZ = blockZ ^ Integer.rotateLeft(blockX * COORDINATE_MIX, 16); return ((long) blockX << 32) ^ (mixedZ & 0xffffffffL); } + private static final class CacheStripe { + private final int maximumEntries; + private final Long2ObjectLinkedOpenHashMap bases; + private final Long2DoubleLinkedOpenHashMap naturalHeights; + private final Long2ObjectLinkedOpenHashMap oceanClassifications; + + private CacheStripe(int maximumEntries) { + this.maximumEntries = maximumEntries; + this.bases = new Long2ObjectLinkedOpenHashMap<>(maximumEntries); + this.naturalHeights = new Long2DoubleLinkedOpenHashMap(maximumEntries); + this.naturalHeights.defaultReturnValue(Double.NaN); + this.oceanClassifications = new Long2ObjectLinkedOpenHashMap<>(maximumEntries); + } + + private void evictOldest(Long2ObjectLinkedOpenHashMap cache) { + if (cache.size() > maximumEntries) { + cache.removeFirst(); + } + } + + private void evictOldest(Long2DoubleLinkedOpenHashMap cache) { + if (cache.size() > maximumEntries) { + cache.removeFirstDouble(); + } + } + } + @FunctionalInterface interface BasisProvider { TerrainBasis sample(int blockX, int blockZ, double naturalHeight); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java index b24bfdb9e..dd8d93c06 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java @@ -119,6 +119,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { IrisPlatforms.isBound() ? MultiBurst.hydrology : null, context.waitingForbidden() ); + this.cache.setTerrainPreparation(context.terrainPreparation()); IrisLogging.debug("Hydrology runtime: tileSize=%d publicationRadius=%d planningThreads=%d", settings.routing().tileSize(), settings.publicationRadius(), MultiBurst.hydrology.parallelism()); } @@ -131,6 +132,10 @@ public final class IrisHydrologyRuntime implements AutoCloseable { return cache.get(key); } + public List diagnosticCandidates(HydrologyTileKey key) { + return cache.diagnosticCandidates(key); + } + /** * Whether the column can be sampled without planning: true when its tiles are cached. Otherwise the * planning pool is asked for them and the caller should answer from natural terrain for now. @@ -319,8 +324,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable { LinkedHashMap candidates = new LinkedHashMap<>(); for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { - HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ)); - for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + HydrologyTileKey key = new HydrologyTileKey(tileX, tileZ); + for (HydrologyDiagnosticCandidate candidate : diagnosticCandidates(key)) { if (candidate.point().x() < minX || candidate.point().x() >= maxX || candidate.point().z() < minZ || candidate.point().z() >= maxZ) { continue; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntimeContext.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntimeContext.java index 784395ea4..005da8f08 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntimeContext.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntimeContext.java @@ -17,7 +17,8 @@ public record IrisHydrologyRuntimeContext( IrisHydrologyNaturalHeightDescriber naturalHeightDescriber, IrisHydrologyNaturalOceanClassifier naturalOceanClassifier, HydrologyCaveVoxelViewFactory caveViewFactory, - BooleanSupplier waitingForbidden + BooleanSupplier waitingForbidden, + Runnable terrainPreparation ) { /** * {@code waitingForbidden} is true on a thread that must never wait for a cold hydrology plan, @@ -36,5 +37,6 @@ public record IrisHydrologyRuntimeContext( Objects.requireNonNull(naturalOceanClassifier); Objects.requireNonNull(caveViewFactory); Objects.requireNonNull(waitingForbidden); + Objects.requireNonNull(terrainPreparation); } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java index 2bd6a85b0..75d9b51e4 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java @@ -324,6 +324,9 @@ public final class MantleHydrologyComponent extends IrisMantleComponent { CavePosition position = new CavePosition(sample.x(), y, sample.z()); HydrologyCaveAction action = plan.actions().get(position); if (action == null || action == HydrologyCaveAction.SEAL_GUARD) { + if (!isCaveLayer(layer)) { + continue; + } throw new IllegalStateException("Accepted cave layer is absent from its containment plan at " + position + "."); } diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java index 67d8fc918..6e859ad06 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisGenerator.java @@ -31,7 +31,7 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.interpolation.IrisInterpolation; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.noise.CellGenerator; -import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; +import java.util.concurrent.atomic.AtomicReferenceArray; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; @@ -262,12 +262,13 @@ public class IrisGenerator extends IrisRegistrant { mixed *= 0xff51afd7ed558ccdL; mixed ^= mixed >>> 33; SurfaceCacheStripe stripe = stripes[(int) mixed & (SURFACE_CACHE_STRIPES - 1)]; - SurfaceSample cached = stripe.get(key, superSeed); + int slot = (int) (mixed >>> 4) & (SURFACE_CACHE_STRIPE_SIZE - 1); + SurfaceSample cached = stripe.get(key, superSeed, slot); if (cached != null) { return cached.height(); } double height = sampleHeight(x, z, superSeed); - stripe.put(key, new SurfaceSample(superSeed, height)); + stripe.put(slot, new SurfaceSample(key, superSeed, height)); return height; } @@ -347,22 +348,19 @@ public class IrisGenerator extends IrisRegistrant { private record SurfaceCache(IrisData data, Engine engine, SurfaceCacheStripe[] stripes) { } - private record SurfaceSample(long seed, double height) { + private record SurfaceSample(long key, long seed, double height) { } private static final class SurfaceCacheStripe { - private final Long2ObjectLinkedOpenHashMap entries = new Long2ObjectLinkedOpenHashMap<>(16); + private final AtomicReferenceArray entries = new AtomicReferenceArray<>(SURFACE_CACHE_STRIPE_SIZE); - private synchronized SurfaceSample get(long key, long seed) { - SurfaceSample sample = entries.getAndMoveToLast(key); - return sample != null && sample.seed() == seed ? sample : null; + private SurfaceSample get(long key, long seed, int slot) { + SurfaceSample sample = entries.get(slot); + return sample != null && sample.key() == key && sample.seed() == seed ? sample : null; } - private synchronized void put(long key, SurfaceSample sample) { - entries.putAndMoveToLast(key, sample); - if (entries.size() > SURFACE_CACHE_STRIPE_SIZE) { - entries.removeFirst(); - } + private void put(int slot, SurfaceSample sample) { + entries.set(slot, sample); } } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java b/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java index 193bd2f07..6287f897a 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java @@ -354,6 +354,8 @@ public final class DimensionTerrainContext implements DataProvider { ); return mappedTerrainHeight(imageMapRuntime, proceduralHeight, x, z); }, Interpolated.DOUBLE), cachePrefix + "ImageMappedHeightStream", engine, cacheSize); + // heightStream is cached and never resolves the surface biome, so the shore band can read its slope. + ProceduralStream shoreSlopeStream = heightStream.slope(3); ProceduralStream finalBiomeStream = focusBiome == null ? GenerationStreams.cache2D(heightStream.convertAware2D((height, x, z) -> { IrisBiome mappedBiome = imageMapRuntime.sampleBiome(x, z); @@ -370,7 +372,8 @@ public final class DimensionTerrainContext implements DataProvider { fluidHeight, landBiomeStream, seaBiomeStream, - shoreBiomeStream + shoreBiomeStream, + shoreSlopeStream ); return resolvedBiome == baseBiome ? baseBiome diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java index e7ba47a08..2a0400860 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java @@ -43,30 +43,25 @@ public final class GenerationCacheWarmer { RNG root = new RNG(engine.getSeedManager().getComponent() + 7777L); int[] counter = {0}; - try { - KList biomes = engine.getAllBiomes(); - biomes.sort(Comparator.comparing(IrisBiome::getLoadKey)); - for (IrisBiome biome : biomes) { - warmPlacements(biome.getObjects(), root, counter, data, engine); - warmDecorators(biome.getDecorators(), root, counter, data); - warmOres(biome.getOres(), root, counter, data); - warmProcedural(biome.getProceduralObjects(), root, counter, data); - } - - KList regions = engine.getDimension().getAllRegions(engine); - regions.sort(Comparator.comparing(IrisRegion::getLoadKey)); - for (IrisRegion region : regions) { - warmPlacements(region.getObjects(), root, counter, data, engine); - warmOres(region.getOres(), root, counter, data); - warmProcedural(region.getProceduralObjects(), root, counter, data); - } - - warmOres(engine.getDimension().getOres(), root, counter, data); - } catch (Throwable e) { - IrisLogging.reportError(e); - IrisLogging.warn("Generation cache warm pass failed: " + e.getMessage()); + KList biomes = engine.getAllBiomes(); + biomes.sort(Comparator.comparing(IrisBiome::getLoadKey)); + for (IrisBiome biome : biomes) { + warmPlacements(biome.getObjects(), root, counter, data, engine); + warmDecorators(biome.getDecorators(), root, counter, data); + warmOres(biome.getOres(), root, counter, data); + warmProcedural(biome.getProceduralObjects(), root, counter, data); } + KList regions = engine.getDimension().getAllRegions(engine); + regions.sort(Comparator.comparing(IrisRegion::getLoadKey)); + for (IrisRegion region : regions) { + warmPlacements(region.getObjects(), root, counter, data, engine); + warmOres(region.getOres(), root, counter, data); + warmProcedural(region.getProceduralObjects(), root, counter, data); + } + + warmOres(engine.getDimension().getOres(), root, counter, data); + IrisLogging.debug("[IrisEngine timing] cache warm " + counter[0] + " configs=" + (M.ms() - start) + "ms"); } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/HydrologyNoiseCacheBudget.java b/core/src/main/java/art/arcane/iris/generation/runtime/HydrologyNoiseCacheBudget.java new file mode 100644 index 000000000..47e3656e7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/runtime/HydrologyNoiseCacheBudget.java @@ -0,0 +1,61 @@ +package art.arcane.iris.generation.runtime; + +final class HydrologyNoiseCacheBudget { + static final int MAXIMUM_CHUNKS = 32_768; + static final int BYTES_PER_CHUNK = 4_096; + static final HydrologyNoiseCacheBudget SHARED = new HydrologyNoiseCacheBudget(Runtime.getRuntime().maxMemory() / 8L); + + private final long maximumBytes; + private long reservedBytes; + + HydrologyNoiseCacheBudget(long maximumBytes) { + if (maximumBytes < 0L) { + throw new IllegalArgumentException("Cache budget must not be negative."); + } + this.maximumBytes = maximumBytes; + } + + synchronized Reservation reserve(int cacheCount, int requestedAdditionalChunks) { + if (cacheCount <= 0 || requestedAdditionalChunks < 0 || requestedAdditionalChunks > MAXIMUM_CHUNKS) { + throw new IllegalArgumentException("Invalid hydrology cache reservation."); + } + long bytesPerIncrement = (long) BYTES_PER_CHUNK * cacheCount; + int additionalChunks = (int) Math.min(requestedAdditionalChunks, (maximumBytes - reservedBytes) / bytesPerIncrement); + long bytes = additionalChunks * bytesPerIncrement; + reservedBytes += bytes; + return new Reservation(this, additionalChunks, bytes); + } + + synchronized long reservedBytes() { + return reservedBytes; + } + + private synchronized void release(Reservation reservation) { + if (!reservation.released) { + reservedBytes -= reservation.bytes; + reservation.released = true; + } + } + + static final class Reservation implements AutoCloseable { + private final HydrologyNoiseCacheBudget budget; + private final int additionalChunks; + private final long bytes; + private boolean released; + + private Reservation(HydrologyNoiseCacheBudget budget, int additionalChunks, long bytes) { + this.budget = budget; + this.additionalChunks = additionalChunks; + this.bytes = bytes; + } + + int additionalChunks() { + return additionalChunks; + } + + @Override + public void close() { + budget.release(this); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index a8b50a616..0249ad90e 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -19,6 +19,8 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.generation.stream.GenerationStreams; +import art.arcane.iris.generation.stream.CachedDoubleStream2D; +import art.arcane.iris.generation.stream.CachedStream2D; import art.arcane.iris.configuration.IrisSettings; @@ -36,6 +38,7 @@ import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyNaturalSample; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntimeContext; +import art.arcane.iris.generation.image.IrisImageMapApplication; import art.arcane.iris.generation.image.IrisImageMapRuntime; import art.arcane.iris.generation.mantle.MantleHydrologyCaveVoxelView; import art.arcane.iris.generation.terrain.InferredType; @@ -78,6 +81,7 @@ import lombok.AccessLevel; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.Setter; import lombok.ToString; import java.lang.ref.WeakReference; @@ -90,6 +94,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -111,6 +116,10 @@ public class IrisComplex implements DataProvider { /** One million corners: about 16 MB, roughly a 4000 by 4000 block area at the 4-block grid. */ private static final int SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20; private static final int HEIGHT_BOUNDS_GRID = 4; + /** The slope streams measure the rise across a run of this many blocks, so gradient is slope over run. */ + private static final double SLOPE_RUN = 3D; + /** A sheer coast would let shoreMinimumWidth buy unbounded height, so the shore band stops climbing here. */ + private static final double MAX_SHORE_BAND_GRADIENT = 3D; private static final Comparator INTERPOLATOR_ORDER = Comparator .comparing((IrisInterpolator interpolator) -> interpolator.getFunction().name()) .thenComparingDouble(IrisInterpolator::getHorizontalScale); @@ -134,6 +143,17 @@ public class IrisComplex implements DataProvider { @Getter(AccessLevel.NONE) private final transient Map>> inferredBiomeStreams; private final TransitionGenerationPlan transitionGenerationPlan; + @Getter(AccessLevel.NONE) + private final transient boolean detached; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient List hydrologyNoiseCaches = List.of(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient HydrologyNoiseCacheBudget.Reservation hydrologyNoiseCacheReservation; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient boolean hydrologyNoiseCachesClosed; private RNG rng; private double fluidHeight; private IrisData data; @@ -166,12 +186,16 @@ public class IrisComplex implements DataProvider { private final ResolvedTerrainProvider resolvedTerrain; private ProceduralStream placementHeightStream; private ProceduralStream heightStream; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private CachedDoubleStream2D cachedHeightStream; private ProceduralStream roundedHeighteightStream; private ProceduralStream maxHeightStream; private ProceduralStream overlayStream; private ProceduralStream heightFluidStream; private ProceduralStream slopeStream; private ProceduralStream naturalSlopeStream; + private ProceduralStream shoreSlopeStream; private ProceduralStream riverDistanceStream; private ProceduralStream riverFlowStream; private ProceduralStream riverCarveWeightStream; @@ -216,6 +240,7 @@ public class IrisComplex implements DataProvider { IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan, boolean detached) { terrainEngine = engine; + this.detached = detached; this.transitionGenerationPlan = transitionGenerationPlan; this.resolvedTerrain = new ResolvedTerrainProvider(engine); int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize(), detached); @@ -317,7 +342,8 @@ public class IrisComplex implements DataProvider { ProceduralStream proceduralRegionStream = focusedRegions != null ? focusedRegions : GenerationStreams.cache2D(regionStyleStream .selectRarity(compatRegionPool(engine)), "regionStream", engine, cacheSize); - regionStream = focusedRegions != null ? proceduralRegionStream : GenerationStreams.cache2D(proceduralRegionStream + regionStream = focusedRegions != null || !imageMapRuntime.has(IrisImageMapApplication.REGION) + ? proceduralRegionStream : GenerationStreams.cache2D(proceduralRegionStream .convertAware2D((region, x, z) -> { IrisRegion mapped = imageMapRuntime.sampleRegion(x, z); return mapped == null ? region : mapped; @@ -347,7 +373,8 @@ public class IrisComplex implements DataProvider { ProceduralStream proceduralBaseBiomeStream = focusedBiomes != null ? focusedBiomes : GenerationStreams.cache2D(bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z)) .convertAware2D(this::implode), "baseBiomeStream", engine, cacheSize); - baseBiomeStream = focusedBiomes != null ? proceduralBaseBiomeStream : GenerationStreams.cache2D(proceduralBaseBiomeStream + baseBiomeStream = focusedBiomes != null || !imageMapRuntime.has(IrisImageMapApplication.BIOME) + ? proceduralBaseBiomeStream : GenerationStreams.cache2D(proceduralBaseBiomeStream .convertAware2D((biome, x, z) -> { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); return mapped == null ? biome : mapped; @@ -400,7 +427,8 @@ public class IrisComplex implements DataProvider { (x, z) -> describeNaturalHeight(engine, x, z), this::sampleNaturalOcean, footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint), - () -> engine.getPlatformHooks().isMainThread() + () -> engine.getPlatformHooks().isMainThread(), + this::expandHydrologyNoiseCaches )); hydrologyRuntime.setNeighbourPrefetchEnabled(!engine.isStudio()); IrisRiverBank3DConfig banks3D = configuredHydrology.getRivers().getGeometry().getBanks3D(); @@ -415,7 +443,8 @@ public class IrisComplex implements DataProvider { Math.max(4096, cacheSize), data, banks3D)); } } - ProceduralStream cachedHeightStream = GenerationStreams.cache2DDouble(ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z)), "heightStream", engine, cacheSize); + cachedHeightStream = new CachedDoubleStream2D("heightStream", engine, + ProceduralStream.ofDouble(this::resolveHydrologyTerrainHeight), cacheSize); heightStream = ProceduralStream.ofDouble((x, z) -> terrainEngine.getPlatformHooks().isMainThread() ? nonblockingTerrainHeight(x, z) : cachedHeightStream.getDouble(x, z)); placementHeightStream = ProceduralStream.ofDouble(this::samplePlacementHeight); @@ -424,6 +453,8 @@ public class IrisComplex implements DataProvider { slopeStream = GenerationStreams.contextInjecting(placementHeightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .slope(3); naturalSlopeStream = GenerationStreams.cache2DDouble(naturalHeightStream.slope(3), "naturalSlopeStream", engine, cacheSize); + // Terrain3D column heights resolve the surface biome, so the shore band reads the pre-terrain3D slope or it recurses. + shoreSlopeStream = terrain3DEnabled ? baseTerrainHeightStream.slope(3) : naturalSlopeStream; trueBiomeStream = focusedBiomes != null ? GenerationStreams.cache2D(focusedBiomes, "trueBiomeStream-focus", engine, cacheSize) : GenerationStreams.cache2D(heightStream .convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z)), "trueBiomeStream", engine, cacheSize); trueBiomeDerivativeStream = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z)) @@ -469,6 +500,66 @@ public class IrisComplex implements DataProvider { return engine.isStudio() && !detached ? Math.max(configuredSize, STUDIO_NOISE_CACHE_SIZE) : configuredSize; } + public synchronized void ensureTerrainNoiseCacheSize(int minimumChunks) { + if (minimumChunks <= 0) { + throw new IllegalArgumentException("Terrain noise cache size must be positive"); + } + CachedDoubleStream2D naturalCache = (CachedDoubleStream2D) naturalHeightStream; + if (naturalCache.getMaxSize() < minimumChunks * 256L) { + naturalCache.setMaximumChunks(minimumChunks); + } + if (cachedHeightStream.getMaxSize() < minimumChunks * 256L) { + cachedHeightStream.setMaximumChunks(minimumChunks); + } + } + + void expandHydrologyNoiseCaches() { + expandHydrologyNoiseCaches(HydrologyNoiseCacheBudget.SHARED); + } + + synchronized void expandHydrologyNoiseCaches(HydrologyNoiseCacheBudget budget) { + if (detached || hydrologyRuntime == null || hydrologyNoiseCachesClosed || hydrologyNoiseCacheReservation != null) { + return; + } + IdentityHashMap, Boolean> selected = new IdentityHashMap<>(); + List capacities = new ArrayList<>(5); + int additionalChunks = HydrologyNoiseCacheBudget.MAXIMUM_CHUNKS; + for (ProceduralStream stream : new ProceduralStream[]{ + baseBiomeStream, baseTerrainHeightStream, unblendedNaturalHeightStream, regionStream, bridgeStream}) { + if (!(stream instanceof CachedStream2D || stream instanceof CachedDoubleStream2D) + || selected.put(stream, Boolean.TRUE) != null) { + continue; + } + int originalChunks = Math.toIntExact(((MeteredCache) stream).getMaxSize() / 256L); + if (originalChunks >= HydrologyNoiseCacheBudget.MAXIMUM_CHUNKS) { + continue; + } + capacities.add(new NoiseCacheCapacity(stream, originalChunks)); + additionalChunks = Math.min(additionalChunks, HydrologyNoiseCacheBudget.MAXIMUM_CHUNKS - originalChunks); + } + if (capacities.isEmpty()) { + return; + } + hydrologyNoiseCaches = List.copyOf(capacities); + hydrologyNoiseCacheReservation = budget.reserve(capacities.size(), additionalChunks); + for (NoiseCacheCapacity capacity : hydrologyNoiseCaches) { + capacity.resize(capacity.originalChunks() + hydrologyNoiseCacheReservation.additionalChunks()); + } + } + + private synchronized void releaseHydrologyNoiseCaches() { + hydrologyNoiseCachesClosed = true; + if (hydrologyNoiseCacheReservation == null) { + return; + } + for (NoiseCacheCapacity capacity : hydrologyNoiseCaches) { + capacity.resize(capacity.originalChunks()); + } + hydrologyNoiseCacheReservation.close(); + hydrologyNoiseCacheReservation = null; + hydrologyNoiseCaches = List.of(); + } + void enableStudioHydrologyCache(String runtimeIdentity, Path persistentRoot) { if (hydrologyRuntime != null) { hydrologyRuntime.enableSharedCache(runtimeIdentity, persistentRoot); @@ -684,11 +775,11 @@ public class IrisComplex implements DataProvider { if (biome == null || region == null) { return biome; } - double shoreHeight = region.getShoreHeight(x, z); - if (height >= fluidHeight - 1 && height <= fluidHeight + shoreHeight && !biome.isShore()) { + double shoreTop = shoreBandTop(height, region, x, z, fluidHeight, shoreSlopeStream); + if (height >= fluidHeight - 1 && height <= shoreTop && !biome.isShore()) { return sampleInferredBiome(region, InferredType.SHORE, x, z); } - if (height > fluidHeight + shoreHeight && !biome.isLand()) { + if (height > shoreTop && !biome.isLand()) { return sampleInferredBiome(region, InferredType.LAND, x, z); } if (height < fluidHeight && !biome.isAquatic()) { @@ -1264,10 +1355,38 @@ public class IrisComplex implements DataProvider { fluidHeight, landBiomeStream, seaBiomeStream, - shoreBiomeStream); + shoreBiomeStream, + shoreSlopeStream); return resolved == biome ? biome : implode(resolved, x, z); } + /** + * The highest column the shore band still covers. Shore height alone is a vertical window, so a steep + * coast gets a beach only a fraction of a block wide; shoreMinimumWidth trades the local gradient back into + * height so the beach keeps that width across the ground. Zero width never reads the slope, and neither + * does a column that is already inside the vertical window or too high for any widening to reach. + */ + static double shoreBandTop( + double height, + IrisRegion region, + double x, + double z, + double fluidHeight, + ProceduralStream shoreSlopes + ) { + double bandTop = fluidHeight + region.getShoreHeight(x, z); + double shoreMinimumWidth = region.getShoreMinimumWidth(); + + if (shoreMinimumWidth <= 0 + || height <= bandTop + || height < fluidHeight - 1 + || height > bandTop + shoreMinimumWidth * MAX_SHORE_BAND_GRADIENT) { + return bandTop; + } + + return bandTop + shoreMinimumWidth * Math.min(shoreSlopes.getDouble(x, z) / SLOPE_RUN, MAX_SHORE_BAND_GRADIENT); + } + static IrisBiome resolveSurfaceBiome( double height, IrisBiome biome, @@ -1277,18 +1396,19 @@ public class IrisComplex implements DataProvider { double fluidHeight, ProceduralStream landBiomes, ProceduralStream seaBiomes, - ProceduralStream shoreBiomes + ProceduralStream shoreBiomes, + ProceduralStream shoreSlopes ) { if (biome == null || region == null) { return biome; } - double sh = region.getShoreHeight(x, z); + double shoreTop = shoreBandTop(height, region, x, z, fluidHeight, shoreSlopes); - if (height >= fluidHeight - 1 && height <= fluidHeight + sh && !biome.isShore()) { + if (height >= fluidHeight - 1 && height <= shoreTop && !biome.isShore()) { return shoreBiomes.get(x, z); } - if (height > fluidHeight + sh && !biome.isLand()) { + if (height > shoreTop && !biome.isLand()) { return landBiomes.get(x, z); } @@ -1944,6 +2064,16 @@ public class IrisComplex implements DataProvider { record GeneratorGroup(IrisInterpolator interpolator, IrisGenerator[] generators) { } + private record NoiseCacheCapacity(ProceduralStream stream, int originalChunks) { + private void resize(int chunks) { + if (stream instanceof CachedDoubleStream2D doubles) { + doubles.setMaximumChunks(chunks); + } else if (stream instanceof CachedStream2D objects) { + objects.setMaximumChunks(chunks); + } + } + } + public void close() { if (hydrologyBanks3D != null) { hydrologyBanks3D.clear(); @@ -1954,6 +2084,7 @@ public class IrisComplex implements DataProvider { if (hydrologyRuntime != null) { hydrologyRuntime.close(); } + releaseHydrologyNoiseCaches(); resolvedTerrain.clear(); } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index b8be3ad33..c8e2ac215 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -322,16 +322,7 @@ public class IrisEngine implements Engine { diagnostics.logPackCompatSummary(); diagnostics.logStudioInitializationPhase("build_runtime", phaseStartedAt, false); phaseStartedAt = System.nanoTime(); - if (requiredMode.warmGenerationCaches()) { - if (requiredMode.studio()) { - startGenerationCacheWarm(phaseStartedAt); - } else { - warmGenerationCaches(phaseStartedAt); - } - } else { - generationCacheWarm.complete(null); - diagnostics.logStudioInitializationPhase("generation_cache_warm", phaseStartedAt, true); - } + prepareGenerationCaches(requiredMode, phaseStartedAt); EngineTickRegistry.registerTicking(this); } catch (Throwable e) { shutdownSequence.cleanupFailedConstruction(e); @@ -355,6 +346,19 @@ public class IrisEngine implements Engine { return !generationCacheWarm.isDone(); } + void prepareGenerationCaches(InitializationMode mode, long phaseStartedAt) { + if (!mode.warmGenerationCaches()) { + generationCacheWarm.complete(null); + diagnostics.logStudioInitializationPhase("generation_cache_warm", phaseStartedAt, true); + return; + } + if (mode == InitializationMode.RUNTIME) { + warmGenerationCaches(phaseStartedAt); + } else { + startGenerationCacheWarm(phaseStartedAt); + } + } + private String currentStudioCacheIdentity() { if (initializationMode != InitializationMode.STUDIO || !IrisPlatforms.isBound()) { return ""; @@ -396,32 +400,32 @@ public class IrisEngine implements Engine { return packRoot.resolve(".iris").resolve("studio-hydrology"); } - public void startStudioEntryHydrology(int blockX, int blockZ) { + public void startEntryHydrology(int blockX, int blockZ) { GenerationRuntimeBinding binding = getActiveGenerationRuntimeBinding(); - if (!backgroundTasks.scheduleTrackedTask(() -> prepareStudioEntryHydrology(binding, blockX, blockZ))) { - throw new IllegalStateException("Iris background task admission closed before Studio entry preparation."); + if (!backgroundTasks.scheduleTrackedTask(() -> prepareEntryHydrology(binding, blockX, blockZ))) { + throw new IllegalStateException("Iris background task admission closed before world entry preparation."); } } - private void prepareStudioEntryHydrology(GenerationRuntimeBinding binding, int blockX, int blockZ) { + private void prepareEntryHydrology(GenerationRuntimeBinding binding, int blockX, int blockZ) { if (closing.get()) { return; } GenerationSessionLease lease; try { - lease = acquireGenerationLease("studio_entry_hydrology"); + lease = acquireGenerationLease("initial_entry_hydrology"); } catch (GenerationSessionException failure) { if (closing.get()) { return; } - throw new IllegalStateException("Studio entry hydrology preparation could not acquire its generation session.", failure); + throw new IllegalStateException("World entry hydrology preparation could not acquire its generation session.", failure); } try (lease; GenerationRuntimeScope runtimeScope = generationRuntimeScopes.open(binding); IrisContext.Scope context = IrisContext.open(this, lease.sessionId(), null)) { getComplex().getHydrologyRuntime().prepareChunkColumns(blockX, blockZ); } catch (Exception failure) { - throw new IllegalStateException("Studio entry hydrology preparation failed at " + throw new IllegalStateException("World entry hydrology preparation failed at " + blockX + "," + blockZ + ".", failure); } } @@ -1685,6 +1689,7 @@ public class IrisEngine implements Engine { public enum InitializationMode { RUNTIME(false, true), + WORLD_CREATION(false, true), STUDIO(true, true), OBJECT_STUDIO(true, false), JIGSAW_STUDIO(true, false); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/PreservationSVC.java b/core/src/main/java/art/arcane/iris/generation/runtime/PreservationSVC.java index d49d5bbc0..5e61a8f6f 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/PreservationSVC.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/PreservationSVC.java @@ -27,14 +27,17 @@ import art.arcane.volmlib.util.scheduling.Looper; import org.jetbrains.annotations.Unmodifiable; import java.lang.ref.WeakReference; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; public class PreservationSVC implements IrisService, PreservationRegistry { + private static final long SHUTDOWN_TIMEOUT_MILLIS = 10_000L; private final List threads = new CopyOnWriteArrayList<>(); private final List services = new CopyOnWriteArrayList<>(); private final List> caches = new CopyOnWriteArrayList<>(); @@ -114,27 +117,64 @@ public class PreservationSVC implements IrisService, PreservationRegistry { } dereference(); - postShutdown(() -> { - for (Thread i : threads) { - if (i.isAlive()) { - try { - i.interrupt(); - IrisLogging.debug("Shutdown Thread " + i.getName()); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } - } + postShutdown(() -> shutdownResources(SHUTDOWN_TIMEOUT_MILLIS)); + } - for (ExecutorService i : services) { + void shutdownResources(long timeoutMillis) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + List ownedThreads = new ArrayList<>(threads); + if (dereferencer != null && !ownedThreads.contains(dereferencer)) { + ownedThreads.add(dereferencer); + } + List ownedServices = List.copyOf(services); + for (Thread thread : ownedThreads) { + if (thread.isAlive() && thread != Thread.currentThread()) { try { - i.shutdownNow(); - IrisLogging.debug("Shutdown Executor Service " + i); - } catch (Throwable e) { - IrisLogging.reportError(e); + thread.interrupt(); + } catch (Throwable failure) { + IrisLogging.reportError("Failed to interrupt owned thread " + thread.getName(), failure); } } - }); + } + for (ExecutorService executor : ownedServices) { + try { + executor.shutdownNow(); + } catch (Throwable failure) { + IrisLogging.reportError("Failed to stop owned executor " + executor, failure); + } + } + try { + for (Thread thread : ownedThreads) { + long remaining = deadline - System.nanoTime(); + if (thread != Thread.currentThread() && thread.isAlive() && remaining > 0L) { + TimeUnit.NANOSECONDS.timedJoin(thread, remaining); + } + } + for (ExecutorService executor : ownedServices) { + long remaining = deadline - System.nanoTime(); + if (!executor.isTerminated() && remaining > 0L) { + executor.awaitTermination(remaining, TimeUnit.NANOSECONDS); + } + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while draining Iris background resources", failure); + } + List activeResources = new ArrayList<>(); + for (Thread thread : ownedThreads) { + if (thread.isAlive() && thread != Thread.currentThread()) { + activeResources.add("thread " + thread.getName() + " (" + thread.getState() + ")"); + } + } + for (ExecutorService executor : ownedServices) { + if (!executor.isTerminated()) { + activeResources.add("executor " + executor); + } + } + if (!activeResources.isEmpty()) { + throw new IllegalStateException("Iris background resources did not terminate within " + + timeoutMillis + " ms: " + String.join(", ", activeResources)); + } } public void updateCaches() { diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisPostModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisPostModifier.java index 9cc6e3020..80efafdfa 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisPostModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisPostModifier.java @@ -195,7 +195,9 @@ public class IrisPostModifier extends EngineAssignedModifier // Wall Patcher IrisBiome biome = context.getBiome().get(currentPostX, currentPostZ); - if (walls && !river) { + // Neighbour heights come from trueHeight, which excludes fluid, so every coastal column reads a + // seaward drop and would have its beach surface replaced with the wall palette. + if (walls && !river && !biome.isShore()) { if (!biome.getWall().getPalette().isEmpty()) { if (ha < h - 2 || hb < h - 2 || hc < h - 2 || hd < h - 2) { boolean brokeGround = false; diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java index 68a0f24cd..5bad65943 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java @@ -246,8 +246,9 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator extends BasicStream implements ProceduralStrea return cache.getMaxSize(); } + public void setMaximumChunks(int maximumChunks) { + cache.setMaximumChunks(maximumChunks); + } + @Override public boolean isClosed() { return engine.isClosed(); diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java index c61b44876..4304e18e6 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java @@ -22,6 +22,7 @@ import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiomeCustom; import art.arcane.iris.generation.biome.IrisCustomBiomeAliasResolver; import art.arcane.iris.generation.biome.IrisFloatingChildBiomes; +import art.arcane.iris.generation.biome.IrisSurfaceLayerFallback; import art.arcane.iris.generation.cave.IrisCaveProfile; import art.arcane.iris.generation.decoration.IrisDepositGenerator; import art.arcane.iris.generation.decoration.IrisDepositVariant; @@ -323,6 +324,8 @@ public class IrisDimension extends IrisRegistrant { private KList overlayNoise = new KList<>(); @Description("The palette of blocks for 'stone'") private IrisMaterialPalette rockPalette = new IrisMaterialPalette().qclear().qadd("stone"); + @Description("What a column surfaces with when every biome layer is rejected. Biomes may override this.") + private IrisSurfaceLayerFallback surfaceLayerFallback = IrisSurfaceLayerFallback.ROCK; @Description("The dimension fluid block palette used for ocean columns and cave aquifers.") private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water"); @Description("Collection of ores to be generated") diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisRegion.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisRegion.java index c786e65ab..e8528dbed 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisRegion.java @@ -127,6 +127,10 @@ public class IrisRegion extends IrisRegistrant implements Rarity { @MinNumber(0.0001) @Description("The varience of the shore height") private double shoreHeightZoom = 3.14; + @MinNumber(0) + @MaxNumber(64) + @Description("The minimum width of the shore in blocks. Shore height alone is a vertical window, so a steep coast gets almost no beach; above zero the shore band also climbs with the local slope to stay this wide across the ground.") + private double shoreMinimumWidth = 0; @MinNumber(0.0001) @Description("How large land biomes are in this region") private double landBiomeZoom = 1; diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java index 59c101dd6..465178825 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java @@ -899,7 +899,7 @@ public class ServerConfigurator { try (Stream children = Files.list(root)) { for (Path child : children.toList()) { String childName = child.getFileName().toString(); - if (PackDirectoryResolver.isHiddenName(childName) || isGeneratedPackFile(childName)) { + if (PackDirectoryResolver.isHiddenName(childName) || isNonContentPackFile(childName)) { continue; } if (Files.isSymbolicLink(child)) { @@ -953,7 +953,7 @@ public class ServerConfigurator { String fileName = file.getFileName().toString(); if ((treeRoot.relativize(file).getNameCount() == 1 && PackDirectoryResolver.isHiddenName(fileName)) - || isGeneratedPackFile(fileName)) { + || isNonContentPackFile(fileName)) { return FileVisitResult.CONTINUE; } if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { @@ -981,8 +981,8 @@ public class ServerConfigurator { }); } - private static boolean isGeneratedPackFile(String name) { - return name != null && name.endsWith(CODE_WORKSPACE_SUFFIX); + private static boolean isNonContentPackFile(String name) { + return name != null && (name.equals(".DS_Store") || name.endsWith(CODE_WORKSPACE_SUFFIX)); } private record FingerprintEntry( diff --git a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java index cccdd3b09..9c2f3cb08 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java @@ -77,6 +77,7 @@ import art.arcane.iris.generation.context.IrisContext; import art.arcane.volmlib.util.io.ReactiveFolder; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.world.task.J; +import art.arcane.iris.world.runtime.WorldRuntimeControlService; import art.arcane.volmlib.util.scheduling.Looper; import io.papermc.lib.PaperLib; import lombok.Data; @@ -98,6 +99,7 @@ import java.io.IOException; import java.io.PrintWriter; import java.nio.file.Path; import java.util.ArrayList; +import java.util.IdentityHashMap; import java.util.Date; import java.util.List; import java.util.Locale; @@ -121,6 +123,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private static final int LOAD_LOCKS = Runtime.getRuntime().availableProcessors() * 4; private static final long HOTLOAD_LOOP_DELAY_MS = 250L; private static final long HOTLOAD_MAINTENANCE_DELAY_MS = 4000L; + private static final int PLAYER_ENTRY_MOVE_PADDING = 3; private final GenerationStageGate loadLock; private final IrisWorld world; private final File dataLocation; @@ -153,6 +156,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private volatile CompletableFuture startupContentReady = CompletableFuture.completedFuture(null); private volatile CompletableFuture startupReady = CompletableFuture.completedFuture(null); private volatile boolean closing; + private volatile boolean initialEntryPending; + private volatile boolean initialPlayerEntryPending; @Setter private volatile StudioGenerator studioGenerator; @@ -391,9 +396,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } int spawnX = spawn.getBlockX(); int spawnZ = spawn.getBlockZ(); - if (studio && expectedEngine.getDimension().getStudioMode() == StudioMode.NORMAL + if ((initialEntryPending || studio && expectedEngine.getDimension().getStudioMode() == StudioMode.NORMAL) && expectedEngine instanceof IrisEngine irisEngine) { - irisEngine.startStudioEntryHydrology(spawnX, spawnZ); + irisEngine.startEntryHydrology(spawnX, spawnZ); return; } int reach = Math.max(16, hydrology.settings().routing().tileSize() / 2); @@ -411,9 +416,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private void updateSpawnLocation(World world) { try { Location initialSpawn = getInitialSpawnLocation(world); - int chunkX = initialSpawn.getBlockX() >> 4; - int chunkZ = initialSpawn.getBlockZ() >> 4; - CompletableFuture chunkFuture = requestChunkAsync(world, chunkX, chunkZ, true); + CompletableFuture chunkFuture = requestInitialSpawnChunks(world, initialSpawn); if (chunkFuture == null) { initialSpawnReady.completeExceptionally(new IllegalStateException( "Initial spawn chunk request returned no completion future for world \"" @@ -454,6 +457,72 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + private CompletableFuture requestInitialSpawnChunks(World world, Location initialSpawn) { + int chunkX = initialSpawn.getBlockX() >> 4; + int chunkZ = initialSpawn.getBlockZ() >> 4; + if (!initialEntryPending) { + return requestChunkAsync(world, chunkX, chunkZ, true); + } + WorldRuntimeControlService runtime = WorldRuntimeControlService.get(); + CompletableFuture canonical = runtime.requestChunkAsync(world, chunkX, chunkZ, true, true); + if (canonical == null || !initialPlayerEntryPending) { + return canonical; + } + List> requests = new ArrayList<>(4); + requests.add(canonical); + int minimumX = (initialSpawn.getBlockX() - PLAYER_ENTRY_MOVE_PADDING) >> 4; + int maximumX = (initialSpawn.getBlockX() + PLAYER_ENTRY_MOVE_PADDING) >> 4; + int minimumZ = (initialSpawn.getBlockZ() - PLAYER_ENTRY_MOVE_PADDING) >> 4; + int maximumZ = (initialSpawn.getBlockZ() + PLAYER_ENTRY_MOVE_PADDING) >> 4; + for (int x = minimumX; x <= maximumX; x++) { + for (int z = minimumZ; z <= maximumZ; z++) { + if (x == chunkX && z == chunkZ) { + continue; + } + try { + CompletableFuture request = runtime.requestChunkAsync(world, x, z, true, true); + if (request == null) { + throw new IllegalStateException("Initial player entry chunk request returned no completion future for world \"" + + world.getName() + "\" at " + x + "," + z + "."); + } + requests.add(request.thenApply(chunk -> Objects.requireNonNull(chunk, + "Initial player entry chunk request completed without a chunk."))); + } catch (RuntimeException | Error failure) { + requests.add(CompletableFuture.failedFuture(failure)); + } + } + } + CompletableFuture prepared = new CompletableFuture<>(); + CompletableFuture.allOf(requests.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> { + if (failure == null) { + prepared.complete(canonical.getNow(null)); + return; + } + Throwable firstFailure = null; + IdentityHashMap failures = new IdentityHashMap<>(); + for (CompletableFuture request : requests) { + Throwable requestFailure = request.handle((chunk, cause) -> cause).getNow(null); + if (requestFailure == null) { + continue; + } + Throwable distinctCause = requestFailure; + while (distinctCause instanceof CompletionException && distinctCause.getCause() != null) { + distinctCause = distinctCause.getCause(); + } + if (failures.put(distinctCause, Boolean.TRUE) != null) { + continue; + } + if (firstFailure == null) { + firstFailure = request == canonical ? requestFailure : distinctCause; + } else { + firstFailure.addSuppressed(distinctCause); + } + } + prepared.completeExceptionally(firstFailure); + }); + return prepared; + } + private void completeSpawnLocation(World world, Location initialSpawn) { try { applySpawnLocation(world, initialSpawn); @@ -515,8 +584,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun lastJigsawStudioRequestId = null; if (generationHistory != null) { try { - generationHistory.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); - targetCache.reset(); + prepareGenerationHistoryTarget(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks()); } catch (IOException failure) { throw new IllegalStateException("Unable to prepare saved Iris terrain for the current generator.", failure); } @@ -568,12 +636,41 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun isAuthoringStudio(), studioEntryBootstrapActive.get(), initializationFailure != null)); - engine = createdEngine; + publishInitializedEngine(createdEngine); populators.clear(); targetCache.reset(); } } + synchronized void publishInitializedEngine(IrisEngine initializedEngine) { + if (initialEntryPending) { + setInitialEntryNeighbourPrefetch(initializedEngine, false); + } + engine = initializedEngine; + } + + void prepareGenerationHistoryTarget(int transitionWidthBlocks) throws IOException { + generationHistory.prepareCurrentGenerator(transitionWidthBlocks); + EngineTarget cachedTarget = targetCache.getIfPresent(); + if (cachedTarget == null) { + return; + } + Path activePackRoot = generationHistory.activePackRoot(); + IrisData cachedData = cachedTarget.getData(); + if (!cachedData.isClosed() && cachedData.getDataFolder().toPath().equals(activePackRoot)) { + return; + } + targetCache.reset(); + EngineTarget pendingTarget = startupTarget; + Engine activeEngine = engine; + if (!cachedData.isClosed() + && cachedData.getEngines().isEmpty() + && (pendingTarget == null || pendingTarget.getData() != cachedData) + && (activeEngine == null || activeEngine.getData() != cachedData)) { + cachedData.close(); + } + } + private EngineTarget loadActiveGenerationHistoryTarget() throws IOException { Path packRoot = generationHistory.activePackRoot(); IrisData data = IrisData.openRuntime(packRoot.toFile()); @@ -604,7 +701,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private IrisEngine createEngine(EngineTarget engineTarget) { - IrisEngine.InitializationMode mode = selectInitializationMode(studio, jigsawStudioActive, objectStudioActive); + IrisEngine.InitializationMode mode = selectInitializationMode(studio, jigsawStudioActive, objectStudioActive, + initialEntryPending); if (generationHistory == null) { return new IrisEngine(engineTarget, mode); } @@ -638,9 +736,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return new IllegalStateException("Unable to attach Iris generation history.", failure); } - static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive, boolean objectStudioActive) { + static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive, + boolean objectStudioActive, boolean initialEntry) { if (!studio) { - return IrisEngine.InitializationMode.RUNTIME; + return initialEntry ? IrisEngine.InitializationMode.WORLD_CREATION : IrisEngine.InitializationMode.RUNTIME; } return jigsawStudioActive ? IrisEngine.InitializationMode.JIGSAW_STUDIO @@ -864,12 +963,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun // reject generation for a world that may still be loaded. closing = false; } + completeInitialEntry(); closeFuture.compareAndSet(future, null); future.completeExceptionally(throwable); return future; } operation.whenComplete((ignored, throwable) -> { + completeInitialEntry(); if (throwable == null) { future.complete(null); } else { @@ -898,6 +999,36 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return studio; } + @Override + public synchronized void beginInitialEntry(boolean playerEntry) { + if (studio || closing || engine != null) { + throw new IllegalStateException("Initial world entry must begin before a normal generator initializes."); + } + initialEntryPending = true; + initialPlayerEntryPending = playerEntry; + } + + @Override + public synchronized void completeInitialEntry() { + if (!initialEntryPending) { + return; + } + initialEntryPending = false; + initialPlayerEntryPending = false; + setInitialEntryNeighbourPrefetch(engine, true); + } + + private void setInitialEntryNeighbourPrefetch(Engine activeEngine, boolean enabled) { + if (activeEngine == null || activeEngine.isClosed()) { + return; + } + IrisComplex complex = activeEngine.getComplex(); + IrisHydrologyRuntime hydrology = complex == null ? null : complex.getHydrologyRuntime(); + if (hydrology != null) { + hydrology.setNeighbourPrefetchEnabled(enabled); + } + } + public void endStudioEntryBootstrap() { Engine activeEngine = engine; if (activeEngine instanceof IrisEngine irisEngine) { diff --git a/core/src/main/java/art/arcane/iris/platform/generation/PlatformChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/PlatformChunkGenerator.java index 87f9066ec..82e0c6520 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/PlatformChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/PlatformChunkGenerator.java @@ -77,6 +77,10 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider { CompletableFuture getSpawnChunks(); + void beginInitialEntry(boolean playerEntry); + + void completeInitialEntry(); + default CompletableFuture getInitialSpawnReady() { return CompletableFuture.completedFuture(null); } diff --git a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java index c1898d560..5bba92d88 100644 --- a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java @@ -205,7 +205,7 @@ public class StudioSVC implements IrisService { queueStudioWorldDeletionOnStartup(worldNamesToDelete); } - public IrisDimension installIntoWorld( + public GenerationPublication installIntoWorld( VolmitSender sender, IrisDimension dimension, File dimensionRoot, @@ -220,7 +220,8 @@ public class StudioSVC implements IrisService { File dimensionRoot, long worldSeed ) { - return publishGenerationHistory(sender, dimension, dimensionRoot, worldSeed, true); + GenerationPublication publication = publishGenerationHistory(sender, dimension, dimensionRoot, worldSeed, true); + return publication == null ? null : publication.dimension(); } public IrisDimension installIntoTransientWorld( @@ -235,7 +236,7 @@ public class StudioSVC implements IrisService { return installIntoDirectory(sender, dimension, folder, true); } - private IrisDimension publishGenerationHistory( + private GenerationPublication publishGenerationHistory( VolmitSender sender, IrisDimension dimension, File dimensionRoot, @@ -288,11 +289,11 @@ public class StudioSVC implements IrisService { Path installedPack = stageUpdate && history.pendingActivation().isPresent() ? history.packRoot(history.pendingActivation().orElseThrow().activationId()) : history.activePackRoot(); - IrisDimension installed = loadInstalledDimension(installedPack, dimensionKey); + IrisDimension installed = loadInstalledDimension(installedPack, source, dimensionKey); if (restartRequired) { ServerConfigurator.restart("An Iris generation epoch update is pending activation."); } - return installed; + return new GenerationPublication(installed, history); } catch (Throwable failure) { IrisLogging.reportError("Failed to publish generation history for dimension '" + dimensionKey + "' into " + dimensionRoot.getPath(), failure); @@ -383,8 +384,9 @@ public class StudioSVC implements IrisService { } } - private static IrisDimension loadInstalledDimension(Path packRoot, String dimensionKey) throws IOException { - PackValidationResult validation = validatePublishedPack(packRoot); + static IrisDimension loadInstalledDimension(Path packRoot, Path validatedSource, String dimensionKey) throws IOException { + String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(packRoot.toFile()); + PackValidationResult validation = validatePublishedPack(packRoot, validatedSource, copiedFingerprint); if (!validation.isLoadable()) { throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); } @@ -1755,6 +1757,13 @@ public class StudioSVC implements IrisService { RESTART } + public record GenerationPublication(IrisDimension dimension, GenerationHistory history) { + public GenerationPublication { + Objects.requireNonNull(dimension, "Published dimension"); + Objects.requireNonNull(history, "Published generation history"); + } + } + private record GenerationCandidate( String packFingerprint, GenerationEpoch.DimensionContract dimensionContract, diff --git a/core/src/main/java/art/arcane/iris/world/IrisCreator.java b/core/src/main/java/art/arcane/iris/world/IrisCreator.java index 17d14c5dc..57e48e218 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/world/IrisCreator.java @@ -20,6 +20,7 @@ package art.arcane.iris.world; import art.arcane.iris.world.runtime.TransientWorldCleanupSupport; import art.arcane.iris.world.runtime.WorldRuntimeControlService; +import art.arcane.iris.world.history.GenerationHistory; import com.google.common.util.concurrent.AtomicDouble; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -125,7 +126,7 @@ public class IrisCreator { private boolean benchmark = false; private BiConsumer studioProgressConsumer; private BiConsumer studioTimingConsumer; - private DatapackPreparation datapackPreparation = DatapackPreparation.INSTALL_IF_CHANGED; + private DatapackPreparation datapackPreparation = DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY; public static boolean removeFromBukkitYml(NamespacedKey worldKey) throws IOException { return BukkitWorldConfiguration.remove( @@ -258,9 +259,20 @@ public class IrisCreator { reportCreationProgress(creationReporter, 0.26D, "prepare_world_pack"); StudioSVC studioService = IrisServices.get(StudioSVC.class); - IrisDimension installedDimension = benchmark && !studio - ? studioService.installIntoTransientWorld(sender, resolvedDimension, dimensionRoot) - : studioService.installIntoWorld(sender, resolvedDimension, dimensionRoot, seed); + long packPreparationStart = System.nanoTime(); + GenerationHistory publishedHistory = null; + IrisDimension installedDimension; + if (benchmark && !studio) { + installedDimension = studioService.installIntoTransientWorld(sender, resolvedDimension, dimensionRoot); + } else { + StudioSVC.GenerationPublication publication = studioService.installIntoWorld( + sender, resolvedDimension, dimensionRoot, seed); + installedDimension = publication == null ? null : publication.dimension(); + if (publication != null) { + publishedHistory = publication.history(); + } + } + reportStudioTiming("prepare_world_pack", packPreparationStart); if (installedDimension == null) { throw new IrisException("Failed to install dimension pack for " + dimension()); } @@ -276,14 +288,17 @@ public class IrisCreator { AtomicDouble pp = new AtomicDouble(0); AtomicBoolean done = new AtomicBoolean(false); long generatorPrepareStart = System.nanoTime(); - WorldCreator wc = new IrisWorldCreator() + IrisWorldCreator worldCreator = new IrisWorldCreator() .dimension(installedDimension) .studioPackSource(resolvedDimension.getLoader().getDataFolder()) .name(name) .seed(seed) .studio(studio) - .persistent(!studio && !benchmark) - .create(); + .persistent(!studio && !benchmark); + if (publishedHistory != null) { + worldCreator.generationHistory(publishedHistory); + } + WorldCreator wc = worldCreator.create(); reportStudioTiming("prepare_studio_generator", generatorPrepareStart); reportStudioProgress(0.40D, "install_datapacks"); @@ -292,6 +307,9 @@ public class IrisCreator { throw new IrisException("Access is null. Something bad happened."); } stagedGenerator = access; + if (!studio && !benchmark) { + access.beginInitialEntry(sender != null && sender.isPlayer()); + } AtomicInteger createProgressTask = startCreateProgressReporter(access, done, creationReporter); reportStudioProgress(0.46D, "create_world"); @@ -366,6 +384,7 @@ public class IrisCreator { } reportCreationProgress(creationReporter, 0.92D, "teleport_player"); awaitSenderTeleport(world); + access.completeInitialEntry(); if (pregen != null) { CompletableFuture ff = new CompletableFuture<>(); @@ -401,6 +420,10 @@ public class IrisCreator { throw irisException; } throw new IrisException("Failed to create world \"" + name + "\".", failure); + } finally { + if (stagedGenerator != null) { + stagedGenerator.completeInitialEntry(); + } } } diff --git a/core/src/main/java/art/arcane/iris/world/IrisWorldCreator.java b/core/src/main/java/art/arcane/iris/world/IrisWorldCreator.java index 0b8b22e71..4265e4177 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisWorldCreator.java +++ b/core/src/main/java/art/arcane/iris/world/IrisWorldCreator.java @@ -30,6 +30,7 @@ import org.bukkit.generator.ChunkGenerator; import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.Objects; public class IrisWorldCreator { @@ -40,6 +41,7 @@ public class IrisWorldCreator { private long seed = 1337; private boolean persistent; private File studioPackSource; + private GenerationHistory generationHistory; public IrisWorldCreator() { @@ -87,6 +89,11 @@ public class IrisWorldCreator { return this; } + public IrisWorldCreator generationHistory(GenerationHistory history) { + this.generationHistory = Objects.requireNonNull(history, "Generation history"); + return this; + } + public WorldCreator create() { IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension; NamespacedKey worldKey = IrisWorldStorage.keyFromName(name); @@ -106,42 +113,46 @@ public class IrisWorldCreator { .seed(seed) .worldFolder(worldFolder) .build(); - GenerationHistory generationHistory = persistent || studio - ? requireGenerationHistory(w.worldFolder(), seed) - : null; + GenerationHistory history = resolveGenerationHistory(w.worldFolder()); File packRoot = studio ? Objects.requireNonNull(studioPackSource, "Studio authoring source") - : generationHistory != null - ? requireActivePack(generationHistory) + : history != null + ? history.paths().packRoot(history.activeEpoch().epochId()).toFile() : new File(w.worldFolder(), "iris/pack"); ChunkGenerator g = new BukkitChunkGenerator( w, studio, packRoot, dimensionName, - generationHistory); + history); return creator.environment(environment) .generateStructures(true) .generator(g).seed(seed); } - private static GenerationHistory requireGenerationHistory(File dimensionRoot, long seed) { + GenerationHistory resolveGenerationHistory(File dimensionRoot) { + if (!persistent && !studio) { + if (generationHistory != null) { + throw new IllegalStateException("Generation history requires a persistent or Studio world."); + } + return null; + } + Path root = Objects.requireNonNull(dimensionRoot, "Dimension root").toPath().toAbsolutePath().normalize(); try { - return GenerationHistory.open(dimensionRoot.toPath(), seed); + if (generationHistory == null) { + return GenerationHistory.open(root, seed); + } + if (!generationHistory.paths().dimensionRoot().equals(root)) { + throw new IllegalArgumentException("Generation history belongs to a different dimension root."); + } + generationHistory.requireWorldSeed(seed); + return generationHistory; } catch (IOException failure) { throw new IllegalStateException("Iris generation history is unusable at " + dimensionRoot + ".", failure); } } - private static File requireActivePack(GenerationHistory history) { - try { - return history.activePackRoot().toFile(); - } catch (IOException failure) { - throw new IllegalStateException("Iris active generation pack is unusable.", failure); - } - } - private World.Environment findEnvironment() { IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension; return BukkitEnvironment.from(dim == null ? null : dim.getEnvironment()); diff --git a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java index 82c81a53a..98c4bec63 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java @@ -488,7 +488,8 @@ public final class IrisWorldStorage { .toAbsolutePath() .normalize(); try { - return GenerationHistory.open(root).activePackRoot().toFile(); + GenerationHistory history = GenerationHistory.open(root); + return history.paths().packRoot(history.activeEpoch().epochId()).toFile(); } catch (IOException failure) { throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure); } @@ -500,7 +501,8 @@ public final class IrisWorldStorage { .toAbsolutePath() .normalize(); try { - return GenerationHistory.open(root, expectedWorldSeed).activePackRoot().toFile(); + GenerationHistory history = GenerationHistory.open(root, expectedWorldSeed); + return history.paths().packRoot(history.activeEpoch().epochId()).toFile(); } catch (IOException failure) { throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure); } diff --git a/core/src/main/java/art/arcane/iris/world/IrisWorlds.java b/core/src/main/java/art/arcane/iris/world/IrisWorlds.java index ecc139e7b..20f101298 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisWorlds.java +++ b/core/src/main/java/art/arcane/iris/world/IrisWorlds.java @@ -180,19 +180,17 @@ public class IrisWorlds { } /** - * Drops registry entries whose pack snapshot no longer backs them. + * Drops registry entries whose canonical world directory no longer exists. *

* A world whose storage is present but unusable is kept and reported instead of dropped: the entry is * what lets {@code /iris remove} find the world, and dropping it would not stop the server from loading - * the folder anyway. It is excluded from {@link #getDimensions()} so one broken world cannot fail the - * whole registry - that failure used to propagate out of the constructor and null out {@link #get()}. + * the folder anyway. Pack history and dimension definitions are validated by {@link #getDimensions()} + * when callers load them, rather than while reading or saving the identity registry. */ public synchronized void clean() { boolean removed = worlds.entrySet().removeIf(entry -> { try { - Optional packRoot = packRoot(entry.getKey()); - return packRoot.isEmpty() - || !new File(packRoot.get(), "dimensions/" + entry.getValue() + ".json").exists(); + return dimensionRoot(entry.getKey()).isEmpty(); } catch (IllegalArgumentException e) { return true; } catch (IllegalStateException e) { @@ -353,7 +351,7 @@ public class IrisWorlds { return null; } - private Optional packRoot(String worldIdentity) { + private Optional dimensionRoot(String worldIdentity) { NamespacedKey worldKey = WorldIdentity.parse(worldIdentity); Path worldContainer = levelRoot.getParent(); if (worldContainer == null) { @@ -363,13 +361,12 @@ public class IrisWorlds { worldKey, levelRoot.getFileName().toString() ); - Optional dimensionRoot = IrisWorldStorage.frozenDimensionRoot( + return IrisWorldStorage.frozenDimensionRoot( worldContainer.toFile(), levelRoot.toFile(), configuredWorldName, worldKey ); - return dimensionRoot.map(IrisWorldStorage::requireActiveGenerationPackRoot); } private static void warnUnusableStorage(String worldIdentity, IllegalStateException failure) { @@ -385,7 +382,9 @@ public class IrisWorlds { private IrisDimension loadDimension(String worldIdentity, String id) { File pack; try { - pack = packRoot(worldIdentity).orElse(null); + pack = dimensionRoot(worldIdentity) + .map(IrisWorldStorage::requireActiveGenerationPackRoot) + .orElse(null); } catch (IllegalStateException unusableStorage) { warnUnusableStorage(worldIdentity, unusableStorage); return null; diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java index 3b7f851bf..1c2865088 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationHistory.java @@ -18,7 +18,6 @@ import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Collection; -import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -26,7 +25,6 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; -import java.util.stream.Stream; public final class GenerationHistory { private static final int MAXIMUM_CACHED_BOUNDARIES = 4; @@ -54,7 +52,8 @@ public final class GenerationHistory { GenerationPackRepository packs, GenerationHistoryStore store, ChunkGenerationOwnership ownership, - GenerationKernelRegistry kernels + GenerationKernelRegistry kernels, + Optional publication ) throws IOException { this.paths = Objects.requireNonNull(paths, "paths"); this.packs = Objects.requireNonNull(packs, "packs"); @@ -68,7 +67,7 @@ public final class GenerationHistory { this.admission = new GenerationAdmission(paths.dimensionRoot()); this.boundaryCache = boundedCache(MAXIMUM_CACHED_BOUNDARIES); this.terrainSignatureCache = boundedCache(MAXIMUM_CACHED_TERRAIN_SIGNATURES); - validateReferencedState(); + validateReferencedState(publication); } public GenerationAdmission.RuntimeLease retainRuntime() { @@ -132,7 +131,7 @@ public final class GenerationHistory { ); requireSourceFingerprint(packSource, epoch.packFingerprint(), epoch.packFingerprintVersion()); GenerationPackRepository packs = new GenerationPackRepository(paths.dimensionRoot()); - packs.publish( + Path publishedPack = packs.publish( epoch.epochId(), epoch.packFingerprint(), epoch.packFingerprintVersion(), @@ -141,7 +140,8 @@ public final class GenerationHistory { forceEpochPublication(paths, epoch.epochId()); GenerationSemanticIndex.initialize(paths.dimensionRoot()); GenerationHistoryStore store = GenerationHistoryStore.initialize(paths.generationRoot(), epoch); - return new GenerationHistory(paths, packs, store, ownership, kernels); + return new GenerationHistory(paths, packs, store, ownership, kernels, + Optional.of(new FreshPublication(publishedPack, epoch))); } public static GenerationHistory open(Path dimensionRoot) throws IOException { @@ -163,7 +163,7 @@ public final class GenerationHistory { GenerationPackRepository packs = new GenerationPackRepository(paths.dimensionRoot()); GenerationHistoryStore store = GenerationHistoryStore.open(paths.generationRoot()); ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(paths.ownershipRoot()); - return new GenerationHistory(paths, packs, store, ownership, kernels); + return new GenerationHistory(paths, packs, store, ownership, kernels, Optional.empty()); } public static Optional openIfPresent(Path dimensionRoot) throws IOException { @@ -436,7 +436,7 @@ public final class GenerationHistory { BoundarySignatureCapture signatureCapture ) throws IOException { WorldChunkInventory inventory = recoverUnstoredClaims(); - validateReferencedState(); + validateReferencedState(Optional.empty()); Optional pending = store.pendingActivation(); if (pending.isEmpty()) { return store.activeActivation(); @@ -470,15 +470,15 @@ public final class GenerationHistory { return paths; } - public synchronized GenerationManifest manifest() { + public GenerationManifest manifest() { return store.manifest(); } - public synchronized GenerationActivation activeActivation() { + public GenerationActivation activeActivation() { return store.activeActivation(); } - public synchronized GenerationEpoch activeEpoch() { + public GenerationEpoch activeEpoch() { return store.activeEpoch(); } @@ -513,7 +513,7 @@ public final class GenerationHistory { synchronized (this) { if (store.pendingActivation().isEmpty() && usesCurrentGenerator()) { recoverUnstoredClaims(); - validateReferencedState(); + validateReferencedState(Optional.empty()); } else { if (store.pendingActivation().isEmpty()) { stageCurrentKernel(transitionWidthBlocks); @@ -808,10 +808,14 @@ public final class GenerationHistory { return ownership.explicitChunkCount(); } - private void validateReferencedState() throws IOException { + private void validateReferencedState(Optional publication) throws IOException { validateRuntimeVersions(); GenerationEpoch active = store.activeEpoch(); - packs.requireExactPack(active.epochId(), active.packFingerprint(), active.packFingerprintVersion()); + if (publication.isEmpty() + || !publication.get().epoch().equals(active) + || !publication.get().pack().equals(paths.packRoot(active.epochId()))) { + packs.requireExactPack(active.epochId(), active.packFingerprint(), active.packFingerprintVersion()); + } if (store.pendingEpoch().isPresent()) { GenerationEpoch pending = store.pendingEpoch().orElseThrow(); packs.requireExactPack(pending.epochId(), pending.packFingerprint(), pending.packFingerprintVersion()); @@ -1138,15 +1142,8 @@ public final class GenerationHistory { throw new IOException("Generation epoch is not a safe directory: " + epochRoot); } if (File.separatorChar != '\\') { - List directories; - try (Stream entries = Files.walk(epochRoot)) { - directories = entries - .filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) - .sorted(Comparator.comparingInt(Path::getNameCount).reversed()) - .toList(); - } - for (Path directory : directories) { - forceDirectory(directory); + if (Durability.enabled()) { + GenerationPackRepository.forceDirectoryTree(epochRoot, GenerationHistory::forceDirectory); } forceDirectory(paths.epochsRoot()); forceDirectory(paths.generationRoot()); @@ -1178,6 +1175,9 @@ public final class GenerationHistory { }; } + private record FreshPublication(Path pack, GenerationEpoch epoch) { + } + private static final class PendingSemanticClaim { private final GenerationStage stage; private final GenerationSemanticIndex.Claim claim; diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationPackFingerprint.java b/core/src/main/java/art/arcane/iris/world/history/GenerationPackFingerprint.java index 459230566..66c14eb87 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationPackFingerprint.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationPackFingerprint.java @@ -18,24 +18,35 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Comparator; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.HexFormat; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; public final class GenerationPackFingerprint { public static final int CURRENT_VERSION = 2; private static final int BUFFER_BYTES = 64 * 1_024; + private static final int PREFETCH_FILE_BYTES = 256 * 1_024; + private static final int PREFETCH_BATCH_FILES = 64; + private static final int MAXIMUM_PREFETCH_WORKERS = 4; private GenerationPackFingerprint() { } public static String compute(Path packRoot, int version) throws IOException { + return computeWithInput(packRoot, version, GenerationPackFingerprint::openInput); + } + + static String computeWithInput(Path packRoot, int version, InputSource inputSource) throws IOException { requireSupported(version); - return switch (version) { - case 1 -> computeVersionOne(packRoot); - case 2 -> computeTree(packRoot, true); - default -> throw new IOException("Unsupported Iris generation pack fingerprint version " + version + "."); - }; + return computeTree(packRoot, version == 2, Objects.requireNonNull(inputSource, "Fingerprint input source")); } public static void requireSupported(int version) throws IOException { @@ -44,11 +55,7 @@ public final class GenerationPackFingerprint { } } - private static String computeVersionOne(Path packRoot) throws IOException { - return computeTree(packRoot, false); - } - - private static String computeTree(Path packRoot, boolean ignoreFinderMetadata) throws IOException { + private static String computeTree(Path packRoot, boolean ignoreFinderMetadata, InputSource inputSource) throws IOException { Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize(); if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) { throw new IOException("Generation pack fingerprint root is missing or unsafe: " + root); @@ -59,30 +66,160 @@ public final class GenerationPackFingerprint { entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); MessageDigest digest = sha256(); byte[] buffer = new byte[BUFFER_BYTES]; - for (FingerprintEntry entry : entries) { - updateEntry(digest, entry.relativePath(), entry.size()); - long readBytes = 0L; - try (InputStream input = Files.newInputStream( - entry.source(), - StandardOpenOption.READ, - LinkOption.NOFOLLOW_LINKS - )) { - int read; - while ((read = input.read(buffer)) >= 0) { - if (read > 0) { - digest.update(buffer, 0, read); - readBytes += read; + int workers = Math.min(MAXIMUM_PREFETCH_WORKERS, Runtime.getRuntime().availableProcessors()); + if (workers <= 1 || !hasPrefetchBatch(entries)) { + for (FingerprintEntry entry : entries) { + updateEntry(digest, entry.relativePath(), entry.size()); + streamEntry(digest, entry, buffer, inputSource); + } + } else { + try (ExecutorService readers = Executors.newFixedThreadPool(workers, + Thread.ofPlatform().name("Iris Pack Fingerprint-", 0L).factory())) { + for (int start = 0; start < entries.size(); start += PREFETCH_BATCH_FILES) { + List batch = entries.subList(start, + Math.min(entries.size(), start + PREFETCH_BATCH_FILES)); + PrefetchedBatch prefetched = prefetchBatch(batch, inputSource, readers, workers, root); + for (int index = 0; index < batch.size(); index++) { + FingerprintEntry entry = batch.get(index); + try { + updateEntry(digest, entry.relativePath(), entry.size()); + requireSuccessfulRead(prefetched.failures()[index]); + byte[] bytes = prefetched.contents()[index]; + if (bytes == null) { + streamEntry(digest, entry, buffer, inputSource); + } else { + digest.update(bytes); + } + } catch (IOException | RuntimeException | Error failure) { + suppressReadFailures(failure, prefetched.failures()); + throw failure; + } } } } - if (readBytes != entry.size()) { - throw new IOException("Iris generation pack changed while fingerprinting: " - + entry.source()); - } } return HexFormat.of().formatHex(digest.digest()); } + private static boolean hasPrefetchBatch(List entries) { + int smallFiles = 0; + for (FingerprintEntry entry : entries) { + if (entry.size() <= PREFETCH_FILE_BYTES && ++smallFiles >= PREFETCH_BATCH_FILES) { + return true; + } + } + return false; + } + + private static PrefetchedBatch prefetchBatch(List entries, InputSource inputSource, + ExecutorService readers, int workers, Path root) throws IOException { + PrefetchedBatch batch = new PrefetchedBatch(new byte[entries.size()][], new Throwable[entries.size()]); + List> tasks = new ArrayList<>(workers); + for (int worker = 0; worker < workers; worker++) { + int first = worker; + tasks.add(() -> { + readBatch(entries, batch, inputSource, first, workers); + return null; + }); + } + try { + for (Future task : readers.invokeAll(tasks)) { + task.get(); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while fingerprinting " + root, failure); + } catch (ExecutionException failure) { + throw new IOException("Unable to prefetch generation pack " + root, failure.getCause()); + } + return batch; + } + + private static void readBatch(List entries, PrefetchedBatch batch, + InputSource inputSource, int first, int workers) { + for (int index = first; index < entries.size(); index += workers) { + FingerprintEntry entry = entries.get(index); + if (entry.size() > PREFETCH_FILE_BYTES) { + continue; + } + try { + batch.contents()[index] = readSmall(entry, inputSource); + } catch (Throwable failure) { + batch.failures()[index] = failure; + } + } + } + + private static byte[] readSmall(FingerprintEntry entry, InputSource inputSource) throws IOException { + byte[] bytes = new byte[(int) entry.size()]; + int offset = 0; + try (InputStream input = inputSource.open(entry.source())) { + while (offset < bytes.length) { + int read = input.read(bytes, offset, bytes.length - offset); + if (read < 0) { + throw changed(entry); + } + offset += read; + } + if (input.read() != -1) { + throw changed(entry); + } + } + return bytes; + } + + private static void streamEntry(MessageDigest digest, FingerprintEntry entry, byte[] buffer, + InputSource inputSource) throws IOException { + long readBytes = 0L; + try (InputStream input = inputSource.open(entry.source())) { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + readBytes += read; + } + } + } + if (readBytes != entry.size()) { + throw changed(entry); + } + } + + static InputStream openInput(Path source) throws IOException { + return Files.newInputStream(source, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS); + } + + private static void requireSuccessfulRead(Throwable failure) throws IOException { + if (failure == null) { + return; + } + if (failure instanceof IOException ioFailure) { + throw ioFailure; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + throw new IOException("Unable to read generation pack entry", failure); + } + + private static void suppressReadFailures(Throwable primary, Throwable[] failures) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + seen.add(primary); + Collections.addAll(seen, primary.getSuppressed()); + for (Throwable failure : failures) { + if (failure != null && seen.add(failure)) { + primary.addSuppressed(failure); + } + } + } + + private static IOException changed(FingerprintEntry entry) { + return new IOException("Iris generation pack changed while fingerprinting: " + entry.source()); + } + private static void collectTree( Path root, List entries, @@ -150,6 +287,14 @@ public final class GenerationPackFingerprint { } } + @FunctionalInterface + interface InputSource { + InputStream open(Path source) throws IOException; + } + + private record PrefetchedBatch(byte[][] contents, Throwable[] failures) { + } + private record FingerprintEntry(Path source, String relativePath, long size) { } } diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationPackRepository.java b/core/src/main/java/art/arcane/iris/world/history/GenerationPackRepository.java index a15a54aa9..e2477fc71 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationPackRepository.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationPackRepository.java @@ -1,7 +1,9 @@ package art.arcane.iris.world.history; +import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.iris.pack.AtomicDirectoryPublisher; import art.arcane.iris.pack.PackDirectoryResolver; +import art.arcane.iris.spi.IrisPlatforms; import java.io.File; import java.io.IOException; @@ -16,12 +18,23 @@ import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.RecursiveAction; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; +import java.util.stream.Stream; public final class GenerationPackRepository { private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}"); + private static final int MAXIMUM_COPY_WORKERS = 4; private final Path dimensionRoot; private final Path generationRoot; @@ -90,7 +103,9 @@ public final class GenerationPackRepository { return requireExactPack(epochId, packFingerprint, packFingerprintVersion); } forceDirectory(epochDirectory); - return requireExactPack(epochId, packFingerprint, packFingerprintVersion); + validateExistingAncestors(epochId); + requireSafeDirectory(target, "Generation epoch pack is missing or unsafe"); + return target; } finally { if (Files.exists(stage, LinkOption.NOFOLLOW_LINKS)) { AtomicDirectoryPublisher.deleteTree(stage); @@ -182,6 +197,16 @@ public final class GenerationPackRepository { } public static void copyPackTree(Path source, Path target) throws IOException { + Executor executor = IrisPlatforms.isBound() ? MultiBurst.ioBurst : ForkJoinPool.commonPool(); + int workers = Math.max(1, Math.min(MAXIMUM_COPY_WORKERS, Runtime.getRuntime().availableProcessors() / 2)); + copyPackTree(source, target, executor, workers); + } + + static void copyPackTree(Path source, Path target, Executor executor, int workers) throws IOException { + Objects.requireNonNull(executor, "executor"); + if (workers < 1 || workers > MAXIMUM_COPY_WORKERS) { + throw new IllegalArgumentException("Pack copy workers must be between 1 and " + MAXIMUM_COPY_WORKERS + "."); + } Path sourcePath = Objects.requireNonNull(source, "source").toAbsolutePath().normalize(); if (Files.isSymbolicLink(sourcePath)) { throw new IOException("Pack source is a symbolic link: " + sourcePath); @@ -199,7 +224,7 @@ public final class GenerationPackRepository { + normalizedSource + " and " + normalizedTarget); } - List copiedFiles = new ArrayList<>(); + List sourceFiles = new ArrayList<>(); Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException { @@ -229,9 +254,7 @@ public final class GenerationPackRepository { || fileName.endsWith(".code-workspace")) { return FileVisitResult.CONTINUE; } - Path destination = copyDestination(normalizedSource, normalizedTarget, file); - Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES); - copiedFiles.add(destination); + sourceFiles.add(file); return FileVisitResult.CONTINUE; } @@ -240,10 +263,103 @@ public final class GenerationPackRepository { throw new IOException("Unable to copy pack entry: " + file, failure); } }); - for (Path copiedFile : copiedFiles) { - try (FileChannel channel = FileChannel.open(copiedFile, StandardOpenOption.WRITE)) { - channel.force(true); + executeFilePhase(sourceFiles, executor, workers, sourceFile -> + Files.copy(sourceFile, copyDestination(normalizedSource, normalizedTarget, sourceFile), + StandardCopyOption.COPY_ATTRIBUTES)); + executeFilePhase(sourceFiles, executor, workers, sourceFile -> + forceFile(copyDestination(normalizedSource, normalizedTarget, sourceFile))); + } + + static void forceDirectoryTree(Path root, FileAction force) throws IOException { + Executor executor = IrisPlatforms.isBound() ? MultiBurst.ioBurst : ForkJoinPool.commonPool(); + int workers = Math.max(1, Math.min(MAXIMUM_COPY_WORKERS, Runtime.getRuntime().availableProcessors() / 2)); + forceDirectoryTree(root, executor, workers, force); + } + + static void forceDirectoryTree(Path root, Executor executor, int workers, FileAction force) throws IOException { + Objects.requireNonNull(executor, "executor"); + Objects.requireNonNull(force, "force"); + if (workers < 1 || workers > MAXIMUM_COPY_WORKERS) { + throw new IllegalArgumentException("Directory sync workers must be between 1 and " + MAXIMUM_COPY_WORKERS + "."); + } + List directories; + try (Stream entries = Files.walk(root)) { + directories = entries + .filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) + .sorted(Comparator.comparingInt(Path::getNameCount).reversed()) + .toList(); + } + int start = 0; + while (start < directories.size()) { + int end = start + 1; + int depth = directories.get(start).getNameCount(); + while (end < directories.size() && directories.get(end).getNameCount() == depth) { + end++; } + executeFilePhase(directories.subList(start, end), executor, workers, force); + start = end; + } + } + + private static void executeFilePhase( + List files, Executor executor, int workers, FileAction action + ) throws IOException { + FileWork work = new FileWork(files, action, new AtomicInteger(), new AtomicBoolean()); + int workerCount = Math.min(workers, files.size()); + List> copies = new ArrayList<>(workerCount); + for (int worker = 0; worker < workerCount; worker++) { + FileTask task = new FileTask(work); + copies.add(task.finished); + try { + executor.execute(task); + } catch (RuntimeException | Error failure) { + task.reject(failure); + break; + } + } + awaitFileTasks(copies); + } + + private static void processFiles(FileWork work) throws IOException { + int index; + while (!work.failed().get() && (index = work.next().getAndIncrement()) < work.files().size()) { + work.action().run(work.files().get(index)); + } + } + + private static void forceFile(Path file) throws IOException { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { + channel.force(true); + } + } + + private static void awaitFileTasks(List> copies) throws IOException { + CompletableFuture.allOf(copies.toArray(CompletableFuture[]::new)) + .handle((ignored, failure) -> null).join(); + Throwable failure = null; + for (CompletableFuture copy : copies) { + try { + copy.join(); + } catch (CompletionException | CancellationException exception) { + Throwable cause = exception instanceof CompletionException ? exception.getCause() : exception; + if (failure == null) { + failure = cause; + } else if (failure != cause) { + failure.addSuppressed(cause); + } + } + } + if (failure instanceof IOException exception) { + throw exception; + } + if (failure instanceof RuntimeException exception) { + throw exception; + } + if (failure instanceof Error error) { + throw error; + } + if (failure != null) { + throw new IOException("Generation pack file operation failed.", failure); } } @@ -274,4 +390,79 @@ public final class GenerationPackRepository { } return requiredValue; } + + @FunctionalInterface + interface FileAction { + void run(Path file) throws IOException; + } + + private record FileWork(List files, FileAction action, AtomicInteger next, AtomicBoolean failed) { + } + + private static final class FileTask extends RecursiveAction implements Runnable { + private final FileWork work; + private final CompletableFuture finished = new CompletableFuture<>(); + private boolean started; + private Throwable failure; + + private FileTask(FileWork work) { + this.work = work; + } + + @Override + public void run() { + quietlyInvoke(); + } + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (finished.isDone() || !super.cancel(mayInterruptIfRunning)) { + return false; + } + reject(new CancellationException("Generation pack file worker was cancelled.")); + return true; + } + + @Override + protected void compute() { + synchronized (this) { + if (started || failure != null) { + return; + } + started = true; + } + Throwable fileFailure = null; + try { + processFiles(work); + } catch (Throwable caught) { + fileFailure = caught; + } finally { + finish(fileFailure); + } + } + + private synchronized void reject(Throwable failure) { + work.failed().set(true); + if (this.failure == null) { + this.failure = failure; + } else if (this.failure != failure) { + this.failure.addSuppressed(failure); + } + if (!started) { + finished.completeExceptionally(this.failure); + } + } + + private synchronized void finish(Throwable fileFailure) { + if (fileFailure != null) { + reject(fileFailure); + } + if (failure == null) { + finished.complete(null); + } else { + super.completeExceptionally(failure); + finished.completeExceptionally(failure); + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java index 12caf8caf..bf45eb238 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeStore.java @@ -554,8 +554,13 @@ public final class SavedBiomeStore { } private WriteStripe regionLock(int chunkX, int chunkZ) { - long key = key(chunkX >> 5, chunkZ >> 5); - return regionLocks[(int) (key ^ (key >>> 32)) & (regionLocks.length - 1)]; + long mixed = key(chunkX >> 5, chunkZ >> 5); + mixed ^= mixed >>> 33; + mixed *= 0xff51afd7ed558ccdL; + mixed ^= mixed >>> 33; + mixed *= 0xc4ceb9fe1a85ec53L; + mixed ^= mixed >>> 33; + return regionLocks[(int) (mixed ^ (mixed >>> 32)) & (regionLocks.length - 1)]; } private static long key(int x, int z) { diff --git a/core/src/main/java/art/arcane/iris/world/pregen/PregenPerformanceProfile.java b/core/src/main/java/art/arcane/iris/world/pregen/PregenPerformanceProfile.java index baf6d2ee6..c5d5b10b2 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/PregenPerformanceProfile.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/PregenPerformanceProfile.java @@ -24,8 +24,6 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.platform.generation.PlatformChunkGenerator; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.generation.concurrent.MultiBurst; -import art.arcane.volmlib.util.stream.ProceduralStream; -import art.arcane.iris.generation.stream.CachedDoubleStream2D; import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; @@ -76,9 +74,11 @@ public final class PregenPerformanceProfile { } public static void apply(Engine engine) { - apply(); if (engine != null) { prepareNoiseCacheInPlace(engine); + } + apply(); + if (engine != null) { IrisLogging.info("Pregen profile applied: noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize()); } @@ -94,15 +94,7 @@ public final class PregenPerformanceProfile { private static void prepareNoiseCacheInPlace(Engine engine) { IrisComplex complex = Objects.requireNonNull(engine.getComplex(), "Pregeneration requires an active biome complex"); - int configuredChunks = IrisSettings.get().getPerformance().getNoiseCacheSize(); - resizeNoiseCache(complex.getNaturalHeightStream(), configuredChunks); - resizeNoiseCache(complex.getRawHeightStream(), configuredChunks); - } - - private static void resizeNoiseCache(ProceduralStream stream, int configuredChunks) { - if (!(stream instanceof CachedDoubleStream2D cachedStream)) { - throw new IllegalStateException("Pregeneration requires a mutable double terrain cache"); - } - cachedStream.setMaximumChunks(configuredChunks); + int configuredChunks = Math.max(IrisSettings.get().getPerformance().getNoiseCacheSize(), 4_096); + complex.ensureTerrainNoiseCacheSize(configuredChunks); } } diff --git a/core/src/main/java/art/arcane/iris/world/runtime/PaperLikeRuntimeControlBackend.java b/core/src/main/java/art/arcane/iris/world/runtime/PaperLikeRuntimeControlBackend.java index 5b346d239..e8703e4ba 100644 --- a/core/src/main/java/art/arcane/iris/world/runtime/PaperLikeRuntimeControlBackend.java +++ b/core/src/main/java/art/arcane/iris/world/runtime/PaperLikeRuntimeControlBackend.java @@ -42,7 +42,7 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend } TimeAccessStrategy strategy = resolveTimeAccessStrategy(world); - if (strategy == null) { + if (strategy == null || strategy.handleMethod() == null || strategy.readMethod() == null) { return OptionalLong.empty(); } diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerSlopeSourceTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerSlopeSourceTest.java index fe4ad4dcd..414d8d2ac 100644 --- a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerSlopeSourceTest.java +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLayerSlopeSourceTest.java @@ -44,6 +44,12 @@ public final class IrisBiomeLayerSlopeSourceTest { this.lockLayers = lockLayers; } + // This test is about which slope source picks the palette, so it pins the fallback to ROCK to + // keep a rejected layer list empty instead of capping it with the top layer. + private static IrisDimension rockFallbackDimension() { + return new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK); + } + @Test public void lowerLedgeSlopeControlsSurfacePaletteSelection() { IrisData data = mock(IrisData.class); @@ -65,9 +71,9 @@ public final class IrisBiomeLayerSlopeSourceTest { when(grass.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(block); KList ledge = biome.generateLayers( - new IrisDimension(), 12D, -8D, rng, 1, 32, data, complex); + rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, complex); KList cap = biome.generateLayers( - new IrisDimension(), 12D, -8D, rng, 1, 96, data, complex); + rockFallbackDimension(), 12D, -8D, rng, 1, 96, data, complex); assertEquals(1, ledge.size()); assertSame(block, ledge.get(0)); @@ -105,9 +111,9 @@ public final class IrisBiomeLayerSlopeSourceTest { when(paletteLayer.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(surfaceBlock); KList hostLayers = biome.generateLayers( - new IrisDimension(), 12D, -8D, rng, 1, 32, data, hostComplex); + rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, hostComplex); KList sourceLayers = biome.generateLayersWithSlope( - new IrisDimension(), 12D, -8D, rng, 1, 32, data, sourceSlope); + rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, sourceSlope); assertTrue(hostLayers.isEmpty()); assertEquals(1, sourceLayers.size()); diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisSurfaceLayerFallbackTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisSurfaceLayerFallbackTest.java new file mode 100644 index 000000000..987470015 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisSurfaceLayerFallbackTest.java @@ -0,0 +1,137 @@ +package art.arcane.iris.generation.biome; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisSlopeClip; + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.noise.CNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public final class IrisSurfaceLayerFallbackTest { + private final IrisData data = mock(IrisData.class); + private final IrisBiomePaletteLayer layer = mock(IrisBiomePaletteLayer.class); + private final CNG heightGenerator = mock(CNG.class); + private final PlatformBlockState surfaceBlock = mock(PlatformBlockState.class); + private final PlatformBlockState secondBlock = mock(PlatformBlockState.class); + private final RNG rng = new RNG(19L); + + @SuppressWarnings("unchecked") + private final ProceduralStream slopeStream = mock(ProceduralStream.class); + + private IrisBiome biome() { + when(layer.getZoom()).thenReturn(1D); + when(layer.getMinHeight()).thenReturn(1); + when(layer.getMaxHeight()).thenReturn(2); + when(layer.getHeightGenerator(any(RNG.class), same(data))).thenReturn(heightGenerator); + when(heightGenerator.fit(1, 2, 12D, -8D)).thenReturn(2); + when(layer.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(surfaceBlock); + when(layer.get(rng, 1, 13D, 1D, -9D, data)).thenReturn(secondBlock); + when(slopeStream.getDouble(12D, -8D)).thenReturn(9D); + return new IrisBiome().setLayers(new KList<>(layer)); + } + + private KList generate(IrisBiome biome, IrisDimension dim) { + return biome.generateLayersWithSlope(dim, 12D, -8D, rng, 4, 32, data, slopeStream); + } + + @Test + public void allGatedLayersEmitTopLayerBlockUnderTopLayerFallback() { + IrisBiome biome = biome(); + when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D)); + + KList blocks = generate(biome, + new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER)); + + assertEquals(1, blocks.size()); + assertSame(surfaceBlock, blocks.get(0)); + } + + @Test + public void allGatedLayersStayEmptyUnderRockFallback() { + IrisBiome biome = biome(); + when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D)); + + KList blocks = generate(biome, + new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK)); + + assertTrue(blocks.isEmpty()); + } + + @Test + public void ungatedLayersAreIdenticalUnderBothFallbacks() { + IrisBiome biome = biome(); + when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip()); + + KList topLayer = generate(biome, + new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER)); + KList rock = generate(biome, + new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK)); + + assertEquals(2, topLayer.size()); + assertSame(surfaceBlock, topLayer.get(0)); + assertSame(secondBlock, topLayer.get(1)); + assertEquals(topLayer, rock); + } + + @Test + public void biomeFallbackOverridesDimensionFallbackInBothDirections() { + IrisBiome pinnedToTopLayer = biome().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER); + when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D)); + + KList overRock = generate(pinnedToTopLayer, + new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK)); + + assertEquals(1, overRock.size()); + assertSame(surfaceBlock, overRock.get(0)); + + IrisBiome pinnedToRock = biome().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK); + + KList overTopLayer = generate(pinnedToRock, + new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER)); + + assertTrue(overTopLayer.isEmpty()); + } + + @Test + public void lockedLayersHonorInheritedAndOverriddenFallbacks() { + IrisBiome biome = biome().setLockLayers(true); + when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D)); + IrisDimension dimension = new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER); + + KList blocks = generate(biome, dimension); + + assertEquals(1, blocks.size()); + assertSame(surfaceBlock, blocks.get(0)); + assertTrue(generate(biome.setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK), dimension).isEmpty()); + assertEquals(blocks, generate(biome.setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER), + dimension.setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK))); + } + + @Test + public void lockedFallbackRespectsEmptyLayersAndDepthLimits() { + IrisBiome biome = biome().setLockLayers(true).setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER); + when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D)); + IrisDimension dimension = new IrisDimension(); + + assertTrue(biome.generateLayersWithSlope(dimension, 12D, -8D, rng, 0, 32, data, slopeStream).isEmpty()); + assertTrue(generate(biome.setLockLayersMax(0), dimension).isEmpty()); + assertTrue(generate(biome.setLockLayersMax(7).setLayers(new KList<>()), dimension).isEmpty()); + } + + @Test + public void dimensionDefaultsToRockFallback() { + assertEquals(IrisSurfaceLayerFallback.ROCK, new IrisDimension().getSurfaceLayerFallback()); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/concurrent/HydrologyBurstTest.java b/core/src/test/java/art/arcane/iris/generation/concurrent/HydrologyBurstTest.java new file mode 100644 index 000000000..7aa6ababd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/concurrent/HydrologyBurstTest.java @@ -0,0 +1,112 @@ +package art.arcane.iris.generation.concurrent; + +import art.arcane.iris.configuration.IrisSettings; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinTask; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class HydrologyBurstTest { + @Test(timeout = 15000) + public void blockedOwnersLeaveTheFullRunnableTargetAvailableForLeafTasks() throws Exception { + int parallelism = 4; + MultiBurst.HydrologyBurst burst = new MultiBurst.HydrologyBurst(); + ForkJoinPool pool = burst.createPool(parallelism, ForkJoinPool.defaultForkJoinWorkerThreadFactory, null); + CountDownLatch ownersReady = new CountDownLatch(parallelism); + CountDownLatch startOwners = new CountDownLatch(1); + CountDownLatch ownersBlocked = new CountDownLatch(parallelism); + CountDownLatch releaseOwners = new CountDownLatch(1); + CountDownLatch leavesStarted = new CountDownLatch(parallelism); + CountDownLatch releaseLeaves = new CountDownLatch(1); + List> owners = new ArrayList<>(); + try { + for (int index = 0; index < parallelism; index++) { + owners.add(pool.submit(() -> { + ownersReady.countDown(); + await(startOwners); + pool.execute(() -> { + leavesStarted.countDown(); + await(releaseLeaves); + }); + ForkJoinPool.managedBlock(new LatchBlocker(ownersBlocked, releaseOwners)); + return null; + })); + } + assertTrue(ownersReady.await(5, TimeUnit.SECONDS)); + startOwners.countDown(); + assertTrue(ownersBlocked.await(5, TimeUnit.SECONDS)); + assertTrue("Managed waits reduced the runnable leaf capacity", leavesStarted.await(5, TimeUnit.SECONDS)); + releaseLeaves.countDown(); + releaseOwners.countDown(); + for (Future owner : owners) { + owner.get(5, TimeUnit.SECONDS); + } + } finally { + startOwners.countDown(); + releaseLeaves.countDown(); + releaseOwners.countDown(); + MultiBurst.close(pool); + } + assertTrue(pool.isTerminated()); + } + + @Test + public void closedHydrologyWorkersTerminateAndReopenCreatesANewPool() throws Exception { + IrisSettings previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + MultiBurst.HydrologyBurst burst = new MultiBurst.HydrologyBurst(); + Callable currentPool = ForkJoinTask::getPool; + try { + ForkJoinPool first = burst.submit(currentPool).get(5, TimeUnit.SECONDS); + burst.close(); + assertTrue(first.isTerminated()); + assertTrue(burst.isTerminated()); + assertSame(Thread.currentThread(), burst.submit(Thread::currentThread).get(5, TimeUnit.SECONDS)); + + burst.reopen(); + ForkJoinPool reopened = burst.submit(currentPool).get(5, TimeUnit.SECONDS); + assertNotSame(first, reopened); + assertTrue(reopened.getParallelism() > 0); + burst.close(); + assertTrue(reopened.isTerminated()); + } finally { + burst.close(); + IrisSettings.settings = previousSettings; + } + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting for pool work"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } + + private record LatchBlocker(CountDownLatch blocked, CountDownLatch release) implements ForkJoinPool.ManagedBlocker { + @Override + public boolean block() throws InterruptedException { + blocked.countDown(); + release.await(); + return true; + } + + @Override + public boolean isReleasable() { + return release.getCount() == 0; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCaveCourseFilterTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCaveCourseFilterTest.java index 9b73bd12b..c12178374 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCaveCourseFilterTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCaveCourseFilterTest.java @@ -73,6 +73,7 @@ public class HydrologyCaveCourseFilterTest { filtered.edges(), filtered.outlets(), filtered.courses(), + Set.of(), filtered.cavePlans(), diagnostics, RiverFootprint.empty() @@ -83,9 +84,9 @@ public class HydrologyCaveCourseFilterTest { assertTrue(tile.features().isEmpty()); assertTrue(tile.renderAt(8, 8).features().isEmpty()); assertTrue(tile.nearestFeature(HydrologyFeatureType.UNDERGROUND_POOL, 8, 8, 16).isEmpty()); - assertEquals(1, tile.diagnosticCandidates().size()); + assertEquals(1, tile.localDiagnosticCandidates().size()); assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, - tile.diagnosticCandidates().getFirst().rejection()); + tile.localDiagnosticCandidates().getFirst().rejection()); } @Test @@ -650,6 +651,7 @@ public class HydrologyCaveCourseFilterTest { filtered.edges(), filtered.outlets(), filtered.courses(), + Set.of(), filtered.cavePlans(), diagnostics, footprint diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java new file mode 100644 index 000000000..2787e3057 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDiagnosticCacheTest.java @@ -0,0 +1,248 @@ +package art.arcane.iris.generation.hydrology; + +import com.github.benmanes.caffeine.cache.Cache; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +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.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class HydrologyDiagnosticCacheTest { + @Test + public void terrainLoadsLeaveDiagnosticsLazyAndQueriesRetainImmutableResults() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + HydrologyDiagnosticCandidate candidate = mock(HydrologyDiagnosticCandidate.class); + ArrayList supplied = new ArrayList<>(List.of(candidate)); + when(planner.plan(key)).thenReturn(tile); + when(planner.diagnosticCandidates(tile)).thenReturn(supplied); + try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4)) { + assertSame(tile, cache.get(key)); + verify(planner, never()).diagnosticCandidates(tile); + AtomicInteger preparations = new AtomicInteger(); + cache.setTerrainPreparation(preparations::incrementAndGet); + List result = cache.diagnosticCandidates(key); + supplied.clear(); + assertEquals(List.of(candidate), result); + assertThrows(UnsupportedOperationException.class, () -> result.add(candidate)); + assertSame(result, cache.diagnosticCandidates(key)); + verify(planner, times(1)).plan(key); + verify(planner, times(1)).diagnosticCandidates(tile); + assertEquals(1, preparations.get()); + } + } + + @Test + public void forbiddenThreadsCanReadOnlyCompleteCachedDiagnostics() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + AtomicBoolean forbidden = new AtomicBoolean(true); + when(planner.plan(key)).thenReturn(tile); + when(planner.diagnosticCandidates(tile)).thenReturn(List.of()); + try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4, null, forbidden::get)) { + assertThrows(IllegalStateException.class, () -> cache.diagnosticCandidates(key)); + verify(planner, never()).plan(key); + forbidden.set(false); + List complete = cache.diagnosticCandidates(key); + forbidden.set(true); + assertSame(complete, cache.diagnosticCandidates(key)); + } + } + + @Test + public void interruptedWaiterLeavesTheSharedDiagnosticLoadRunning() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + List expected = List.of(mock(HydrologyDiagnosticCandidate.class)); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch exited = new CountDownLatch(1); + AtomicReference waiterThread = new AtomicReference<>(); + AtomicBoolean interrupted = new AtomicBoolean(); + when(planner.plan(key)).thenReturn(tile); + when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> { + started.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return expected; + }); + ExecutorService workers = Executors.newSingleThreadExecutor(); + ExecutorService callers = Executors.newFixedThreadPool(2); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers); + try { + Future waiter = callers.submit(() -> { + waiterThread.set(Thread.currentThread()); + try { + assertThrows(CancellationException.class, () -> cache.diagnosticCandidates(key)); + interrupted.set(Thread.currentThread().isInterrupted()); + } finally { + exited.countDown(); + } + }); + assertTrue(started.await(5L, TimeUnit.SECONDS)); + Future> second = callers.submit(() -> cache.diagnosticCandidates(key)); + waiterThread.get().interrupt(); + assertTrue(exited.await(2L, TimeUnit.SECONDS)); + waiter.get(2L, TimeUnit.SECONDS); + assertTrue(interrupted.get()); + assertFalse(second.isDone()); + release.countDown(); + assertEquals(expected, second.get(5L, TimeUnit.SECONDS)); + verify(planner, times(1)).diagnosticCandidates(tile); + } finally { + release.countDown(); + workers.shutdownNow(); + callers.shutdownNow(); + assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS)); + assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS)); + cache.close(); + } + } + + @Test + public void clearDoesNotPublishAnOlderDiagnosticResultIntoTheNewEpoch() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + List old = List.of(mock(HydrologyDiagnosticCandidate.class)); + List fresh = List.of(mock(HydrologyDiagnosticCandidate.class)); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger attempts = new AtomicInteger(); + when(planner.plan(key)).thenReturn(tile); + when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> { + if (attempts.getAndIncrement() == 0) { + started.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return old; + } + return fresh; + }); + ExecutorService caller = Executors.newSingleThreadExecutor(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4); + try { + Future> first = caller.submit(() -> cache.diagnosticCandidates(key)); + assertTrue(started.await(5L, TimeUnit.SECONDS)); + cache.clear(); + assertEquals(fresh, cache.diagnosticCandidates(key)); + release.countDown(); + assertEquals(old, first.get(5L, TimeUnit.SECONDS)); + assertEquals(fresh, cache.diagnosticCandidates(key)); + assertEquals(2, attempts.get()); + } finally { + release.countDown(); + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS)); + cache.close(); + } + } + + @Test + public void closeCancelsQueuedDiagnosticsAndDrainsActiveDiagnostics() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey firstKey = new HydrologyTileKey(0, 0); + HydrologyTileKey secondKey = new HydrologyTileKey(1, 0); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch queued = new CountDownLatch(1); + AtomicInteger submissions = new AtomicInteger(); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> { + started.countDown(); + assertTrue(release.await(5L, TimeUnit.SECONDS)); + return List.of(); + }); + ExecutorService workers = Executors.newSingleThreadExecutor(); + ExecutorService callers = Executors.newFixedThreadPool(3); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, task -> { + workers.execute(task); + if (submissions.incrementAndGet() == 2) { + queued.countDown(); + } + }); + try { + Future> first = callers.submit(() -> cache.diagnosticCandidates(firstKey)); + assertTrue(started.await(5L, TimeUnit.SECONDS)); + Future second = callers.submit(() -> cache.diagnosticCandidates(secondKey)); + assertTrue(queued.await(5L, TimeUnit.SECONDS)); + Future closing = callers.submit(cache::close); + ExecutionException cancelled = assertThrows(ExecutionException.class, () -> second.get(5L, TimeUnit.SECONDS)); + assertTrue(cancelled.getCause() instanceof CancellationException); + assertThrows(TimeoutException.class, () -> closing.get(100L, TimeUnit.MILLISECONDS)); + assertThrows(CancellationException.class, () -> cache.diagnosticCandidates(firstKey)); + release.countDown(); + assertEquals(List.of(), first.get(5L, TimeUnit.SECONDS)); + closing.get(5L, TimeUnit.SECONDS); + verify(planner, never()).plan(secondKey); + } finally { + release.countDown(); + workers.shutdownNow(); + callers.shutdownNow(); + assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS)); + assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test + public void aDiagnosticLoadCannotCloseItsOwnCache() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + when(planner.plan(key)).thenReturn(tile); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4); + when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> { + assertThrows(IllegalStateException.class, cache::close); + return List.of(); + }); + assertEquals(List.of(), cache.diagnosticCandidates(key)); + assertSame(tile, cache.get(key)); + cache.close(); + } + + @Test + public void diagnosticFailureIsRetriedAndCompletedListsRemainBounded() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(planner.diagnosticCandidates(tile)).thenThrow(new IllegalStateException("expected")).thenReturn(List.of()); + try (HydrologyTileCache cache = new HydrologyTileCache(planner, 2)) { + HydrologyTileKey key = new HydrologyTileKey(0, 0); + assertThrows(IllegalStateException.class, () -> cache.diagnosticCandidates(key)); + assertEquals(List.of(), cache.diagnosticCandidates(key)); + for (int index = 1; index < 24; index++) { + assertEquals(List.of(), cache.diagnosticCandidates(new HydrologyTileKey(index, 0))); + } + Field field = HydrologyTileCache.class.getDeclaredField("diagnostics"); + field.setAccessible(true); + Cache completed = (Cache) field.get(cache); + completed.cleanUp(); + assertTrue(completed.estimatedSize() <= 2L); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackDiagnosticsTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackDiagnosticsTest.java new file mode 100644 index 000000000..196fbe0f0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackDiagnosticsTest.java @@ -0,0 +1,188 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class HydrologyFallbackDiagnosticsTest { + private static final HydrologyTileKey KEY = new HydrologyTileKey(0, 0); + private static final HydrologyCaveCourseFilter.Result EMPTY = new HydrologyCaveCourseFilter.Result( + List.of(), List.of(), List.of(), List.of(), List.of()); + private static final long DISCARDED = 200L; + private static final long ADOPTED = 201L; + + /** + * A fallback re-routing re-runs source selection over the whole owned lattice, so every discarded trial invents a + * full set of per-node rejections. Only the trial the draft actually keeps may report any. + */ + @Test(timeout = 15000) + public void discardedFallbackTrialsDoNotReportTheirRejections() throws Exception { + Harness harness = harness(); + HydrologyOwnerDraft draft = run(harness.resolver()); + + assertEquals(List.of(diagnosticId(ADOPTED)), + draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList()); + assertEquals(List.of(ADOPTED), draft.result().courses().stream().map(RiverCourse::id).toList()); + } + + /** + * Only the rejections of a discarded trial are dropped. Its refined edges and source compilations are caches + * keyed by routing and source set, so the owner keeps them and the next trial does not recompile them. This is + * the one part of a discarded trial that can still reach generation output. + */ + @Test(timeout = 15000) + public void discardedFallbackTrialsStillMergeTheirCompilationCaches() throws Exception { + Harness harness = harness(); + run(harness.resolver()); + + assertEquals(Set.of(DISCARDED, ADOPTED), new HashSet<>(harness.refinedEdges().get().keySet())); + assertEquals(Set.of(DISCARDED, ADOPTED), harness.sourceCompilations().get().keySet().stream() + .map(key -> key.routing().outlets().getFirst().outlet().id()) + .collect(Collectors.toSet())); + } + + private static HydrologyOwnerDraft run(HydrologyCrossTileResolver resolver) throws Exception { + ForkJoinPool pool = new ForkJoinPool(4); + try { + return pool.submit(() -> resolver.compileOwnerDraft(KEY, null, false)).get(10, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static Harness harness() throws Exception { + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"); + HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyPlannerSettings.defaults(), terrain); + HydrologyGridNode source = new HydrologyGridNode(0, 0, 0, 0, 0, 10L, terrain.sample(0, 0)); + HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 2048, 1, 64, List.of(source)); + HydrologyRoutingPlan primary = routing(List.of()); + planner.routingContexts.put(KEY, new SourceRoutingContext(grid, primary, primary, List.of())); + HydrologySourcePlanner sourcePlanner = mock(HydrologySourcePlanner.class); + when(sourcePlanner.hasRoutedSourceSearch()).thenReturn(true); + when(sourcePlanner.requireOrganicSurface(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(sourcePlanner.buildRouting(any(), anyList(), anyBoolean())) + .thenAnswer(invocation -> routing(invocation.getArgument(1))); + when(sourcePlanner.selectSources(any(), any(), any(), anyBoolean(), anyBoolean(), anyList(), anyMap())) + .thenAnswer(invocation -> { + if (!invocation.getArgument(3)) { + return SourceSelection.empty(false); + } + HydrologyRoutingPlan selected = invocation.getArgument(2); + if (selected.outlets().isEmpty()) { + return selection(false); + } + long outlet = selected.outlets().getFirst().outlet().id(); + invocation.>getArgument(5).add(diagnostic(diagnosticId(outlet))); + return selection(true); + }); + when(sourcePlanner.uniqueDiagnostics(anyList())) + .thenAnswer(invocation -> List.copyOf(invocation.>getArgument(0))); + replace(planner, "sourcePlanner", sourcePlanner); + HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class); + when(outletPlanner.resolveSurfaceFallbackOutlets(any())).thenReturn(List.of(outlet(0), outlet(1))); + replace(planner, "outletPlanner", outletPlanner); + HydrologyCrossTileResolver resolver = spy(planner.crossTile); + AtomicReference>> ownerEdges = new AtomicReference<>(); + AtomicReference> ownerCompilations = new AtomicReference<>(); + doAnswer(invocation -> { + Map> refinedEdges = invocation.getArgument(7); + Map sourceCompilations = invocation.getArgument(8); + if (invocation.getArgument(9)) { + // Only the owner draft publishes deep fluids, so these are the maps the trials merge into. + ownerEdges.compareAndSet(null, refinedEdges); + ownerCompilations.compareAndSet(null, sourceCompilations); + } + SourceSelection selected = invocation.getArgument(4); + if (selected.selectedCandidateIndices.isEmpty()) { + return new HydrologyCrossTileResolver.PublicationAttempt(EMPTY, List.of()); + } + HydrologyRoutingPlan selectedRouting = invocation.getArgument(2); + long outlet = selectedRouting.outlets().getFirst().outlet().id(); + refinedEdges.put(outlet, List.of(new HydrologyPoint(0, 70, 0))); + sourceCompilations.put( + new HydrologyCrossTileResolver.SourceCompilationKey(true, selectedRouting, List.of(0)), + new HydrologyCrossTileResolver.SourceCompilation( + new CompiledGraph(List.of(), List.of(), List.of(), Map.of()), List.of(), List.of())); + return new HydrologyCrossTileResolver.PublicationAttempt( + outlet == ADOPTED ? result(outlet, source) : EMPTY, List.of()); + }).when(resolver).compilePublication(any(), any(), any(), any(), any(), any(), any(), anyMap(), anyMap(), anyBoolean()); + return new Harness(resolver, ownerEdges, ownerCompilations); + } + + private record Harness( + HydrologyCrossTileResolver resolver, + AtomicReference>> refinedEdges, + AtomicReference> sourceCompilations + ) { + } + + private static long diagnosticId(long outlet) { + return 700L + outlet; + } + + private static SourceSelection selection(boolean accepted) { + List candidates = accepted ? List.of(new SourceCandidate(0, 10L, 1D, false)) : List.of(); + int size = candidates.size(); + SourceAdmissionSelection admission = new SourceAdmissionSelection(1, accepted ? List.of(0) : List.of(), + new boolean[size], new boolean[size], new boolean[size], index -> true, + SourceAdmissionSelection.Quotas.uniform(new int[size], 1, 1)); + return new SourceSelection(true, candidates, admission, new int[]{0}, 1); + } + + private static HydrologyRoutingPlan routing(List outlets) { + return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true); + } + + private static OutletCandidate outlet(int index) { + HydrologyPoint point = new HydrologyPoint(128, 63, index * 128); + return new OutletCandidate(0, 0, new RiverOutlet(200L + index, HydrologyFeatureType.MOUTH, 10L, + point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true)); + } + + private static HydrologyCaveCourseFilter.Result result(long id, HydrologyGridNode source) { + HydraulicSegment segment = new HydraulicSegment(id, id, HydrologyFeatureType.SURFACE_POOL, + 70, 70, 4, 2, false, false, List.of(source.naturalPoint(), new HydrologyPoint(128, 70, 0)), + new HydraulicChannelProfile(new double[]{4D}, new double[]{2D})); + RiverCourse course = new RiverCourse(id, RiverCourseType.SURFACE, OptionalLong.of(source.id()), + OptionalLong.of(id), "default", 1, List.of(), List.of(segment)); + DrainageNode node = new DrainageNode(source.id(), source.x(), source.z(), source.terrain(), 0D, id); + return new HydrologyCaveCourseFilter.Result(List.of(node), List.of(), + List.of(outlet(Math.toIntExact(id - 200L)).outlet()), List.of(course), List.of()); + } + + private static HydrologyDiagnosticCandidate diagnostic(long id) { + return new HydrologyDiagnosticCandidate(id, HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, new HydrologyPoint(0, 70, 0), + HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0); + } + + private static void replace(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java new file mode 100644 index 000000000..1b7fc4df6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java @@ -0,0 +1,225 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalLong; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicIntegerArray; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class HydrologyFallbackParallelismTest { + private static final HydrologyTileKey KEY = new HydrologyTileKey(0, 0); + private static final HydrologyCaveCourseFilter.Result EMPTY = new HydrologyCaveCourseFilter.Result( + List.of(), List.of(), List.of(), List.of(), List.of()); + + @Test(timeout = 15000) + public void publicationRejectionConsumesTheNextCompletedOutletWithoutRebuildingIt() throws Exception { + AtomicIntegerArray builds = new AtomicIntegerArray(4); + Fixture fixture = fixture(outlet -> { + builds.incrementAndGet(outlet); + if (outlet == 3) { + throw new IllegalStateException("Unused fallback provider failed"); + } + }); + ArrayList submitted = new ArrayList<>(); + CrossTileDraftAdmission admission = new CrossTileDraftAdmission() { + @Override + public void prepare() { + } + + @Override + public CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result) { + if (result.courses().isEmpty()) { + return new CrossTilePublicationAdmission(result, List.of(), false); + } + for (int outlet = 0; outlet < 4; outlet++) { + assertEquals("Every trial provider must finish before publication", 1, builds.get(outlet)); + } + long course = result.courses().getFirst().id(); + submitted.add(course); + return course == 200L + ? new CrossTilePublicationAdmission(EMPTY, List.of(diagnostic(900L)), true) + : new CrossTilePublicationAdmission(result, List.of(), false); + } + }; + HydrologyOwnerDraft draft = run(fixture, admission); + + assertEquals(List.of(200L, 201L), submitted); + assertEquals(List.of(201L), draft.result().courses().stream().map(RiverCourse::id).toList()); + assertEquals(List.of(200L, 900L, 201L), draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList()); + for (int outlet = 0; outlet < 4; outlet++) { + assertEquals(1, builds.get(outlet)); + } + } + + @Test(timeout = 15000) + public void requiredFallbackFailureDrainsOtherStartedProviders() throws Exception { + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean completed = new AtomicBoolean(); + IllegalStateException expected = new IllegalStateException("Required fallback provider failed"); + Fixture fixture = fixture(outlet -> { + if (outlet == 0) { + failed.countDown(); + throw expected; + } + if (outlet == 1) { + blocked.countDown(); + await(release); + completed.set(true); + } + }); + ForkJoinPool pool = new ForkJoinPool(4); + try { + Future pending = pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, null, false)); + assertTrue(failed.await(5, TimeUnit.SECONDS)); + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + assertFalse(pending.isDone()); + release.countDown(); + ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS)); + Throwable cause = failure.getCause(); + while (cause.getCause() != null) { + cause = cause.getCause(); + } + assertSame(expected, cause); + assertTrue(completed.get()); + } finally { + release.countDown(); + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static HydrologyOwnerDraft run(Fixture fixture, CrossTileDraftAdmission admission) throws Exception { + ForkJoinPool pool = new ForkJoinPool(4); + try { + return pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, admission, false)).get(10, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static Fixture fixture(OutletProvider provider) throws Exception { + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"); + HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyPlannerSettings.defaults(), terrain); + HydrologyGridNode source = new HydrologyGridNode(0, 0, 0, 0, 0, 10L, terrain.sample(0, 0)); + HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 2048, 1, 64, List.of(source)); + HydrologyRoutingPlan primary = routing(List.of()); + planner.routingContexts.put(KEY, new SourceRoutingContext(grid, primary, primary, List.of())); + HydrologySourcePlanner sourcePlanner = mock(HydrologySourcePlanner.class); + when(sourcePlanner.hasRoutedSourceSearch()).thenReturn(true); + when(sourcePlanner.requireOrganicSurface(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(sourcePlanner.buildRouting(any(), anyList(), anyBoolean())).thenAnswer(invocation -> { + List outlets = invocation.getArgument(1); + provider.build(Math.toIntExact(outlets.getFirst().outlet().id() - 200L)); + return routing(outlets); + }); + when(sourcePlanner.selectSources(any(), any(), any(), anyBoolean(), anyBoolean(), anyList(), anyMap())) + .thenAnswer(invocation -> { + if (!invocation.getArgument(3)) { + return SourceSelection.empty(false); + } + HydrologyRoutingPlan selected = invocation.getArgument(2); + return selection(!selected.outlets().isEmpty()); + }); + when(sourcePlanner.uniqueDiagnostics(anyList())).thenAnswer(invocation -> List.copyOf(invocation.>getArgument(0))); + replace(planner, "sourcePlanner", sourcePlanner); + HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class); + when(outletPlanner.resolveSurfaceFallbackOutlets(any())).thenReturn(List.of(outlet(0), outlet(1), outlet(2), outlet(3))); + replace(planner, "outletPlanner", outletPlanner); + HydrologyCrossTileResolver resolver = spy(planner.crossTile); + doAnswer(invocation -> { + SourceSelection selected = invocation.getArgument(4); + if (selected.selectedCandidateIndices.isEmpty()) { + return new HydrologyCrossTileResolver.PublicationAttempt(EMPTY, List.of()); + } + HydrologyRoutingPlan selectedRouting = invocation.getArgument(2); + long id = selectedRouting.outlets().getFirst().outlet().id(); + boolean finalPublication = invocation.getArgument(9); + return new HydrologyCrossTileResolver.PublicationAttempt(result(id, source), + finalPublication ? List.of() : List.of(diagnostic(id))); + }).when(resolver).compilePublication(any(), any(), any(), any(), any(), any(), any(), anyMap(), anyMap(), anyBoolean()); + return new Fixture(resolver); + } + + private static SourceSelection selection(boolean accepted) { + List candidates = accepted ? List.of(new SourceCandidate(0, 10L, 1D, false)) : List.of(); + int size = candidates.size(); + SourceAdmissionSelection admission = new SourceAdmissionSelection(1, accepted ? List.of(0) : List.of(), + new boolean[size], new boolean[size], new boolean[size], index -> true, + SourceAdmissionSelection.Quotas.uniform(new int[size], 1, 1)); + return new SourceSelection(true, candidates, admission, new int[]{0}, 1); + } + + private static HydrologyRoutingPlan routing(List outlets) { + return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true); + } + + private static OutletCandidate outlet(int index) { + HydrologyPoint point = new HydrologyPoint(128, 63, index * 128); + return new OutletCandidate(0, 0, new RiverOutlet(200L + index, HydrologyFeatureType.MOUTH, 10L, + point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true)); + } + + private static HydrologyCaveCourseFilter.Result result(long id, HydrologyGridNode source) { + HydraulicSegment segment = new HydraulicSegment(id, id, HydrologyFeatureType.SURFACE_POOL, + 70, 70, 4, 2, false, false, List.of(source.naturalPoint(), new HydrologyPoint(128, 70, 0)), + new HydraulicChannelProfile(new double[]{4D}, new double[]{2D})); + RiverCourse course = new RiverCourse(id, RiverCourseType.SURFACE, OptionalLong.of(source.id()), + OptionalLong.of(id), "default", 1, List.of(), List.of(segment)); + DrainageNode node = new DrainageNode(source.id(), source.x(), source.z(), source.terrain(), 0D, id); + return new HydrologyCaveCourseFilter.Result(List.of(node), List.of(), List.of(outlet(Math.toIntExact(id - 200L)).outlet()), + List.of(course), List.of()); + } + + private static HydrologyDiagnosticCandidate diagnostic(long id) { + return new HydrologyDiagnosticCandidate(id, HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, new HydrologyPoint(0, 70, 0), HydrologyCandidateRejection.SOURCE_QUOTA, 0); + } + + private static void replace(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue("Timed out waiting for fallback release", latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } + + @FunctionalInterface + private interface OutletProvider { + void build(int outlet); + } + + private record Fixture(HydrologyCrossTileResolver resolver) { + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyModelTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyModelTest.java index 58ed3deec..da66814c8 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyModelTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyModelTest.java @@ -758,6 +758,7 @@ public class HydrologyModelTest { List.of(), List.of(), List.of(course), + Set.of(), List.of(plan), List.of(), new RiverFootprint(Map.of( diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java index f77e85e11..336cf1627 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java @@ -17,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 = "20e96d6f310786d09e7182ae6f73e41e5b7d07591b5571a62b1299fe1ab58b9a"; + private static final String EXPECTED_PLAN_DIGEST = "9ec4c9cf7dbdad16976041a70853b909816655170cd694058bf359092c2d5a10"; private static final List TILES = List.of( new HydrologyTileKey(0, 0), @@ -66,7 +66,7 @@ public class HydrologyPlanHashTest { for (HydrologyCavePlan plan : tile.cavePlans()) { out.append("cave ").append(plan).append('\n'); } - for (HydrologyDiagnosticCandidate diagnostic : tile.diagnosticCandidates()) { + for (HydrologyDiagnosticCandidate diagnostic : tile.localDiagnosticCandidates()) { out.append("diagnostic ").append(diagnostic).append('\n'); } for (HydrologyFeatureRef feature : tile.features()) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerConfinementTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerConfinementTest.java index d27b8f9ba..e0f9c48b4 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerConfinementTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerConfinementTest.java @@ -26,7 +26,7 @@ public class HydrologyPlannerConfinementTest { HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(false, true)).plan(TILE); List westCourses = westCourses(tile); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), westCourses.isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), westCourses.isEmpty()); assertTrue("no western course crossed into the east: " + describe(westCourses), westCourses.stream().anyMatch(course -> maximumX(course) >= BOUNDARY)); } @@ -36,7 +36,7 @@ public class HydrologyPlannerConfinementTest { HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(true, true)).plan(TILE); List westCourses = westCourses(tile); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), westCourses.isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), westCourses.isEmpty()); for (RiverCourse course : westCourses) { assertTrue("western course left its region: " + describe(List.of(course)), maximumX(course) < BOUNDARY); } @@ -47,7 +47,7 @@ public class HydrologyPlannerConfinementTest { HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(true, false)).plan(TILE); assertTrue(describe(westCourses(tile)), westCourses(tile).isEmpty()); - assertTrue("diagnostics=" + tile.diagnosticCandidates(), tile.diagnosticCandidates().stream() + assertTrue("diagnostics=" + tile.localDiagnosticCandidates(), tile.localDiagnosticCandidates().stream() .anyMatch(candidate -> candidate.rejection() == HydrologyCandidateRejection.CONFINED_NO_OUTLET)); } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java index e9452f31e..a25f2cedc 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java @@ -73,8 +73,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { assertEquals(forward, reverse); List firstSources = sourcePoints(forward.get(pair.first()), RiverCourseType.SURFACE); List secondSources = sourcePoints(forward.get(pair.second()), RiverCourseType.SURFACE); - assertEquals(forward.get(pair.first()).diagnosticCandidates().toString(), 1, firstSources.size()); - assertEquals(forward.get(pair.second()).diagnosticCandidates().toString(), 1, secondSources.size()); + assertEquals(forward.get(pair.first()).localDiagnosticCandidates().toString(), 1, firstSources.size()); + assertEquals(forward.get(pair.second()).localDiagnosticCandidates().toString(), 1, secondSources.size()); assertPointNear(new HydrologyPoint(32, 121, -272), firstSources.getFirst(), 8); // The second tile keeps the mouth on its own coast, so its heaviest required source no longer // competes with the first tile's network and is accepted ahead of the lighter fallback source. diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerGlobalInvariantTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerGlobalInvariantTest.java index fa6878a31..3264ba31b 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerGlobalInvariantTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerGlobalInvariantTest.java @@ -443,7 +443,7 @@ public class HydrologyPlannerGlobalInvariantTest { ).plan(new HydrologyTileKey(0, 0)); assertTrue(courses(tile, RiverCourseType.SURFACE).isEmpty()); - assertTrue(tile.diagnosticCandidates().stream().anyMatch( + assertTrue(tile.localDiagnosticCandidates().stream().anyMatch( (HydrologyDiagnosticCandidate candidate) -> candidate.rejection() == HydrologyCandidateRejection.NO_DRAINAGE_PATH )); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSeaCaveTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSeaCaveTest.java index 669e55818..b6d6a43c6 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSeaCaveTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSeaCaveTest.java @@ -28,7 +28,7 @@ public class HydrologyPlannerSeaCaveTest { HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast).plan(TILE); List caves = courses(tile, RiverCourseType.SEA_CAVE); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), caves.isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), caves.isEmpty()); for (RiverCourse cave : caves) { assertEquals(1, cave.segments().size()); HydraulicSegment chamber = cave.segments().getFirst(); @@ -42,7 +42,7 @@ public class HydrologyPlannerSeaCaveTest { assertTrue("inner=" + inner + " connection=" + connection, inner.distanceSquared2D(connection) >= (long) seaCaves.depth() * seaCaves.depth()); HydrologyCavePlan plan = tile.cavePlan(cave.id()).orElse(null); - assertTrue("cave plan missing or rejected for " + cave.id() + " diagnostics=" + tile.diagnosticCandidates(), + assertTrue("cave plan missing or rejected for " + cave.id() + " diagnostics=" + tile.localDiagnosticCandidates(), plan != null && plan.accepted()); int chamberColumns = 0; @@ -148,10 +148,10 @@ public class HydrologyPlannerSeaCaveTest { HydrologyTile tile = new HydrologyPlanner(17L, settings, coast).plan(TILE); List caves = courses(tile, RiverCourseType.SEA_CAVE); - assertEquals("diagnostics=" + tile.diagnosticCandidates(), 2, caves.size()); + assertEquals("diagnostics=" + tile.localDiagnosticCandidates(), 2, caves.size()); for (RiverCourse cave : caves) { HydrologyPoint connection = cave.segments().getFirst().end(); - assertTrue("connection=" + connection + " diagnostics=" + tile.diagnosticCandidates(), + assertTrue("connection=" + connection + " diagnostics=" + tile.localDiagnosticCandidates(), connection.z() >= 128 && connection.z() < 256); } HydrologyPoint first = caves.get(0).segments().getFirst().end(); @@ -159,7 +159,7 @@ public class HydrologyPlannerSeaCaveTest { long minimum = seaCaves.minimumSpacing() - 2L; assertTrue(first + " vs " + second, first.distanceSquared2D(second) >= minimum * minimum); int lowCoastRejections = 0; - for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) { if (candidate.kind() != HydrologyCandidateKind.OUTLET || candidate.projectedType() != HydrologyFeatureType.COASTAL_GROTTO || candidate.point().z() < 0 || candidate.point().z() >= 128) { @@ -195,8 +195,8 @@ public class HydrologyPlannerSeaCaveTest { } List caves = courses(tile, RiverCourseType.SEA_CAVE); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), outletPoints.isEmpty()); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), caves.isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), outletPoints.isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), caves.isEmpty()); long clearance = 2L * CHAMBER.horizontalRadius() + 12L; for (RiverCourse cave : caves) { for (HydrologyPoint point : cave.segments().getFirst().centerline()) { @@ -213,7 +213,7 @@ public class HydrologyPlannerSeaCaveTest { cliffCoast(112, 66) ).plan(TILE); assertTrue(courses(lowCoast, RiverCourseType.SEA_CAVE).isEmpty()); - assertTrue(lowCoast.diagnosticCandidates().toString(), + assertTrue(lowCoast.localDiagnosticCandidates().toString(), hasDiagnostic(lowCoast, HydrologyCandidateRejection.SURFACE_HEAD_RANGE)); } @@ -226,12 +226,12 @@ public class HydrologyPlannerSeaCaveTest { HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast(112, 92)).plan(TILE); assertTrue(courses(tile, RiverCourseType.SEA_CAVE).isEmpty()); - assertTrue(tile.diagnosticCandidates().toString(), + assertTrue(tile.localDiagnosticCandidates().toString(), hasDiagnostic(tile, HydrologyCandidateRejection.VOLUME_LIMIT)); } private static boolean hasDiagnostic(HydrologyTile tile, HydrologyCandidateRejection rejection) { - for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) { if (candidate.kind() == HydrologyCandidateKind.OUTLET && candidate.projectedType() == HydrologyFeatureType.COASTAL_GROTTO && candidate.rejection() == rejection) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSurfacePoolTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSurfacePoolTest.java index 3daac6f40..e4abbaa03 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSurfacePoolTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSurfacePoolTest.java @@ -23,7 +23,7 @@ public class HydrologyPlannerSurfacePoolTest { assertEquals(tile, replay); List pools = pools(tile); - assertFalse(tile.diagnosticCandidates().toString(), pools.isEmpty()); + assertFalse(tile.localDiagnosticCandidates().toString(), pools.isEmpty()); for (RiverCourse pool : pools) { assertEquals("lava_pool", pool.profileKey()); assertEquals(1, pool.segments().size()); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java index a990adac5..cf81365ff 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java @@ -332,7 +332,7 @@ public class HydrologyPlannerTest { observedParallelOwner |= parallelThreads.size() > 1; assertTileContentsEqual(expected, actual); assertEquals(expected.courses(), actual.courses()); - assertEquals(expected.diagnosticCandidates(), actual.diagnosticCandidates()); + assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates()); } } finally { pool.shutdownNow(); @@ -376,7 +376,7 @@ public class HydrologyPlannerTest { assertEquals("outlets", expected.outlets(), actual.outlets()); assertEquals("courses", expected.courses(), actual.courses()); assertEquals("cave plans", expected.cavePlans(), actual.cavePlans()); - assertEquals("diagnostics", expected.diagnosticCandidates(), actual.diagnosticCandidates()); + assertEquals("diagnostics", expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates()); assertEquals("footprint column count", expected.footprint().columns().size(), actual.footprint().columns().size()); for (Map.Entry entry : expected.footprint().columns().entrySet()) { assertEquals("footprint column " + entry.getKey(), entry.getValue(), actual.footprint().columns().get(entry.getKey())); @@ -623,7 +623,7 @@ public class HydrologyPlannerTest { assertEquals(expected, actual); assertEquals(expected.courses(), actual.courses()); - assertEquals(expected.diagnosticCandidates(), actual.diagnosticCandidates()); + assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates()); assertTrue(cached.computations <= uncached.computations); if (organic) { assertTrue("hits=" + cached.hits, cached.hits > 0); @@ -645,7 +645,7 @@ public class HydrologyPlannerTest { HydrologyTile tile = planner.plan(TILE); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty()); assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean)); assertTrue(tile.acyclic()); assertFalse(surfaceCourses(tile).isEmpty()); @@ -674,7 +674,7 @@ public class HydrologyPlannerTest { assertEquals(first.courses(), replay.courses()); List courses = surfaceCourses(first); - assertTrue(first.diagnosticCandidates().toString(), courses.size() <= 4); + assertTrue(first.localDiagnosticCandidates().toString(), courses.size() <= 4); RiverCourse mainCourse = courses.getFirst(); for (RiverCourse course : courses) { if (course.drainageEdges().size() > mainCourse.drainageEdges().size()) { @@ -740,7 +740,7 @@ public class HydrologyPlannerTest { HydrologyTile tile = planner(78L, settings, rollingCoast(112)).plan(TILE); - assertFalse(tile.diagnosticCandidates().toString(), surfaceCourses(tile).isEmpty()); + assertFalse(tile.localDiagnosticCandidates().toString(), surfaceCourses(tile).isEmpty()); assertTrue(surfaceCourses(tile).stream().allMatch(RiverCourse::hydraulicallyNonRising)); } @@ -835,7 +835,7 @@ public class HydrologyPlannerTest { DrainageNode replacementSource = backfilled.node(replacement.sourceNodeId().orElseThrow()).orElseThrow(); assertTrue(initialSource.z() < replacementSource.z()); assertFalse(replacement.sourceNodeId().equals(initiallySelected.sourceNodeId())); - assertTrue(backfilled.diagnosticCandidates().stream().anyMatch( + assertTrue(backfilled.localDiagnosticCandidates().stream().anyMatch( (HydrologyDiagnosticCandidate candidate) -> candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT && candidate.point().x() == initialSource.x() @@ -973,7 +973,7 @@ public class HydrologyPlannerTest { HydrologyTile tile = new HydrologyPlanner(82L, settings, terrain).plan(TILE); assertTrue(surfaceCourses(tile).isEmpty()); - assertTrue(tile.diagnosticCandidates().toString(), tile.diagnosticCandidates().stream().anyMatch( + assertTrue(tile.localDiagnosticCandidates().toString(), tile.localDiagnosticCandidates().stream().anyMatch( candidate -> candidate.rejection() == HydrologyCandidateRejection.NO_DRAINAGE_PATH )); } @@ -996,8 +996,8 @@ public class HydrologyPlannerTest { assertTrue(tile.courses().isEmpty()); assertTrue(tile.footprint().isEmpty()); assertTrue(tile.features().isEmpty()); - assertFalse(tile.diagnosticCandidates().isEmpty()); - for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + assertFalse(tile.localDiagnosticCandidates().isEmpty()); + for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) { assertEquals(HydrologyCandidateKind.SOURCE, candidate.kind()); assertEquals(HydrologyFeatureType.SURFACE_POOL, candidate.projectedType()); assertEquals(HydrologyCandidateRejection.NO_LEGAL_OUTLET, candidate.rejection()); @@ -1009,7 +1009,7 @@ public class HydrologyPlannerTest { candidate.point().z(), 0 ).isEmpty()); - HydrologyDiagnosticRenderSample diagnostic = tile.diagnosticRenderAt( + HydrologyDiagnosticRenderSample diagnostic = tile.localDiagnosticRenderAt( candidate.point().x(), candidate.point().z(), 0 @@ -1034,7 +1034,7 @@ public class HydrologyPlannerTest { HydrologyTile cliff = new HydrologyPlanner(19L, settings, cliffTerrain).plan(TILE); assertTrue( - "diagnostics=" + rolling.diagnosticCandidates() + " segments=" + rolling.courses().stream() + "diagnostics=" + rolling.localDiagnosticCandidates() + " segments=" + rolling.courses().stream() .flatMap((RiverCourse course) -> course.segments().stream()) .map((HydraulicSegment segment) -> segment.type() + ":" + segment.drop()) .toList(), @@ -1058,7 +1058,7 @@ public class HydrologyPlannerTest { } assertTrue( "features=" + cliff.features().stream().map(HydrologyFeatureRef::type).toList() - + " diagnostics=" + cliff.diagnosticCandidates().stream() + + " diagnostics=" + cliff.localDiagnosticCandidates().stream() .map((HydrologyDiagnosticCandidate candidate) -> candidate.projectedType() + ":" + candidate.rejection()) .toList(), @@ -1134,7 +1134,7 @@ public class HydrologyPlannerTest { }; HydrologyTile tile = new HydrologyPlanner(812L, settings, terrain, solidCaveView()).plan(TILE); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), surfaceCourses(tile).isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty()); boolean aggregateTransition = false; for (RiverCourse course : surfaceCourses(tile)) { for (HydraulicSegment segment : course.segments()) { @@ -1352,7 +1352,7 @@ public class HydrologyPlannerTest { HydrologyTile tile = new HydrologyPlanner(333L, settings, coast, solidCaveView()).plan(TILE); assertTrue(courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); - assertTrue(tile.diagnosticCandidates().stream().anyMatch( + assertTrue(tile.localDiagnosticCandidates().stream().anyMatch( (HydrologyDiagnosticCandidate candidate) -> candidate.rejection() == HydrologyCandidateRejection.OUTLET_LEVEL )); @@ -1393,7 +1393,7 @@ public class HydrologyPlannerTest { footprint -> solidCaveView() ).plan(TILE); - assertFalse(tile.diagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); + assertFalse(tile.localDiagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); for (RiverCourse course : courses(tile, RiverCourseType.UNDERGROUND)) { RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); assertEquals(HydrologyFeatureType.INLAND_GROTTO, outlet.type()); @@ -1428,11 +1428,11 @@ public class HydrologyPlannerTest { assertTrue(courses(narrowTile, RiverCourseType.UNDERGROUND).isEmpty()); assertTrue(courses(wideTile, RiverCourseType.UNDERGROUND).isEmpty()); - assertTrue(narrowTile.diagnosticCandidates().stream().anyMatch( + assertTrue(narrowTile.localDiagnosticCandidates().stream().anyMatch( (HydrologyDiagnosticCandidate candidate) -> candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT )); - assertTrue(wideTile.diagnosticCandidates().stream().anyMatch( + assertTrue(wideTile.localDiagnosticCandidates().stream().anyMatch( (HydrologyDiagnosticCandidate candidate) -> candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT )); @@ -1458,12 +1458,12 @@ public class HydrologyPlannerTest { footprint -> solidCaveView() ).plan(TILE); - assertFalse(tile.diagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); + assertFalse(tile.localDiagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); RiverCourse course = courses(tile, RiverCourseType.UNDERGROUND).getFirst(); RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); assertFalse( "outlet=" + outlet.type() + " source=" + course.sourceNodeId() - + " diagnostics=" + tile.diagnosticCandidates(), + + " diagnostics=" + tile.localDiagnosticCandidates(), outlet.directOcean() ); assertEquals(71, outlet.connectionPoint().y()); @@ -1597,7 +1597,7 @@ public class HydrologyPlannerTest { HydrologyTile tile = planner.plan(TILE); assertFalse( - "courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(), + "courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty() ); assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean)); @@ -1625,7 +1625,7 @@ public class HydrologyPlannerTest { ).plan(TILE); assertFalse( - "courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(), + "courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(), tile.footprint().isEmpty() ); for (HydrologyColumnSample column : tile.footprint().columns().values()) { @@ -1650,7 +1650,7 @@ public class HydrologyPlannerTest { rollingCoast(112) ).plan(TILE); - assertFalse("courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(), tile.footprint().isEmpty()); + assertFalse("courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(), tile.footprint().isEmpty()); boolean sawGrading = false; for (HydrologyColumnSample column : tile.footprint().columns().values()) { assertEquals(column.ocean() ? "ocean_parent" : "parent", column.parentBiomeKey()); @@ -1870,9 +1870,9 @@ public class HydrologyPlannerTest { }; HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast).plan(TILE); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty()); for (RiverOutlet outlet : tile.outlets()) { - assertEquals("outlet=" + outlet + " diagnostics=" + tile.diagnosticCandidates(), + assertEquals("outlet=" + outlet + " diagnostics=" + tile.localDiagnosticCandidates(), HydrologyFeatureType.COASTAL_GROTTO, outlet.type()); assertTrue(outlet.directOcean()); assertEquals(settings.seaLevel(), outlet.connectionPoint().y()); @@ -1937,12 +1937,12 @@ public class HydrologyPlannerTest { HydrologyTile tile = new HydrologyPlanner(70143L, settings, terrain, solidCaveView()).plan(TILE); assertEquals( - "outlets=" + tile.outlets() + " diagnostics=" + tile.diagnosticCandidates(), + "outlets=" + tile.outlets() + " diagnostics=" + tile.localDiagnosticCandidates(), 1, tile.outlets().stream().filter(RiverOutlet::directOcean).count() ); assertEquals( - "outlets=" + tile.outlets() + " diagnostics=" + tile.diagnosticCandidates(), + "outlets=" + tile.outlets() + " diagnostics=" + tile.localDiagnosticCandidates(), 1, tile.outlets().stream().filter((RiverOutlet outlet) -> !outlet.directOcean()).count() ); @@ -1985,7 +1985,7 @@ public class HydrologyPlannerTest { HydrologyTile tile = new HydrologyPlanner(883L, settings, twoCoasts).plan(TILE); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty()); assertFalse(surfaceCourses(tile).isEmpty()); for (RiverOutlet outlet : tile.outlets()) { assertTrue( @@ -2034,12 +2034,12 @@ public class HydrologyPlannerTest { HydrologyTile tile = new HydrologyPlanner(884L, settings, coast, solidCaveView()).plan(TILE); - List level = tile.diagnosticCandidates().stream() + List level = tile.localDiagnosticCandidates().stream() .filter((HydrologyDiagnosticCandidate candidate) -> candidate.kind() == HydrologyCandidateKind.OUTLET && candidate.rejection() == HydrologyCandidateRejection.OUTLET_LEVEL) .toList(); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), level.isEmpty()); + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), level.isEmpty()); for (HydrologyDiagnosticCandidate candidate : level) { assertTrue( candidate.toString(), @@ -2071,8 +2071,8 @@ public class HydrologyPlannerTest { HydrologyTile tile = new HydrologyPlanner(885L, settings, island, solidCaveView()).plan(TILE); - assertFalse("diagnostics=" + tile.diagnosticCandidates(), surfaceCourses(tile).isEmpty()); - assertTrue(tile.diagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) -> + assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty()); + assertTrue(tile.localDiagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) -> candidate.rejection() == HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED)); for (RiverCourse course : surfaceCourses(tile)) { RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); @@ -2107,7 +2107,7 @@ public class HydrologyPlannerTest { HydrologyTile repeated = planner.plan(TILE); assertEquals(first, repeated); - assertEquals(first.diagnosticCandidates().toString(), 1, surfaceCourses(first).size()); + assertEquals(first.localDiagnosticCandidates().toString(), 1, surfaceCourses(first).size()); RiverCourse course = surfaceCourses(first).getFirst(); assertTrue(course.surfaceSinkholeContinuation()); RiverOutlet outlet = first.outlet(course.outletId().orElseThrow()).orElseThrow(); @@ -2179,7 +2179,7 @@ public class HydrologyPlannerTest { assertTrue(surfaceCourses(tile).isEmpty()); assertFalse(courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); - assertTrue(tile.diagnosticCandidates().stream().anyMatch( + assertTrue(tile.localDiagnosticCandidates().stream().anyMatch( (HydrologyDiagnosticCandidate candidate) -> candidate.projectedType() == HydrologyFeatureType.SURFACE_POOL && candidate.rejection() == HydrologyCandidateRejection.NO_LEGAL_OUTLET )); @@ -2239,7 +2239,7 @@ public class HydrologyPlannerTest { HydrologyTile tile = new HydrologyPlanner(70142L, settings, terrain, solidCaveView()).plan(TILE); - assertEquals(tile.diagnosticCandidates().toString(), 1, surfaceCourses(tile).size()); + assertEquals(tile.localDiagnosticCandidates().toString(), 1, surfaceCourses(tile).size()); RiverCourse course = surfaceCourses(tile).getFirst(); RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); assertFalse(outlet.directOcean()); @@ -2264,7 +2264,7 @@ public class HydrologyPlannerTest { HydrologyTile tile = new HydrologyPlanner(70140L, settings, terrain, solidCaveView()).plan(TILE); assertTrue( - tile.diagnosticCandidates().toString(), + tile.localDiagnosticCandidates().toString(), tile.outlets().stream().anyMatch((RiverOutlet outlet) -> !outlet.directOcean()) ); RiverCourse course = surfaceCourses(tile).stream() @@ -2300,7 +2300,7 @@ public class HydrologyPlannerTest { RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); assertFalse( "outlet=" + outlet.type() + " source=" + course.sourceNodeId() - + " diagnostics=" + tile.diagnosticCandidates(), + + " diagnostics=" + tile.localDiagnosticCandidates(), outlet.directOcean() ); assertTrue(hasAny(tile, HydrologyFeatureType.SINKHOLE, HydrologyFeatureType.INLAND_GROTTO)); @@ -2331,7 +2331,7 @@ public class HydrologyPlannerTest { assertTrue(rejected.cavePlans().stream().noneMatch( (HydrologyCavePlan plan) -> plan.actions().containsKey(hazard) )); - assertTrue(rejected.diagnosticCandidates().stream().anyMatch( + assertTrue(rejected.localDiagnosticCandidates().stream().anyMatch( (HydrologyDiagnosticCandidate candidate) -> candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT )); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTributaryTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTributaryTest.java index 4c8bb8dfc..81dff3693 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTributaryTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTributaryTest.java @@ -61,8 +61,8 @@ public class HydrologyPlannerTributaryTest { List singleCourses = surface(single); List splitCourses = surface(split); - assertEquals(single.diagnosticCandidates().toString(), 1, singleCourses.size()); - assertEquals(split.diagnosticCandidates().toString(), 2, splitCourses.size()); + assertEquals(single.localDiagnosticCandidates().toString(), 1, singleCourses.size()); + assertEquals(split.localDiagnosticCandidates().toString(), 2, splitCourses.size()); RiverCourse stem = null; RiverCourse tributary = null; for (RiverCourse course : splitCourses) { @@ -390,7 +390,7 @@ public class HydrologyPlannerTributaryTest { HydrologyTile split = SPLIT_TILE; List courses = surface(split); - assertEquals(split.diagnosticCandidates().toString(), 2, courses.size()); + assertEquals(split.localDiagnosticCandidates().toString(), 2, courses.size()); RiverCourse stem = stations(courses.get(0)) >= stations(courses.get(1)) ? courses.get(0) : courses.get(1); RiverCourse tributary = stem == courses.get(0) ? courses.get(1) : courses.get(0); java.util.Set stemEdges = new java.util.HashSet<>(); @@ -414,7 +414,7 @@ public class HydrologyPlannerTributaryTest { } assertNotNull("tributary ends on the stem", stemHead); assertEquals(stemHead.intValue(), tributary.segments().getLast().downstreamHeadY()); - for (HydrologyDiagnosticCandidate candidate : split.diagnosticCandidates()) { + for (HydrologyDiagnosticCandidate candidate : split.localDiagnosticCandidates()) { assertTrue(candidate.toString(), candidate.kind() != HydrologyCandidateKind.TRIBUTARY); } } @@ -426,8 +426,8 @@ public class HydrologyPlannerTributaryTest { HydrologyTile single = new HydrologyPlanner(19L, settings(0, 1D, 1), terrain).plan(TILE); HydrologyTile joined = new HydrologyPlanner(19L, settings(1, 1D, 1), terrain).plan(TILE); - assertEquals(single.diagnosticCandidates().toString(), 1, surface(single).size()); - assertEquals(joined.diagnosticCandidates().toString(), 2, surface(joined).size()); + assertEquals(single.localDiagnosticCandidates().toString(), 1, surface(single).size()); + assertEquals(joined.localDiagnosticCandidates().toString(), 2, surface(joined).size()); } @Test @@ -437,9 +437,9 @@ public class HydrologyPlannerTributaryTest { HydrologyTile single = new HydrologyPlanner(23L, undergroundSettings(0), terrain).plan(TILE); HydrologyTile joined = new HydrologyPlanner(23L, undergroundSettings(1), terrain).plan(TILE); - assertEquals(single.diagnosticCandidates().toString(), 1, underground(single).size()); + assertEquals(single.localDiagnosticCandidates().toString(), 1, underground(single).size()); List courses = underground(joined); - assertEquals(joined.diagnosticCandidates().toString(), 2, courses.size()); + assertEquals(joined.localDiagnosticCandidates().toString(), 2, courses.size()); RiverCourse stem = stations(courses.get(0)) >= stations(courses.get(1)) ? courses.get(0) : courses.get(1); RiverCourse tributary = stem == courses.get(0) ? courses.get(1) : courses.get(0); assertEquals(stem.outletId(), tributary.outletId()); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerUndergroundKnobsTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerUndergroundKnobsTest.java index 174a97702..0bb18f8a8 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerUndergroundKnobsTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerUndergroundKnobsTest.java @@ -41,7 +41,7 @@ public class HydrologyPlannerUndergroundKnobsTest { ).plan(TILE); List planned = courses(fromFactory, RiverCourseType.UNDERGROUND); - assertFalse("diagnostics=" + fromFactory.diagnosticCandidates(), planned.isEmpty()); + assertFalse("diagnostics=" + fromFactory.localDiagnosticCandidates(), planned.isEmpty()); assertEquals(fromFactory.courses(), explicit.courses()); assertCoveredByRock(coast, planned, base.underground().minimumHeadroom(), 1); @@ -94,7 +94,7 @@ public class HydrologyPlannerUndergroundKnobsTest { ).plan(TILE); List shallowCourses = courses(shallow, RiverCourseType.UNDERGROUND); - assertFalse("diagnostics=" + shallow.diagnosticCandidates(), shallowCourses.isEmpty()); + assertFalse("diagnostics=" + shallow.localDiagnosticCandidates(), shallowCourses.isEmpty()); assertTrue( "heads=" + heads(shallowCourses) + " bound=" + bound, heads(shallowCourses).stream().anyMatch((Integer head) -> head < bound) @@ -166,17 +166,17 @@ public class HydrologyPlannerUndergroundKnobsTest { SOLID_CAVE_VIEW ).plan(TILE); - assertFalse("diagnostics=" + atTheDefault.diagnosticCandidates(), atTheDefault.outlets().isEmpty()); + assertFalse("diagnostics=" + atTheDefault.localDiagnosticCandidates(), atTheDefault.outlets().isEmpty()); for (RiverOutlet outlet : atTheDefault.outlets()) { assertNotEquals(HydrologyFeatureType.COASTAL_GROTTO, outlet.type()); } - assertFalse("diagnostics=" + sensitive.diagnosticCandidates(), sensitive.outlets().isEmpty()); + assertFalse("diagnostics=" + sensitive.localDiagnosticCandidates(), sensitive.outlets().isEmpty()); boolean grotto = false; for (RiverOutlet outlet : sensitive.outlets()) { grotto |= outlet.type() == HydrologyFeatureType.COASTAL_GROTTO; } assertTrue("outlets=" + sensitive.outlets(), grotto); - assertFalse("diagnostics=" + slopeRuleOff.diagnosticCandidates(), slopeRuleOff.outlets().isEmpty()); + assertFalse("diagnostics=" + slopeRuleOff.localDiagnosticCandidates(), slopeRuleOff.outlets().isEmpty()); for (RiverOutlet outlet : slopeRuleOff.outlets()) { assertNotEquals(HydrologyFeatureType.COASTAL_GROTTO, outlet.type()); } @@ -205,8 +205,8 @@ public class HydrologyPlannerUndergroundKnobsTest { List turnedCaves = courses(turned, RiverCourseType.SEA_CAVE); List straightCaves = courses(straight, RiverCourseType.SEA_CAVE); - assertFalse("diagnostics=" + turned.diagnosticCandidates(), turnedCaves.isEmpty()); - assertFalse("diagnostics=" + straight.diagnosticCandidates(), straightCaves.isEmpty()); + assertFalse("diagnostics=" + turned.localDiagnosticCandidates(), turnedCaves.isEmpty()); + assertFalse("diagnostics=" + straight.localDiagnosticCandidates(), straightCaves.isEmpty()); boolean offNormal = false; for (RiverCourse cave : turnedCaves) { HydraulicSegment chamber = cave.segments().getFirst(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalBankSupportTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalBankSupportTest.java index b557036d5..54970443c 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalBankSupportTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalBankSupportTest.java @@ -9,9 +9,7 @@ import art.arcane.iris.generation.hydrology.surface.SurfaceTerminal; import org.junit.Test; import java.lang.reflect.Method; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.OptionalLong; import java.util.concurrent.atomic.AtomicInteger; @@ -108,23 +106,14 @@ public class HydrologyRegionalBankSupportTest { return ground; }; HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain)); - Map sampled = new HashMap<>(); + HydrologyRegionalTerrainRefiner.Samples sampled = refiner.new Samples(); for (int index = 0; index < 65536; index++) { - sampled.put(RiverFootprint.pack(index, 100000), ground); + sampled.sample(index, 100000); } - Method sample = HydrologyRegionalTerrainRefiner.class.getDeclaredMethod("sample", int.class, int.class, Map.class); - sample.setAccessible(true); - HydrologyTerrainSampler limited = (x, z) -> { - try { - return (HydrologyTerrainSample) sample.invoke(refiner, x, z, sampled); - } catch (ReflectiveOperationException failure) { - throw new AssertionError(failure); - } - }; + requests.set(0); - assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), limited)); + assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), sampled)); assertEquals(0, requests.get()); - assertEquals(65536, sampled.size()); } private static HydrologyRegionalHydraulics.HeadStation station(int incomingHead) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java index fa6b4c9ee..9279717e3 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java @@ -14,6 +14,34 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class HydrologyRegionalCacheTest { + @Test + public void sharedTerrainRetentionIsIndependentOfEachSearchBudget() { + AtomicInteger reads = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> { + reads.incrementAndGet(); + return HydrologyTerrainSample.openLand(70, 0D, "land"); + }; + HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); + HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner); + HydrologyRegionalTerrainRefiner.Samples first = refiner.new Samples(); + for (int x = 0; x < 65536; x++) { + assertNotNull(first.sample(x, 0)); + } + assertNull(first.sample(65536, 0)); + HydrologyRegionalTerrainRefiner.Samples second = refiner.new Samples(); + for (int x = 65536; x < 100000; x++) { + assertNotNull(second.sample(x, 0)); + } + assertEquals(100000, reads.get()); + for (int x = 0; x < 100000; x++) { + assertEquals(70, refiner.sample(x, 0).naturalHeight()); + } + assertEquals(100000, reads.get()); + refiner.clear(); + assertEquals(70, refiner.sample(0, 0).naturalHeight()); + assertEquals(100001, reads.get()); + } + @Test public void clearingTilesRefreshesChangedRegionalTerrain() throws Exception { AtomicInteger height = new AtomicInteger(70); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConcurrencyTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConcurrencyTest.java new file mode 100644 index 000000000..8ad2ee4c9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalConcurrencyTest.java @@ -0,0 +1,208 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; +import org.junit.Test; + +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyRegionalConcurrencyTest { + @Test(timeout = 15000) + public void collidingBasinKeysCompileWithoutHoldingCacheLocks() throws Exception { + HydrologyTileKey first = new HydrologyTileKey(0, 0); + HydrologyTileKey second = new HydrologyTileKey(1, -31); + assertEquals(first.hashCode(), second.hashCode()); + CountDownLatch started = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + HydrologyPlanner planner = planner(request -> { + started.countDown(); + await(release); + return land(70); + }); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future firstResult = workers.submit(() -> planner.regional.draft(first)); + Future secondResult = workers.submit(() -> planner.regional.draft(second)); + assertTrue("Independent basin compilation must overlap", started.await(5, TimeUnit.SECONDS)); + release.countDown(); + assertTrue(firstResult.get(5, TimeUnit.SECONDS).courses().isEmpty()); + assertTrue(secondResult.get(5, TimeUnit.SECONDS).courses().isEmpty()); + } finally { + release.countDown(); + workers.shutdownNow(); + } + } + + @Test(timeout = 15000) + public void concurrentRequestsShareOneBasinCompilation() throws Exception { + AtomicInteger compilations = new AtomicInteger(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch callers = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + HydrologyPlanner planner = planner(request -> { + compilations.incrementAndGet(); + started.countDown(); + await(release); + return land(70); + }); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future first = workers.submit(() -> { + callers.countDown(); + return planner.regional.draft(key); + }); + Future second = workers.submit(() -> { + callers.countDown(); + return planner.regional.draft(key); + }); + assertTrue(callers.await(5, TimeUnit.SECONDS)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + release.countDown(); + assertSame(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS)); + assertEquals(1, compilations.get()); + } finally { + release.countDown(); + workers.shutdownNow(); + } + } + + @Test(timeout = 15000) + public void clearDuringCompilationDoesNotPublishTheOldDraft() throws Exception { + AtomicInteger compilations = new AtomicInteger(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + HydrologyPlanner planner = planner(request -> { + if (compilations.incrementAndGet() == 1) { + started.countDown(); + await(release); + return land(70); + } + return land(85); + }); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future old = worker.submit(() -> planner.regional.draft(key)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + planner.regional.clear(); + HydrologyRegionalNetwork current = planner.regional.draft(key); + release.countDown(); + assertEquals(70, old.get(5, TimeUnit.SECONDS).diagnostics().getFirst().point().y()); + assertEquals(85, current.diagnostics().getFirst().point().y()); + assertSame(current, planner.regional.draft(key)); + assertEquals(2, compilations.get()); + } finally { + release.countDown(); + worker.shutdownNow(); + } + } + + @Test + public void failedCompilationCanBeRetried() { + AtomicInteger compilations = new AtomicInteger(); + IllegalStateException expected = new IllegalStateException("Terrain is unavailable"); + HydrologyPlanner planner = planner(request -> { + if (compilations.incrementAndGet() == 1) { + throw expected; + } + return land(70); + }); + HydrologyTileKey key = new HydrologyTileKey(0, 0); + assertSame(expected, assertThrows(IllegalStateException.class, () -> planner.regional.draft(key))); + HydrologyRegionalNetwork current = planner.regional.draft(key); + assertSame(current, planner.regional.draft(key)); + assertEquals(2, compilations.get()); + } + + @Test(timeout = 15000) + public void aBasinWindowDispatchesIndependentDraftsBeforeWaiting() throws Exception { + AtomicInteger compilations = new AtomicInteger(); + CountDownLatch started = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + HydrologyPlanner planner = planner(request -> { + compilations.incrementAndGet(); + started.countDown(); + await(release); + return land(70); + }); + SurfaceBounds bounds = new SurfaceBounds(0, 0, 15, 15); + ForkJoinPool pool = new ForkJoinPool(2); + try { + Future result = pool.submit(() -> planner.regional.coursesIn(bounds)); + assertTrue("A window must start more than one cold basin", started.await(5, TimeUnit.SECONDS)); + release.countDown(); + assertEquals(planner(request -> land(70)).regional.coursesIn(bounds), result.get(5, TimeUnit.SECONDS)); + assertEquals(4, compilations.get()); + } finally { + release.countDown(); + pool.shutdownNow(); + } + } + + @Test(timeout = 15000) + public void aBasinWindowCompletesOnASaturatedSingleWorkerPool() throws Exception { + HydrologyPlanner planner = planner(request -> land(70)); + SurfaceBounds bounds = new SurfaceBounds(0, 0, 15, 15); + HydrologyRegionalNetwork expected = planner(request -> land(70)).regional.coursesIn(bounds); + ForkJoinPool pool = new ForkJoinPool(1, ForkJoinPool.defaultForkJoinWorkerThreadFactory, + null, false, 1, 1, 1, null, 60, TimeUnit.SECONDS); + try { + assertEquals(expected, pool.submit(() -> planner.regional.coursesIn(bounds)).get(5, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + } + } + + private static HydrologyPlanner planner( + Function grids + ) { + HydrologyTerrainSampler terrain = (x, z) -> land(70); + return new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 8), terrain, + new ControlledRoutingSampler(grids), HydrologyGeometrySampler.deterministic(terrain), 0, + footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128)); + } + + private static HydrologyTerrainSample land(int height) { + return HydrologyTerrainSample.openLand(height, 0D, "land"); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting for basin compilation"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } + + private record ControlledRoutingSampler( + Function grids + ) implements HydrologyRoutingTerrainSampler { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()]; + Arrays.fill(samples, grids.apply(request)); + return samples; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + return NaturalClassification.LAND; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCorridorSamplingTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCorridorSamplingTest.java new file mode 100644 index 000000000..bf3bbbb6e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCorridorSamplingTest.java @@ -0,0 +1,135 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +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 HydrologyRegionalCorridorSamplingTest { + @Test + public void parallelSamplesPreserveCorridorOrderAcrossSegmentsAndBatches() throws Exception { + List points = List.of(new HydrologyPoint(-137, 70, -67), + new HydrologyPoint(-7, 70, 19), new HydrologyPoint(-7, 70, 19), + new HydrologyPoint(113, 70, 39), new HydrologyPoint(237, 70, 97)); + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70 + Math.floorMod(x + z, 9), 0D, "land"); + HydrologyRegionalRoute.Refinement serial = validate(points, terrain); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + HydrologyRegionalRoute.Refinement parallel = pool.submit(() -> validate(points, terrain)).get(10, TimeUnit.SECONDS); + assertNull(serial.rejection()); + assertEquals(points, serial.points()); + assertEquals(serial, parallel); + } + } + + @Test + public void independentColumnsRunTogetherWithinOneBoundedBatch() throws Exception { + CountDownLatch overlapping = new CountDownLatch(2); + AtomicInteger samples = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> { + samples.incrementAndGet(); + overlapping.countDown(); + try { + assertTrue(overlapping.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + return x == 0 ? null : HydrologyTerrainSample.openLand(70, 0D, "land"); + }; + try (ForkJoinPool pool = new ForkJoinPool(4)) { + HydrologyRegionalRoute.Refinement result = pool.submit(() -> validate(straight(), terrain)).get(10, TimeUnit.SECONDS); + assertEquals(HydrologyCandidateRejection.POLICY_EXCLUDED, result.rejection()); + assertEquals(new HydrologyPoint(0, 70, 0), result.failure()); + assertEquals(128, samples.get()); + } + } + + @Test + public void rejectionKeepsPrecedenceOverLaterSpeculativeFailure() throws Exception { + HydrologyTerrainSampler terrain = (x, z) -> { + if (x == 64) { + throw new IllegalStateException("Unavailable later sample"); + } + return x == 31 ? null : HydrologyTerrainSample.openLand(70, 0D, "land"); + }; + HydrologyRegionalRoute.Refinement serial = validate(straight(), terrain); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + HydrologyRegionalRoute.Refinement parallel = pool.submit(() -> validate(straight(), terrain)).get(10, TimeUnit.SECONDS); + assertEquals(serial, parallel); + assertEquals(new HydrologyPoint(31, 70, 0), parallel.failure()); + } + } + + @Test + public void consumedSampleFailurePropagatesInEncounterOrder() throws Exception { + IllegalStateException first = new IllegalStateException("First failed sample"); + IllegalStateException second = new IllegalStateException("Second failed sample"); + HydrologyTerrainSampler terrain = (x, z) -> { + if (x == 7) { + throw first; + } + if (x == 64) { + throw second; + } + return HydrologyTerrainSample.openLand(70, 0D, "land"); + }; + try (ForkJoinPool pool = new ForkJoinPool(4)) { + IllegalStateException actual = pool.submit(() -> assertThrows(IllegalStateException.class, + () -> validate(straight(), terrain))).get(10, TimeUnit.SECONDS); + assertSame(first, actual); + } + } + + @Test + public void oceanEntryAndRetracingRejectionsMatchSerialValidation() throws Exception { + HydrologyTerrainSampler ocean = (x, z) -> x >= 701 ? HydrologyTerrainSample.ocean(60, "ocean") + : HydrologyTerrainSample.openLand(70, 0D, "land"); + List retracing = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(193, 70, 0), + new HydrologyPoint(0, 70, 0), new HydrologyPoint(900, 70, 0)); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + HydrologyRegionalRoute.Refinement oceanSerial = validate(straight(), ocean); + HydrologyRegionalRoute.Refinement oceanParallel = pool.submit(() -> validate(straight(), ocean)).get(10, TimeUnit.SECONDS); + assertNull(oceanSerial.rejection()); + assertEquals(new HydrologyPoint(700, 63, 0), oceanSerial.oceanEntry().landward()); + assertEquals(oceanSerial, oceanParallel); + HydrologyRegionalRoute.Refinement retracingSerial = validate(retracing, ocean); + HydrologyRegionalRoute.Refinement retracingParallel = pool.submit(() -> validate(retracing, ocean)).get(10, TimeUnit.SECONDS); + assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, retracingSerial.rejection()); + assertEquals(retracingSerial, retracingParallel); + } + } + + private static List straight() { + return List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(900, 70, 0)); + } + + private static HydrologyRegionalRoute.Refinement validate(List points, + HydrologyTerrainSampler terrain) throws Exception { + HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); + HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner); + Method method = HydrologyRegionalRoute.class.getDeclaredMethod("validateCorridor", List.class, String.class, boolean.class); + method.setAccessible(true); + try { + return (HydrologyRegionalRoute.Refinement) method.invoke(route, points, "default", false); + } catch (InvocationTargetException failure) { + if (failure.getCause() instanceof RuntimeException runtime) { + throw runtime; + } + if (failure.getCause() instanceof Error error) { + throw error; + } + throw failure; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalDemandTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalDemandTest.java new file mode 100644 index 000000000..383c2bad9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalDemandTest.java @@ -0,0 +1,242 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.OptionalLong; +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.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +public class HydrologyRegionalDemandTest { + private static final HydrologyTileKey ORIGIN = new HydrologyTileKey(0, 0); + private static final SurfaceBounds EXCLUDED = new SurfaceBounds(1792, 1792, 1807, 1807); + + @Test(timeout = 120000) + public void excludedWindowDefersGeometryUntilDiagnosticsAndPreservesLaterRiver() { + HydrologyPlannerSettings settings = localizedSettings(); + HydrologyTerrainSampler terrain = localizedCoast(); + AtomicInteger eagerGeometry = new AtomicInteger(); + HydrologyPlanner eager = planner(settings, terrain, eagerGeometry); + HydrologyRegionalNetwork expected = eager.regional.draft(ORIGIN); + assertFalse("The excluded basin must contain a successful river", expected.courses().isEmpty()); + assertTrue(eagerGeometry.get() > 0); + AtomicInteger geometry = new AtomicInteger(); + HydrologyPlanner demand = planner(settings, terrain, geometry); + + assertEquals(HydrologyRegionalNetwork.EMPTY, demand.regional.coursesIn(EXCLUDED)); + assertEquals("A distant query must not build fine source geometry", 0, geometry.get()); + assertEquals(expected.diagnostics(), demand.regional.diagnosticsIn(EXCLUDED)); + assertTrue("Diagnostics must finish the complete source/outlet competition", geometry.get() > 0); + assertEquals(expected, demand.regional.draft(ORIGIN)); + + SurfaceBounds nearby = window(expected.courses().getFirst().segments().getFirst().start()); + HydrologyRegionalNetwork expectedWindow = eager.regional.coursesIn(nearby); + assertFalse(expectedWindow.courses().isEmpty()); + HydrologyRegionalNetwork actual = demand.regional.coursesIn(nearby); + assertEquals(expectedWindow, actual); + assertEquals(eager.regional.materialize(expectedWindow, nearby).columns(), + demand.regional.materialize(actual, nearby).columns()); + + demand.regional.clear(); + geometry.set(0); + assertEquals(HydrologyRegionalNetwork.EMPTY, demand.regional.coursesIn(EXCLUDED)); + assertEquals(0, geometry.get()); + assertEquals(expectedWindow, demand.regional.coursesIn(nearby)); + } + + @Test(timeout = 120000) + public void coldQueriesRetainCurvedRiverExtremesAndTheirFootprints() { + HydrologyPlannerSettings settings = localizedSettings(); + HydrologyTerrainSampler terrain = localizedCoast(); + HydrologyPlanner eager = planner(settings, terrain, new AtomicInteger()); + HydrologyRegionalNetwork full = eager.regional.draft(ORIGIN); + assertFalse(full.courses().isEmpty()); + ArrayList points = new ArrayList<>(); + for (HydraulicSegment segment : full.courses().getFirst().segments()) { + points.addAll(segment.centerline()); + } + HydrologyPoint minimumX = points.getFirst(); + HydrologyPoint maximumX = minimumX; + HydrologyPoint minimumZ = minimumX; + HydrologyPoint maximumZ = minimumX; + for (HydrologyPoint point : points) { + if (point.x() < minimumX.x()) { + minimumX = point; + } + if (point.x() > maximumX.x()) { + maximumX = point; + } + if (point.z() < minimumZ.z()) { + minimumZ = point; + } + if (point.z() > maximumZ.z()) { + maximumZ = point; + } + } + assertTrue("The route must contain a lateral bend", maximumZ.z() - minimumZ.z() > 4); + for (HydrologyPoint point : List.of(minimumX, maximumX, minimumZ, maximumZ)) { + SurfaceBounds bounds = window(point); + HydrologyRegionalNetwork expected = eager.regional.coursesIn(bounds); + assertFalse(expected.courses().isEmpty()); + HydrologyPlanner demand = planner(settings, terrain, new AtomicInteger()); + HydrologyRegionalNetwork actual = demand.regional.coursesIn(bounds); + assertEquals(expected, actual); + assertEquals(eager.regional.materialize(expected, bounds).columns(), + demand.regional.materialize(actual, bounds).columns()); + } + } + + @Test(timeout = 120000) + public void aLowerRankedNeighborStillRejectsAnOverlappingAcceptedCourse() { + HydrologyPlanner planner = planner(localizedSettings(), localizedCoast(), new AtomicInteger()); + HydrologyRegionalNetwork full = planner.regional.draft(ORIGIN); + assertFalse(full.courses().isEmpty()); + RiverCourse course = full.courses().getFirst(); + HydrologyTileKey neighbor = new HydrologyTileKey(0, -1); + assertTrue(Long.compareUnsigned(HydrologyHash.mix(71L, 0x524547424153494eL, 0, -1), + HydrologyHash.mix(71L, 0x524547424153494eL, 0, 0)) < 0); + HydrologyRegionalPlanner regional = spy(planner.regional); + RiverCourse blocker = copyIdentity(course); + assertTrue(regional.conflicts(course, List.of(blocker))); + doReturn(new HydrologyRegionalNetwork(List.of(), List.of(), List.of(), List.of(blocker), List.of(), List.of())) + .when(regional).draft(neighbor); + + HydrologyRegionalNetwork actual = regional.coursesIn(window(course.segments().getFirst().start())); + + verify(regional).draft(neighbor); + assertFalse(actual.courses().stream().anyMatch(candidate -> candidate.id() == course.id())); + } + + @Test(timeout = 120000) + public void coastalBasinsKeepBothReceivingSeasWhenQueriedAwayFromTheChannel() { + HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(true, 8); + HydrologyTerrainSampler terrain = (x, z) -> x < 128 || x >= 1664 + ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(65, 0D, "land"); + HydrologyPlanner eager = new HydrologyPlanner(19L, settings, terrain); + HydrologyRegionalNetwork expected = eager.regional.draft(ORIGIN); + assertEquals(HydrologyFeatureType.MOUTH, expected.courses().getFirst().segments().getFirst().type()); + assertEquals(2, expected.outlets().size()); + AtomicInteger geometry = new AtomicInteger(); + HydrologyGeometrySampler delegate = HydrologyGeometrySampler.deterministic(terrain); + HydrologyPlanner demand = new HydrologyPlanner(19L, settings, terrain, request -> { + geometry.incrementAndGet(); + return delegate.sample(request); + }, 0, footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128)); + + demand.regional.coursesIn(new SurfaceBounds(1536, 1536, 1551, 1551)); + + assertTrue("A seeded coastal channel must remain unpruned", geometry.get() > 0); + assertEquals(expected, demand.regional.draft(ORIGIN)); + assertEquals(expected.diagnostics(), demand.regional.diagnosticsIn(EXCLUDED)); + } + + @Test(timeout = 15000) + public void clearSeparatesConcurrentCoarseAndCompleteDraftPublications() throws Exception { + AtomicInteger grids = new AtomicInteger(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(85, 0D, "land"); + HydrologyRoutingTerrainSampler routing = new HydrologyRoutingTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + int height = grids.incrementAndGet() == 1 ? 70 : 85; + if (height == 70) { + started.countDown(); + await(release); + } + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()]; + Arrays.fill(samples, HydrologyTerrainSample.openLand(height, 0D, "land")); + return samples; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + return NaturalClassification.LAND; + } + }; + HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 8), terrain, + routing, HydrologyGeometrySampler.deterministic(terrain), 0, + footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128)); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future old = worker.submit(() -> planner.regional.coursesIn(new SurfaceBounds(1024, 1024, 1039, 1039))); + assertTrue(started.await(5, TimeUnit.SECONDS)); + planner.regional.clear(); + HydrologyRegionalNetwork current = planner.regional.draft(ORIGIN); + assertEquals(85, current.diagnostics().getFirst().point().y()); + release.countDown(); + assertEquals(HydrologyRegionalNetwork.EMPTY, old.get(5, TimeUnit.SECONDS)); + assertSame(current, planner.regional.draft(ORIGIN)); + assertEquals(current.diagnostics(), planner.regional.diagnosticsIn(new SurfaceBounds(1024, 1024, 1039, 1039))); + assertEquals(2, grids.get()); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static RiverCourse copyIdentity(RiverCourse course) { + long id = course.id() + 1L; + ArrayList segments = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + segments.add(new HydraulicSegment(segment.id(), id, segment.type(), segment.upstreamHeadY(), + segment.downstreamHeadY(), segment.width(), segment.depth(), segment.fallingFluid(), + segment.receivingPool(), segment.centerline(), segment.channelProfile())); + } + return new RiverCourse(id, course.type(), course.sourceNodeId(), OptionalLong.of(course.outletId().orElseThrow() + 1L), + course.profileKey(), course.discharge(), course.drainageEdges(), segments); + } + + private static SurfaceBounds window(HydrologyPoint point) { + return new SurfaceBounds(point.x() - 32, point.z() - 32, point.x() + 32, point.z() + 32); + } + + private static HydrologyPlanner planner(HydrologyPlannerSettings settings, HydrologyTerrainSampler terrain, AtomicInteger geometry) { + HydrologyGeometrySampler delegate = HydrologyGeometrySampler.deterministic(terrain); + return new HydrologyPlanner(71L, settings, terrain, request -> { + geometry.incrementAndGet(); + return delegate.sample(request); + }, 0, footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128)); + } + + private static HydrologyTerrainSampler localizedCoast() { + return (x, z) -> x < 128 ? HydrologyTerrainSample.ocean(60, "ocean") + : HydrologyTerrainSample.openLand(x >= 768 && x <= 1024 && z >= 0 && z <= 256 ? 70 : 66, 0D, "land"); + } + + private static HydrologyPlannerSettings localizedSettings() { + HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 8); + HydrologyPlannerSettings.Surface surface = base.surface(); + HydrologyPlannerSettings.Source source = new HydrologyPlannerSettings.Source(true, 0D, 70, 0, 0, 128); + return new HydrologyPlannerSettings(base.seaLevel(), base.routing(), + new HydrologyPlannerSettings.Surface(surface.enabled(), source, surface.minimumWidth(), surface.maximumWidth(), + surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()), + base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), + base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds()); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalFallsTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalFallsTest.java index af3a54907..8405a6025 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalFallsTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalFallsTest.java @@ -6,10 +6,13 @@ import art.arcane.iris.generation.hydrology.cave.CaveVoxelView; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.Map; import java.util.OptionalLong; import java.util.concurrent.atomic.AtomicInteger; @@ -23,6 +26,52 @@ public final class HydrologyRegionalFallsTest { private static final HydrologyPlannerSettings SETTINGS = HydrologyPlannerSettings.defaults(); private static final HydrologyTerrainSampler TERRAIN = (x, z) -> HydrologyTerrainSample.openLand(89, 0D, "parent"); + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void persistedMixedSurfaceOwnershipRestoresWithoutTerrainPlanning() throws Exception { + HydrologyPlanner planner = new HydrologyPlanner(1L, SETTINGS, TERRAIN, solid(null)); + RiverCourse regional = course(); + HydrologyRegionalNetwork network = metadata(regional, List.of()); + List plans = new HydrologyRegionalFalls(planner, regional) + .validate(network, new ArrayList<>()); + network = metadata(regional, plans); + SurfaceBounds bounds = new SurfaceBounds(-2, -2, 1, 2); + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN, planner.geometrySampler); + compiler.seedRegionalSurface(network, bounds); + HydrologyCrossTileResolver.MaterializedHydrology materialized = new HydrologyCrossTileResolver(planner) + .materializeFinalHydrology(new HydrologyCaveCourseFilter.Result(network.nodes(), network.edges(), + network.outlets(), network.courses(), plans), new ArrayList<>(), compiler, true); + HydrologyCaveCourseFilter.Result clipped = HydrologyCrossTileResolver.clipRegionalPlans(materialized.result(), network, bounds); + RiverCourse local = new RiverCourse(101L, RiverCourseType.SURFACE, regional.sourceNodeId(), regional.outletId(), + "water", 1, List.of(), List.of(new HydraulicSegment(102L, 101L, HydrologyFeatureType.SURFACE_POOL, + 72, 72, 4, 2, false, false, List.of(new HydrologyPoint(0, 72, 0), new HydrologyPoint(1, 72, 0)), + HydraulicChannelProfile.uniform(4, 2)))); + HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(), + SETTINGS.routing().tileSize(), network.nodes(), network.edges(), network.outlets(), + List.of(regional, local), Set.of(regional.id()), clipped.cavePlans(), List.of(), materialized.footprint()); + StudioHydrologyTileStore store = new StudioHydrologyTileStore(temporaryFolder.newFolder().toPath(), + new HydrologyTileCache.SharedCacheScope("regional-ownership", 1L, 128, "overworld", SETTINGS.fingerprint()), + SETTINGS.routing().tileSize()); + store.save(original); + HydrologyTile restored = store.load(original.key()).orElseThrow(); + assertEquals(original, restored); + assertFalse(restored.cavePlans().isEmpty()); + HydrologyPlanner fresh = new HydrologyPlanner(1L, SETTINGS, (x, z) -> { + throw new AssertionError("Restoring final ownership must not sample terrain"); + }); + + fresh.reuseResolvedTile(restored); + + HydrologyCaveCourseFilter.Result owned = fresh.resolvedOwners.getIfPresent(restored.key()).draft().result(); + assertEquals(List.of(local), owned.courses()); + assertTrue(owned.cavePlans().isEmpty()); + assertEquals(restored.nodes(), owned.nodes()); + assertEquals(restored.edges(), owned.edges()); + assertEquals(restored.outlets(), owned.outlets()); + } + @Test public void fullContainmentProofIsCachedAndOnlyFinalTileActionsAreClipped() { AtomicInteger validations = new AtomicInteger(); @@ -55,7 +104,7 @@ public final class HydrologyRegionalFallsTest { assertEquals(full.actions().get(entry.getKey()), entry.getValue()); } HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(), 512, - clipped.nodes(), clipped.edges(), clipped.outlets(), clipped.courses(), clipped.cavePlans(), List.of(), materialized.footprint()); + clipped.nodes(), clipped.edges(), clipped.outlets(), clipped.courses(), Set.of(clipped.courses().getFirst().id()), clipped.cavePlans(), List.of(), materialized.footprint()); assertEquals(1, tile.courses().size()); assertEquals(full, regional.cavePlans().getFirst()); } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlannerTest.java index ce04254ec..d0259e01c 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlannerTest.java @@ -4,19 +4,46 @@ import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; import org.junit.Test; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Set; import java.util.HashSet; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class HydrologyRegionalPlannerTest { + @Test(timeout = 120000) + public void reusedRegionalTilePreservesAdjacentPlanningWithoutSamplingDuringRestore() { + HydrologyPlannerSettings settings = settings(false, 8); + HydrologyTerrainSampler terrain = coast(); + HydrologyPlanner baseline = new HydrologyPlanner(71L, settings, terrain); + RiverCourse regional = baseline.regional.draft(new HydrologyTileKey(0, 0)).courses().getFirst(); + HydrologyTileKey key = crossedTiles(regional, settings.routing().tileSize()).stream() + .min(Comparator.comparingInt(HydrologyTileKey::tileX).thenComparingInt(HydrologyTileKey::tileZ)) + .orElseThrow(); + HydrologyTile original = baseline.plan(key); + assertTrue(original.regionalCourseIds().contains(regional.id())); + HydrologyTileKey adjacent = new HydrologyTileKey(key.tileX(), key.tileZ() + 1); + HydrologyTile expected = baseline.plan(adjacent); + AtomicBoolean planning = new AtomicBoolean(); + HydrologyPlanner restored = new HydrologyPlanner(71L, settings, (x, z) -> { + assertTrue("Restoring final ownership must not sample terrain", planning.get()); + return terrain.sample(x, z); + }); + + restored.reuseResolvedTile(original); + planning.set(true); + + assertEquals(expected, restored.plan(adjacent)); + } + @Test(timeout = 120000) public void oneRegionalCourseCrossesSeveralGenerationTilesWithStableHydraulics() { HydrologyPlanner planner = new HydrologyPlanner(71L, settings(false, 8), coast()); @@ -87,29 +114,48 @@ public class HydrologyRegionalPlannerTest { planner.regional.clear(); assertEquals(first.diagnostics(), planner.regional.draft(key).diagnostics()); HydrologyRegionalNetwork window = planner.regional.coursesIn(new SurfaceBounds(0, 0, 511, 511)); - assertEquals(first.diagnostics(), window.diagnostics()); + assertTrue(window.diagnostics().isEmpty()); + assertEquals(first.diagnostics(), planner.regional.diagnosticsIn(new SurfaceBounds(0, 0, 511, 511))); } @Test(timeout = 120000) public void regionalCacheEvictionAndConcurrentRequestsKeepTheSameCourse() throws Exception { - HydrologyPlanner planner = new HydrologyPlanner(71L, settings(false, 1), coast()); + HydrologyPlannerSettings settings = settings(false, 1); + HydrologyTerrainSampler terrain = coast(); + Set samplingThreads = ConcurrentHashMap.newKeySet(); + HydrologyPlanner planner = new HydrologyPlanner(71L, settings, (x, z) -> { + samplingThreads.add(Thread.currentThread()); + return terrain.sample(x, z); + }); HydrologyTileKey key = new HydrologyTileKey(0, 0); HydrologyRegionalNetwork first = planner.regional.draft(key); assertFalse(first.courses().isEmpty()); + assertTrue(first.courses().stream().allMatch(course -> course.type() == RiverCourseType.SURFACE)); + SurfaceBounds bounds = networkBounds(first).expand(settings.publicationRadius()); + RiverFootprint footprint = planner.regional.materialize(first, bounds); + assertFalse(footprint.columns().isEmpty()); + assertTrue(footprint.columns().values().stream().anyMatch(column -> column.layers().stream().anyMatch(HydrologyColumnLayer::fluidOwned))); planner.regional.draft(new HydrologyTileKey(0, 1)); assertEquals(first, planner.regional.draft(key)); planner.regional.clear(); - ExecutorService workers = Executors.newFixedThreadPool(3); + samplingThreads.clear(); + ForkJoinPool workers = new ForkJoinPool(4); try { ArrayList> futures = new ArrayList<>(); for (int index = 0; index < 3; index++) { futures.add(CompletableFuture.supplyAsync(() -> planner.regional.draft(key), workers)); } + HydrologyRegionalNetwork parallel = futures.getFirst().get(90, TimeUnit.SECONDS); for (CompletableFuture future : futures) { assertEquals(first, future.get(90, TimeUnit.SECONDS)); } + assertTrue("A cold successful basin must sample on multiple workers: " + samplingThreads, samplingThreads.size() > 1); + assertNetworkRawBits(first, parallel); + RiverFootprint actual = workers.submit(() -> planner.regional.materialize(parallel, bounds)).get(30, TimeUnit.SECONDS); + assertEquals(footprint.columns(), actual.columns()); } finally { workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); } } @@ -126,6 +172,48 @@ public class HydrologyRegionalPlannerTest { } } + private static SurfaceBounds networkBounds(HydrologyRegionalNetwork network) { + int minimumX = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (RiverCourse course : network.courses()) { + for (HydraulicSegment segment : course.segments()) { + for (HydrologyPoint point : segment.centerline()) { + minimumX = Math.min(minimumX, point.x()); + minimumZ = Math.min(minimumZ, point.z()); + maximumX = Math.max(maximumX, point.x()); + maximumZ = Math.max(maximumZ, point.z()); + } + } + } + return new SurfaceBounds(minimumX, minimumZ, maximumX, maximumZ); + } + + private static void assertNetworkRawBits(HydrologyRegionalNetwork expected, HydrologyRegionalNetwork actual) { + assertEquals(expected, actual); + for (int index = 0; index < expected.nodes().size(); index++) { + assertEquals(Double.doubleToRawLongBits(expected.nodes().get(index).potential()), + Double.doubleToRawLongBits(actual.nodes().get(index).potential())); + } + for (int index = 0; index < expected.edges().size(); index++) { + assertEquals(Double.doubleToRawLongBits(expected.edges().get(index).cost()), + Double.doubleToRawLongBits(actual.edges().get(index).cost())); + } + for (int courseIndex = 0; courseIndex < expected.courses().size(); courseIndex++) { + List expectedSegments = expected.courses().get(courseIndex).segments(); + List actualSegments = actual.courses().get(courseIndex).segments(); + for (int segmentIndex = 0; segmentIndex < expectedSegments.size(); segmentIndex++) { + HydraulicChannelProfile first = expectedSegments.get(segmentIndex).channelProfile(); + HydraulicChannelProfile second = actualSegments.get(segmentIndex).channelProfile(); + for (int station = 0; station < first.size(); station++) { + assertEquals(Double.doubleToRawLongBits(first.widthAt(station)), Double.doubleToRawLongBits(second.widthAt(station))); + assertEquals(Double.doubleToRawLongBits(first.depthAt(station)), Double.doubleToRawLongBits(second.depthAt(station))); + } + } + } + } + private static void assertGraphEndpoints(HydrologyRegionalNetwork network, RiverCourse course) { DrainageNode source = network.nodes().stream() .filter(node -> node.id() == course.sourceNodeId().orElseThrow()).findFirst().orElseThrow(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSamplingTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSamplingTest.java new file mode 100644 index 000000000..d05eca45e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSamplingTest.java @@ -0,0 +1,272 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.surface.SurfaceBankSupport; +import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +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.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyRegionalSamplingTest { + private static final HydrologyPlannerSettings SETTINGS = HydrologyRegionalPlannerTest.settings(false, 1); + + @Test + public void bankSamplesPreserveSamplingOrderAndResultsForRotatedWidths() throws Exception { + HydrologyTerrainSampler terrain = (x, z) -> x == 17 ? null + : HydrologyTerrainSample.openLand(70 + Math.floorMod(x + z, 13), 0D, "land"); + SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel()); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + for (double angle : new double[]{0D, 0.37D, 0.7853981633974483D}) { + for (double width : new double[]{2D, 5D, 16D, 48D}) { + SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(11, -7, + StrictMath.cos(angle), StrictMath.sin(angle), width); + RecordingSampler serial = new RecordingSampler(terrain); + SurfaceBankSupport.CrossSection cross = support.crossSection(serial, station, true); + SurfaceBankSupport.Perimeter perimeter = support.perimeter(serial, station, 80); + RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples()); + pool.submit(() -> { + assertEquals(cross, support.crossSection(parallel, station, true)); + assertEquals(perimeter, support.perimeter(parallel, station, 80)); + }).get(10, TimeUnit.SECONDS); + assertEquals(serial.requests, parallel.requests); + } + } + } + } + + @Test + public void searchLimitCountsNullsAndRetainsAlreadyAdmittedCoordinates() { + AtomicInteger reads = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> { + reads.incrementAndGet(); + return x == 0 ? null : HydrologyTerrainSample.openLand(70, 0D, "land"); + }; + HydrologyRegionalTerrainRefiner refiner = refiner(terrain); + refiner.sample(100000, 0); + HydrologyRegionalTerrainRefiner.Samples samples = refiner.new Samples(); + assertNull(samples.sample(0, 0)); + for (int x = 1; x < 65536; x++) { + assertEquals(70, samples.sample(x, 0).naturalHeight()); + } + assertNull(samples.sample(0, 0)); + assertEquals(70, samples.sample(1, 0).naturalHeight()); + assertNull(samples.sample(65536, 0)); + assertNull(samples.sample(100000, 0)); + assertEquals(65537, reads.get()); + } + + @Test + public void failedRequiredSampleRetainsItsExceptionAndDoesNotConsumeAdmission() { + AtomicInteger failedReads = new AtomicInteger(); + IllegalStateException failure = new IllegalStateException("Required terrain unavailable"); + HydrologyTerrainSampler terrain = (x, z) -> { + if (x == -1 && failedReads.incrementAndGet() == 1) { + throw failure; + } + return HydrologyTerrainSample.openLand(70, 0D, "land"); + }; + HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples(); + for (int x = 0; x < 65535; x++) { + samples.sample(x, 0); + } + assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(-1, 0))); + assertEquals(70, samples.sample(-1, 0).naturalHeight()); + assertEquals(70, samples.sample(0, 0).naturalHeight()); + assertNull(samples.sample(-2, 0)); + assertEquals(2, failedReads.get()); + } + + @Test + public void unavailableBankStopsBeforeALaterProviderFailure() throws Exception { + IllegalStateException failure = new IllegalStateException("Later bank sample"); + HydrologyTerrainSampler terrain = (x, z) -> { + if (z == 0) { + throw failure; + } + return z == -5 ? null : HydrologyTerrainSample.openLand(70, 0D, "land"); + }; + SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel()); + SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(0, 0, 1D, 0D, 4D); + SurfaceBankSupport.CrossSection expected = support.crossSection(terrain, station, true); + HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples(); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + assertEquals(expected, pool.submit(() -> support.crossSection(samples, station, true)).get(10, TimeUnit.SECONDS)); + } + assertTrue(expected.blocked()); + assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(0, 0))); + } + + @Test + public void hydraulicBudgetAndLazyWidthsPreserveConsumptionOrder() throws Exception { + HydrologyPlannerSettings.Surface surface = SETTINGS.surface(); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings(SETTINGS.seaLevel(), SETTINGS.routing(), + new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), 256, + surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()), + SETTINGS.hydraulics(), SETTINGS.underground(), SETTINGS.outlets(), SETTINGS.geometry(), SETTINGS.deepFluids(), + SETTINGS.surfacePools(), SETTINGS.widestShoreBiomeWidth(), SETTINGS.seaCaves(), SETTINGS.surfacePolicyBounds()); + HydrologyTerrainSample ground = new HydrologyTerrainSample(65, 0D, false, false, 0, 0, + true, true, true, false, true, false, 0D, 1D, 1D, 0.75D, 1D, 1D, 1D, 1D, + "land", "land", "land", "land", "land", "land", List.of("default"), List.of(), + Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT); + HydrologyTerrainSampler terrain = (x, z) -> x == 133 ? null : x == 132 && z == 0 ? ground + : HydrologyTerrainSample.openLand(80, 0D, "land"); + HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(settings); + HydrologyRegionalHydraulics.HeadStation station = new HydrologyRegionalHydraulics.HeadStation( + 132, 0, 1D, 0D, terrain.sample(132, 0), 80); + RecordingSampler serial = new RecordingSampler(terrain); + int expected = hydraulics.supportedHead(station, serial); + RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples()); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + assertEquals(expected, pool.submit(() -> hydraulics.supportedHead(station, parallel)).get(10, TimeUnit.SECONDS).intValue()); + } + assertEquals(serial.requests, parallel.requests); + assertTrue(serial.requests.size() > 2048); + } + + @Test + public void routeRefinementMatchesSerialTerrainAndHydraulics() throws Exception { + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( + x >= 96 && x <= 160 && Math.abs(z) < 32 ? 95 : 70, 0D, "land"); + List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)); + List expected = refiner(terrain).refine(guide, "default", false, terrain); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + List actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain)) + .get(15, TimeUnit.SECONDS); + assertFalse(expected.isEmpty()); + assertEquals(expected, actual); + } + } + + @Test + public void coastalCenterlineRetainsNegativeAndFractionalTraversalOrder() throws Exception { + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(64, 0D, "land"); + List guide = List.of(new HydrologyPoint(-173, 64, -117), new HydrologyPoint(174, 64, 138)); + List expected = refiner(terrain).refine(guide, "default", true, terrain); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + assertFalse(expected.isEmpty()); + assertEquals(expected, pool.submit(() -> refiner(terrain).refine(guide, "default", true, terrain)) + .get(15, TimeUnit.SECONDS)); + } + } + + @Test + public void rejectedTraversalDoesNotReadALaterProviderFailure() throws Exception { + IllegalStateException failure = new IllegalStateException("Later centerline sample"); + AtomicInteger failures = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> { + if (x == 32 && z == 0) { + failures.incrementAndGet(); + throw failure; + } + return z == 0 && (x == 0 || x == 128) + ? HydrologyTerrainSample.openLand(70, 0D, "land") : null; + }; + List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0)); + assertTrue(refiner(terrain).refine(guide, "default", false, terrain).isEmpty()); + assertEquals(0, failures.get()); + try (ForkJoinPool pool = new ForkJoinPool(4)) { + assertTrue(pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain)) + .get(15, TimeUnit.SECONDS).isEmpty()); + assertEquals(0, failures.get()); + } + } + + @Test + public void clearingAnActiveReachCannotPublishIntoTheNewCache() throws Exception { + AtomicBoolean changed = new AtomicBoolean(); + AtomicBoolean blocked = new AtomicBoolean(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + HydrologyTerrainSampler terrain = (x, z) -> { + boolean elevated = changed.get(); + if (x == 64 && z == 0 && blocked.compareAndSet(false, true)) { + started.countDown(); + await(release); + } + return HydrologyTerrainSample.openLand(elevated && x == 64 && z == 0 ? 100 : 70, 0D, "land"); + }; + HydrologyRegionalTerrainRefiner refiner = refiner(terrain); + List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)); + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + Future> previous = executor.submit(() -> refiner.refine(guide, "default", false, terrain)); + try { + assertTrue(started.await(5, TimeUnit.SECONDS)); + changed.set(true); + refiner.clear(); + assertEquals(100, refiner.sample(64, 0).naturalHeight()); + } finally { + release.countDown(); + } + previous.get(15, TimeUnit.SECONDS); + assertEquals(refiner(terrain).refine(guide, "default", false, terrain), + refiner.refine(guide, "default", false, terrain)); + assertEquals(100, refiner.sample(64, 0).naturalHeight()); + } + } + + @Test + public void signedCoordinateTiesRemainStableInEveryDirection() throws Exception { + try (ForkJoinPool pool = new ForkJoinPool(8)) { + for (int[] direction : new int[][]{{1, 0}, {0, 1}, {-1, 0}, {0, -1}}) { + HydrologyTerrainSampler terrain = (x, z) -> { + int along = x * direction[0] + z * direction[1]; + int across = -x * direction[1] + z * direction[0]; + return HydrologyTerrainSample.openLand( + along >= 96 && along <= 160 && Math.abs(across) < 32 ? 95 : 70, 0D, "land"); + }; + List guide = List.of(new HydrologyPoint(0, 70, 0), + new HydrologyPoint(256 * direction[0], 70, 256 * direction[1])); + List expected = refiner(terrain).refine(guide, "default", false, terrain); + List actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain)) + .get(15, TimeUnit.SECONDS); + assertFalse(expected.isEmpty()); + assertEquals(expected, actual); + } + } + } + + private static HydrologyRegionalTerrainRefiner refiner(HydrologyTerrainSampler terrain) { + return new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain)); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } + + private static final class RecordingSampler implements HydrologyTerrainSampler { + private final HydrologyTerrainSampler sampler; + private final List requests = new ArrayList<>(); + + private RecordingSampler(HydrologyTerrainSampler sampler) { + this.sampler = sampler; + } + + @Override + public HydrologyTerrainSample sample(int x, int z) { + requests.add(RiverFootprint.pack(x, z)); + return sampler.sample(x, z); + } + + + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalShorelineTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalShorelineTest.java new file mode 100644 index 000000000..7cd34889f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalShorelineTest.java @@ -0,0 +1,163 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +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.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyRegionalShorelineTest { + @Test(timeout = 15000) + public void successfulBoundariesMatchSerialCandidatesInGridOrderAcrossWindows() throws Exception { + HydrologySampledGrid grid = grid(); + HydrologyPlanner serial = planner(HydrologyRegionalShorelineTest::terrain); + List expected = serial.outletPlanner.oceanOutletCandidates(grid, true); + CountDownLatch overlapping = new CountDownLatch(4); + Set threads = ConcurrentHashMap.newKeySet(); + HydrologyPlanner parallel = planner((x, z) -> { + if (x == 1 && z < 64) { + threads.add(Thread.currentThread().threadId()); + overlapping.countDown(); + await(overlapping); + } + return terrain(x, z); + }); + ForkJoinPool pool = new ForkJoinPool(4); + try { + List actual = pool.submit(() -> parallel.outletPlanner.regionalOceanOutletCandidates(grid)).get(10, TimeUnit.SECONDS); + assertEquals(32, expected.size()); + assertEquals(expected, actual); + assertEquals(4, threads.size()); + for (int index = 0; index < actual.size(); index++) { + assertEquals(index * 32, actual.get(index).landIndex()); + assertEquals(new HydrologyPoint(9, 63, index * 16), actual.get(index).outlet().landwardPoint()); + assertEquals(new HydrologyPoint(10, 63, index * 16), actual.get(index).outlet().connectionPoint()); + } + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void unavailableShorelinesKeepTheSameAcceptedOrder() throws Exception { + HydrologyTerrainSampler sampler = (x, z) -> x == 5 && z % 48 == 0 ? null : terrain(x, z); + HydrologySampledGrid grid = grid(); + List expected = planner(sampler).outletPlanner.oceanOutletCandidates(grid, true); + HydrologyPlanner parallel = planner(sampler); + ForkJoinPool pool = new ForkJoinPool(4); + try { + List actual = pool.submit(() -> parallel.outletPlanner.regionalOceanOutletCandidates(grid)).get(10, TimeUnit.SECONDS); + assertEquals(21, expected.size()); + assertEquals(expected, actual); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void firstFailureDrainsStartedWorkAndDoesNotStartTheNextWindow() throws Exception { + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean finished = new AtomicBoolean(); + AtomicBoolean restored = new AtomicBoolean(); + AtomicInteger laterWindow = new AtomicInteger(); + IllegalStateException expected = new IllegalStateException("First shoreline provider failure"); + IllegalArgumentException laterFailure = new IllegalArgumentException("Later shoreline provider failure"); + HydrologyPlanner planner = planner((x, z) -> { + if (x == 1 && z == 0) { + await(blocked); + failed.countDown(); + throw expected; + } + if (x == 1 && z == 16) { + blocked.countDown(); + await(release); + finished.set(true); + } + if (x == 1 && z == 32) { + throw laterFailure; + } + if (x == 1 && z >= 64) { + laterWindow.incrementAndGet(); + } + return terrain(x, z); + }); + HydrologySampledGrid grid = grid(); + ForkJoinPool pool = new ForkJoinPool(4); + try { + Future> pending = pool.submit(() -> { + HydrologyPlanner.PlanningSamples previous = new HydrologyPlanner.PlanningSamples(); + planner.planningSamples.set(previous); + try { + return planner.outletPlanner.regionalOceanOutletCandidates(grid); + } finally { + restored.set(planner.planningSamples.get() == previous); + planner.planningSamples.remove(); + } + }); + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + assertTrue(failed.await(5, TimeUnit.SECONDS)); + assertFalse(pending.isDone()); + release.countDown(); + ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS)); + Throwable cause = failure.getCause(); + while (cause.getCause() != null) { + cause = cause.getCause(); + } + assertSame(expected, cause); + assertEquals(List.of(laterFailure), List.of(expected.getSuppressed())); + assertTrue(finished.get()); + assertTrue(restored.get()); + assertEquals(0, laterWindow.get()); + } finally { + release.countDown(); + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static HydrologyPlanner planner(HydrologyTerrainSampler sampler) { + return new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 1), sampler); + } + + private static HydrologyTerrainSample terrain(int x, int z) { + return x >= 10 ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(66, 0D, "land"); + } + + private static HydrologySampledGrid grid() { + ArrayList nodes = new ArrayList<>(1024); + for (int z = 0; z < 32; z++) { + for (int x = 0; x < 32; x++) { + int index = z * 32 + x; + nodes.add(new HydrologyGridNode(index, x, z, x * 16, z * 16, index + 1L, terrain(x * 16, z * 16))); + } + } + return new HydrologySampledGrid(0, 0, 0, 0, 512, 32, 16, nodes); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java new file mode 100644 index 000000000..db010c5d4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java @@ -0,0 +1,246 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +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.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class HydrologyRegionalSourceParallelismTest { + @Test(timeout = 15000) + public void acceptsOutletsInSourceOrderWhenTheMiddleOfAWindowSucceeds() throws Exception { + Fixture fixture = fixture(4, source -> source == 4 || source == 1 ? accepted(source) : rejected(source)); + HydrologyRegionalNetwork network = run(fixture); + + assertEquals(List.of(104L, 101L), network.courses().stream().map(RiverCourse::id).toList()); + assertEquals(List.of(0L), network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList()); + } + + @Test(timeout = 15000) + public void skipsFailureFromAnOutletAlreadyAcceptedAndDrainsItsStartedWork() throws Exception { + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean completed = new AtomicBoolean(); + Fixture fixture = fixture(4, source -> { + if (source == 5) { + blocked.countDown(); + await(release); + completed.set(true); + throw new IllegalStateException("Unused outlet provider failed"); + } + return source == 4 ? accepted(source) : rejected(source); + }); + ForkJoinPool pool = new ForkJoinPool(4); + try { + Future pending = pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0))); + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + assertFalse(pending.isDone()); + release.countDown(); + HydrologyRegionalNetwork network = pending.get(5, TimeUnit.SECONDS); + + assertTrue(completed.get()); + assertEquals(List.of(104L), network.courses().stream().map(RiverCourse::id).toList()); + assertEquals(List.of(0L, 1L, 2L, 3L), network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList()); + } finally { + release.countDown(); + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void acceptedOutletSkipsLeaveExactlyTwentyFourActualSourceTrials() throws Exception { + AtomicInteger evaluations = new AtomicInteger(); + Fixture fixture = fixture(32, source -> { + evaluations.incrementAndGet(); + return source == 0 ? accepted(source) : rejected(source); + }); + HydrologyRegionalNetwork network = run(fixture); + + assertEquals(List.of(100L), network.courses().stream().map(RiverCourse::id).toList()); + assertEquals(23, network.diagnostics().size()); + assertEquals(23L, network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).distinct().count()); + assertTrue(network.diagnostics().stream().allMatch(diagnostic -> diagnostic.id() >= 4L)); + assertTrue("The fixture must include work skipped after outlet acceptance", evaluations.get() > 24); + } + + @Test(timeout = 15000) + public void requiredSourceFailureWaitsForAllStartedTrialProviders() throws Exception { + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean completed = new AtomicBoolean(); + IllegalStateException expected = new IllegalStateException("Required source provider failed"); + Fixture fixture = fixture(4, source -> { + if (source == 1) { + failed.countDown(); + throw expected; + } + if (source == 5) { + blocked.countDown(); + await(release); + completed.set(true); + } + return rejected(source); + }); + ForkJoinPool pool = new ForkJoinPool(4); + try { + Future pending = pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0))); + assertTrue(failed.await(5, TimeUnit.SECONDS)); + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + assertFalse(pending.isDone()); + release.countDown(); + ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS)); + Throwable cause = failure.getCause(); + while (cause.getCause() != null) { + cause = cause.getCause(); + } + assertSame(expected, cause); + assertTrue(completed.get()); + } finally { + release.countDown(); + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static HydrologyRegionalNetwork run(Fixture fixture) throws Exception { + ForkJoinPool pool = new ForkJoinPool(4); + try { + return pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0))).get(10, TimeUnit.SECONDS); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static Fixture fixture(int secondCatchmentSize, SourceProvider provider) throws Exception { + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"); + HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 1); + HydrologyPlannerSettings.Routing routing = base.routing(); + HydrologyPlannerSettings.Regional regional = routing.regional(); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), + new HydrologyPlannerSettings.Routing(routing.tileSize(), routing.sampleSpacing(), routing.maximumRouteNodes(), + routing.maximumRouteLength(), routing.minimumSurfaceCourseLength(), routing.minimumUndergroundCourseLength(), + routing.valleyPreference(), routing.uphillPenalty(), routing.slopePenalty(), routing.confluenceAttraction(), + routing.lengthPreference(), routing.tributaries(), new HydrologyPlannerSettings.Regional(true, + regional.sampleSpacing(), regional.minimumLength(), 2, regional.maximumCachedBasins(), + regional.maximumCachedStations(), false, regional.coastalChannelChance(), regional.maximumCoastalIncision())), + base.surface(), base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), + base.surfacePools(), base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds()); + HydrologyPlanner planner = new HydrologyPlanner(71L, settings, terrain); + ArrayList outlets = new ArrayList<>(List.of(outlet(0), outlet(1))); + HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class); + when(outletPlanner.regionalOceanOutletCandidates(any())).thenAnswer(invocation -> new ArrayList<>(outlets)); + replace(planner, "outletPlanner", outletPlanner); + Map sources = new HashMap<>(); + HydrologyRegionalGraph graph = mock(HydrologyRegionalGraph.class); + when(graph.route(any(), anyList(), anyBoolean())).thenAnswer(invocation -> { + HydrologySampledGrid grid = invocation.getArgument(0); + HydrologyRegionalGraph.Label[] labels = new HydrologyRegionalGraph.Label[grid.nodes().size()]; + int[] contributions = new int[grid.nodes().size()]; + Arrays.fill(contributions, 1); + int ordinal = 0; + for (HydrologyGridNode node : grid.nodes()) { + if (!grid.owns(node.x(), node.z()) || ordinal >= secondCatchmentSize + 4) { + continue; + } + int root = ordinal < 4 ? 0 : 1; + int local = ordinal < 4 ? ordinal : ordinal - 4; + double length = ordinal < 4 || secondCatchmentSize == 4 ? 10000D - local * 2000D : 10000D - local; + HydrologyRegionalGraph.Label terminal = new HydrologyRegionalGraph.Label( + new HydrologyRegionalGraph.LabelState(0, 0, 63, 0D, 0D, root), null); + labels[node.index()] = new HydrologyRegionalGraph.Label(new HydrologyRegionalGraph.LabelState( + node.index(), node.index(), 63, 0D, length, root), terminal); + sources.put(RiverFootprint.pack(node.x(), node.z()), ordinal++); + } + assertEquals(secondCatchmentSize + 4, ordinal); + return new HydrologyRegionalGraph.Tree(labels, contributions, outlets); + }); + when(graph.path(any(), anyInt())).thenAnswer(invocation -> List.of(invocation.getArgument(1), 0)); + replace(planner.regional, "graph", graph); + HydrologyRegionalRoute routes = mock(HydrologyRegionalRoute.class); + when(routes.select(anyList(), anyString(), anyBoolean(), any(), any())).thenAnswer(invocation -> { + List guide = invocation.getArgument(0); + HydrologyPoint first = guide.getFirst(); + return provider.build(sources.get(RiverFootprint.pack(first.x(), first.z()))); + }); + replace(planner.regional, "routes", routes); + return new Fixture(planner); + } + + private static OutletCandidate outlet(int root) { + HydrologyPoint point = new HydrologyPoint(2048, 63, root * 1024); + return new OutletCandidate(0, 0, new RiverOutlet(1000L + root, HydrologyFeatureType.MOUTH, + root, point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true)); + } + + private static HydrologyRegionalRoute.Attempt accepted(int source) { + int z = source * 4096; + HydrologyPoint first = new HydrologyPoint(0, 70, z); + HydrologyPoint last = new HydrologyPoint(512, 70, z); + HydraulicSegment segment = new HydraulicSegment(100L + source, 100L + source, HydrologyFeatureType.SURFACE_POOL, + 70, 70, 4, 2, false, false, List.of(first, last), + new HydraulicChannelProfile(new double[]{4D}, new double[]{2D})); + RiverCourse course = new RiverCourse(100L + source, RiverCourseType.SURFACE, OptionalLong.of(10000L + source), + OptionalLong.of(1000L + (source < 4 ? 0 : 1)), "default", 1, List.of(), List.of(segment)); + HydrologyRegionalNetwork network = new HydrologyRegionalNetwork(List.of(), List.of(), List.of(), + List.of(course), List.of(), List.of()); + return new HydrologyRegionalRoute.Attempt(new HydrologyRegionalRoute.Refinement(List.of(first, last), null, null, 0, null), network, null); + } + + private static HydrologyRegionalRoute.Attempt rejected(int source) { + HydrologyPoint point = new HydrologyPoint(source, 70, 0); + HydrologyDiagnosticCandidate diagnostic = new HydrologyDiagnosticCandidate(source, HydrologyCandidateKind.REGIONAL_SOURCE, + HydrologyFeatureType.SURFACE_POOL, point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, source); + return new HydrologyRegionalRoute.Attempt(new HydrologyRegionalRoute.Refinement(List.of(point), null, null, 0, null), + HydrologyRegionalNetwork.EMPTY, diagnostic); + } + + private static void replace(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue("Timed out waiting for trial release", latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } + + @FunctionalInterface + private interface SourceProvider { + HydrologyRegionalRoute.Attempt build(int source); + } + + private record Fixture(HydrologyPlanner planner) { + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefinerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefinerTest.java index c7bc2c0de..a5125b247 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefinerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefinerTest.java @@ -8,6 +8,11 @@ import java.util.List; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.lang.reflect.Method; @@ -17,6 +22,69 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class HydrologyRegionalTerrainRefinerTest { + @Test(timeout = 10000) + public void clearingDuringSamplingCannotPublishOldTerrain() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> { + int call = calls.incrementAndGet(); + if (call == 1) { + started.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + } + return HydrologyTerrainSample.openLand(call == 1 ? 70 : 80, 0D, "land"); + }; + HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner( + new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain)); + try (ExecutorService workers = Executors.newSingleThreadExecutor()) { + Future first = workers.submit(() -> refiner.sample(0, 0)); + try { + assertTrue(started.await(3, TimeUnit.SECONDS)); + refiner.clear(); + assertEquals(80, refiner.sample(0, 0).naturalHeight()); + } finally { + release.countDown(); + } + assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight()); + assertEquals(80, refiner.sample(0, 0).naturalHeight()); + assertEquals(2, calls.get()); + } + } + + @Test(timeout = 10000) + public void independentDiagonalSamplesDoNotWaitForEachOthersTerrain() throws Exception { + CountDownLatch sampling = new CountDownLatch(2); + AtomicInteger calls = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> { + calls.incrementAndGet(); + sampling.countDown(); + try { + assertTrue("Independent terrain samples were serialized", sampling.await(3, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + return HydrologyTerrainSample.openLand(70 + x, 0D, "land"); + }; + HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner( + new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain)); + try (ExecutorService workers = Executors.newFixedThreadPool(2)) { + Future first = workers.submit(() -> refiner.sample(0, 0)); + Future second = workers.submit(() -> refiner.sample(1, 1)); + assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight()); + assertEquals(71, second.get(5, TimeUnit.SECONDS).naturalHeight()); + assertEquals(first.get(), refiner.sample(0, 0)); + assertEquals(second.get(), refiner.sample(1, 1)); + assertEquals(2, calls.get()); + } + } + @Test public void aHigherHeadDetourSurvivesACheaperInfeasibleArrival() { int[][] heights = { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTributariesTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTributariesTest.java index 47e3c17e0..262a63e01 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTributariesTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTributariesTest.java @@ -5,6 +5,7 @@ import org.junit.Test; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.Map; import java.util.OptionalLong; @@ -43,7 +44,7 @@ public class HydrologyRegionalTributariesTest { HydrologyCaveCourseFilter.Result combined = planner.regional.include(new HydrologyCaveCourseFilter.Result( connected.nodes(), connected.edges(), connected.outlets(), courses, List.of()), fixture.regional()); HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 19L, 0L, 512, - combined.nodes(), combined.edges(), combined.outlets(), combined.courses(), List.of(), diagnostics, + combined.nodes(), combined.edges(), combined.outlets(), combined.courses(), Set.of(fixture.regional().courses().getFirst().id()), List.of(), diagnostics, RiverFootprint.empty()); assertTrue(tile.acyclic()); DrainageNode source = tile.node(tributary.sourceNodeId().orElseThrow()).orElseThrow(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java new file mode 100644 index 000000000..65c72eca7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java @@ -0,0 +1,225 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class HydrologySourceSpacingTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + private static final int WIDTH = 11; + private static final int SPACING = 64; + /** One lattice step (64, or 91 diagonally) is inside the spacing; two steps (128) are outside it. */ + private static final int MINIMUM_SPACING = 100; + private static final int HIGH = 200; + private static final int ROUTE = 512; + private static final int LONG_ROUTE = 4096; + + @Test + public void aRejectedSourceNoLongerCostsItsLowerRankedNeighbourTheSlot() { + // A > B > C by weight, A one step from B, B one step from C, A two steps from C. + Field withMiddle = field(Map.of(pack(3, 4), 3D, pack(4, 4), 2D, pack(5, 4), 1D)); + assertTrue(withMiddle.admits(3, 4)); + assertFalse(withMiddle.admits(4, 4)); + assertTrue(withMiddle.admits(5, 4)); + + Field withoutMiddle = field(Map.of(pack(3, 4), 3D, pack(5, 4), 1D)); + assertEquals(withoutMiddle.accepted(), withMiddle.accepted()); + } + + /** + * Route length and potential belong to the routing plan of the tile that owns a node, so a contest scored with + * them ranks neighbours either side of a tile seam against different outlet sets and hands the slot to whoever + * owns the node. The contest is terrain only, and must not move when the plan under it does. + */ + @Test + public void theSpacingContestIgnoresTheOwnerTileRoutingPlan() { + int[] pair = hashInvertedPair(planner()); + long left = pack(pair[0], pair[1]); + long right = pack(pair[0] + 1, pair[1]); + Map weights = Map.of(left, 1D, right, 1D); + + Set terrainOnly = field(weights).accepted(); + assertEquals(Set.of(left), terrainOnly); + assertEquals(terrainOnly, field(weights, Map.of(right, LONG_ROUTE)).accepted()); + assertEquals(terrainOnly, field(weights, Map.of(left, LONG_ROUTE)).accepted()); + } + + @Test + public void greedyAdmissionIsDeterministicSeparatedAndMaximal() { + Set first = denseField().accepted(); + Set second = denseField().accepted(); + assertEquals(first, second); + assertFalse(first.isEmpty()); + + for (long accepted : first) { + for (long other : first) { + if (accepted != other) { + assertTrue("accepted sources " + accepted + " and " + other + " sit inside the spacing", + separation(accepted, other) >= MINIMUM_SPACING); + } + } + } + for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) { + for (int gridX = 1; gridX < WIDTH - 1; gridX++) { + long node = pack(gridX, gridZ); + if (first.contains(node)) { + continue; + } + boolean blocked = false; + for (long accepted : first) { + blocked |= separation(node, accepted) < MINIMUM_SPACING; + } + assertTrue("rejected source " + node + " is separated from every accepted source", blocked); + } + } + } + + private static Field denseField() { + HashMap weights = new HashMap<>(); + for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) { + for (int gridX = 1; gridX < WIDTH - 1; gridX++) { + weights.put(pack(gridX, gridZ), 1D + (gridX * 7 + gridZ * 13) % 5); + } + } + return field(weights); + } + + /** The first adjacent pair whose raw source hashes rank the left node above the right one. */ + private static int[] hashInvertedPair(HydrologyPlanner planner) { + for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) { + for (int gridX = 1; gridX < WIDTH - 2; gridX++) { + long left = planner.sourcePlanner.sourceStableId(HydrologySourcePlanner.SURFACE_SOURCE_SALT, + gridX * SPACING, gridZ * SPACING); + long right = planner.sourcePlanner.sourceStableId(HydrologySourcePlanner.SURFACE_SOURCE_SALT, + (gridX + 1) * SPACING, gridZ * SPACING); + if (Long.compareUnsigned(left, right) > 0) { + return new int[]{gridX, gridZ}; + } + } + } + fail("No hash-inverted adjacent source pair exists on this lattice"); + return null; + } + + private static double separation(long first, long second) { + return StrictMath.hypot((double) (unpackX(first) - unpackX(second)) * SPACING, + (double) (unpackZ(first) - unpackZ(second)) * SPACING); + } + + private static Field field(Map weights) { + return field(weights, Map.of()); + } + + private static Field field(Map weights, Map routeLengths) { + HydrologyPlanner planner = planner(); + HydrologySampledGrid grid = grid(weights); + HydrologyRoutingPlan routing = routing(grid, routeLengths); + HashMap contexts = new HashMap<>(); + contexts.put(TILE, new SourceRoutingContext(grid, routing, routing, List.of())); + return new Field(planner, grid, weights.keySet(), contexts, new HashMap<>()); + } + + private static HydrologyPlanner planner() { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(true, + new HydrologyPlannerSettings.Source(true, 0.5D, 88, 0, 1, MINIMUM_SPACING), + 4, 8, 2, 4, 10, 1.5D, HydrologyPlannerSettings.Banks.defaults()); + HydrologyPlannerSettings settings = 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()); + return new HydrologyPlanner(1337L, settings, (x, z) -> land(HIGH, 0D)); + } + + private static HydrologySampledGrid grid(Map weights) { + ArrayList nodes = new ArrayList<>(WIDTH * WIDTH); + for (int gridZ = 0; gridZ < WIDTH; gridZ++) { + for (int gridX = 0; gridX < WIDTH; gridX++) { + int index = gridZ * WIDTH + gridX; + long node = pack(gridX, gridZ); + nodes.add(new HydrologyGridNode(index, gridX, gridZ, gridX * SPACING, gridZ * SPACING, index + 1L, + land(HIGH, weights.getOrDefault(node, 0D)))); + } + } + return new HydrologySampledGrid(0, 0, 0, 0, WIDTH * SPACING, WIDTH, SPACING, List.copyOf(nodes)); + } + + private static HydrologyRoutingPlan routing(HydrologySampledGrid grid, Map routeLengths) { + 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); + ArrayList candidates = new ArrayList<>(count); + for (HydrologyGridNode node : grid.nodes()) { + outlets[node.index()] = node.index(); + lengths[node.index()] = routeLengths.getOrDefault(pack(node.gridX(), node.gridZ()), ROUTE); + 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 HydrologyTerrainSample land(int naturalHeight, double weight) { + return new HydrologyTerrainSample(naturalHeight, 0D, false, false, naturalHeight - 32, naturalHeight - 30, + true, true, true, false, false, false, + 0D, weight, 0D, 1D, 1D, 1D, 1D, 1D, "land", "land", "land", "land", "land", "land", + List.of("default"), List.of(), Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT); + } + + private static long pack(int gridX, int gridZ) { + return RiverFootprint.pack(gridX, gridZ); + } + + private static int unpackX(long packed) { + return RiverFootprint.unpackX(packed); + } + + private static int unpackZ(long packed) { + return RiverFootprint.unpackZ(packed); + } + + private record Field( + HydrologyPlanner planner, + HydrologySampledGrid grid, + Set eligible, + Map contexts, + Map admissions + ) { + boolean admits(int gridX, int gridZ) { + return planner.sourcePlanner.globallyAdmittedSource( + grid.nodeAt(gridX, gridZ), + planner.settings.surface().sources(), + HydrologySourcePlanner.SURFACE_SOURCE_SALT, + true, + contexts, + admissions + ); + } + + Set accepted() { + LinkedHashSet selected = new LinkedHashSet<>(); + for (long node : new TreeSet<>(eligible)) { + if (admits(unpackX(node), unpackZ(node))) { + selected.add(node); + } + } + return selected; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java new file mode 100644 index 000000000..2ce22c1d9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java @@ -0,0 +1,85 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.NavigableSet; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class HydrologySurfaceBankProbeTest { + @Test + public void raisingShoreWidthWidensTheBankProbeBandInsteadOfSlidingIt() { + NavigableSet narrow = probedBankOffsets(1.5D); + NavigableSet wide = probedBankOffsets(3D); + int innerRing = innerRing(); + + assertTrue("narrow band " + narrow, narrow.contains(innerRing)); + assertTrue("wide band " + wide, wide.contains(innerRing)); + assertTrue("band " + wide + " must still cover " + narrow, wide.containsAll(narrow)); + assertTrue("band " + wide + " must reach past " + narrow, wide.size() > narrow.size()); + } + + @Test + public void theInnerProbeRingDoesNotMoveWithShoreWidth() { + int innerRing = innerRing(); + for (double shoreWidth : new double[]{0D, 1.5D, 3D, 16D}) { + NavigableSet band = probedBankOffsets(shoreWidth); + assertEquals("band " + band + " at shoreWidth " + shoreWidth, + innerRing, band.first().intValue()); + } + } + + /** One block outside half the nominal maximum width - see HydrologySurfaceCoursePlanner. */ + private int innerRing() { + return (int) StrictMath.ceil( + HydrologyPlannerSettings.defaults().surface().maximumWidth() / 2D) + 1; + } + + private NavigableSet probedBankOffsets(double shoreWidth) { + HydrologyPoint center = new HydrologyPoint(0, 84, 0); + NavigableSet probed = new TreeSet<>(); + HydrologyTerrainSampler sampler = (x, z) -> { + if (x == center.x()) { + probed.add(Math.abs(z - center.z())); + } + return HydrologyTerrainSample.openLand(84, 0D, "land"); + }; + HydrologyPlanner planner = new HydrologyPlanner(91L, settings(shoreWidth), sampler); + planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); + + planner.surfaceCourses.surfaceRouteCandidateBankPenalty(new RouteCandidate( + center, center.x(), center.z(), 0D, 0D, 0D, new RouteDirection(1D, 0D), true)); + + probed.remove(0); + return probed; + } + + private HydrologyPlannerSettings settings(double shoreWidth) { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Surface surface = base.surface(); + return new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + new HydrologyPlannerSettings.Surface( + surface.enabled(), + surface.sources(), + surface.minimumWidth(), + surface.maximumWidth(), + surface.minimumDepth(), + surface.maximumDepth(), + surface.maximumIncision(), + shoreWidth, + surface.banks()), + base.hydraulics(), + base.underground(), + base.outlets(), + base.geometry(), + base.deepFluids(), + base.surfacePools(), + base.widestShoreBiomeWidth(), + base.seaCaves(), + base.surfacePolicyBounds()); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRasterTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRasterTest.java index e1a1dfc38..d19665e3b 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRasterTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRasterTest.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.Map; import java.util.OptionalLong; import java.util.concurrent.atomic.AtomicInteger; @@ -345,7 +346,7 @@ public final class HydrologySurfaceDropRasterTest { columns.put(RiverFootprint.pack(sample.x(), sample.z()), sample); } HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, 1L, 128, - accepted.nodes(), accepted.edges(), accepted.outlets(), accepted.courses(), accepted.cavePlans(), List.of(), new RiverFootprint(columns)); + accepted.nodes(), accepted.edges(), accepted.outlets(), accepted.courses(), Set.of(), accepted.cavePlans(), List.of(), new RiverFootprint(columns)); assertEquals(1, tile.courses().size()); for (boolean connectToExistingCaves : new boolean[] {false, true}) { diagnostics.clear(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java index 1be175ae2..b19df82c8 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java @@ -388,6 +388,13 @@ public class HydrologyTileCacheTest { when(tile.tileSize()).thenReturn(64); HydrologyTileCache first = new HydrologyTileCache(firstPlanner, 4, null, null, scope); HydrologyTileCache second = new HydrologyTileCache(secondPlanner, 4, null, null, scope); + AtomicInteger terrainPreparations = new AtomicInteger(); + first.setTerrainPreparation(terrainPreparations::incrementAndGet); + second.setTerrainPreparation(() -> { + throw new AssertionError("Shared tile reuse must not prepare terrain caches"); + }); + + assertEquals(0, terrainPreparations.get()); assertSame(tile, first.get(key)); assertSame(tile, second.get(key)); @@ -395,6 +402,7 @@ public class HydrologyTileCacheTest { verify(firstPlanner, times(1)).plan(key); verify(secondPlanner, never()).plan(key); verify(secondPlanner, times(1)).reuseResolvedTile(tile); + assertEquals(1, terrainPreparations.get()); } @Test @@ -801,7 +809,7 @@ public class HydrologyTileCacheTest { public void planningFailureFallsBackToTheEmptyTileAndIsCached() { HydrologyPlanner planner = mock(HydrologyPlanner.class); HydrologyTileKey key = new HydrologyTileKey(3, -2); - HydrologyTile empty = new HydrologyTile(key, 7L, 11L, 1024, List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), RiverFootprint.empty()); + HydrologyTile empty = new HydrologyTile(key, 7L, 11L, 1024, List.of(), List.of(), List.of(), List.of(), Set.of(), List.of(), List.of(), RiverFootprint.empty()); when(planner.settings()).thenReturn(emptySettings()); when(planner.plan(key)).thenThrow(new IllegalStateException("Hydrology natural height was not finite at -66,-641")); when(planner.emptyTile(key)).thenReturn(empty); @@ -870,6 +878,33 @@ public class HydrologyTileCacheTest { assertEquals(width * height, cache.size()); } + @Test + public void deferredNeighboursAllowFurtherDemandAndResumeAfterEntry() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = emptySettings(); + when(planner.settings()).thenReturn(settings); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, Runnable::run); + cache.setNeighbourPrefetchEnabled(false); + int tileSize = settings.routing().tileSize(); + + cache.prepareChunkColumns(0, 0); + int requiredTiles = cache.size(); + HydrologyTileKey distant = new HydrologyTileKey(20, 20); + assertSame(tile, cache.get(distant)); + assertEquals(requiredTiles + 1, cache.size()); + verify(planner, never()).plan(new HydrologyTileKey(-2, -2)); + + cache.setNeighbourPrefetchEnabled(true); + cache.prepareChunkColumns(tileSize * 20 + 32, tileSize * 20 + 48); + + verify(planner).plan(new HydrologyTileKey(19, 19)); + assertTrue(cache.size() > requiredTiles + 1); + cache.close(); + } + @Test public void pregenPreparationStartsAtTheNearestUncachedForwardTile() { HydrologyPlanner planner = mock(HydrologyPlanner.class); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java index db56c19f2..64d5df685 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java @@ -1,19 +1,27 @@ package art.arcane.iris.generation.hydrology; import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import java.io.InputStream; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Set; import java.util.Map; import java.util.OptionalLong; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class StudioHydrologyTileStoreTest { @@ -35,9 +43,29 @@ public class StudioHydrologyTileStoreTest { assertEquals(original.edges(), restored.edges()); assertEquals(original.outlets(), restored.outlets()); assertEquals(original.courses(), restored.courses()); + assertEquals(original.regionalCourseIds(), restored.regionalCourseIds()); assertEquals(original.cavePlans(), restored.cavePlans()); - assertEquals(original.diagnosticCandidates(), restored.diagnosticCandidates()); + assertEquals(original.localDiagnosticCandidates(), restored.localDiagnosticCandidates()); assertEquals(original.footprint(), restored.footprint()); + assertThrows(UnsupportedOperationException.class, () -> restored.regionalCourseIds().clear()); + } + + @Test + public void entryRequiresRegionalOwnershipMetadata() throws Exception { + HydrologyTile original = tile(); + StudioHydrologyTileStore store = store(); + store.save(original); + Path file = store.file(original.key()); + JsonObject persisted; + try (InputStream input = new GZIPInputStream(Files.newInputStream(file))) { + persisted = JsonParser.parseString(new String(input.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject(); + } + persisted.remove("regionalCourseIds"); + try (OutputStream output = new GZIPOutputStream(Files.newOutputStream(file))) { + output.write(persisted.toString().getBytes(StandardCharsets.UTF_8)); + } + + assertTrue(store.load(original.key()).isEmpty()); } @Test @@ -168,6 +196,7 @@ public class StudioHydrologyTileStoreTest { List.of(), List.of(outlet), List.of(course), + Set.of(course.id()), List.of(), List.of(), new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column)) diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyPhysicalOceanTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyPhysicalOceanTest.java index 2f099846e..a7403c145 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyPhysicalOceanTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyPhysicalOceanTest.java @@ -159,7 +159,7 @@ public class IrisHydrologyPhysicalOceanTest { shore.setLoadKey("shore"); return new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(17L, 768, dimension, data, (x, z, height) -> new IrisHydrologyNaturalSample(height, oceanIntent.isOcean(x, z), shore, region), - heights, (x, z) -> "coast", oceanIntent, footprint -> null, () -> false)); + heights, (x, z) -> "coast", oceanIntent, footprint -> null, () -> false, () -> {})); } private static IrisHydrologyRoutingTerrainSampler sampler(IrisHydrologyRuntime runtime) throws Exception { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyPolicyCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyPolicyCacheTest.java index bc294a684..788e8d7a0 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyPolicyCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyPolicyCacheTest.java @@ -229,7 +229,7 @@ public class IrisHydrologyPolicyCacheTest { private IrisHydrologyRuntime runtime() { return new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(17L, 768, dimension, data, (x, z, height) -> new IrisHydrologyNaturalSample(height, false, biome.get(), region.get()), - (x, z) -> 180D, (x, z) -> "constant", (x, z) -> false, footprint -> null, () -> false)); + (x, z) -> 180D, (x, z) -> "constant", (x, z) -> false, footprint -> null, () -> false, () -> {})); } } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingCacheConcurrencyTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingCacheConcurrencyTest.java new file mode 100644 index 000000000..ac21c0eb4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingCacheConcurrencyTest.java @@ -0,0 +1,122 @@ +package art.arcane.iris.generation.hydrology.runtime; + +import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler.NaturalClassification; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; +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.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class IrisHydrologyRoutingCacheConcurrencyTest { + @Test(timeout = 15000) + public void publishedBasisSupersedesAnEarlierClassification() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (x, z, height) -> new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height, + HydrologyTerrainSample.ocean(60, "ocean")), + (x, z) -> 60D, + (x, z) -> { + started.countDown(); + await(release); + return false; + }, 63), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(65536)); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future classification = worker.submit(() -> sampler.classifyNatural(-8193, 257)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertTrue(sampler.sampleBasisWithoutSlope(-8193, 257).ocean()); + release.countDown(); + assertEquals(NaturalClassification.OCEAN, classification.get(5, TimeUnit.SECONDS)); + assertEquals(NaturalClassification.OCEAN, sampler.classifyNatural(-8193, 257)); + assertEquals(0, sampler.oceanClassificationCacheSize()); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void concurrentSameCoordinateReturnsTheFirstPublishedBasis() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (x, z, height) -> { + if (calls.incrementAndGet() == 1) { + started.countDown(); + await(release); + } + return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height, + HydrologyTerrainSample.openLand(70, 0D, "land")); + }, (x, z) -> 70D, (x, z) -> false, 63), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(65536)); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future first = worker.submit(() -> sampler.basis(4096, -37)); + assertTrue(started.await(5, TimeUnit.SECONDS)); + IrisHydrologyRoutingTerrainSampler.TerrainBasis published = sampler.basis(4096, -37); + release.countDown(); + assertSame(published, first.get(5, TimeUnit.SECONDS)); + assertSame(published, sampler.basis(4096, -37)); + assertEquals(2, calls.get()); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void unevenStripeCapacityStaysBoundedAndCloseClearsEveryCache() { + int capacity = 4097; + AtomicInteger heightCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (x, z, height) -> new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height, + HydrologyTerrainSample.openLand((int) height, 0D, "land")), + (x, z) -> { + heightCalls.incrementAndGet(); + return 70D + Math.floorMod(x + z, 11); + }, (x, z) -> false, 63), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(capacity)); + for (int coordinate = -10000; coordinate < 10000; coordinate++) { + sampler.sampleBasisWithoutSlope(coordinate, coordinate * 3); + sampler.classifyNatural(coordinate + 100000, -coordinate); + } + assertTrue(sampler.basisCacheSize() <= capacity); + assertTrue(sampler.naturalHeightCacheSize() <= capacity); + assertTrue(sampler.oceanClassificationCacheSize() <= capacity); + assertTrue(sampler.basisCacheSize() > capacity / 2); + assertTrue(sampler.oceanClassificationCacheSize() > capacity / 2); + HydrologyTerrainSample before = sampler.sampleBasisWithoutSlope(7, 21); + int previousCalls = heightCalls.get(); + sampler.close(); + assertEquals(0, sampler.basisCacheSize()); + assertEquals(0, sampler.naturalHeightCacheSize()); + assertEquals(0, sampler.oceanClassificationCacheSize()); + assertEquals(before, sampler.sampleBasisWithoutSlope(7, 21)); + assertEquals(previousCalls + 1, heightCalls.get()); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue("Timed out waiting for provider release", latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java index 145a1a22d..ee8755c17 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -268,6 +268,43 @@ public class IrisHydrologyRoutingTerrainSamplerTest { assertEquals(1, calls.get(pack(x + 3, z)).intValue()); } + @Test + public void slopeReusesBasisHeightAfterNaturalHeightEvictionUntilClose() { + Map heightCalls = new HashMap<>(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> { + heightCalls.merge(pack(x, z), 1, Integer::sum); + return height(x, z); + }, + (int x, int z) -> false, + 128 + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(2) + ); + + sampler.sampleBasisWithoutSlope(0, 0); + sampler.localSlope(16, 16, height(16, 16)); + assertEquals(3, heightCalls.size()); + assertEquals(2, sampler.naturalHeightCacheSize()); + assertEquals(1, sampler.basisCacheSize()); + + double expected = IrisHydrologyRoutingTerrainSampler.localSlope( + height(-3, 0), height(0, 0), height(-3, 3)); + double actual = sampler.localSlope(-3, 0, height(-3, 0)); + + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + assertEquals(1, heightCalls.get(pack(0, 0)).intValue()); + assertEquals(4, heightCalls.size()); + + sampler.close(); + double afterClose = sampler.localSlope(-3, 0, height(-3, 0)); + + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(afterClose)); + assertEquals(2, heightCalls.get(pack(0, 0)).intValue()); + } + @Test public void denseDetailedStencilsShareRawNaturalHeights() { AtomicInteger basisCalls = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyComponentTest.java index 1d1a56643..db17f9561 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyComponentTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyComponentTest.java @@ -305,7 +305,7 @@ public class MantleHydrologyComponentTest { surfaceCourses.add(course); } } - assertEquals(tile.diagnosticCandidates().toString(), 1, surfaceCourses.size()); + assertEquals(tile.localDiagnosticCandidates().toString(), 1, surfaceCourses.size()); RiverCourse course = surfaceCourses.getFirst(); LinkedHashMap fluidLayers = new LinkedHashMap<>(); @@ -1202,6 +1202,34 @@ public class MantleHydrologyComponentTest { ); } + @Test + public void partiallyOverlappingSurfaceLayerPublishesOnlyAcceptedCaveCells() { + HydrologyColumnLayer surface = layerForCourse( + HydrologyFeatureType.SURFACE_POOL, 71L, 1L, 14L, 18, 25, 25, "river" + ); + HydrologyColumnLayer cave = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, 72L, 1L, 15L, 20, 23, 24, "river" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(surface, cave)); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), List.of(acceptedPlan(sample, cave)), new TestCaveVoxelView() + ); + + for (int y = 19; y <= 25; y++) { + CavePosition position = new CavePosition(8, y, 8); + if (y >= 21 && y <= 24) { + assertEquals(HydrologyCaveAction.WET_SOURCE, publication.caveCells().get(position).action()); + assertFalse(publication.surfaceWrites().containsKey(position)); + } else { + assertEquals(HydrologyCaveAction.WET_SOURCE, publication.surfaceWrites().get(position).action()); + assertFalse(publication.caveCells().containsKey(position)); + } + } + assertEquals(HydrologyCaveAction.SEAL_GUARD, + publication.caveCells().get(new CavePosition(9, 22, 8)).action()); + } + @Test public void matchingSurfaceFluidComposesOverAcceptedCaveClearance() { HydrologyColumnLayer surface = layer( diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java index 3a6b3ea2a..a53040dea 100644 --- a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorSurfaceDetailTest.java @@ -5,6 +5,11 @@ import art.arcane.iris.pack.loading.IrisData; import art.arcane.volmlib.util.collection.KList; import org.junit.Test; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.ToDoubleFunction; @@ -91,6 +96,104 @@ public class IrisGeneratorSurfaceDetailTest { assertEquals(91, samples.get()); } + @Test + public void repeatedCellSamplingMatchesColdCacheRawBits() { + Sampler source = (seed, x, z) -> Math.sin(x * 0.017D) + Math.cos(z * 0.031D); + IrisGenerator warm = generator(source).setSurfaceDetail(0.5D); + IrisGenerator cold = generator(source).setSurfaceDetail(0.5D); + double[][] points = { + {-6D, -6D}, {-5.75D, -5.5D}, {-4.25D, -4.5D}, {-3D, -3D}, + {-0.25D, -0.125D}, {0D, 0D}, {1.25D, 1.75D}, {5.5D, 5.75D}, + {6D, 6D}, {6.25D, 6.5D}, {8191.25D, -4096.75D} + }; + + for (double[] point : points) { + cold.rescale(1D); + long expected = Double.doubleToRawLongBits(cold.getHeight(point[0], point[1], 27L)); + for (int repeat = 0; repeat < 4; repeat++) { + assertEquals(expected, Double.doubleToRawLongBits(warm.getHeight(point[0], point[1], 27L))); + } + } + } + + @Test + public void repeatedLatticeSamplesRefreshSeedChanges() { + AtomicInteger samples = new AtomicInteger(); + IrisGenerator generator = generator((seed, x, z) -> { + samples.incrementAndGet(); + return seed * 0.000001D; + }).setSurfaceDetail(0.5D); + + double first = generator.getHeight(0, 0, 27L); + assertEquals(Double.doubleToRawLongBits(first), Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L))); + assertEquals(1, samples.get()); + + double second = generator.getHeight(0, 0, 28L); + assertEquals(0.000001D, second - first, 1e-12D); + assertEquals(Double.doubleToRawLongBits(second), Double.doubleToRawLongBits(generator.getHeight(0, 0, 28L))); + assertEquals(2, samples.get()); + + assertEquals(Double.doubleToRawLongBits(first), Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L))); + assertEquals(3, samples.get()); + } + + @Test + public void collidingLatticeSamplesRecomputeWithoutReturningAnotherCoordinate() { + AtomicInteger originSamples = new AtomicInteger(); + IrisGenerator generator = generator((seed, x, z) -> { + if (x == 0D && z == 0D) { + originSamples.incrementAndGet(); + } + return x * 0.00001D; + }).setSurfaceDetail(0.5D); + + assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L))); + assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L))); + assertEquals(1, originSamples.get()); + assertEquals(Double.doubleToRawLongBits(71754D * 0.00001D), + Double.doubleToRawLongBits(generator.getHeight(71754, 0, 27L))); + assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L))); + assertEquals(2, originSamples.get()); + assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L))); + assertEquals(2, originSamples.get()); + } + + @Test(timeout = 15000) + public void concurrentCollisionsAndSeedChangesPreserveRawHeightBits() throws Exception { + Sampler source = (seed, x, z) -> seed * 0.000001D + x * 0.00001D - z * 0.00003D; + IrisGenerator generator = generator(source).setSurfaceDetail(0.5D); + IrisGenerator reference = generator(source); + double[] xs = {0D, 71754D}; + long[] seeds = {27L, 28L}; + long[][] expected = new long[xs.length][seeds.length]; + for (int coordinate = 0; coordinate < xs.length; coordinate++) { + for (int seed = 0; seed < seeds.length; seed++) { + expected[coordinate][seed] = Double.doubleToRawLongBits(reference.getHeight(xs[coordinate], 0D, seeds[seed])); + } + } + ForkJoinPool pool = new ForkJoinPool(4); + try { + List> tasks = new ArrayList<>(); + for (int task = 0; task < 16; task++) { + int offset = task; + tasks.add(pool.submit(() -> { + for (int sample = 0; sample < 64; sample++) { + int coordinate = (sample + offset) & 1; + int seed = (sample / 2 + offset) & 1; + assertEquals(expected[coordinate][seed], + Double.doubleToRawLongBits(generator.getHeight(xs[coordinate], 0D, seeds[seed]))); + } + })); + } + for (Future task : tasks) { + task.get(5, TimeUnit.SECONDS); + } + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS)); + } + } + @Test public void cachedLatticeHeightsRemainIsolatedBySeed() { IrisGenerator generator = generator((seed, x, z) -> seed * 0.000001D); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java index 175fa7cfa..8e181e29e 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineShutdownDrainTest.java @@ -69,6 +69,9 @@ public class EngineShutdownDrainTest { @ClassRule public static final DurabilityMode DURABILITY = DurabilityMode.relaxed(); + @ClassRule + public static final TemporaryFolder PLATFORM_FILES = new TemporaryFolder(); + private static IrisPlatform previousPlatform; @Rule @@ -79,6 +82,7 @@ public class EngineShutdownDrainTest { previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; IrisPlatforms.unbind(); IrisPlatform platform = mock(IrisPlatform.class); + when(platform.dataFile("iris.json")).thenReturn(PLATFORM_FILES.getRoot().toPath().resolve("iris.json").toFile()); PlatformRegistries registries = mock(PlatformRegistries.class); when(platform.registries()).thenReturn(registries); when(registries.block(anyString())).thenReturn(mock(PlatformBlockState.class)); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/HydrologyNoiseCacheBudgetTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/HydrologyNoiseCacheBudgetTest.java new file mode 100644 index 000000000..23df1cd58 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/HydrologyNoiseCacheBudgetTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.generation.runtime; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyNoiseCacheBudgetTest { + @Test + public void reservationsClampToTheRemainingSharedCapacity() { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(4_096L * 15L + 4_095L); + HydrologyNoiseCacheBudget.Reservation first = budget.reserve(4, 10); + HydrologyNoiseCacheBudget.Reservation second = budget.reserve(2, 10); + assertEquals(3, first.additionalChunks()); + assertEquals(1, second.additionalChunks()); + assertEquals(14L * 4_096L, budget.reservedBytes()); + first.close(); + first.close(); + assertEquals(2L * 4_096L, budget.reservedBytes()); + second.close(); + assertEquals(0L, budget.reservedBytes()); + } + + @Test + public void emptyBudgetAndZeroDemandReserveNothing() { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(0L); + try (HydrologyNoiseCacheBudget.Reservation reservation = budget.reserve(5, 32_768)) { + assertEquals(0, reservation.additionalChunks()); + } + HydrologyNoiseCacheBudget available = new HydrologyNoiseCacheBudget(1_000_000L); + try (HydrologyNoiseCacheBudget.Reservation reservation = available.reserve(5, 0)) { + assertEquals(0, reservation.additionalChunks()); + } + assertEquals(0L, budget.reservedBytes()); + assertEquals(0L, available.reservedBytes()); + } + + @Test + public void requestsCannotExceedTheCapacityLimit() { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + try (HydrologyNoiseCacheBudget.Reservation reservation = budget.reserve(5, 32_768)) { + assertEquals(32_768, reservation.additionalChunks()); + assertEquals(5L * 32_768L * 4_096L, budget.reservedBytes()); + } + assertThrows(IllegalArgumentException.class, () -> budget.reserve(0, 10)); + assertThrows(IllegalArgumentException.class, () -> budget.reserve(5, -1)); + assertThrows(IllegalArgumentException.class, () -> budget.reserve(5, 32_769)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyNoiseCacheBudget(-1L)); + } + + @Test(timeout = 10_000L) + public void concurrentOwnersNeverOversubscribeAndReleaseIndependently() throws Exception { + long maximumBytes = 4_096L * 503L; + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(maximumBytes); + CountDownLatch start = new CountDownLatch(1); + List> pending = new ArrayList<>(); + try (ExecutorService workers = Executors.newFixedThreadPool(16)) { + for (int index = 0; index < 32; index++) { + pending.add(workers.submit(() -> { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return budget.reserve(5, 50); + })); + } + start.countDown(); + List reservations = new ArrayList<>(); + long expectedBytes = 0L; + for (Future future : pending) { + HydrologyNoiseCacheBudget.Reservation reservation = future.get(5L, TimeUnit.SECONDS); + reservations.add(reservation); + expectedBytes += reservation.additionalChunks() * 5L * 4_096L; + } + assertEquals(expectedBytes, budget.reservedBytes()); + assertTrue(expectedBytes <= maximumBytes); + List> releases = new ArrayList<>(); + for (HydrologyNoiseCacheBudget.Reservation reservation : reservations) { + releases.add(workers.submit(() -> { + reservation.close(); + reservation.close(); + })); + } + for (Future release : releases) { + release.get(5L, TimeUnit.SECONDS); + } + assertEquals(0L, budget.reservedBytes()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java new file mode 100644 index 000000000..c9b0ee36e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHydrologyNoiseCacheTest.java @@ -0,0 +1,356 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.world.pregen.PregenPerformanceProfile; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.hydrology.HydrologyPlanner; +import art.arcane.iris.generation.hydrology.HydrologyTile; +import art.arcane.iris.generation.hydrology.HydrologyTileCache; +import art.arcane.iris.generation.hydrology.HydrologyTileKey; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.generation.stream.CachedDoubleStream2D; +import art.arcane.iris.generation.stream.CachedStream2D; +import art.arcane.iris.generation.terrain.InferredType; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertSame; +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.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class IrisComplexHydrologyNoiseCacheTest { + @Test + public void pregenGrowsTheBackingHeightCacheWithoutReplacingItsThreadAwareStream() throws Exception { + IrisSettings previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + IrisSettings.get().getPerformance().setNoiseCacheSize(1_024); + try (Fixture fixture = new Fixture(1_024)) { + ProceduralStream wrapper = fixture.complex.getRawHeightStream(); + assertEquals(13D, fixture.height.getDouble(18, 5), 0D); + assertEquals(13D, fixture.rawHeight.getDouble(18, 5), 0D); + + PregenPerformanceProfile.apply(fixture.engine); + + assertEquals(4_096L * 256L, fixture.height.getMaxSize()); + assertEquals(4_096L * 256L, fixture.rawHeight.getMaxSize()); + assertEquals(1_024L * 256L, fixture.biomes.getMaxSize()); + assertSame(wrapper, fixture.complex.getRawHeightStream()); + assertEquals(47D, wrapper.getDouble(18, 5), 0D); + assertEquals(13D, fixture.height.getDouble(18, 5), 0D); + assertEquals(13D, fixture.rawHeight.getDouble(18, 5), 0D); + } finally { + PregenPerformanceProfile.restore(); + IrisSettings.settings = previousSettings; + } + } + + @Test + public void pregenPreservesActiveHydrologyGrants() throws Exception { + try (Fixture fixture = new Fixture(1_024)) { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + fixture.complex.expandHydrologyNoiseCaches(budget); + long reservedBytes = budget.reservedBytes(); + + fixture.complex.ensureTerrainNoiseCacheSize(4_096); + + fixture.assertCapacities(32_768); + assertEquals(4_096L * 256L, fixture.rawHeight.getMaxSize()); + assertEquals(reservedBytes, budget.reservedBytes()); + fixture.complex.close(); + assertEquals(0L, budget.reservedBytes()); + } + } + + @Test + public void pregenPreservesLargerRuntimeCacheCapacities() throws Exception { + try (Fixture fixture = new Fixture(65_536)) { + fixture.complex.ensureTerrainNoiseCacheSize(4_096); + + fixture.assertCapacities(65_536); + assertEquals(65_536L * 256L, fixture.rawHeight.getMaxSize()); + } + } + + @Test + public void idleRuntimeReservesOnlyOnDemandAndRestoresAfterHydrologyDrains() throws Exception { + try (Fixture fixture = new Fixture(1_024)) { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + HydrologyTileCache cache = fixture.planningCache(budget); + fixture.assertCapacities(1_024); + assertEquals(0L, budget.reservedBytes()); + cache.get(new HydrologyTileKey(0, 0)); + cache.get(new HydrologyTileKey(0, 0)); + fixture.assertCapacities(32_768); + assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes()); + doAnswer(invocation -> { + fixture.assertCapacities(32_768); + assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes()); + cache.close(); + return null; + }).when(fixture.hydrology).close(); + fixture.complex.close(); + fixture.assertCapacities(1_024); + assertEquals(0L, budget.reservedBytes()); + doNothing().when(fixture.hydrology).close(); + fixture.complex.close(); + fixture.complex.expandHydrologyNoiseCaches(budget); + fixture.assertCapacities(1_024); + assertEquals(0L, budget.reservedBytes()); + } + } + + @Test(timeout = 10_000L) + public void concurrentFirstDemandsShareOneReservation() throws Exception { + try (Fixture fixture = new Fixture(1_024); + ExecutorService callers = Executors.newFixedThreadPool(2)) { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + HydrologyTileCache cache = fixture.planningCache(budget); + CountDownLatch entered = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + cache.setTerrainPreparation(() -> { + entered.countDown(); + await(release); + fixture.complex.expandHydrologyNoiseCaches(budget); + }); + Future first = callers.submit(() -> cache.get(new HydrologyTileKey(0, 0))); + Future second = callers.submit(() -> cache.get(new HydrologyTileKey(1, 0))); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + assertEquals(0L, budget.reservedBytes()); + release.countDown(); + first.get(5L, TimeUnit.SECONDS); + second.get(5L, TimeUnit.SECONDS); + fixture.assertCapacities(32_768); + assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes()); + fixture.complex.close(); + assertEquals(0L, budget.reservedBytes()); + } + } + + @Test(timeout = 10_000L) + public void closeDrainsAnAdmittedFirstDemandBeforeReleasingItsGrant() throws Exception { + try (Fixture fixture = new Fixture(1_024); + ExecutorService callers = Executors.newFixedThreadPool(2)) { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + HydrologyTileCache cache = fixture.planningCache(budget); + CountDownLatch preparing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch closing = new CountDownLatch(1); + cache.setTerrainPreparation(() -> { + preparing.countDown(); + await(release); + fixture.complex.expandHydrologyNoiseCaches(budget); + }); + doAnswer(invocation -> { + closing.countDown(); + cache.close(); + return null; + }).when(fixture.hydrology).close(); + Future demand = callers.submit(() -> cache.get(new HydrologyTileKey(0, 0))); + assertTrue(preparing.await(5L, TimeUnit.SECONDS)); + Future close = callers.submit(fixture.complex::close); + assertTrue(closing.await(5L, TimeUnit.SECONDS)); + try { + assertThrows(TimeoutException.class, () -> close.get(100L, TimeUnit.MILLISECONDS)); + assertEquals(0L, budget.reservedBytes()); + } finally { + release.countDown(); + } + demand.get(5L, TimeUnit.SECONDS); + close.get(5L, TimeUnit.SECONDS); + fixture.assertCapacities(1_024); + assertEquals(0L, budget.reservedBytes()); + } + } + + @Test + public void volumetricNaturalHeightReservesItsDistinctFifthCache() throws Exception { + try (Fixture fixture = new Fixture(1_024)) { + CachedDoubleStream2D natural = fixture.heightCache(1_024); + fixture.complex.setUnblendedNaturalHeightStream(natural); + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + fixture.complex.expandHydrologyNoiseCaches(budget); + fixture.assertCapacities(32_768); + assertEquals(32_768L * 256L, natural.getMaxSize()); + assertEquals(5L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes()); + fixture.complex.close(); + assertEquals(1_024L * 256L, natural.getMaxSize()); + assertEquals(0L, budget.reservedBytes()); + } + } + + @Test + public void partialGrantPreservesTheConfiguredFloorForEveryCache() throws Exception { + try (Fixture fixture = new Fixture(1_024)) { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(4L * 80L * 4_096L + 4_095L); + fixture.complex.expandHydrologyNoiseCaches(budget); + fixture.assertCapacities(1_104); + assertEquals(4L * 80L * 4_096L, budget.reservedBytes()); + fixture.complex.close(); + fixture.assertCapacities(1_024); + assertEquals(0L, budget.reservedBytes()); + } + } + + @Test + public void configuredCapacityAboveTargetRemainsUnchanged() throws Exception { + try (Fixture fixture = new Fixture(65_536)) { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + fixture.complex.expandHydrologyNoiseCaches(budget); + fixture.assertCapacities(65_536); + assertEquals(0L, budget.reservedBytes()); + fixture.complex.close(); + fixture.assertCapacities(65_536); + } + } + + @Test + public void detachedAndNonHydrologyRuntimesDoNotReserve() throws Exception { + try (Fixture fixture = new Fixture(1_024)) { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + setField(fixture.complex, "detached", true); + fixture.complex.expandHydrologyNoiseCaches(budget); + fixture.assertCapacities(1_024); + setField(fixture.complex, "detached", false); + fixture.complex.setHydrologyRuntime(null); + fixture.complex.expandHydrologyNoiseCaches(budget); + fixture.assertCapacities(1_024); + assertEquals(0L, budget.reservedBytes()); + } + } + + @Test + public void failedHydrologyDrainRetainsTheReservationUntilRetry() throws Exception { + try (Fixture fixture = new Fixture(1_024)) { + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + HydrologyTileCache cache = fixture.planningCache(budget); + cache.get(new HydrologyTileKey(0, 0)); + doThrow(new IllegalStateException("Hydrology is still draining")).when(fixture.hydrology).close(); + assertThrows(IllegalStateException.class, fixture.complex::close); + fixture.assertCapacities(32_768); + assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes()); + doAnswer(invocation -> { + cache.close(); + return null; + }).when(fixture.hydrology).close(); + fixture.complex.close(); + fixture.assertCapacities(1_024); + assertEquals(0L, budget.reservedBytes()); + } + } + + @Test + public void mappedStreamRetainsOnlyTheFinalCacheAtTheExpandedCapacity() throws Exception { + try (Fixture fixture = new Fixture(1_024)) { + CachedStream2D inner = fixture.biomes; + CachedStream2D mapped = new CachedStream2D<>("mapped", fixture.engine, inner, 1_024); + fixture.complex.setBaseBiomeStream(mapped); + HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE); + fixture.complex.expandHydrologyNoiseCaches(budget); + assertEquals(1_024L * 256L, inner.getMaxSize()); + assertEquals(32_768L * 256L, mapped.getMaxSize()); + fixture.complex.close(); + assertEquals(1_024L * 256L, mapped.getMaxSize()); + } + } + + private static void setField(IrisComplex complex, String name, Object value) throws Exception { + Field field = IrisComplex.class.getDeclaredField(name); + field.setAccessible(true); + field.set(complex, value); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue(latch.await(5L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } + + private static final class Fixture implements AutoCloseable { + private final MockedStatic services = mockStatic(IrisServices.class); + private final Engine engine = mock(Engine.class); + private final IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); + private final IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + private final CachedStream2D biomes; + private final CachedStream2D regions; + private final CachedStream2D bridge; + private final CachedDoubleStream2D height; + private final CachedDoubleStream2D rawHeight; + + private Fixture(int capacity) throws Exception { + services.when(() -> IrisServices.get(PreservationRegistry.class)).thenReturn(mock(PreservationRegistry.class)); + biomes = objectCache(capacity); + regions = objectCache(capacity); + bridge = objectCache(capacity); + height = heightCache(capacity); + rawHeight = heightCache(capacity); + complex.setNaturalHeightStream(height); + complex.setHeightStream(ProceduralStream.ofDouble((x, z) -> 47D)); + setField(complex, "cachedHeightStream", rawHeight); + when(engine.getComplex()).thenReturn(complex); + complex.setBaseBiomeStream(biomes); + complex.setRegionStream(regions); + complex.setBridgeStream(bridge); + complex.setBaseTerrainHeightStream(height); + complex.setUnblendedNaturalHeightStream(height); + complex.setHydrologyRuntime(hydrology); + setField(complex, "resolvedTerrain", mock(ResolvedTerrainProvider.class)); + } + + @Override + public void close() { + services.close(); + } + + private CachedStream2D objectCache(int capacity) { + @SuppressWarnings("unchecked") + ProceduralStream source = mock(ProceduralStream.class); + return new CachedStream2D<>("cache", engine, source, capacity); + } + + private CachedDoubleStream2D heightCache(int capacity) { + return new CachedDoubleStream2D("height", engine, ProceduralStream.ofDouble((x, z) -> x - z), capacity); + } + + private HydrologyTileCache planningCache(HydrologyNoiseCacheBudget budget) { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(mock(HydrologyTile.class)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4); + cache.setTerrainPreparation(() -> complex.expandHydrologyNoiseCaches(budget)); + doAnswer(invocation -> { + cache.close(); + return null; + }).when(hydrology).close(); + return cache; + } + + private void assertCapacities(int chunks) { + assertEquals(chunks * 256L, biomes.getMaxSize()); + assertEquals(chunks * 256L, regions.getMaxSize()); + assertEquals(chunks * 256L, bridge.getMaxSize()); + assertEquals(chunks * 256L, height.getMaxSize()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexSurfaceBiomeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexSurfaceBiomeTest.java index 98ac46d78..67aeaa2e9 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexSurfaceBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexSurfaceBiomeTest.java @@ -129,7 +129,8 @@ public class IrisComplexSurfaceBiomeTest { 70D, constant(base), constant(sea), - constant(base) + constant(base), + constantSlope(0D) ); assertSame(sea, resolved); @@ -152,7 +153,8 @@ public class IrisComplexSurfaceBiomeTest { 63D, constant(base), constant(base), - constant(shore) + constant(shore), + constantSlope(0D) ); assertSame(shore, resolved); @@ -176,12 +178,151 @@ public class IrisComplexSurfaceBiomeTest { 63D, constant(land), constant(aquatic), - constant(aquatic) + constant(aquatic), + constantSlope(0D) ); assertSame(land, resolved); } + @Test + public void zeroShoreMinimumWidthLeavesTheVerticalBandAlone() { + IrisBiome base = mock(IrisBiome.class); + IrisBiome land = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + doReturn(false).when(base).isShore(); + doReturn(false).when(base).isLand(); + doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(0D).when(region).getShoreMinimumWidth(); + + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + 72D, + base, + region, + 12D, + 18D, + 63D, + constant(land), + constant(base), + constant(base), + unsampledSlopes() + ); + + assertSame(land, resolved); + } + + @Test + public void shoreMinimumWidthWidensTheBandOnSteepGround() { + IrisBiome base = mock(IrisBiome.class); + IrisBiome shore = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + doReturn(false).when(base).isShore(); + doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(3D).when(region).getShoreMinimumWidth(); + + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + 74D, + base, + region, + 12D, + 18D, + 63D, + constant(base), + constant(base), + constant(shore), + constantSlope(9D) + ); + + assertSame(shore, resolved); + } + + @Test + public void shoreMinimumWidthDoesNothingOnFlatGround() { + IrisBiome base = mock(IrisBiome.class); + IrisBiome land = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + doReturn(false).when(base).isShore(); + doReturn(false).when(base).isLand(); + doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(3D).when(region).getShoreMinimumWidth(); + + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + 67D, + base, + region, + 12D, + 18D, + 63D, + constant(land), + constant(base), + constant(base), + constantSlope(0D) + ); + + assertSame(land, resolved); + } + + @Test + public void widenedBandStillLeavesDeepWaterAndHighGroundAlone() { + IrisBiome base = mock(IrisBiome.class); + IrisBiome sea = mock(IrisBiome.class); + IrisBiome land = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + doReturn(false).when(base).isShore(); + doReturn(false).when(base).isLand(); + doReturn(false).when(base).isAquatic(); + doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(3D).when(region).getShoreMinimumWidth(); + + assertSame(sea, IrisComplex.resolveSurfaceBiome( + 60D, + base, + region, + 12D, + 18D, + 63D, + constant(land), + constant(sea), + constant(base), + unsampledSlopes() + )); + assertSame(land, IrisComplex.resolveSurfaceBiome( + 200D, + base, + region, + 12D, + 18D, + 63D, + constant(land), + constant(sea), + constant(base), + unsampledSlopes() + )); + } + + @Test + public void shoreBandTopTradesHorizontalWidthForHeightOnSlopes() { + IrisRegion region = mock(IrisRegion.class); + doReturn(3D).when(region).getShoreHeight(12D, 18D); + doReturn(3D).when(region).getShoreMinimumWidth(); + + assertEquals(66D, IrisComplex.shoreBandTop(67D, region, 12D, 18D, 63D, constantSlope(0D)), 0D); + assertEquals(75D, IrisComplex.shoreBandTop(74D, region, 12D, 18D, 63D, constantSlope(9D)), 0D); + assertEquals(75D, IrisComplex.shoreBandTop(74D, region, 12D, 18D, 63D, constantSlope(120D)), 0D); + assertEquals(66D, IrisComplex.shoreBandTop(80D, region, 12D, 18D, 63D, constantSlope(120D)), 0D); + assertEquals(66D, IrisComplex.shoreBandTop(64D, region, 12D, 18D, 63D, unsampledSlopes()), 0D); + } + + private static ProceduralStream constantSlope(double slope) { + return ProceduralStream.ofDouble((x, z) -> slope); + } + + private static ProceduralStream unsampledSlopes() { + return ProceduralStream.ofDouble((x, z) -> { + throw new AssertionError("The shore band sampled the slope for a column that cannot be widened"); + }); + } + private static ProceduralStream constant(IrisBiome biome) { @SuppressWarnings("unchecked") ProceduralStream stream = mock(ProceduralStream.class); @@ -189,6 +330,11 @@ public class IrisComplexSurfaceBiomeTest { return stream; } + @Test + public void regionShoreMinimumWidthDefaultsToZeroSoExistingPacksAreUnchanged() { + assertEquals(0D, new IrisRegion().getShoreMinimumWidth(), 0D); + } + private static ProceduralStream constantType(InferredType type) { return ProceduralStream.of( (x, z) -> type, diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineStudioEntryHydrologyTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineEntryHydrologyTest.java similarity index 94% rename from core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineStudioEntryHydrologyTest.java rename to core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineEntryHydrologyTest.java index 8dac36346..49d622c97 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineStudioEntryHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineEntryHydrologyTest.java @@ -46,7 +46,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -public class IrisEngineStudioEntryHydrologyTest { +public class IrisEngineEntryHydrologyTest { @Test @SuppressWarnings("unchecked") public void queuesScopedEntryDemandWithoutBlockingInitialization() throws Exception { @@ -57,7 +57,7 @@ public class IrisEngineStudioEntryHydrologyTest { GenerationSessionLease lease = mock(GenerationSessionLease.class); IrisHydrologyRuntime hydrology = hydrology(engine); when(engine.getActiveGenerationRuntimeBinding()).thenReturn(binding); - when(engine.acquireGenerationLease("studio_entry_hydrology")).thenReturn(lease); + when(engine.acquireGenerationLease("initial_entry_hydrology")).thenReturn(lease); when(lease.sessionId()).thenReturn(93L); doAnswer(invocation -> { assertSame(engine, IrisContext.require().getEngine()); @@ -67,7 +67,7 @@ public class IrisEngineStudioEntryHydrologyTest { }).when(hydrology).prepareChunkColumns(0, 0); AtomicReference> queued = new AtomicReference<>(); try (MockedStatic scheduler = scheduler(queued)) { - engine.startStudioEntryHydrology(0, 0); + engine.startEntryHydrology(0, 0); assertNotNull(queued.get()); verifyNoInteractions(hydrology, lease); verify(engine, never()).acquireGenerationLease(any()); @@ -86,7 +86,7 @@ public class IrisEngineStudioEntryHydrologyTest { tasks.openBackgroundTaskAdmission(); IrisEngine engine = engine(tasks); GenerationSessionLease lease = mock(GenerationSessionLease.class); - when(engine.acquireGenerationLease("studio_entry_hydrology")).thenReturn(lease); + when(engine.acquireGenerationLease("initial_entry_hydrology")).thenReturn(lease); IrisHydrologyRuntime hydrology = hydrology(engine); IllegalStateException cause = new IllegalStateException("planning failed"); doAnswer(invocation -> { @@ -96,10 +96,10 @@ public class IrisEngineStudioEntryHydrologyTest { AtomicReference> queued = new AtomicReference<>(); try (MockedStatic scheduler = scheduler(queued); MockedStatic logging = mockStatic(IrisLogging.class)) { - engine.startStudioEntryHydrology(0, 0); + engine.startEntryHydrology(0, 0); IllegalStateException failure = assertThrows(IllegalStateException.class, () -> queued.get().call()); assertSame(cause, failure.getCause()); - assertTrue(failure.getMessage().contains("Studio entry hydrology preparation failed at 0,0")); + assertTrue(failure.getMessage().contains("World entry hydrology preparation failed at 0,0")); logging.verify(() -> IrisLogging.reportError(failure)); verify(lease).close(); } @@ -110,14 +110,14 @@ public class IrisEngineStudioEntryHydrologyTest { EngineBackgroundTasks tasks = new EngineBackgroundTasks(); IrisEngine engine = engine(tasks); IllegalStateException failure = assertThrows(IllegalStateException.class, - () -> engine.startStudioEntryHydrology(0, 0)); + () -> engine.startEntryHydrology(0, 0)); assertTrue(failure.getMessage().contains("admission closed")); tasks.openBackgroundTaskAdmission(); RejectedExecutionException rejected = new RejectedExecutionException("scheduler closed"); try (MockedStatic scheduler = mockStatic(J.class)) { scheduler.when(() -> J.a(any(Callable.class))).thenThrow(rejected); assertSame(rejected, assertThrows(RejectedExecutionException.class, - () -> engine.startStudioEntryHydrology(0, 0))); + () -> engine.startEntryHydrology(0, 0))); } verify(engine, never()).acquireGenerationLease(any()); } @@ -133,7 +133,7 @@ public class IrisEngineStudioEntryHydrologyTest { AtomicReference> queued = new AtomicReference<>(); try (MockedStatic scheduler = scheduler(queued); MockedStatic logging = mockStatic(IrisLogging.class)) { - engine.startStudioEntryHydrology(0, 0); + engine.startEntryHydrology(0, 0); assertEquals(0, sessions.activeLeases()); closing(engine).set(true); tasks.closeBackgroundTaskAdmission(); @@ -161,7 +161,7 @@ public class IrisEngineStudioEntryHydrologyTest { AtomicReference> queued = new AtomicReference<>(); try (MockedStatic scheduler = scheduler(queued); MockedStatic logging = mockStatic(IrisLogging.class)) { - engine.startStudioEntryHydrology(0, 0); + engine.startEntryHydrology(0, 0); if (transition) { queued.get().call(); logging.verifyNoInteractions(); @@ -189,7 +189,7 @@ public class IrisEngineStudioEntryHydrologyTest { queued.set(future); return future; }); - engine.startStudioEntryHydrology(0, 0); + engine.startEntryHydrology(0, 0); assertTrue(queued.get().cancel(false)); queued.get().run(); sessions.sealAndAwait("close", 0L, true); @@ -216,7 +216,7 @@ public class IrisEngineStudioEntryHydrologyTest { AtomicReference> queued = new AtomicReference<>(); try (MockedStatic scheduler = scheduler(queued); ExecutorService executor = Executors.newFixedThreadPool(2)) { - engine.startStudioEntryHydrology(0, 0); + engine.startEntryHydrology(0, 0); Future worker = executor.submit(queued.get()); assertTrue(admitted.await(5, TimeUnit.SECONDS)); Future drain = executor.submit(() -> { @@ -328,7 +328,7 @@ public class IrisEngineStudioEntryHydrologyTest { Field lifecycle = IrisEngine.class.getDeclaredField("lifecycleLock"); lifecycle.setAccessible(true); lifecycle.set(engine, new Object()); - doCallRealMethod().when(engine).startStudioEntryHydrology(anyInt(), anyInt()); + doCallRealMethod().when(engine).startEntryHydrology(anyInt(), anyInt()); return engine; } } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationCacheWarmTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationCacheWarmTest.java new file mode 100644 index 000000000..5c600b41f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationCacheWarmTest.java @@ -0,0 +1,178 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.runtime.EngineBackgroundTasks.BackgroundTaskDrain; +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.decoration.IrisOreGenerator; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.world.task.J; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +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.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +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.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +public class IrisEngineGenerationCacheWarmTest { + @Test + public void newWorldAndStudioReturnBeforeWarmButReadinessWaits() throws Exception { + for (IrisEngine.InitializationMode mode : new IrisEngine.InitializationMode[]{ + IrisEngine.InitializationMode.WORLD_CREATION, IrisEngine.InitializationMode.STUDIO}) { + Fixture fixture = fixture(); + AtomicReference> queued = new AtomicReference<>(); + try (MockedStatic scheduling = scheduler(queued); + MockedStatic warmer = mockStatic(GenerationCacheWarmer.class)) { + fixture.engine().prepareGenerationCaches(mode, System.nanoTime()); + assertNotNull(queued.get()); + assertFalse(fixture.ready().isDone()); + warmer.verifyNoInteractions(); + queued.get().call(); + fixture.engine().awaitGenerationCacheWarm(); + warmer.verify(() -> GenerationCacheWarmer.warm(fixture.engine())); + fixture.tasks().drainBackgroundTasks("test").requireComplete("test"); + } + } + } + + @Test + public void restoredRuntimeStillWarmsSynchronously() throws Exception { + Fixture fixture = fixture(); + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic warmer = mockStatic(GenerationCacheWarmer.class)) { + fixture.engine().prepareGenerationCaches(IrisEngine.InitializationMode.RUNTIME, System.nanoTime()); + assertTrue(fixture.ready().isDone()); + scheduling.verifyNoInteractions(); + warmer.verify(() -> GenerationCacheWarmer.warm(fixture.engine())); + } + } + + @Test + public void failedWarmIsLoggedAndBothGenerationPathsRejectItsCause() throws Exception { + Fixture fixture = fixture(); + AtomicReference> queued = new AtomicReference<>(); + IllegalStateException expected = new IllegalStateException("cache warm failed"); + IrisData data = mock(IrisData.class); + IrisOreGenerator ore = mock(IrisOreGenerator.class); + IrisBiome biome = new IrisBiome(); + biome.setLoadKey("failing-ore"); + biome.setOres(new KList<>(ore)); + when(fixture.engine().getData()).thenReturn(data); + when(fixture.engine().getSeedManager()).thenReturn(mock(SeedManager.class)); + when(fixture.engine().getAllBiomes()).thenReturn(new KList<>(biome)); + doThrow(expected).when(ore).warm(any(RNG.class), same(data)); + try (MockedStatic scheduling = scheduler(queued); + MockedStatic logging = mockStatic(IrisLogging.class)) { + fixture.engine().prepareGenerationCaches(IrisEngine.InitializationMode.WORLD_CREATION, System.nanoTime()); + assertSame(expected, assertThrows(IllegalStateException.class, () -> queued.get().call())); + assertTrue(fixture.ready().isCompletedExceptionally()); + logging.verify(() -> IrisLogging.reportError(expected)); + assertSame(expected, assertThrows(IllegalStateException.class, + () -> fixture.engine().generate(0, 0, null, null, false)).getCause()); + assertSame(expected, assertThrows(IllegalStateException.class, + () -> fixture.engine().generateMatter(0, 0, false, null)).getCause()); + fixture.tasks().drainBackgroundTasks("test").requireComplete("test"); + } + } + + @Test + public void runningWarmRemainsOwnedUntilShutdownDrainCompletes() throws Exception { + Fixture fixture = fixture(); + AtomicReference> queued = new AtomicReference<>(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(2); + try (MockedStatic scheduling = scheduler(queued)) { + fixture.engine().prepareGenerationCaches(IrisEngine.InitializationMode.WORLD_CREATION, System.nanoTime()); + Future warming = workers.submit(() -> { + try (MockedStatic warmer = mockStatic(GenerationCacheWarmer.class)) { + warmer.when(() -> GenerationCacheWarmer.warm(fixture.engine())).thenAnswer(invocation -> { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }); + return queued.get().call(); + } + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + fixture.tasks().closeBackgroundTaskAdmission(); + Future drain = workers.submit(() -> fixture.tasks().drainBackgroundTasks("close")); + assertThrows(TimeoutException.class, () -> drain.get(100, TimeUnit.MILLISECONDS)); + assertFalse(fixture.ready().isDone()); + release.countDown(); + warming.get(5, TimeUnit.SECONDS); + drain.get(5, TimeUnit.SECONDS).requireComplete("close"); + fixture.engine().awaitGenerationCacheWarm(); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void closedTaskAdmissionRejectsNewWorldWarmSynchronously() throws Exception { + Fixture fixture = fixture(); + fixture.tasks().closeBackgroundTaskAdmission(); + assertThrows(IllegalStateException.class, + () -> fixture.engine().prepareGenerationCaches(IrisEngine.InitializationMode.WORLD_CREATION, System.nanoTime())); + } + + private static Fixture fixture() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + EngineBackgroundTasks tasks = new EngineBackgroundTasks(); + tasks.openBackgroundTaskAdmission(); + CompletableFuture ready = new CompletableFuture<>(); + set(engine, "backgroundTasks", tasks); + set(engine, "generationCacheWarm", ready); + set(engine, "diagnostics", mock(EngineDiagnostics.class)); + doCallRealMethod().when(engine).prepareGenerationCaches(any(), anyLong()); + doCallRealMethod().when(engine).awaitGenerationCacheWarm(); + doCallRealMethod().when(engine).generate(0, 0, null, null, false); + doCallRealMethod().when(engine).generateMatter(0, 0, false, null); + return new Fixture(engine, tasks, ready); + } + + @SuppressWarnings("unchecked") + private static MockedStatic scheduler(AtomicReference> queued) { + MockedStatic scheduling = mockStatic(J.class); + scheduling.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> { + Callable task = invocation.getArgument(0); + queued.set(task); + return new FutureTask<>(task); + }); + return scheduling; + } + + private static void set(IrisEngine engine, String name, Object value) throws Exception { + Field field = IrisEngine.class.getDeclaredField(name); + field.setAccessible(true); + field.set(engine, value); + } + + private record Fixture(IrisEngine engine, EngineBackgroundTasks tasks, CompletableFuture ready) { + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/PreservationSVCTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/PreservationSVCTest.java index 2fcdfe554..1300a288e 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/PreservationSVCTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/PreservationSVCTest.java @@ -6,10 +6,19 @@ import org.mockito.MockedStatic; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; @@ -57,6 +66,98 @@ public class PreservationSVCTest { assertFalse(new PreservationSVC().hasActiveResources()); } + @Test(timeout = 10_000L) + public void postShutdownWaitsForInterruptedThreadsAndExecutorsToFinish() throws Exception { + PreservationSVC preservation = spy(new PreservationSVC()); + AtomicReference postShutdown = new AtomicReference<>(); + doAnswer(invocation -> { + postShutdown.set(invocation.getArgument(0)); + return null; + }).when(preservation).postShutdown(any(Runnable.class)); + CountDownLatch started = new CountDownLatch(2); + CountDownLatch interrupted = new CountDownLatch(2); + CountDownLatch finish = new CountDownLatch(1); + Thread thread = new Thread(() -> drainAfterInterrupt(started, interrupted, finish), "preserved-worker"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + preservation.register(thread); + preservation.register(executor); + thread.start(); + executor.submit(() -> drainAfterInterrupt(started, interrupted, finish)); + try (ExecutorService closer = Executors.newSingleThreadExecutor(); + MockedStatic data = mockStatic(IrisData.class)) { + assertTrue(started.await(5L, TimeUnit.SECONDS)); + preservation.onDisable(); + assertNotNull(postShutdown.get()); + Future stopped = closer.submit(postShutdown.get()); + try { + assertTrue(interrupted.await(5L, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> stopped.get(100L, TimeUnit.MILLISECONDS)); + assertTrue(preservation.hasActiveResources()); + } finally { + finish.countDown(); + } + stopped.get(5L, TimeUnit.SECONDS); + assertFalse(preservation.hasActiveResources()); + } finally { + finish.countDown(); + thread.interrupt(); + thread.join(5_000L); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 10_000L) + public void timedOutWorkersRemainTrackedWithTheirIdentityInTheFailure() throws Exception { + PreservationSVC preservation = new PreservationSVC(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + CountDownLatch finish = new CountDownLatch(1); + Thread worker = new Thread(() -> drainAfterInterrupt(started, interrupted, finish), "stalled-preserved-worker"); + preservation.register(worker); + worker.start(); + try { + assertTrue(started.await(5L, TimeUnit.SECONDS)); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> preservation.shutdownResources(25L)); + assertTrue(failure.getMessage().contains("stalled-preserved-worker")); + assertTrue(preservation.hasActiveResources()); + } finally { + finish.countDown(); + worker.interrupt(); + worker.join(5_000L); + } + assertFalse(preservation.hasActiveResources()); + } + + @Test(timeout = 5_000L) + public void shutdownNeverInterruptsOrWaitsForItsCallingThread() { + PreservationSVC preservation = new PreservationSVC(); + preservation.register(Thread.currentThread()); + + preservation.shutdownResources(1_000L); + + assertFalse(Thread.currentThread().isInterrupted()); + assertTrue(preservation.hasActiveResources()); + } + + private static void drainAfterInterrupt(CountDownLatch started, CountDownLatch interrupted, CountDownLatch finish) { + started.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException expected) { + interrupted.countDown(); + } + boolean released = false; + while (!released) { + try { + finish.await(); + released = true; + } catch (InterruptedException ignored) { + } + } + } + private static void awaitStop(CountDownLatch started, CountDownLatch stop) { started.countDown(); try { diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierWallShoreTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierWallShoreTest.java new file mode 100644 index 000000000..c128b7bc2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisPostModifierWallShoreTest.java @@ -0,0 +1,138 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 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.generation.stage; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomePaletteLayer; +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.ChunkedDataCache; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; + +/** + * The wall patcher fires on any neighbour more than two blocks lower, and neighbour heights come from + * trueHeight, which excludes fluid. Every coastal column therefore sees a seaward drop, so without a + * shore check the beach surface is replaced with the wall palette. + */ +public class IrisPostModifierWallShoreTest { + private static final int PLANE_WIDTH = 3; + private static final int SURFACE = 64; + private static final int HUNK_HEIGHT = 80; + private static final PlatformBlockState GROUND = state("minecraft:sand"); + private static final PlatformBlockState WALL = state("minecraft:stone"); + private static final PlatformBlockState AIR = state("minecraft:air"); + + @Test + public void shoreColumnsKeepTheirSurfaceBlockBesideASeawardDrop() throws ReflectiveOperationException { + assertSame(GROUND, surfaceAfterPost(true, true, false)); + } + + @Test + public void inlandColumnsStillGetWallPatchedBesideADrop() throws ReflectiveOperationException { + assertSame(WALL, surfaceAfterPost(false, true, false)); + } + + @Test + public void disabledWallsAndRiverColumnsAreUntouched() throws ReflectiveOperationException { + assertSame(GROUND, surfaceAfterPost(false, false, false)); + assertSame(GROUND, surfaceAfterPost(false, true, true)); + } + + /** + * Drives the private per-column pass directly. The heights plane is the only input that decides + * whether the wall patcher fires, and building it through onModify would need a live mantle. + */ + @SuppressWarnings("unchecked") + private PlatformBlockState surfaceAfterPost(boolean shore, boolean walls, boolean river) throws ReflectiveOperationException { + Hunk data = Hunk.newHunk(1, HUNK_HEIGHT, 1); + + for (int y = 0; y < HUNK_HEIGHT; y++) { + data.set(0, y, 0, y <= SURFACE ? GROUND : AIR); + } + + IrisBiomePaletteLayer wallLayer = mock(IrisBiomePaletteLayer.class); + doReturn(new KList().qadd(new IrisBlockData())).when(wallLayer).getPalette(); + doReturn(WALL).when(wallLayer).get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any()); + + IrisBiome biome = mock(IrisBiome.class); + doReturn(wallLayer).when(biome).getWall(); + doReturn(shore).when(biome).isShore(); + + ChunkedDataCache biomes = mock(ChunkedDataCache.class); + doReturn(biome).when(biomes).get(0, 0); + ChunkContext context = mock(ChunkContext.class); + doReturn(biomes).when(context).getBiome(); + + ProceduralStream carveWeights = mock(ProceduralStream.class); + doReturn(river ? 1D : 0D).when(carveWeights).get(anyDouble(), anyDouble()); + ProceduralStream waterSurface = mock(ProceduralStream.class); + doReturn(0D).when(waterSurface).get(anyDouble(), anyDouble()); + + IrisComplex complex = mock(IrisComplex.class); + doReturn(carveWeights).when(complex).getRiverCarveWeightStream(); + doReturn(waterSurface).when(complex).getRiverWaterSurfaceStream(); + + Engine engine = mock(Engine.class); + doReturn(complex).when(engine).getComplex(); + doReturn(mock(IrisData.class)).when(engine).getData(); + + IrisPostModifier modifier = mock(IrisPostModifier.class, CALLS_REAL_METHODS); + doReturn(engine).when(modifier).getEngine(); + Field rng = IrisPostModifier.class.getDeclaredField("rng"); + rng.setAccessible(true); + rng.set(modifier, new RNG(88L)); + + // Only the +x neighbour is low, so neither nib pass can fire and the wall patcher is the sole writer. + int[] heights = new int[PLANE_WIDTH * PLANE_WIDTH]; + Arrays.fill(heights, SURFACE); + heights[5] = SURFACE - 4; + + Method post = IrisPostModifier.class.getDeclaredMethod("post", int.class, int.class, Hunk.class, + int.class, int.class, ChunkContext.class, int[].class, int.class, boolean.class, boolean.class); + post.setAccessible(true); + post.invoke(modifier, 0, 0, data, 0, 0, context, heights, PLANE_WIDTH, walls, false); + return data.get(0, SURFACE, 0); + } + + private static PlatformBlockState state(String key) { + PlatformBlockState state = mock(PlatformBlockState.class); + doReturn(key).when(state).key(); + doReturn(key).when(state).materialKey(); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java index a64538719..5ed6dd797 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java @@ -270,7 +270,7 @@ public class IrisTerrainNormalActuatorHydrologyTest { assertSame(rock, output.get(0, 58, 0)); material.setEnabled(false); new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context); - assertSame(rock, output.get(0, 60, 0)); + assertSame(grass, output.get(0, 60, 0)); Terrain3DColumn carved = mock(Terrain3DColumn.class); when(carved.spanCount()).thenReturn(2); @@ -287,7 +287,7 @@ public class IrisTerrainNormalActuatorHydrologyTest { anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>()); new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context); assertSame(grass, output.get(0, 60, 0)); - assertSame(rock, output.get(0, 40, 0)); + assertSame(grass, output.get(0, 40, 0)); material.setEnabled(true); new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context); assertSame(painted, output.get(0, 60, 0)); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java new file mode 100644 index 000000000..5638823da --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorStrataCutTest.java @@ -0,0 +1,194 @@ +package art.arcane.iris.generation.stage; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.context.ChunkedDataCache; +import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; +import art.arcane.iris.generation.hydrology.HydrologyColumnSample; +import art.arcane.iris.generation.hydrology.HydrologyFeatureRef; +import art.arcane.iris.generation.hydrology.HydrologyFeatureType; +import art.arcane.iris.generation.hydrology.IrisHydrology; +import art.arcane.iris.generation.image.IrisImageMapRuntime; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.generation.terrain.Terrain3DColumn; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.stream.interpolation.Interpolated; +import org.junit.Rule; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisTerrainNormalActuatorStrataCutTest { + @Rule + public final PlatformBinding platform = PlatformBinding.mockPlatform(); + + @Test + public void exposedBankCutDeeperThanThePaletteShowsTheDeepestAuthoredLayer() { + Fixture fixture = new Fixture(); + + Hunk output = fixture.actuate(); + + assertBlock(fixture.dirt, output.get(0, 40, 0)); + assertBlock(fixture.dirt, output.get(0, 39, 0)); + } + + @Test + public void exposedBankBelowTheAuthoredSoilStillFallsThroughToRock() { + Fixture fixture = new Fixture(); + + Hunk output = fixture.actuate(); + + assertBlock(fixture.rock, output.get(0, 38, 0)); + } + + @Test + public void uncutColumnsKeepTheirExactPaletteDepths() { + Fixture fixture = new Fixture(); + + Hunk output = fixture.actuate(); + + assertBlock(fixture.grass, output.get(0, 60, 0)); + assertBlock(fixture.dirt, output.get(0, 59, 0)); + assertBlock(fixture.rock, output.get(0, 58, 0)); + } + + @Test + public void aCutClampsToTheDeepestAuthoredLayerAndNoFurther() { + assertEquals(3, IrisTerrainNormalActuator.strataIndex(0, 6, 4)); + assertEquals(3, IrisTerrainNormalActuator.strataIndex(1, 6, 4)); + assertEquals(3, IrisTerrainNormalActuator.strataIndex(2, 6, 4)); + assertEquals(3, IrisTerrainNormalActuator.strataIndex(3, 6, 4)); + assertEquals(4, IrisTerrainNormalActuator.strataIndex(4, 6, 4)); + } + + @Test + public void aModestCutStillShiftsTheStrataInsteadOfClamping() { + assertEquals(1, IrisTerrainNormalActuator.strataIndex(0, 1, 4)); + assertEquals(2, IrisTerrainNormalActuator.strataIndex(1, 1, 4)); + assertEquals(3, IrisTerrainNormalActuator.strataIndex(2, 1, 4)); + assertEquals(3, IrisTerrainNormalActuator.strataIndex(3, 1, 4)); + assertEquals(4, IrisTerrainNormalActuator.strataIndex(4, 1, 4)); + } + + @Test + public void aZeroCutIndexesTheBiomePaletteByDepthAlone() { + for (int paletteSize = 0; paletteSize <= 16; paletteSize++) { + for (int depth = 0; depth <= 32; depth++) { + assertEquals(depth, IrisTerrainNormalActuator.strataIndex(depth, 0, paletteSize)); + } + } + } + + private static void assertBlock(PlatformBlockState expected, PlatformBlockState actual) { + assertEquals(expected.key(), actual == null ? null : actual.key()); + assertSame(expected, actual); + } + + /** + * A bank column whose upper span is untouched (cut 0) and whose lower span lost four blocks to + * erosion, over a two block soil palette. + */ + private static final class Fixture { + private final PlatformBlockState rock = mock(PlatformBlockState.class); + private final PlatformBlockState grass = mock(PlatformBlockState.class); + private final PlatformBlockState dirt = mock(PlatformBlockState.class); + private final Engine engine = mock(Engine.class); + private final ChunkContext context = mock(ChunkContext.class); + + private Fixture() { + when(rock.key()).thenReturn("minecraft:stone"); + when(grass.key()).thenReturn("minecraft:grass_block"); + when(dirt.key()).thenReturn("minecraft:dirt"); + + IrisComplex complex = mock(IrisComplex.class); + IrisData data = mock(IrisData.class); + IrisBiome biome = mock(IrisBiome.class); + IrisRegion region = new IrisRegion(); + IrisDimension dimension = new IrisDimension() + .setHydrology(new IrisHydrology()) + .setBedrock(false) + .setHideOresForHiddenOre(true); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(engine.getData()).thenReturn(data); + when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); + when(complex.getImageMapRuntime()).thenReturn(mock(IrisImageMapRuntime.class)); + when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 0D)); + when(complex.sampleHydrologyColumn(11, -4)).thenReturn(new HydrologyColumnSample( + 11, -4, 80, 0, false, "parent", List.of(bankLayer()))); + + Terrain3DColumn carved = mock(Terrain3DColumn.class); + when(carved.spanCount()).thenReturn(2); + when(carved.floor(1)).thenReturn(60); + when(carved.ceiling(1)).thenReturn(50); + when(carved.floor(0)).thenReturn(40); + when(carved.ceiling(0)).thenReturn(0); + Terrain3DColumn natural = mock(Terrain3DColumn.class); + when(natural.surfaceY(60)).thenReturn(60); + when(natural.surfaceY(40)).thenReturn(44); + when(complex.terrainColumn(eq(11), eq(-4), any(HydrologyColumnSample.class))).thenReturn(carved); + when(complex.naturalTerrainColumn(11, -4)).thenReturn(natural); + + when(context.getRoundedHeight(0, 0)).thenReturn(60); + when(context.getBiome()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> biome)), 11, -4, false)); + when(context.getRegion()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), 11, -4, false)); + when(context.getRock()).thenReturn(new ChunkedDataCache<>( + ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), 11, -4, false)); + when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), + anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>(List.of(grass, dirt))); + when(biome.generateCeilingLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), + anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>()); + } + + private Hunk actuate() { + Hunk output = Hunk.newArrayHunk(1, 64, 1); + new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context); + return output; + } + } + + private static HydrologyColumnLayer bankLayer() { + return new HydrologyColumnLayer( + new HydrologyFeatureRef(21L, HydrologyFeatureType.RIFFLE, 22L, 23L, 11, 63, -4, 1, 0, false), + 60, + 63, + 63, + false, + false, + true, + false, + false, + false, + true, + false, + false, + "default", + "river", + "mouth", + "shore", + "bank", + "cave" + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorDatapackFingerprintTest.java index 3cc8a33b3..33630df43 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorDatapackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorDatapackFingerprintTest.java @@ -198,6 +198,49 @@ public class ServerConfiguratorDatapackFingerprintTest { packsDir.toPath().resolve("overworld").toFile())); } + @Test + public void fingerprintsIgnoreNestedFinderMetadata() throws Exception { + File packsDir = tmp.newFolder("finder-metadata-packs"); + Path pack = packsDir.toPath().resolve("overworld"); + Path dimension = pack.resolve("dimensions/overworld.json"); + Path metadata = pack.resolve("objects/nested/.DS_Store"); + Files.createDirectories(dimension.getParent()); + Files.createDirectories(metadata.getParent()); + Files.writeString(dimension, "authored", StandardCharsets.UTF_8); + ServerConfigurator.PackContentSnapshot before = ServerConfigurator.computePackContentSnapshot(packsDir); + String metadataBefore = ServerConfigurator.computePackMetadataDigest(packsDir); + + for (String content : new String[]{"initial Finder layout", "updated Finder layout with different length"}) { + Files.writeString(metadata, content, StandardCharsets.UTF_8); + ServerConfigurator.PackContentSnapshot after = ServerConfigurator.computePackContentSnapshot(packsDir); + assertEquals(before, after); + assertEquals(before.packContents().get("overworld"), + ServerConfigurator.computePackTreeFingerprint(pack.toFile())); + assertEquals(metadataBefore, ServerConfigurator.computePackMetadataDigest(packsDir)); + } + } + + @Test + public void fingerprintsStillDetectNestedResourceChangesAlongsideFinderMetadata() throws Exception { + File packsDir = tmp.newFolder("finder-resource-packs"); + Path pack = packsDir.toPath().resolve("overworld"); + Path resource = pack.resolve("objects/nested/tree.iob"); + Files.createDirectories(resource.getParent()); + Files.writeString(resource, "aaaa", StandardCharsets.UTF_8); + Files.writeString(resource.resolveSibling(".DS_Store"), "Finder layout", StandardCharsets.UTF_8); + FileTime originalMtime = Files.getLastModifiedTime(resource); + ServerConfigurator.PackContentSnapshot before = ServerConfigurator.computePackContentSnapshot(packsDir); + + Files.writeString(resource, "bbbb", StandardCharsets.UTF_8); + Files.setLastModifiedTime(resource, originalMtime); + ServerConfigurator.PackContentSnapshot after = ServerConfigurator.computePackContentSnapshot(packsDir); + + assertNotEquals(before.content(), after.content()); + assertNotEquals(before.packContents().get("overworld"), after.packContents().get("overworld")); + assertEquals(after.packContents().get("overworld"), + ServerConfigurator.computePackTreeFingerprint(pack.toFile())); + } + @Test public void resolvePackFingerprintReusesCachedContentWhileMetadataIsUnchanged() throws Exception { File packsDir = tmp.newFolder("two-tier-packs"); diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index fa4774533..a477bee3f 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/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 = "545cbfcdaa4b4c8dd03fca96f6b2617c37bbe9bf73d2011679d2fabcf3f2a6be"; + private static final String EXPECTED_SCHEMA_DIGEST = "5f041feb24213ee5b7e2e06c84b113ec0242b8f8e04c73dde6ea2356944d93fd"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceForkHygieneTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceForkHygieneTest.java new file mode 100644 index 000000000..55dfbb6d5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceForkHygieneTest.java @@ -0,0 +1,120 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 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.platform.bukkit; + +import org.junit.Test; +import org.junit.runner.JUnitCore; +import org.junit.runner.Result; + +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * org.bukkit.Registry resolves its static fields through a live RegistryAccess, which no unit test binds. + * A failed static initialiser is permanent and JVM-wide, so a probe that touches Registry without one + * breaks every later test in the same Gradle fork - a try/catch around the probe cannot undo it. + * Replaying the conformance suite against fork-local copies of the Bukkit classes makes that leak + * observable without poisoning the fork this test runs in. + */ +public class BukkitSpiConformanceForkHygieneTest { + private static final String CONFORMANCE = "art.arcane.iris.platform.bukkit.BukkitSpiConformanceTest"; + private static final String REGISTRY = "org.bukkit.Registry"; + + @Test + public void conformanceSuiteLeavesBukkitRegistryInitialisable() throws Exception { + ForkLocalClassLoader isolated = new ForkLocalClassLoader(getClass().getClassLoader()); + Result result = JUnitCore.runClasses(Class.forName(CONFORMANCE, true, isolated)); + assertTrue("the conformance suite did not run in isolation", result.getRunCount() > 0); + assertEquals(result.getFailures().toString(), 0, result.getFailureCount()); + + Throwable initialisation = null; + + try { + Class.forName(REGISTRY, true, isolated); + } catch (Throwable thrown) { + initialisation = thrown; + } + + assertFalse(REGISTRY + " was left permanently un-initialisable by the conformance suite: " + initialisation, + initialisation instanceof NoClassDefFoundError); + } + + /** Child-first for the Bukkit and Iris classes under test, parent for JUnit, Mockito and the JDK. */ + private static final class ForkLocalClassLoader extends ClassLoader { + private static final String[] FORK_LOCAL = { + "org.bukkit.", + "io.papermc.", + "com.destroystokyo.", + "art.arcane.iris.", + "art.arcane.volmlib." + }; + + static { + registerAsParallelCapable(); + } + + ForkLocalClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + + if (loaded == null) { + loaded = forkLocal(name) ? define(name) : getParent().loadClass(name); + } + + if (resolve) { + resolveClass(loaded); + } + + return loaded; + } + } + + private Class define(String name) throws ClassNotFoundException { + try (InputStream source = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { + if (source == null) { + throw new ClassNotFoundException(name); + } + + byte[] bytes = source.readAllBytes(); + return defineClass(name, bytes, 0, bytes.length); + } catch (IOException unreadable) { + throw new ClassNotFoundException(name, unreadable); + } + } + + private static boolean forkLocal(String name) { + for (String prefix : FORK_LOCAL) { + if (name.startsWith(prefix)) { + return true; + } + } + + return false; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java index 598552c53..d300afc53 100644 --- a/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/BukkitSpiConformanceTest.java @@ -253,6 +253,9 @@ public class BukkitSpiConformanceTest { @Test public void blockStatePropertiesAreKeyedByNamespacedBlock() { + // Pre-flight before touching the registry: org.bukkit.Registry fail-initialises without a live + // RegistryAccess, and that is permanent and JVM-wide, so the catch below is already too late. + Assume.assumeTrue("live Bukkit registry unavailable in this environment", liveRegistriesAvailable()); Map> states; try { states = registries().blockStateProperties(); diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java index 814cb35e5..d3f237754 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorCloseTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.platform.generation; -import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.ReactiveFolder; import art.arcane.volmlib.util.scheduling.Looper; @@ -25,8 +27,39 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class BukkitChunkGeneratorCloseTest { + @Test + public void failedCloseReleasesInitialEntryDeferral() throws Exception { + CloseFixture fixture = new CloseFixture(); + fixture.beginInitialEntry(); + doThrow(new IllegalStateException("Planner remains active")).when(fixture.engine).close(); + CompletableFuture operation = fixture.queueOperation(); + CompletableFuture closed = fixture.generator.closeAsync(); + + operation.completeExceptionally(assertThrows(IllegalStateException.class, fixture.operation.get()::run)); + + assertTrue(closed.isCompletedExceptionally()); + verify(fixture.hydrology).setNeighbourPrefetchEnabled(false); + verify(fixture.hydrology).setNeighbourPrefetchEnabled(true); + fixture.generator.completeInitialEntry(); + verify(fixture.hydrology, times(1)).setNeighbourPrefetchEnabled(true); + } + + @Test + public void rejectedCloseDispatchReleasesInitialEntryDeferral() throws Exception { + CloseFixture fixture = new CloseFixture(); + fixture.beginInitialEntry(); + doThrow(new IllegalStateException("Scheduler unavailable")).when(fixture.generator) + .withExclusiveControlFuture(any(Runnable.class)); + + assertTrue(fixture.generator.closeAsync().isCompletedExceptionally()); + + assertFalse(fixture.generator.isClosing()); + verify(fixture.hydrology).setNeighbourPrefetchEnabled(true); + } + @Test public void asynchronousCloseFailureCanRetryWithoutReopeningGeneration() throws Exception { CloseFixture fixture = new CloseFixture(); @@ -121,12 +154,16 @@ public class BukkitChunkGeneratorCloseTest { private static final class CloseFixture { private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); - private final Engine engine = mock(Engine.class); + private final IrisEngine engine = mock(IrisEngine.class); + private final IrisComplex complex = mock(IrisComplex.class); + private final IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); private final AtomicReference operation = new AtomicReference<>(); private final AtomicReference> watcherHotload = new AtomicReference<>(); private final ConcurrentLinkedQueue> operations = new ConcurrentLinkedQueue<>(); private CloseFixture() throws Exception { + when(engine.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(hydrology); setField(generator, "closeFuture", new AtomicReference>()); setField(generator, "watcherHotload", watcherHotload); setField(generator, "startupReady", CompletableFuture.completedFuture(null)); @@ -144,5 +181,11 @@ public class BukkitChunkGeneratorCloseTest { operations.add(future); return future; } + + private void beginInitialEntry() { + generator.setEngine(null); + generator.beginInitialEntry(false); + generator.publishInitializedEngine(engine); + } } } diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorHistoryTargetTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorHistoryTargetTest.java new file mode 100644 index 000000000..4ce723513 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorHistoryTargetTest.java @@ -0,0 +1,152 @@ +package art.arcane.iris.platform.generation; + +import art.arcane.iris.generation.cache.AtomicCache; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EngineTarget; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.world.history.GenerationHistory; +import org.junit.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Path; +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.mockito.Mockito.CALLS_REAL_METHODS; +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 BukkitChunkGeneratorHistoryTargetTest { + @Test + public void unchangedVerifiedPackRetainsThePreviouslyLoadedTarget() throws Exception { + Fixture fixture = new Fixture(); + + fixture.generator.prepareGenerationHistoryTarget(128); + + assertSame(fixture.target, fixture.generator.getTarget()); + verify(fixture.history).prepareCurrentGenerator(128); + verify(fixture.history).activePackRoot(); + verify(fixture.data, never()).close(); + verify(fixture.data, never()).dump(); + verify(fixture.data, never()).clearLists(); + } + + @Test + public void changedActivePackInvalidatesAndClosesTheDetachedTarget() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.history.activePackRoot()).thenReturn(Path.of("generation", "packs", "next")); + + fixture.generator.prepareGenerationHistoryTarget(128); + + assertNull(fixture.targets.getIfPresent()); + verify(fixture.data).close(); + } + + @Test + public void closedTargetCannotBeReusedEvenWhenItsPackIsUnchanged() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.data.isClosed()).thenReturn(true); + + fixture.generator.prepareGenerationHistoryTarget(128); + + assertNull(fixture.targets.getIfPresent()); + verify(fixture.history).activePackRoot(); + verify(fixture.data, never()).close(); + } + + @Test + public void finalPackVerificationStillRejectsTamperingAfterPreparation() throws Exception { + Fixture fixture = new Fixture(); + IOException failure = new IOException("Active pack fingerprint changed"); + when(fixture.history.activePackRoot()).thenThrow(failure); + + assertSame(failure, assertThrows(IOException.class, + () -> fixture.generator.prepareGenerationHistoryTarget(128))); + + verify(fixture.history).prepareCurrentGenerator(128); + verify(fixture.data, never()).close(); + } + + @Test + public void preparationFailureCannotBeHiddenByAnExistingTarget() throws Exception { + Fixture fixture = new Fixture(); + IOException failure = new IOException("Pending activation is invalid"); + doThrow(failure).when(fixture.history).prepareCurrentGenerator(128); + + assertSame(failure, assertThrows(IOException.class, + () -> fixture.generator.prepareGenerationHistoryTarget(128))); + + verify(fixture.history, never()).activePackRoot(); + verify(fixture.data, never()).close(); + } + + @Test + public void discardedTargetDoesNotCloseDataHeldByAnotherEngine() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.history.activePackRoot()).thenReturn(Path.of("generation", "packs", "next")); + when(fixture.data.getEngines()).thenReturn(List.of(mock(Engine.class))); + + fixture.generator.prepareGenerationHistoryTarget(128); + + assertNull(fixture.targets.getIfPresent()); + verify(fixture.data, never()).close(); + } + + @Test + public void discardedTargetDoesNotCloseDeferredStartupData() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.history.activePackRoot()).thenReturn(Path.of("generation", "packs", "next")); + setField(fixture.generator, "startupTarget", fixture.target); + + fixture.generator.prepareGenerationHistoryTarget(128); + + assertNull(fixture.targets.getIfPresent()); + assertSame(fixture.target, fixture.generator.getTarget()); + verify(fixture.data, never()).close(); + } + + @Test + public void discardedTargetDoesNotClosePublishedEngineData() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.history.activePackRoot()).thenReturn(Path.of("generation", "packs", "next")); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(fixture.data); + fixture.generator.setEngine(engine); + + fixture.generator.prepareGenerationHistoryTarget(128); + + assertNull(fixture.targets.getIfPresent()); + verify(fixture.data, never()).close(); + } + + private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception { + Field field = BukkitChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + field.set(generator, value); + } + + private static final class Fixture { + private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + private final GenerationHistory history = mock(GenerationHistory.class); + private final AtomicCache targets = new AtomicCache<>(); + private final EngineTarget target = mock(EngineTarget.class); + private final IrisData data = mock(IrisData.class); + + private Fixture() throws Exception { + Path packRoot = Path.of("generation", "packs", "current"); + when(history.activePackRoot()).thenReturn(packRoot); + when(target.getData()).thenReturn(data); + when(data.getDataFolder()).thenReturn(packRoot.toFile()); + when(data.getEngines()).thenReturn(List.of()); + targets.aquireOrThrow(() -> target); + setField(generator, "generationHistory", history); + setField(generator, "targetCache", targets); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorInitialSpawnTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorInitialSpawnTest.java index 3268fea5f..b18d3e268 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorInitialSpawnTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorInitialSpawnTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.platform.generation; import art.arcane.iris.world.task.J; +import art.arcane.iris.world.runtime.WorldRuntimeControlService; import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.World; @@ -9,21 +10,28 @@ import org.mockito.MockedStatic; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; 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.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 BukkitChunkGeneratorInitialSpawnTest { @@ -58,9 +66,182 @@ public class BukkitChunkGeneratorInitialSpawnTest { assertFalse(readiness.isDone()); regionTask.get().run(); readiness.get(5L, TimeUnit.SECONDS); + verify(world).getChunkAtAsync(0, 0, true); + verify(world, never()).getChunkAtAsync(0, 0, true, true); } } + @Test + public void freshConsoleWorldRequestsOnlyCanonicalChunkAndWaitsForRegionPlacement() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + generator.beginInitialEntry(false); + World world = world("fresh-spawn-success"); + Chunk chunk = chunk(world); + CompletableFuture chunkFuture = new CompletableFuture<>(); + AtomicReference regionTask = new AtomicReference<>(); + WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class); + when(runtime.requestChunkAsync(world, 0, 0, true, true)).thenReturn(chunkFuture); + when(world.getSpawnLocation()).thenReturn(new Location(world, 0.5D, 64D, 0.5D)); + when(world.getHighestBlockYAt(any(Location.class))).thenReturn(70); + + try (MockedStatic control = mockStatic(WorldRuntimeControlService.class); + MockedStatic scheduling = mockStatic(J.class)) { + control.when(WorldRuntimeControlService::get).thenReturn(runtime); + scheduling.when(() -> J.runRegionFuture( + any(World.class), + anyInt(), + anyInt(), + any(Runnable.class))) + .thenAnswer(invocation -> { + regionTask.set(invocation.getArgument(3, Runnable.class)); + return CompletableFuture.completedFuture(null); + }); + + invokeUpdateSpawnLocation(generator, world); + verify(runtime).requestChunkAsync(world, 0, 0, true, true); + verify(runtime, times(1)).requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true)); + verify(world, never()).getChunkAtAsync(0, 0, true); + assertFalse(readiness.isDone()); + + chunkFuture.complete(chunk); + assertNotNull(regionTask.get()); + assertFalse(readiness.isDone()); + regionTask.get().run(); + readiness.get(5L, TimeUnit.SECONDS); + } + } + + @Test + public void playerEntryRequestsNativeFootprintBeforeCanonicalChunkCompletes() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + generator.beginInitialEntry(true); + World world = world("player-entry-footprint"); + Chunk chunk = chunk(world); + CompletableFuture canonical = new CompletableFuture<>(); + CompletableFuture neighbours = new CompletableFuture<>(); + AtomicReference regionTask = new AtomicReference<>(); + WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class); + when(runtime.requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true))).thenReturn(neighbours); + when(runtime.requestChunkAsync(world, 0, 0, true, true)).thenReturn(canonical); + when(world.getSpawnLocation()).thenReturn(new Location(world, 0.5D, 64D, 0.5D)); + when(world.getHighestBlockYAt(any(Location.class))).thenReturn(52); + + try (MockedStatic control = mockStatic(WorldRuntimeControlService.class); + MockedStatic scheduling = mockStatic(J.class)) { + control.when(WorldRuntimeControlService::get).thenReturn(runtime); + scheduling.when(() -> J.runRegionFuture(any(World.class), anyInt(), anyInt(), any(Runnable.class))) + .thenAnswer(invocation -> { + regionTask.set(invocation.getArgument(3, Runnable.class)); + return CompletableFuture.completedFuture(null); + }); + + invokeUpdateSpawnLocation(generator, world); + verify(runtime).requestChunkAsync(world, 0, 0, true, true); + verify(runtime).requestChunkAsync(world, -1, -1, true, true); + verify(runtime).requestChunkAsync(world, -1, 0, true, true); + verify(runtime).requestChunkAsync(world, 0, -1, true, true); + verify(runtime, times(4)).requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true)); + assertFalse(canonical.isDone()); + neighbours.complete(chunk); + assertNull(regionTask.get()); + assertFalse(readiness.isDone()); + verify(world, never()).getHighestBlockYAt(any(Location.class)); + + canonical.complete(chunk); + assertNotNull(regionTask.get()); + assertFalse(readiness.isDone()); + regionTask.get().run(); + readiness.get(5L, TimeUnit.SECONDS); + verify(world).setSpawnLocation(new Location(world, 0.5D, 53D, 0.5D)); + } + } + + @Test + public void failedEntryNeighbourWaitsForOtherRequiredChunksAndFailsReadiness() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + generator.beginInitialEntry(true); + World world = world("player-entry-footprint-failure"); + CompletableFuture pending = new CompletableFuture<>(); + IllegalStateException failure = new IllegalStateException("entry neighbour request rejected"); + WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class); + when(runtime.requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true))).thenReturn(pending); + when(runtime.requestChunkAsync(world, -1, -1, true, true)).thenThrow(failure); + + try (MockedStatic control = mockStatic(WorldRuntimeControlService.class); + MockedStatic scheduling = mockStatic(J.class)) { + control.when(WorldRuntimeControlService::get).thenReturn(runtime); + invokeUpdateSpawnLocation(generator, world); + verify(runtime, times(4)).requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true)); + assertFalse(readiness.isDone()); + pending.complete(chunk(world)); + assertSame(failure, awaitFailure(readiness)); + scheduling.verifyNoInteractions(); + } + } + + @Test + public void canonicalEntryFailureRetainsItsCauseAfterNeighboursDrain() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + generator.beginInitialEntry(true); + World world = world("player-entry-canonical-failure"); + CompletableFuture neighbours = new CompletableFuture<>(); + IllegalStateException failure = new IllegalStateException("canonical entry failed"); + IllegalArgumentException neighbourFailure = new IllegalArgumentException("entry neighbour failed"); + WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class); + when(runtime.requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true))).thenReturn(neighbours); + when(runtime.requestChunkAsync(world, 0, 0, true, true)).thenReturn(CompletableFuture.failedFuture(failure)); + + try (MockedStatic control = mockStatic(WorldRuntimeControlService.class)) { + control.when(WorldRuntimeControlService::get).thenReturn(runtime); + invokeUpdateSpawnLocation(generator, world); + assertFalse(readiness.isDone()); + neighbours.completeExceptionally(neighbourFailure); + assertSame(failure, awaitFailure(readiness)); + assertEquals(List.of(neighbourFailure), List.of(failure.getSuppressed())); + } + } + + @Test + public void failedUrgentChunkRequestFailsSpawnReadiness() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + generator.beginInitialEntry(false); + World world = world("fresh-spawn-chunk-failure"); + IllegalStateException failure = new IllegalStateException("urgent chunk failed"); + WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class); + when(runtime.requestChunkAsync(world, 0, 0, true, true)) + .thenReturn(CompletableFuture.failedFuture(failure)); + + try (MockedStatic control = mockStatic(WorldRuntimeControlService.class)) { + control.when(WorldRuntimeControlService::get).thenReturn(runtime); + invokeUpdateSpawnLocation(generator, world); + } + + assertSame(failure, awaitFailure(readiness)); + } + + @Test + public void rejectedUrgentChunkRequestFailsSpawnReadiness() throws Exception { + CompletableFuture readiness = new CompletableFuture<>(); + BukkitChunkGenerator generator = generatorWithReadiness(readiness); + generator.beginInitialEntry(false); + World world = world("fresh-spawn-request-failure"); + IllegalStateException failure = new IllegalStateException("urgent request rejected"); + WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class); + when(runtime.requestChunkAsync(world, 0, 0, true, true)).thenThrow(failure); + + try (MockedStatic control = mockStatic(WorldRuntimeControlService.class)) { + control.when(WorldRuntimeControlService::get).thenReturn(runtime); + invokeUpdateSpawnLocation(generator, world); + } + + assertSame(failure, awaitFailure(readiness)); + } + @Test public void failedChunkRequestFailsSpawnReadiness() throws Exception { CompletableFuture readiness = new CompletableFuture<>(); diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorInitializationModeTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorInitializationModeTest.java index f1954c449..2947cf022 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorInitializationModeTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorInitializationModeTest.java @@ -63,7 +63,7 @@ public class BukkitChunkGeneratorInitializationModeTest { ((CompletableFuture) prefetch.invoke(generator, engine, world)).get(5L, TimeUnit.SECONDS); - verify(engine).startStudioEntryHydrology(0, 0); + verify(engine).startEntryHydrology(0, 0); verifyNoInteractions(hydrology); assertEquals(0.5D, generator.getInitialSpawnLocation(world).getX(), 0D); assertEquals(96D, generator.getInitialSpawnLocation(world).getY(), 0D); @@ -71,7 +71,7 @@ public class BukkitChunkGeneratorInitializationModeTest { when(dimension.getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_1x1); ((CompletableFuture) prefetch.invoke(generator, engine, world)).get(5L, TimeUnit.SECONDS); - verify(engine, times(1)).startStudioEntryHydrology(0, 0); + verify(engine, times(1)).startEntryHydrology(0, 0); verify(hydrology).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL); @@ -85,7 +85,7 @@ public class BukkitChunkGeneratorInitializationModeTest { doReturn(false).when(generator).usesFlatStudioTerrain(); when(world.getChunkAtAsync(0, 0, false)).thenReturn(CompletableFuture.completedFuture(mock(Chunk.class))); ((CompletableFuture) prefetch.invoke(generator, generated, world)).get(5L, TimeUnit.SECONDS); - verify(generated, never()).startStudioEntryHydrology(anyInt(), anyInt()); + verify(generated, never()).startEntryHydrology(anyInt(), anyInt()); } @Test @@ -108,9 +108,9 @@ public class BukkitChunkGeneratorInitializationModeTest { @Test public void runtimeAndOrdinaryStudioWarmGenerationCaches() { IrisEngine.InitializationMode runtime = - BukkitChunkGenerator.selectInitializationMode(false, false, false); + BukkitChunkGenerator.selectInitializationMode(false, false, false, false); IrisEngine.InitializationMode studio = - BukkitChunkGenerator.selectInitializationMode(true, false, false); + BukkitChunkGenerator.selectInitializationMode(true, false, false, false); assertEquals(IrisEngine.InitializationMode.RUNTIME, runtime); assertFalse(runtime.studio()); @@ -120,10 +120,22 @@ public class BukkitChunkGeneratorInitializationModeTest { assertTrue(studio.warmGenerationCaches()); } + @Test + public void onlyFreshNormalWorldsUseCreationCacheWarmMode() { + IrisEngine.InitializationMode mode = + BukkitChunkGenerator.selectInitializationMode(false, false, false, true); + + assertEquals(IrisEngine.InitializationMode.WORLD_CREATION, mode); + assertFalse(mode.studio()); + assertTrue(mode.warmGenerationCaches()); + assertEquals(IrisEngine.InitializationMode.STUDIO, + BukkitChunkGenerator.selectInitializationMode(true, false, false, true)); + } + @Test public void activeJigsawStudioSkipsGenerationCacheWarm() { IrisEngine.InitializationMode mode = - BukkitChunkGenerator.selectInitializationMode(true, true, false); + BukkitChunkGenerator.selectInitializationMode(true, true, false, false); assertEquals(IrisEngine.InitializationMode.JIGSAW_STUDIO, mode); assertTrue(mode.studio()); @@ -133,13 +145,13 @@ public class BukkitChunkGeneratorInitializationModeTest { @Test public void activeObjectStudioSkipsGenerationCacheWarm() { IrisEngine.InitializationMode mode = - BukkitChunkGenerator.selectInitializationMode(true, false, true); + BukkitChunkGenerator.selectInitializationMode(true, false, true, false); assertEquals(IrisEngine.InitializationMode.OBJECT_STUDIO, mode); assertTrue(mode.studio()); assertFalse(mode.warmGenerationCaches()); assertEquals(IrisEngine.InitializationMode.RUNTIME, - BukkitChunkGenerator.selectInitializationMode(false, false, true)); + BukkitChunkGenerator.selectInitializationMode(false, false, true, false)); } @Test diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorSpawnHydrologyTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorSpawnHydrologyTest.java index c993c8bd2..b3317b817 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorSpawnHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorSpawnHydrologyTest.java @@ -17,6 +17,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -35,8 +36,56 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; public class BukkitChunkGeneratorSpawnHydrologyTest { + @Test + public void creationDefersNeighboursBeforePublishingAndKeepsEntryDemandTracked() throws Exception { + Fixture fixture = new Fixture(); + fixture.generator.setEngine(null); + setField(fixture.generator, "studioEntryBootstrapActive", new AtomicBoolean(false)); + fixture.generator.beginInitialEntry(false); + fixture.generator.publishInitializedEngine(fixture.engine); + verify(fixture.hydrology).setNeighbourPrefetchEnabled(false); + assertTrue(fixture.generator.shouldGenerateStructures()); + + CompletableFuture prefetch = fixture.prefetch(); + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + + verify(fixture.engine).startEntryHydrology(0, 0); + verify(fixture.hydrology, never()).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt()); + verify(fixture.hydrology, never()).setNeighbourPrefetchEnabled(true); + fixture.generator.completeInitialEntry(); + fixture.generator.completeInitialEntry(); + verify(fixture.hydrology, times(1)).setNeighbourPrefetchEnabled(true); + } + + @Test + public void completionBeforePublicationDoesNotLeaveNormalStartupDisabled() throws Exception { + Fixture fixture = new Fixture(); + fixture.generator.setEngine(null); + fixture.generator.beginInitialEntry(false); + fixture.generator.completeInitialEntry(); + fixture.generator.publishInitializedEngine(fixture.engine); + verifyNoInteractions(fixture.hydrology); + + CompletableFuture prefetch = fixture.prefetch(); + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + + verify(fixture.hydrology).prefetchArea(-256, -256, 255, 255, 0, 0); + verify(fixture.engine, never()).startEntryHydrology(anyInt(), anyInt()); + } + + @Test + public void initializedGeneratorCannotBeginLateEntryDeferral() throws Exception { + Fixture fixture = new Fixture(); + + assertThrows(IllegalStateException.class, () -> fixture.generator.beginInitialEntry(false)); + verifyNoInteractions(fixture.hydrology); + } + @Test public void startupWaitsAsynchronouslyWithoutHoldingGenerationAdmission() throws Exception { Fixture fixture = new Fixture(); @@ -144,7 +193,7 @@ public class BukkitChunkGeneratorSpawnHydrologyTest { fixture.lookup.complete(null); prefetch.get(5L, TimeUnit.SECONDS); - verify(fixture.engine).startStudioEntryHydrology(0, 0); + verify(fixture.engine).startEntryHydrology(0, 0); verifyNoInteractions(fixture.hydrology); } diff --git a/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java b/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java index f988b7869..5903883e7 100644 --- a/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java +++ b/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java @@ -16,6 +16,7 @@ import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; import java.io.IOException; import java.nio.file.FileAlreadyExistsException; @@ -31,7 +32,9 @@ 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.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; public class StudioSVCWorldPackPublishTest { @ClassRule @@ -264,6 +267,42 @@ public class StudioSVCWorldPackPublishTest { assertSame(sourceValidation, PackValidationRegistry.requireLoadable(target)); } + @Test + public void immutableSnapshotDimensionLoadingReusesExactSourceValidation() throws Exception { + Path source = temporaryFolder.newFolder("snapshot-source").toPath(); + Path target = temporaryFolder.getRoot().toPath().resolve("snapshot-target"); + writeValidPack(source); + StudioSVC.copyPackTree(source, target); + PackValidationResult sourceValidation = new PackValidationResult( + "snapshot-source", List.of(), List.of("source warning"), 23L); + PackValidationRegistry.publish(source, sourceValidation, + ServerConfigurator.computePackTreeFingerprint(source.toFile())); + + IrisData installed = IrisData.openDatapackCompiler(target.toFile()); + try (MockedStatic data = mockStatic(IrisData.class, CALLS_REAL_METHODS)) { + data.when(() -> IrisData.get(target.toFile())).thenReturn(installed); + + assertEquals("main", StudioSVC.loadInstalledDimension(target, source, "main").getLoadKey()); + assertSame(sourceValidation, PackValidationRegistry.requireLoadable(target)); + } finally { + installed.close(); + } + } + + @Test + public void immutableSnapshotDimensionLoadingRejectsChangedContent() throws Exception { + Path source = temporaryFolder.newFolder("changed-snapshot-source").toPath(); + Path target = temporaryFolder.getRoot().toPath().resolve("changed-snapshot-target"); + writeValidPack(source); + StudioSVC.copyPackTree(source, target); + PackValidationRegistry.publish(source, new PackValidationResult("snapshot-source", List.of(), List.of(), 29L), + ServerConfigurator.computePackTreeFingerprint(source.toFile())); + Files.writeString(target.resolve("dimensions/main.json"), "{"); + + assertThrows(BrokenPackException.class, () -> StudioSVC.loadInstalledDimension(target, source, "main")); + assertTrue(PackValidationRegistry.isBroken(target)); + } + @Test public void copiedFingerprintMismatchFallsBackToTargetValidation() throws Exception { Path root = temporaryFolder.newFolder("mismatched-validation-copy").toPath(); diff --git a/core/src/test/java/art/arcane/iris/world/IrisCreatorDatapackPreparationTest.java b/core/src/test/java/art/arcane/iris/world/IrisCreatorDatapackPreparationTest.java index 9f0ebd310..6de168154 100644 --- a/core/src/test/java/art/arcane/iris/world/IrisCreatorDatapackPreparationTest.java +++ b/core/src/test/java/art/arcane/iris/world/IrisCreatorDatapackPreparationTest.java @@ -1,19 +1,31 @@ package art.arcane.iris.world; +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.datapack.DatapackInstallResult; +import art.arcane.iris.pack.datapack.ServerConfigurator; import org.junit.Test; +import org.mockito.MockedStatic; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; public class IrisCreatorDatapackPreparationTest { @Test - public void nonStudioCreationAlwaysInstallsDatapacks() { + public void explicitInstallModeAlwaysChecksDatapacks() { assertTrue(IrisCreator.DatapackPreparation.INSTALL_IF_CHANGED.requiresInstall(false)); assertTrue(IrisCreator.DatapackPreparation.INSTALL_IF_CHANGED.requiresInstall(true)); } @Test - public void studioCreationReusesOnlyAReadyLoadedRuntimeWithMatchingInputs() { + public void normalAndStudioCreationReuseOnlyAReadyLoadedRuntime() { assertTrue(IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY.requiresInstall(false)); assertFalse(IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY.requiresInstall(true)); } @@ -24,4 +36,72 @@ public class IrisCreatorDatapackPreparationTest { assertFalse(IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME.requiresInstall(true)); assertTrue(IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME.forcesLoadedRuntime()); } + + @Test + public void defaultCreationSkipsInstallationWhenRequiredRegistriesAreLoaded() throws Exception { + IrisCreator creator = creator(); + IrisDimension dimension = mock(IrisDimension.class); + try (MockedStatic configuration = mockStatic(ServerConfigurator.class)) { + configuration.when(() -> ServerConfigurator.isLoadedDatapackRuntimeReady(dimension)).thenReturn(true); + + assertEquals(DatapackInstallResult.unchangedResult(), prepare(creator, dimension)); + + configuration.verify(() -> ServerConfigurator.isLoadedDatapackRuntimeReady(dimension)); + configuration.verify(() -> ServerConfigurator.installDataPacksIfChanged(true, null), never()); + } + } + + @Test + public void unavailableRequiredRegistriesRunInstallationAndPreserveRestartRequirement() throws Exception { + IrisCreator creator = creator(); + IrisDimension dimension = mock(IrisDimension.class); + DatapackInstallResult result = DatapackInstallResult.restartRequiredResult(); + try (MockedStatic configuration = mockStatic(ServerConfigurator.class)) { + configuration.when(() -> ServerConfigurator.isLoadedDatapackRuntimeReady(dimension)).thenReturn(false); + configuration.when(() -> ServerConfigurator.installDataPacksIfChanged(true, null)).thenReturn(result); + + assertSame(result, prepare(creator, dimension)); + + configuration.verify(() -> ServerConfigurator.installDataPacksIfChanged(true, null)); + } + } + + @Test + public void failedInstallationRemainsFailed() throws Exception { + IrisCreator creator = creator(); + IrisDimension dimension = mock(IrisDimension.class); + DatapackInstallResult result = DatapackInstallResult.failedResult(); + try (MockedStatic configuration = mockStatic(ServerConfigurator.class)) { + configuration.when(() -> ServerConfigurator.installDataPacksIfChanged(true, null)).thenReturn(result); + + assertSame(result, prepare(creator, dimension)); + } + } + + @Test + public void explicitInstallRequestDoesNotReuseLoadedRegistries() throws Exception { + IrisCreator creator = creator().datapackPreparation(IrisCreator.DatapackPreparation.INSTALL_IF_CHANGED); + IrisDimension dimension = mock(IrisDimension.class); + DatapackInstallResult result = DatapackInstallResult.readyResult(); + try (MockedStatic configuration = mockStatic(ServerConfigurator.class)) { + configuration.when(() -> ServerConfigurator.installDataPacksIfChanged(true, null)).thenReturn(result); + + assertSame(result, prepare(creator, dimension)); + + configuration.verify(() -> ServerConfigurator.isLoadedDatapackRuntimeReady(dimension), never()); + } + } + + private static IrisCreator creator() { + try (MockedStatic settings = mockStatic(IrisSettings.class)) { + settings.when(IrisSettings::get).thenReturn(new IrisSettings()); + return new IrisCreator(); + } + } + + private static DatapackInstallResult prepare(IrisCreator creator, IrisDimension dimension) throws Exception { + Method method = IrisCreator.class.getDeclaredMethod("prepareDatapacks", IrisDimension.class); + method.setAccessible(true); + return (DatapackInstallResult) method.invoke(creator, dimension); + } } diff --git a/core/src/test/java/art/arcane/iris/world/IrisWorldCreatorHistoryTest.java b/core/src/test/java/art/arcane/iris/world/IrisWorldCreatorHistoryTest.java new file mode 100644 index 000000000..62e62453d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/IrisWorldCreatorHistoryTest.java @@ -0,0 +1,159 @@ +package art.arcane.iris.world; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.platform.generation.BukkitChunkGenerator; +import art.arcane.iris.studio.StudioSVC; +import art.arcane.iris.world.history.GenerationEpoch; +import art.arcane.iris.world.history.GenerationEpochContractFactory; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationPackFingerprint; +import art.arcane.iris.world.history.GenerationRegistryContract; +import org.bukkit.NamespacedKey; +import org.bukkit.WorldCreator; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.RETURNS_SELF; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class IrisWorldCreatorHistoryTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void normalCreationHandsThePublishedHistoryToItsGeneratorWithoutReopening() throws Exception { + assertGeneratorHistory(false, true); + } + + @Test + public void studioCreationHandsThePublishedHistoryToItsGeneratorWithoutReopening() throws Exception { + assertGeneratorHistory(true, true); + } + + @Test + public void creationWithoutAHandoffOpensAndValidatesThePackOnce() throws Exception { + assertGeneratorHistory(false, false); + } + + @Test + public void suppliedHistoryMustMatchTheDimensionRootAndSeed() throws Exception { + GenerationHistory history = createHistory(); + IrisWorldCreator creator = new IrisWorldCreator().persistent(true).seed(42L).generationHistory(history); + + assertThrows(IllegalArgumentException.class, () -> creator.resolveGenerationHistory( + temporaryFolder.newFolder("different-world"))); + assertThrows(IllegalStateException.class, () -> creator.seed(43L).resolveGenerationHistory( + history.paths().dimensionRoot().toFile())); + } + + @Test + public void transientWorldsDoNotAcquireGenerationHistory() throws Exception { + GenerationHistory history = createHistory(); + IrisWorldCreator creator = new IrisWorldCreator(); + + assertNull(creator.resolveGenerationHistory(history.paths().dimensionRoot().toFile())); + assertThrows(IllegalStateException.class, () -> creator.generationHistory(history) + .resolveGenerationHistory(history.paths().dimensionRoot().toFile())); + } + + @Test + public void finalGeneratorPreparationRejectsTamperingAfterTheHandoff() throws Exception { + GenerationHistory history = createHistory(); + Path pack = history.paths().packRoot(history.activeEpoch().epochId()); + IrisWorldCreator creator = new IrisWorldCreator().persistent(true).seed(42L).generationHistory(history); + GenerationHistory resolved = creator.resolveGenerationHistory(history.paths().dimensionRoot().toFile()); + Files.writeString(pack.resolve("dimensions/overworld.json"), "changed after publication"); + + assertSame(history, resolved); + assertThrows(IOException.class, () -> resolved.prepareCurrentGenerator(128)); + assertThrows(IllegalStateException.class, () -> new IrisWorldCreator().persistent(true).seed(42L) + .resolveGenerationHistory(history.paths().dimensionRoot().toFile())); + } + + private void assertGeneratorHistory(boolean studio, boolean handoff) throws Exception { + GenerationHistory history = createHistory(); + File root = history.paths().dimensionRoot().toFile(); + File pack = history.paths().packRoot(history.activeEpoch().epochId()).toFile(); + File authoring = temporaryFolder.newFolder("authoring"); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getLoadKey()).thenReturn("overworld"); + when(dimension.getMinHeight()).thenReturn(-64); + when(dimension.getMaxHeight()).thenReturn(320); + StudioSVC.GenerationPublication publication = new StudioSVC.GenerationPublication(dimension, history); + IrisWorldCreator creator = new IrisWorldCreator().name("created").dimension(publication.dimension()) + .studio(studio).persistent(!studio).studioPackSource(authoring).seed(42L); + if (handoff) { + creator.generationHistory(publication.history()); + } + NamespacedKey key = new NamespacedKey("iris", "created"); + WorldCreator bukkitCreator = mock(WorldCreator.class, RETURNS_SELF); + when(bukkitCreator.name()).thenReturn("world_iris_created"); + List histories = new ArrayList<>(); + List packs = new ArrayList<>(); + AtomicInteger hashes = new AtomicInteger(); + try (MockedStatic storage = mockStatic(IrisWorldStorage.class); + MockedStatic compatibility = mockStatic(WorldCreatorCompat.class); + MockedStatic fingerprints = mockStatic(GenerationPackFingerprint.class, invocation -> { + if (invocation.getMethod().getName().equals("compute")) { + hashes.incrementAndGet(); + } + return invocation.callRealMethod(); + }); + MockedConstruction generators = mockConstruction(BukkitChunkGenerator.class, + (generator, context) -> { + packs.add((File) context.arguments().get(2)); + histories.add((GenerationHistory) context.arguments().get(4)); + })) { + storage.when(() -> IrisWorldStorage.keyFromName("created")).thenReturn(key); + storage.when(() -> IrisWorldStorage.dimensionRoot(key)).thenReturn(root); + compatibility.when(() -> WorldCreatorCompat.ofKey(key)).thenReturn(bukkitCreator); + compatibility.when(() -> WorldCreatorCompat.ofPersistentKey(key)).thenReturn(bukkitCreator); + compatibility.when(() -> WorldCreatorCompat.persistentDimensionRoot(key)).thenReturn(root); + + assertSame(bukkitCreator, creator.create()); + + assertEquals(1, generators.constructed().size()); + assertEquals(List.of(studio ? authoring : pack), packs); + assertEquals(handoff ? 0 : 1, hashes.get()); + if (handoff) { + assertSame(history, histories.getFirst()); + } else { + assertNotSame(history, histories.getFirst()); + assertEquals(history.activeEpoch(), histories.getFirst().activeEpoch()); + } + } + } + + private GenerationHistory createHistory() throws IOException { + Path world = temporaryFolder.newFolder("world").toPath(); + Path source = temporaryFolder.newFolder("source").toPath(); + Files.createDirectories(source.resolve("dimensions")); + Files.writeString(source.resolve("dimensions/overworld.json"), "{}"); + GenerationEpoch.DimensionContract contract = new GenerationEpoch.DimensionContract( + "overworld", "iris:overworld_type", "NORMAL", "OVERWORLD", 127, + -64, 384, 384, 1D, false, "none", 0, "0".repeat(64), + GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, "c".repeat(64)); + return GenerationHistory.create(world, source, + GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION), + 42L, contract, GenerationRegistryContract.empty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/IrisWorldStorageTest.java b/core/src/test/java/art/arcane/iris/world/IrisWorldStorageTest.java index 739ec1694..a19a41837 100644 --- a/core/src/test/java/art/arcane/iris/world/IrisWorldStorageTest.java +++ b/core/src/test/java/art/arcane/iris/world/IrisWorldStorageTest.java @@ -9,6 +9,7 @@ import org.bukkit.NamespacedKey; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; import java.io.File; import java.io.IOException; @@ -16,11 +17,13 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; public class IrisWorldStorageTest { @Rule @@ -442,6 +445,36 @@ public class IrisWorldStorageTest { ); } + @Test + public void activePackResolutionHashesOnceAndRechecksLaterAccesses() throws Exception { + Path world = temporaryFolder.newFolder("generation-pack-resolution").toPath(); + Path source = createGenerationPack("resolution-source"); + GenerationHistory history = createGenerationHistory(world, source, 42L); + Path expectedPack = history.paths().packRoot(history.activeEpoch().epochId()); + AtomicInteger hashes = new AtomicInteger(); + + try (MockedStatic ignored = mockStatic(GenerationPackFingerprint.class, invocation -> { + if (invocation.getMethod().getName().equals("compute")) { + hashes.incrementAndGet(); + } + return invocation.callRealMethod(); + })) { + assertEquals(expectedPack.toFile(), IrisWorldStorage.requireActiveGenerationPackRoot(world.toFile())); + assertEquals(1, hashes.get()); + assertEquals(expectedPack.toFile(), IrisWorldStorage.requireActiveGenerationPackRoot(world.toFile(), 42L)); + assertEquals(2, hashes.get()); + + Files.writeString(expectedPack.resolve("dimensions/overworld.json"), "changed"); + + assertThrows(IllegalStateException.class, + () -> IrisWorldStorage.requireActiveGenerationPackRoot(world.toFile())); + assertEquals(3, hashes.get()); + assertThrows(IllegalStateException.class, + () -> IrisWorldStorage.requireActiveGenerationPackRoot(world.toFile(), 42L)); + assertEquals(4, hashes.get()); + } + } + @Test public void managedWorldStorageIsOnlyReportedForRealIrisWorldDirectories() throws Exception { File levelRoot = temporaryFolder.newFolder("managed-storage-level-root"); diff --git a/core/src/test/java/art/arcane/iris/world/IrisWorldsTest.java b/core/src/test/java/art/arcane/iris/world/IrisWorldsTest.java index 8720a9a81..405f2fb15 100644 --- a/core/src/test/java/art/arcane/iris/world/IrisWorldsTest.java +++ b/core/src/test/java/art/arcane/iris/world/IrisWorldsTest.java @@ -1,10 +1,17 @@ package art.arcane.iris.world; import art.arcane.iris.world.lifecycle.MissingWorldStorageLog; +import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.volmlib.util.collection.KMap; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import java.io.UncheckedIOException; +import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; @@ -14,7 +21,11 @@ import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; public class IrisWorldsTest { @Rule @@ -110,4 +121,98 @@ public class IrisWorldsTest { assertEquals(Map.of("world_iris_moon", "overworld"), selected); } + + @Test + public void registryHousekeepingDoesNotOpenFrozenPacks() throws Exception { + Path levelRoot = temporaryFolder.newFolder("housekeeping").toPath(); + Path first = Files.createDirectories(levelRoot.resolve("dimensions/iris/first")); + Files.createDirectories(first.resolve("iris/generation")); + Files.writeString(first.resolve("iris/generation/manifest.json"), "invalid history"); + Files.createDirectories(levelRoot.resolve("dimensions/iris/second")); + KMap entries = new KMap<>(); + entries.put("iris:first", "overworld"); + IrisWorlds registry = registry(levelRoot, entries); + + try (MockedStatic history = mockStatic(GenerationHistory.class)) { + registry.clean(); + registry.save(); + registry.put("iris:second", "missing_dimension"); + registry.save(); + history.verifyNoInteractions(); + } + + assertEquals(Map.of("iris:first", "overworld", "iris:second", "missing_dimension"), entries); + JsonObject saved = savedRegistry(levelRoot); + assertEquals("overworld", saved.get("iris:first").getAsString()); + assertEquals("missing_dimension", saved.get("iris:second").getAsString()); + } + + @Test + public void savingPersistsEntriesRemovedByStorageCleanup() throws Exception { + Path levelRoot = temporaryFolder.newFolder("cleanup").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris/retained")); + KMap entries = new KMap<>(); + entries.put("iris:retained", "overworld"); + entries.put("iris:missing", "overworld"); + IrisWorlds registry = registry(levelRoot, entries); + + registry.save(); + + assertEquals(Map.of("iris:retained", "overworld"), entries); + assertEquals(Set.of("iris:retained"), savedRegistry(levelRoot).keySet()); + } + + @Test + public void failedPutRestoresPreviousValueAndCanBeRetried() throws Exception { + Path levelRoot = temporaryFolder.newFolder("put-rollback").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris/retained")); + Path blockedParent = levelRoot.resolve("iris"); + Files.writeString(blockedParent, "not a directory"); + KMap entries = new KMap<>(); + entries.put("iris:retained", "overworld"); + IrisWorlds registry = registry(levelRoot, entries); + + assertThrows(UncheckedIOException.class, () -> registry.put("iris:retained", "replacement")); + assertEquals("overworld", entries.get("iris:retained")); + Files.delete(blockedParent); + registry.save(); + + assertEquals("overworld", savedRegistry(levelRoot).get("iris:retained").getAsString()); + } + + @Test + public void failedRemovalRestoresEntryAndCanBeRetried() throws Exception { + Path levelRoot = temporaryFolder.newFolder("remove-rollback").toPath(); + Files.createDirectories(levelRoot.resolve("dimensions/iris/retained")); + Path blockedParent = levelRoot.resolve("iris"); + Files.writeString(blockedParent, "not a directory"); + KMap entries = new KMap<>(); + entries.put("iris:retained", "overworld"); + IrisWorlds registry = registry(levelRoot, entries); + + assertThrows(UncheckedIOException.class, () -> registry.remove("iris:retained")); + assertEquals("overworld", entries.get("iris:retained")); + Files.delete(blockedParent); + assertTrue(registry.remove("iris:retained")); + + assertTrue(savedRegistry(levelRoot).isEmpty()); + } + + private static IrisWorlds registry(Path levelRoot, KMap entries) throws Exception { + IrisWorlds registry = mock(IrisWorlds.class, CALLS_REAL_METHODS); + setField(registry, "levelRoot", levelRoot.toAbsolutePath().normalize()); + setField(registry, "registryFile", IrisWorlds.registryFile(levelRoot)); + setField(registry, "worlds", entries); + return registry; + } + + private static void setField(IrisWorlds registry, String name, Object value) throws Exception { + Field field = IrisWorlds.class.getDeclaredField(name); + field.setAccessible(true); + field.set(registry, value); + } + + private static JsonObject savedRegistry(Path levelRoot) throws Exception { + return JsonParser.parseString(Files.readString(IrisWorlds.registryFile(levelRoot))).getAsJsonObject(); + } } diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryAdmissionTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryAdmissionTest.java index 6f7e70017..627a76b46 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryAdmissionTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryAdmissionTest.java @@ -47,6 +47,7 @@ public class GenerationHistoryAdmissionTest { @Test public void stageMetadataProceedsWhileUnrelatedSemanticPersistenceWaits() throws Exception { GenerationHistory history = history(); + GenerationManifest manifest = history.manifest(); GenerationActivation active = history.activeActivation(); GenerationEpoch epoch = history.activeEpoch(); GenerationAdmission.RuntimeLease runtime = history.retainRuntime(); @@ -59,6 +60,9 @@ public class GenerationHistoryAdmissionTest { Future persisted = executor.submit(() -> persistPaused(history, writing, claim, entered, release)); assertTrue(entered.await(5, TimeUnit.SECONDS)); Future admitted = executor.submit(() -> { + assertSame(manifest, history.manifest()); + assertEquals(active, history.activeActivation()); + assertEquals(epoch, history.activeEpoch()); try (GenerationHistory.GenerationStage stage = history.openStage(32, 64)) { assertEquals(active, stage.activation()); assertEquals(epoch, stage.epoch()); @@ -109,6 +113,11 @@ public class GenerationHistoryAdmissionTest { writing.close(); assertTrue(cutoverEntered.await(5, TimeUnit.SECONDS)); assertThrows(TimeoutException.class, () -> promoted.get(100, TimeUnit.MILLISECONDS)); + executor.submit(() -> { + assertEquals(writing.activation(), history.activeActivation()); + assertEquals(writing.epoch(), history.activeEpoch()); + assertEquals(writing.activation(), history.manifest().activeActivation()); + }).get(1, TimeUnit.SECONDS); assertThrows(IllegalStateException.class, runtime::close); Future admitted = executor.submit(() -> { try (GenerationHistory.GenerationStage stage = history.openStage(32, 64)) { @@ -122,6 +131,9 @@ public class GenerationHistoryAdmissionTest { release.countDown(); assertTrue(persisted.get(5, TimeUnit.SECONDS)); assertEquals(pending.activationId(), promoted.get(5, TimeUnit.SECONDS).activationId()); + assertEquals(pending.activationId(), history.activeActivation().activationId()); + assertEquals(pending.epochId(), history.activeEpoch().epochId()); + assertEquals(pending.activationId(), history.manifest().activeActivation().activationId()); admitted.get(5, TimeUnit.SECONDS); } finally { release.countDown(); @@ -143,6 +155,9 @@ public class GenerationHistoryAdmissionTest { IOException failure = new IOException("Manifest publication failed"); failureField.set(store, failure); try { + assertSame(failure, assertThrows(IllegalStateException.class, history::manifest).getCause()); + assertSame(failure, assertThrows(IllegalStateException.class, history::activeActivation).getCause()); + assertSame(failure, assertThrows(IllegalStateException.class, history::activeEpoch).getCause()); IllegalStateException rejected = assertThrows(IllegalStateException.class, () -> history.openStage(1, 2)); assertSame(failure, rejected.getCause()); } finally { diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java index 860f81e27..b9c12ef08 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationHistoryTest.java @@ -4,14 +4,52 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; +import org.mockito.MockedStatic; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; public class GenerationHistoryTest extends GenerationHistorySupport { + @Test + public void freshPublicationReusesOnlyItsInternalVerification() throws Exception { + Path world = temporaryFolder.newFolder("verified-publication-world").toPath(); + Path pack = createPack("verified-publication-pack", "alpha"); + String expected = fingerprint(pack); + AtomicInteger hashes = new AtomicInteger(); + try (MockedStatic ignored = mockStatic(GenerationPackFingerprint.class, invocation -> { + if (invocation.getMethod().getName().equals("compute")) { + hashes.incrementAndGet(); + } + return invocation.callRealMethod(); + })) { + GenerationHistory history = GenerationHistory.create( + world, pack, expected, 42L, contract(), GenerationRegistryContract.empty()); + assertEquals(2, hashes.get()); + assertEquals(history.paths().packRoot(history.activeEpoch().epochId()), history.activePackRoot()); + assertEquals(3, hashes.get()); + GenerationHistory.open(world); + assertEquals(4, hashes.get()); + } + } + + @Test + public void firstPackAccessRejectsTamperingAfterCreation() throws Exception { + Path world = temporaryFolder.newFolder("tampered-publication-world").toPath(); + Path pack = createPack("tampered-publication-pack", "alpha"); + GenerationHistory history = createHistory(world, pack); + Path installed = history.paths().packRoot(history.activeEpoch().epochId()); + Files.writeString(installed.resolve("dimensions/main.json"), "changed"); + + assertThrows(IOException.class, history::activePackRoot); + assertThrows(IOException.class, () -> history.packRoot(1L)); + assertThrows(IOException.class, () -> GenerationHistory.open(world)); + } + @Test public void upperContentUpdatesPreserveLayoutAndDistinctActivationsAcrossRestarts() throws Exception { Path world = temporaryFolder.newFolder("upper-update-world").toPath(); diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationPackCopyConcurrencyTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationPackCopyConcurrencyTest.java new file mode 100644 index 000000000..61e680bbf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationPackCopyConcurrencyTest.java @@ -0,0 +1,319 @@ +package art.arcane.iris.world.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class GenerationPackCopyConcurrencyTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test(timeout = 15000) + public void forkJoinShutdownSettlesCancelledQueuedCopyWorkers() throws Exception { + Path source = createPack("cancelled-queue-source", 2); + Path target = temporaryFolder.getRoot().toPath().resolve("cancelled-queue-target"); + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch queued = new CountDownLatch(2); + ForkJoinPool workers = new ForkJoinPool(1); + ExecutorService caller = Executors.newSingleThreadExecutor(); + workers.execute(() -> { + blocked.countDown(); + awaitUninterruptibly(release); + }); + Executor execution = command -> { + workers.execute(command); + queued.countDown(); + }; + try { + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + Future copy = caller.submit(() -> { + GenerationPackRepository.copyPackTree(source, target, execution, 2); + return null; + }); + assertTrue(queued.await(5, TimeUnit.SECONDS)); + + workers.shutdownNow(); + + ExecutionException failure = assertThrows(ExecutionException.class, () -> copy.get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof CancellationException); + assertFalse(Files.exists(target.resolve("dimensions/0.json"))); + assertFalse(Files.exists(target.resolve("dimensions/1.json"))); + } finally { + release.countDown(); + caller.shutdownNow(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void cancelledRunningFlushDrainsFileWorkBeforeReturning() throws Exception { + Path source = createPack("cancelled-flush-source", 1); + Path target = temporaryFolder.getRoot().toPath().resolve("cancelled-flush-target"); + CountDownLatch flushing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference> submitted = new AtomicReference<>(); + AtomicReference opened = new AtomicReference<>(); + AtomicBoolean flushed = new AtomicBoolean(); + ExecutorService workers = Executors.newSingleThreadExecutor(); + ExecutorService caller = Executors.newSingleThreadExecutor(); + Executor execution = command -> { + submitted.set((Future) command); + workers.execute(() -> { + try (MockedStatic ignored = mockStatic(FileChannel.class, invocation -> { + FileChannel channel = (FileChannel) invocation.callRealMethod(); + opened.set(channel); + FileChannel intercepted = mock(FileChannel.class, delegatesTo(channel)); + doAnswer(force -> { + flushing.countDown(); + awaitUninterruptibly(release); + channel.force(true); + flushed.set(true); + return null; + }).when(intercepted).force(true); + return intercepted; + })) { + command.run(); + } + }); + }; + try { + Future copy = caller.submit(() -> { + GenerationPackRepository.copyPackTree(source, target, execution, 1); + return null; + }); + assertTrue(flushing.await(5, TimeUnit.SECONDS)); + + assertTrue(submitted.get().cancel(true)); + + assertThrows(TimeoutException.class, () -> copy.get(100, TimeUnit.MILLISECONDS)); + assertFalse(flushed.get()); + assertTrue(opened.get().isOpen()); + assertTrue(Files.exists(target.resolve("dimensions/0.json"))); + release.countDown(); + ExecutionException failure = assertThrows(ExecutionException.class, () -> copy.get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof CancellationException); + assertTrue(flushed.get()); + assertFalse(opened.get().isOpen()); + } finally { + release.countDown(); + caller.shutdownNow(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void boundedWorkersCopyTheExactFilteredTreeAndAttributes() throws Exception { + Path source = createPack("source", 48); + Path target = temporaryFolder.getRoot().toPath().resolve("target"); + Files.createDirectories(source.resolve(".iris")); + Files.writeString(source.resolve(".iris/cache"), "excluded"); + Files.writeString(source.resolve("source.code-workspace"), "excluded"); + Files.writeString(source.resolve("dimensions/.hidden.json"), "{}"); + FileTime timestamp = FileTime.fromMillis(1_700_000_000_000L); + Files.setLastModifiedTime(source.resolve("dimensions/0.json"), timestamp); + CountDownLatch started = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger submitted = new AtomicInteger(); + ExecutorService workers = Executors.newFixedThreadPool(2); + ExecutorService caller = Executors.newSingleThreadExecutor(); + Executor execution = command -> { + if (submitted.incrementAndGet() == 3) { + try { + assertEquals(fingerprint(source), fingerprint(target)); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + } + workers.execute(() -> { + started.countDown(); + await(release); + command.run(); + }); + }; + try { + Future copy = caller.submit(() -> { + GenerationPackRepository.copyPackTree(source, target, execution, 2); + return null; + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertFalse(copy.isDone()); + release.countDown(); + copy.get(5, TimeUnit.SECONDS); + assertEquals(4, submitted.get()); + assertEquals(fingerprint(source), fingerprint(target)); + assertEquals(timestamp, Files.getLastModifiedTime(target.resolve("dimensions/0.json"))); + assertFalse(Files.exists(target.resolve(".iris"))); + assertFalse(Files.exists(target.resolve("source.code-workspace"))); + assertTrue(Files.isRegularFile(target.resolve("dimensions/.hidden.json"))); + } finally { + release.countDown(); + caller.shutdownNow(); + workers.shutdownNow(); + } + } + + @Test(timeout = 15000) + public void rejectedSubmissionDrainsAcceptedWorkersBeforeReturning() throws Exception { + Path source = createPack("rejected-source", 2); + Path target = temporaryFolder.getRoot().toPath().resolve("rejected-target"); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch rejected = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger submitted = new AtomicInteger(); + AtomicInteger completed = new AtomicInteger(); + RejectedExecutionException expected = new RejectedExecutionException("IO executor closed"); + ExecutorService worker = Executors.newSingleThreadExecutor(); + ExecutorService caller = Executors.newSingleThreadExecutor(); + Executor execution = command -> { + if (submitted.getAndIncrement() != 0) { + rejected.countDown(); + throw expected; + } + worker.execute(() -> { + started.countDown(); + await(release); + command.run(); + completed.incrementAndGet(); + }); + }; + try { + Future copy = caller.submit(() -> { + GenerationPackRepository.copyPackTree(source, target, execution, 2); + return null; + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertTrue(rejected.await(5, TimeUnit.SECONDS)); + assertFalse(copy.isDone()); + release.countDown(); + ExecutionException actual = assertThrows(ExecutionException.class, () -> copy.get(5, TimeUnit.SECONDS)); + assertSame(expected, actual.getCause()); + worker.submit(() -> { }).get(5, TimeUnit.SECONDS); + assertEquals(1, completed.get()); + assertFalse(Files.exists(target.resolve("dimensions/0.json"))); + } finally { + release.countDown(); + caller.shutdownNow(); + worker.shutdownNow(); + } + } + + @Test + public void fileFailurePropagatesAsIOException() throws Exception { + Path source = createPack("missing-source", 1); + Path target = temporaryFolder.getRoot().toPath().resolve("missing-target"); + Executor execution = command -> { + try { + Files.delete(source.resolve("dimensions/0.json")); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + command.run(); + }; + + assertThrows(IOException.class, () -> GenerationPackRepository.copyPackTree(source, target, execution, 1)); + } + + @Test(timeout = 15000) + public void copyCanJoinWorkSubmittedToItsOwnForkJoinPool() throws Exception { + Path source = createPack("nested-source", 4); + Path target = temporaryFolder.getRoot().toPath().resolve("nested-target"); + try (ForkJoinPool pool = new ForkJoinPool(1)) { + pool.submit(() -> { + GenerationPackRepository.copyPackTree(source, target, pool, 2); + return null; + }).get(5, TimeUnit.SECONDS); + assertEquals(fingerprint(source), fingerprint(target)); + } + } + + @Test + public void emptyPackTreeDoesNotSubmitFileWork() throws Exception { + Path source = temporaryFolder.newFolder("empty-source").toPath(); + Path target = temporaryFolder.getRoot().toPath().resolve("empty-target"); + Files.createDirectory(source.resolve("empty")); + GenerationPackRepository.copyPackTree(source, target, command -> { + throw new AssertionError("Empty packs do not need copy workers"); + }, 2); + assertTrue(Files.isDirectory(target.resolve("empty"))); + } + + private Path createPack(String name, int files) throws IOException { + Path root = temporaryFolder.newFolder(name).toPath(); + Files.createDirectory(root.resolve("dimensions")); + for (int index = 0; index < files; index++) { + Files.writeString(root.resolve("dimensions/" + index + ".json"), "{\"value\":" + index + "}"); + } + return root; + } + + private static String fingerprint(Path root) throws IOException { + return GenerationPackFingerprint.compute(root, GenerationPackFingerprint.CURRENT_VERSION); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting for copy workers"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } + + private static void awaitUninterruptibly(CountDownLatch latch) { + boolean interrupted = false; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + try { + while (true) { + try { + if (!latch.await(Math.max(0L, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) { + throw new AssertionError("Timed out waiting for copy worker release"); + } + return; + } catch (InterruptedException failure) { + interrupted = true; + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationPackDirectorySyncTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationPackDirectorySyncTest.java new file mode 100644 index 000000000..50e33298b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationPackDirectorySyncTest.java @@ -0,0 +1,148 @@ +package art.arcane.iris.world.history; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationPackDirectorySyncTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test(timeout = 15000) + public void syncsEveryChildBeforeItsParentWithBoundedSiblingConcurrency() throws Exception { + Path root = temporaryFolder.newFolder("tree").toPath(); + for (int index = 0; index < 12; index++) { + Files.createDirectories(root.resolve("branch-" + (index / 3)).resolve("leaf-" + index)); + } + Set completed = ConcurrentHashMap.newKeySet(); + AtomicInteger active = new AtomicInteger(); + AtomicInteger maximum = new AtomicInteger(); + CountDownLatch siblings = new CountDownLatch(4); + CountDownLatch release = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(8); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future sync = caller.submit(() -> { + GenerationPackRepository.forceDirectoryTree(root, workers, 4, directory -> { + int running = active.incrementAndGet(); + maximum.accumulateAndGet(running, Math::max); + try { + if (directory.getFileName().toString().startsWith("leaf-")) { + siblings.countDown(); + await(release); + } + try (Stream children = Files.list(directory)) { + assertTrue(children.filter(Files::isDirectory).allMatch(completed::contains)); + } + assertTrue(completed.add(directory)); + } finally { + active.decrementAndGet(); + } + }); + return null; + }); + assertTrue(siblings.await(5, TimeUnit.SECONDS)); + assertFalse(sync.isDone()); + assertFalse(completed.contains(root)); + release.countDown(); + sync.get(5, TimeUnit.SECONDS); + assertEquals(4, maximum.get()); + assertEquals(17, completed.size()); + } finally { + release.countDown(); + caller.shutdownNow(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void failedSiblingDrainsStartedSyncsAndNeverSyncsTheParent() throws Exception { + Path root = temporaryFolder.newFolder("failed-tree").toPath(); + Files.createDirectory(root.resolve("first")); + Files.createDirectory(root.resolve("second")); + AtomicInteger next = new AtomicInteger(); + Set completed = ConcurrentHashMap.newKeySet(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + IOException expected = new IOException("directory sync failed"); + ExecutorService workers = Executors.newFixedThreadPool(2); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future sync = caller.submit(() -> { + GenerationPackRepository.forceDirectoryTree(root, workers, 2, directory -> { + if (next.getAndIncrement() == 0) { + started.countDown(); + await(release); + completed.add(directory); + } else { + await(started); + failed.countDown(); + throw expected; + } + }); + return null; + }); + assertTrue(failed.await(5, TimeUnit.SECONDS)); + assertFalse(sync.isDone()); + release.countDown(); + ExecutionException failure = assertThrows(ExecutionException.class, () -> sync.get(5, TimeUnit.SECONDS)); + assertSame(expected, failure.getCause()); + assertEquals(1, completed.size()); + assertFalse(completed.contains(root)); + } finally { + release.countDown(); + caller.shutdownNow(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test(timeout = 15000) + public void syncCanJoinItsOwnForkJoinPool() throws Exception { + Path root = temporaryFolder.newFolder("nested-tree").toPath(); + Files.createDirectory(root.resolve("first")); + Files.createDirectory(root.resolve("second")); + AtomicInteger completed = new AtomicInteger(); + try (ForkJoinPool pool = new ForkJoinPool(1)) { + pool.submit(() -> { + GenerationPackRepository.forceDirectoryTree(root, pool, 2, directory -> completed.incrementAndGet()); + return null; + }).get(5, TimeUnit.SECONDS); + } + assertEquals(3, completed.get()); + } + + private static void await(CountDownLatch latch) throws IOException { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IOException("Directory sync wait timed out"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Directory sync wait was interrupted", failure); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationPackFingerprintPrefetchTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationPackFingerprintPrefetchTest.java new file mode 100644 index 000000000..bcc30b81a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationPackFingerprintPrefetchTest.java @@ -0,0 +1,334 @@ +package art.arcane.iris.world.history; + +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class GenerationPackFingerprintPrefetchTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void mixedFilesAndMetadataMatchTheSerialContentAddressForBothVersions() throws Exception { + Path pack = temporaryFolder.newFolder("mixed").toPath(); + Map included = new TreeMap<>(); + for (int index = 0; index < 70; index++) { + included.put("objects/entry-" + index + ".bin", new byte[]{(byte) index, 3, 7}); + } + byte[] large = new byte[300 * 1_024 + 1]; + new Random(1827L).nextBytes(large); + included.put("objects/large.bin", large); + included.put("objects/zero.bin", new byte[0]); + included.put("objects/.nested/resource.bin", new byte[]{17}); + included.put("objects/.DS_Store", new byte[]{42}); + included.put("objects/.nested/.DS_Store", new byte[]{43}); + for (Map.Entry entry : included.entrySet()) { + write(pack.resolve(entry.getKey()), entry.getValue()); + } + write(pack.resolve(".git/config"), new byte[]{1}); + write(pack.resolve(".hidden"), new byte[]{2}); + write(pack.resolve(".DS_Store"), new byte[]{3}); + write(pack.resolve("objects/ignored.code-workspace"), new byte[]{4}); + write(pack.resolve("workspace.code-workspace"), new byte[]{5}); + + assertEquals(serialFingerprint(included), GenerationPackFingerprint.compute(pack, 1)); + included.remove("objects/.DS_Store"); + included.remove("objects/.nested/.DS_Store"); + assertEquals(serialFingerprint(included), GenerationPackFingerprint.compute(pack, 2)); + } + + @Test + public void fewerThanSixtyFourSmallFilesStayOnTheCallingThread() throws Exception { + Path pack = smallPack("small", 63); + Set readers = ConcurrentHashMap.newKeySet(); + Thread caller = Thread.currentThread(); + + GenerationPackFingerprint.computeWithInput(pack, 2, path -> { + readers.add(Thread.currentThread()); + return GenerationPackFingerprint.openInput(path); + }); + + assertEquals(Set.of(caller), readers); + } + + @Test + public void largeFilesStreamOnTheCallerAndSmallReadsAreBounded() throws Exception { + requireParallelReaders(); + Path pack = smallPack("parallel", 64); + Path large = pack.resolve("large.bin"); + Files.write(large, new byte[256 * 1_024 + 1]); + AtomicReference largeReader = new AtomicReference<>(); + Set smallReaders = ConcurrentHashMap.newKeySet(); + Thread caller = Thread.currentThread(); + + GenerationPackFingerprint.computeWithInput(pack, 2, path -> { + if (path.equals(large)) { + largeReader.set(Thread.currentThread()); + } else { + smallReaders.add(Thread.currentThread()); + } + return GenerationPackFingerprint.openInput(path); + }); + + assertSame(caller, largeReader.get()); + assertFalse(smallReaders.contains(caller)); + assertFalse(smallReaders.isEmpty()); + assertTrue(smallReaders.size() <= 4); + assertStopped(smallReaders); + } + + @Test + public void shortAndGrowingFilesFailAndCloseEveryStartedInput() throws Exception { + requireParallelReaders(); + for (int changedSize : new int[]{0, 2}) { + Path pack = smallPack("changed-" + changedSize, 64); + AtomicInteger active = new AtomicInteger(); + Set readers = ConcurrentHashMap.newKeySet(); + IOException failure = assertThrows(IOException.class, () -> GenerationPackFingerprint.computeWithInput(pack, 2, path -> { + readers.add(Thread.currentThread()); + if (path.getFileName().toString().equals("000.bin")) { + Files.write(path, new byte[changedSize]); + } + return tracked(GenerationPackFingerprint.openInput(path), active); + })); + + assertTrue(failure.getMessage().contains("changed while fingerprinting")); + assertEquals(0, active.get()); + assertStopped(readers); + } + } + + @Test + public void leafReplacedByASymlinkAfterCollectionIsRejected() throws Exception { + requireParallelReaders(); + Path pack = smallPack("replaced", 64); + Path outside = temporaryFolder.newFile("outside.bin").toPath(); + Files.write(outside, new byte[]{99}); + Path probe = temporaryFolder.getRoot().toPath().resolve("symlink-probe"); + try { + Files.createSymbolicLink(probe, outside); + } catch (IOException | UnsupportedOperationException failure) { + Assume.assumeNoException(failure); + } + Files.delete(probe); + + assertThrows(IOException.class, () -> GenerationPackFingerprint.computeWithInput(pack, 2, path -> { + if (path.getFileName().toString().equals("000.bin")) { + Files.delete(path); + Files.createSymbolicLink(path, outside); + } + return GenerationPackFingerprint.openInput(path); + })); + } + + @Test + public void firstOrderedReadFailureWaitsForEveryStartedInputToClose() throws Exception { + requireParallelReaders(); + Path pack = smallPack("failure", 64); + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch failed = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger active = new AtomicInteger(); + Set readers = ConcurrentHashMap.newKeySet(); + IOException expected = new IOException("first ordered failure"); + IOException later = new IOException("later ordered failure"); + CompletableFuture completion = new CompletableFuture<>(); + Thread caller = Thread.ofPlatform().start(() -> { + try { + GenerationPackFingerprint.computeWithInput(pack, 2, path -> { + readers.add(Thread.currentThread()); + String name = path.getFileName().toString(); + if (name.equals("000.bin")) { + await(blocked); + failed.countDown(); + throw expected; + } + if (name.equals("001.bin")) { + return tracked(blockingInput(blocked, release, null), active); + } + if (name.equals("002.bin")) { + throw later; + } + return tracked(GenerationPackFingerprint.openInput(path), active); + }); + completion.complete(null); + } catch (Throwable failure) { + completion.complete(failure); + } + }); + try { + assertTrue(failed.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> completion.get(100, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + caller.join(5_000L); + } + + assertFalse(caller.isAlive()); + assertSame(expected, completion.get(5, TimeUnit.SECONDS)); + assertEquals(1, expected.getSuppressed().length); + assertSame(later, expected.getSuppressed()[0]); + assertEquals(0, active.get()); + assertStopped(readers); + } + + @Test + public void interruptedCallerDrainsStartedReadsAndRetainsItsInterruptFlag() throws Exception { + requireParallelReaders(); + Path pack = smallPack("interrupted", 64); + CountDownLatch blocked = new CountDownLatch(1); + CountDownLatch interruptedReader = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger active = new AtomicInteger(); + AtomicBoolean interruptPreserved = new AtomicBoolean(); + Set readers = ConcurrentHashMap.newKeySet(); + CompletableFuture completion = new CompletableFuture<>(); + Thread caller = Thread.ofPlatform().start(() -> { + try { + GenerationPackFingerprint.computeWithInput(pack, 2, path -> { + readers.add(Thread.currentThread()); + if (path.getFileName().toString().equals("000.bin")) { + return tracked(blockingInput(blocked, release, interruptedReader), active); + } + return tracked(GenerationPackFingerprint.openInput(path), active); + }); + completion.complete(null); + } catch (Throwable failure) { + interruptPreserved.set(Thread.currentThread().isInterrupted()); + completion.complete(failure); + } + }); + try { + assertTrue(blocked.await(5, TimeUnit.SECONDS)); + caller.interrupt(); + assertTrue(interruptedReader.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> completion.get(100, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + caller.join(5_000L); + } + + assertFalse(caller.isAlive()); + assertTrue(completion.get(5, TimeUnit.SECONDS) instanceof IOException); + assertTrue(interruptPreserved.get()); + assertEquals(0, active.get()); + assertStopped(readers); + } + + private Path smallPack(String name, int count) throws IOException { + Path pack = temporaryFolder.newFolder(name).toPath(); + for (int index = 0; index < count; index++) { + Files.write(pack.resolve(String.format("%03d.bin", index)), new byte[]{1}); + } + return pack; + } + + private static void write(Path path, byte[] bytes) throws IOException { + Files.createDirectories(path.getParent()); + Files.write(path, bytes); + } + + private static String serialFingerprint(Map entries) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (Map.Entry entry : entries.entrySet()) { + byte[] name = entry.getKey().getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(4).putInt(name.length).array()); + digest.update(name); + digest.update(ByteBuffer.allocate(8).putLong(entry.getValue().length).array()); + digest.update(entry.getValue()); + } + return HexFormat.of().formatHex(digest.digest()); + } + + private static InputStream tracked(InputStream input, AtomicInteger active) { + active.incrementAndGet(); + return new FilterInputStream(input) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + active.decrementAndGet(); + } + } + }; + } + + private static InputStream blockingInput(CountDownLatch blocked, CountDownLatch release, + CountDownLatch interrupted) { + return new ByteArrayInputStream(new byte[]{1}) { + @Override + public synchronized int read(byte[] bytes, int offset, int length) { + blocked.countDown(); + boolean wasInterrupted = false; + while (true) { + try { + release.await(); + break; + } catch (InterruptedException failure) { + wasInterrupted = true; + if (interrupted != null) { + interrupted.countDown(); + } + } + } + if (wasInterrupted) { + Thread.currentThread().interrupt(); + } + return super.read(bytes, offset, length); + } + }; + } + + private static void await(CountDownLatch latch) throws IOException { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting for the concurrent reader"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException(failure); + } + } + + private static void assertStopped(Set readers) throws InterruptedException { + for (Thread reader : readers) { + reader.join(5_000L); + assertFalse("Fingerprint reader did not stop: " + reader.getName(), reader.isAlive()); + } + } + + private static void requireParallelReaders() { + Assume.assumeTrue(Runtime.getRuntime().availableProcessors() > 1); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationPackRepositoryTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationPackRepositoryTest.java index 8df763740..22046a8dc 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationPackRepositoryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationPackRepositoryTest.java @@ -72,6 +72,28 @@ public class GenerationPackRepositoryTest { )); } + @Test + public void rejectsAChangedCopyBeforePublishingTheEpoch() throws Exception { + Path dimensionRoot = temporaryFolder.newFolder("changed-copy-world").toPath(); + Path source = createPack("changed-copy-source", "{}"); + String expected = fingerprint(source); + String epochId = digest('a'); + GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot); + try (MockedStatic ignored = mockStatic(GenerationPackFingerprint.class, invocation -> { + if (invocation.getMethod().getName().equals("compute")) { + Path pack = invocation.getArgument(0); + if (pack.getFileName().toString().startsWith(".pack-")) { + Files.writeString(pack.resolve("dimensions/main.json"), "changed"); + } + } + return invocation.callRealMethod(); + })) { + assertThrows(IOException.class, () -> repository.publish( + epochId, expected, GenerationPackFingerprint.CURRENT_VERSION, source)); + assertFalse(Files.exists(repository.packRoot(epochId))); + } + } + @Test public void finderMetadataCreatedDuringPublicationDoesNotChangeVersionTwoIdentity() throws Exception { Path dimensionRoot = temporaryFolder.newFolder("finder-world").toPath(); diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreBatchTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreBatchTest.java index 0362d08b2..9d80533af 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreBatchTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeStoreBatchTest.java @@ -9,13 +9,17 @@ import org.mockito.MockedConstruction; import java.io.IOException; import java.io.RandomAccessFile; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; import java.util.Queue; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -41,6 +45,56 @@ public class SavedBiomeStoreBatchTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void independentOriginRegionsPersistWhileOneRegionRemainsLocked() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + SavedBiomeStore store = SavedBiomeStore.open(root); + Object lockedRegion = regionLock(store, 0, 0); + List independent = List.of(chunk(-1, -1, 3L), chunk(-1, 0, 3L), chunk(0, -1, 3L)); + Set owners = Collections.newSetFromMap(new IdentityHashMap<>()); + owners.add(lockedRegion); + for (SavedBiomeChunk chunk : independent) { + Object owner = regionLock(store, chunk.chunkX(), chunk.chunkZ()); + assertTrue(owners.add(owner)); + assertSame(owner, regionLock(store, chunk.chunkX() & ~31, chunk.chunkZ() & ~31)); + assertSame(owner, regionLock(store, chunk.chunkX() | 31, chunk.chunkZ() | 31)); + } + CountDownLatch started = new CountDownLatch(4); + ExecutorService workers = Executors.newFixedThreadPool(4); + List> writes = new ArrayList<>(); + Future sameRegion; + try { + synchronized (lockedRegion) { + for (SavedBiomeChunk chunk : independent) { + writes.add(workers.submit(() -> { + started.countDown(); + return store.claimAndPersist(chunk); + })); + } + sameRegion = workers.submit(() -> { + started.countDown(); + return store.claimAndPersist(chunk(31, 31, 3L)); + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + for (Future write : writes) { + assertTrue(write.get(5, TimeUnit.SECONDS)); + } + assertFalse(sameRegion.isDone()); + assertTrue(store.cached(31, 31).isEmpty()); + assertSame(lockedRegion, regionLock(store, 31, 31)); + } + assertTrue(sameRegion.get(5, TimeUnit.SECONDS)); + SavedBiomeStore reopened = SavedBiomeStore.open(root); + for (SavedBiomeChunk chunk : independent) { + assertEquals(chunk, reopened.get(chunk.chunkX(), chunk.chunkZ()).orElseThrow()); + } + assertEquals(chunk(31, 31, 3L), reopened.get(31, 31).orElseThrow()); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS)); + } + } + @Test public void queuedClaimsShareOneForceAndPublishAfterItCompletes() throws Exception { Path root = temporaryFolder.newFolder().toPath(); @@ -158,8 +212,9 @@ public class SavedBiomeStoreBatchTest { List> results = enqueue(store, root, control, workers, List.of(chunk(1, 3L), chunk(2, 3L), chunk(3, 3L), chunk(4, 3L))); assertTrue(control.forcing.await(5, TimeUnit.SECONDS)); + Object writingRegion = regionLock(store, 0, 0); for (int region = 1; region <= 132; region++) { - if ((region & 63) != 0) { + if (regionLock(store, region << 5, 0) != writingRegion) { assertTrue(store.get(region << 5, 0).isEmpty()); } } @@ -194,9 +249,7 @@ public class SavedBiomeStoreBatchTest { private static List> enqueue(SavedBiomeStore store, Path root, BatchControl control, ExecutorService workers, List chunks) throws Exception { - Field locksField = SavedBiomeStore.class.getDeclaredField("regionLocks"); - locksField.setAccessible(true); - Object stripe = ((Object[]) locksField.get(store))[0]; + Object stripe = regionLock(store, 0, 0); Field writingField = stripe.getClass().getDeclaredField("writing"); Field pendingField = stripe.getClass().getDeclaredField("pending"); writingField.setAccessible(true); @@ -252,18 +305,28 @@ public class SavedBiomeStoreBatchTest { } private static SavedBiomeChunk chunk(int x, long activation) { + return chunk(x, 0, activation); + } + + private static SavedBiomeChunk chunk(int x, int z, long activation) { SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(activation, "biome", "region"); SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell, List.of(new SavedBiomeChunk.Span(-64, 320, cell))); - SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(x, 0, activation, -64, 384)); - for (int z = 0; z < 16; z++) { + SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(x, z, activation, -64, 384)); + for (int localZ = 0; localZ < 16; localZ++) { for (int localX = 0; localX < 16; localX++) { - builder.column(localX, z, column); + builder.column(localX, localZ, column); } } return builder.build(); } + private static Object regionLock(SavedBiomeStore store, int x, int z) throws ReflectiveOperationException { + Method lock = SavedBiomeStore.class.getDeclaredMethod("regionLock", int.class, int.class); + lock.setAccessible(true); + return lock.invoke(store, x, z); + } + private static final class BatchControl { private final CountDownLatch forcing = new CountDownLatch(1); private final CountDownLatch release = new CountDownLatch(1); diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/WorldLifecycleRuntimeLevelStemTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/WorldLifecycleRuntimeLevelStemTest.java index 343fd403a..01119248e 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/WorldLifecycleRuntimeLevelStemTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/WorldLifecycleRuntimeLevelStemTest.java @@ -120,6 +120,14 @@ public class WorldLifecycleRuntimeLevelStemTest { return CompletableFuture.completedFuture(0); } + @Override + public void beginInitialEntry(boolean playerEntry) { + } + + @Override + public void completeInitialEntry() { + } + @Override public void hotload() { } diff --git a/core/src/test/java/art/arcane/iris/world/pregen/PregenPerformanceProfileTest.java b/core/src/test/java/art/arcane/iris/world/pregen/PregenPerformanceProfileTest.java index 7dc1fe451..96c0e407f 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/PregenPerformanceProfileTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/PregenPerformanceProfileTest.java @@ -6,7 +6,6 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.platform.generation.PlatformChunkGenerator; import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.volmlib.util.stream.ProceduralStream; -import art.arcane.iris.generation.stream.CachedDoubleStream2D; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -16,6 +15,7 @@ import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -26,8 +26,6 @@ public class PregenPerformanceProfileTest { private IrisSettings previousSettings; private Engine engine; private IrisComplex complex; - private CachedDoubleStream2D natural; - private CachedDoubleStream2D raw; @Before public void setUp() { @@ -36,11 +34,7 @@ public class PregenPerformanceProfileTest { IrisSettings.get().getPerformance().setNoiseCacheSize(1_024); engine = mock(Engine.class); complex = mock(IrisComplex.class); - natural = mock(CachedDoubleStream2D.class); - raw = mock(CachedDoubleStream2D.class); when(engine.getComplex()).thenReturn(complex); - when(complex.getNaturalHeightStream()).thenReturn(natural); - when(complex.getRawHeightStream()).thenReturn(raw); when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 47D)); doThrow(new IllegalStateException("Immutable history cannot rebuild its runtime")) .when(engine).hotloadComplex(); @@ -56,8 +50,7 @@ public class PregenPerformanceProfileTest { PregenPerformanceProfile.apply(engine); assertEquals(4_096, IrisSettings.get().getPerformance().getNoiseCacheSize()); - verify(natural).setMaximumChunks(4_096); - verify(raw).setMaximumChunks(4_096); + verify(complex).ensureTerrainNoiseCacheSize(4_096); verify(complex, never()).getHeightStream(); verify(engine, never()).hotloadComplex(); } @@ -69,8 +62,7 @@ public class PregenPerformanceProfileTest { PregenPerformanceProfile.applyToGenerator(generator); - verify(natural).setMaximumChunks(4_096); - verify(raw).setMaximumChunks(4_096); + verify(complex).ensureTerrainNoiseCacheSize(4_096); verify(generator, never()).hotloadComplexAsync(30L, TimeUnit.SECONDS); verify(engine, never()).hotloadComplex(); } @@ -82,8 +74,21 @@ public class PregenPerformanceProfileTest { PregenPerformanceProfile.apply(engine); assertEquals(8_192, IrisSettings.get().getPerformance().getNoiseCacheSize()); - verify(natural).setMaximumChunks(8_192); - verify(raw).setMaximumChunks(8_192); + verify(complex).ensureTerrainNoiseCacheSize(8_192); + } + + @Test + public void failedCachePreparationDoesNotChangeGlobalTuningOrAcquireAParallelismHold() { + int holders = PregenPerformanceProfile.parallelismHolders(); + int parallelism = MultiBurst.burst.parallelism(); + doThrow(new IllegalStateException("Runtime closed")) + .when(complex).ensureTerrainNoiseCacheSize(4_096); + + assertThrows(IllegalStateException.class, () -> PregenPerformanceProfile.apply(engine)); + + assertEquals(1_024, IrisSettings.get().getPerformance().getNoiseCacheSize()); + assertEquals(parallelism, MultiBurst.burst.parallelism()); + assertEquals(holders, PregenPerformanceProfile.parallelismHolders()); } @Test diff --git a/core/src/test/java/art/arcane/iris/world/runtime/PaperLikeRuntimeControlBackendTimeTest.java b/core/src/test/java/art/arcane/iris/world/runtime/PaperLikeRuntimeControlBackendTimeTest.java new file mode 100644 index 000000000..ee4a26402 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/runtime/PaperLikeRuntimeControlBackendTimeTest.java @@ -0,0 +1,82 @@ +package art.arcane.iris.world.runtime; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.testsupport.BukkitTestServer; +import art.arcane.iris.world.lifecycle.CapabilitySnapshot; +import org.bukkit.World; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.OptionalLong; + +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.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +public class PaperLikeRuntimeControlBackendTimeTest { + @Before + public void installServer() { + BukkitTestServer.install(); + } + + @Test + public void missingWorldHandleSkipsClockWithoutReportingFailure() throws Exception { + assertUnsupportedClock(mock(World.class)); + } + + @Test + public void missingClockMethodsSkipClockWithoutReportingFailure() throws Exception { + assertUnsupportedClock(worldWithHandle(new Object())); + } + + @Test + public void supportedClockStillReadsAndWrites() throws Exception { + ClockHandle handle = new ClockHandle(); + World world = worldWithHandle(handle); + PaperLikeRuntimeControlBackend backend = new PaperLikeRuntimeControlBackend(mock(CapabilitySnapshot.class)); + + assertEquals(OptionalLong.of(12000L), backend.readDayTime(world)); + assertTrue(backend.writeDayTime(world, 6000L)); + assertEquals(OptionalLong.of(6000L), backend.readDayTime(world)); + } + + private void assertUnsupportedClock(World world) throws Exception { + PaperLikeRuntimeControlBackend backend = new PaperLikeRuntimeControlBackend(mock(CapabilitySnapshot.class)); + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + assertEquals(OptionalLong.empty(), backend.readDayTime(world)); + assertEquals(OptionalLong.empty(), backend.readDayTime(world)); + assertFalse(backend.writeDayTime(world, 6000L)); + logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never()); + } + } + + private World worldWithHandle(Object handle) { + World world = mock(World.class, withSettings().extraInterfaces(HandleWorld.class)); + when(((HandleWorld) world).getHandle()).thenReturn(handle); + return world; + } + + public interface HandleWorld { + Object getHandle(); + } + + public static class ClockHandle { + private long dayTime = 12000L; + + public long getDayTime() { + return dayTime; + } + + public void setDayTime(long dayTime) { + this.dayTime = dayTime; + } + } +} diff --git a/gradle.properties b/gradle.properties index 656b7c9a4..b939f4fd0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,7 +23,7 @@ org.gradle.configureondemand=false nmsTools.repo-url=https://repo.codemc.org/repository/nms/ nmsTools.specialSourceVersion=1.11.4 -irisVersion=4.1.0-26.2 +irisVersion=4.1.1-26.2 minecraftVersion=26.2 # Bukkit plugin api-version: lowest supported Minecraft release line so one artifact loads on 26.1.2 and 26.2 apiVersion=26.1 diff --git a/probe/build.gradle b/probe/build.gradle index 3f6d21c7d..2cafe6521 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -21,6 +21,7 @@ dependencies { implementation(project(':core')) compileOnly(libs.spigot) testImplementation('junit:junit:4.13.2') + testImplementation(libs.gson) testImplementation(testFixtures(project(':core'))) testImplementation(volmLibCoordinate) { transitive = false 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 cabd3c937..7eb0917a1 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java @@ -3078,7 +3078,7 @@ public final class HydrologyPackProbe { deepCourses++; } } - for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + for (HydrologyDiagnosticCandidate candidate : runtime.diagnosticCandidates(tile.key())) { RejectionKey rejection = new RejectionKey( candidate.kind(), candidate.projectedType(), @@ -3738,7 +3738,6 @@ public final class HydrologyPackProbe { surfaceBiome, engine, engine.getSeedManager().getBiome(), - engine.getDimension().getLoadKey(), position.x(), position.z() ); @@ -3759,7 +3758,6 @@ public final class HydrologyPackProbe { IrisBiome biome, Engine engine, long biomeSeed, - String dimensionKey, int x, int z ) { @@ -3778,7 +3776,7 @@ public final class HydrologyPackProbe { if (custom == null || custom.getId() == null || custom.getId().isBlank()) { throw new IllegalStateException("Generated custom surface Iris biome resolved no custom biome id."); } - return IrisDimension.customBiomeKey(dimensionKey, custom.getId()); + return engine.getData().customBiomeResourceKey(engine.getDimension(), custom); } private static void verifyCaveWitness( 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 4d7efa0be..e6f410dac 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java @@ -89,7 +89,7 @@ public final class HydrologyPlannerProbe { warmTimings[seedIndex], tile.courses().size(), tile.features().size(), - tile.diagnosticCandidates().size(), + cache.diagnosticCandidates(tile.key()).size(), counts ); cache.close(); diff --git a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java index a67fea6fd..3b4550db7 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java @@ -224,7 +224,8 @@ public final class RiverTransectProbe { tile.cavePlan(course.id()).map((plan) -> plan.accepted() ? "accepted" : "rejected").orElse("none"))); } - for (String line : rejectionLines(tile)) { + List diagnostics = runtime.diagnosticCandidates(key); + for (String line : rejectionLines(diagnostics)) { System.out.println(PREFIX + " " + line); } writeTileDiagnostics(new File(configuration.output(), "tile-diagnostics.png"), tile, complex, runtime, seaLevel); @@ -253,7 +254,7 @@ public final class RiverTransectProbe { System.out.println(PREFIX + " " + detail); } } - writeSummary(new File(configuration.output(), "summary.txt"), configuration, tile, summaries); + writeSummary(new File(configuration.output(), "summary.txt"), configuration, tile, summaries, diagnostics); boolean pass = !summaries.isEmpty() && summaries.stream().allMatch(CourseSummary::passes); System.out.println(PREFIX + " " + (pass ? "PASS" : "FAIL") + " surfaceCourses=" + summaries.size() @@ -262,10 +263,10 @@ public final class RiverTransectProbe { } } - static List rejectionLines(HydrologyTile tile) { + static List rejectionLines(List candidates) { TreeMap counts = new TreeMap<>(); TreeMap> details = new TreeMap<>(); - for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + for (HydrologyDiagnosticCandidate candidate : candidates) { String key = "rejected " + candidate.kind() + " " + candidate.projectedType() + " " + candidate.rejection(); counts.merge(key, 1, Integer::sum); if (candidate.detail() != 0) { @@ -652,7 +653,7 @@ public final class RiverTransectProbe { } } } - for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + for (HydrologyDiagnosticCandidate candidate : runtime.diagnosticCandidates(tile.key())) { int rgb = switch (candidate.rejection()) { case NO_DRAINAGE_PATH -> 0xFF2020; case COURSE_TOO_SHORT -> 0xFFB000; @@ -846,7 +847,8 @@ public final class RiverTransectProbe { File file, Configuration configuration, HydrologyTile tile, - List summaries + List summaries, + List diagnostics ) throws IOException { StringBuilder text = new StringBuilder(); text.append("tile=").append(tile.key().tileX()).append(',').append(tile.key().tileZ()) @@ -855,7 +857,7 @@ public final class RiverTransectProbe { .append(" courses=").append(tile.courses().size()) .append(" surfaceCourses=").append(summaries.size()) .append('\n'); - for (String line : rejectionLines(tile)) { + for (String line : rejectionLines(diagnostics)) { text.append(line).append('\n'); } int maximumCut = 0; 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 4621daaae..16ce0f889 100644 --- a/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java @@ -14,6 +14,10 @@ import art.arcane.iris.generation.hydrology.cave.CavePosition; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiomeCustom; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.world.history.GenerationRegistryContractFactory; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; @@ -25,6 +29,7 @@ import org.junit.rules.TemporaryFolder; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.File; +import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; @@ -37,6 +42,7 @@ import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -222,6 +228,7 @@ public final class HydrologyPackProbeTest { List.of(), List.of(), List.of(), + Set.of(), List.of(), List.of(), new RiverFootprint(Map.of( @@ -265,7 +272,6 @@ public final class HydrologyPackProbeTest { biome, null, 19L, - "overworld", 32, -48 ) @@ -273,21 +279,32 @@ public final class HydrologyPackProbeTest { } @Test - public void generatedCustomSurfaceBiomeUsesRecursiveDimensionRegistryPath() { - IrisBiome biome = new IrisBiome() - .setCustomDerivitives(new KList<>(new IrisBiomeCustom().setId("Aurora"))); + public void generatedCustomSurfaceBiomeUsesPhysicalRegistryIdentity() throws Exception { + IrisData data = IrisData.openDatapackCompiler(temporaryFolder.newFolder("custom-biome-identity")); + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("layers/sky"); + Engine engine = (Engine) Proxy.newProxyInstance(Engine.class.getClassLoader(), new Class[]{Engine.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getData" -> data; + case "getDimension" -> dimension; + default -> throw new UnsupportedOperationException(method.getName()); + }); + try { + IrisBiomeCustom first = new IrisBiomeCustom().setId("aurora").setGrassColor("#114422"); + IrisBiomeCustom second = new IrisBiomeCustom().setId("aurora").setGrassColor("#BBCCDD"); + IrisBiome firstBiome = new IrisBiome().setCustomDerivitives(new KList<>(first)); + IrisBiome secondBiome = new IrisBiome().setCustomDerivitives(new KList<>(second)); + String firstKey = HydrologyPackProbe.generatedSurfaceBiomeKey(firstBiome, engine, 19L, 32, -48); + String secondKey = HydrologyPackProbe.generatedSurfaceBiomeKey(secondBiome, engine, 19L, 32, -48); - assertEquals( - "layers:sky/aurora", - HydrologyPackProbe.generatedSurfaceBiomeKey( - biome, - null, - 19L, - "Layers/Sky", - 32, - -48 - ) - ); + assertEquals(GenerationRegistryContractFactory.customBiomeResourceKey("layers/sky", first), firstKey); + assertEquals(GenerationRegistryContractFactory.customBiomeResourceKey("layers/sky", second), secondKey); + assertTrue(firstKey.startsWith("iris:biomes/")); + assertNotEquals(IrisDimension.customBiomeKey("layers/sky", "aurora"), firstKey); + assertNotEquals(firstKey, secondKey); + } finally { + data.close(); + } } @Test diff --git a/probe/src/test/java/art/arcane/iris/probe/HydrologyPlannerProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/HydrologyPlannerProbeTest.java index 9bcaa5c8e..2d895d2b6 100644 --- a/probe/src/test/java/art/arcane/iris/probe/HydrologyPlannerProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyPlannerProbeTest.java @@ -16,14 +16,14 @@ public final class HydrologyPlannerProbeTest { @Test public void coastalFixturePublishesAnAcceptedOceanMouth() { HydrologyTile tile = HydrologyPlannerProbe.surfaceMouthTile(); - assertTrue(tile.diagnosticCandidates().stream().limit(16).toList().toString(), + assertTrue(tile.localDiagnosticCandidates().stream().limit(16).toList().toString(), tile.features().stream().anyMatch(feature -> feature.type() == HydrologyFeatureType.MOUTH)); } @Test public void inlandFixturePublishesAContainedWaterfall() { HydrologyTile tile = HydrologyPlannerProbe.surfaceWaterfallTile(); - assertTrue(tile.diagnosticCandidates().stream().limit(16).toList().toString(), + assertTrue(tile.localDiagnosticCandidates().stream().limit(16).toList().toString(), tile.features().stream().anyMatch(feature -> feature.type() == HydrologyFeatureType.WATERFALL)); assertTrue(tile.courses().stream().anyMatch(course -> course.segments().stream().anyMatch(segment -> segment.fallingFluid() && segment.type() == HydrologyFeatureType.WATERFALL)