From fe5651f85401d7a29d02bf0de494d7e3a00427a1 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sun, 23 Aug 2026 13:56:23 -0400 Subject: [PATCH] d d --- .../core/nms/v26_2_R1/CustomBiomeSource.java | 153 +- .../core/nms/v26_2_R1/IrisChunkGenerator.java | 5 +- .../iris/core/nms/v26_2_R1/NMSBinding.java | 7 +- ...ustomBiomeSourceStructureContractTest.java | 47 +- ...IrisChunkGeneratorFailureContractTest.java | 89 +- .../NMSBindingDatapackStructureScopeTest.java | 12 +- .../src/main/java/art/arcane/iris/Iris.java | 19 +- .../iris/api/terrain/IrisColumnField.java | 7 +- .../iris/api/terrain/IrisColumnSample.java | 64 + .../iris/api/terrain/IrisColumnSink.java | 2 +- .../iris/api/terrain/IrisRiverState.java | 7 + .../iris/api/terrain/IrisSurfaceKind.java | 3 + ...java => IrisFailClosedChunkGenerator.java} | 102 +- .../iris/core/IrisWorldGeneratorResolver.java | 9 +- .../iris/core/commands/CommandObject.java | 4 +- .../iris/core/commands/CommandStudio.java | 28 +- .../iris/core/gui/BukkitVisionOverlay.java | 2 +- .../iris/core/service/IrisTerrainSVC.java | 72 +- .../terrain/IrisSurfaceClassifier.java | 31 + .../arcane/iris/IrisShutdownOrderingTest.java | 9 +- .../api/terrain/IrisColumnSampleTest.java | 145 ++ .../iris/core/IrisStartupOrderingTest.java | 12 + .../core/IrisWorldGeneratorResolverTest.java | 143 +- .../CommandObjectFluidHeightTest.java | 45 + .../runtime/StudioPlayerModeContractTest.java | 15 + .../iris/core/service/IrisTerrainSVCTest.java | 45 +- .../terrain/IrisSurfaceClassifierTest.java | 88 + .../modded/command/ModdedStudioCommands.java | 7 +- .../modded/command/ModdedVisionOverlay.java | 2 +- .../iris/core/IrisStartupValidation.java | 5 + .../arcane/iris/core/ServerConfigurator.java | 19 + .../art/arcane/iris/core/gui/GuiHost.java | 22 +- .../iris/core/gui/NoiseExplorerGUI.java | 1500 +++++++++---- .../arcane/iris/core/gui/NoisePalette.java | 96 + .../iris/core/gui/NoiseRenderCoordinator.java | 393 ++++ .../arcane/iris/core/gui/NoiseViewport.java | 44 + .../art/arcane/iris/core/gui/VisionGUI.java | 1943 +++++++++-------- .../iris/core/gui/VisionRenderController.java | 626 ++++++ .../arcane/iris/core/gui/VisionViewport.java | 42 + .../core/localization/DesktopUiMessages.java | 17 +- .../art/arcane/iris/core/nms/INMSBinding.java | 2 +- .../iris/core/nms/v1X/NMSBinding1X.java | 4 +- .../iris/core/pack/PackRiverValidator.java | 876 ++++++++ .../arcane/iris/core/pack/PackValidator.java | 3 + .../core/runtime/StudioOpenCoordinator.java | 377 +++- .../runtime/WorldRuntimeControlService.java | 99 + .../arcane/iris/core/service/StudioSVC.java | 25 + .../art/arcane/iris/core/service/TreeSVC.java | 6 +- .../art/arcane/iris/engine/IrisComplex.java | 277 ++- .../arcane/iris/engine/IrisEngineMantle.java | 28 +- .../iris/engine/UpperDimensionContext.java | 6 +- .../engine/actuator/IrisDecorantActuator.java | 18 +- .../actuator/IrisTerrainNormalActuator.java | 12 +- .../decorator/IrisShoreLineDecorator.java | 13 +- .../decorator/IrisSurfaceDecorator.java | 2 +- .../arcane/iris/engine/framework/Engine.java | 10 + .../NativeStructurePlacementPlanner.java | 5 +- .../StructureCaveAnchorResolver.java | 27 +- .../framework/StructurePlacementScope.java | 98 +- .../framework/placer/WorldObjectPlacer.java | 3 +- .../engine/framework/render/IrisRenderer.java | 719 +++++- .../engine/framework/render/RenderType.java | 2 +- .../iris/engine/mantle/EngineMantle.java | 14 +- .../iris/engine/mantle/MantleComponent.java | 4 + .../iris/engine/mantle/MantleWriter.java | 53 +- .../mantle/components/CarveOrphanSweep.java | 15 +- .../CaveObjectPlacementTransaction.java | 10 +- .../ConfiguredRiverGrottoShape.java | 66 + .../mantle/components/IrisCaveCarver3D.java | 26 +- .../components/IrisStructureComponent.java | 2 +- .../components/MantleCarvingComponent.java | 36 +- .../MantleFloatingObjectComponent.java | 2 +- .../components/MantleObjectComponent.java | 25 +- .../components/MantleRiverCaveVoxelView.java | 157 ++ .../MantleRiverHydrologyComponent.java | 707 ++++++ .../components/SurfaceFluidBoundaryPlan.java | 72 +- .../engine/modifier/IrisCarveModifier.java | 344 ++- .../engine/modifier/IrisPostModifier.java | 6 +- .../iris/engine/object/IObjectPlacer.java | 11 + .../arcane/iris/engine/object/IrisBiome.java | 2 + .../engine/object/IrisBiomeColorRenderer.java | 2 +- .../iris/engine/object/IrisDimension.java | 13 +- .../engine/object/IrisEngineStreamType.java | 17 +- .../object/IrisObjectPlacementRunner.java | 3 +- .../arcane/iris/engine/object/IrisRegion.java | 21 +- .../iris/engine/object/IrisRiverBiomes.java | 61 + .../engine/object/IrisRiverCaveFallback.java | 12 + .../iris/engine/object/IrisRiverCaveMode.java | 21 + .../iris/engine/object/IrisRiverCaves.java | 95 + .../object/IrisRiverExistingFluidPolicy.java | 15 + .../iris/engine/object/IrisRiverNetwork.java | 30 + .../engine/object/IrisRiverNoiseChance.java | 27 + .../iris/engine/object/IrisRiverOverride.java | 103 + .../engine/object/IrisRiverRoutingPolicy.java | 15 + .../engine/object/IrisRiverTerminalMode.java | 15 + .../iris/engine/object/IrisRiverTerrain.java | 99 + .../iris/engine/object/IrisRiverTopology.java | 92 + .../iris/engine/object/IrisRiverWater.java | 32 + .../engine/object/IrisRiverWaterMode.java | 12 + .../engine/platform/BukkitChunkGenerator.java | 157 +- .../arcane/iris/engine/river/RiverAnchor.java | 27 + .../arcane/iris/engine/river/RiverEdgeId.java | 34 + .../engine/river/RiverMeanderContext.java | 18 + .../iris/engine/river/RiverNetwork.java | 918 ++++++++ .../engine/river/RiverNetworkOptions.java | 351 +++ .../arcane/iris/engine/river/RiverNode.java | 24 + .../arcane/iris/engine/river/RiverNodeId.java | 13 + .../iris/engine/river/RiverPolyline.java | 48 + .../arcane/iris/engine/river/RiverReach.java | 36 + .../arcane/iris/engine/river/RiverRoute.java | 18 + .../iris/engine/river/RiverRouteState.java | 7 + .../engine/river/RiverRoutingContext.java | 105 + .../arcane/iris/engine/river/RiverSample.java | 37 + .../iris/engine/river/RiverSection.java | 10 + .../engine/river/RiverTerminalPolicy.java | 8 + .../engine/river/RiverTerrainNodeSample.java | 9 + .../engine/river/RiverTerrainSampler.java | 76 + .../river/RiverTerrainSourceSample.java | 8 + .../arcane/iris/engine/river/RiverTile.java | 621 ++++++ .../iris/engine/river/RiverTileCache.java | 200 ++ .../engine/river/RiverTopologyComplexity.java | 148 ++ .../iris/engine/river/cave/CavePosition.java | 7 + .../iris/engine/river/cave/CaveVoxel.java | 9 + .../river/cave/CaveVoxelPrecondition.java | 9 + .../iris/engine/river/cave/CaveVoxelView.java | 9 + .../engine/river/cave/RiverCaveAction.java | 8 + .../cave/RiverCaveContainmentPlanner.java | 941 ++++++++ .../river/cave/RiverCaveFluidPolicy.java | 7 + .../river/cave/RiverCaveGrottoShape.java | 21 + .../engine/river/cave/RiverCaveHydrology.java | 82 + .../river/cave/RiverCaveHydrologyStorage.java | 41 + .../iris/engine/river/cave/RiverCaveMode.java | 8 + .../iris/engine/river/cave/RiverCavePlan.java | 31 + .../river/cave/RiverCavePlannerSettings.java | 104 + .../river/cave/RiverCavePlanningResult.java | 17 + .../engine/river/cave/RiverCaveRejection.java | 21 + .../engine/river/cave/RiverCaveSource.java | 17 + .../river/runtime/EffectiveRiverSettings.java | 161 ++ .../river/runtime/IrisRiverRuntime.java | 1162 ++++++++++ .../runtime/IrisRiverRuntimeContext.java | 38 + .../river/runtime/IrisRiverSurfaceSample.java | 33 + .../river/runtime/IrisRiverTunnelSample.java | 22 + .../runtime/RiverHeightBoundsSampler.java | 8 + .../river/runtime/RiverPolylineProbe.java | 56 + .../director/handlers/GeneratorHandler.java | 2 +- .../hunk/view/ChunkDataHunkHolder.java | 20 +- .../project/matter/IrisMatterSupport.java | 2 + .../slices/RiverCaveHydrologyMatter.java | 58 + core/src/main/resources/languages/de_DE.json | 9 +- core/src/main/resources/languages/es_ES.json | 9 +- core/src/main/resources/languages/fi_FI.json | 9 +- core/src/main/resources/languages/fr_FR.json | 9 +- core/src/main/resources/languages/he_IL.json | 9 +- core/src/main/resources/languages/it_IT.json | 9 +- core/src/main/resources/languages/ja-JP.json | 9 +- core/src/main/resources/languages/ko_KR.json | 9 +- core/src/main/resources/languages/lt_LT.json | 9 +- core/src/main/resources/languages/nl_NL.json | 9 +- core/src/main/resources/languages/pl_PL.json | 9 +- core/src/main/resources/languages/pt_PT.json | 9 +- core/src/main/resources/languages/ru_RU.json | 9 +- core/src/main/resources/languages/tr_TR.json | 9 +- core/src/main/resources/languages/vi_VI.json | 9 +- core/src/main/resources/languages/zh_CN.json | 9 +- core/src/main/resources/languages/zh_TW.json | 9 +- .../iris/core/IrisStartupValidationTest.java | 3 + .../ServerConfiguratorStartupRestartTest.java | 78 + .../art/arcane/iris/core/gui/GuiHostTest.java | 17 +- .../iris/core/gui/NoiseExplorerGUITest.java | 117 + .../iris/core/gui/NoisePaletteTest.java | 36 + .../core/gui/NoiseRenderCoordinatorTest.java | 413 ++++ .../iris/core/gui/NoiseViewportTest.java | 31 + .../core/gui/VisionRenderControllerTest.java | 263 +++ .../iris/core/gui/VisionRenderTypeTest.java | 33 + .../iris/core/gui/VisionViewportTest.java | 33 + .../v1X/NMSBinding1XWorldGenerationTest.java | 16 + .../core/pack/PackRiverValidatorTest.java | 620 ++++++ .../core/project/IrisRiverSchemaTest.java | 103 + .../StudioOpenCoordinatorOpenKindTest.java | 117 +- ...enCoordinatorSpawnStuckRegressionTest.java | 23 +- ...rldRuntimeControlServiceSafeEntryTest.java | 110 +- .../StudioSVCWorldPackPublishTest.java | 28 + .../core/service/TreeSVCFluidHeightTest.java | 22 + .../engine/IrisComplexSurfaceBiomeTest.java | 204 ++ ...ructureBootstrapLifecycleContractTest.java | 1 + .../IrisDecorantActuatorRiverTest.java | 40 + .../IrisDecoratorCaveContextTest.java | 7 + .../decorator/IrisShoreLineDecoratorTest.java | 3 + .../IrisStructureLocatorContractTest.java | 2 +- .../NativeStructurePlacementPlannerTest.java | 17 + .../StructureCaveAnchorResolverTest.java | 40 + .../StructurePlacementScopeTest.java | 40 +- .../IrisRendererPerformanceContractTest.java | 166 ++ .../render/IrisRendererRiverTest.java | 96 + .../mantle/EngineMantleCleanupTest.java | 2 + .../mantle/MantleWriterOverlayTest.java | 50 +- .../CaveObjectPlacementTransactionTest.java | 17 + .../IrisCaveCarver3DNearParityTest.java | 11 +- .../MantleObjectComponentCaveAnchorTest.java | 14 + .../MantleRiverCaveVoxelViewTest.java | 37 + .../MantleRiverHydrologyComponentTest.java | 731 +++++++ .../SurfaceFluidBoundaryPlanTest.java | 51 +- .../IrisCarveModifierBoundarySupportTest.java | 18 +- ...isCarveModifierInferenceIsolationTest.java | 3 + .../IrisCarveModifierRiverHydrologyTest.java | 110 + .../object/IObjectPlacerFluidHeightTest.java | 71 + .../object/IrisRiverConfigurationTest.java | 212 ++ ...tChunkGeneratorInitializationModeTest.java | 1 + .../iris/engine/river/RiverNetworkTest.java | 1095 ++++++++++ .../iris/engine/river/RiverTileCacheTest.java | 305 +++ .../cave/RiverCaveContainmentPlannerTest.java | 672 ++++++ .../cave/RiverCaveHydrologyStorageTest.java | 46 + .../runtime/EffectiveRiverSettingsTest.java | 62 + .../river/runtime/IrisRiverRuntimeTest.java | 891 ++++++++ .../handlers/GeneratorHandlerTest.java | 12 + .../slices/RiverCaveHydrologyMatterTest.java | 44 + 216 files changed, 21919 insertions(+), 1865 deletions(-) create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSample.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisRiverState.java rename adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/{IrisProbeChunkGenerator.java => IrisFailClosedChunkGenerator.java} (53%) create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnSampleTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectFluidHeightTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/gui/NoisePalette.java create mode 100644 core/src/main/java/art/arcane/iris/core/gui/NoiseRenderCoordinator.java create mode 100644 core/src/main/java/art/arcane/iris/core/gui/NoiseViewport.java create mode 100644 core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java create mode 100644 core/src/main/java/art/arcane/iris/core/gui/VisionViewport.java create mode 100644 core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java create mode 100644 core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java create mode 100644 core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java create mode 100644 core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverBiomes.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveFallback.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverExistingFluidPolicy.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverNetwork.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverNoiseChance.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverOverride.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingPolicy.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerminalMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverAnchor.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverEdgeId.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverMeanderContext.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverNode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverNodeId.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverPolyline.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverReach.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverRoute.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverRouteState.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverRoutingContext.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverSample.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverSection.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverTerminalPolicy.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverTerrainNodeSample.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSourceSample.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverTile.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverTileCache.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/CavePosition.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxel.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelPrecondition.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelView.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidPolicy.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveGrottoShape.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorage.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlan.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlannerSettings.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlanningResult.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveRejection.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveSource.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettings.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverSurfaceSample.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverTunnelSample.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/runtime/RiverHeightBoundsSampler.java create mode 100644 core/src/main/java/art/arcane/iris/engine/river/runtime/RiverPolylineProbe.java create mode 100644 core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java create mode 100644 core/src/test/java/art/arcane/iris/core/ServerConfiguratorStartupRestartTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/NoiseExplorerGUITest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/NoisePaletteTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/NoiseRenderCoordinatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/NoiseViewportTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/VisionRenderControllerTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/VisionRenderTypeTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/gui/VisionViewportTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/TreeSVCFluidHeightTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererPerformanceContractTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IObjectPlacerFluidHeightTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorageTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettingsTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java create mode 100644 core/src/test/java/art/arcane/iris/util/common/director/handlers/GeneratorHandlerTest.java create mode 100644 core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 43d6dad87..101823f62 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -61,6 +61,7 @@ public class CustomBiomeSource extends BiomeSource { private final ConcurrentHashMap> noiseBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> surfaceStructureBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> naturalSurfaceStructureBiomeCache = new ConcurrentHashMap<>(); private volatile KMap> customBiomes; private volatile Map> vanillaSpawnBiomes; private volatile IrisDimension cacheDimension; @@ -330,8 +331,18 @@ public class CustomBiomeSource extends BiomeSource { if (quartStep == 1) { return super.findBiomeHorizontal(x, y, z, searchRadius, allowed, random, sampler); } - return super.findBiomeHorizontal( - x, y, z, searchRadius, quartStep, allowed, random, false, sampler); + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_structure_ring_biome"); + if (lease == null) { + throw new IllegalStateException("Iris structure ring biome lookup was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isRuntimeAvailable()) { + throw new IllegalStateException("Iris structure ring biome lookup has no active engine runtime"); + } + ensureCachesCurrent(); + return findNaturalSurfaceBiomeHorizontal( + x, y, z, searchRadius, quartStep, allowed, random); + } } static int horizontalBiomeSearchQuartStep(int blockY, int searchRadius) { @@ -389,6 +400,60 @@ public class CustomBiomeSource extends BiomeSource { return resolvedSurfaceHolder; } + private Pair> findNaturalSurfaceBiomeHorizontal( + int x, + int y, + int z, + int searchRadius, + int quartStep, + Predicate> allowed, + RandomSource random + ) { + int centerQuartX = QuartPos.fromBlock(x); + int centerQuartZ = QuartPos.fromBlock(z); + int quartRadius = QuartPos.fromBlock(searchRadius); + Pair> selected = null; + int matches = 0; + for (int radius = 0; radius <= quartRadius; radius += quartStep) { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ += quartStep) { + for (int offsetX = -radius; offsetX <= radius; offsetX += quartStep) { + int quartX = centerQuartX + offsetX; + int quartZ = centerQuartZ + offsetZ; + Holder holder = getNaturalSurfaceStructureBiomeHolder(quartX, quartZ); + if (!allowed.test(holder)) { + continue; + } + if (selected == null || random.nextInt(matches + 1) == 0) { + selected = Pair.of(new BlockPos( + QuartPos.toBlock(quartX), + y, + QuartPos.toBlock(quartZ)), holder); + } + matches++; + } + } + } + return selected; + } + + private Holder getNaturalSurfaceStructureBiomeHolder(int x, int z) { + long columnKey = packColumnKey(x, z); + Holder cachedHolder = naturalSurfaceStructureBiomeCache.get(columnKey); + if (cachedHolder != null) { + return cachedHolder; + } + Holder resolvedHolder = resolveNaturalSurfaceStructureBiomeHolder(x, z); + Holder existingHolder = naturalSurfaceStructureBiomeCache.putIfAbsent( + columnKey, resolvedHolder); + if (existingHolder != null) { + return existingHolder; + } + if (naturalSurfaceStructureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + naturalSurfaceStructureBiomeCache.clear(); + } + return resolvedHolder; + } + private boolean isGuaranteedSurfaceBiome(int quartY) { if (engine == null || engine.isClosed() || engine.getComplex() == null) { return false; @@ -416,36 +481,66 @@ public class CustomBiomeSource extends BiomeSource { return holder; } + private Holder resolveNaturalSurfaceStructureBiomeHolder(int x, int z) { + int blockX = x << 2; + int blockZ = z << 2; + IrisBiome irisBiome = engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ); + if (irisBiome == null) { + throw new IllegalStateException("Iris returned no natural structure biome at block " + + blockX + "," + blockZ); + } + Holder holder = resolveBiomeHolder(biomeRegistry, irisBiome.getStructureDerivativeKey()); + if (holder == null) { + throw new IllegalStateException("Iris natural structure biome derivative '" + + irisBiome.getStructureDerivativeKey() + "' is not registered at block " + + blockX + "," + blockZ); + } + return holder; + } + public Holder getVisibleNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_visible_biome"); if (lease == null) { throw new IllegalStateException("Iris visible biome lookup was rejected during an engine transition"); } try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - if (!isRuntimeAvailable()) { - throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); - } - ensureCachesCurrent(); - long cacheKey = packNoiseKey(x, y, z); - Holder cachedHolder = noiseBiomeCache.get(cacheKey); - if (cachedHolder != null) { - return cachedHolder; - } - - Holder resolvedHolder = resolveVisibleBiomeHolder(x, y, z); - Holder existingHolder = noiseBiomeCache.putIfAbsent(cacheKey, resolvedHolder); - if (existingHolder != null) { - return existingHolder; - } - - if (noiseBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { - noiseBiomeCache.clear(); - } - - return resolvedHolder; + prepareVisibleBiomeBatch(); + return getVisibleNoiseBiomeWithActiveGenerationLease(x, y, z, sampler); } } + void prepareVisibleBiomeBatch() { + if (!isRuntimeAvailable()) { + throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); + } + ensureCachesCurrent(); + } + + Holder getVisibleNoiseBiomeWithActiveGenerationLease( + int x, + int y, + int z, + Climate.Sampler sampler + ) { + long cacheKey = packNoiseKey(x, y, z); + Holder cachedHolder = noiseBiomeCache.get(cacheKey); + if (cachedHolder != null) { + return cachedHolder; + } + + Holder resolvedHolder = resolveVisibleBiomeHolder(x, y, z); + Holder existingHolder = noiseBiomeCache.putIfAbsent(cacheKey, resolvedHolder); + if (existingHolder != null) { + return existingHolder; + } + + if (noiseBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + noiseBiomeCache.clear(); + } + + return resolvedHolder; + } + private GenerationSessionLease tryAcquireGenerationLease(String operation) { if (engine.isClosed()) { return null; @@ -482,6 +577,7 @@ public class CustomBiomeSource extends BiomeSource { noiseBiomeCache.clear(); structureBiomeCache.clear(); surfaceStructureBiomeCache.clear(); + naturalSurfaceStructureBiomeCache.clear(); customBiomes = refreshedCustomBiomes; vanillaSpawnBiomes = refreshedSpawnBiomes; cacheDimension = dimension; @@ -573,11 +669,20 @@ public class CustomBiomeSource extends BiomeSource { return null; } + return createBiomeResolution(irisBiome, underground, blockX, blockY, blockZ); + } + + private BiomeResolution createBiomeResolution( + IrisBiome irisBiome, + boolean underground, + int blockX, + int blockY, + int blockZ + ) { RNG noiseRng = new RNG(seed ^ (((long) blockX) * 341873128712L) ^ (((long) blockY) * 132897987541L) ^ (((long) blockZ) * 42317861L)); - return new BiomeResolution(irisBiome, underground, blockX, blockY, blockZ, noiseRng); } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 7c106f661..1a3a31eed 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -642,7 +642,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator { try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_biomes"); GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_biomes"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - ichunkaccess.fillBiomesFromNoise(customBiomeSource::getVisibleNoiseBiome, randomstate.sampler()); + customBiomeSource.prepareVisibleBiomeBatch(); + ichunkaccess.fillBiomesFromNoise( + customBiomeSource::getVisibleNoiseBiomeWithActiveGenerationLease, + randomstate.sampler()); return CompletableFuture.completedFuture(ichunkaccess); } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index cdf42970c..e5a80cdb5 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -168,6 +168,7 @@ import java.util.Map; import java.util.Optional; import java.util.List; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -1295,16 +1296,16 @@ public class NMSBinding implements INMSBinding { } @Override - public void completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException { + public CompletableFuture completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException { ServerLevel level = ((CraftWorld) world).getHandle(); ChunkMap chunkMap = level.getChunkSource().chunkMap; IrisChunkGenerator generator = requireIrisGenerator(level.getChunkSource().getGenerator()); IrisChunkGenerator.StudioStructureState retained = generator.retainedStudioStructureState(level, chunkMap); if (retained == null) { - return; + return CompletableFuture.completedFuture(null); } - generator.activateStudioStructureState(retained); + return generator.activateStudioStructureState(retained); } @Override diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java index 76fb17d62..3d4d48e82 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java @@ -45,8 +45,53 @@ public class CustomBiomeSourceStructureContractTest { assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_Y = 0")); assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_RADIUS = 112")); assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_QUART_STEP = 4")); - assertTrue(source.contains("x, y, z, searchRadius, quartStep, allowed, random, false, sampler")); assertTrue(source.contains( "return super.findBiomeHorizontal(x, y, z, searchRadius, allowed, random, sampler)")); + assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_structure_ring_biome\")")); + assertTrue(source.contains("findNaturalSurfaceBiomeHorizontal(")); + assertTrue(source.contains("radius += quartStep")); + assertTrue(source.contains("offsetZ += quartStep")); + assertTrue(source.contains("offsetX += quartStep")); + assertTrue(source.contains("random.nextInt(matches + 1) == 0")); + } + + @Test + public void onlyConcentricRingSuitabilityUsesTheNaturalTerrainStream() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + int realResolutionStart = source.indexOf("private Holder resolveSurfaceStructureBiomeHolder("); + int naturalResolutionStart = source.indexOf( + "private Holder resolveNaturalSurfaceStructureBiomeHolder("); + int resolutionEnd = source.indexOf("public Holder getVisibleNoiseBiome(", naturalResolutionStart); + + assertTrue(realResolutionStart >= 0); + assertTrue(naturalResolutionStart > realResolutionStart); + assertTrue(resolutionEnd > naturalResolutionStart); + String realResolution = source.substring(realResolutionStart, naturalResolutionStart); + String naturalResolution = source.substring(naturalResolutionStart, resolutionEnd); + + assertTrue(realResolution.contains("engine.getComplex().getTrueBiomeStream().get(blockX, blockZ)")); + assertFalse(realResolution.contains("getNaturalTrueBiomeStream()")); + assertTrue(naturalResolution.contains( + "engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ)")); + assertFalse(naturalResolution.contains("getTrueBiomeStream()")); + assertFalse(source.contains("studioBootstrapSurfaceStructureBiomeCache")); + } + + @Test + public void visibleBiomeBatchReusesTheCallersGenerationLease() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + int lookupStart = source.indexOf("public Holder getVisibleNoiseBiome("); + int lookupEnd = source.indexOf("private GenerationSessionLease tryAcquireGenerationLease(", lookupStart); + + assertTrue(lookupStart >= 0); + assertTrue(lookupEnd > lookupStart); + String lookup = source.substring(lookupStart, lookupEnd); + + assertTrue(lookup.contains("tryAcquireGenerationLease(\"bukkit_visible_biome\")")); + assertTrue(lookup.contains("prepareVisibleBiomeBatch()")); + assertTrue(lookup.contains("getVisibleNoiseBiomeWithActiveGenerationLease(x, y, z, sampler)")); + assertTrue(lookup.indexOf("prepareVisibleBiomeBatch()") + < lookup.indexOf("getVisibleNoiseBiomeWithActiveGenerationLease(x, y, z, sampler)")); + assertFalse(source.contains("studioBootstrapNoiseBiomeCache")); } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java index d1365b6c5..5dc1e6159 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -219,30 +219,89 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(source.contains("int minY = chunk.getMinY() + 1;")); } + @Test + public void everyChunkUsesTheProductionGenerationStages() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))) + .replace("\r\n", "\n"); + String createBiomes = method( + source, + "public CompletableFuture createBiomes", + "public void buildSurface"); + String buildSurface = method( + source, + "public void buildSurface", + "public void applyCarvers"); + String carvers = method( + source, + "public void applyCarvers", + "public CompletableFuture fillFromNoise"); + String noise = method( + source, + "public CompletableFuture fillFromNoise", + "private static boolean isCancellationFailure"); + String decoration = method( + source, + "public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla)", + "public BiomeGenerationSettings getBiomeGenerationSettings"); + String spawning = method( + source, + "public void spawnOriginalMobs", + "private static WeightedList mergeSpawnTables"); + String baseHeight = method( + source, + "public int getBaseHeight", + "public NoiseColumn getBaseColumn"); + String baseColumn = method( + source, + "public NoiseColumn getBaseColumn", + "private GenerationSessionLease requireGenerationLease"); + + assertTrue(createBiomes.contains("requireGenerationStage(\"bukkit_nms_create_biomes\")")); + assertTrue(createBiomes.contains("customBiomeSource.prepareVisibleBiomeBatch()")); + assertTrue(createBiomes.contains("customBiomeSource::getVisibleNoiseBiomeWithActiveGenerationLease")); + assertTrue(buildSurface.contains("delegate.buildSurface(")); + assertTrue(carvers.contains("delegate.applyCarvers(")); + assertTrue(noise.contains("requireNoiseGenerationStage(")); + assertTrue(noise.contains("\"bukkit_nms_chunk_pipeline\"")); + assertTrue(noise.contains("stage.close()")); + assertTrue(noise.contains("completion.cancel(false)")); + assertTrue(decoration.contains("requireGenerationStage(\"bukkit_nms_biome_decoration\")")); + assertTrue(spawning.contains("NaturalSpawner.spawnMobsForChunkGeneration(")); + assertTrue(baseHeight.contains("engine.getHeight(")); + assertTrue(baseColumn.contains("engine.getHeight(")); + assertFalse(source.contains("synthetic_entry")); + assertFalse(source.contains("StudioEntryChunkGenerator")); + } + @Test public void fillFromNoiseLeaseSpansTheDelegateAndHeightmapPipeline() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))).replace("\r\n", "\n"); int fillStart = source.indexOf("public CompletableFuture fillFromNoise"); int fillEnd = source.indexOf("private static boolean isCancellationFailure", fillStart); String fill = source.substring(fillStart, fillEnd); - int completionStart = fill.indexOf("pipeline.whenComplete("); - int completionEnd = fill.indexOf(" return completion;", completionStart); + int productionStart = fill.indexOf( + " BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage(", + 0); + assertTrue(productionStart >= 0); + String productionFill = fill.substring(productionStart); + int completionStart = productionFill.indexOf("pipeline.whenComplete("); + int completionEnd = productionFill.indexOf(" return completion;", completionStart); assertTrue(completionStart >= 0); assertTrue(completionEnd > completionStart); - String completion = fill.substring(completionStart, completionEnd); + String completion = productionFill.substring(completionStart, completionEnd); - assertBefore(fill, + assertBefore(productionFill, "BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage(", "GenerationSessionLease lease"); - assertBefore(fill, + assertBefore(productionFill, "requireNoiseGenerationStage(", "\"bukkit_nms_chunk_pipeline\")"); - assertBefore(fill, + assertBefore(productionFill, "lease = requireGenerationLease(\"bukkit_nms_chunk_pipeline\")", ".fillFromNoise(blender, randomstate, structuremanager, ichunkaccess)"); - assertTrue(fill.contains("IrisContext.open(engine, lease.sessionId(), null)")); - assertBefore(fill, "primeWorldgenHeightmaps(filled)", "pipeline.whenComplete("); - assertTrue(fill.contains("CompletableFuture completion = new CompletableFuture<>()")); + assertTrue(productionFill.contains("IrisContext.open(engine, lease.sessionId(), null)")); + assertBefore(productionFill, "primeWorldgenHeightmaps(filled)", "pipeline.whenComplete("); + assertTrue(productionFill.contains("CompletableFuture completion = new CompletableFuture<>()")); assertBefore(completion, "boolean cancelled = isCancellationFailure(failure);", "lease.close();"); assertBefore(completion, "lease.close();", "stage.close();"); assertBefore(completion, "stage.close();", "completion.complete(filled)"); @@ -251,12 +310,12 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(completion.contains("else if (cancelled)")); assertFalse(completion.contains("pipeline.isCancelled()")); assertFalse(completion.contains("finally")); - assertTrue(fill.contains("catch (RuntimeException | Error failure)")); - assertTrue(fill.contains("lease.close();\n stage.close();\n throw failure;")); - assertTrue(fill.contains("return completion;")); - assertFalse(fill.contains("return pipeline;")); - assertFalse(fill.contains("pipeline.cancel(")); - assertFalse(fill.contains("bukkit_nms_worldgen_heightmaps")); + assertTrue(productionFill.contains("catch (RuntimeException | Error failure)")); + assertTrue(productionFill.contains("lease.close();\n stage.close();\n throw failure;")); + assertTrue(productionFill.contains("return completion;")); + assertFalse(productionFill.contains("return pipeline;")); + assertFalse(productionFill.contains("pipeline.cancel(")); + assertFalse(productionFill.contains("bukkit_nms_worldgen_heightmaps")); } @Test diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java index 3dac36b41..fcf2f33da 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java @@ -356,7 +356,9 @@ public class NMSBindingDatapackStructureScopeTest { Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")).replace("\r\n", "\n"); int methodStart = source.indexOf("public DatapackStructureScopeResult scopeDatapackStructures("); - int methodEnd = source.indexOf("\n @Override\n public void completeStudioStructureBootstrap", methodStart); + int methodEnd = source.indexOf( + "\n @Override\n public CompletableFuture completeStudioStructureBootstrap", + methodStart); assertTrue(methodStart >= 0); assertTrue(methodEnd > methodStart); @@ -390,19 +392,21 @@ public class NMSBindingDatapackStructureScopeTest { } @Test - public void standardCompletionActivatesTheAlreadyPublishedStateWithoutReplacingIt() throws IOException { + public void standardCompletionReturnsTheExactActivationFutureWithoutReplacingState() throws IOException { Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")).replace("\r\n", "\n"); - int methodStart = source.indexOf("public void completeStudioStructureBootstrap(World world)"); + int methodStart = source.indexOf("public CompletableFuture completeStudioStructureBootstrap(World world)"); int methodEnd = source.indexOf("\n @Override\n public void abandonStudioStructureBootstrap", methodStart); assertTrue(methodStart >= 0); assertTrue(methodEnd > methodStart); String method = source.substring(methodStart, methodEnd); int retained = method.indexOf("generator.retainedStudioStructureState(level, chunkMap)"); - int activation = method.indexOf("generator.activateStudioStructureState(retained);"); + int emptyCompletion = method.indexOf("return CompletableFuture.completedFuture(null);"); + int activation = method.indexOf("return generator.activateStudioStructureState(retained);"); assertTrue(retained >= 0); + assertTrue(emptyCompletion > retained); assertTrue(activation > retained); assertFalse(method.contains("stateField.set(")); assertFalse(method.contains("retained.fullState()")); 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 c75055962..67416824a 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 @@ -184,6 +184,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { private final AtomicBoolean serverStopTeardownDeferred = new AtomicBoolean(false); private final AtomicBoolean servicesDisabled = new AtomicBoolean(false); private final AtomicBoolean sharedRuntimeClosed = new AtomicBoolean(false); + private final AtomicBoolean startupBoundaryRestart = new AtomicBoolean(false); private final AtomicBoolean terminalCleanupCompleted = new AtomicBoolean(false); private volatile PlaceholderRegistration papiRegistration; private volatile IrisPapiListener papiListener; @@ -588,6 +589,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { serverStopTeardownDeferred.set(false); servicesDisabled.set(false); sharedRuntimeClosed.set(false); + startupBoundaryRestart.set(false); terminalCleanupCompleted.set(false); deferredShutdownGenerators.clear(); MultiBurst.burst.reopen(); @@ -827,6 +829,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { BukkitGuiHost.install(); // super.onEnable() already registers this instance as a listener. super.onEnable(); + if (IrisStartupValidation.isRestartRequired()) { + String restartReason = IrisStartupValidation.denialReason() + .orElse("Iris startup validation requires a restart."); + startupBoundaryRestart.set(true); + ServerConfigurator.restartAtStartupBoundary(restartReason); + } } /** @@ -863,7 +871,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { public void onDisable() { teardownPapi(); boolean serverStopping = IrisToolbelt.isServerStopping(); - if (serverStopping) { + boolean restartingAtStartupBoundary = startupBoundaryRestart.get(); + if (restartingAtStartupBoundary) { + teardownRuntime("startup-boundary-restart", 30L); + } else if (serverStopping) { quiesceRuntimeForServerShutdown("onDisable"); startPostStopFinisher(); } else { @@ -881,7 +892,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } // super.onDisable() cancels plugin tasks and unregisters every listener. super.onDisable(); - if (!serverStopping) { + if (!serverStopping || restartingAtStartupBoundary) { finishTerminalCleanup(); } } @@ -1017,6 +1028,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } private void runShutdownHook() { + if (startupBoundaryRestart.get()) { + finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L); + return; + } if (!awaitServerShutdownBoundary()) { Iris.warn("Iris skipped JVM-hook runtime teardown because Paper did not reach its post-world-close boundary."); return; diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java index 1d3748e42..53b429e65 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java @@ -2,6 +2,11 @@ package art.arcane.iris.api.terrain; public enum IrisColumnField { SURFACE_HEIGHT, + NATURAL_HEIGHT, SURFACE_KIND, - BIOME_KEY + BIOME_KEY, + RIVER_STATE, + RIVER_DISTANCE, + RIVER_FLOW, + RIVER_WATER_SURFACE_Y } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSample.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSample.java new file mode 100644 index 000000000..4bbabe286 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSample.java @@ -0,0 +1,64 @@ +package art.arcane.iris.api.terrain; + +import java.util.Objects; + +public record IrisColumnSample( + int blockX, + int blockZ, + int surfaceHeight, + int naturalHeight, + IrisSurfaceKind surfaceKind, + String biomeKey, + IrisRiverState riverState, + double riverDistance, + int riverFlow, + int riverWaterSurfaceY +) { + public static final int UNAVAILABLE_HEIGHT = Integer.MIN_VALUE; + public static final double UNAVAILABLE_RIVER_DISTANCE = Double.NaN; + public static final int UNAVAILABLE_RIVER_FLOW = -1; + + public IrisColumnSample { + Objects.requireNonNull(surfaceKind, "surfaceKind"); + Objects.requireNonNull(riverState, "riverState"); + biomeKey = biomeKey == null || biomeKey.isBlank() ? null : biomeKey; + if (!Double.isNaN(riverDistance) && (!Double.isFinite(riverDistance) || riverDistance < 0D)) { + throw new IllegalArgumentException("riverDistance must be non-negative, finite, or unavailable"); + } + if (riverFlow < UNAVAILABLE_RIVER_FLOW) { + throw new IllegalArgumentException("riverFlow must be non-negative or unavailable"); + } + } + + public boolean hasSurfaceHeight() { + return surfaceHeight != UNAVAILABLE_HEIGHT; + } + + public boolean hasNaturalHeight() { + return naturalHeight != UNAVAILABLE_HEIGHT; + } + + public boolean hasSurfaceKind() { + return surfaceKind != IrisSurfaceKind.UNKNOWN; + } + + public boolean hasBiomeKey() { + return biomeKey != null; + } + + public boolean hasRiverState() { + return riverState != IrisRiverState.NONE; + } + + public boolean hasRiverDistance() { + return !Double.isNaN(riverDistance); + } + + public boolean hasRiverFlow() { + return riverFlow != UNAVAILABLE_RIVER_FLOW; + } + + public boolean hasRiverWaterSurfaceY() { + return riverWaterSurfaceY != UNAVAILABLE_HEIGHT; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java index 4c6bccffe..ac6bb40ab 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java @@ -2,5 +2,5 @@ package art.arcane.iris.api.terrain; @FunctionalInterface public interface IrisColumnSink { - void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey); + void accept(IrisColumnSample sample); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisRiverState.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisRiverState.java new file mode 100644 index 000000000..c8bdbdec0 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisRiverState.java @@ -0,0 +1,7 @@ +package art.arcane.iris.api.terrain; + +public enum IrisRiverState { + NONE, + WET, + DRY +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java index be60360b3..8d81f7c2e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java @@ -4,6 +4,9 @@ public enum IrisSurfaceKind { UNKNOWN, LAND, SHORE, + RIVER, + RIVER_SHORE, + DRY_CHANNEL, OCEAN, VOID } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisProbeChunkGenerator.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisFailClosedChunkGenerator.java similarity index 53% rename from adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisProbeChunkGenerator.java rename to adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisFailClosedChunkGenerator.java index c38ea4c9e..937c29902 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisProbeChunkGenerator.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisFailClosedChunkGenerator.java @@ -31,22 +31,21 @@ import java.util.List; import java.util.Objects; import java.util.Random; -/** - * Answer to a generator-plugin discovery probe. - *

- * Multiverse-Core calls {@code getDefaultWorldGenerator} with an empty dimension id and the name of - * an already loaded world purely to find out whether a plugin is a generator plugin, then throws the - * returned instance away. A non-null answer keeps Iris in {@code /mv generators} and in Multiverse's - * generator tab-completion without Multiverse logging a warning block on every boot. - *

- * Nothing here can build terrain: every generation entry point refuses, so an instance that escapes - * the probe fails loudly instead of silently producing vanilla chunks. - */ -final class IrisProbeChunkGenerator extends ChunkGenerator { - private final String worldName; +final class IrisFailClosedChunkGenerator extends ChunkGenerator { + private final String refusalMessage; - IrisProbeChunkGenerator(String worldName) { - this.worldName = Objects.requireNonNull(worldName, "worldName"); + private IrisFailClosedChunkGenerator(String refusalMessage) { + this.refusalMessage = Objects.requireNonNull(refusalMessage, "refusalMessage"); + } + + static IrisFailClosedChunkGenerator discoveryProbe(String worldName) { + return new IrisFailClosedChunkGenerator("Iris generator-discovery probe for '" + worldName + + "' was asked to generate terrain. Iris worlds are created with /iris create."); + } + + static IrisFailClosedChunkGenerator startupLock(String worldName, String denialReason) { + return new IrisFailClosedChunkGenerator("Iris generation for '" + worldName + + "' remains locked: " + denialReason); } @Override @@ -84,6 +83,11 @@ final class IrisProbeChunkGenerator extends ChunkGenerator { throw refusal(); } + @Override + public boolean canSpawn(@NotNull World world, int x, int z) { + throw refusal(); + } + @Override public List getDefaultPopulators(@NotNull World world) { throw refusal(); @@ -94,8 +98,72 @@ final class IrisProbeChunkGenerator extends ChunkGenerator { throw refusal(); } + @Override + public boolean shouldGenerateNoise() { + return false; + } + + @Override + public boolean shouldGenerateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) { + return false; + } + + @Override + public boolean shouldGenerateSurface() { + return false; + } + + @Override + public boolean shouldGenerateSurface(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) { + return false; + } + + @Override + public boolean shouldGenerateBedrock() { + return false; + } + + @Override + public boolean shouldGenerateCaves() { + return false; + } + + @Override + public boolean shouldGenerateCaves(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) { + return false; + } + + @Override + public boolean shouldGenerateDecorations() { + return false; + } + + @Override + public boolean shouldGenerateDecorations(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) { + return false; + } + + @Override + public boolean shouldGenerateMobs() { + return false; + } + + @Override + public boolean shouldGenerateMobs(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) { + return false; + } + + @Override + public boolean shouldGenerateStructures() { + return false; + } + + @Override + public boolean shouldGenerateStructures(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z) { + return false; + } + private IllegalStateException refusal() { - return new IllegalStateException("Iris generator-discovery probe for '" + worldName - + "' was asked to generate terrain. Iris worlds are created with /iris create."); + return new IllegalStateException(refusalMessage); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index 534c099a1..a6bcff211 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -349,9 +349,14 @@ public final class IrisWorldGeneratorResolver { } if (isGeneratorDiscoveryProbe(worldName, id)) { Iris.debug("Generator discovery probe for loaded world " + worldName); - return new IrisProbeChunkGenerator(worldName); + return IrisFailClosedChunkGenerator.discoveryProbe(worldName); + } + Optional startupDenial = IrisStartupValidation.denialReason(); + if (startupDenial.isPresent()) { + Iris.warn("Keeping configured Iris world '" + worldName + + "' generation-locked: " + startupDenial.get()); + return IrisFailClosedChunkGenerator.startupLock(worldName, startupDenial.get()); } - IrisStartupValidation.requireWorldCreationReady(); ChunkGenerator stagedGenerator = WorldLifecycleStaging.consumeGenerator(worldName); if (stagedGenerator != null) { Iris.debug("Using staged runtime generator for " + worldName); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java index e12d3e682..4fa13657d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandObject.java @@ -250,7 +250,9 @@ public class CommandObject implements DirectorExecutor { @Override public int getFluidHeight() { - return 63; + return targetEngine == null + ? 63 + : targetEngine.getMinHeight() + targetEngine.getDimension().getFluidHeight(); } @Override diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index ca41119b0..894457603 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -18,7 +18,6 @@ package art.arcane.iris.core.commands; -import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.Iris; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.IrisSettings; @@ -47,7 +46,6 @@ import art.arcane.iris.engine.object.IrisNoiseGenerator; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.object.NoiseStyle; import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.engine.platform.PlatformChunkGenerator; @@ -62,7 +60,6 @@ import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.function.Function2; import art.arcane.volmlib.util.function.NoiseProvider; import art.arcane.iris.util.project.interpolation.InterpolationMethod; import art.arcane.volmlib.util.io.IO; @@ -102,7 +99,6 @@ import java.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.BukkitCommandMessages; @@ -264,8 +260,8 @@ public class CommandStudio implements DirectorExecutor { return; } - Supplier> supplier = () -> (x, z) -> generator.getHeight(x, z, new RNG(seed).nextParallelRNG(3245).lmax()); - NoiseExplorerGUI.launch(supplier, "Custom Generator"); + String generatorKey = generator.getLoadKey(); + NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed); } @Director(description = "Show loot if a chest were right here", descriptionKey = "iris.director.commandstudio.director.show_loot_if_chest_were_right_here", origin = DirectorOrigin.PLAYER, sync = true) @@ -648,7 +644,8 @@ public class CommandStudio implements DirectorExecutor { @Director(description = "Teleport to the active studio world", descriptionKey = "iris.director.commandstudio.director.teleport_active_studio_world", aliases = "stp", origin = DirectorOrigin.PLAYER, sync = true) public void tpstudio() { - if (!Iris.service(StudioSVC.class).isProjectOpen()) { + StudioSVC studioService = Iris.service(StudioSVC.class); + if (!studioService.isProjectOpen()) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_NO_STUDIO_WORLD_IS_OPEN)); return; } @@ -660,13 +657,16 @@ public class CommandStudio implements DirectorExecutor { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_SENDING_YOU_STUDIO_WORLD)); Player player = player(); - IrisWorld studioWorld = Iris.service(StudioSVC.class) - .getActiveProject() - .getActiveProvider() - .getTarget() - .getWorld(); - BukkitPlatform.teleportAsync(player, BukkitWorldBinding.spawnLocation(studioWorld)) - .thenRun(() -> player.setGameMode(GameMode.CREATIVE)); + studioService.teleportToActiveProject(player) + .whenComplete((teleported, failure) -> { + if (failure != null) { + Iris.reportError("Studio teleport failed for player \"" + player.getName() + "\".", failure); + return; + } + if (Boolean.TRUE.equals(teleported)) { + J.runEntity(player, () -> player.setGameMode(GameMode.CREATIVE)); + } + }); } @Director(description = "Update your dimension projects VSCode workspace", descriptionKey = "iris.director.commandstudio.director.update_your_dimension_projects_vscode_workspace") diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java index 6202bb0b6..16ef55fff 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java @@ -169,7 +169,7 @@ public final class BukkitVisionOverlay implements GuiOverlay { public String openInEditor(double worldX, double worldZ, RenderType type) { IrisComplex complex = engine.getComplex(); File file = switch (type) { - case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT -> + case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER -> complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java index ee1ed09ec..5c8d57713 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java @@ -2,7 +2,9 @@ package art.arcane.iris.core.service; import art.arcane.iris.api.terrain.IrisColumnField; import art.arcane.iris.api.terrain.IrisColumnQuery; +import art.arcane.iris.api.terrain.IrisColumnSample; import art.arcane.iris.api.terrain.IrisColumnSink; +import art.arcane.iris.api.terrain.IrisRiverState; import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisTerrainService; import art.arcane.iris.api.terrain.IrisWorldInfo; @@ -17,6 +19,8 @@ import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -101,13 +105,14 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { try { int surface = engine.getHeight(blockX, blockZ); - int fluid = engine.getDimension().getFluidHeight(); + IrisRiverSurfaceSample riverSurface = engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ); + int fluid = (int) Math.round(riverSurface.waterSurfaceY()); InferredType inferredType = null; if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) { IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ); inferredType = biome == null ? null : biome.getInferredType(); } - return IrisSurfaceClassifier.classify(surface, fluid, inferredType); + return IrisSurfaceClassifier.classify(surface, fluid, inferredType, riverSurface); } catch (Throwable error) { reportQueryFault("surfaceKind", world, error); return IrisSurfaceKind.UNKNOWN; @@ -224,26 +229,72 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { EnumSet fields = query.fields(); boolean wantHeight = fields.contains(IrisColumnField.SURFACE_HEIGHT); + boolean wantNaturalHeight = fields.contains(IrisColumnField.NATURAL_HEIGHT); boolean wantKind = fields.contains(IrisColumnField.SURFACE_KIND); boolean wantBiome = fields.contains(IrisColumnField.BIOME_KEY); + boolean wantRiverState = fields.contains(IrisColumnField.RIVER_STATE); + boolean wantRiverDistance = fields.contains(IrisColumnField.RIVER_DISTANCE); + boolean wantRiverFlow = fields.contains(IrisColumnField.RIVER_FLOW); + boolean wantRiverWaterSurface = fields.contains(IrisColumnField.RIVER_WATER_SURFACE_Y); + boolean wantRiver = wantKind || wantRiverState || wantRiverDistance || wantRiverFlow + || wantRiverWaterSurface; try { int minHeight = engine.getMinHeight(); - int fluid = engine.getDimension().getFluidHeight(); long visited = IrisColumnWalk.walk(query, (int blockX, int blockZ) -> { if (engine.isClosed()) { return false; } + IrisRiverSurfaceSample riverSurface = wantRiver + ? engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ) + : null; int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0; + int fluid = riverSurface == null + ? engine.getDimension().getFluidHeight() + : (int) Math.round(riverSurface.waterSurfaceY()); boolean needsBiome = wantBiome || (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)); IrisBiome biome = needsBiome ? engine.getSurfaceBiome(blockX, blockZ) : null; IrisSurfaceKind kind = wantKind - ? IrisSurfaceClassifier.classify(surface, fluid, biome == null ? null : biome.getInferredType()) + ? IrisSurfaceClassifier.classify( + surface, + fluid, + biome == null ? null : biome.getInferredType(), + riverSurface + ) : IrisSurfaceKind.UNKNOWN; String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null; - sink.accept(blockX, blockZ, wantHeight ? surface + minHeight : -1, kind, biomeKey); + int natural = wantNaturalHeight + ? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight + : IrisColumnSample.UNAVAILABLE_HEIGHT; + boolean riverPresent = riverSurface != null && riverSurface.river().present(); + IrisRiverState riverState = wantRiverState && riverSurface != null + ? riverState(riverSurface) + : IrisRiverState.NONE; + double riverDistance = wantRiverDistance && riverPresent + ? riverSurface.river().distance() + : IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE; + int riverFlow = wantRiverFlow && riverPresent + ? riverSurface.river().flow() + : IrisColumnSample.UNAVAILABLE_RIVER_FLOW; + int riverWaterSurfaceY = wantRiverWaterSurface + && riverPresent + && riverSurface.river().state() == RiverRouteState.WET + ? fluid + minHeight + : IrisColumnSample.UNAVAILABLE_HEIGHT; + sink.accept(new IrisColumnSample( + blockX, + blockZ, + wantHeight ? surface + minHeight : IrisColumnSample.UNAVAILABLE_HEIGHT, + natural, + kind, + biomeKey, + riverState, + riverDistance, + riverFlow, + riverWaterSurfaceY + )); return true; }); return visited == query.columnCount(); @@ -253,6 +304,17 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { } } + static IrisRiverState riverState(IrisRiverSurfaceSample surface) { + if (!surface.river().present()) { + return IrisRiverState.NONE; + } + return switch (surface.river().state()) { + case WET -> IrisRiverState.WET; + case DRY -> IrisRiverState.DRY; + case SUPPRESSED -> IrisRiverState.NONE; + }; + } + private static Optional key(IrisBiome biome) { String loadKey = biome == null ? null : biome.getLoadKey(); return loadKey == null || loadKey.isEmpty() ? Optional.empty() : Optional.of(loadKey); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java index 75be7325a..520dfb81e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java @@ -2,6 +2,9 @@ package art.arcane.iris.core.service.terrain; import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; public final class IrisSurfaceClassifier { private IrisSurfaceClassifier() { @@ -22,4 +25,32 @@ public final class IrisSurfaceClassifier { return inferredType == InferredType.SHORE ? IrisSurfaceKind.SHORE : IrisSurfaceKind.LAND; } + + public static IrisSurfaceKind classify( + int engineSurfaceHeight, + int engineFluidHeight, + InferredType inferredType, + IrisRiverSurfaceSample riverSurface + ) { + if (engineSurfaceHeight <= 0) { + return IrisSurfaceKind.VOID; + } + if (riverSurface != null && riverSurface.river().present() && !riverSurface.subterranean()) { + if (riverSurface.river().state() == RiverRouteState.DRY) { + return riverSurface.river().section() == RiverSection.DRY_CHANNEL + ? IrisSurfaceKind.DRY_CHANNEL + : IrisSurfaceKind.LAND; + } + if (riverSurface.river().state() == RiverRouteState.WET) { + RiverSection section = riverSurface.river().section(); + if (section == RiverSection.BANK) { + return IrisSurfaceKind.RIVER_SHORE; + } + if (section == RiverSection.CHANNEL || section == RiverSection.MOUTH) { + return IrisSurfaceKind.RIVER; + } + } + } + return classify(engineSurfaceHeight, engineFluidHeight, inferredType); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java index dadb1b80a..0e0138ba5 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisShutdownOrderingTest.java @@ -31,12 +31,17 @@ public class IrisShutdownOrderingTest { "public void quiesceForServerShutdown()", "public boolean isStudio()"); assertOrdered(onDisable, - "if (serverStopping)", + "startupBoundaryRestart.get()", + "teardownRuntime(\"startup-boundary-restart\", 30L)", + "else if (serverStopping)", "quiesceRuntimeForServerShutdown(\"onDisable\")", "startPostStopFinisher()", - "else", + "} else {", "teardownRuntime(\"onDisable\", 30L)"); assertOrdered(shutdownHook, + "startupBoundaryRestart.get()", + "finishDeferredRuntimeTeardown(\"startup-boundary-restart-hook\", 30L)", + "return;", "awaitServerShutdownBoundary()", "finishDeferredRuntimeTeardown(\"shutdown-hook\", 30L)"); assertOrdered(finisher, diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnSampleTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnSampleTest.java new file mode 100644 index 000000000..6947f39f3 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnSampleTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.api.terrain; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class IrisColumnSampleTest { + @Test + public void unavailableFieldsHaveUnambiguousSentinels() { + IrisColumnSample sample = sample( + IrisColumnSample.UNAVAILABLE_HEIGHT, + IrisColumnSample.UNAVAILABLE_HEIGHT, + IrisSurfaceKind.UNKNOWN, + null, + IrisRiverState.NONE, + IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE, + IrisColumnSample.UNAVAILABLE_RIVER_FLOW, + IrisColumnSample.UNAVAILABLE_HEIGHT + ); + + assertFalse(sample.hasSurfaceHeight()); + assertFalse(sample.hasNaturalHeight()); + assertFalse(sample.hasSurfaceKind()); + assertFalse(sample.hasBiomeKey()); + assertFalse(sample.hasRiverState()); + assertFalse(sample.hasRiverDistance()); + assertFalse(sample.hasRiverFlow()); + assertFalse(sample.hasRiverWaterSurfaceY()); + } + + @Test + public void negativeWorldHeightsRemainAvailableValues() { + IrisColumnSample sample = sample( + -1, + -64, + IrisSurfaceKind.DRY_CHANNEL, + "test:river", + IrisRiverState.DRY, + 0D, + 0, + -1 + ); + + assertTrue(sample.hasSurfaceHeight()); + assertTrue(sample.hasNaturalHeight()); + assertTrue(sample.hasSurfaceKind()); + assertTrue(sample.hasBiomeKey()); + assertTrue(sample.hasRiverState()); + assertTrue(sample.hasRiverDistance()); + assertTrue(sample.hasRiverFlow()); + assertTrue(sample.hasRiverWaterSurfaceY()); + } + + @Test + public void blankBiomeKeysNormalizeToUnavailable() { + IrisColumnSample sample = sample( + 64, + 65, + IrisSurfaceKind.LAND, + " ", + IrisRiverState.NONE, + IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE, + IrisColumnSample.UNAVAILABLE_RIVER_FLOW, + IrisColumnSample.UNAVAILABLE_HEIGHT + ); + + assertNull(sample.biomeKey()); + assertFalse(sample.hasBiomeKey()); + } + + @Test + public void theSinkReceivesTheTypedSample() { + IrisColumnSample sample = sample( + 64, + 65, + IrisSurfaceKind.RIVER, + "test:river", + IrisRiverState.WET, + 0.5D, + 3, + 67 + ); + AtomicReference received = new AtomicReference<>(); + IrisColumnSink sink = received::set; + + sink.accept(sample); + + assertSame(sample, received.get()); + } + + @Test + public void invalidHydrologyValuesAreRejected() { + assertInvalid(Double.POSITIVE_INFINITY, 1); + assertInvalid(-0.1D, 1); + assertInvalid(0D, -2); + } + + private static void assertInvalid(double distance, int flow) { + try { + sample( + 64, + 65, + IrisSurfaceKind.RIVER, + "test:river", + IrisRiverState.WET, + distance, + flow, + 67 + ); + fail("Expected invalid hydrology values to be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().startsWith("river")); + } + } + + private static IrisColumnSample sample( + int surfaceHeight, + int naturalHeight, + IrisSurfaceKind surfaceKind, + String biomeKey, + IrisRiverState riverState, + double riverDistance, + int riverFlow, + int riverWaterSurfaceY + ) { + return new IrisColumnSample( + 12, + -7, + surfaceHeight, + naturalHeight, + surfaceKind, + biomeKey, + riverState, + riverDistance, + riverFlow, + riverWaterSurfaceY + ); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisStartupOrderingTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisStartupOrderingTest.java index 6c587b1c2..feae5cf6e 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisStartupOrderingTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisStartupOrderingTest.java @@ -29,6 +29,18 @@ public class IrisStartupOrderingTest { "generatorResolver.validateAllPacks();"); } + @Test + public void restartRequiredStartupTerminatesAfterPluginInitialization() throws Exception { + String source = Files.readString(Path.of(System.getProperty("iris.startupSource"))); + String onEnable = section(source, "public void onEnable()", "public void onDisable()"); + + assertOrdered(onEnable, + "BukkitGuiHost.install();", + "super.onEnable();", + "IrisStartupValidation.isRestartRequired()", + "ServerConfigurator.restartAtStartupBoundary(restartReason);"); + } + private static String section(String source, String startMarker, String endMarker) { int start = source.indexOf(startMarker); int end = source.indexOf(endMarker, start); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java index 71818d532..88467a5a8 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.core; import art.arcane.iris.Iris; +import art.arcane.iris.core.lifecycle.WorldLifecycleStaging; import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; @@ -22,11 +23,14 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Random; +import java.util.function.Supplier; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -39,8 +43,10 @@ public class IrisWorldGeneratorResolverTest { public TemporaryFolder temporaryFolder = new TemporaryFolder(); @After - public void clearValidationRegistry() { + public void clearValidationState() { PackValidationRegistry.clear(); + IrisStartupValidation.disable(); + WorldLifecycleStaging.clearAll("world_nether"); } @Test @@ -234,7 +240,9 @@ public class IrisWorldGeneratorResolverTest { int plotSquaredProbe = resolver.indexOf("isPlotSquaredGeneratorDiscoveryProbe(worldName, id)"); int probe = resolver.indexOf("isGeneratorDiscoveryProbe(worldName, id)", plotSquaredProbe); - int readiness = resolver.indexOf("IrisStartupValidation.requireWorldCreationReady()"); + int denial = resolver.indexOf("IrisStartupValidation.denialReason()", probe); + int failClosed = resolver.indexOf("IrisFailClosedChunkGenerator.startupLock(", denial); + int staged = resolver.indexOf("WorldLifecycleStaging.consumeGenerator(worldName)", failClosed); int duplicateGuard = resolver.indexOf("requireWorldKeyAvailable(worldName, worldKey)"); int ownership = resolver.indexOf("requireOwnedWorld(worldName, levelRoot, worldKey)"); int frozen = resolver.indexOf("return resolveFrozenWorldGenerator(", ownership); @@ -245,8 +253,10 @@ public class IrisWorldGeneratorResolverTest { assertTrue(plotSquaredProbe >= 0); assertTrue(probe > plotSquaredProbe); - assertTrue(readiness > probe); - assertTrue(duplicateGuard > readiness); + assertTrue(denial > probe); + assertTrue(failClosed > denial); + assertTrue(staged > failClosed); + assertTrue(duplicateGuard > staged); assertTrue(ownership > duplicateGuard); assertTrue(frozen > ownership); assertTrue(failureCapture > frozen); @@ -302,6 +312,8 @@ public class IrisWorldGeneratorResolverTest { try (MockedStatic bukkit = mockStatic(Bukkit.class); MockedStatic iris = mockStatic(Iris.class)) { bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(mock(World.class)); + IrisStartupValidation.begin(); + IrisStartupValidation.requireRestart("restart boundary"); ChunkGenerator probe = new IrisWorldGeneratorResolver(null) .resolveDefaultWorldGenerator("world", ""); @@ -312,6 +324,95 @@ public class IrisWorldGeneratorResolverTest { IllegalStateException.class, () -> probe.generateNoise(mock(WorldInfo.class), new Random(), 0, 0, null)); assertTrue(refusal.getMessage(), refusal.getMessage().contains("'world'")); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("discovery probe")); + } + } + + @Test + public void restartRequiredDefaultWorldCannotFallBackToVanilla() { + ChunkGenerator stagedGenerator = mock(ChunkGenerator.class); + ChunkGenerator vanillaFallback = mock(ChunkGenerator.class); + WorldLifecycleStaging.stageGenerator("world_nether", stagedGenerator, null); + IrisStartupValidation.begin(); + IrisStartupValidation.requireRestart("updated external datapacks require restart"); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + bukkit.when(() -> Bukkit.getWorld("world_nether")).thenReturn(null); + + ChunkGenerator selected = craftBukkitGeneratorOrFallback( + () -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world_nether", "underworld"), + vanillaFallback + ); + + assertNotSame(vanillaFallback, selected); + assertNotSame(stagedGenerator, selected); + assertSame(stagedGenerator, WorldLifecycleStaging.consumeGenerator("world_nether")); + WorldInfo worldInfo = mock(WorldInfo.class); + Random random = new Random(); + assertFalse(selected.shouldGenerateNoise()); + assertFalse(selected.shouldGenerateNoise(worldInfo, random, 0, 0)); + assertFalse(selected.shouldGenerateSurface()); + assertFalse(selected.shouldGenerateSurface(worldInfo, random, 0, 0)); + assertFalse(selected.shouldGenerateBedrock()); + assertFalse(selected.shouldGenerateCaves()); + assertFalse(selected.shouldGenerateCaves(worldInfo, random, 0, 0)); + assertFalse(selected.shouldGenerateDecorations()); + assertFalse(selected.shouldGenerateDecorations(worldInfo, random, 0, 0)); + assertFalse(selected.shouldGenerateMobs()); + assertFalse(selected.shouldGenerateMobs(worldInfo, random, 0, 0)); + assertFalse(selected.shouldGenerateStructures()); + assertFalse(selected.shouldGenerateStructures(worldInfo, random, 0, 0)); + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> selected.generateNoise( + worldInfo, + random, + 0, + 0, + mock(ChunkGenerator.ChunkData.class) + ) + ); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("world_nether")); + assertTrue(refusal.getMessage(), refusal.getMessage().contains("updated external datapacks require restart")); + } + } + + @Test + public void everyBlockingStartupStateReturnsFailClosedGenerator() { + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + IrisStartupValidation.begin(); + assertStartupStateFailsClosed("world_pending", "external datapacks"); + + IrisStartupValidation.begin(); + IrisStartupValidation.markDatapacksInvalid("invalid external datapack state"); + assertStartupStateFailsClosed("world_datapack_invalid", "invalid external datapack state"); + + IrisStartupValidation.begin(); + IrisStartupValidation.markDatapacksReady(); + IrisStartupValidation.markPacksInvalid(List.of("invalid dimension pack state")); + assertStartupStateFailsClosed("world_pack_invalid", "invalid dimension pack state"); + } + } + + @Test + public void readyStartupStillConsumesStagedGenerator() { + ChunkGenerator stagedGenerator = mock(ChunkGenerator.class); + WorldLifecycleStaging.stageGenerator("world_nether", stagedGenerator, null); + IrisStartupValidation.begin(); + IrisStartupValidation.markDatapacksReady(); + IrisStartupValidation.markPacksReady(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic iris = mockStatic(Iris.class)) { + bukkit.when(() -> Bukkit.getWorld("world_nether")).thenReturn(null); + + ChunkGenerator selected = new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator("world_nether", "underworld"); + + assertSame(stagedGenerator, selected); } } @@ -436,4 +537,38 @@ public class IrisWorldGeneratorResolverTest { "{\"name\":\"Biome\"}", StandardCharsets.UTF_8); } + + private static void assertStartupStateFailsClosed(String worldName, String expectedReason) { + ChunkGenerator vanillaFallback = mock(ChunkGenerator.class); + ChunkGenerator selected = craftBukkitGeneratorOrFallback( + () -> new IrisWorldGeneratorResolver(null) + .resolveDefaultWorldGenerator(worldName, "overworld"), + vanillaFallback + ); + assertNotSame(vanillaFallback, selected); + IllegalStateException refusal = assertThrows( + IllegalStateException.class, + () -> selected.generateNoise( + mock(WorldInfo.class), + new Random(), + 0, + 0, + mock(ChunkGenerator.ChunkData.class) + ) + ); + assertTrue(refusal.getMessage(), refusal.getMessage().contains(worldName)); + assertTrue(refusal.getMessage(), refusal.getMessage().contains(expectedReason)); + } + + private static ChunkGenerator craftBukkitGeneratorOrFallback( + Supplier resolution, + ChunkGenerator fallback + ) { + try { + ChunkGenerator selected = resolution.get(); + return selected == null ? fallback : selected; + } catch (Throwable ignoredGeneratorFailure) { + return fallback; + } + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectFluidHeightTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectFluidHeightTest.java new file mode 100644 index 000000000..ec5cecbe9 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectFluidHeightTest.java @@ -0,0 +1,45 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IObjectPlacer; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class CommandObjectFluidHeightTest { + @Test + public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() { + World world = mock(World.class); + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisComplex complex = mock(IrisComplex.class); + @SuppressWarnings("unchecked") + ProceduralStream riverHead = mock(ProceduralStream.class); + Map future = new HashMap<>(); + + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); + when(dimension.getFluidHeight()).thenReturn(127); + when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead); + when(riverHead.get(12, -7)).thenReturn(131D); + + IObjectPlacer placer = CommandObject.createPlacer(world, future, engine); + + assertEquals(63, placer.getFluidHeight()); + assertEquals(67, placer.getFluidHeight(12, -7)); + verify(riverHead).get(12, -7); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/StudioPlayerModeContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/StudioPlayerModeContractTest.java index 078fd6ed2..9a19118c1 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/StudioPlayerModeContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/StudioPlayerModeContractTest.java @@ -21,4 +21,19 @@ public class StudioPlayerModeContractTest { assertTrue(plugin.contains("GameMode.CREATIVE")); assertTrue(commands.contains("GameMode.CREATIVE")); } + + @Test + public void tpStudioUsesThePreparedCoordinatorEntry() throws IOException { + String commands = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/commands/CommandStudio.java")).replace("\r\n", "\n"); + int methodStart = commands.indexOf("public void tpstudio()"); + int methodEnd = commands.indexOf("\n @Director", methodStart); + String method = commands.substring(methodStart, methodEnd); + + assertTrue(method.contains("StudioSVC studioService = Iris.service(StudioSVC.class)")); + assertTrue(method.contains("studioService.teleportToActiveProject(player)")); + assertFalse(method.contains("getActiveProject()")); + assertFalse(method.contains("BukkitPlatform.teleportAsync")); + assertFalse(method.contains("BukkitWorldBinding.spawnLocation")); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java index 5e9a2dec1..90368cd39 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java @@ -2,8 +2,15 @@ package art.arcane.iris.core.service; import art.arcane.iris.api.terrain.IrisColumnField; import art.arcane.iris.api.terrain.IrisColumnQuery; +import art.arcane.iris.api.terrain.IrisRiverState; import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisTerrainService; +import art.arcane.iris.engine.river.RiverEdgeId; +import art.arcane.iris.engine.river.RiverNodeId; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; import art.arcane.iris.util.common.plugin.IrisService; import org.bukkit.World; import org.junit.Test; @@ -82,9 +89,7 @@ public class IrisTerrainSVCTest { IrisTerrainSVC service = new IrisTerrainSVC(); AtomicInteger sinkCalls = new AtomicInteger(); - boolean answered = service.sampleColumns(null, SMALL, - (int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey) - -> sinkCalls.incrementAndGet()); + boolean answered = service.sampleColumns(null, SMALL, sample -> sinkCalls.incrementAndGet()); assertFalse(answered); assertEquals(0, sinkCalls.get()); @@ -97,4 +102,38 @@ public class IrisTerrainSVCTest { assertFalse(service.sampleColumns(null, null, null)); assertFalse(service.sampleColumns(null, SMALL, null)); } + + @Test + public void riverRouteStatesMapToThePublicDiagnosticStates() { + assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState( + new IrisRiverSurfaceSample(RiverSample.none(), 70D, 70D, 70D, false, false) + )); + assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(RiverRouteState.WET))); + assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(RiverRouteState.DRY))); + assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(river(RiverRouteState.SUPPRESSED))); + } + + private static IrisRiverSurfaceSample river(RiverRouteState state) { + RiverSection section = switch (state) { + case WET -> RiverSection.CHANNEL; + case DRY -> RiverSection.DRY_CHANNEL; + case SUPPRESSED -> RiverSection.NONE; + }; + RiverSample sample = new RiverSample( + true, + state, + section, + 0D, + 0.5D, + 1D, + 1, + 1, + 8D, + 4D, + 3D, + false, + RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0)) + ); + return new IrisRiverSurfaceSample(sample, 70D, 60D, 63D, false, state == RiverRouteState.WET); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java index a9368289b..90281845e 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java @@ -2,6 +2,12 @@ package art.arcane.iris.core.service.terrain; import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.river.RiverEdgeId; +import art.arcane.iris.engine.river.RiverNodeId; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -58,4 +64,86 @@ public class IrisSurfaceClassifierTest { } } } + + @Test + public void activeRiverGeometryOverridesGenericOceanAndLandKinds() { + assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( + 60, + 63, + InferredType.SEA, + river(RiverRouteState.WET, RiverSection.CHANNEL) + )); + assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( + 60, + 63, + InferredType.SEA, + river(RiverRouteState.WET, RiverSection.MOUTH) + )); + assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify( + 64, + 63, + InferredType.SHORE, + river(RiverRouteState.WET, RiverSection.BANK) + )); + assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify( + 60, + 60, + InferredType.LAND, + river(RiverRouteState.DRY, RiverSection.DRY_CHANNEL) + )); + assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( + 60, + 60, + InferredType.LAND, + river(RiverRouteState.DRY, RiverSection.DRY_BANK) + )); + } + + @Test + public void voidClassificationWinsOverRiverGeometry() { + for (RiverSection section : RiverSection.values()) { + if (section == RiverSection.NONE) { + continue; + } + RiverRouteState state = section == RiverSection.DRY_CHANNEL || section == RiverSection.DRY_BANK + ? RiverRouteState.DRY + : RiverRouteState.WET; + assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify( + 0, + 63, + InferredType.LAND, + river(state, section) + )); + } + } + + @Test + public void suppressedRoutesDoNotCreatePublicRiverSurfaceKinds() { + assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( + 64, + 63, + InferredType.LAND, + river(RiverRouteState.SUPPRESSED, RiverSection.CHANNEL) + )); + } + + private static IrisRiverSurfaceSample river(RiverRouteState state, RiverSection section) { + RiverSample sample = new RiverSample( + true, + state, + section, + 0D, + 0.5D, + 1D, + 1, + 1, + 8D, + 4D, + 3D, + false, + RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0)) + ); + double waterSurface = state == RiverRouteState.WET ? 63D : 60D; + return new IrisRiverSurfaceSample(sample, 70D, 60D, waterSurface, false, state == RiverRouteState.WET); + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java index 9657abbb2..5b654c209 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStudioCommands.java @@ -44,7 +44,6 @@ import art.arcane.iris.util.common.parallel.BurstExecutor; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.function.Function2; import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.math.M; @@ -80,7 +79,6 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; -import java.util.function.Supplier; import java.util.regex.Pattern; import art.arcane.iris.core.localization.IrisLanguage; @@ -246,9 +244,8 @@ public final class ModdedStudioCommands { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_UNKNOWN_GENERATOR_PACK, MessageArgument.untrusted("generatorKey", generatorKey), MessageArgument.untrusted("value", engine.getDimension().getLoadKey()))); return 0; } - long mixedSeed = new RNG(seed).nextParallelRNG(3245).lmax(); - Supplier> supplier = () -> (Double x, Double z) -> generator.getHeight(x, z, mixedSeed); - NoiseExplorerGUI.launch(supplier, generatorKey.trim()); + String selectedGeneratorKey = generatorKey.trim(); + NoiseExplorerGUI.launchGeneratorKey(selectedGeneratorKey, generator, seed); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_GENERATOR_SEED, MessageArgument.untrusted("value", generatorKey.trim()), MessageArgument.untrusted("seed", seed))); return 1; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java index 1b015b8b1..2ab8bd56c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java @@ -104,7 +104,7 @@ public final class ModdedVisionOverlay implements GuiOverlay { } IrisComplex complex = engine.getComplex(); File file = switch (type) { - case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT -> + case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER -> complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode(); diff --git a/core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java b/core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java index cfd8536ae..ab8949075 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java +++ b/core/src/main/java/art/arcane/iris/core/IrisStartupValidation.java @@ -79,6 +79,11 @@ public final class IrisStartupValidation { return isReady(snapshot); } + public static boolean isRestartRequired() { + Snapshot current = snapshot; + return current.enforced() && current.datapacks() == ValidationState.RESTART_REQUIRED; + } + public static Optional denialReason() { Snapshot current = snapshot; if (!current.enforced() || isReady(current)) { diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index b6e4ef496..e697e6f3a 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -124,6 +124,7 @@ public class ServerConfigurator { && pinLoadedDatapackCompilerInputs() && pinLoadedDatapackRegistryRequirements(); if (result.restartRequired()) { + requireDatapackRestart(); IrisLogging.warn("Iris datapack changes require another server restart before worlds can use them."); } } @@ -1020,6 +1021,24 @@ public class ServerConfigurator { })); } + public static void restartAtStartupBoundary(String reason) { + String restartReason = reason == null || reason.isBlank() + ? "Iris startup validation requires a restart." + : reason.trim(); + IrisLogging.warn(restartReason + " Restarting server before default worlds are loaded."); + try { + Bukkit.restart(); + } catch (Throwable failure) { + IrisLogging.reportError("Unable to restart the server at the Iris startup boundary.", failure); + } + IrisLogging.error("The immediate Iris startup restart returned unexpectedly; stopping the server instead."); + try { + Bukkit.shutdown(); + } catch (Throwable failure) { + IrisLogging.reportError("Unable to stop the server after the Iris startup restart returned.", failure); + } + } + public static boolean verifyDataPackInstalled(IrisDimension dimension) { KSet keys = new KSet<>(); boolean warn = false; diff --git a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java index ec4517cc4..2e5af710e 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java +++ b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java @@ -24,13 +24,19 @@ import art.arcane.iris.engine.framework.Engine; import javax.swing.JFrame; import java.awt.Desktop; +import java.awt.EventQueue; import java.awt.GraphicsEnvironment; import java.awt.desktop.QuitResponse; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; public final class GuiHost { private static final AtomicBoolean DESKTOP_QUIT_GUARD_INSTALLED = new AtomicBoolean(false); + private static final Set MANAGED_FRAMES = ConcurrentHashMap.newKeySet(); private static volatile Provider provider = new Provider() { }; private static volatile boolean desktopSuppressed = false; @@ -78,6 +84,13 @@ public final class GuiHost { public static void prepareFrame(JFrame frame) { frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + MANAGED_FRAMES.add(frame); + frame.addWindowListener(new WindowAdapter() { + @Override + public void windowClosed(WindowEvent event) { + MANAGED_FRAMES.remove(frame); + } + }); prepareServerDesktop(); } @@ -96,15 +109,20 @@ public final class GuiHost { if (!desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER)) { return; } - desktop.setQuitHandler((event, response) -> cancelDesktopQuit(response)); + desktop.setQuitHandler((event, response) -> closeDesktopWindowsAndCancelQuit(response)); } catch (Throwable error) { IrisLogging.reportError(error); IrisLogging.info("Unable to install the Iris desktop quit guard; use the server stop command instead of macOS Quit"); } } - static void cancelDesktopQuit(QuitResponse response) { + static void closeDesktopWindowsAndCancelQuit(QuitResponse response) { response.cancelQuit(); + EventQueue.invokeLater(() -> { + for (JFrame frame : MANAGED_FRAMES) { + frame.dispose(); + } + }); } /** diff --git a/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java b/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java index f8d6075c4..f9674662f 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/NoiseExplorerGUI.java @@ -18,35 +18,40 @@ package art.arcane.iris.core.gui; -import art.arcane.iris.core.localization.DesktopUiMessages; -import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.localization.DesktopUiMessages; +import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.volmlib.util.function.Function2; -import art.arcane.volmlib.util.math.M; -import art.arcane.volmlib.util.math.RNG; -import art.arcane.volmlib.util.math.RollingSequence; -import art.arcane.volmlib.util.localization.MessageArgument; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.common.parallel.BurstExecutor; -import art.arcane.iris.util.common.parallel.MultiBurst; -import art.arcane.iris.util.common.scheduling.J; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; +import art.arcane.volmlib.util.function.NoiseProvider; +import art.arcane.volmlib.util.localization.MessageArgument; +import art.arcane.volmlib.util.math.RNG; +import javax.swing.AbstractAction; import javax.swing.BorderFactory; import javax.swing.DefaultListCellRenderer; import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JComboBox; import javax.swing.JFrame; +import javax.swing.JLabel; import javax.swing.JList; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTextField; +import javax.swing.KeyStroke; import javax.swing.ListSelectionModel; +import javax.swing.SwingConstants; +import javax.swing.Timer; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; +import java.awt.BasicStroke; import java.awt.BorderLayout; import java.awt.Color; import java.awt.Component; @@ -55,322 +60,1085 @@ import java.awt.EventQueue; import java.awt.Font; import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.GridLayout; import java.awt.Point; import java.awt.RenderingHints; +import java.awt.event.ActionEvent; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import java.awt.event.MouseMotionListener; import java.awt.event.MouseWheelEvent; -import java.awt.event.MouseWheelListener; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.awt.image.BufferedImage; -import java.awt.image.DataBufferInt; import java.util.ArrayList; import java.util.Arrays; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.function.Supplier; - -public class NoiseExplorerGUI extends JPanel implements MouseWheelListener { +import java.util.Locale; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongFunction; +public final class NoiseExplorerGUI extends JPanel { private static final long serialVersionUID = 2094606939770332040L; - private static final Color BG = new Color(24, 24, 28); - private static final Color SIDEBAR_BG = new Color(20, 20, 24); - private static final Color SIDEBAR_SELECTED = new Color(40, 50, 70); - private static final Color SIDEBAR_ITEM_COLOR = new Color(170, 170, 185); - private static final Color SEARCH_BG = new Color(30, 30, 38); - private static final Color SEARCH_FG = new Color(180, 180, 190); - private static final Color STATUS_BG = new Color(32, 32, 38, 230); - private static final Color STATUS_TEXT = new Color(180, 180, 190); - private static final Color ACCENT = new Color(90, 140, 255); - private static final Color SEPARATOR = new Color(40, 40, 50); - private static final Font STATUS_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12); - private static final Font SIDEBAR_HEADER_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11); - private static final Font SIDEBAR_ITEM_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12); - private static final Font SEARCH_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 13); - private static final int SIDEBAR_WIDTH = 240; - private static final int[] HSB_LUT = new int[256]; - + private static final Color BACKGROUND = new Color(12, 15, 22); + private static final Color SIDEBAR_BACKGROUND = new Color(17, 20, 29); + private static final Color PANEL_BACKGROUND = new Color(23, 27, 38); + private static final Color FIELD_BACKGROUND = new Color(29, 34, 47); + private static final Color SELECTED_BACKGROUND = new Color(42, 55, 78); + private static final Color PRIMARY_TEXT = new Color(230, 234, 242); + private static final Color SECONDARY_TEXT = new Color(151, 160, 178); + private static final Color ACCENT = new Color(99, 161, 255); + private static final Color BORDER = new Color(48, 55, 72); + private static final Color ERROR = new Color(255, 116, 116); + private static final Color GRID = new Color(255, 255, 255, 15); + private static final Color CROSSHAIR = new Color(255, 255, 255, 125); + private static final Color LEGEND_BACKGROUND = new Color(10, 12, 18, 215); + private static final Font MONOSPACED_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12); + private static final Font HEADER_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 11); + private static final Font ITEM_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12); + private static final Font LEGEND_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 10); + private static final int SIDEBAR_WIDTH = 270; + private static final int STATUS_HEIGHT = 50; + private static final int REFINEMENT_DELAY_MILLIS = 180; + private static final long DEFAULT_SEED = 12345L; + private static final long COARSE_SAMPLE_BUDGET = 4_096L; + private static final long MAX_REFINED_SAMPLE_BUDGET = 48_000L; + private static final double REFINEMENT_TARGET_MILLIS = 250D; + private static final double WHEEL_ZOOM_EXPONENT = 0.08D; private static final NoiseCategory[] CATEGORY_ORDER = { - NoiseCategory.PACK_GENERATORS, NoiseCategory.SIMPLEX, NoiseCategory.PERLIN, - NoiseCategory.CELLULAR, NoiseCategory.IRIS, NoiseCategory.CLOVER, NoiseCategory.HEXAGON, - NoiseCategory.VASCULAR, NoiseCategory.GLOBE, NoiseCategory.CUBIC, NoiseCategory.FRACTAL, - NoiseCategory.STATIC, NoiseCategory.NOWHERE, NoiseCategory.SIERPINSKI, NoiseCategory.UTILITY, + NoiseCategory.CUSTOM, + NoiseCategory.PACK_GENERATORS, + NoiseCategory.SIMPLEX, + NoiseCategory.PERLIN, + NoiseCategory.CELLULAR, + NoiseCategory.IRIS, + NoiseCategory.CLOVER, + NoiseCategory.HEXAGON, + NoiseCategory.VASCULAR, + NoiseCategory.GLOBE, + NoiseCategory.CUBIC, + NoiseCategory.FRACTAL, + NoiseCategory.STATIC, + NoiseCategory.NOWHERE, + NoiseCategory.SIERPINSKI, + NoiseCategory.UTILITY, NoiseCategory.OTHER }; - static { - for (int i = 0; i < 256; i++) { - float n = i / 255f; - HSB_LUT[i] = Color.HSBtoRGB(0.666f - n * 0.666f, 1f, 1f - n * 0.8f); - } - } + private volatile Engine activeEngine; + private final LongFunction customFactory; + private final String customName; + private final List fixedSources; + private final ThreadPoolExecutor sourceExecutor; + private final ThreadPoolExecutor catalogExecutor; + private final NoiseRenderCoordinator renderCoordinator; + private final Timer refinementTimer; + private final Timer interactionTimer; + private final AtomicLong sourceLoadRevision = new AtomicLong(); + private final AtomicLong catalogLoadRevision = new AtomicLong(); + private final Runnable hotloadHook = this::handleHotload; + private final DefaultListModel sourceModel = new DefaultListModel<>(); + private final JList sourceList = new JList<>(sourceModel); + private final JTextField searchField = new JTextField(); + private final JTextField seedField = new JTextField(); + private final JLabel selectedLabel = new JLabel("No source selected"); + private final JLabel progressLabel = new JLabel("Opening noise explorer..."); + private final JLabel catalogLabel = new JLabel("Pack catalog waiting"); + private final JComboBox palettePicker = new JComboBox<>(NoisePalette.values()); - private final RollingSequence fpsHistory = new RollingSequence(60); - private final boolean colorMode = IrisSettings.get().getGui().colorMode; - private final MultiBurst gx = MultiBurst.burst; - private final Runnable hotloadHook = this::refreshGenerator; - private double scale = 1; - private double animScale = 10; - private double ox = 0; - private double oz = 0; - private double animOx = 0; - private double animOz = 0; - private double lastMouseX = Double.MAX_VALUE; - private double lastMouseZ = Double.MAX_VALUE; - private double time = 0; - private double animTime = 0; - private int imgWidth = 0; - private int imgHeight = 0; - private BufferedImage img; - private CNG cng = NoiseStyle.STATIC.create(new RNG(RNG.r.nextLong())); - private Function2 generator; - private Supplier> loader; - private String currentName = "STATIC"; + private List packSources = List.of(); + private NoiseViewport viewport = new NoiseViewport(0D, 0D, 1D); + private NoisePalette palette; + private SourceItem selectedSource; + private NoiseProvider sampler; + private NoiseRenderCoordinator.Result renderResult; + private BufferedImage renderedImage; + private Point pointer; + private Point dragPoint; + private long seed; + private long viewRevision; + private boolean catalogLoading; + private boolean sourceLoading; + private boolean rendering; + private boolean disposed; + private String errorMessage; public NoiseExplorerGUI() { - GuiHost.get().registerHotloadHook(hotloadHook); - setBackground(BG); - addMouseWheelListener(this); - addMouseMotionListener(new MouseMotionListener() { - @Override - public void mouseMoved(MouseEvent e) { - Point cp = e.getPoint(); - lastMouseX = cp.getX(); - lastMouseZ = cp.getY(); - } + this(GuiHost.get().findActiveEngine(), null, null, null, DEFAULT_SEED, null, null); + } - @Override - public void mouseDragged(MouseEvent e) { - Point cp = e.getPoint(); - ox += (lastMouseX - cp.getX()) * scale; - oz += (lastMouseZ - cp.getY()) * scale; - lastMouseX = cp.getX(); - lastMouseZ = cp.getY(); - } - }); + private NoiseExplorerGUI(Engine engine, String customGeneratorKey, IrisGenerator fallbackGenerator, + String customName, long initialSeed) { + this(engine, customGeneratorKey, fallbackGenerator, customName, initialSeed, null, null); + } + + NoiseExplorerGUI(LongFunction customFactory, String customName, long initialSeed) { + this(null, null, null, customName, initialSeed, customFactory, NoisePalette.TERRAIN); + } + + private NoiseExplorerGUI(Engine engine, String customGeneratorKey, IrisGenerator fallbackGenerator, + String customName, long initialSeed, LongFunction injectedFactory, + NoisePalette initialPalette) { + this.activeEngine = engine; + this.customFactory = injectedFactory != null + ? injectedFactory + : fallbackGenerator == null + ? null + : sourceSeed -> createGeneratorSampler(customGeneratorKey, fallbackGenerator, sourceSeed); + this.customName = customName; + this.seed = initialSeed; + this.seedField.setText(Long.toString(initialSeed)); + this.palette = initialPalette != null + ? initialPalette + : IrisSettings.get().getGui().colorMode + ? NoisePalette.TERRAIN + : NoisePalette.GRAYSCALE; + this.sourceExecutor = createExecutor("Iris Noise Source Loader"); + this.catalogExecutor = createExecutor("Iris Noise Catalog Loader"); + this.renderCoordinator = new NoiseRenderCoordinator(new RenderListener()); + registerExecutor(sourceExecutor); + registerExecutor(catalogExecutor); + this.refinementTimer = new Timer(REFINEMENT_DELAY_MILLIS, event -> requestRefinement()); + this.refinementTimer.setRepeats(false); + this.interactionTimer = new Timer(45, event -> requestPreview()); + this.interactionTimer.setRepeats(false); + this.fixedSources = buildFixedSources(); + setBackground(BACKGROUND); + setFocusable(true); + installInputHandlers(); + GuiHost.get().registerHotloadHook(hotloadHook); } public static void launch() { Engine engine = GuiHost.get().findActiveEngine(); EventQueue.invokeLater(() -> { - NoiseExplorerGUI nv = new NoiseExplorerGUI(); - buildFrame(IrisLanguage.plain(DesktopUiMessages.NOISE_TITLE), nv, engine, null, null); + NoiseExplorerGUI explorer = new NoiseExplorerGUI(engine, null, null, null, DEFAULT_SEED, null, null); + buildFrame(IrisLanguage.plain(DesktopUiMessages.NOISE_TITLE), explorer); }); } - public static void launch(Supplier> gen, String genName) { + public static void launchGeneratorKey(String generatorKey, IrisGenerator fallbackGenerator, long initialSeed) { Engine engine = GuiHost.get().findActiveEngine(); + String displayName = generatorKey == null || generatorKey.isBlank() ? "Custom Generator" : generatorKey; EventQueue.invokeLater(() -> { - NoiseExplorerGUI nv = new NoiseExplorerGUI(); - nv.loader = gen; - nv.generator = gen.get(); - nv.currentName = genName; - buildFrame(IrisLanguage.plain(DesktopUiMessages.NOISE_TITLE_GENERATOR, MessageArgument.untrusted("generator", genName)), nv, engine, gen, genName); + NoiseExplorerGUI explorer = new NoiseExplorerGUI( + engine, + generatorKey, + fallbackGenerator, + displayName, + initialSeed + ); + String title = IrisLanguage.plain( + DesktopUiMessages.NOISE_TITLE_GENERATOR, + MessageArgument.untrusted("generator", displayName) + ); + buildFrame(title, explorer); }); } - private static JFrame buildFrame(String title, NoiseExplorerGUI nv, Engine engine, - Supplier> customGen, String customName) { + private static void buildFrame(String title, NoiseExplorerGUI explorer) { JFrame frame = new JFrame(title); GuiHost.prepareFrame(frame); - frame.getContentPane().setBackground(BG); + frame.getContentPane().setBackground(BACKGROUND); frame.setLayout(new BorderLayout()); - - JPanel sidebar = buildSidebar(nv, engine, customGen, customName); - frame.add(sidebar, BorderLayout.WEST); - frame.add(nv, BorderLayout.CENTER); - + frame.add(explorer.buildSidebar(), BorderLayout.WEST); + frame.add(explorer, BorderLayout.CENTER); frame.setSize(1440, 820); - frame.setMinimumSize(new Dimension(640, 480)); + frame.setMinimumSize(new Dimension(760, 500)); frame.setLocationRelativeTo(null); - frame.setVisible(true); frame.addWindowListener(new WindowAdapter() { @Override - public void windowClosed(WindowEvent e) { - GuiHost.get().unregisterHotloadHook(nv.hotloadHook); + public void windowClosing(WindowEvent event) { + explorer.close(); + } + + @Override + public void windowClosed(WindowEvent event) { + explorer.close(); } }); - return frame; + frame.setVisible(true); + explorer.openViewer(); } - private static JPanel buildSidebar(NoiseExplorerGUI nv, Engine engine, - Supplier> customGen, String customName) { + private static ThreadPoolExecutor createExecutor(String name) { + AtomicInteger threadId = new AtomicInteger(); + ThreadFactory threadFactory = runnable -> { + Thread thread = new Thread(runnable, name + " " + threadId.incrementAndGet()); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }; + ThreadPoolExecutor executor = new ThreadPoolExecutor( + 1, + 1, + 30L, + TimeUnit.SECONDS, + new ArrayBlockingQueue<>(1), + threadFactory, + new ThreadPoolExecutor.DiscardOldestPolicy() + ); + executor.allowCoreThreadTimeOut(true); + return executor; + } + + private static void registerExecutor(ThreadPoolExecutor executor) { + PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class); + if (preservation != null) { + preservation.register(executor); + } + } + + JPanel buildSidebar() { JPanel sidebar = new JPanel(new BorderLayout()); sidebar.setPreferredSize(new Dimension(SIDEBAR_WIDTH, 0)); - sidebar.setBackground(SIDEBAR_BG); - sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, SEPARATOR)); + sidebar.setBackground(SIDEBAR_BACKGROUND); + sidebar.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, BORDER)); - JTextField search = new JTextField(); - search.setBackground(SEARCH_BG); - search.setForeground(SEARCH_FG); - search.setCaretColor(SEARCH_FG); - search.setFont(SEARCH_FONT); - search.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createMatteBorder(0, 0, 1, 0, SEPARATOR), - BorderFactory.createEmptyBorder(8, 10, 8, 10) + JPanel heading = new JPanel(new BorderLayout()); + heading.setBackground(SIDEBAR_BACKGROUND); + heading.setBorder(BorderFactory.createEmptyBorder(12, 12, 10, 12)); + selectedLabel.setForeground(PRIMARY_TEXT); + selectedLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 14)); + progressLabel.setForeground(SECONDARY_TEXT); + progressLabel.setFont(ITEM_FONT); + heading.add(selectedLabel, BorderLayout.NORTH); + heading.add(progressLabel, BorderLayout.SOUTH); + + configureTextField(searchField); + searchField.putClientProperty("JTextField.placeholderText", IrisLanguage.plain(DesktopUiMessages.NOISE_SEARCH)); + searchField.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 1, 0, BORDER), + BorderFactory.createEmptyBorder(8, 12, 8, 12) )); - search.putClientProperty("JTextField.placeholderText", IrisLanguage.plain(DesktopUiMessages.NOISE_SEARCH)); + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent event) { + populateSourceList(); + } - LinkedHashMap> categories = buildCategoryMap(nv, engine, customGen, customName); - DefaultListModel model = new DefaultListModel<>(); - populateModel(model, categories, ""); + @Override + public void removeUpdate(DocumentEvent event) { + populateSourceList(); + } - JList list = new JList<>(model); - list.setBackground(SIDEBAR_BG); - list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - list.setCellRenderer(new SidebarCellRenderer()); - list.setFixedCellHeight(-1); - - list.addListSelectionListener(e -> { - if (e.getValueIsAdjusting()) return; - ListItem selected = list.getSelectedValue(); - if (selected != null && !selected.header && selected.action != null) { - selected.action.run(); + @Override + public void changedUpdate(DocumentEvent event) { + populateSourceList(); } }); - search.getDocument().addDocumentListener(new DocumentListener() { - private void filter() { - String text = search.getText().trim(); - populateModel(model, categories, text); + JPanel north = new JPanel(new BorderLayout()); + north.setBackground(SIDEBAR_BACKGROUND); + north.add(heading, BorderLayout.NORTH); + north.add(searchField, BorderLayout.SOUTH); + + sourceList.setBackground(SIDEBAR_BACKGROUND); + sourceList.setForeground(SECONDARY_TEXT); + sourceList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + sourceList.setCellRenderer(new SourceCellRenderer()); + sourceList.addListSelectionListener(event -> { + if (event.getValueIsAdjusting()) { + return; + } + ListItem item = sourceList.getSelectedValue(); + if (item != null && item.source() != null) { + selectSource(item.source()); } - - public void insertUpdate(DocumentEvent e) { filter(); } - public void removeUpdate(DocumentEvent e) { filter(); } - public void changedUpdate(DocumentEvent e) { filter(); } }); - - JScrollPane scrollPane = new JScrollPane(list); + JScrollPane scrollPane = new JScrollPane(sourceList); scrollPane.setBorder(BorderFactory.createEmptyBorder()); - scrollPane.getVerticalScrollBar().setUnitIncrement(16); - scrollPane.getVerticalScrollBar().setBackground(SIDEBAR_BG); + scrollPane.getVerticalScrollBar().setUnitIncrement(18); - sidebar.add(search, BorderLayout.NORTH); + JPanel controls = buildControls(); + sidebar.add(north, BorderLayout.NORTH); sidebar.add(scrollPane, BorderLayout.CENTER); + sidebar.add(controls, BorderLayout.SOUTH); + populateSourceList(); return sidebar; } - private static void populateModel(DefaultListModel model, LinkedHashMap> categories, String filter) { - model.clear(); - String lower = filter.toLowerCase(); - for (Map.Entry> entry : categories.entrySet()) { - List matching = new ArrayList<>(); - for (ListItem item : entry.getValue()) { - if (lower.isEmpty() || item.text.toLowerCase().contains(lower) || item.rawName.toLowerCase().contains(lower)) { - matching.add(item); + private JPanel buildControls() { + JPanel controls = new JPanel(new GridLayout(0, 1, 0, 7)); + controls.setBackground(PANEL_BACKGROUND); + controls.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, BORDER), + BorderFactory.createEmptyBorder(10, 12, 12, 12) + )); + + catalogLabel.setForeground(SECONDARY_TEXT); + catalogLabel.setFont(ITEM_FONT); + controls.add(catalogLabel); + + JLabel seedTitle = new JLabel("Seed"); + seedTitle.setForeground(SECONDARY_TEXT); + seedTitle.setFont(HEADER_FONT); + controls.add(seedTitle); + JPanel seedRow = new JPanel(new BorderLayout(7, 0)); + seedRow.setOpaque(false); + configureTextField(seedField); + JButton applySeed = createButton("Apply"); + applySeed.addActionListener(event -> applySeed()); + seedField.addActionListener(event -> applySeed()); + seedRow.add(seedField, BorderLayout.CENTER); + seedRow.add(applySeed, BorderLayout.EAST); + controls.add(seedRow); + + JLabel paletteTitle = new JLabel("Palette"); + paletteTitle.setForeground(SECONDARY_TEXT); + paletteTitle.setFont(HEADER_FONT); + controls.add(paletteTitle); + palettePicker.setSelectedItem(palette); + palettePicker.setBackground(FIELD_BACKGROUND); + palettePicker.setForeground(PRIMARY_TEXT); + palettePicker.addActionListener(event -> { + NoisePalette nextPalette = (NoisePalette) palettePicker.getSelectedItem(); + if (nextPalette != null && nextPalette != palette) { + palette = nextPalette; + requestPreview(); + } + }); + controls.add(palettePicker); + + JButton resetView = createButton("Reset view"); + resetView.addActionListener(event -> resetViewport()); + controls.add(resetView); + return controls; + } + + private static JButton createButton(String text) { + JButton button = new JButton(text); + button.setBackground(FIELD_BACKGROUND); + button.setForeground(PRIMARY_TEXT); + button.setFocusPainted(false); + button.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + BorderFactory.createEmptyBorder(5, 9, 5, 9) + )); + return button; + } + + private static void configureTextField(JTextField field) { + field.setBackground(FIELD_BACKGROUND); + field.setForeground(PRIMARY_TEXT); + field.setCaretColor(PRIMARY_TEXT); + field.setFont(ITEM_FONT); + field.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(BORDER), + BorderFactory.createEmptyBorder(6, 8, 6, 8) + )); + } + + void openViewer() { + loadCatalog(); + SourceItem initial = findInitialSource(); + if (initial != null && !selectSourceInList(initial.id())) { + selectSource(initial); + } + } + + private SourceItem findInitialSource() { + if (customFactory != null) { + for (SourceItem source : fixedSources) { + if (source.category() == NoiseCategory.CUSTOM) { + return source; } } - if (!matching.isEmpty()) { - model.addElement(new ListItem(entry.getKey(), entry.getKey(), true, null)); - for (ListItem item : matching) { - model.addElement(item); - } + } + for (SourceItem source : fixedSources) { + if (source.id().equals("style:SIMPLEX")) { + return source; + } + } + return fixedSources.isEmpty() ? null : fixedSources.get(0); + } + + private List buildFixedSources() { + List sources = new ArrayList<>(); + if (customFactory != null && customName != null) { + sources.add(new SourceItem( + "custom:" + customName, + customName, + customName, + NoiseCategory.CUSTOM, + true, + customFactory::apply + )); + } + for (NoiseStyle style : NoiseStyle.values()) { + sources.add(new SourceItem( + "style:" + style.name(), + formatName(style.name()), + style.name(), + categorize(style), + true, + sourceSeed -> { + CNG noise = style.create(new RNG(sourceSeed)); + return noise::noiseFast2D; + } + )); + } + return List.copyOf(sources); + } + + private void loadCatalog() { + if (disposed) { + return; + } + long revision = catalogLoadRevision.incrementAndGet(); + catalogLoading = true; + catalogLabel.setForeground(SECONDARY_TEXT); + catalogLabel.setText("Loading pack catalog..."); + submitLatest(catalogExecutor, () -> { + try { + Engine engine = resolveEngine(); + List loadedSources = loadPackSources(engine); + EventQueue.invokeLater(() -> applyCatalog(revision, loadedSources, null)); + } catch (Throwable error) { + IrisLogging.reportError(error); + EventQueue.invokeLater(() -> applyCatalog(revision, List.of(), error)); + } + }); + } + + private Engine resolveEngine() { + Engine engine = activeEngine; + return engine != null && !engine.isClosed() ? engine : null; + } + + private NoiseProvider createGeneratorSampler(String generatorKey, IrisGenerator fallback, long sourceSeed) { + IrisGenerator generator = fallback; + Engine engine = resolveEngine(); + if (engine != null && generatorKey != null && !generatorKey.isBlank()) { + IrisGenerator refreshedGenerator = engine.getData().getGeneratorLoader().load(generatorKey); + if (refreshedGenerator != null) { + generator = refreshedGenerator; + } + } + long mixedSeed = new RNG(sourceSeed).nextParallelRNG(3245).lmax(); + IrisGenerator selectedGenerator = generator; + return (x, z) -> selectedGenerator.getHeight(x, z, mixedSeed); + } + + private static List loadPackSources(Engine engine) { + if (engine == null) { + return List.of(); + } + IrisData data = engine.getData(); + String[] keys = data.getGeneratorLoader().getPossibleKeys(); + if (keys == null || keys.length == 0) { + return List.of(); + } + Arrays.sort(keys); + List sources = new ArrayList<>(keys.length); + for (String key : keys) { + sources.add(new SourceItem( + "pack:" + key, + formatName(key), + key, + NoiseCategory.PACK_GENERATORS, + true, + sourceSeed -> { + IrisGenerator generator = data.getGeneratorLoader().load(key); + if (generator == null) { + throw new IllegalStateException("Pack generator not found: " + key); + } + long mixedSeed = new RNG(sourceSeed).nextParallelRNG(3245).lmax(); + return (x, z) -> generator.getHeight(x, z, mixedSeed); + } + )); + } + return List.copyOf(sources); + } + + private void applyCatalog(long revision, List sources, Throwable error) { + if (disposed || revision != catalogLoadRevision.get()) { + return; + } + catalogLoading = false; + packSources = sources; + if (error != null) { + catalogLabel.setForeground(ERROR); + catalogLabel.setText("Pack catalog unavailable"); + } else if (sources.isEmpty()) { + catalogLabel.setForeground(SECONDARY_TEXT); + catalogLabel.setText("No active pack generators"); + } else { + catalogLabel.setForeground(SECONDARY_TEXT); + catalogLabel.setText(sources.size() + " pack generators"); + } + boolean reloadSelectedPack = false; + if (error == null + && selectedSource != null + && selectedSource.category() == NoiseCategory.PACK_GENERATORS) { + SourceItem replacement = findSourceById(selectedSource.id(), sources); + if (replacement == null) { + failRemovedPackSource(selectedSource); + } else { + selectedSource = replacement; + reloadSelectedPack = true; + } + } + populateSourceList(); + if (reloadSelectedPack) { + loadSelectedSource(); + } + } + + private void failRemovedPackSource(SourceItem removedSource) { + sourceLoadRevision.incrementAndGet(); + refinementTimer.stop(); + viewRevision++; + renderCoordinator.cancel(viewRevision); + selectedSource = null; + sampler = null; + renderedImage = null; + renderResult = null; + sourceLoading = false; + rendering = false; + errorMessage = "Pack generator removed during hotload: " + removedSource.rawName(); + selectedLabel.setText("No source selected"); + progressLabel.setForeground(ERROR); + progressLabel.setText("Selected pack generator was removed"); + sourceList.clearSelection(); + repaint(); + } + + private void populateSourceList() { + if (!EventQueue.isDispatchThread()) { + EventQueue.invokeLater(this::populateSourceList); + return; + } + String selectedId = selectedSource == null ? null : selectedSource.id(); + String filter = searchField.getText().trim().toLowerCase(Locale.ROOT); + sourceModel.clear(); + for (NoiseCategory category : CATEGORY_ORDER) { + List matching = matchingSources(category, filter); + if (matching.isEmpty()) { + continue; + } + sourceModel.addElement(new ListItem(categoryLabel(category), null)); + for (SourceItem source : matching) { + sourceModel.addElement(new ListItem(source.text(), source)); + } + } + if (selectedId != null) { + selectSourceInList(selectedId); + } + } + + private List matchingSources(NoiseCategory category, String filter) { + List matching = new ArrayList<>(); + addMatchingSources(matching, fixedSources, category, filter); + addMatchingSources(matching, packSources, category, filter); + return matching; + } + + private static void addMatchingSources(List destination, List sources, + NoiseCategory category, String filter) { + for (SourceItem source : sources) { + if (source.category() != category) { + continue; + } + String searchable = (source.text() + " " + source.rawName()).toLowerCase(Locale.ROOT); + if (filter.isEmpty() || searchable.contains(filter)) { + destination.add(source); } } } - private static LinkedHashMap> buildCategoryMap(NoiseExplorerGUI nv, Engine engine, - Supplier> customGen, String customName) { - LinkedHashMap> categories = new LinkedHashMap<>(); - - if (customGen != null && customName != null) { - List custom = new ArrayList<>(); - custom.add(new ListItem(customName, customName, false, () -> { - nv.generator = customGen.get(); - nv.loader = customGen; - nv.currentName = customName; - })); - categories.put(IrisLanguage.plain(DesktopUiMessages.NOISE_CATEGORY_CUSTOM), custom); + private boolean selectSourceInList(String sourceId) { + for (int index = 0; index < sourceModel.size(); index++) { + ListItem item = sourceModel.get(index); + if (item.source() != null && item.source().id().equals(sourceId)) { + sourceList.setSelectedIndex(index); + sourceList.ensureIndexIsVisible(index); + return true; + } } + return false; + } - Map> styleGroups = new LinkedHashMap<>(); - for (NoiseStyle style : NoiseStyle.values()) { - NoiseCategory cat = categorize(style); - styleGroups.computeIfAbsent(cat, k -> new ArrayList<>()).add(style); + private void selectSource(SourceItem source) { + if (disposed || source == null || (source == selectedSource && (sampler != null || sourceLoading))) { + return; } + selectedSource = source; + selectedLabel.setText(source.text()); + loadSelectedSource(); + } - if (engine != null && !engine.isClosed()) { - List genItems = new ArrayList<>(); + private void loadSelectedSource() { + SourceItem source = selectedSource; + if (disposed || source == null) { + return; + } + long revision = sourceLoadRevision.incrementAndGet(); + long requestedSeed = seed; + refinementTimer.stop(); + viewRevision++; + renderCoordinator.cancel(viewRevision); + sampler = null; + renderedImage = null; + renderResult = null; + sourceLoading = true; + rendering = false; + errorMessage = null; + progressLabel.setForeground(SECONDARY_TEXT); + progressLabel.setText("Loading source..."); + repaint(); + submitLatest(sourceExecutor, () -> { try { - IrisData data = engine.getData(); - String[] keys = data.getGeneratorLoader().getPossibleKeys(); - Arrays.sort(keys); - for (String key : keys) { - IrisGenerator gen = data.getGeneratorLoader().load(key); - if (gen != null) { - long seed = new RNG(12345).nextParallelRNG(3245).lmax(); - genItems.add(new ListItem(formatName(key), key, false, () -> { - nv.generator = (x, z) -> gen.getHeight(x, z, seed); - nv.loader = null; - nv.currentName = key; - })); - } + NoiseProvider loadedSampler = source.factory().create(requestedSeed); + if (loadedSampler == null) { + throw new IllegalStateException("Noise source returned no sampler: " + source.rawName()); } - } catch (Throwable ignored) {} - if (!genItems.isEmpty()) { - categories.put(categoryLabel(NoiseCategory.PACK_GENERATORS), genItems); + EventQueue.invokeLater(() -> applyLoadedSource(revision, source, loadedSampler)); + } catch (Throwable error) { + IrisLogging.reportError(error); + EventQueue.invokeLater(() -> applySourceError(revision, source, error)); + } + }); + } + + private void applyLoadedSource(long revision, SourceItem source, NoiseProvider loadedSampler) { + if (disposed || revision != sourceLoadRevision.get() || selectedSource != source) { + return; + } + sampler = loadedSampler; + sourceLoading = false; + errorMessage = null; + renderedImage = null; + renderResult = null; + progressLabel.setForeground(SECONDARY_TEXT); + progressLabel.setText(source.usesSeed() ? "Ready · seed " + seed : "Ready"); + requestPreview(); + } + + private void applySourceError(long revision, SourceItem source, Throwable error) { + if (disposed || revision != sourceLoadRevision.get() || selectedSource != source) { + return; + } + sampler = null; + sourceLoading = false; + rendering = false; + errorMessage = readableError(error); + progressLabel.setForeground(ERROR); + progressLabel.setText("Source failed to load"); + repaint(); + } + + private void applySeed() { + String rawSeed = seedField.getText().trim(); + try { + long nextSeed = Long.parseLong(rawSeed); + seedField.setForeground(PRIMARY_TEXT); + if (nextSeed == seed) { + return; + } + seed = nextSeed; + if (selectedSource != null && selectedSource.usesSeed()) { + loadSelectedSource(); + } + } catch (NumberFormatException error) { + seedField.setForeground(ERROR); + progressLabel.setForeground(ERROR); + progressLabel.setText("Seed must be a whole number"); + } + } + + private void requestPreview() { + if (disposed || sampler == null || getWidth() < 1 || renderHeight() < 1) { + repaint(); + return; + } + refinementTimer.stop(); + viewRevision++; + int sampleStep = NoiseRenderCoordinator.sampleStepForBudget( + getWidth(), + renderHeight(), + COARSE_SAMPLE_BUDGET + ); + requestRender(sampleStep); + } + + private void requestRefinement() { + if (disposed || sampler == null || getWidth() < 1 || renderHeight() < 1) { + return; + } + NoiseRenderCoordinator.Result currentResult = renderResult; + if (currentResult == null || currentResult.request().revision() != viewRevision) { + return; + } + long sampleBudget = refinedSampleBudget(currentResult); + int sampleStep = NoiseRenderCoordinator.nextRefinementStep( + getWidth(), + renderHeight(), + currentResult.request().sampleStep(), + sampleBudget + ); + if (sampleStep >= currentResult.request().sampleStep()) { + return; + } + requestRender(sampleStep); + } + + private static long refinedSampleBudget(NoiseRenderCoordinator.Result currentResult) { + return NoiseRenderCoordinator.timeBoundSampleBudget( + currentResult.samples(), + currentResult.milliseconds(), + REFINEMENT_TARGET_MILLIS, + COARSE_SAMPLE_BUDGET, + MAX_REFINED_SAMPLE_BUDGET + ); + } + + private void requestRender(int sampleStep) { + NoiseRenderCoordinator.Request request = new NoiseRenderCoordinator.Request( + viewRevision, + sampler, + viewport, + palette, + getWidth(), + renderHeight(), + sampleStep + ); + renderCoordinator.request(request); + } + + private void installInputHandlers() { + MouseAdapter mouseHandler = new MouseAdapter() { + @Override + public void mousePressed(MouseEvent event) { + requestFocusInWindow(); + dragPoint = event.getPoint(); + pointer = event.getPoint(); + } + + @Override + public void mouseReleased(MouseEvent event) { + boolean wasDragging = dragPoint != null; + dragPoint = null; + if (wasDragging) { + interactionTimer.stop(); + requestPreview(); + } + } + + @Override + public void mouseDragged(MouseEvent event) { + Point nextPoint = event.getPoint(); + pointer = nextPoint; + if (dragPoint != null) { + double deltaX = nextPoint.getX() - dragPoint.getX(); + double deltaZ = nextPoint.getY() - dragPoint.getY(); + viewport = viewport.panPixels(deltaX, deltaZ); + dragPoint = nextPoint; + interactionTimer.restart(); + } + } + + @Override + public void mouseMoved(MouseEvent event) { + pointer = event.getPoint(); + repaint(); + } + + @Override + public void mouseExited(MouseEvent event) { + pointer = null; + repaint(); + } + + @Override + public void mouseWheelMoved(MouseWheelEvent event) { + pointer = event.getPoint(); + double factor = Math.pow(2D, event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT); + viewport = viewport.zoomAt( + event.getX(), + Math.min(event.getY(), renderHeight()), + getWidth(), + renderHeight(), + factor + ); + interactionTimer.restart(); + event.consume(); + } + }; + addMouseListener(mouseHandler); + addMouseMotionListener(mouseHandler); + addMouseWheelListener(mouseHandler); + addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent event) { + interactionTimer.restart(); + } + }); + getInputMap(WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke('R'), "reset-view"); + getActionMap().put("reset-view", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent event) { + resetViewport(); + } + }); + } + + private void resetViewport() { + viewport = new NoiseViewport(0D, 0D, 1D); + requestPreview(); + } + + private void handleHotload() { + Engine refreshedEngine = GuiHost.get().findActiveEngine(); + EventQueue.invokeLater(() -> { + if (disposed) { + return; + } + if (refreshedEngine != null && !refreshedEngine.isClosed()) { + activeEngine = refreshedEngine; + } + loadCatalog(); + if (selectedSource != null && selectedSource.category() == NoiseCategory.CUSTOM) { + loadSelectedSource(); + } + }); + } + + void close() { + if (disposed) { + return; + } + disposed = true; + refinementTimer.stop(); + interactionTimer.stop(); + GuiHost.get().unregisterHotloadHook(hotloadHook); + sourceLoadRevision.incrementAndGet(); + catalogLoadRevision.incrementAndGet(); + sourceExecutor.shutdownNow(); + catalogExecutor.shutdownNow(); + renderCoordinator.close(); + } + + @Override + protected void paintComponent(Graphics graphics) { + super.paintComponent(graphics); + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + canvas.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + int renderHeight = renderHeight(); + if (renderedImage == null) { + drawEmptyState(canvas, getWidth(), renderHeight); + } else { + Object interpolation = renderedImage.getWidth() == getWidth() + ? RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR + : RenderingHints.VALUE_INTERPOLATION_BILINEAR; + canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, interpolation); + canvas.drawImage(renderedImage, 0, 0, getWidth(), renderHeight, null); + } + drawCrosshair(canvas, getWidth(), renderHeight); + drawLegend(canvas, getWidth()); + drawStatus(canvas, getWidth(), renderHeight); + } finally { + canvas.dispose(); + } + } + + private static void drawEmptyState(Graphics2D canvas, int width, int height) { + canvas.setColor(BACKGROUND); + canvas.fillRect(0, 0, width, height); + canvas.setColor(GRID); + for (int x = 0; x < width; x += 48) { + canvas.drawLine(x, 0, x, height); + } + for (int y = 0; y < height; y += 48) { + canvas.drawLine(0, y, width, y); + } + } + + private static void drawCrosshair(Graphics2D canvas, int width, int height) { + int centerX = width / 2; + int centerZ = height / 2; + canvas.setColor(CROSSHAIR); + canvas.setStroke(new BasicStroke(1F)); + canvas.drawLine(centerX - 8, centerZ, centerX + 8, centerZ); + canvas.drawLine(centerX, centerZ - 8, centerX, centerZ + 8); + } + + private void drawLegend(Graphics2D canvas, int width) { + int legendWidth = 190; + int legendHeight = 40; + int legendX = Math.max(10, width - legendWidth - 12); + int legendY = 12; + canvas.setColor(LEGEND_BACKGROUND); + canvas.fillRoundRect(legendX, legendY, legendWidth, legendHeight, 10, 10); + int gradientX = legendX + 9; + int gradientY = legendY + 8; + int gradientWidth = legendWidth - 18; + for (int x = 0; x < gradientWidth; x++) { + double normalized = x / (double) Math.max(1, gradientWidth - 1); + canvas.setColor(palette.displayColorNormalized(normalized)); + canvas.drawLine(gradientX + x, gradientY, gradientX + x, gradientY + 8); + } + canvas.setFont(LEGEND_FONT); + canvas.setColor(PRIMARY_TEXT); + canvas.drawString(formatNumber(palette.minimum()), gradientX, legendY + 31); + String maximum = formatNumber(palette.maximum()); + int maximumWidth = canvas.getFontMetrics().stringWidth(maximum); + canvas.drawString(maximum, gradientX + gradientWidth - maximumWidth, legendY + 31); + String label = palette.label(); + int labelWidth = canvas.getFontMetrics().stringWidth(label); + canvas.drawString(label, legendX + ((legendWidth - labelWidth) / 2), legendY + 31); + } + + private void drawStatus(Graphics2D canvas, int width, int renderHeight) { + canvas.setColor(PANEL_BACKGROUND); + canvas.fillRect(0, renderHeight, width, STATUS_HEIGHT); + canvas.setColor(BORDER); + canvas.drawLine(0, renderHeight, width, renderHeight); + canvas.setFont(MONOSPACED_FONT); + canvas.setColor(errorMessage == null ? PRIMARY_TEXT : ERROR); + canvas.drawString(primaryStatus(), 10, renderHeight + 19); + canvas.setColor(SECONDARY_TEXT); + canvas.drawString(secondaryStatus(width, renderHeight), 10, renderHeight + 38); + } + + private String primaryStatus() { + if (errorMessage != null) { + return "Error: " + errorMessage; + } + String sourceName = selectedSource == null ? "No source" : selectedSource.text(); + if (sourceLoading) { + return sourceName + " · loading source"; + } + if (rendering) { + return sourceName + " · rendering preview"; + } + if (renderResult == null) { + return sourceName + " · waiting for preview"; + } + NoiseRenderCoordinator.Result result = renderResult; + String quality = result.request().sampleStep() == 1 + ? "full" + : "sample 1:" + result.request().sampleStep(); + return sourceName + " · " + quality + " · " + formatNumber(result.centerValue()) + + " center · " + String.format(Locale.ROOT, "%.1f ms", result.milliseconds()); + } + + private String secondaryStatus(int width, int renderHeight) { + Point currentPointer = pointer; + double screenX = currentPointer == null ? width / 2D : currentPointer.getX(); + double screenZ = currentPointer == null ? renderHeight / 2D : Math.min(currentPointer.getY(), renderHeight); + double worldX = viewport.worldX(screenX, width); + double worldZ = viewport.worldZ(screenZ, renderHeight); + String coordinates = String.format( + Locale.ROOT, + "X %.2f Z %.2f · %.5f blocks/px", + worldX, + worldZ, + viewport.blocksPerPixel() + ); + if (renderResult == null) { + return coordinates + (catalogLoading ? " · loading catalog" : ""); + } + NoiseRenderCoordinator.Result result = renderResult; + long clipped = result.underflow() + result.overflow(); + return coordinates + " · range " + formatNumber(result.minimum()) + ".." + formatNumber(result.maximum()) + + " · clipped " + clipped + " invalid " + result.invalid(); + } + + private int renderHeight() { + return Math.max(1, getHeight() - STATUS_HEIGHT); + } + + private static String formatNumber(double value) { + if (!Double.isFinite(value)) { + return "n/a"; + } + return String.format(Locale.ROOT, "%.4f", value); + } + + private static String readableError(Throwable error) { + String message = error.getMessage(); + if (message == null || message.isBlank()) { + return error.getClass().getSimpleName(); + } + return message; + } + + private static SourceItem findSourceById(String id, List sources) { + for (SourceItem source : sources) { + if (source.id().equals(id)) { + return source; } } + return null; + } - for (NoiseCategory cat : CATEGORY_ORDER) { - if (cat == NoiseCategory.PACK_GENERATORS) continue; - List styles = styleGroups.get(cat); - if (styles != null && !styles.isEmpty()) { - List items = new ArrayList<>(); - for (NoiseStyle style : styles) { - items.add(new ListItem(formatName(style.name()), style.name(), false, () -> { - nv.cng = style.create(RNG.r.nextParallelRNG(RNG.r.imax())); - nv.generator = null; - nv.loader = null; - nv.currentName = style.name(); - })); - } - categories.put(categoryLabel(cat), items); - } + private static void submitLatest(ThreadPoolExecutor executor, Runnable task) { + if (executor.isShutdown()) { + return; } - - for (Map.Entry> entry : styleGroups.entrySet()) { - String category = categoryLabel(entry.getKey()); - if (!categories.containsKey(category)) { - List items = new ArrayList<>(); - for (NoiseStyle style : entry.getValue()) { - items.add(new ListItem(formatName(style.name()), style.name(), false, () -> { - nv.cng = style.create(RNG.r.nextParallelRNG(RNG.r.imax())); - nv.generator = null; - nv.loader = null; - nv.currentName = style.name(); - })); - } - categories.put(category, items); - } - } - - return categories; + executor.getQueue().clear(); + executor.execute(task); } private static NoiseCategory categorize(NoiseStyle style) { - String n = style.name(); - if (n.startsWith("STATIC")) return NoiseCategory.STATIC; - if (n.startsWith("IRIS")) return NoiseCategory.IRIS; - if (n.startsWith("CLOVER")) return NoiseCategory.CLOVER; - if (n.startsWith("VASCULAR")) return NoiseCategory.VASCULAR; - if (n.equals("FLAT")) return NoiseCategory.UTILITY; - if (n.startsWith("CELLULAR")) return NoiseCategory.CELLULAR; - if (n.startsWith("HEX") || n.equals("HEXAGON")) return NoiseCategory.HEXAGON; - if (n.startsWith("SIERPINSKI")) return NoiseCategory.SIERPINSKI; - if (n.startsWith("NOWHERE")) return NoiseCategory.NOWHERE; - if (n.startsWith("GLOB")) return NoiseCategory.GLOBE; - if (n.startsWith("PERLIN")) return NoiseCategory.PERLIN; - if (n.startsWith("CUBIC") || (n.startsWith("FRACTAL") && n.contains("CUBIC"))) return NoiseCategory.CUBIC; - if (n.contains("SIMPLEX") && !n.startsWith("FRACTAL")) return NoiseCategory.SIMPLEX; - if (n.startsWith("FRACTAL")) return NoiseCategory.FRACTAL; + String name = style.name(); + if (name.startsWith("STATIC")) { + return NoiseCategory.STATIC; + } + if (name.startsWith("IRIS")) { + return NoiseCategory.IRIS; + } + if (name.startsWith("CLOVER")) { + return NoiseCategory.CLOVER; + } + if (name.startsWith("VASCULAR")) { + return NoiseCategory.VASCULAR; + } + if (name.equals("FLAT")) { + return NoiseCategory.UTILITY; + } + if (name.startsWith("CELLULAR")) { + return NoiseCategory.CELLULAR; + } + if (name.startsWith("HEX") || name.equals("HEXAGON")) { + return NoiseCategory.HEXAGON; + } + if (name.startsWith("SIERPINSKI")) { + return NoiseCategory.SIERPINSKI; + } + if (name.startsWith("NOWHERE")) { + return NoiseCategory.NOWHERE; + } + if (name.startsWith("GLOB")) { + return NoiseCategory.GLOBE; + } + if (name.startsWith("PERLIN")) { + return NoiseCategory.PERLIN; + } + if (name.startsWith("CUBIC") || (name.startsWith("FRACTAL") && name.contains("CUBIC"))) { + return NoiseCategory.CUBIC; + } + if (name.contains("SIMPLEX") && !name.startsWith("FRACTAL")) { + return NoiseCategory.SIMPLEX; + } + if (name.startsWith("FRACTAL")) { + return NoiseCategory.FRACTAL; + } return NoiseCategory.OTHER; } private static String categoryLabel(NoiseCategory category) { return IrisLanguage.plain(switch (category) { + case CUSTOM -> DesktopUiMessages.NOISE_CATEGORY_CUSTOM; case PACK_GENERATORS -> DesktopUiMessages.NOISE_CATEGORY_PACK_GENERATORS; case SIMPLEX -> DesktopUiMessages.NOISE_CATEGORY_SIMPLEX; case PERLIN -> DesktopUiMessages.NOISE_CATEGORY_PERLIN; @@ -390,208 +1158,104 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener { }); } - private static String formatName(String enumName) { - String lower = enumName.toLowerCase().replace('_', ' '); + private static String formatName(String rawName) { + if (rawName == null || rawName.isEmpty()) { + return "Unnamed"; + } + String lower = rawName.toLowerCase(Locale.ROOT).replace('_', ' '); return Character.toUpperCase(lower.charAt(0)) + lower.substring(1); } - private void refreshGenerator() { - if (generator != null && loader != null) { - generator = loader.get(); - } + @FunctionalInterface + private interface SourceFactory { + NoiseProvider create(long seed); } - @Override - public void mouseWheelMoved(MouseWheelEvent e) { - int notches = e.getWheelRotation(); - if (e.isControlDown()) { - time = time + ((0.0025 * time) * notches); - return; - } - scale = scale + ((0.044 * scale) * notches); - scale = Math.max(scale, 0.00001); + private record SourceItem(String id, String text, String rawName, NoiseCategory category, boolean usesSeed, + SourceFactory factory) { } - private double lerp(double current, double target, double speed) { - double diff = target - current; - if (Math.abs(diff) < 0.001) return target; - return current + diff * speed; + private record ListItem(String text, SourceItem source) { } - @Override - public void paint(Graphics g) { - animScale = lerp(animScale, scale, 0.16); - animTime = lerp(animTime, time, 0.29); - animOx = lerp(animOx, ox, 0.16); - animOz = lerp(animOz, oz, 0.16); - - PrecisionStopwatch p = PrecisionStopwatch.start(); - - if (g instanceof Graphics2D gg) { - gg.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); - gg.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); - - int pw = getWidth(); - int ph = getHeight(); - - if (pw != imgWidth || ph != imgHeight || img == null) { - imgWidth = pw; - imgHeight = ph; - img = null; - } - - int accuracy = M.clip((fpsHistory.getAverage() / 14D), 1D, 64D).intValue(); - int rw = Math.max(1, pw / accuracy); - int rh = Math.max(1, ph / accuracy); - - if (img == null || img.getWidth() != rw || img.getHeight() != rh) { - img = new BufferedImage(rw, rh, BufferedImage.TYPE_INT_RGB); - } - - int[] pixels = ((DataBufferInt) img.getRaster().getDataBuffer()).getData(); - - BurstExecutor burst = gx.burst(rw); - for (int x = 0; x < rw; x++) { - int xx = x; - burst.queue(() -> { - for (int z = 0; z < rh; z++) { - double worldX = (xx * accuracy * animScale) + animOx; - double worldZ = (z * accuracy * animScale) + animOz; - double n = generator != null - ? generator.apply(worldX, worldZ) - : cng.noise(worldX, worldZ); - n = Math.max(0, Math.min(1, n)); - - int rgb; - if (colorMode) { - rgb = HSB_LUT[(int) (n * 255)]; - } else { - int v = (int) (n * 255); - rgb = (v << 16) | (v << 8) | v; - } - pixels[z * rw + xx] = rgb; - } - }); - } - burst.complete(); - - gg.setColor(BG); - gg.fillRect(0, 0, pw, ph); - gg.drawImage(img, 0, 0, pw, ph, null); - - renderStatusBar(gg, pw, ph, p.getMilliseconds()); - renderCrosshair(gg, pw, ph); - } - - p.end(); - time += 1D; - fpsHistory.put(p.getMilliseconds()); - - if (!isVisible() || !getParent().isVisible()) { - return; - } - - long sleepMs = Math.max(1, 16 - (long) p.getMilliseconds()); - // Pace on a worker, not the EDT: a sleep queued on the event thread blocks painting - // and input for the whole frame budget. repaint() marshals itself back. - J.a(() -> { - J.sleep(sleepMs); - repaint(); - }); - } - - private void renderCrosshair(Graphics2D g, int w, int h) { - int cx = w / 2; - int cy = h / 2; - g.setColor(new Color(255, 255, 255, 40)); - g.drawLine(cx - 8, cy, cx + 8, cy); - g.drawLine(cx, cy - 8, cx, cy + 8); - } - - private void renderStatusBar(Graphics2D g, int w, int h, double frameMs) { - int barHeight = 28; - int y = h - barHeight; - - g.setColor(STATUS_BG); - g.fillRect(0, y, w, barHeight); - g.setColor(new Color(50, 50, 60)); - g.drawLine(0, y, w, y); - - g.setFont(STATUS_FONT); - g.setColor(STATUS_TEXT); - - double worldX = (w / 2.0 * animScale) + animOx; - double worldZ = (h / 2.0 * animScale) + animOz; - double noiseVal = generator != null - ? generator.apply(worldX, worldZ) - : cng.noise(worldX, worldZ); - noiseVal = Math.max(0, Math.min(1, noiseVal)); - - int fps = frameMs > 0 ? (int) (1000.0 / frameMs) : 0; - - String status = IrisLanguage.plain( - DesktopUiMessages.NOISE_STATUS, - MessageArgument.untrusted("name", currentName), - MessageArgument.trusted("x", String.format("%.1f", worldX)), - MessageArgument.trusted("z", String.format("%.1f", worldZ)), - MessageArgument.trusted("zoom", String.format("%.4f", animScale)), - MessageArgument.trusted("value", String.format("%.4f", noiseVal)), - MessageArgument.trusted("fps", fps) - ); - g.drawString(status, 8, y + 18); - - int barW = 60; - int barX = w - barW - 12; - int barY = y + 6; - int barH = barHeight - 12; - g.setColor(new Color(40, 40, 48)); - g.fillRoundRect(barX, barY, barW, barH, 4, 4); - int fillW = (int) (noiseVal * (barW - 2)); - g.setColor(ACCENT); - g.fillRoundRect(barX + 1, barY + 1, fillW, barH - 2, 3, 3); - } - - private static final class ListItem { - final String text; - final String rawName; - final boolean header; - final Runnable action; - - ListItem(String text, String rawName, boolean header, Runnable action) { - this.text = text; - this.rawName = rawName; - this.header = header; - this.action = action; + private final class RenderListener implements NoiseRenderCoordinator.Listener { + @Override + public void onRenderStarted(NoiseRenderCoordinator.Request request) { + EventQueue.invokeLater(() -> { + if (!disposed && request.revision() == viewRevision) { + rendering = true; + repaint(); + } + }); } @Override - public String toString() { - return text; + public void onRenderCompleted(NoiseRenderCoordinator.Result result) { + EventQueue.invokeLater(() -> { + if (disposed || result.request().revision() != viewRevision) { + return; + } + rendering = false; + errorMessage = null; + renderResult = result; + renderedImage = result.image(); + repaint(); + if (result.request().sampleStep() <= 1) { + return; + } + long nextBudget = refinedSampleBudget(result); + int nextStep = NoiseRenderCoordinator.nextRefinementStep( + result.request().width(), + result.request().height(), + result.request().sampleStep(), + nextBudget + ); + if (nextStep < result.request().sampleStep()) { + refinementTimer.restart(); + } + }); + } + + @Override + public void onRenderFailed(NoiseRenderCoordinator.Request request, Throwable error) { + IrisLogging.reportError(error); + EventQueue.invokeLater(() -> { + if (disposed || request.revision() != viewRevision) { + return; + } + rendering = false; + errorMessage = readableError(error); + repaint(); + }); } } - private static final class SidebarCellRenderer extends DefaultListCellRenderer { + private static final class SourceCellRenderer extends DefaultListCellRenderer { @Override - public Component getListCellRendererComponent(JList list, Object value, int index, boolean selected, boolean focus) { + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean selected, boolean focus) { ListItem item = (ListItem) value; - super.getListCellRendererComponent(list, item.text, index, !item.header && selected, false); + boolean header = item.source() == null; + super.getListCellRendererComponent(list, item.text(), index, selected && !header, false); setOpaque(true); - if (item.header) { - setFont(SIDEBAR_HEADER_FONT); + if (header) { + setFont(HEADER_FONT); setForeground(ACCENT); - setBackground(SIDEBAR_BG); - setBorder(BorderFactory.createEmptyBorder(10, 10, 4, 10)); + setBackground(SIDEBAR_BACKGROUND); + setBorder(BorderFactory.createEmptyBorder(11, 11, 4, 11)); } else { - setFont(SIDEBAR_ITEM_FONT); - setForeground(selected ? Color.WHITE : SIDEBAR_ITEM_COLOR); - setBackground(selected ? SIDEBAR_SELECTED : SIDEBAR_BG); - setBorder(BorderFactory.createEmptyBorder(3, 20, 3, 10)); + setFont(ITEM_FONT); + setForeground(selected ? Color.WHITE : SECONDARY_TEXT); + setBackground(selected ? SELECTED_BACKGROUND : SIDEBAR_BACKGROUND); + setBorder(BorderFactory.createEmptyBorder(4, 20, 4, 10)); } + setHorizontalAlignment(SwingConstants.LEFT); return this; } } private enum NoiseCategory { + CUSTOM, PACK_GENERATORS, SIMPLEX, PERLIN, diff --git a/core/src/main/java/art/arcane/iris/core/gui/NoisePalette.java b/core/src/main/java/art/arcane/iris/core/gui/NoisePalette.java new file mode 100644 index 000000000..499440552 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/NoisePalette.java @@ -0,0 +1,96 @@ +package art.arcane.iris.core.gui; + +import java.awt.Color; + +enum NoisePalette { + TERRAIN("Terrain", 0D, 1D, new int[]{0x071A2F, 0x155E75, 0x2A9D8F, 0xE9C46A, 0xF4F1DE}), + SIGNED("Signed", -1D, 1D, new int[]{0x173B66, 0x4F86C6, 0xE7EDF3, 0xE89555, 0x9D3B35}), + GRAYSCALE("Grayscale", 0D, 1D, new int[]{0x050505, 0xFFFFFF}); + + static final int INVALID_COLOR = 0xFF2DAA; + + private final String label; + private final double minimum; + private final double maximum; + private final int[] lookup; + private final Color[] displayColors; + + NoisePalette(String label, double minimum, double maximum, int[] stops) { + this.label = label; + this.minimum = minimum; + this.maximum = maximum; + this.lookup = buildLookup(stops); + this.displayColors = buildDisplayColors(lookup); + } + + int color(double value) { + if (!Double.isFinite(value)) { + return INVALID_COLOR; + } + return colorFinite(value); + } + + int colorFinite(double value) { + double normalized = (value - minimum) / (maximum - minimum); + return colorNormalized(normalized); + } + + int colorNormalized(double normalized) { + double clipped = Math.max(0D, Math.min(1D, normalized)); + return lookup[(int) Math.round(clipped * (lookup.length - 1))]; + } + + Color displayColorNormalized(double normalized) { + double clipped = Math.max(0D, Math.min(1D, normalized)); + return displayColors[(int) Math.round(clipped * (displayColors.length - 1))]; + } + + String label() { + return label; + } + + double minimum() { + return minimum; + } + + double maximum() { + return maximum; + } + + @Override + public String toString() { + return label; + } + + private static int[] buildLookup(int[] stops) { + int[] values = new int[256]; + for (int index = 0; index < values.length; index++) { + double position = (index / 255D) * (stops.length - 1); + int lowerIndex = Math.min(stops.length - 2, (int) position); + double fraction = position - lowerIndex; + values[index] = interpolate(stops[lowerIndex], stops[lowerIndex + 1], fraction); + } + return values; + } + + private static Color[] buildDisplayColors(int[] lookup) { + Color[] colors = new Color[lookup.length]; + for (int index = 0; index < lookup.length; index++) { + colors[index] = new Color(lookup[index]); + } + return colors; + } + + private static int interpolate(int from, int to, double fraction) { + int red = channel(from, 16, to, fraction); + int green = channel(from, 8, to, fraction); + int blue = channel(from, 0, to, fraction); + return (red << 16) | (green << 8) | blue; + } + + private static int channel(int from, int shift, int to, double fraction) { + int fromChannel = (from >>> shift) & 0xFF; + int toChannel = (to >>> shift) & 0xFF; + return (int) Math.round(fromChannel + ((toChannel - fromChannel) * fraction)); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/gui/NoiseRenderCoordinator.java b/core/src/main/java/art/arcane/iris/core/gui/NoiseRenderCoordinator.java new file mode 100644 index 000000000..53335f69e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/NoiseRenderCoordinator.java @@ -0,0 +1,393 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.util.function.NoiseProvider; + +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +final class NoiseRenderCoordinator implements AutoCloseable { + private final int workerCount; + private final Listener listener; + private final ThreadFactory workerThreadFactory; + private final ThreadFactory coordinatorThreadFactory; + private final AtomicReference latestRequested = new AtomicReference<>(); + private final AtomicLong latestRevision = new AtomicLong(); + private final AtomicBoolean closed = new AtomicBoolean(); + + NoiseRenderCoordinator(Listener listener) { + this(Math.min(4, Math.max(1, Runtime.getRuntime().availableProcessors() / 2)), listener); + } + + NoiseRenderCoordinator(int workerCount, Listener listener) { + this.workerCount = Math.max(1, workerCount); + this.listener = Objects.requireNonNull(listener, "listener"); + AtomicInteger threadIds = new AtomicInteger(); + workerThreadFactory = runnable -> { + Thread thread = new Thread(runnable, "Iris Noise Renderer " + threadIds.incrementAndGet()); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }; + coordinatorThreadFactory = runnable -> { + Thread thread = new Thread(runnable, "Iris Noise Coordinator " + threadIds.incrementAndGet()); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }; + } + + void request(Request request) { + Objects.requireNonNull(request, "request"); + if (closed.get()) { + return; + } + latestRevision.accumulateAndGet(request.revision(), Math::max); + cancelActiveRender(); + RenderGeneration generation = createGeneration(request); + latestRequested.set(generation); + generation.start(); + } + + void cancel(long revision) { + latestRevision.accumulateAndGet(revision, Math::max); + cancelActiveRender(); + } + + boolean isClosed() { + return closed.get(); + } + + static int sampleStepForBudget(int width, int height, long sampleBudget) { + if (width < 1 || height < 1 || sampleBudget < 1L) { + throw new IllegalArgumentException("Invalid noise sample budget"); + } + long totalSamples = (long) width * height; + int sampleStep = Math.max(1, (int) Math.ceil(Math.sqrt(totalSamples / (double) sampleBudget))); + while (sampleCount(width, height, sampleStep) > sampleBudget) { + sampleStep++; + } + return sampleStep; + } + + static long sampleCount(int width, int height, int sampleStep) { + if (width < 1 || height < 1 || sampleStep < 1) { + throw new IllegalArgumentException("Invalid noise sample dimensions"); + } + long outputWidth = ((long) width + sampleStep - 1L) / sampleStep; + long outputHeight = ((long) height + sampleStep - 1L) / sampleStep; + return outputWidth * outputHeight; + } + + static int nextRefinementStep(int width, int height, int currentStep, long sampleBudget) { + if (currentStep <= 1) { + throw new IllegalArgumentException("Noise refinement requires a coarse input"); + } + return Math.min(currentStep, sampleStepForBudget(width, height, sampleBudget)); + } + + static long timeBoundSampleBudget(long completedSamples, double milliseconds, double targetMilliseconds, + long minimumBudget, long maximumBudget) { + if (completedSamples < 1L + || !Double.isFinite(milliseconds) + || milliseconds <= 0D + || !Double.isFinite(targetMilliseconds) + || targetMilliseconds <= 0D + || minimumBudget < 1L + || maximumBudget < minimumBudget) { + throw new IllegalArgumentException("Invalid timed noise sample budget"); + } + double projectedSamples = Math.ceil((completedSamples / milliseconds) * targetMilliseconds); + long timeBoundBudget = (long) Math.min(maximumBudget, projectedSamples); + return Math.max(minimumBudget, timeBoundBudget); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + latestRevision.incrementAndGet(); + cancelActiveRender(); + } + + private void renderGeneration(RenderGeneration generation) { + Request request = generation.request(); + if (!isCurrent(request)) { + generation.close(); + return; + } + listener.onRenderStarted(request); + try { + Result result = render(generation); + if (result != null && isCurrent(request)) { + listener.onRenderCompleted(result); + } + } catch (Throwable error) { + if (isCurrent(request)) { + listener.onRenderFailed(request, error); + } + } finally { + latestRequested.compareAndSet(generation, null); + generation.close(); + } + } + + private Result render(RenderGeneration generation) throws InterruptedException { + Request request = generation.request(); + long started = System.nanoTime(); + int outputWidth = Math.max(1, (request.width() + request.sampleStep() - 1) / request.sampleStep()); + int outputHeight = Math.max(1, (request.height() + request.sampleStep() - 1) / request.sampleStep()); + BufferedImage image = new BufferedImage(outputWidth, outputHeight, BufferedImage.TYPE_INT_RGB); + int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + int bandCount = Math.min(workerCount, outputHeight); + BandStats[] bandStats = new BandStats[bandCount]; + CountDownLatch completion = new CountDownLatch(bandCount); + AtomicReference failure = new AtomicReference<>(); + for (int band = 0; band < bandCount; band++) { + bandStats[band] = new BandStats(); + } + for (int band = 0; band < bandCount; band++) { + int bandIndex = band; + Runnable task = () -> { + try { + renderBand(request, image, pixels, bandIndex, bandCount, bandStats[bandIndex]); + } catch (Throwable error) { + failure.compareAndSet(null, error); + } finally { + completion.countDown(); + } + }; + try { + generation.executor().execute(task); + } catch (RejectedExecutionException exception) { + failure.compareAndSet(null, exception); + completion.countDown(); + } + } + completion.await(); + Throwable renderFailure = failure.get(); + if (renderFailure != null) { + if (renderFailure instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (renderFailure instanceof Error error) { + throw error; + } + throw new IllegalStateException("Noise rendering failed", renderFailure); + } + if (!isCurrent(request)) { + return null; + } + RenderStats combined = combine(bandStats); + double milliseconds = (System.nanoTime() - started) / 1_000_000D; + return new Result(request, image, milliseconds, combined.samples, combined.minimum, combined.maximum, + combined.centerValue, combined.underflow, combined.overflow, combined.invalid); + } + + private void renderBand(Request request, BufferedImage image, int[] pixels, int bandIndex, int bandCount, + BandStats stats) { + int outputWidth = image.getWidth(); + int outputHeight = image.getHeight(); + int fromY = (outputHeight * bandIndex) / bandCount; + int toY = (outputHeight * (bandIndex + 1)) / bandCount; + double sampleOffset = request.sampleStep() * 0.5D; + double startWorldX = request.viewport().worldX(sampleOffset, request.width()); + double worldStep = request.viewport().blocksPerPixel() * request.sampleStep(); + NoisePalette palette = request.palette(); + double paletteMinimum = palette.minimum(); + double paletteMaximum = palette.maximum(); + for (int y = fromY; y < toY; y++) { + double screenY = (y * (double) request.sampleStep()) + sampleOffset; + double worldZ = request.viewport().worldZ(screenY, request.height()); + double worldX = startWorldX; + int pixelIndex = y * outputWidth; + boolean centerRow = y == outputHeight / 2; + for (int x = 0; x < outputWidth; x++) { + if ((x & 31) == 0 && (!isCurrent(request) || Thread.currentThread().isInterrupted())) { + return; + } + double value = request.sampler().noise(worldX, worldZ); + boolean center = centerRow && x == outputWidth / 2; + if (Double.isFinite(value)) { + pixels[pixelIndex++] = palette.colorFinite(value); + stats.acceptFinite(value, center, paletteMinimum, paletteMaximum); + } else { + pixels[pixelIndex++] = NoisePalette.INVALID_COLOR; + stats.acceptInvalid(value, center); + } + worldX += worldStep; + } + } + } + + private boolean isCurrent(Request request) { + return !closed.get() + && latestRequested.get() != null + && latestRequested.get().request() == request + && request.revision() >= latestRevision.get(); + } + + private void cancelActiveRender() { + RenderGeneration generation = latestRequested.getAndSet(null); + if (generation != null) { + generation.close(); + } + } + + private RenderGeneration createGeneration(Request request) { + ThreadPoolExecutor executor = new ThreadPoolExecutor( + workerCount, + workerCount, + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(Math.max(1, workerCount)), + workerThreadFactory, + new ThreadPoolExecutor.AbortPolicy() + ); + PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class); + if (preservation != null) { + preservation.register(executor); + } + RenderGeneration generation = new RenderGeneration(request, executor); + Thread actualRunner = coordinatorThreadFactory.newThread(() -> renderGeneration(generation)); + generation.setRunner(actualRunner); + if (preservation != null) { + preservation.register(actualRunner); + } + return generation; + } + + private static RenderStats combine(BandStats[] bands) { + long samples = 0L; + long underflow = 0L; + long overflow = 0L; + long invalid = 0L; + double minimum = Double.POSITIVE_INFINITY; + double maximum = Double.NEGATIVE_INFINITY; + double centerValue = Double.NaN; + for (BandStats band : bands) { + samples += band.samples; + underflow += band.underflow; + overflow += band.overflow; + invalid += band.invalid; + minimum = Math.min(minimum, band.minimum); + maximum = Math.max(maximum, band.maximum); + if (!Double.isNaN(band.centerValue)) { + centerValue = band.centerValue; + } + } + if (minimum == Double.POSITIVE_INFINITY) { + minimum = Double.NaN; + maximum = Double.NaN; + } + return new RenderStats(samples, minimum, maximum, centerValue, underflow, overflow, invalid); + } + + interface Listener { + void onRenderStarted(Request request); + + void onRenderCompleted(Result result); + + void onRenderFailed(Request request, Throwable error); + } + + record Request(long revision, NoiseProvider sampler, NoiseViewport viewport, NoisePalette palette, + int width, int height, int sampleStep) { + Request { + Objects.requireNonNull(sampler, "sampler"); + Objects.requireNonNull(viewport, "viewport"); + Objects.requireNonNull(palette, "palette"); + if (revision < 0L || width < 1 || height < 1 || sampleStep < 1) { + throw new IllegalArgumentException("Invalid noise render request"); + } + } + } + + record Result(Request request, BufferedImage image, double milliseconds, long samples, double minimum, + double maximum, double centerValue, long underflow, long overflow, long invalid) { + } + + private static final class RenderGeneration { + private final Request request; + private final ThreadPoolExecutor executor; + private volatile Thread runner; + + private RenderGeneration(Request request, ThreadPoolExecutor executor) { + this.request = Objects.requireNonNull(request, "request"); + this.executor = Objects.requireNonNull(executor, "executor"); + } + + private Request request() { + return request; + } + + private ThreadPoolExecutor executor() { + return executor; + } + + private void setRunner(Thread runner) { + this.runner = Objects.requireNonNull(runner, "runner"); + } + + private void start() { + runner.start(); + } + + private void close() { + Thread activeRunner = runner; + if (activeRunner != null) { + activeRunner.interrupt(); + } + executor.shutdownNow(); + } + } + + private static final class BandStats { + private long samples; + private long underflow; + private long overflow; + private long invalid; + private double minimum = Double.POSITIVE_INFINITY; + private double maximum = Double.NEGATIVE_INFINITY; + private double centerValue = Double.NaN; + + private void acceptFinite(double value, boolean center, double paletteMinimum, double paletteMaximum) { + samples++; + if (center) { + centerValue = value; + } + minimum = Math.min(minimum, value); + maximum = Math.max(maximum, value); + if (value < paletteMinimum) { + underflow++; + } else if (value > paletteMaximum) { + overflow++; + } + } + + private void acceptInvalid(double value, boolean center) { + samples++; + invalid++; + if (center) { + centerValue = value; + } + } + } + + private record RenderStats(long samples, double minimum, double maximum, double centerValue, + long underflow, long overflow, long invalid) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/gui/NoiseViewport.java b/core/src/main/java/art/arcane/iris/core/gui/NoiseViewport.java new file mode 100644 index 000000000..9f33853f0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/NoiseViewport.java @@ -0,0 +1,44 @@ +package art.arcane.iris.core.gui; + +record NoiseViewport(double centerX, double centerZ, double blocksPerPixel) { + static final double MIN_BLOCKS_PER_PIXEL = 0.0001D; + static final double MAX_BLOCKS_PER_PIXEL = 1_000_000D; + + NoiseViewport { + if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) { + throw new IllegalArgumentException("Viewport center must be finite"); + } + if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) { + throw new IllegalArgumentException("Viewport scale must be finite and positive"); + } + } + + double worldX(double screenX, int width) { + return centerX + ((screenX - (width / 2D)) * blocksPerPixel); + } + + double worldZ(double screenZ, int height) { + return centerZ + ((screenZ - (height / 2D)) * blocksPerPixel); + } + + NoiseViewport panPixels(double deltaX, double deltaZ) { + return new NoiseViewport( + centerX - (deltaX * blocksPerPixel), + centerZ - (deltaZ * blocksPerPixel), + blocksPerPixel + ); + } + + NoiseViewport zoomAt(double screenX, double screenZ, int width, int height, double factor) { + if (!Double.isFinite(factor) || factor <= 0D) { + throw new IllegalArgumentException("Zoom factor must be finite and positive"); + } + double anchorX = worldX(screenX, width); + double anchorZ = worldZ(screenZ, height); + double nextScale = Math.max(MIN_BLOCKS_PER_PIXEL, + Math.min(MAX_BLOCKS_PER_PIXEL, blocksPerPixel * factor)); + double nextCenterX = anchorX - ((screenX - (width / 2D)) * nextScale); + double nextCenterZ = anchorZ - ((screenZ - (height / 2D)) * nextScale); + return new NoiseViewport(nextCenterX, nextCenterZ, nextScale); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java index 6aac2efa7..43d470249 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java @@ -20,38 +20,32 @@ package art.arcane.iris.core.gui; import art.arcane.iris.core.localization.DesktopUiMessages; import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.render.IrisRenderer; import art.arcane.iris.engine.framework.render.RenderType; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.framework.PreservationRegistry; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisServices; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KMap; -import art.arcane.volmlib.util.collection.KSet; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.math.BlockPosition; -import art.arcane.volmlib.util.math.M; -import art.arcane.volmlib.util.math.RollingSequence; import art.arcane.volmlib.util.localization.MessageArgument; -import art.arcane.volmlib.util.scheduling.ChronoLatch; -import art.arcane.iris.util.common.scheduling.J; -import art.arcane.volmlib.util.scheduling.O; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; +import art.arcane.volmlib.util.localization.MessageKey; import javax.swing.BorderFactory; -import javax.swing.ButtonGroup; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JButton; +import javax.swing.JComboBox; import javax.swing.JFrame; import javax.swing.JLabel; +import javax.swing.JList; import javax.swing.JPanel; -import javax.swing.JSeparator; import javax.swing.JToggleButton; -import javax.swing.SwingConstants; +import javax.swing.Timer; import javax.swing.event.MouseInputListener; import java.awt.BorderLayout; import java.awt.Color; +import java.awt.Component; import java.awt.Dimension; import java.awt.EventQueue; import java.awt.FlowLayout; @@ -60,6 +54,8 @@ import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Point; import java.awt.RenderingHints; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; import java.awt.event.KeyEvent; import java.awt.event.KeyListener; import java.awt.event.MouseEvent; @@ -70,919 +66,1154 @@ import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.awt.geom.RoundRectangle2D; import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.Objects; -public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener, MouseMotionListener, MouseInputListener { +public final class VisionGUI extends JPanel implements MouseWheelListener, KeyListener, MouseMotionListener, MouseInputListener { private static final long serialVersionUID = 2094606939770332040L; - private static final Color BG = new Color(18, 18, 22); - private static final Color CARD_BG = new Color(28, 28, 36, 220); - private static final Color CARD_BORDER = new Color(60, 60, 75, 180); - private static final Color TEXT_PRIMARY = new Color(220, 220, 230); - private static final Color TEXT_SECONDARY = new Color(140, 140, 155); - private static final Color ACCENT = new Color(90, 140, 255); - private static final Color PLAYER_COLOR = new Color(80, 200, 120); - private static final Color MOB_COLOR = new Color(220, 80, 80); - private static final Color STATUS_BG = new Color(24, 24, 30, 240); - private static final Color GRID_COLOR = new Color(255, 255, 255, 12); - private static final Font FONT_STATUS = new Font(Font.MONOSPACED, Font.PLAIN, 12); - private static final Font FONT_CARD_TITLE = new Font(Font.SANS_SERIF, Font.BOLD, 13); - private static final Font FONT_CARD_BODY = new Font(Font.SANS_SERIF, Font.PLAIN, 12); - private static final Font FONT_HELP_KEY = new Font(Font.MONOSPACED, Font.BOLD, 12); - private static final Font FONT_NOTIFICATION = new Font(Font.SANS_SERIF, Font.BOLD, 14); - private static final int CARD_RADIUS = 8; - private static final int CARD_PAD = 12; - private static final int STATUS_HEIGHT = 26; + private static final Color BACKGROUND = new Color(15, 17, 22); + private static final Color TOOLBAR_BACKGROUND = new Color(23, 25, 31); + private static final Color CONTROL_BACKGROUND = new Color(31, 34, 42); + private static final Color CONTROL_SELECTED = new Color(50, 64, 91); + private static final Color CARD_BACKGROUND = new Color(25, 28, 35, 232); + private static final Color CARD_BORDER = new Color(69, 74, 88, 210); + private static final Color TEXT_PRIMARY = new Color(229, 231, 237); + private static final Color TEXT_SECONDARY = new Color(155, 161, 174); + private static final Color ACCENT = new Color(96, 146, 244); + private static final Color PLAYER_COLOR = new Color(81, 201, 128); + private static final Color MOB_COLOR = new Color(227, 94, 98); + private static final Color STATUS_BACKGROUND = new Color(21, 23, 29, 246); + private static final Color GRID_MINOR = new Color(255, 255, 255, 18); + private static final Color GRID_MAJOR = new Color(255, 255, 255, 38); + private static final Color TILE_EMPTY_A = new Color(24, 27, 34); + private static final Color TILE_EMPTY_B = new Color(27, 30, 38); + private static final Font STATUS_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12); + private static final Font BODY_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12); + private static final Font BODY_BOLD_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 12); + private static final Font TOOLBAR_FONT = new Font(Font.SANS_SERIF, Font.PLAIN, 12); + private static final Font KEY_FONT = new Font(Font.MONOSPACED, Font.BOLD, 12); + private static final int STATUS_HEIGHT = 28; + private static final int PROGRESS_HEIGHT = 3; + private static final int CARD_RADIUS = 10; + private static final int CARD_PADDING = 12; + private static final double DEFAULT_BLOCKS_PER_PIXEL = 4D; + private static final double WHEEL_ZOOM_EXPONENT = 0.08D; + private static final double KEYBOARD_ZOOM_FACTOR = 1.189207115002721D; - private final KList lastEntities = new KList<>(); - private final KMap notifications = new KMap<>(); - private final ChronoLatch centities = new ChronoLatch(1000); - private final RollingSequence rs = new RollingSequence(512); - private final O m = new O<>(); - private final KMap positions = new KMap<>(); - private final KMap fastpositions = new KMap<>(); - private final KSet working = new KSet<>(); - private final KSet workingfast = new KSet<>(); + private final JFrame hostFrame; + private final VisionRenderController controller; + private final Runnable hotloadHook; + private final Timer resizeTimer; + private final Timer dragRenderTimer; + private final Timer zoomRenderTimer; + private final Timer hoverTimer; + private final Timer markerTimer; + private final Timer notificationTimer; + private final Map notifications; - private RenderType currentType = RenderType.BIOME; - private boolean help = true; - private boolean helpIgnored = false; - private boolean shift = false; - private boolean debug = false; - private boolean control = false; - private boolean eco = false; - private boolean lowtile = false; - private boolean follow = false; - private boolean alt = false; - private boolean grid = false; - private GuiMarker followMarker = null; + private Engine engine; private IrisRenderer renderer; private GuiOverlay overlay; - private double velocity = 0; - private int lowq = 12; - private double scale = 128; - private double mscale = 4D; - private int w = 0; - private int h = 0; - private double lx = 0; - private double lz = 0; - private double ox = 0; - private double oz = 0; - private double hx = 0; - private double hz = 0; - private double oxp = 0; - private double ozp = 0; - private Engine engine; - private int tid = 0; - private Map modeButtons; + private VisionRenderController.Frame renderFrame; + private JComboBox modeSelector; + private JToggleButton gridToggle; + private JToggleButton followToggle; + private RenderType currentType; + private List players; + private List entities; + private HoverInfo hoverInfo; + private Point cursor; + private double centerX; + private double centerZ; + private double dragX; + private double dragY; + private double heightMaximum; + private double fluidHeight; + private long contentRevision; + private long previousPaintStartedNanos; + private double blocksPerPixel; + private int paintCadenceFps; + private boolean grid; + private boolean follow; + private boolean help; + private boolean debug; + private boolean detailedHover; + private boolean controlsUpdating; + private boolean closed; - private final ExecutorService e = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), r -> { - tid++; - Thread t = new Thread(r); - t.setName("Iris HD Renderer " + tid); - t.setPriority(Thread.MIN_PRIORITY); - t.setUncaughtExceptionHandler((et, ex) -> ex.printStackTrace()); - return t; - }); + private VisionGUI(JFrame hostFrame, Engine engine) { + this.hostFrame = Objects.requireNonNull(hostFrame, "hostFrame"); + this.engine = Objects.requireNonNull(engine, "engine"); + this.renderer = new IrisRenderer(engine); + this.overlay = GuiHost.get().overlayFor(engine); + this.controller = new VisionRenderController(this::repaint); + this.hotloadHook = () -> EventQueue.invokeLater(this::refreshContent); + this.notifications = new LinkedHashMap<>(); + this.players = List.of(); + this.entities = List.of(); + this.currentType = RenderType.BIOME; + this.blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL; + this.contentRevision = 1L; + captureHeightRange(); - private final ExecutorService eh = Executors.newFixedThreadPool(3, r -> { - tid++; - Thread t = new Thread(r); - t.setName("Iris Renderer " + tid); - t.setPriority(Thread.NORM_PRIORITY); - t.setUncaughtExceptionHandler((et, ex) -> ex.printStackTrace()); - return t; - }); - - public VisionGUI(JFrame frame) { - PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class); - if (preservation != null) { - preservation.register(e); - preservation.register(eh); - } - m.set(8); - rs.put(1); - setBackground(BG); + setBackground(BACKGROUND); + setFocusable(true); addMouseWheelListener(this); addMouseMotionListener(this); addMouseListener(this); - frame.addKeyListener(this); - J.a(() -> { - J.sleep(10000); - if (!helpIgnored && help) { - help = false; - } - }); - frame.addWindowListener(new WindowAdapter() { + hostFrame.addKeyListener(this); + + this.resizeTimer = singleShotTimer(90, this::requestRender); + this.dragRenderTimer = singleShotTimer(35, this::requestRender); + this.zoomRenderTimer = singleShotTimer(110, this::requestRender); + this.hoverTimer = singleShotTimer(90, this::requestHover); + this.markerTimer = new Timer(650, event -> refreshMarkers()); + this.markerTimer.setCoalesce(true); + this.notificationTimer = new Timer(200, event -> expireNotifications()); + this.notificationTimer.setCoalesce(true); + + addComponentListener(new ComponentAdapter() { @Override - public void windowClosed(WindowEvent windowEvent) { - e.shutdownNow(); - eh.shutdownNow(); + public void componentResized(ComponentEvent event) { + resizeTimer.restart(); + repaint(); } }); + hostFrame.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent event) { + close(); + } + + @Override + public void windowClosed(WindowEvent event) { + close(); + } + }); + GuiHost.get().registerHotloadHook(hotloadHook); + markerTimer.start(); + notificationTimer.start(); } - public static void launch(Engine g) { - EventQueue.invokeLater(() -> createAndShowGUI(g)); + public static void launch(Engine engine) { + EventQueue.invokeLater(() -> createAndShowGUI(engine)); } - private static void createAndShowGUI(Engine r) { + private static void createAndShowGUI(Engine engine) { JFrame frame = new JFrame(IrisLanguage.plain(DesktopUiMessages.VISION_TITLE)); GuiHost.prepareFrame(frame); - VisionGUI nv = new VisionGUI(frame); - nv.engine = r; - nv.overlay = GuiHost.get().overlayFor(r); - nv.renderer = new IrisRenderer(r); - frame.getContentPane().setBackground(BG); + VisionGUI vision = new VisionGUI(frame, engine); + frame.getContentPane().setBackground(BACKGROUND); frame.setLayout(new BorderLayout()); - frame.add(buildToolbar(nv), BorderLayout.NORTH); - frame.add(nv, BorderLayout.CENTER); + frame.add(buildToolbar(vision), BorderLayout.NORTH); + frame.add(vision, BorderLayout.CENTER); frame.setSize(1440, 820); - frame.setMinimumSize(new Dimension(640, 480)); + frame.setMinimumSize(new Dimension(720, 500)); frame.setLocationRelativeTo(null); frame.setVisible(true); + EventQueue.invokeLater(() -> { + vision.requestFocusInWindow(); + vision.refreshMarkers(); + vision.requestRender(); + }); } - private static JPanel buildToolbar(VisionGUI nv) { - JPanel toolbar = new JPanel(new FlowLayout(FlowLayout.LEFT, 3, 3)); - toolbar.setBackground(new Color(22, 22, 28)); - toolbar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, new Color(45, 45, 55))); + private static JPanel buildToolbar(VisionGUI vision) { + JPanel toolbar = new JPanel(new BorderLayout()); + toolbar.setBackground(TOOLBAR_BACKGROUND); + toolbar.setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, CARD_BORDER)); - JLabel modeLabel = new JLabel(IrisLanguage.plain(DesktopUiMessages.VISION_VIEW)); - modeLabel.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 11)); - modeLabel.setForeground(TEXT_SECONDARY); - modeLabel.setBorder(BorderFactory.createEmptyBorder(0, 6, 0, 2)); - toolbar.add(modeLabel); + JPanel leading = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 5)); + leading.setOpaque(false); + JLabel viewLabel = new JLabel(IrisLanguage.plain(DesktopUiMessages.VISION_VIEW)); + viewLabel.setFont(BODY_BOLD_FONT); + viewLabel.setForeground(TEXT_SECONDARY); + leading.add(viewLabel); - ButtonGroup modeGroup = new ButtonGroup(); - Map modeButtons = new LinkedHashMap<>(); - for (RenderType type : RenderType.values()) { - JToggleButton btn = createToolbarToggle(modeName(type), type == nv.currentType); - btn.addActionListener(e -> { - nv.setRenderType(type); - for (Map.Entry entry : modeButtons.entrySet()) { - entry.getValue().setSelected(entry.getKey() == type); - } - }); - modeGroup.add(btn); - modeButtons.put(type, btn); - toolbar.add(btn); - } - nv.modeButtons = modeButtons; + JComboBox selector = new JComboBox<>(RenderType.values()); + selector.setSelectedItem(vision.currentType); + selector.setFont(TOOLBAR_FONT); + selector.setForeground(TEXT_PRIMARY); + selector.setBackground(CONTROL_BACKGROUND); + selector.setFocusable(false); + selector.setRenderer(new ModeCellRenderer()); + selector.addActionListener(event -> { + if (!vision.controlsUpdating && selector.getSelectedItem() instanceof RenderType type) { + vision.setRenderType(type); + } + }); + vision.modeSelector = selector; + leading.add(selector); - toolbar.add(createToolbarSeparator()); - - JToggleButton gridBtn = createToolbarToggle(IrisLanguage.plain(DesktopUiMessages.VISION_GRID), nv.grid); - gridBtn.addActionListener(e -> { nv.toggleGrid(); gridBtn.setSelected(nv.grid); }); - toolbar.add(gridBtn); - - JToggleButton followBtn = createToolbarToggle(IrisLanguage.plain(DesktopUiMessages.VISION_FOLLOW), nv.follow); - followBtn.addActionListener(e -> { nv.toggleFollow(); followBtn.setSelected(nv.follow); }); - toolbar.add(followBtn); - - JToggleButton qualityBtn = createToolbarToggle(IrisLanguage.plain(DesktopUiMessages.VISION_LOW_QUALITY_SHORT), nv.lowtile); - qualityBtn.addActionListener(e -> { nv.toggleQuality(); qualityBtn.setSelected(nv.lowtile); }); - toolbar.add(qualityBtn); + JButton refresh = new JButton(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_REFRESH)); + styleButton(refresh); + refresh.addActionListener(event -> vision.refreshContent()); + leading.add(refresh); + toolbar.add(leading, BorderLayout.WEST); + JPanel trailing = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 5)); + trailing.setOpaque(false); + vision.gridToggle = createToggle(IrisLanguage.plain(DesktopUiMessages.VISION_GRID), vision.grid); + vision.gridToggle.addActionListener(event -> vision.toggleGrid()); + trailing.add(vision.gridToggle); + vision.followToggle = createToggle(IrisLanguage.plain(DesktopUiMessages.VISION_FOLLOW), vision.follow); + vision.followToggle.addActionListener(event -> vision.toggleFollow()); + trailing.add(vision.followToggle); + toolbar.add(trailing, BorderLayout.EAST); return toolbar; } - private static JToggleButton createToolbarToggle(String text, boolean selected) { - JToggleButton btn = new JToggleButton(text, selected); - btn.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 11)); - btn.setFocusable(false); - btn.setForeground(new Color(170, 170, 185)); - btn.setBackground(new Color(32, 32, 40)); - btn.setBorder(BorderFactory.createCompoundBorder( - BorderFactory.createLineBorder(new Color(50, 50, 60)), - BorderFactory.createEmptyBorder(3, 8, 3, 8) + private static void styleButton(JButton button) { + button.setFont(TOOLBAR_FONT); + button.setForeground(TEXT_PRIMARY); + button.setBackground(CONTROL_BACKGROUND); + button.setFocusable(false); + button.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(CARD_BORDER), + BorderFactory.createEmptyBorder(4, 9, 4, 9) )); - btn.setOpaque(true); - btn.addChangeListener(e -> { - if (btn.isSelected()) { - btn.setBackground(new Color(50, 60, 85)); - btn.setForeground(Color.WHITE); - } else { - btn.setBackground(new Color(32, 32, 40)); - btn.setForeground(new Color(170, 170, 185)); + } + + private static JToggleButton createToggle(String text, boolean selected) { + JToggleButton toggle = new JToggleButton(text, selected); + toggle.setFont(TOOLBAR_FONT); + toggle.setForeground(selected ? TEXT_PRIMARY : TEXT_SECONDARY); + toggle.setBackground(selected ? CONTROL_SELECTED : CONTROL_BACKGROUND); + toggle.setFocusable(false); + toggle.setOpaque(true); + toggle.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(selected ? ACCENT : CARD_BORDER), + BorderFactory.createEmptyBorder(4, 9, 4, 9) + )); + toggle.addChangeListener(event -> updateToggleStyle(toggle)); + return toggle; + } + + private static void updateToggleStyle(JToggleButton toggle) { + toggle.setForeground(toggle.isSelected() ? TEXT_PRIMARY : TEXT_SECONDARY); + toggle.setBackground(toggle.isSelected() ? CONTROL_SELECTED : CONTROL_BACKGROUND); + toggle.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(toggle.isSelected() ? ACCENT : CARD_BORDER), + BorderFactory.createEmptyBorder(4, 9, 4, 9) + )); + } + + private static Timer singleShotTimer(int delay, Runnable task) { + Timer timer = new Timer(delay, event -> task.run()); + timer.setRepeats(false); + timer.setCoalesce(true); + return timer; + } + + @Override + protected void paintComponent(Graphics graphics) { + long started = System.nanoTime(); + if (previousPaintStartedNanos > 0L) { + long interval = started - previousPaintStartedNanos; + paintCadenceFps = interval > 2_000_000_000L + ? 0 + : (int) Math.min(240L, Math.round(1_000_000_000D / Math.max(1L, interval))); + } + previousPaintStartedNanos = started; + super.paintComponent(graphics); + Graphics2D canvas = (Graphics2D) graphics.create(); + try { + canvas.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + canvas.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + renderTiles(canvas); + if (grid) { + renderGrid(canvas); } - }); - return btn; + renderMarkers(canvas); + renderLegend(canvas); + if (help) { + renderHelp(canvas); + } else if (debug) { + renderDebug(canvas); + } + renderHover(canvas); + renderNotifications(canvas); + renderStatus(canvas); + } catch (Throwable error) { + IrisLogging.debug("Vision paint failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); + } finally { + canvas.dispose(); + } } - private static JSeparator createToolbarSeparator() { - JSeparator sep = new JSeparator(SwingConstants.VERTICAL); - sep.setPreferredSize(new Dimension(1, 24)); - sep.setForeground(new Color(50, 50, 60)); - sep.setBackground(new Color(22, 22, 28)); - return sep; + private void renderTiles(Graphics2D canvas) { + VisionRenderController.Frame frame = renderFrame; + if (frame == null) { + canvas.setColor(BACKGROUND); + canvas.fillRect(0, 0, getWidth(), getHeight()); + return; + } + + canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + double currentBlocksPerPixel = blocksPerPixel(); + double frameTileSpan = VisionRenderController.TILE_PIXELS * frame.spec().blocksPerPixel(); + for (VisionRenderController.VisibleTile tile : frame.tiles()) { + double worldX = tile.tileX() * frameTileSpan; + double worldZ = tile.tileZ() * frameTileSpan; + int screenX = (int) Math.round(getWidth() * 0.5D + (worldX - centerX) / currentBlocksPerPixel); + int screenY = (int) Math.round(getHeight() * 0.5D + (worldZ - centerZ) / currentBlocksPerPixel); + int screenEndX = (int) Math.round( + getWidth() * 0.5D + (worldX + frameTileSpan - centerX) / currentBlocksPerPixel); + int screenEndY = (int) Math.round( + getHeight() * 0.5D + (worldZ + frameTileSpan - centerZ) / currentBlocksPerPixel); + int tileWidth = Math.max(1, screenEndX - screenX); + int tileHeight = Math.max(1, screenEndY - screenY); + canvas.setColor(((tile.tileX() ^ tile.tileZ()) & 1L) == 0L ? TILE_EMPTY_A : TILE_EMPTY_B); + canvas.fillRect(screenX, screenY, tileWidth, tileHeight); + BufferedImage image = controller.image(frame, tile); + if (image != null) { + canvas.drawImage(image, screenX, screenY, tileWidth, tileHeight, null); + } + } } - public boolean updateEngine() { - if (engine.isClosed() || engine.isClosing() || engine.getComplex() == null) { - try { - Engine reacquired = GuiHost.get().findActiveEngine(); - if (reacquired != null && !reacquired.isClosed()) { - engine = reacquired; - overlay = GuiHost.get().overlayFor(reacquired); - renderer = new IrisRenderer(reacquired); - return true; + private void renderGrid(Graphics2D canvas) { + double blocksPerPixel = blocksPerPixel(); + double chunkPixels = 16D / blocksPerPixel; + if (chunkPixels >= 8D) { + canvas.setColor(GRID_MINOR); + drawWorldGrid(canvas, 16D, chunkPixels); + } + canvas.setColor(GRID_MAJOR); + drawWorldGrid(canvas, VisionRenderController.TILE_PIXELS * blocksPerPixel, VisionRenderController.TILE_PIXELS); + } + + private void drawWorldGrid(Graphics2D canvas, double blockSpacing, double pixelSpacing) { + double leftWorld = screenToWorldX(0D); + double topWorld = screenToWorldZ(0D); + double firstX = Math.floor(leftWorld / blockSpacing) * blockSpacing; + double firstZ = Math.floor(topWorld / blockSpacing) * blockSpacing; + double firstScreenX = worldToScreenX(firstX); + double firstScreenZ = worldToScreenZ(firstZ); + for (double screen = firstScreenX; screen <= getWidth(); screen += pixelSpacing) { + int screenX = (int) Math.round(screen); + canvas.drawLine(screenX, 0, screenX, getHeight() - STATUS_HEIGHT); + } + for (double screen = firstScreenZ; screen <= getHeight(); screen += pixelSpacing) { + int screenY = (int) Math.round(screen); + canvas.drawLine(0, screenY, getWidth(), screenY); + } + } + + private void renderMarkers(Graphics2D canvas) { + for (GuiMarker marker : entities) { + int screenX = (int) Math.round(worldToScreenX(marker.worldX())); + int screenY = (int) Math.round(worldToScreenZ(marker.worldZ())); + canvas.setColor(MOB_COLOR); + canvas.fillRect(screenX - 2, screenY - 2, 5, 5); + } + + for (GuiMarker marker : players) { + int screenX = (int) Math.round(worldToScreenX(marker.worldX())); + int screenY = (int) Math.round(worldToScreenZ(marker.worldZ())); + canvas.setColor(new Color(81, 201, 128, 48)); + canvas.fillOval(screenX - 12, screenY - 12, 24, 24); + canvas.setColor(PLAYER_COLOR); + canvas.fillOval(screenX - 5, screenY - 5, 10, 10); + canvas.setFont(BODY_FONT); + canvas.setColor(TEXT_PRIMARY); + int labelWidth = canvas.getFontMetrics().stringWidth(marker.label()); + canvas.drawString(marker.label(), screenX - labelWidth / 2, screenY - 14); + } + if (detailedHover) { + renderNearestEntity(canvas); + } + } + + private void renderNearestEntity(Graphics2D canvas) { + Point point = cursor; + if (point == null || entities.isEmpty()) { + return; + } + double worldX = screenToWorldX(point.x); + double worldZ = screenToWorldZ(point.y); + GuiMarker nearest = null; + double nearestDistance = Double.MAX_VALUE; + for (GuiMarker marker : entities) { + double deltaX = marker.worldX() - worldX; + double deltaZ = marker.worldZ() - worldZ; + double distance = deltaX * deltaX + deltaZ * deltaZ; + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = marker; + } + } + if (nearest == null) { + return; + } + ArrayList lines = new ArrayList<>(); + lines.add(nearest.label()); + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_ENTITY_POSITION, + MessageArgument.trusted("x", (int) nearest.worldX()), + MessageArgument.trusted("y", (int) nearest.worldY()), + MessageArgument.trusted("z", (int) nearest.worldZ()) + )); + if (nearest.maxHealth() > 0D) { + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_ENTITY_HEALTH, + MessageArgument.trusted("health", Form.f(nearest.health(), 1)), + MessageArgument.trusted("maximum", Form.f(nearest.maxHealth(), 1)) + )); + } + drawCard(canvas, getWidth() - CARD_PADDING, CARD_PADDING, 1D, 0D, lines); + } + + private void renderLegend(Graphics2D canvas) { + if (currentType == RenderType.RIVER) { + renderRiverLegend(canvas); + } else if (currentType == RenderType.HEIGHT) { + renderHeightLegend(canvas); + } + } + + private void renderRiverLegend(Graphics2D canvas) { + RiverSection[] sections = RiverSection.values(); + int lineHeight = 18; + int width = 148; + int height = sections.length * lineHeight + CARD_PADDING * 2; + int x = getWidth() - width - CARD_PADDING; + int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING; + drawCardBackground(canvas, x, y, width, height); + canvas.setFont(BODY_FONT); + for (int index = 0; index < sections.length; index++) { + RiverSection section = sections[index]; + int rowY = y + CARD_PADDING + index * lineHeight; + canvas.setColor(new Color(IrisRenderer.riverColor(section))); + canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4); + canvas.setColor(TEXT_SECONDARY); + String label = section.name().toLowerCase(Locale.ROOT).replace('_', ' '); + canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13); + } + } + + private void renderHeightLegend(Graphics2D canvas) { + int width = 244; + int height = 54; + int x = getWidth() - width - CARD_PADDING; + int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING; + drawCardBackground(canvas, x, y, width, height); + int gradientX = x + CARD_PADDING; + int gradientY = y + CARD_PADDING; + int gradientWidth = width - CARD_PADDING * 2; + for (int pixel = 0; pixel < gradientWidth; pixel++) { + double sampleHeight = heightMaximum * pixel / Math.max(1D, gradientWidth - 1D); + canvas.setColor(new Color(IrisRenderer.heightColor(sampleHeight, heightMaximum, fluidHeight))); + canvas.drawLine(gradientX + pixel, gradientY, gradientX + pixel, gradientY + 14); + } + canvas.setColor(CARD_BORDER); + canvas.drawRect(gradientX, gradientY, gradientWidth, 14); + canvas.setFont(STATUS_FONT); + canvas.setColor(TEXT_SECONDARY); + canvas.drawString("0", gradientX, gradientY + 32); + String fluid = Integer.toString((int) Math.round(fluidHeight)); + int fluidX = gradientX + (int) Math.round(gradientWidth * fluidHeight / Math.max(1D, heightMaximum)); + canvas.drawString(fluid, Math.max(gradientX, fluidX - canvas.getFontMetrics().stringWidth(fluid) / 2), gradientY + 32); + String maximum = Integer.toString((int) Math.round(heightMaximum)); + canvas.drawString(maximum, gradientX + gradientWidth - canvas.getFontMetrics().stringWidth(maximum), gradientY + 32); + } + + private void renderStatus(Graphics2D canvas) { + VisionRenderController.Frame frame = renderFrame; + VisionRenderController.Progress progress = controller.progress(frame); + int statusY = getHeight() - STATUS_HEIGHT; + int progressY = statusY - PROGRESS_HEIGHT; + canvas.setColor(CONTROL_BACKGROUND); + canvas.fillRect(0, progressY, getWidth(), PROGRESS_HEIGHT); + canvas.setColor(ACCENT); + canvas.fillRect(0, progressY, (int) Math.round(getWidth() * progress.completion()), PROGRESS_HEIGHT); + canvas.setColor(STATUS_BACKGROUND); + canvas.fillRect(0, statusY, getWidth(), STATUS_HEIGHT); + canvas.setColor(CARD_BORDER); + canvas.drawLine(0, statusY, getWidth(), statusY); + canvas.setFont(STATUS_FONT); + canvas.setColor(TEXT_SECONDARY); + + String left = IrisLanguage.plain( + DesktopUiMessages.VISION_STATUS_LEFT, + MessageArgument.trusted("mode", modeName(currentType)), + MessageArgument.trusted("bpp", Form.f(blocksPerPixel(), 1)), + MessageArgument.trusted("width", Form.f((int) (blocksPerPixel() * getWidth()))), + MessageArgument.trusted("height", Form.f((int) (blocksPerPixel() * Math.max(0, getHeight() - STATUS_HEIGHT)))) + ); + canvas.drawString(left, 9, statusY + 19); + + String right = IrisLanguage.plain( + DesktopUiMessages.VISION_STATUS_RIGHT, + MessageArgument.trusted("x", Form.f((int) centerX)), + MessageArgument.trusted("z", Form.f((int) centerZ)), + MessageArgument.trusted("fps", paintCadenceFps) + ); + int rightWidth = canvas.getFontMetrics().stringWidth(right); + canvas.drawString(right, getWidth() - rightWidth - 9, statusY + 19); + + String middle = progress.ready() + "/" + progress.total() + " exact " + + progress.active() + "+" + progress.queued(); + int middleWidth = canvas.getFontMetrics().stringWidth(middle); + if (middleWidth + canvas.getFontMetrics().stringWidth(left) + rightWidth + 54 < getWidth()) { + canvas.drawString(middle, (getWidth() - middleWidth) / 2, statusY + 19); + } + } + + private void renderHover(Graphics2D canvas) { + HoverInfo info = hoverInfo; + if (info == null) { + return; + } + ArrayList lines = new ArrayList<>(); + lines.add(info.biomeName()); + lines.add(info.regionName()); + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_BLOCK_POSITION, + MessageArgument.trusted("x", (int) info.worldX()), + MessageArgument.trusted("z", (int) info.worldZ()) + )); + if (detailedHover) { + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_CHUNK_POSITION, + MessageArgument.trusted("x", (int) info.worldX() >> 4), + MessageArgument.trusted("z", (int) info.worldZ() >> 4) + )); + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_REGION_POSITION, + MessageArgument.trusted("x", (int) info.worldX() >> 9), + MessageArgument.trusted("z", (int) info.worldZ() >> 9) + )); + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_BIOME_KEY, + MessageArgument.untrusted("key", info.biomeKey()) + )); + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_BIOME_FILE, + MessageArgument.untrusted("file", info.biomeFile()) + )); + } + int screenX = (int) Math.round(worldToScreenX(info.worldX())) + 16; + int screenY = (int) Math.round(worldToScreenZ(info.worldZ())); + drawCard(canvas, screenX, screenY, 0D, 0D, lines); + } + + private void renderHelp(Graphics2D canvas) { + List keys = List.of("/", "R", "F", "+/-", "\\", "M", "G", "Shift", "Ctrl+Click", "Alt+Click"); + List descriptions = List.of( + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_TOGGLE), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_REFRESH), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_FOLLOW), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_ZOOM), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_RESET_ZOOM), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_CYCLE_MODE), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_GRID), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_BIOME), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_TELEPORT), + IrisLanguage.plain(DesktopUiMessages.VISION_HELP_EDITOR) + ); + int keyWidth = 0; + canvas.setFont(KEY_FONT); + for (String key : keys) { + keyWidth = Math.max(keyWidth, canvas.getFontMetrics().stringWidth(key)); + } + int lineHeight = 20; + int width = keyWidth + 206; + int height = keys.size() * lineHeight + CARD_PADDING * 2; + drawCardBackground(canvas, CARD_PADDING, CARD_PADDING, width, height); + for (int index = 0; index < keys.size(); index++) { + int y = CARD_PADDING * 2 + 14 + index * lineHeight; + canvas.setFont(KEY_FONT); + canvas.setColor(ACCENT); + canvas.drawString(keys.get(index), CARD_PADDING * 2, y); + canvas.setFont(BODY_FONT); + canvas.setColor(TEXT_SECONDARY); + canvas.drawString(descriptions.get(index), CARD_PADDING * 2 + keyWidth + 14, y); + } + } + + private void renderDebug(Graphics2D canvas) { + VisionRenderController.Progress progress = controller.progress(renderFrame); + ArrayList lines = new ArrayList<>(); + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_TILES, + MessageArgument.trusted("ready", progress.ready()), + MessageArgument.trusted("total", progress.total()) + )); + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_WORKERS, + MessageArgument.trusted("active", progress.active()), + MessageArgument.trusted("queued", progress.queued()) + )); + lines.add(IrisLanguage.plain( + DesktopUiMessages.VISION_CENTER, + MessageArgument.trusted("x", Form.f((int) centerX)), + MessageArgument.trusted("z", Form.f((int) centerZ)) + )); + drawCard(canvas, CARD_PADDING, getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - CARD_PADDING, 0D, 1D, lines); + } + + private void renderNotifications(Graphics2D canvas) { + if (notifications.isEmpty()) { + return; + } + String text = String.join(" · ", notifications.keySet()); + canvas.setFont(BODY_BOLD_FONT); + int textWidth = canvas.getFontMetrics().stringWidth(text); + int width = textWidth + CARD_PADDING * 2; + int height = 36; + int x = (getWidth() - width) / 2; + int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - 14; + drawCardBackground(canvas, x, y, width, height); + canvas.setColor(TEXT_PRIMARY); + canvas.drawString(text, x + CARD_PADDING, y + 23); + } + + private void drawCard(Graphics2D canvas, int anchorX, int anchorY, double pushX, double pushY, List lines) { + canvas.setFont(BODY_FONT); + int lineHeight = canvas.getFontMetrics().getHeight(); + int width = 0; + for (String line : lines) { + width = Math.max(width, canvas.getFontMetrics().stringWidth(line)); + } + width += CARD_PADDING * 2; + int height = lines.size() * lineHeight + CARD_PADDING * 2 - 2; + int x = (int) Math.round(anchorX - width * pushX); + int y = (int) Math.round(anchorY - height * pushY); + x = Math.max(CARD_PADDING, Math.min(getWidth() - width - CARD_PADDING, x)); + y = Math.max(CARD_PADDING, Math.min(getHeight() - STATUS_HEIGHT - height - CARD_PADDING, y)); + drawCardBackground(canvas, x, y, width, height); + for (int index = 0; index < lines.size(); index++) { + canvas.setFont(index == 0 ? BODY_BOLD_FONT : BODY_FONT); + canvas.setColor(index == 0 ? TEXT_PRIMARY : TEXT_SECONDARY); + canvas.drawString(lines.get(index), x + CARD_PADDING, y + CARD_PADDING + lineHeight - 3 + index * lineHeight); + } + } + + private void drawCardBackground(Graphics2D canvas, int x, int y, int width, int height) { + RoundRectangle2D card = new RoundRectangle2D.Double(x, y, width, height, CARD_RADIUS, CARD_RADIUS); + canvas.setColor(CARD_BACKGROUND); + canvas.fill(card); + canvas.setColor(CARD_BORDER); + canvas.draw(card); + } + + private void requestRender() { + if (closed || getWidth() < 1 || getHeight() < 1 || !ensureEngine()) { + return; + } + hoverInfo = null; + VisionRenderController.RenderSpec spec = new VisionRenderController.RenderSpec( + renderer, + currentType, + contentRevision, + centerX, + centerZ, + blocksPerPixel, + getWidth(), + getHeight() + ); + renderFrame = controller.request(spec); + repaint(); + } + + private boolean ensureEngine() { + Engine activeEngine = engine; + if (activeEngine != null + && !activeEngine.isClosed() + && !activeEngine.isClosing() + && activeEngine.getComplex() != null) { + return true; + } + try { + Engine reacquired = GuiHost.get().findActiveEngine(); + if (reacquired == null || reacquired.isClosed() || reacquired.isClosing() || reacquired.getComplex() == null) { + return false; + } + engine = reacquired; + renderer = new IrisRenderer(reacquired); + overlay = GuiHost.get().overlayFor(reacquired); + contentRevision++; + captureHeightRange(); + return true; + } catch (Throwable error) { + IrisLogging.debug("Vision engine reacquisition failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); + return false; + } + } + + private void refreshContent() { + if (closed) { + return; + } + if (!ensureEngine()) { + notifyUser("No active Iris engine"); + return; + } + renderer = new IrisRenderer(engine); + overlay = GuiHost.get().overlayFor(engine); + contentRevision++; + captureHeightRange(); + requestRender(); + notifyUser(IrisLanguage.plain(DesktopUiMessages.VISION_REFRESHING)); + } + + private void captureHeightRange() { + heightMaximum = Math.max(1D, engine.getHeight()); + IrisDimension dimension = engine.getDimension(); + fluidHeight = dimension == null + ? 0D + : Math.max(0D, Math.min(heightMaximum, dimension.getFluidHeight())); + } + + private void refreshMarkers() { + if (closed || overlay == null) { + return; + } + try { + List nextPlayers = overlay.players(); + players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers); + overlay.requestEntities(next -> EventQueue.invokeLater(() -> { + if (!closed) { + entities = next == null ? List.of() : List.copyOf(next); + repaint(); } - } catch (Throwable ignored) { - } - } - return false; - } - - @Override - public void mouseMoved(MouseEvent e) { - Point cp = e.getPoint(); - lx = cp.getX(); - lz = cp.getY(); - } - - @Override - public void mouseDragged(MouseEvent e) { - Point cp = e.getPoint(); - ox += (lx - cp.getX()) * scale; - oz += (lz - cp.getY()) * scale; - lx = cp.getX(); - lz = cp.getY(); - } - - public void notify(String s) { - notifications.put(s, M.ms() + 2500); - } - - @Override - public void keyTyped(KeyEvent e) { - } - - @Override - public void keyPressed(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_SHIFT) shift = true; - if (e.getKeyCode() == KeyEvent.VK_CONTROL) control = true; - if (e.getKeyCode() == KeyEvent.VK_SEMICOLON) debug = true; - if (e.getKeyCode() == KeyEvent.VK_SLASH) { help = true; helpIgnored = true; } - if (e.getKeyCode() == KeyEvent.VK_ALT) alt = true; - } - - @Override - public void keyReleased(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_SEMICOLON) debug = false; - if (e.getKeyCode() == KeyEvent.VK_SHIFT) shift = false; - if (e.getKeyCode() == KeyEvent.VK_CONTROL) control = false; - if (e.getKeyCode() == KeyEvent.VK_SLASH) { help = false; helpIgnored = true; } - if (e.getKeyCode() == KeyEvent.VK_ALT) alt = false; - - if (e.getKeyCode() == KeyEvent.VK_F) { toggleFollow(); return; } - if (e.getKeyCode() == KeyEvent.VK_R) { dump(); notify(IrisLanguage.plain(DesktopUiMessages.VISION_REFRESHING)); return; } - if (e.getKeyCode() == KeyEvent.VK_P) { toggleQuality(); return; } - if (e.getKeyCode() == KeyEvent.VK_E) { eco = !eco; dump(); notify(IrisLanguage.plain(DesktopUiMessages.VISION_FPS, MessageArgument.trusted("fps", eco ? 30 : 60))); return; } - if (e.getKeyCode() == KeyEvent.VK_G) { toggleGrid(); return; } - - if (e.getKeyCode() == KeyEvent.VK_EQUALS) { - mscale = mscale + ((0.044 * mscale) * -3); - mscale = Math.max(mscale, 0.00001); - dump(); - return; - } - if (e.getKeyCode() == KeyEvent.VK_MINUS) { - mscale = mscale + ((0.044 * mscale) * 3); - mscale = Math.max(mscale, 0.00001); - dump(); - return; - } - if (e.getKeyCode() == KeyEvent.VK_BACK_SLASH) { - mscale = 1D; - dump(); - notify(IrisLanguage.plain(DesktopUiMessages.VISION_ZOOM_RESET)); - return; - } - - int currentMode = currentType.ordinal(); - for (RenderType i : RenderType.values()) { - if (e.getKeyChar() == String.valueOf(i.ordinal() + 1).charAt(0)) { - if (i.ordinal() != currentMode) { - setRenderType(i); - syncModeButtons(); - return; + })); + if (follow && !players.isEmpty()) { + GuiMarker player = players.get(0); + if (Math.abs(centerX - player.worldX()) > 0.5D || Math.abs(centerZ - player.worldZ()) > 0.5D) { + centerX = player.worldX(); + centerZ = player.worldZ(); + requestRender(); } } + repaint(); + } catch (Throwable error) { + IrisLogging.debug("Vision marker refresh failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); } + } - if (e.getKeyCode() == KeyEvent.VK_M) { - setRenderType(RenderType.values()[(currentMode + 1) % RenderType.values().length]); - syncModeButtons(); + private void requestHover() { + Point point = cursor; + VisionRenderController.Frame frame = renderFrame; + Engine probeEngine = engine; + if (closed || point == null || frame == null || probeEngine == null || probeEngine.getComplex() == null) { + return; } + double worldX = screenToWorldX(point.x); + double worldZ = screenToWorldZ(point.y); + controller.submitProbe( + frame, + () -> sampleHover(probeEngine, worldX, worldZ), + info -> { + hoverInfo = info; + repaint(); + } + ); + } + + private static HoverInfo sampleHover(Engine engine, double worldX, double worldZ) { + IrisBiome biome = engine.getComplex().getBaseBiomeStream().get(worldX, worldZ); + IrisRegion region = engine.getComplex().getRegionStream().get(worldX, worldZ); + return new HoverInfo( + worldX, + worldZ, + biome.getName(), + region.getName(), + biome.getLoadKey(), + String.valueOf(biome.getLoadFile()) + ); + } + + private void setRenderType(RenderType type) { + if (type == null || type == currentType) { + return; + } + currentType = type; + syncControls(); + requestRender(); + notifyUser(modeName(type)); + } + + private void toggleGrid() { + grid = !grid; + syncControls(); + repaint(); + notifyUser(IrisLanguage.plain(grid ? DesktopUiMessages.VISION_GRID_ENABLED : DesktopUiMessages.VISION_GRID_DISABLED)); + } + + private void toggleFollow() { + follow = !follow; + if (follow && players.isEmpty()) { + follow = false; + notifyUser(IrisLanguage.plain(DesktopUiMessages.VISION_NO_PLAYER)); + } else if (follow) { + GuiMarker player = players.get(0); + centerX = player.worldX(); + centerZ = player.worldZ(); + requestRender(); + notifyUser(IrisLanguage.plain( + DesktopUiMessages.VISION_FOLLOWING, + MessageArgument.untrusted("player", player.label()) + )); + } else { + notifyUser(IrisLanguage.plain(DesktopUiMessages.VISION_FOLLOW_DISABLED)); + } + syncControls(); + } + + private void syncControls() { + controlsUpdating = true; + try { + if (modeSelector != null) { + modeSelector.setSelectedItem(currentType); + } + if (gridToggle != null) { + gridToggle.setSelected(grid); + } + if (followToggle != null) { + followToggle.setSelected(follow); + } + } finally { + controlsUpdating = false; + } + } + + private void notifyUser(String message) { + notifications.put(message, System.currentTimeMillis() + 2500L); + repaint(); + } + + private void expireNotifications() { + long now = System.currentTimeMillis(); + boolean changed = false; + Iterator> iterator = notifications.entrySet().iterator(); + while (iterator.hasNext()) { + if (iterator.next().getValue() <= now) { + iterator.remove(); + changed = true; + } + } + if (changed) { + repaint(); + } + } + + private void changeZoom(double factor, Point anchor) { + if (!Double.isFinite(factor) || factor <= 0D) { + return; + } + Point effectiveAnchor = anchor == null ? new Point(getWidth() / 2, getHeight() / 2) : anchor; + VisionViewport next = new VisionViewport(centerX, centerZ, blocksPerPixel).zoomAt( + effectiveAnchor.x, + effectiveAnchor.y, + getWidth(), + getHeight(), + factor, + VisionRenderController.MINIMUM_BLOCKS_PER_PIXEL, + VisionRenderController.MAXIMUM_BLOCKS_PER_PIXEL + ); + if (next.blocksPerPixel() == blocksPerPixel) { + return; + } + centerX = next.centerX(); + centerZ = next.centerZ(); + blocksPerPixel = next.blocksPerPixel(); + follow = false; + hoverInfo = null; + syncControls(); + zoomRenderTimer.restart(); + repaint(); + } + + private double blocksPerPixel() { + return blocksPerPixel; + } + + private double screenToWorldX(double screenX) { + return centerX + (screenX - getWidth() * 0.5D) * blocksPerPixel(); + } + + private double screenToWorldZ(double screenY) { + return centerZ + (screenY - getHeight() * 0.5D) * blocksPerPixel(); + } + + private double worldToScreenX(double worldX) { + return getWidth() * 0.5D + (worldX - centerX) / blocksPerPixel(); + } + + private double worldToScreenZ(double worldZ) { + return getHeight() * 0.5D + (worldZ - centerZ) / blocksPerPixel(); + } + + private void openAtCursor() { + GuiOverlay activeOverlay = overlay; + Point point = cursor; + VisionRenderController.Frame frame = renderFrame; + if (activeOverlay == null || point == null || frame == null) { + return; + } + double worldX = screenToWorldX(point.x); + double worldZ = screenToWorldZ(point.y); + RenderType type = currentType; + controller.submitProbe( + frame, + () -> activeOverlay.openInEditor(worldX, worldZ, type), + opened -> { + if (opened != null) { + notifyUser(IrisLanguage.plain( + DesktopUiMessages.VISION_OPENED, + MessageArgument.untrusted("target", opened) + )); + } + } + ); + } + + private void teleportToCursor() { + Point point = cursor; + if (overlay == null || point == null) { + notifyUser(IrisLanguage.plain(DesktopUiMessages.VISION_NO_PLAYER)); + return; + } + int worldX = (int) screenToWorldX(point.x); + int worldZ = (int) screenToWorldZ(point.y); + overlay.teleport(worldX, worldZ); + notifyUser(IrisLanguage.plain( + DesktopUiMessages.VISION_TELEPORTING, + MessageArgument.trusted("x", worldX), + MessageArgument.trusted("z", worldZ) + )); + } + + private void close() { + if (closed) { + return; + } + closed = true; + resizeTimer.stop(); + dragRenderTimer.stop(); + zoomRenderTimer.stop(); + hoverTimer.stop(); + markerTimer.stop(); + notificationTimer.stop(); + GuiHost.get().unregisterHotloadHook(hotloadHook); + hostFrame.removeKeyListener(this); + controller.close(); } private static String modeName(RenderType type) { - return IrisLanguage.plain(switch (type) { + return IrisLanguage.plain(modeKey(type)); + } + + static MessageKey modeKey(RenderType type) { + return switch (type) { case BIOME -> DesktopUiMessages.VISION_MODE_BIOME; case BIOME_LAND -> DesktopUiMessages.VISION_MODE_BIOME_LAND; case BIOME_SEA -> DesktopUiMessages.VISION_MODE_BIOME_SEA; case REGION -> DesktopUiMessages.VISION_MODE_REGION; case CAVE_LAND -> DesktopUiMessages.VISION_MODE_CAVE_LAND; + case RIVER -> DesktopUiMessages.VISION_MODE_RIVER; case HEIGHT -> DesktopUiMessages.VISION_MODE_HEIGHT; case OBJECT_LOAD -> DesktopUiMessages.VISION_MODE_OBJECT_LOAD; case DECORATOR_LOAD -> DesktopUiMessages.VISION_MODE_DECORATOR_LOAD; case CONTINENT -> DesktopUiMessages.VISION_MODE_CONTINENT; case LAYER_LOAD -> DesktopUiMessages.VISION_MODE_LAYER_LOAD; - }); - } - - void setRenderType(RenderType type) { - currentType = type; - dump(); - notify(modeName(type)); - } - - void toggleGrid() { - grid = !grid; - notify(IrisLanguage.plain(grid ? DesktopUiMessages.VISION_GRID_ENABLED : DesktopUiMessages.VISION_GRID_DISABLED)); - } - - void toggleFollow() { - follow = !follow; - if (followMarker != null && follow) { - notify(IrisLanguage.plain(DesktopUiMessages.VISION_FOLLOWING, MessageArgument.untrusted("player", followMarker.label()))); - } else if (follow) { - notify(IrisLanguage.plain(DesktopUiMessages.VISION_NO_PLAYER)); - follow = false; - } else { - notify(IrisLanguage.plain(DesktopUiMessages.VISION_FOLLOW_DISABLED)); - } - } - - void toggleQuality() { - lowtile = !lowtile; - dump(); - notify(IrisLanguage.plain(lowtile ? DesktopUiMessages.VISION_LOW_QUALITY : DesktopUiMessages.VISION_HIGH_QUALITY)); - } - - private void syncModeButtons() { - if (modeButtons == null) return; - for (Map.Entry entry : modeButtons.entrySet()) { - entry.getValue().setSelected(entry.getKey() == currentType); - } - } - - private void dump() { - positions.clear(); - fastpositions.clear(); - } - - public BufferedImage getTile(KSet fg, int div, int x, int z, O m) { - BlockPosition key = new BlockPosition((int) mscale, Math.floorDiv(x, div), Math.floorDiv(z, div)); - fg.add(key); - - if (positions.containsKey(key)) { - return positions.get(key); - } - - if (fastpositions.containsKey(key)) { - if (!working.contains(key) && working.size() < 9) { - m.set(m.get() - 1); - if (m.get() >= 0 && velocity < 50) { - working.add(key); - double mk = mscale; - double mkd = scale; - e.submit(() -> { - // finally: a render throw is swallowed by the discarded Future, and a - // leaked key permanently blocks the admission gate (working < 9). - try { - PrecisionStopwatch ps = PrecisionStopwatch.start(); - BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / (lowtile ? 3 : 1), currentType); - rs.put(ps.getMilliseconds()); - if (mk == mscale && mkd == scale) { - positions.put(key, b); - } - } catch (Throwable ex) { - IrisLogging.debug("Vision tile render failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); - } finally { - working.remove(key); - } - }); - } - } - return fastpositions.get(key); - } - - if (workingfast.contains(key) || workingfast.size() > Runtime.getRuntime().availableProcessors()) { - return null; - } - - workingfast.add(key); - double mk = mscale; - double mkd = scale; - eh.submit(() -> { - try { - PrecisionStopwatch ps = PrecisionStopwatch.start(); - BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / lowq, currentType); - rs.put(ps.getMilliseconds()); - if (mk == mscale && mkd == scale) { - fastpositions.put(key, b); - } - } catch (Throwable ex) { - IrisLogging.debug("Vision tile render failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage()); - } finally { - workingfast.remove(key); - } - }); - return null; - } - - private double getWorldX(double screenX) { - return (mscale * screenX) + ((oxp / scale) * mscale); - } - - private double getWorldZ(double screenZ) { - return (mscale * screenZ) + ((ozp / scale) * mscale); - } - - private double getScreenX(double x) { - return (x / mscale) - (oxp / scale); - } - - private double getScreenZ(double z) { - return (z / mscale) - (ozp / scale); - } - - private double lerp(double current, double target, double speed) { - double diff = target - current; - if (Math.abs(diff) < 0.5) return target; - return current + diff * speed; + }; } @Override - public void paint(Graphics gx) { - // Cover the closing window too: releaseRuntime nulls the complex BEFORE closed flips, - // and a paint NPE in that window killed the self-driven repaint loop forever. - if ((engine.isClosed() || engine.isClosing() || engine.getComplex() == null) && !updateEngine()) { - EventQueue.invokeLater(() -> { - try { setVisible(false); } catch (Throwable ignored) { } - }); - return; - } - double frameMs = 0; - try { - frameMs = paintBody(gx); - } catch (Throwable e) { - IrisLogging.debug("Vision paint failed: " + e.getClass().getSimpleName() + ": " + e.getMessage()); - } finally { - // The repaint loop is entirely self-driven from here; it must survive any render - // exception or the window freezes on a stale frame permanently. Frame-time - // compensated so a slow frame does not stack a full sleep on top of itself. - long sleepMs = Math.max(1, (eco ? 32 : 16) - (long) frameMs); - J.a(() -> { - J.sleep(sleepMs); - repaint(); - }); - } - } - - private double paintBody(Graphics gx) { - - velocity = Math.abs(ox - oxp) * 0.36 + Math.abs(oz - ozp) * 0.36; - oxp = lerp(oxp, ox, 0.36); - ozp = lerp(ozp, oz, 0.36); - hx = lerp(hx, lx, 0.36); - hz = lerp(hz, lz, 0.36); - - if (centities.flip() && overlay != null) { - overlay.requestEntities((List next) -> { - synchronized (lastEntities) { - lastEntities.clear(); - if (next != null) { - lastEntities.addAll(next); - } - } - }); - } - - lowq = Math.max(Math.min((int) M.lerp(8, 28, velocity / 1000D), 28), 8); - PrecisionStopwatch p = PrecisionStopwatch.start(); - Graphics2D g = (Graphics2D) gx; - g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB); - g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); - - w = getWidth(); - h = getHeight(); - double vscale = scale; - scale = w / 12D; - - if (scale != vscale) { - positions.clear(); - } - - KSet gg = new KSet<>(); - int iscale = (int) scale; - g.setColor(BG); - g.fillRect(0, 0, w, h); - double offsetX = oxp / scale; - double offsetZ = ozp / scale; - m.set(3); - - for (int r = 0; r < Math.max(w, h); r += iscale) { - for (int i = -iscale; i < w + iscale; i += iscale) { - for (int j = -iscale; j < h + iscale; j += iscale) { - int a = i - (w / 2); - int b = j - (h / 2); - if (a * a + b * b <= r * r) { - int tx = (int) (Math.floor((offsetX + i) / iscale) * iscale); - int tz = (int) (Math.floor((offsetZ + j) / iscale) * iscale); - BufferedImage t = getTile(gg, iscale, tx, tz, m); - - if (t != null) { - int rx = Math.floorMod((int) Math.floor(offsetX), iscale); - int rz = Math.floorMod((int) Math.floor(offsetZ), iscale); - g.drawImage(t, i - rx, j - rz, iscale, iscale, null); - } - } - } - } - } - - if (grid) { - renderGrid(g, iscale, offsetX, offsetZ); - } - - p.end(); - - for (BlockPosition i : positions.k()) { - if (!gg.contains(i)) { - positions.remove(i); - } - } - - // Bounded, not pruned-to-frame: the low-quality cache is the pan placeholder, but - // unbounded growth while panning leaked one BufferedImage per visited tile forever. - if (fastpositions.size() > 4096) { - for (BlockPosition i : fastpositions.k()) { - if (!gg.contains(i)) { - fastpositions.remove(i); - } - } - } - - handleFollow(); - renderOverlays(g, p.getMilliseconds()); - return p.getMilliseconds(); - } - - private void renderGrid(Graphics2D g, int tileSize, double offsetX, double offsetZ) { - g.setColor(GRID_COLOR); - int rx = Math.floorMod((int) Math.floor(offsetX), tileSize); - int rz = Math.floorMod((int) Math.floor(offsetZ), tileSize); - for (int i = -tileSize; i < w + tileSize; i += tileSize) { - g.drawLine(i - rx, 0, i - rx, h); - } - for (int j = -tileSize; j < h + tileSize; j += tileSize) { - g.drawLine(0, j - rz, w, j - rz); - } - } - - private void handleFollow() { - if (follow && followMarker != null) { - animateTo(followMarker.worldX(), followMarker.worldZ()); - } - } - - private void renderOverlays(Graphics2D g, double frameMs) { - renderEntities(g); - - if (help) { - renderOverlayHelp(g); - } else if (debug) { - renderOverlayDebug(g); - } - - renderStatusBar(g, frameMs); - renderHoverOverlay(g, shift); - - if (!notifications.isEmpty()) { - renderNotification(g); - } - } - - private void renderStatusBar(Graphics2D g, double frameMs) { - int y = h - STATUS_HEIGHT; - g.setColor(STATUS_BG); - g.fillRect(0, y, w, STATUS_HEIGHT); - g.setColor(CARD_BORDER); - g.drawLine(0, y, w, y); - - g.setFont(FONT_STATUS); - g.setColor(TEXT_SECONDARY); - - double wx = getWorldX(w / 2.0); - double wz = getWorldZ(h / 2.0); - int fps = frameMs > 0 ? (int) (1000.0 / frameMs) : 0; - - String left = IrisLanguage.plain( - DesktopUiMessages.VISION_STATUS_LEFT, - MessageArgument.trusted("mode", modeName(currentType)), - MessageArgument.trusted("bpp", Form.f(mscale, 1)), - MessageArgument.trusted("width", Form.f((int) (mscale * w))), - MessageArgument.trusted("height", Form.f((int) (mscale * h))) - ); - g.drawString(left, 8, y + 17); - - String right = IrisLanguage.plain( - DesktopUiMessages.VISION_STATUS_RIGHT, - MessageArgument.trusted("x", Form.f((int) wx)), - MessageArgument.trusted("z", Form.f((int) wz)), - MessageArgument.trusted("fps", fps) - ); - int rw = g.getFontMetrics().stringWidth(right); - g.drawString(right, w - rw - 8, y + 17); - - g.setColor(ACCENT); - g.fillRect(0, y + 1, 3, STATUS_HEIGHT - 1); - } - - private void renderEntities(Graphics2D g) { - GuiMarker firstPlayer = null; - - List players = overlay == null ? List.of() : overlay.players(); - if (players != null) { - for (GuiMarker i : players) { - firstPlayer = i; - renderPlayerMarker(g, i.worldX(), i.worldZ(), i.label()); - } - } - - synchronized (lastEntities) { - double dist = Double.MAX_VALUE; - GuiMarker nearest = null; - - for (GuiMarker i : lastEntities) { - renderMobMarker(g, i.worldX(), i.worldZ()); - if (shift) { - double dx = i.worldX() - getWorldX(hx); - double dz = i.worldZ() - getWorldZ(hz); - double d = dx * dx + dz * dz; - if (d < dist) { - dist = d; - nearest = i; - } - } - } - - if (nearest != null && shift) { - double sx = getScreenX(nearest.worldX()); - double sz = getScreenZ(nearest.worldZ()); - g.setColor(MOB_COLOR); - g.fillOval((int) sx - 6, (int) sz - 6, 12, 12); - g.setColor(new Color(220, 80, 80, 60)); - g.fillOval((int) sx - 10, (int) sz - 10, 20, 20); - - KList k = new KList<>(); - k.add(nearest.label()); - k.add(IrisLanguage.plain( - DesktopUiMessages.VISION_ENTITY_POSITION, - MessageArgument.trusted("x", (int) nearest.worldX()), - MessageArgument.trusted("y", (int) nearest.worldY()), - MessageArgument.trusted("z", (int) nearest.worldZ()) - )); - if (nearest.maxHealth() > 0) { - k.add(IrisLanguage.plain( - DesktopUiMessages.VISION_ENTITY_HEALTH, - MessageArgument.trusted("health", Form.f(nearest.health(), 1)), - MessageArgument.trusted("maximum", Form.f(nearest.maxHealth(), 1)) - )); - } - drawCard(w - CARD_PAD, CARD_PAD, 1, 0, g, k); - } - } - - followMarker = firstPlayer; - } - - private void renderPlayerMarker(Graphics2D g, double x, double z, String name) { - int sx = (int) getScreenX(x); - int sz = (int) getScreenZ(z); - g.setColor(new Color(80, 200, 120, 40)); - g.fillOval(sx - 12, sz - 12, 24, 24); - g.setColor(PLAYER_COLOR); - g.fillOval(sx - 5, sz - 5, 10, 10); - g.setColor(new Color(40, 160, 80)); - g.drawOval(sx - 5, sz - 5, 10, 10); - - g.setFont(FONT_CARD_BODY); - g.setColor(TEXT_PRIMARY); - int nw = g.getFontMetrics().stringWidth(name); - g.drawString(name, sx - nw / 2, sz - 14); - } - - private void renderMobMarker(Graphics2D g, double x, double z) { - int sx = (int) getScreenX(x); - int sz = (int) getScreenZ(z); - g.setColor(MOB_COLOR); - g.fillRect(sx - 2, sz - 2, 4, 4); - } - - private void animateTo(double wx, double wz) { - double cx = getWorldX(getWidth() / 2.0); - double cz = getWorldZ(getHeight() / 2.0); - ox += ((wx - cx) / mscale) * scale; - oz += ((wz - cz) / mscale) * scale; - } - - private void renderHoverOverlay(Graphics2D g, boolean detailed) { - IrisBiome biome = engine.getComplex().getTrueBiomeStream().get(getWorldX(hx), getWorldZ(hz)); - IrisRegion region = engine.getComplex().getRegionStream().get(getWorldX(hx), getWorldZ(hz)); - KList l = new KList<>(); - l.add(biome.getName()); - l.add(region.getName()); - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_BLOCK_POSITION, MessageArgument.trusted("x", (int) getWorldX(hx)), MessageArgument.trusted("z", (int) getWorldZ(hz)))); - if (detailed) { - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_CHUNK_POSITION, MessageArgument.trusted("x", (int) getWorldX(hx) >> 4), MessageArgument.trusted("z", (int) getWorldZ(hz) >> 4))); - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_REGION_POSITION, MessageArgument.trusted("x", (int) getWorldX(hx) >> 9), MessageArgument.trusted("z", (int) getWorldZ(hz) >> 9))); - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_BIOME_KEY, MessageArgument.untrusted("key", biome.getLoadKey()))); - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_BIOME_FILE, MessageArgument.untrusted("file", String.valueOf(biome.getLoadFile())))); - } - drawCard((float) hx + 16, (float) hz, 0, 0, g, l); - } - - private void renderOverlayDebug(Graphics2D g) { - KList l = new KList<>(); - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_VELOCITY, MessageArgument.trusted("velocity", (int) velocity))); - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_TILES, MessageArgument.trusted("high", positions.size()), MessageArgument.trusted("low", fastpositions.size()))); - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_WORKERS, MessageArgument.trusted("high", working.size()), MessageArgument.trusted("low", workingfast.size()))); - l.add(IrisLanguage.plain(DesktopUiMessages.VISION_CENTER, MessageArgument.trusted("x", Form.f((int) getWorldX(getWidth() / 2.0))), MessageArgument.trusted("z", Form.f((int) getWorldZ(getHeight() / 2.0))))); - drawCard(CARD_PAD, h - STATUS_HEIGHT - CARD_PAD, 0, 1, g, l); - } - - private void renderOverlayHelp(Graphics2D g) { - KList keys = new KList<>(); - KList descs = new KList<>(); - keys.add("/"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_TOGGLE)); - keys.add("R"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_REFRESH)); - keys.add("F"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_FOLLOW)); - keys.add("+/-"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_ZOOM)); - keys.add("\\"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_RESET_ZOOM)); - keys.add("M"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_CYCLE_MODE)); - keys.add("P"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_QUALITY)); - keys.add("E"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_FPS)); - keys.add("G"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_GRID)); - - int ff = 0; - for (RenderType i : RenderType.values()) { - ff++; - keys.add(String.valueOf(ff)); - descs.add(modeName(i)); - } - - keys.add("Shift"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_BIOME)); - keys.add("Ctrl+Click"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_TELEPORT)); - keys.add("Alt+Click"); descs.add(IrisLanguage.plain(DesktopUiMessages.VISION_HELP_EDITOR)); - - int maxKeyW = 0; - g.setFont(FONT_HELP_KEY); - for (String k : keys) { - maxKeyW = Math.max(maxKeyW, g.getFontMetrics().stringWidth(k)); - } - - int lineH = 20; - int totalH = keys.size() * lineH + CARD_PAD * 2 + 4; - int totalW = maxKeyW + 180 + CARD_PAD * 2; - - drawCardBackground(g, CARD_PAD, CARD_PAD, totalW, totalH); - - for (int i = 0; i < keys.size(); i++) { - int y = CARD_PAD + 16 + i * lineH; - - g.setFont(FONT_HELP_KEY); - g.setColor(ACCENT); - g.drawString(keys.get(i), CARD_PAD * 2, y); - - g.setFont(FONT_CARD_BODY); - g.setColor(TEXT_SECONDARY); - g.drawString(descs.get(i), CARD_PAD * 2 + maxKeyW + 16, y); - } - } - - private void renderNotification(Graphics2D g) { - int y = h - STATUS_HEIGHT - 50; - g.setFont(FONT_NOTIFICATION); - - KList active = new KList<>(); - for (String i : notifications.k()) { - if (M.ms() > notifications.get(i)) { - notifications.remove(i); - } else { - active.add(i); - } - } - - if (active.isEmpty()) return; - - String text = String.join(" | ", active); - int tw = g.getFontMetrics().stringWidth(text); - int th = g.getFontMetrics().getHeight(); - int px = (w - tw) / 2 - 16; - int py = y - th / 2 - 8; - int bw = tw + 32; - int bh = th + 16; - - drawCardBackground(g, px, py, bw, bh); - g.setColor(TEXT_PRIMARY); - g.drawString(text, px + 16, py + th + 4); - } - - private void drawCardBackground(Graphics2D g, int x, int y, int w, int h) { - RoundRectangle2D rect = new RoundRectangle2D.Double(x, y, w, h, CARD_RADIUS, CARD_RADIUS); - g.setColor(CARD_BG); - g.fill(rect); - g.setColor(CARD_BORDER); - g.draw(rect); - } - - private void drawCard(float x, float y, double pushX, double pushZ, Graphics2D g, KList text) { - g.setFont(FONT_CARD_BODY); - int lineH = g.getFontMetrics().getHeight(); - int cardW = 0; - for (String i : text) { - cardW = Math.max(cardW, g.getFontMetrics().stringWidth(i)); - } - cardW += CARD_PAD * 2; - int cardH = text.size() * lineH + CARD_PAD * 2 - 4; - - int cx = (int) (x - cardW * pushX); - int cy = (int) (y - cardH * pushZ); - - drawCardBackground(g, cx, cy, cardW, cardH); - - int ty = cy + CARD_PAD + lineH - 4; - for (int i = 0; i < text.size(); i++) { - g.setColor(i == 0 ? TEXT_PRIMARY : TEXT_SECONDARY); - g.setFont(i == 0 ? FONT_CARD_TITLE : FONT_CARD_BODY); - g.drawString(text.get(i), cx + CARD_PAD, ty + i * lineH); - } - } - - public void mouseWheelMoved(MouseWheelEvent e) { - int notches = e.getWheelRotation(); - if (e.isControlDown()) return; - - double m0 = mscale; - double m1 = m0 + ((0.25 * m0) * notches); - m1 = Math.max(m1, 0.00001); - if (m1 == m0) return; - - positions.clear(); - fastpositions.clear(); - - Point p = e.getPoint(); - double sx = p.getX(); - double sz = p.getY(); - - double newOxp = scale * ((m0 / m1) * (sx + (oxp / scale)) - sx); - double newOzp = scale * ((m0 / m1) * (sz + (ozp / scale)) - sz); - - mscale = m1; - oxp = newOxp; - ozp = newOzp; - ox = oxp; - oz = ozp; + public void mouseMoved(MouseEvent event) { + cursor = event.getPoint(); + hoverTimer.restart(); } @Override - public void mouseClicked(MouseEvent e) { - if (control) teleport(); - else if (alt) open(); + public void mouseDragged(MouseEvent event) { + cursor = event.getPoint(); + double deltaX = dragX - event.getX(); + double deltaY = dragY - event.getY(); + centerX += deltaX * blocksPerPixel(); + centerZ += deltaY * blocksPerPixel(); + dragX = event.getX(); + dragY = event.getY(); + follow = false; + hoverInfo = null; + syncControls(); + dragRenderTimer.restart(); + repaint(); } - @Override public void mousePressed(MouseEvent e) { } - @Override public void mouseReleased(MouseEvent e) { } - @Override public void mouseEntered(MouseEvent e) { } - @Override public void mouseExited(MouseEvent e) { } - - private void open() { - if (overlay == null) { - return; - } - String opened = overlay.openInEditor(getWorldX(hx), getWorldZ(hz), currentType); - if (opened != null) { - notify(IrisLanguage.plain(DesktopUiMessages.VISION_OPENED, MessageArgument.untrusted("target", opened))); + @Override + public void mouseWheelMoved(MouseWheelEvent event) { + if (!event.isControlDown()) { + cursor = event.getPoint(); + double factor = Math.pow(2D, event.getPreciseWheelRotation() * WHEEL_ZOOM_EXPONENT); + changeZoom(factor, cursor); + event.consume(); } } - private void teleport() { - if (overlay == null) { - notify(IrisLanguage.plain(DesktopUiMessages.VISION_NO_PLAYER)); + @Override + public void mouseClicked(MouseEvent event) { + cursor = event.getPoint(); + if (event.isControlDown()) { + teleportToCursor(); + } else if (event.isAltDown()) { + openAtCursor(); + } + } + + @Override + public void mousePressed(MouseEvent event) { + requestFocusInWindow(); + cursor = event.getPoint(); + dragX = event.getX(); + dragY = event.getY(); + } + + @Override + public void mouseReleased(MouseEvent event) { + if (dragRenderTimer.isRunning()) { + dragRenderTimer.stop(); + requestRender(); + } + } + + @Override + public void mouseEntered(MouseEvent event) { + cursor = event.getPoint(); + } + + @Override + public void mouseExited(MouseEvent event) { + cursor = null; + hoverInfo = null; + hoverTimer.stop(); + repaint(); + } + + @Override + public void keyPressed(KeyEvent event) { + if (event.getKeyCode() == KeyEvent.VK_SHIFT) { + detailedHover = true; + repaint(); + } else if (event.getKeyCode() == KeyEvent.VK_SEMICOLON) { + debug = true; + repaint(); + } + } + + @Override + public void keyReleased(KeyEvent event) { + int keyCode = event.getKeyCode(); + if (keyCode == KeyEvent.VK_SHIFT) { + detailedHover = false; + repaint(); return; } - int xx = (int) getWorldX(hx); - int zz = (int) getWorldZ(hz); - overlay.teleport(xx, zz); - notify(IrisLanguage.plain(DesktopUiMessages.VISION_TELEPORTING, MessageArgument.trusted("x", xx), MessageArgument.trusted("z", zz))); + if (keyCode == KeyEvent.VK_SEMICOLON) { + debug = false; + repaint(); + return; + } + if (keyCode == KeyEvent.VK_SLASH) { + help = !help; + repaint(); + return; + } + if (keyCode == KeyEvent.VK_R) { + refreshContent(); + return; + } + if (keyCode == KeyEvent.VK_F) { + toggleFollow(); + return; + } + if (keyCode == KeyEvent.VK_G) { + toggleGrid(); + return; + } + if (keyCode == KeyEvent.VK_EQUALS || keyCode == KeyEvent.VK_ADD) { + changeZoom(1D / KEYBOARD_ZOOM_FACTOR, cursor); + return; + } + if (keyCode == KeyEvent.VK_MINUS || keyCode == KeyEvent.VK_SUBTRACT) { + changeZoom(KEYBOARD_ZOOM_FACTOR, cursor); + return; + } + if (keyCode == KeyEvent.VK_BACK_SLASH) { + changeZoom(DEFAULT_BLOCKS_PER_PIXEL / blocksPerPixel, null); + notifyUser(IrisLanguage.plain(DesktopUiMessages.VISION_ZOOM_RESET)); + return; + } + if (keyCode == KeyEvent.VK_M) { + RenderType[] types = RenderType.values(); + setRenderType(types[(currentType.ordinal() + 1) % types.length]); + return; + } + + int modeIndex = modeIndex(keyCode); + RenderType[] types = RenderType.values(); + if (modeIndex >= 0 && modeIndex < types.length) { + setRenderType(types[modeIndex]); + } + } + + private static int modeIndex(int keyCode) { + if (keyCode >= KeyEvent.VK_1 && keyCode <= KeyEvent.VK_9) { + return keyCode - KeyEvent.VK_1; + } + if (keyCode == KeyEvent.VK_0) { + return 9; + } + if (keyCode == KeyEvent.VK_BACK_QUOTE) { + return 10; + } + return -1; + } + + @Override + public void keyTyped(KeyEvent event) { + } + + private record HoverInfo( + double worldX, + double worldZ, + String biomeName, + String regionName, + String biomeKey, + String biomeFile + ) { + } + + private static final class ModeCellRenderer extends DefaultListCellRenderer { + private static final long serialVersionUID = -3387015482484702284L; + + @Override + public Component getListCellRendererComponent( + JList list, + Object value, + int index, + boolean selected, + boolean focus + ) { + Component component = super.getListCellRendererComponent(list, value, index, selected, focus); + if (component instanceof JLabel label && value instanceof RenderType type) { + label.setText(modeName(type)); + label.setFont(TOOLBAR_FONT); + label.setForeground(selected ? TEXT_PRIMARY : TEXT_SECONDARY); + label.setBackground(selected ? CONTROL_SELECTED : CONTROL_BACKGROUND); + label.setBorder(BorderFactory.createEmptyBorder(3, 7, 3, 7)); + } + return component; + } } } diff --git a/core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java b/core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java new file mode 100644 index 000000000..9702a829b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java @@ -0,0 +1,626 @@ +/* + * Iris is a World Generator for Minecraft 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.core.gui; + +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.engine.framework.render.IrisRenderer; +import art.arcane.iris.engine.framework.render.RenderType; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; + +import java.awt.EventQueue; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +final class VisionRenderController implements AutoCloseable { + static final int TILE_PIXELS = 64; + static final double MINIMUM_BLOCKS_PER_PIXEL = 1D; + static final double MAXIMUM_BLOCKS_PER_PIXEL = 4_096D; + + private static final int MAXIMUM_WORKERS = 3; + private static final int MAXIMUM_VISIBLE_TILES = 32_768; + private static final int RENDER_QUEUE_CAPACITY = 12; + private static final int PROBE_QUEUE_CAPACITY = 1; + private static final long CACHE_BYTES = 64L * 1024L * 1024L; + + private final Runnable listener; + private final int renderWorkerCount; + private final ThreadPoolExecutor renderExecutor; + private final ThreadPoolExecutor probeExecutor; + private final WeightedTileCache cache; + private final AtomicLong viewSequence; + private final AtomicLong probeSequence; + private final AtomicBoolean closed; + private final AtomicBoolean publicationQueued; + private final AtomicBoolean publicationDirty; + private final Set> activeRenderTasks; + private volatile Frame currentFrame; + private volatile WorkState currentWork; + private volatile CancellationToken currentToken; + + VisionRenderController(Runnable listener) { + this(listener, RuntimeOptions.production()); + } + + VisionRenderController(Runnable listener, RuntimeOptions options) { + this.listener = Objects.requireNonNull(listener, "listener"); + Objects.requireNonNull(options, "options"); + AtomicInteger threadSequence = new AtomicInteger(); + this.renderWorkerCount = options.workers(); + this.renderExecutor = new ThreadPoolExecutor( + options.workers(), + options.workers(), + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(options.renderQueueCapacity()), + daemonFactory("Iris Vision Render", Thread.NORM_PRIORITY, threadSequence), + new ThreadPoolExecutor.AbortPolicy() + ); + this.probeExecutor = new ThreadPoolExecutor( + 1, + 1, + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(PROBE_QUEUE_CAPACITY), + daemonFactory("Iris Vision Probe", Thread.MIN_PRIORITY, threadSequence), + new ThreadPoolExecutor.DiscardOldestPolicy() + ); + this.cache = new WeightedTileCache(CACHE_BYTES); + this.viewSequence = new AtomicLong(); + this.probeSequence = new AtomicLong(); + this.closed = new AtomicBoolean(); + this.publicationQueued = new AtomicBoolean(); + this.publicationDirty = new AtomicBoolean(); + this.activeRenderTasks = ConcurrentHashMap.newKeySet(); + if (options.registerPreservation()) { + PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class); + if (preservation != null) { + preservation.register(renderExecutor); + preservation.register(probeExecutor); + } + } + } + + synchronized Frame request(RenderSpec spec) { + Objects.requireNonNull(spec, "spec"); + if (closed.get()) { + throw new IllegalStateException("Vision render controller is closed"); + } + + CancellationToken previousToken = currentToken; + if (previousToken != null) { + previousToken.cancel(); + } + renderExecutor.getQueue().clear(); + cancelActiveRenderTasks(); + probeSequence.incrementAndGet(); + probeExecutor.getQueue().clear(); + + List tiles = visibleTiles(spec); + Frame frame = new Frame(viewSequence.incrementAndGet(), spec, tiles); + for (VisibleTile tile : tiles) { + tile.setImage(cache.get(frame.key(tile))); + } + CancellationToken token = new CancellationToken(); + WorkState work = new WorkState(frame, token); + currentFrame = frame; + currentToken = token; + currentWork = work; + schedule(work); + publish(frame, token); + return frame; + } + + Frame currentFrame() { + return currentFrame; + } + + BufferedImage image(Frame frame, VisibleTile tile) { + if (frame == null || tile == null) { + return null; + } + return tile.image(); + } + + Progress progress(Frame frame) { + if (frame == null) { + return new Progress(0, 0, 0, 0); + } + int ready = 0; + for (VisibleTile tile : frame.tiles()) { + if (tile.image() != null) { + ready++; + } + } + return new Progress(frame.tiles().size(), ready, renderExecutor.getActiveCount(), renderExecutor.getQueue().size()); + } + + void submitProbe(Frame frame, Callable probe, Consumer consumer) { + Objects.requireNonNull(probe, "probe"); + Objects.requireNonNull(consumer, "consumer"); + if (frame == null || closed.get() || currentFrame != frame) { + return; + } + long probeRevision = probeSequence.incrementAndGet(); + probeExecutor.getQueue().clear(); + try { + probeExecutor.execute(() -> runProbe(frame, probeRevision, probe, consumer)); + } catch (RejectedExecutionException ignored) { + probeExecutor.getQueue().clear(); + } + } + + @Override + public synchronized void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + CancellationToken token = currentToken; + if (token != null) { + token.cancel(); + } + currentWork = null; + currentFrame = null; + renderExecutor.getQueue().clear(); + cancelActiveRenderTasks(); + probeExecutor.getQueue().clear(); + renderExecutor.shutdownNow(); + probeExecutor.shutdownNow(); + cache.clear(); + } + + static List visibleTiles(RenderSpec spec) { + Objects.requireNonNull(spec, "spec"); + double blocksPerPixel = spec.blocksPerPixel(); + double tileSpan = TILE_PIXELS * blocksPerPixel; + double halfWidth = spec.width() * blocksPerPixel * 0.5D; + double halfHeight = spec.height() * blocksPerPixel * 0.5D; + long minimumX = floorTile(spec.centerX() - halfWidth, tileSpan); + long maximumX = floorTile(Math.nextDown(spec.centerX() + halfWidth), tileSpan); + long minimumZ = floorTile(spec.centerZ() - halfHeight, tileSpan); + long maximumZ = floorTile(Math.nextDown(spec.centerZ() + halfHeight), tileSpan); + long tileCount = Math.multiplyExact(maximumX - minimumX + 1L, maximumZ - minimumZ + 1L); + if (tileCount > MAXIMUM_VISIBLE_TILES) { + throw new IllegalArgumentException("Vision viewport contains too many tiles"); + } + + ArrayList tiles = new ArrayList<>((int) tileCount); + double centerTileX = spec.centerX() / tileSpan; + double centerTileZ = spec.centerZ() / tileSpan; + for (long tileZ = minimumZ; ; tileZ++) { + for (long tileX = minimumX; ; tileX++) { + int screenX = (int) Math.round(spec.width() * 0.5D + (tileX * tileSpan - spec.centerX()) / blocksPerPixel); + int screenY = (int) Math.round(spec.height() * 0.5D + (tileZ * tileSpan - spec.centerZ()) / blocksPerPixel); + double deltaX = tileX + 0.5D - centerTileX; + double deltaZ = tileZ + 0.5D - centerTileZ; + tiles.add(new VisibleTile(tileX, tileZ, screenX, screenY, deltaX * deltaX + deltaZ * deltaZ)); + if (tileX == maximumX) { + break; + } + } + if (tileZ == maximumZ) { + break; + } + } + tiles.sort(Comparator.comparingDouble(VisibleTile::distanceSquared) + .thenComparingLong(VisibleTile::tileZ) + .thenComparingLong(VisibleTile::tileX)); + return List.copyOf(tiles); + } + + static long sampleCount(int tileCount) { + if (tileCount < 1) { + throw new IllegalArgumentException("Vision tile count must be positive"); + } + return Math.multiplyExact((long) tileCount, (long) TILE_PIXELS * TILE_PIXELS); + } + + private static long floorTile(double coordinate, double tileSpan) { + double tile = Math.floor(coordinate / tileSpan); + if (tile < Long.MIN_VALUE || tile > Long.MAX_VALUE) { + throw new IllegalArgumentException("Vision viewport exceeds tile coordinate range"); + } + return (long) tile; + } + + private static ThreadFactory daemonFactory(String name, int priority, AtomicInteger sequence) { + return (Runnable runnable) -> { + Thread thread = new Thread(runnable, name + " " + sequence.incrementAndGet()); + thread.setDaemon(true); + thread.setPriority(priority); + thread.setUncaughtExceptionHandler((Thread failedThread, Throwable error) -> IrisLogging.reportError(error)); + return thread; + }; + } + + private void schedule(WorkState work) { + synchronized (work) { + if (!isCurrent(work)) { + return; + } + int admissionLimit = work.frame().spec().type() == RenderType.RIVER ? 1 : renderWorkerCount; + while (work.inFlight() < admissionLimit) { + VisibleTile tile = work.nextMissing(); + if (tile == null) { + return; + } + work.incrementInFlight(); + TrackedRenderTask task = new TrackedRenderTask(() -> render(work, tile), activeRenderTasks); + activeRenderTasks.add(task); + try { + renderExecutor.execute(task); + } catch (RejectedExecutionException ignored) { + task.cancel(false); + work.decrementInFlight(); + return; + } + } + } + } + + private void render(WorkState work, VisibleTile tile) { + Frame frame = work.frame(); + CancellationToken token = work.token(); + try { + if (!isCurrent(frame, token)) { + return; + } + double tileSpan = TILE_PIXELS * frame.spec().blocksPerPixel(); + BufferedImage image = frame.spec().renderer().renderStudio( + tile.tileX() * tileSpan, + tile.tileZ() * tileSpan, + tileSpan, + TILE_PIXELS, + frame.spec().type(), + () -> !isCurrent(frame, token) + ); + if (!isCurrent(frame, token)) { + return; + } + cache.put(frame.key(tile), image); + tile.setImage(image); + publish(frame, token); + } catch (CancellationException ignored) { + } catch (Throwable error) { + IrisLogging.debug("Vision tile render failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); + } finally { + complete(work); + } + } + + private void complete(WorkState work) { + synchronized (work) { + work.decrementInFlight(); + if (!isCurrent(work)) { + return; + } + } + schedule(work); + } + + private void runProbe(Frame frame, long probeRevision, Callable probe, Consumer consumer) { + try { + if (!isProbeCurrent(frame, probeRevision)) { + return; + } + T result = probe.call(); + if (!isProbeCurrent(frame, probeRevision)) { + return; + } + EventQueue.invokeLater(() -> { + if (isProbeCurrent(frame, probeRevision)) { + consumer.accept(result); + } + }); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (Throwable error) { + IrisLogging.debug("Vision probe failed: " + error.getClass().getSimpleName() + ": " + error.getMessage()); + } + } + + private boolean isProbeCurrent(Frame frame, long probeRevision) { + return !closed.get() && currentFrame == frame && probeSequence.get() == probeRevision; + } + + private boolean isCurrent(Frame frame, CancellationToken token) { + return !closed.get() && !token.cancelled() && currentFrame == frame; + } + + private boolean isCurrent(WorkState work) { + return currentWork == work && isCurrent(work.frame(), work.token()); + } + + private void publish(Frame frame, CancellationToken token) { + if (!isCurrent(frame, token)) { + return; + } + publicationDirty.set(true); + queuePublication(); + } + + private void queuePublication() { + if (!publicationQueued.compareAndSet(false, true)) { + return; + } + EventQueue.invokeLater(() -> { + try { + if (publicationDirty.getAndSet(false) && !closed.get()) { + listener.run(); + } + } finally { + publicationQueued.set(false); + if (publicationDirty.get() && !closed.get()) { + queuePublication(); + } + } + }); + } + + private void cancelActiveRenderTasks() { + for (Future task : activeRenderTasks) { + task.cancel(true); + } + activeRenderTasks.clear(); + } + + record RenderSpec( + IrisRenderer renderer, + RenderType type, + long contentRevision, + double centerX, + double centerZ, + double blocksPerPixel, + int width, + int height + ) { + RenderSpec { + Objects.requireNonNull(renderer, "renderer"); + Objects.requireNonNull(type, "type"); + if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) { + throw new IllegalArgumentException("Vision center must be finite"); + } + if (!Double.isFinite(blocksPerPixel) + || blocksPerPixel < MINIMUM_BLOCKS_PER_PIXEL + || blocksPerPixel > MAXIMUM_BLOCKS_PER_PIXEL) { + throw new IllegalArgumentException("Vision scale is outside the supported range"); + } + if (width < 1 || height < 1) { + throw new IllegalArgumentException("Vision viewport must be positive"); + } + } + + } + + record Frame(long viewRevision, RenderSpec spec, List tiles) { + Frame { + Objects.requireNonNull(spec, "spec"); + tiles = List.copyOf(tiles); + } + + TileKey key(VisibleTile tile) { + return new TileKey(spec.contentRevision(), spec.type(), spec.blocksPerPixel(), tile.tileX(), tile.tileZ()); + } + } + + static final class VisibleTile { + private final long tileX; + private final long tileZ; + private final int screenX; + private final int screenY; + private final double distanceSquared; + private volatile BufferedImage image; + + private VisibleTile(long tileX, long tileZ, int screenX, int screenY, double distanceSquared) { + this.tileX = tileX; + this.tileZ = tileZ; + this.screenX = screenX; + this.screenY = screenY; + this.distanceSquared = distanceSquared; + } + + long tileX() { + return tileX; + } + + long tileZ() { + return tileZ; + } + + int screenX() { + return screenX; + } + + int screenY() { + return screenY; + } + + double distanceSquared() { + return distanceSquared; + } + + BufferedImage image() { + return image; + } + + void setImage(BufferedImage image) { + this.image = image; + } + } + + record TileKey(long contentRevision, RenderType type, double blocksPerPixel, long tileX, long tileZ) { + TileKey { + Objects.requireNonNull(type, "type"); + if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) { + throw new IllegalArgumentException("Vision cache scale must be finite and positive"); + } + } + } + + record Progress(int total, int ready, int active, int queued) { + double completion() { + if (total == 0) { + return 1D; + } + return Math.min(1D, ready / (double) total); + } + } + + record RuntimeOptions(int workers, int renderQueueCapacity, boolean registerPreservation) { + RuntimeOptions { + if (workers < 1 || renderQueueCapacity < 1) { + throw new IllegalArgumentException("Vision render runtime options are invalid"); + } + } + + static RuntimeOptions production() { + int processors = Math.max(1, Runtime.getRuntime().availableProcessors()); + int workers = Math.max(1, Math.min(MAXIMUM_WORKERS, processors)); + return new RuntimeOptions(workers, RENDER_QUEUE_CAPACITY, true); + } + } + + private static final class CancellationToken { + private final AtomicBoolean cancelled = new AtomicBoolean(); + + void cancel() { + cancelled.set(true); + } + + boolean cancelled() { + return cancelled.get(); + } + } + + private static final class TrackedRenderTask extends FutureTask { + private final Set> tasks; + + private TrackedRenderTask(Runnable task, Set> tasks) { + super(task, null); + this.tasks = tasks; + } + + @Override + protected void done() { + tasks.remove(this); + } + } + + private static final class WorkState { + private final Frame frame; + private final CancellationToken token; + private int index; + private int inFlight; + + private WorkState(Frame frame, CancellationToken token) { + this.frame = frame; + this.token = token; + } + + Frame frame() { + return frame; + } + + CancellationToken token() { + return token; + } + + VisibleTile nextMissing() { + while (index < frame.tiles().size()) { + VisibleTile tile = frame.tiles().get(index++); + if (tile.image() == null) { + return tile; + } + } + return null; + } + + int inFlight() { + return inFlight; + } + + void incrementInFlight() { + inFlight++; + } + + void decrementInFlight() { + inFlight--; + } + } + + private static final class WeightedTileCache { + private final long maximumBytes; + private final LinkedHashMap entries; + private long bytes; + + private WeightedTileCache(long maximumBytes) { + this.maximumBytes = maximumBytes; + this.entries = new LinkedHashMap<>(128, 0.75F, true); + } + + synchronized BufferedImage get(TileKey key) { + CacheEntry entry = entries.get(key); + return entry == null ? null : entry.image(); + } + + synchronized void put(TileKey key, BufferedImage image) { + long imageBytes = (long) image.getWidth() * image.getHeight() * Integer.BYTES; + CacheEntry previous = entries.put(key, new CacheEntry(image, imageBytes)); + if (previous != null) { + bytes -= previous.bytes(); + } + bytes += imageBytes; + while (bytes > maximumBytes && !entries.isEmpty()) { + Iterator> iterator = entries.entrySet().iterator(); + Map.Entry eldest = iterator.next(); + bytes -= eldest.getValue().bytes(); + iterator.remove(); + } + } + + synchronized void clear() { + entries.clear(); + bytes = 0L; + } + } + + private record CacheEntry(BufferedImage image, long bytes) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/gui/VisionViewport.java b/core/src/main/java/art/arcane/iris/core/gui/VisionViewport.java new file mode 100644 index 000000000..08b75401f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionViewport.java @@ -0,0 +1,42 @@ +package art.arcane.iris.core.gui; + +record VisionViewport(double centerX, double centerZ, double blocksPerPixel) { + VisionViewport { + if (!Double.isFinite(centerX) || !Double.isFinite(centerZ)) { + throw new IllegalArgumentException("Vision viewport center must be finite"); + } + if (!Double.isFinite(blocksPerPixel) || blocksPerPixel <= 0D) { + throw new IllegalArgumentException("Vision viewport scale must be finite and positive"); + } + } + + double worldX(double screenX, int width) { + return centerX + (screenX - width * 0.5D) * blocksPerPixel; + } + + double worldZ(double screenZ, int height) { + return centerZ + (screenZ - height * 0.5D) * blocksPerPixel; + } + + VisionViewport zoomAt( + double screenX, + double screenZ, + int width, + int height, + double factor, + double minimumScale, + double maximumScale + ) { + if (!Double.isFinite(factor) || factor <= 0D) { + throw new IllegalArgumentException("Vision zoom factor must be finite and positive"); + } + double anchorX = worldX(screenX, width); + double anchorZ = worldZ(screenZ, height); + double nextScale = Math.max(minimumScale, Math.min(maximumScale, blocksPerPixel * factor)); + return new VisionViewport( + anchorX - (screenX - width * 0.5D) * nextScale, + anchorZ - (screenZ - height * 0.5D) * nextScale, + nextScale + ); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java b/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java index af17ffe7e..273f0fd3b 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java @@ -10,7 +10,6 @@ public final class DesktopUiMessages { public static final TextKey VISION_VIEW = TextKey.of("iris.desktop.vision.view", "View:"); public static final TextKey VISION_GRID = TextKey.of("iris.desktop.vision.grid", "Grid"); public static final TextKey VISION_FOLLOW = TextKey.of("iris.desktop.vision.follow", "Follow"); - public static final TextKey VISION_LOW_QUALITY_SHORT = TextKey.of("iris.desktop.vision.low_quality_short", "LQ"); public static final TextKey VISION_REFRESHING = TextKey.of("iris.desktop.vision.refreshing", "Refreshing"); public static final TextKey VISION_FPS = TextKey.of("iris.desktop.vision.fps", "{fps} FPS"); public static final TextKey VISION_ZOOM_RESET = TextKey.of("iris.desktop.vision.zoom_reset", "Zoom reset"); @@ -19,8 +18,6 @@ public final class DesktopUiMessages { public static final TextKey VISION_FOLLOWING = TextKey.of("iris.desktop.vision.following", "Following {player}"); public static final TextKey VISION_NO_PLAYER = TextKey.of("iris.desktop.vision.no_player", "No player in world"); public static final TextKey VISION_FOLLOW_DISABLED = TextKey.of("iris.desktop.vision.follow_disabled", "Follow disabled"); - public static final TextKey VISION_LOW_QUALITY = TextKey.of("iris.desktop.vision.low_quality", "Low quality"); - public static final TextKey VISION_HIGH_QUALITY = TextKey.of("iris.desktop.vision.high_quality", "High quality"); public static final TextKey VISION_STATUS_LEFT = TextKey.of("iris.desktop.vision.status_left", "{mode} | {bpp} bpp | {width} x {height} blocks"); public static final TextKey VISION_STATUS_RIGHT = TextKey.of("iris.desktop.vision.status_right", "X: {x} Z: {z} | {fps} FPS"); public static final TextKey VISION_ENTITY_POSITION = TextKey.of("iris.desktop.vision.entity_position", "Position: {x}, {y}, {z}"); @@ -31,8 +28,8 @@ public final class DesktopUiMessages { public static final TextKey VISION_BIOME_KEY = TextKey.of("iris.desktop.vision.biome_key", "Key: {key}"); public static final TextKey VISION_BIOME_FILE = TextKey.of("iris.desktop.vision.biome_file", "File: {file}"); public static final TextKey VISION_VELOCITY = TextKey.of("iris.desktop.vision.velocity", "Velocity: {velocity}"); - public static final TextKey VISION_TILES = TextKey.of("iris.desktop.vision.tiles", "Tiles: {high} HD / {low} LQ"); - public static final TextKey VISION_WORKERS = TextKey.of("iris.desktop.vision.workers", "Workers: {high} HD / {low} LQ"); + public static final TextKey VISION_TILES = TextKey.of("iris.desktop.vision.tiles", "Atlas pages: {ready} / {total} exact"); + public static final TextKey VISION_WORKERS = TextKey.of("iris.desktop.vision.workers", "Workers: {active} active / {queued} queued"); public static final TextKey VISION_CENTER = TextKey.of("iris.desktop.vision.center", "Center: {x}, {z}"); public static final TextKey VISION_HELP_TOGGLE = TextKey.of("iris.desktop.vision.help.toggle", "Toggle help"); public static final TextKey VISION_HELP_REFRESH = TextKey.of("iris.desktop.vision.help.refresh", "Refresh tiles"); @@ -40,7 +37,6 @@ public final class DesktopUiMessages { public static final TextKey VISION_HELP_ZOOM = TextKey.of("iris.desktop.vision.help.zoom", "Zoom in/out"); public static final TextKey VISION_HELP_RESET_ZOOM = TextKey.of("iris.desktop.vision.help.reset_zoom", "Reset zoom"); public static final TextKey VISION_HELP_CYCLE_MODE = TextKey.of("iris.desktop.vision.help.cycle_mode", "Cycle render mode"); - public static final TextKey VISION_HELP_QUALITY = TextKey.of("iris.desktop.vision.help.quality", "Toggle tile quality"); public static final TextKey VISION_HELP_FPS = TextKey.of("iris.desktop.vision.help.fps", "Toggle 30/60 FPS"); public static final TextKey VISION_HELP_GRID = TextKey.of("iris.desktop.vision.help.grid", "Toggle grid"); public static final TextKey VISION_HELP_BIOME = TextKey.of("iris.desktop.vision.help.biome", "Detailed biome info"); @@ -53,6 +49,7 @@ public final class DesktopUiMessages { public static final TextKey VISION_MODE_BIOME_SEA = TextKey.of("iris.desktop.vision.mode.biome_sea", "Biome sea"); public static final TextKey VISION_MODE_REGION = TextKey.of("iris.desktop.vision.mode.region", "Region"); public static final TextKey VISION_MODE_CAVE_LAND = TextKey.of("iris.desktop.vision.mode.cave_land", "Cave land"); + public static final TextKey VISION_MODE_RIVER = TextKey.of("iris.desktop.vision.mode.river", "River network"); public static final TextKey VISION_MODE_HEIGHT = TextKey.of("iris.desktop.vision.mode.height", "Height"); public static final TextKey VISION_MODE_OBJECT_LOAD = TextKey.of("iris.desktop.vision.mode.object_load", "Object load"); public static final TextKey VISION_MODE_DECORATOR_LOAD = TextKey.of("iris.desktop.vision.mode.decorator_load", "Decorator load"); @@ -95,17 +92,17 @@ public final class DesktopUiMessages { public static final TextKey PREGEN_MEMORY = TextKey.of("iris.desktop.pregen.memory", "Memory: {used} ({usage}) Pressure: {pressure}/s"); private static final List KEYS = List.of( - VISION_TITLE, VISION_VIEW, VISION_GRID, VISION_FOLLOW, VISION_LOW_QUALITY_SHORT, + VISION_TITLE, VISION_VIEW, VISION_GRID, VISION_FOLLOW, VISION_REFRESHING, VISION_FPS, VISION_ZOOM_RESET, VISION_GRID_ENABLED, VISION_GRID_DISABLED, - VISION_FOLLOWING, VISION_NO_PLAYER, VISION_FOLLOW_DISABLED, VISION_LOW_QUALITY, VISION_HIGH_QUALITY, + VISION_FOLLOWING, VISION_NO_PLAYER, VISION_FOLLOW_DISABLED, VISION_STATUS_LEFT, VISION_STATUS_RIGHT, VISION_ENTITY_POSITION, VISION_ENTITY_HEALTH, VISION_BLOCK_POSITION, VISION_CHUNK_POSITION, VISION_REGION_POSITION, VISION_BIOME_KEY, VISION_BIOME_FILE, VISION_VELOCITY, VISION_TILES, VISION_WORKERS, VISION_CENTER, VISION_HELP_TOGGLE, VISION_HELP_REFRESH, VISION_HELP_FOLLOW, VISION_HELP_ZOOM, - VISION_HELP_RESET_ZOOM, VISION_HELP_CYCLE_MODE, VISION_HELP_QUALITY, VISION_HELP_FPS, + VISION_HELP_RESET_ZOOM, VISION_HELP_CYCLE_MODE, VISION_HELP_FPS, VISION_HELP_GRID, VISION_HELP_BIOME, VISION_HELP_TELEPORT, VISION_HELP_EDITOR, VISION_OPENED, VISION_TELEPORTING, VISION_MODE_BIOME, VISION_MODE_BIOME_LAND, VISION_MODE_BIOME_SEA, - VISION_MODE_REGION, VISION_MODE_CAVE_LAND, VISION_MODE_HEIGHT, VISION_MODE_OBJECT_LOAD, + VISION_MODE_REGION, VISION_MODE_CAVE_LAND, VISION_MODE_RIVER, VISION_MODE_HEIGHT, VISION_MODE_OBJECT_LOAD, VISION_MODE_DECORATOR_LOAD, VISION_MODE_CONTINENT, VISION_MODE_LAYER_LOAD, NOISE_TITLE, NOISE_TITLE_GENERATOR, NOISE_SEARCH, NOISE_STATUS, NOISE_CATEGORY_CUSTOM, NOISE_CATEGORY_PACK_GENERATORS, NOISE_CATEGORY_SIMPLEX, NOISE_CATEGORY_PERLIN, diff --git a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java index f88f93ff3..2a9f975f9 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java +++ b/core/src/main/java/art/arcane/iris/core/nms/INMSBinding.java @@ -239,7 +239,7 @@ public interface INMSBinding { IrisImportedStructureControl importedStructures ) throws NoSuchFieldException, IllegalAccessException; - void completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException; + CompletableFuture completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException; void abandonStudioStructureBootstrap(World world); diff --git a/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java b/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java index 020988ad7..af94ff7c8 100644 --- a/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java +++ b/core/src/main/java/art/arcane/iris/core/nms/v1X/NMSBinding1X.java @@ -49,6 +49,7 @@ import org.bukkit.inventory.ItemStack; import java.awt.Color; import java.util.List; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.stream.StreamSupport; public class NMSBinding1X implements INMSBinding { @@ -117,7 +118,8 @@ public class NMSBinding1X implements INMSBinding { } @Override - public void completeStudioStructureBootstrap(World world) { + public CompletableFuture completeStudioStructureBootstrap(World world) { + return CompletableFuture.completedFuture(null); } @Override diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java new file mode 100644 index 000000000..82847fb26 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java @@ -0,0 +1,876 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.object.NoiseStyle; +import art.arcane.iris.engine.river.RiverTopologyComplexity; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class PackRiverValidator { + private static final Set WATER_MODES = Set.of("SEA_LEVEL", "TERRACED"); + private static final Set TERMINAL_MODES = Set.of("SUPPRESS", "DRY_CHANNEL", "SINKHOLE_GROTTO"); + private static final Set ROUTING_POLICIES = Set.of("ALLOW", "AVOID", "BLOCK"); + private static final Set CAVE_MODES = Set.of( + "SEALED", + "FLOOD_CLOSED_COMPONENT", + "GENERATE_GROTTO", + "GROTTO_OR_CLOSED_COMPONENT", + "WATERFALL_POOL" + ); + private static final Set CAVE_FALLBACKS = Set.of("SEALED", "GENERATE_GROTTO"); + private static final Set EXISTING_FLUID_POLICIES = Set.of("REJECT", "ALLOW_SAME", "REPLACE"); + private static final Set UNSAFE_RIVER_STREAMS = Set.of("HEIGHT", "HEIGHT_OR_FLUID", "SLOPE"); + private static final Set NOISE_STYLES = noiseStyles(); + + private PackRiverValidator() { + } + + static Validation validate(File packFolder, File[] dimensionFiles) { + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory() || dimensionFiles == null) { + return new Validation(errors, warnings); + } + + boolean enabled = false; + List contexts = new ArrayList<>(); + List sortedDimensions = new ArrayList<>(List.of(dimensionFiles)); + sortedDimensions.sort(Comparator.comparing(File::getPath)); + for (File dimensionFile : sortedDimensions) { + JSONObject dimension = PackValidationIo.readJson(dimensionFile); + if (dimension == null || !dimension.has("rivers")) { + continue; + } + String dimensionKey = PackValidationIo.stripExtension(dimensionFile.getName()); + String path = "Dimension '" + dimensionKey + "' rivers"; + JSONObject rivers = requireObject(dimension, "rivers", path, errors); + if (rivers == null) { + continue; + } + PackJsonFieldChecks.validateOptionalBoolean(path, rivers, "enabled", errors); + if (!booleanValue(rivers, "enabled", false)) { + continue; + } + enabled = true; + DimensionRiverContext context = new DimensionRiverContext( + dimensionKey, + dimension, + rivers, + referencedKeys(dimension.optJSONArray("regions")) + ); + contexts.add(context); + validateNetwork(packFolder, path, context, errors, warnings); + } + + if (enabled) { + validateOverrides(packFolder, new File(packFolder, "regions"), "Region", contexts, errors, warnings); + validateOverrides(packFolder, new File(packFolder, "biomes"), "Biome", contexts, errors, warnings); + } + return new Validation(errors, warnings); + } + + private static void validateNetwork(File packFolder, String path, DimensionRiverContext context, + List errors, List warnings) { + JSONObject rivers = context.rivers(); + JSONObject topology = nestedObject(rivers, "topology", path, errors); + JSONObject terrain = nestedObject(rivers, "terrain", path, errors); + JSONObject water = nestedObject(rivers, "water", path, errors); + JSONObject biomes = nestedObject(rivers, "biomes", path, errors); + JSONObject caves = nestedObject(rivers, "caves", path, errors); + + if (topology != null) { + validateTopology(packFolder, path + ".topology", topology, errors, warnings); + } + if (terrain != null) { + validateTerrain(packFolder, path + ".terrain", terrain, errors, warnings); + } + if (water != null) { + validateWater(path + ".water", water, errors); + } + if (biomes != null) { + validateBiomePools(packFolder, path + ".biomes", biomes, false, errors, warnings); + } + boolean sinkholeTerminal = terrain != null + && "SINKHOLE_GROTTO".equals(stringValue(terrain, "terminalMode", "DRY_CHANNEL")); + if (caves != null) { + validateCaves(packFolder, path + ".caves", caves, sinkholeTerminal, errors, warnings); + } + + if (topology != null && terrain != null) { + double meanderStrength = doubleValue(terrain, "meanderStrength", 72D); + int cellSize = integerValue(topology, "cellSize", 512); + if (Double.isFinite(meanderStrength) && meanderStrength > cellSize) { + warnings.add(path + ".terrain.meanderStrength exceeds topology.cellSize; reaches may require large cache halos."); + } + validateTopologyComplexity(path, topology, terrain, errors); + } + if (sinkholeTerminal && caves != null) { + validateSinkholeCapability( + path + ".terrain.terminalMode", + context, + caves, + path + ".caves", + errors + ); + } + } + + private static void validateTopology(File packFolder, String path, JSONObject topology, + List errors, List warnings) { + PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "cellSize", 64, 4096, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "tileCells", 1, 64, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "siteJitter", 0D, 0.49D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "maxRouteReaches", 1, 256, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "minimumSourcesPerTile", 0, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "sinkSearchReaches", 0, 7, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "routingBasinCells", 8, 256, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "routingPlateauHeight", 1D, 64D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "routingNoiseWeight", 0D, 1024D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "terrainHeightWeight", 0D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "terrainSlopeWeight", 0D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "oceanAttraction", 0D, 16D, errors); + PackJsonFieldChecks.validateOptionalBoolean(path, topology, "requireOcean", errors); + validateNoiseChance(packFolder, topology, "source", path, errors); + validateNoiseChance(packFolder, topology, "continuation", path, errors); + validateStyle(packFolder, topology, "routingStyle", path, errors); + + int tileCells = integerValue(topology, "tileCells", 4); + int minimumSourcesPerTile = integerValue(topology, "minimumSourcesPerTile", 0); + if (tileCells >= 1 && tileCells <= 64 + && minimumSourcesPerTile >= 0 + && minimumSourcesPerTile > tileCells * tileCells) { + errors.add(path + ".minimumSourcesPerTile must not exceed tileCells squared."); + } + + } + + private static void validateTerrain(File packFolder, String path, JSONObject terrain, + List errors, List warnings) { + validateStyledRange(packFolder, terrain, "channelWidth", path, 1D, 2048D, errors, warnings); + validateStyledRange(packFolder, terrain, "bankWidth", path, 0D, 2048D, errors, warnings); + validateStyledRange(packFolder, terrain, "depth", path, 1D, 512D, errors, warnings); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxChannelWidth", 1D, 2048D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxBankWidth", 0D, 2048D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxDepth", 1D, 512D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "orderWidthFactor", 0D, 8D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "orderDepthFactor", 0D, 8D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "maxIncision", 0, 512, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "bankExponent", 0.125D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "meanderStrength", 0D, 1024D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "meanderSubdivisions", 1, 64, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "bedRoughness", 0D, 8D, errors); + PackJsonFieldChecks.validateOptionalEnum(path, terrain, "terminalMode", TERMINAL_MODES, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "terminalTaper", 8, 1024, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "dryContinuationChance", 0D, 1D, errors); + validateNoiseChance(packFolder, terrain, "incision", path, errors); + validateStyle(packFolder, terrain, "meanderStyle", path, errors); + validateStyle(packFolder, terrain, "bedRoughnessStyle", path, errors); + } + + private static void validateWater(String path, JSONObject water, List errors) { + PackJsonFieldChecks.validateOptionalEnum(path, water, "mode", WATER_MODES, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "poolLength", 8, 4096, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "maximumPoolRise", 0, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "dropHeight", 1, 32, errors); + + String mode = stringValue(water, "mode", "SEA_LEVEL"); + int maximumPoolRise = integerValue(water, "maximumPoolRise", 4); + int dropHeight = integerValue(water, "dropHeight", 1); + if ("TERRACED".equals(mode) && dropHeight > maximumPoolRise) { + errors.add(path + ".dropHeight must not exceed maximumPoolRise in TERRACED mode."); + } + } + + private static void validateTopologyComplexity( + String path, + JSONObject topology, + JSONObject terrain, + List errors + ) { + int cellSize = integerValue(topology, "cellSize", 512); + int tileCells = integerValue(topology, "tileCells", 4); + double siteJitter = doubleValue(topology, "siteJitter", 0.35D); + int maxRouteReaches = integerValue(topology, "maxRouteReaches", 16); + double meanderStrength = doubleValue(terrain, "meanderStrength", 72D); + int meanderSubdivisions = integerValue(terrain, "meanderSubdivisions", 8); + double maximumChannelWidth = doubleValue(terrain, "maxChannelWidth", 10D); + double maximumBankWidth = doubleValue(terrain, "maxBankWidth", 4D); + if (cellSize < 64 || cellSize > 4096 + || tileCells < 1 || tileCells > 64 + || !Double.isFinite(siteJitter) || siteJitter < 0D || siteJitter > 0.49D + || maxRouteReaches < 1 || maxRouteReaches > 256 + || !Double.isFinite(meanderStrength) || meanderStrength < 0D || meanderStrength > 1024D + || meanderSubdivisions < 1 || meanderSubdivisions > 64 + || !Double.isFinite(maximumChannelWidth) || maximumChannelWidth < 1D || maximumChannelWidth > 2048D + || !Double.isFinite(maximumBankWidth) || maximumBankWidth < 0D || maximumBankWidth > 2048D) { + return; + } + double maximumReachRadius = maximumChannelWidth * 0.5D + maximumBankWidth; + RiverTopologyComplexity.Estimate estimate = RiverTopologyComplexity.estimate( + cellSize, + tileCells, + siteJitter, + maxRouteReaches, + maximumReachRadius, + meanderStrength, + meanderSubdivisions + ); + for (String violation : estimate.violations()) { + errors.add(path + " exceeds the safe derived complexity budget. " + violation); + } + } + + private static void validateCaves(File packFolder, String path, JSONObject caves, + boolean forceGeneratedGrotto, + List errors, List warnings) { + PackJsonFieldChecks.validateOptionalEnum(path, caves, "mode", CAVE_MODES, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "minimumSpacing", 16, 4096, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maximumPerReach", 0, 16, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maxBoreDepth", 1, 256, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "throatRadius", 1, 16, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "waterLevelOffset", -64, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "dryHeadroom", 0, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "grottoHorizontalRadius", 2, 128, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "grottoVerticalRadius", 2, 128, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, caves, "grottoWarpStrength", 0D, 32D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maxFloodRadius", 4, 256, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maxFloodDepth", 4, 256, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maxFloodVolume", 64, 1048576, errors); + PackJsonFieldChecks.validateOptionalEnum(path, caves, "fallback", CAVE_FALLBACKS, errors); + PackJsonFieldChecks.validateOptionalEnum(path, caves, "existingFluidPolicy", EXISTING_FLUID_POLICIES, errors); + validateNoiseChance(packFolder, caves, "entry", path, errors); + validateStyle(packFolder, caves, "grottoShapeStyle", path, errors); + validateStyle(packFolder, caves, "grottoWarpStyle", path, errors); + + String mode = stringValue(caves, "mode", "SEALED"); + if ("SEALED".equals(mode) && !forceGeneratedGrotto) { + return; + } + + int maximumPerReach = integerValue(caves, "maximumPerReach", 1); + double entryChance = noiseChanceValue(caves, "entry", 0.12D); + if (maximumPerReach == 0 || (!forceGeneratedGrotto && entryChance == 0D)) { + warnings.add(path + " enables cave hydrology but its entry gate cannot accept any connections."); + } + + int maxBoreDepth = integerValue(caves, "maxBoreDepth", 48); + int throatRadius = integerValue(caves, "throatRadius", 2); + int maxFloodRadius = integerValue(caves, "maxFloodRadius", 48); + int maxFloodDepth = integerValue(caves, "maxFloodDepth", 32); + if (throatRadius >= maxFloodRadius) { + errors.add(path + ".throatRadius must be smaller than maxFloodRadius so the proof boundary can contain the throat."); + } + if (throatRadius >= maxFloodDepth) { + errors.add(path + ".throatRadius must be smaller than maxFloodDepth so the proof boundary can contain the throat."); + } + if (maxBoreDepth > maxFloodDepth) { + warnings.add(path + ".maxBoreDepth exceeds maxFloodDepth; deeper cave targets found by the bore search will be rejected by containment proof."); + } + + String fallback = stringValue(caves, "fallback", "SEALED"); + if (forceGeneratedGrotto || usesGeneratedGrotto(mode, fallback)) { + validateGrotto(path, caves, errors); + } + } + + private static void validateGrotto(String path, JSONObject caves, List errors) { + int throatRadius = integerValue(caves, "throatRadius", 2); + int dryHeadroom = integerValue(caves, "dryHeadroom", 4); + int horizontalRadius = integerValue(caves, "grottoHorizontalRadius", 12); + int verticalRadius = integerValue(caves, "grottoVerticalRadius", 7); + double warpStrength = doubleValue(caves, "grottoWarpStrength", 2D); + int maxFloodRadius = integerValue(caves, "maxFloodRadius", 48); + int maxFloodDepth = integerValue(caves, "maxFloodDepth", 32); + int maxFloodVolume = integerValue(caves, "maxFloodVolume", 8192); + if (throatRadius >= horizontalRadius || throatRadius >= verticalRadius) { + addDistinct(errors, path + ".throatRadius must be smaller than both grotto radii so a sealed chamber can surround the inlet."); + } + if (dryHeadroom >= (verticalRadius * 2) + 1) { + addDistinct(errors, path + ".dryHeadroom must fit inside the generated grotto height."); + } + + int warpEnvelope = Double.isFinite(warpStrength) ? (int) Math.ceil(warpStrength) : 0; + int requiredRadius = horizontalRadius + warpEnvelope + 1; + int requiredDepth = verticalRadius + warpEnvelope + 1; + if (maxFloodRadius < requiredRadius) { + addDistinct(errors, path + ".maxFloodRadius must be at least " + requiredRadius + + " to prove the configured grotto and its sealed shell."); + } + if (maxFloodDepth < requiredDepth) { + addDistinct(errors, path + ".maxFloodDepth must be at least " + requiredDepth + + " to prove the configured grotto and its sealed shell."); + } + + long volume = grottoVolume(horizontalRadius, verticalRadius); + if (volume > maxFloodVolume) { + addDistinct(errors, path + ".maxFloodVolume must be at least " + volume + + " to contain the configured grotto before its throat and shell are considered."); + } + } + + private static long grottoVolume(int horizontalRadius, int verticalRadius) { + long volume = 0L; + double horizontalSquared = (double) horizontalRadius * horizontalRadius; + double verticalSquared = (double) verticalRadius * verticalRadius; + for (int dx = -horizontalRadius; dx <= horizontalRadius; dx++) { + for (int dy = -verticalRadius; dy <= verticalRadius; dy++) { + double remaining = 1D - ((double) dx * dx / horizontalSquared) + - ((double) dy * dy / verticalSquared); + if (remaining < 0D) { + continue; + } + int maximumZ = (int) Math.floor(horizontalRadius * Math.sqrt(remaining)); + volume += (maximumZ * 2L) + 1L; + } + } + return volume; + } + + private static boolean usesGeneratedGrotto(String mode, String fallback) { + return "GENERATE_GROTTO".equals(mode) + || "GROTTO_OR_CLOSED_COMPONENT".equals(mode) + || "WATERFALL_POOL".equals(mode) + || "GENERATE_GROTTO".equals(fallback); + } + + private static void validateSinkholeCapability( + String terminalPath, + DimensionRiverContext context, + JSONObject caves, + String cavesPath, + List errors + ) { + String suffix = " in Dimension '" + context.dimensionKey() + "'."; + if (!booleanValue(context.dimension(), "carvingEnabled", true)) { + addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires carvingEnabled to be true" + suffix); + } + if (!booleanValue(context.dimension(), "useMantle", true)) { + addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires useMantle to be true" + suffix); + } + if (disabled(context.dimension(), "CARVED")) { + addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires CARVED to remain enabled" + suffix); + } + if (disabled(context.dimension(), "RIVER_HYDROLOGY")) { + addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires RIVER_HYDROLOGY to remain enabled" + suffix); + } + if ("SEALED".equals(stringValue(caves, "mode", "SEALED"))) { + addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires a non-SEALED caves.mode" + suffix); + } + if (integerValue(caves, "maximumPerReach", 1) <= 0) { + addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires caves.maximumPerReach above zero" + suffix); + } + validateGrotto(cavesPath, caves, errors); + } + + private static void validateOverrideSinkhole( + File packFolder, + String terminalPath, + String resourceKey, + String resourceType, + List contexts, + List errors, + List warnings + ) { + boolean referenced = false; + for (DimensionRiverContext context : contexts) { + boolean reachable = "Region".equals(resourceType) + ? context.regionKeys().contains(resourceKey) + : referencedSurfaceBiomes(packFolder, context.regionKeys()).contains(resourceKey); + if (!reachable) { + continue; + } + referenced = true; + JSONObject caves = nestedObject(context.rivers(), "caves", "Dimension '" + + context.dimensionKey() + "' rivers", errors); + if (caves != null) { + validateSinkholeCapability( + terminalPath, + context, + caves, + "Dimension '" + context.dimensionKey() + "' rivers.caves", + errors + ); + } + } + if (!referenced) { + addDistinct(warnings, terminalPath + + " selects SINKHOLE_GROTTO but no enabled river dimension reaches this " + + resourceType.toLowerCase() + "."); + } + } + + private static Set referencedSurfaceBiomes(File packFolder, Set regionKeys) { + Set biomes = new HashSet<>(); + File regionsFolder = new File(packFolder, "regions"); + for (String regionKey : regionKeys) { + JSONObject region = PackValidationIo.readJson(new File(regionsFolder, regionKey + ".json")); + if (region == null) { + continue; + } + collectBiomeKeys(region.optJSONArray("landBiomes"), biomes); + collectBiomeKeys(region.optJSONArray("seaBiomes"), biomes); + collectBiomeKeys(region.optJSONArray("shoreBiomes"), biomes); + } + Set roots = Set.copyOf(biomes); + for (String biomeKey : roots) { + collectBiomeChildren(packFolder, biomeKey, 0, biomes); + } + return biomes; + } + + private static void collectBiomeChildren(File packFolder, String biomeKey, int depth, Set biomes) { + if (depth >= 4) { + return; + } + JSONObject biome = PackValidationIo.readJson(new File(packFolder, "biomes/" + biomeKey + ".json")); + if (biome == null) { + return; + } + JSONArray children = biome.optJSONArray("children"); + if (children == null) { + return; + } + for (int index = 0; index < children.length(); index++) { + String child = children.optString(index, null); + if (child == null || child.isBlank()) { + continue; + } + boolean added = biomes.add(child); + if (added) { + collectBiomeChildren(packFolder, child, depth + 1, biomes); + } + } + } + + private static Set referencedKeys(JSONArray keys) { + Set referenced = new HashSet<>(); + collectBiomeKeys(keys, referenced); + return Set.copyOf(referenced); + } + + private static void collectBiomeKeys(JSONArray keys, Set destination) { + if (keys == null) { + return; + } + for (int index = 0; index < keys.length(); index++) { + String key = keys.optString(index, null); + if (key != null && !key.isBlank()) { + destination.add(key); + } + } + } + + private static boolean disabled(JSONObject dimension, String flag) { + JSONArray disabled = dimension.optJSONArray("disabledComponents"); + if (disabled == null) { + return false; + } + for (int index = 0; index < disabled.length(); index++) { + if (flag.equals(disabled.optString(index, null))) { + return true; + } + } + return false; + } + + private static void addDistinct(List destination, String value) { + if (!destination.contains(value)) { + destination.add(value); + } + } + + private static void validateOverrides(File packFolder, File resourceFolder, String resourceType, + List contexts, + List errors, List warnings) { + if (!resourceFolder.isDirectory()) { + return; + } + List files = PackValidationIo.listJsonRecursive(resourceFolder); + files.sort(Comparator.comparing(File::getPath)); + for (File file : files) { + JSONObject resource = PackValidationIo.readJson(file); + if (resource == null || !resource.has("riverOverride")) { + continue; + } + String key = PackValidationIo.deriveKey(resourceFolder, file); + String path = resourceType + " '" + key + "' riverOverride"; + Object rawOverride = resource.opt("riverOverride"); + if (rawOverride == JSONObject.NULL) { + continue; + } + if (!(rawOverride instanceof JSONObject override)) { + errors.add(path + " must be an object or null."); + continue; + } + validateOverride(packFolder, path, key, resourceType, override, contexts, errors, warnings); + } + } + + private static void validateOverride(File packFolder, String path, String resourceKey, String resourceType, + JSONObject override, List contexts, + List errors, List warnings) { + PackJsonFieldChecks.validateOptionalBoolean(path, override, "allowSources", errors); + PackJsonFieldChecks.validateOptionalEnum(path, override, "routingPolicy", ROUTING_POLICIES, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "routingCostMultiplier", 0D, 64D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "widthMultiplier", 0.0001D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "bankWidthMultiplier", 0D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "depthMultiplier", 0.0001D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "maxIncisionMultiplier", 0D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "continuationChanceMultiplier", 0D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "caveEntryMultiplier", 0D, 16D, errors); + PackJsonFieldChecks.validateOptionalEnum(path, override, "terminalMode", TERMINAL_MODES, errors); + validateBiomePool(packFolder, path, override, "channelBiomes", RiverBiomeRole.CHANNEL, true, errors, warnings); + validateBiomePool(packFolder, path, override, "bankBiomes", RiverBiomeRole.BANK, true, errors, warnings); + validateBiomePool(packFolder, path, override, "mouthBiomes", RiverBiomeRole.MOUTH, true, errors, warnings); + validateBiomePool(packFolder, path, override, "dryBiomes", RiverBiomeRole.DRY, true, errors, warnings); + validateBiomePool(packFolder, path, override, "floodedCaveBiomes", RiverBiomeRole.FLOODED_CAVE, true, + errors, warnings); + if ("SINKHOLE_GROTTO".equals(stringValue(override, "terminalMode", null))) { + validateOverrideSinkhole( + packFolder, + path + ".terminalMode", + resourceKey, + resourceType, + contexts, + errors, + warnings + ); + } + } + + private static void validateBiomePools(File packFolder, String path, JSONObject biomes, boolean allowNull, + List errors, List warnings) { + validateStyle(packFolder, biomes, "selectionStyle", path, errors); + validateBiomePool(packFolder, path, biomes, "channel", RiverBiomeRole.CHANNEL, allowNull, errors, warnings); + validateBiomePool(packFolder, path, biomes, "bank", RiverBiomeRole.BANK, allowNull, errors, warnings); + validateBiomePool(packFolder, path, biomes, "mouth", RiverBiomeRole.MOUTH, allowNull, errors, warnings); + validateBiomePool(packFolder, path, biomes, "dry", RiverBiomeRole.DRY, allowNull, errors, warnings); + validateBiomePool(packFolder, path, biomes, "floodedCave", RiverBiomeRole.FLOODED_CAVE, allowNull, + errors, warnings); + } + + private static void validateBiomePool(File packFolder, String path, JSONObject owner, String field, + RiverBiomeRole role, boolean allowNull, + List errors, List warnings) { + if (!owner.has(field)) { + return; + } + Object rawPool = owner.opt(field); + if (rawPool == JSONObject.NULL && allowNull) { + return; + } + if (!(rawPool instanceof JSONArray pool)) { + errors.add(path + "." + field + " must be an array" + (allowNull ? " or null" : "") + "."); + return; + } + + Set seen = new HashSet<>(); + File biomesFolder = new File(packFolder, "biomes"); + for (int index = 0; index < pool.length(); index++) { + Object rawKey = pool.opt(index); + String entryPath = path + "." + field + "[" + index + "]"; + if (!(rawKey instanceof String key) || key.isBlank()) { + errors.add(entryPath + " must name a biome resource."); + continue; + } + if (!seen.add(key)) { + warnings.add(entryPath + " duplicates biome '" + key + "' in the same river pool."); + continue; + } + File biomeFile = new File(biomesFolder, key + ".json"); + if (!biomeFile.isFile()) { + errors.add(entryPath + " references missing biome '" + key + "'."); + continue; + } + validateBiomeSuitability(entryPath, key, role, PackValidationIo.readJson(biomeFile), warnings); + } + } + + private static void validateBiomeSuitability(String path, String biomeKey, RiverBiomeRole role, + JSONObject biome, List warnings) { + if (biome == null || role == RiverBiomeRole.DRY || role == RiverBiomeRole.FLOODED_CAVE) { + return; + } + String derivative = stringValue(biome, "vanillaDerivative", null); + if (derivative == null || derivative.isBlank()) { + derivative = stringValue(biome, "derivative", "minecraft:the_void"); + } + String normalized = derivative.indexOf(':') >= 0 ? derivative : "minecraft:" + derivative; + if (!normalized.startsWith("minecraft:")) { + return; + } + boolean suitable = switch (role) { + case CHANNEL, MOUTH -> normalized.contains("ocean") || normalized.endsWith("river"); + case BANK -> normalized.endsWith("beach") || normalized.endsWith("shore"); + default -> true; + }; + if (!suitable) { + warnings.add(path + " assigns biome '" + biomeKey + "' the inferred river role " + role.label + + " but its vanilla derivative '" + normalized + + "' does not match that role; native structure selection will use Iris's safe role fallback."); + } + } + + private static void validateNoiseChance(File packFolder, JSONObject owner, String field, String path, + List errors) { + if (!owner.has(field)) { + return; + } + JSONObject chance = requireObject(owner, field, path + "." + field, errors); + if (chance == null) { + return; + } + String chancePath = path + "." + field; + PackJsonFieldChecks.validateOptionalDoubleRange(chancePath, chance, "chance", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(chancePath, chance, "influence", 0D, 1D, errors); + validateStyle(packFolder, chance, "style", chancePath, errors); + } + + private static void validateStyledRange(File packFolder, JSONObject owner, String field, String path, + double minimum, double maximum, + List errors, List warnings) { + if (!owner.has(field)) { + return; + } + String rangePath = path + "." + field; + JSONObject range = resolveObject(packFolder, owner.opt(field), "snippet/style-range/", rangePath, errors); + if (range == null) { + return; + } + boolean hasMinimum = range.has("min") && range.opt("min") != JSONObject.NULL; + boolean hasMaximum = range.has("max") && range.opt("max") != JSONObject.NULL; + if (!hasMinimum && !hasMaximum) { + errors.add(rangePath + " must set min and max explicitly."); + } else if (!hasMinimum || !hasMaximum) { + warnings.add(rangePath + + " should set both min and max explicitly; the omitted bound uses the shared style-range default."); + } + PackJsonFieldChecks.validateOptionalDoubleRange(rangePath, range, "min", minimum, maximum, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(rangePath, range, "max", minimum, maximum, errors); + double minimumValue = doubleValue(range, "min", 16D); + double maximumValue = doubleValue(range, "max", 32D); + if (Double.isFinite(minimumValue) && Double.isFinite(maximumValue) && minimumValue > maximumValue) { + errors.add(rangePath + ".min must not exceed " + rangePath + ".max."); + } + validateStyle(packFolder, range, "style", rangePath, errors); + } + + private static void validateStyle(File packFolder, JSONObject owner, String field, String path, + List errors) { + if (!owner.has(field)) { + return; + } + validateStyle(packFolder, owner.opt(field), path + "." + field, errors, new HashSet<>()); + } + + private static void validateStyle(File packFolder, Object rawStyle, String path, + List errors, Set dependencyStack) { + String styleMarker = rawStyle instanceof String reference ? "style:" + reference : null; + if (styleMarker != null && !dependencyStack.add(styleMarker)) { + errors.add(path + " has a cyclic river-noise style snippet dependency."); + return; + } + try { + JSONObject style = resolveObject(packFolder, rawStyle, "snippet/style/", path, errors); + if (style != null) { + validateResolvedStyle(packFolder, style, path, errors, dependencyStack); + } + } finally { + if (styleMarker != null) { + dependencyStack.remove(styleMarker); + } + } + } + + private static void validateResolvedStyle(File packFolder, JSONObject style, String path, + List errors, Set dependencyStack) { + PackJsonFieldChecks.validateOptionalEnum(path, style, "style", NOISE_STYLES, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "cellularFrequency", 0D, Double.MAX_VALUE, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "cellularZoom", 0.00001D, Double.MAX_VALUE, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "zoom", 0.00001D, Double.MAX_VALUE, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "multiplier", + -Double.MAX_VALUE, Double.MAX_VALUE, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "exponent", 0.01562D, 64D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, style, "cacheSize", 0, 8192, errors); + + if (style.has("expression") && style.opt("expression") != JSONObject.NULL) { + Object rawExpression = style.opt("expression"); + if (!(rawExpression instanceof String expressionKey) || expressionKey.isBlank()) { + errors.add(path + ".expression must name an expression resource."); + } else { + validateExpression(packFolder, expressionKey, path, errors, dependencyStack); + } + } + if (style.has("fracture") && style.opt("fracture") != JSONObject.NULL) { + validateStyle(packFolder, style.opt("fracture"), path + ".fracture", errors, dependencyStack); + } + } + + private static void validateExpression(File packFolder, String expressionKey, String usePath, + List errors, Set dependencyStack) { + String expressionMarker = "expression:" + expressionKey; + if (!dependencyStack.add(expressionMarker)) { + errors.add(usePath + " has a cyclic river-noise expression dependency through '" + expressionKey + "'."); + return; + } + try { + File expressionFile = new File(new File(packFolder, "expressions"), expressionKey + ".json"); + JSONObject expression = PackValidationIo.readJson(expressionFile); + if (expression == null) { + return; + } + scanExpressionEntries(packFolder, expression, "variables", expressionKey, usePath, errors, dependencyStack); + scanExpressionEntries(packFolder, expression, "functions", expressionKey, usePath, errors, dependencyStack); + } finally { + dependencyStack.remove(expressionMarker); + } + } + + private static void scanExpressionEntries(File packFolder, JSONObject expression, String field, + String expressionKey, String usePath, + List errors, Set dependencyStack) { + JSONArray entries = expression.optJSONArray(field); + if (entries == null) { + return; + } + for (int index = 0; index < entries.length(); index++) { + Object rawEntry = entries.opt(index); + String snippetFolder = "variables".equals(field) + ? "snippet/expression-load/" + : "snippet/expression-function/"; + JSONObject entry = resolveExpressionEntry(packFolder, rawEntry, snippetFolder); + if (entry == null) { + continue; + } + String stream = stringValue(entry, "engineStreamValue", null); + if (stream != null && isUnsafeRiverStream(stream)) { + errors.add(usePath + " uses expression '" + expressionKey + "' " + field + "[" + index + + "].engineStreamValue '" + stream + + "', which depends on final river-shaped terrain and would recurse during river generation."); + } + if (entry.has("styleValue")) { + validateStyle(packFolder, entry.opt("styleValue"), usePath + " -> expression '" + expressionKey + + "' " + field + "[" + index + "].styleValue", errors, dependencyStack); + } + } + } + + private static JSONObject resolveExpressionEntry(File packFolder, Object rawEntry, String snippetFolder) { + if (rawEntry instanceof JSONObject entry) { + return entry; + } + if (!(rawEntry instanceof String reference) || !reference.startsWith("snippet/")) { + return null; + } + String resolved = reference.startsWith(snippetFolder) + ? reference + : snippetFolder + reference.substring("snippet/".length()); + return PackValidationIo.readJson(new File(packFolder, resolved + ".json")); + } + + private static boolean isUnsafeRiverStream(String stream) { + return UNSAFE_RIVER_STREAMS.contains(stream) || stream.startsWith("RIVER_"); + } + + private static Set noiseStyles() { + Set styles = new HashSet<>(); + for (NoiseStyle style : NoiseStyle.values()) { + styles.add(style.name()); + } + return Set.copyOf(styles); + } + + private static JSONObject nestedObject(JSONObject owner, String field, String path, List errors) { + if (!owner.has(field)) { + return new JSONObject(); + } + return requireObject(owner, field, path + "." + field, errors); + } + + private static JSONObject requireObject(JSONObject owner, String field, String path, List errors) { + Object raw = owner.opt(field); + if (!(raw instanceof JSONObject object)) { + errors.add(path + " must be an object."); + return null; + } + return object; + } + + private static JSONObject resolveObject(File packFolder, Object raw, String snippetFolder, + String path, List errors) { + if (raw instanceof JSONObject object) { + return object; + } + if (raw instanceof String reference && reference.startsWith("snippet/")) { + String resolved = reference.startsWith(snippetFolder) + ? reference + : snippetFolder + reference.substring("snippet/".length()); + return PackValidationIo.readJson(new File(packFolder, resolved + ".json")); + } + errors.add(path + " must be an object or snippet reference."); + return null; + } + + private static boolean booleanValue(JSONObject object, String field, boolean defaultValue) { + Object raw = object.opt(field); + return raw instanceof Boolean value ? value : defaultValue; + } + + private static int integerValue(JSONObject object, String field, int defaultValue) { + Object raw = object.opt(field); + if (!(raw instanceof Number number) || !Double.isFinite(number.doubleValue())) { + return defaultValue; + } + return number.intValue(); + } + + private static double doubleValue(JSONObject object, String field, double defaultValue) { + Object raw = object.opt(field); + return raw instanceof Number number ? number.doubleValue() : defaultValue; + } + + private static String stringValue(JSONObject object, String field, String defaultValue) { + Object raw = object.opt(field); + return raw instanceof String value ? value : defaultValue; + } + + private static double noiseChanceValue(JSONObject owner, String field, double defaultValue) { + JSONObject chance = owner.optJSONObject(field); + return chance == null ? defaultValue : doubleValue(chance, "chance", defaultValue); + } + + record Validation(List errors, List warnings) { + Validation { + errors = List.copyOf(errors); + warnings = List.copyOf(warnings); + } + } + + private record DimensionRiverContext( + String dimensionKey, + JSONObject dimension, + JSONObject rivers, + Set regionKeys + ) { + } + + private enum RiverBiomeRole { + CHANNEL("SEA"), + BANK("SHORE"), + MOUTH("SEA"), + DRY("LAND"), + FLOODED_CAVE("CAVE"); + + private final String label; + + RiverBiomeRole(String label) { + this.label = label; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index a66070d44..fec3e2746 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -76,6 +76,9 @@ public final class PackValidator { } PackDimensionValidator.validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings); + PackRiverValidator.Validation riverValidation = PackRiverValidator.validate(packFolder, dimensionFiles); + addDistinct(blockingErrors, riverValidation.errors()); + addDistinct(warnings, riverValidation.warnings()); blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder)); PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder); addDistinct(blockingErrors, lootIssues.errors()); diff --git a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java index fdc1351ab..2d9b2ffd3 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java @@ -16,7 +16,6 @@ import art.arcane.iris.core.tools.IrisCreator; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.exceptions.IrisException; @@ -55,6 +54,7 @@ import java.util.function.Supplier; public final class StudioOpenCoordinator { private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L; private static final long STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS = 30L; + private static final long STUDIO_ENTRY_RELEASE_TIMEOUT_SECONDS = 15L; private static final long STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS = 30L; private static final long STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS = 120L; private static volatile StudioOpenCoordinator instance; @@ -103,10 +103,115 @@ public final class StudioOpenCoordinator { return closeWorldCoordinated(provider, worldName, world, true, project); } + public CompletableFuture teleportPlayerToProject( + IrisProject project, + Player player, + AtomicBoolean admission, + long deadlineNanos + ) { + if (project == null || player == null) { + return CompletableFuture.completedFuture(false); + } + AtomicBoolean activeAdmission = Objects.requireNonNull( + admission, + "Studio teleport admission"); + if (!isStudioTeleportAdmitted(activeAdmission, deadlineNanos)) { + return studioTeleportDeadlineFailure("entry loading"); + } + PlatformChunkGenerator provider = project.getActiveProvider(); + if (provider == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio runtime provider is unavailable.")); + } + World world = BukkitWorldBinding.world(provider.getTarget().getWorld()); + if (world == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio world is not loaded.")); + } + Location entryAnchor = WorldRuntimeControlService.get().resolveEntryAnchor(world); + if (entryAnchor == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio entry anchor could not be resolved.")); + } + EntryChunkResolution entryResolution = loadEntryChunk(world, entryAnchor); + CompletableFuture teleportOperation = beforeStudioTeleportDeadline( + entryResolution.chunk(), + activeAdmission, + deadlineNanos, + "entry loading") + .thenCompose(ignored -> { + if (!isStudioTeleportAdmitted(activeAdmission, deadlineNanos)) { + return studioTeleportDeadlineFailure("safe-entry resolution"); + } + return beforeStudioTeleportDeadline( + entryResolution.safeEntry(), + activeAdmission, + deadlineNanos, + "safe-entry resolution"); + }) + .thenCompose(entry -> { + if (entry == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio entry point could not be resolved.")); + } + if (System.nanoTime() >= deadlineNanos + || !activeAdmission.compareAndSet(true, false)) { + return studioTeleportDeadlineFailure("native teleport delegation"); + } + CompletableFuture teleport = + WorldRuntimeControlService.get().teleport(player, entry); + if (teleport == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio native teleport returned no completion future.")); + } + return teleport; + }); + return teleportOperation; + } + + private CompletableFuture beforeStudioTeleportDeadline( + CompletableFuture stage, + AtomicBoolean admission, + long deadlineNanos, + String stageName + ) { + if (stage == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio " + stageName + " returned no completion future.")); + } + long remainingNanos = deadlineNanos - System.nanoTime(); + if (!admission.get() || remainingNanos <= 0L) { + return studioTeleportDeadlineFailure(stageName); + } + CompletableFuture bounded = new CompletableFuture<>(); + stage.whenComplete((value, failure) -> { + if (failure == null) { + bounded.complete(value); + } else { + bounded.completeExceptionally(failure); + } + }); + CompletableFuture.delayedExecutor(remainingNanos, TimeUnit.NANOSECONDS) + .execute(() -> bounded.completeExceptionally(new TimeoutException( + "Studio teleport deadline expired during " + stageName + "."))); + return bounded; + } + + private boolean isStudioTeleportAdmitted(AtomicBoolean admission, long deadlineNanos) { + return admission.get() && System.nanoTime() < deadlineNanos; + } + + private CompletableFuture studioTeleportDeadlineFailure(String stageName) { + return CompletableFuture.failedFuture(new TimeoutException( + "Studio teleport deadline expired before " + stageName + ".")); + } + private void executeOpen(StudioOpenRequest request, CompletableFuture future) { World world = null; PlatformChunkGenerator provider = null; CompletableFuture entryLoadFuture = null; + CompletableFuture entryUseFuture = null; + CompletableFuture nativeTeleportFuture = null; try { long openStart = System.nanoTime(); long t = openStart; @@ -158,35 +263,75 @@ public final class StudioOpenCoordinator { } t = logStudioPhase(request, "resolve_entry_anchor", t, openStart); - updateStage(request, "load_entry_chunk", 0.80D); - int entryChunkX = entryAnchor.getBlockX() >> 4; - int entryChunkZ = entryAnchor.getBlockZ() >> 4; - try { - entryLoadFuture = loadEntryChunk(world, entryChunkX, entryChunkZ); - entryLoads.register(request.worldName(), entryLoadFuture); - entryLoadFuture.get(STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (TimeoutException e) { - throw new IllegalStateException("Studio entry chunk did not load in time at " - + entryChunkX + "," + entryChunkZ + " — chunk system may be stalled.", e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Studio entry chunk load was interrupted at " - + entryChunkX + "," + entryChunkZ + ".", e); + long entryPrecomputeStartedAt = System.nanoTime(); + CompletableFuture preparedEntryChunks = CompletableFuture.completedFuture(null); + if (requiresLoadedEntry(request)) { + if (!(provider instanceof BukkitChunkGenerator bukkitGenerator)) { + throw new IllegalStateException( + "Studio runtime provider cannot prepare its entry chunks."); + } + preparedEntryChunks = bukkitGenerator.prepareStudioEntryChunks( + world, + entryAnchor.getBlockX() >> 4, + entryAnchor.getBlockZ() >> 4 + ); } - t = logStudioPhase(request, "load_entry_chunk", t, openStart); - updateStage(request, "resolve_safe_entry", 0.84D); - Location safeEntry; - try { - safeEntry = WorldRuntimeControlService.get().resolveSafeEntry(world, entryAnchor) - .get(5L, TimeUnit.SECONDS); - } catch (TimeoutException e) { - throw new IllegalStateException("Studio entry point resolution timed out — region thread may be stalled."); + updateStage(request, "prepare_structure_rings", 0.79D); + endStudioEntryBootstrap(world, provider); + t = logStudioPhase(request, "prepare_structure_rings", t, openStart); + + if (requiresLoadedEntry(request)) { + try { + preparedEntryChunks.get( + STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS, + TimeUnit.SECONDS + ); + } catch (TimeoutException e) { + throw new IllegalStateException( + "Studio entry chunk precompute did not finish in time.", e); + } + t = logOverlappedStudioPhase( + request, + "prepare_entry_chunks", + entryPrecomputeStartedAt, + openStart + ); } - if (safeEntry == null) { - throw new IllegalStateException("Studio entry point could not be resolved for world \"" + request.worldName() + "\"."); + + Location safeEntry = entryAnchor; + if (requiresLoadedEntry(request)) { + updateStage(request, "load_entry_chunk", 0.80D); + int entryChunkX = entryAnchor.getBlockX() >> 4; + int entryChunkZ = entryAnchor.getBlockZ() >> 4; + EntryChunkResolution entryResolution = loadEntryChunk(world, entryAnchor); + CompletableFuture useSettlement = new CompletableFuture<>(); + entryUseFuture = useSettlement; + entryLoadFuture = entryResolution.safeEntry().thenCompose(ignored -> useSettlement); + entryLoads.register(request.worldName(), entryLoadFuture); + try { + entryResolution.chunk().get(STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new IllegalStateException("Studio entry chunk did not load in time at " + + entryChunkX + "," + entryChunkZ + " — chunk system may be stalled.", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Studio entry chunk load was interrupted at " + + entryChunkX + "," + entryChunkZ + ".", e); + } + t = logStudioPhase(request, "load_entry_chunk", t, openStart); + + updateStage(request, "resolve_safe_entry", 0.84D); + try { + safeEntry = entryResolution.safeEntry().get(5L, TimeUnit.SECONDS); + } catch (TimeoutException e) { + throw new IllegalStateException("Studio entry point resolution timed out — region thread may be stalled."); + } + if (safeEntry == null) { + throw new IllegalStateException("Studio entry point could not be resolved for world \"" + request.worldName() + "\"."); + } + t = logStudioPhase(request, "resolve_safe_entry", t, openStart); } - t = logStudioPhase(request, "resolve_safe_entry", t, openStart); if (request.openKind().teleportThroughStandardEntry() && request.playerName() != null @@ -199,7 +344,12 @@ public final class StudioOpenCoordinator { Boolean teleported; try { - teleported = WorldRuntimeControlService.get().teleport(player, safeEntry).get(60L, TimeUnit.SECONDS); + nativeTeleportFuture = WorldRuntimeControlService.get().teleport(player, safeEntry); + if (nativeTeleportFuture == null) { + throw new IllegalStateException( + "Studio native teleport returned no completion future."); + } + teleported = nativeTeleportFuture.get(60L, TimeUnit.SECONDS); } catch (TimeoutException e) { throw new IllegalStateException("Studio teleport timed out — destination region may still be generating."); } @@ -209,8 +359,6 @@ public final class StudioOpenCoordinator { t = logStudioPhase(request, "teleport_standard_entry", t, openStart); } - endStudioEntryBootstrap(world, provider); - updateStage(request, "finalize_open", 1.00D); if (request.project() != null) { request.project().setActiveProvider(provider); @@ -221,11 +369,17 @@ public final class StudioOpenCoordinator { runOpenFinalizer(request.onDone(), world); t = logStudioPhase(request, "finalize_open", t, openStart); + settleEntryUseAfterOperation(entryUseFuture, nativeTeleportFuture); + if (entryLoadFuture != null) { + entryLoadFuture.get(STUDIO_ENTRY_RELEASE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + IrisLogging.info("Studio open: " + world.getName() + " ready in " + elapsedMillis(openStart) + "ms"); entryLoads.release(request.worldName(), entryLoadFuture); future.complete(new StudioOpenResult(world, safeEntry)); } catch (Throwable e) { + settleEntryUseAfterOperation(entryUseFuture, nativeTeleportFuture); abandonStudioEntryBootstrap(world, e); IrisLogging.reportError("Studio open failed for world \"" + request.worldName() + "\".", e); if (!request.retainOnFailure()) { @@ -262,11 +416,23 @@ public final class StudioOpenCoordinator { + request.worldName() + "\".", unwrapFailure(cleanupError)); } } + } else if (entryLoadFuture != null) { + entryLoads.releaseAfterSuccessfulCompletion( + request.worldName(), + entryLoadFuture, + entryLoadFuture); } future.completeExceptionally(e); } } + static boolean requiresLoadedEntry(StudioOpenRequest request) { + Objects.requireNonNull(request, "Studio open request"); + return request.openKind().teleportThroughStandardEntry() + && request.playerName() != null + && !request.playerName().isBlank(); + } + private void deferFailedOpenCleanupToRestart( PlatformChunkGenerator provider, String worldName, @@ -278,7 +444,12 @@ public final class StudioOpenCoordinator { worldName, new IllegalStateException("Studio cleanup deferred across the queued server restart.")); } - entryLoads.release(worldName, entryLoadFuture); + if (entryLoadFuture != null) { + entryLoads.releaseAfterSuccessfulCompletion( + worldName, + entryLoadFuture, + entryLoadFuture); + } } private boolean transientWorldStorageExists(String worldName) { @@ -305,6 +476,22 @@ public final class StudioOpenCoordinator { return now; } + private long logOverlappedStudioPhase( + StudioOpenRequest request, + String phase, + long phaseStart, + long openStart + ) { + long now = System.nanoTime(); + IrisLogging.info("[Studio timing] world=%s kind=%s phase=%s duration=%dms cumulative=%dms", + request.worldName(), + request.openKind().name().toLowerCase(Locale.ROOT), + phase, + TimeUnit.NANOSECONDS.toMillis(now - phaseStart), + TimeUnit.NANOSECONDS.toMillis(now - openStart)); + return now; + } + private void runOpenFinalizer(Consumer finalizer, World world) throws InterruptedException, ExecutionException, TimeoutException { if (finalizer == null) { @@ -331,14 +518,12 @@ public final class StudioOpenCoordinator { duration); } - private CompletableFuture loadEntryChunk(World world, int chunkX, int chunkZ) { - if (!J.isFolia()) { - return loadEntryChunkAsync(world, chunkX, chunkZ); - } - return scheduleEntryChunkRetention(world, chunkX, chunkZ); - } - - private CompletableFuture loadEntryChunkAsync(World world, int chunkX, int chunkZ) { + private EntryChunkResolution loadEntryChunk(World world, Location entryAnchor) { + int chunkX = entryAnchor.getBlockX() >> 4; + int chunkZ = entryAnchor.getBlockZ() >> 4; + CompletableFuture chunkFuture = new CompletableFuture<>(); + CompletableFuture safeEntryFuture = new CompletableFuture<>(); + EntryChunkResolution resolution = new EntryChunkResolution(chunkFuture, safeEntryFuture); CompletableFuture requested; try { requested = WorldRuntimeControlService.get().requestChunkAsync( @@ -348,56 +533,67 @@ public final class StudioOpenCoordinator { true, true); } catch (Throwable throwable) { - return CompletableFuture.failedFuture(throwable); + failEntryResolution(resolution, throwable); + return resolution; } if (requested == null) { - return CompletableFuture.failedFuture(new IllegalStateException( + failEntryResolution(resolution, new IllegalStateException( "Entry-chunk async request did not return a future at " + chunkX + "," + chunkZ + ".")); + return resolution; } - return requested.thenCompose(chunk -> { - if (chunk == null) { - return CompletableFuture.failedFuture(new IllegalStateException( - "Entry-chunk async request returned no chunk at " + chunkX + "," + chunkZ + ".")); + requested.whenComplete((chunk, failure) -> { + if (failure != null) { + failEntryResolution(resolution, failure); + return; + } + if (chunk == null) { + failEntryResolution(resolution, new IllegalStateException( + "Entry-chunk async request returned no chunk at " + chunkX + "," + chunkZ + ".")); + return; + } + Runnable resolve = () -> { + chunkFuture.complete(chunk); + try { + Location safeEntry = WorldRuntimeControlService.findTopSafeStudioLocation(world, entryAnchor); + safeEntryFuture.complete(safeEntry); + } catch (Throwable resolutionFailure) { + failEntryResolution(resolution, resolutionFailure); + } + }; + try { + if (J.isOwnedByCurrentRegion(world, chunkX, chunkZ)) { + resolve.run(); + return; + } + if (!J.runRegion(world, chunkX, chunkZ, resolve)) { + failEntryResolution(resolution, new IllegalStateException( + "Failed to resolve the entry chunk on its owning region at " + + chunkX + "," + chunkZ + ".")); + } + } catch (Throwable schedulingFailure) { + failEntryResolution(resolution, schedulingFailure); } - return scheduleEntryChunkRetention(world, chunkX, chunkZ); }); + return resolution; } - private CompletableFuture scheduleEntryChunkRetention(World world, int chunkX, int chunkZ) { - CompletableFuture loaded = new CompletableFuture<>(); - try { - J.s(() -> retainAndConfirmEntryChunk(world, chunkX, chunkZ) - .whenComplete((ignored, throwable) -> complete(loaded, throwable))); - } catch (Throwable throwable) { - loaded.completeExceptionally(throwable); - } - return loaded; + private void failEntryResolution(EntryChunkResolution resolution, Throwable failure) { + resolution.chunk().completeExceptionally(failure); + resolution.safeEntry().completeExceptionally(failure); } - private CompletableFuture retainAndConfirmEntryChunk(World world, int chunkX, int chunkZ) { - CompletableFuture confirmed = new CompletableFuture<>(); - try { - world.addPluginChunkTicket( - chunkX, - chunkZ, - BukkitPlatform.plugin()); - } catch (Throwable throwable) { - confirmed.completeExceptionally(throwable); - return confirmed; - } - if (!J.runRegion(world, chunkX, chunkZ, () -> confirmed.complete(null))) { - confirmed.completeExceptionally(new IllegalStateException( - "Failed to confirm entry-chunk region at " + chunkX + "," + chunkZ + ".")); - } - return confirmed; - } - - private void complete(CompletableFuture target, Throwable throwable) { - if (throwable == null) { - target.complete(null); + private void settleEntryUseAfterOperation( + CompletableFuture entryUseFuture, + CompletableFuture operation + ) { + if (entryUseFuture == null) { return; } - target.completeExceptionally(throwable); + if (operation == null) { + entryUseFuture.complete(null); + return; + } + operation.whenComplete((ignored, failure) -> entryUseFuture.complete(null)); } private void endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) { @@ -405,19 +601,28 @@ public final class StudioOpenCoordinator { throw new IllegalStateException("Studio runtime provider cannot finish its entry bootstrap."); } AtomicBoolean activationClaim = new AtomicBoolean(true); - CompletableFuture activation = J.sfut(() -> { + CompletableFuture> scheduledActivation = J.sfut(() -> { if (!activationClaim.compareAndSet(true, false)) { INMS.get().abandonStudioStructureBootstrap(world); - return; + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio native structure activation was cancelled before it began.")); } try { - INMS.get().completeStudioStructureBootstrap(world); - bukkitGenerator.endStudioEntryBootstrap(); + CompletableFuture nativeActivation = + INMS.get().completeStudioStructureBootstrap(world); + if (nativeActivation == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio native structure activation returned no completion future.")); + } + return nativeActivation; } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Studio native structure state could not be activated after entry bootstrap.", e); + return CompletableFuture.failedFuture(new IllegalStateException( + "Studio native structure state could not be activated after entry bootstrap.", e)); } }); + CompletableFuture activation = scheduledActivation + .thenCompose(nativeActivation -> nativeActivation) + .thenCompose(ignored -> J.sfut(bukkitGenerator::endStudioEntryBootstrap)); try { activation.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (InterruptedException e) { @@ -910,7 +1115,7 @@ public final class StudioOpenCoordinator { }; } - private Throwable unwrapFailure(Throwable throwable) { + private static Throwable unwrapFailure(Throwable throwable) { Throwable cursor = throwable; while (cursor instanceof CompletionException || cursor instanceof ExecutionException) { if (cursor.getCause() == null) { @@ -1053,6 +1258,12 @@ public final class StudioOpenCoordinator { } } + private record EntryChunkResolution( + CompletableFuture chunk, + CompletableFuture safeEntry + ) { + } + static final class EntryLoadRegistry { private final ConcurrentHashMap> entryLoads; diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java index cc624419d..a95614204 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java @@ -21,6 +21,7 @@ import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Levelled; import org.bukkit.block.data.Waterlogged; import org.bukkit.entity.Player; import org.bukkit.event.world.TimeSkipEvent; @@ -430,6 +431,62 @@ public final class WorldRuntimeControlService { return null; } + static Location findTopSafeStudioLocation(World world, Location source) { + Location dryLocation = findTopSafeLocation(world, source); + if (dryLocation != null) { + return dryLocation; + } + + int sourceX = source.getBlockX(); + int sourceZ = source.getBlockZ(); + int chunkX = sourceX >> 4; + int chunkZ = sourceZ >> 4; + if (!world.isChunkLoaded(chunkX, chunkZ)) { + return null; + } + + int minimumFloorY = world.getMinHeight(); + int maximumFloorY = world.getMaxHeight() - 3; + if (minimumFloorY > maximumFloorY) { + return null; + } + + int minimumX = chunkX << 4; + int minimumZ = chunkZ << 4; + int maximumX = minimumX + 15; + int maximumZ = minimumZ + 15; + for (int radius = 0; radius <= MAX_SAFE_ENTRY_HORIZONTAL_RADIUS; radius++) { + for (int offsetX = -radius; offsetX <= radius; offsetX++) { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { + if (Math.max(Math.abs(offsetX), Math.abs(offsetZ)) != radius) { + continue; + } + + int x = sourceX + offsetX; + int z = sourceZ + offsetZ; + if (x < minimumX || x > maximumX || z < minimumZ || z > maximumZ) { + continue; + } + + Location waterLocation = findSafeWaterSurfaceLocationInColumn( + world, + x, + z, + minimumFloorY, + maximumFloorY, + source.getYaw(), + source.getPitch() + ); + if (waterLocation != null) { + return waterLocation; + } + } + } + } + + return null; + } + private static Location findSafeLocationInColumn( World world, int x, @@ -458,6 +515,48 @@ public final class WorldRuntimeControlService { return null; } + private static Location findSafeWaterSurfaceLocationInColumn( + World world, + int x, + int z, + int minimumFloorY, + int maximumFloorY, + float yaw, + float pitch + ) { + int surfaceY = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES); + if (surfaceY <= minimumFloorY || surfaceY > maximumFloorY) { + return null; + } + + Block surface = world.getBlockAt(x, surfaceY, z); + if (!isStableWater(surface)) { + return null; + } + + Block support = world.getBlockAt(x, surfaceY - 1, z); + if (!isStableWater(support) && !isSafeFloor(support)) { + return null; + } + + Block feet = world.getBlockAt(x, surfaceY + 1, z); + Block head = world.getBlockAt(x, surfaceY + 2, z); + if (!isClearEntryBlock(feet) || !isClearEntryBlock(head)) { + return null; + } + + return new Location(world, x + BLOCK_CENTER, surfaceY + 1D, z + BLOCK_CENTER, yaw, pitch); + } + + private static boolean isStableWater(Block block) { + if (block.getType() != Material.WATER || !block.isLiquid()) { + return false; + } + + BlockData blockData = block.getBlockData(); + return blockData instanceof Levelled levelled && levelled.getLevel() == 0; + } + private static boolean isSafeFloor(Block block) { Material material = block.getType(); if (material == null diff --git a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java index 90c42b1e7..e07dd7bec 100644 --- a/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/StudioSVC.java @@ -59,6 +59,7 @@ import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.common.scheduling.J; import org.bukkit.Bukkit; import org.bukkit.World; +import org.bukkit.entity.Player; import java.io.File; import java.io.IOException; @@ -82,6 +83,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Supplier; import java.util.regex.Pattern; @@ -93,6 +95,7 @@ import art.arcane.volmlib.util.localization.MessageArgument; public class StudioSVC implements IrisService { public static final String WORKSPACE_NAME = "packs"; private static final long DOWNLOAD_SHUTDOWN_POLL_SECONDS = 15L; + private static final long STUDIO_PLAYER_TELEPORT_TIMEOUT_SECONDS = 10L; private static final Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+"); private static final AtomicCache counter = new AtomicCache<>(); private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue(); @@ -474,6 +477,28 @@ public class StudioSVC implements IrisService { return activeProject != null && activeProject.isOpen(); } + public CompletableFuture teleportToActiveProject(Player player) { + Player target = Objects.requireNonNull(player, "Studio teleport player"); + AtomicBoolean admission = new AtomicBoolean(true); + long deadlineNanos = System.nanoTime() + + TimeUnit.SECONDS.toNanos(STUDIO_PLAYER_TELEPORT_TIMEOUT_SECONDS); + CompletableFuture transition = studioTransitions.submit(() -> { + IrisProject project = activeProject; + if (project == null || !project.isOpen()) { + return CompletableFuture.failedFuture(new IllegalStateException( + "No active Studio project is available for teleport.")); + } + return StudioOpenCoordinator.get().teleportPlayerToProject( + project, + target, + admission, + deadlineNanos); + }); + transition.orTimeout(STUDIO_PLAYER_TELEPORT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + transition.whenComplete((ignored, failure) -> admission.set(false)); + return transition; + } + public void open(VolmitSender sender, String dimm) { open(sender, 1337, dimm); } diff --git a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java index cefa5cc3e..5dcd00d24 100644 --- a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java @@ -207,7 +207,7 @@ public class TreeSVC implements IrisService { @Override public int getFluidHeight() { - return worldAccess.getEngine().getDimension().getFluidHeight(); + return worldFluidHeight(engine); } @Override @@ -290,6 +290,10 @@ public class TreeSVC implements IrisService { } } + static int worldFluidHeight(Engine engine) { + return engine.getMinHeight() + engine.getDimension().getFluidHeight(); + } + /** * Finds a single object placement (which may contain more than one object) for the requirements species, location & * size diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 04aef3d5f..6b2b85a05 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -29,7 +29,16 @@ import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisInterpolator; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverOverride; +import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; +import art.arcane.iris.engine.object.IrisRiverWaterMode; import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; +import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; +import art.arcane.iris.engine.river.runtime.IrisRiverRuntimeContext; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; @@ -61,8 +70,8 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; @Data -@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators"}) -@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators"}) +@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime"}) +@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime"}) public class IrisComplex implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); @@ -96,14 +105,22 @@ public class IrisComplex implements DataProvider { private ProceduralStream shoreBiomeStream; private ProceduralStream baseBiomeStream; private ProceduralStream baseBiomeIDStream; + private ProceduralStream naturalTrueBiomeStream; private ProceduralStream trueBiomeStream; private ProceduralStream trueBiomeDerivativeStream; + private ProceduralStream naturalHeightStream; private ProceduralStream heightStream; private ProceduralStream roundedHeighteightStream; private ProceduralStream maxHeightStream; private ProceduralStream overlayStream; private ProceduralStream heightFluidStream; + private ProceduralStream naturalSlopeStream; private ProceduralStream slopeStream; + private ProceduralStream riverSurfaceStream; + private ProceduralStream riverDistanceStream; + private ProceduralStream riverFlowStream; + private ProceduralStream riverCarveWeightStream; + private ProceduralStream riverWaterSurfaceStream; private ProceduralStream topSurfaceStream; private ProceduralStream terrainSurfaceDecoration; private ProceduralStream terrainCeilingDecoration; @@ -118,6 +135,7 @@ public class IrisComplex implements DataProvider { private IrisRegion focusRegion; private Map> generatorBounds; private Set generatorBiomes; + private IrisRiverRuntime riverRuntime; // Copy-on-write: reads happen per column on every burst thread; the synchronizedMap // monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map // per insert is cheap. Identity keying is load-bearing (IrisBiome is mutable/value-hashed). @@ -150,7 +168,7 @@ public class IrisComplex implements DataProvider { //@builder if (focusRegion != null) { prepareInferredBiomes(focusRegion); - focusRegion.getAllBiomes(this).forEach(this::registerGenerators); + focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators); } else { engine.getDimension().getRegions().forEach(regionKey -> { IrisRegion region = data.getRegionLoader().load(regionKey); @@ -158,7 +176,7 @@ public class IrisComplex implements DataProvider { return; } prepareInferredBiomes(region); - region.getAllBiomes(this).forEach(this::registerGenerators); + region.getNaturalBiomes(this).forEach(this::registerGenerators); }); } int interpolatorCount = generators.size(); @@ -246,25 +264,85 @@ public class IrisComplex implements DataProvider { bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z)) .convertAware2D(this::implode) .cache2D("baseBiomeStream", engine, cacheSize); - heightStream = ProceduralStream.of((x, z) -> { + naturalHeightStream = ProceduralStream.of((x, z) -> { IrisBiome b = focusBiome != null ? focusBiome : baseBiomeStream.get(x, z); return getHeight(engine, b, x, z, engine.getSeedManager().getHeight()); - }, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize); + }, Interpolated.DOUBLE).cache2DDouble("naturalHeightStream", engine, cacheSize); + naturalSlopeStream = naturalHeightStream.slope(3) + .cache2DDouble("naturalSlopeStream", engine, cacheSize); + naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, + b -> focusBiome)) + .cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream + .convertAware2D((h, x, z) -> + fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight)) + .cache2D("naturalTrueBiomeStream", engine, cacheSize); + if (engine.getDimension().getRivers() != null && engine.getDimension().getRivers().isEnabled()) { + ProceduralStream naturalOceanStream = createNaturalOceanStream( + naturalHeightStream, + bridgeStream, + focusBiome, + fluidHeight, + engine.getDimension().getRivers().getWater().getMode() + ).cache2D("naturalOceanStream", engine, cacheSize); + riverRuntime = new IrisRiverRuntime(new IrisRiverRuntimeContext( + engine.getSeedManager().getBodies(), + engine.getDimension().getRivers(), + data, + (int) Math.round(fluidHeight), + IrisEngineMantle.isRiverHydrologyEnabled(engine.getDimension()), + IrisEngineMantle.isRiverCaveHydrologyEnabled(engine.getDimension()), + blockingRiverRoutingPossible(engine), + variableMaxIncisionPossible(engine), + biomeRiverOverridesPossible(focusBiome, generatorBiomes), + (blockX, blockZ) -> naturalHeightBounds(engine, overlayNoise, blockX, blockZ), + naturalHeightStream, + naturalSlopeStream, + naturalOceanStream, + naturalTrueBiomeStream, + regionStream + )); + riverSurfaceStream = ProceduralStream.of( + (x, z) -> riverRuntime.sample(x, z), + Interpolated.of( + IrisRiverSurfaceSample::terrainHeight, + value -> IrisRiverSurfaceSample.none(value, fluidHeight) + ) + ) + .cache2D("riverSurfaceStream", engine, cacheSize); + } else { + riverSurfaceStream = naturalHeightStream.convert( + value -> IrisRiverSurfaceSample.none(value, fluidHeight) + ) + .cache2D("riverSurfaceStream-disabled", engine, cacheSize); + } + heightStream = riverSurfaceStream.convert(IrisRiverSurfaceSample::terrainHeight) + .cache2DDouble("heightStream", engine, cacheSize); roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .round(); slopeStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .slope(3).cache2DDouble("slopeStream", engine, cacheSize); trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, b -> focusBiome)) - .cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream - .convertAware2D((h, x, z) -> - fixBiomeType(h, baseBiomeStream.get(x, z), - regionStream.contextInjecting(engine, (c, xx, zz) -> c.getRegion().get(xx, zz)).get(x, z), x, z, fluidHeight)) + .cache2D("trueBiomeStream-focus", engine, cacheSize) : riverSurfaceStream + .convertAware2D((sample, x, z) -> resolveRiverSurfaceBiome(sample, x, z)) .cache2D("trueBiomeStream", engine, cacheSize); trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) .convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize); - heightFluidStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) - .max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize); + riverDistanceStream = riverSurfaceStream.convert(sample -> sample.river().present() + ? sample.river().distance() + : Double.MAX_VALUE) + .cache2DDouble("riverDistanceStream", engine, cacheSize); + riverFlowStream = riverSurfaceStream.convert(sample -> (double) sample.river().flow()) + .cache2DDouble("riverFlowStream", engine, cacheSize); + riverCarveWeightStream = riverSurfaceStream.convert(sample -> sample.river().carveWeight()) + .cache2DDouble("riverCarveWeightStream", engine, cacheSize); + riverWaterSurfaceStream = riverSurfaceStream.convert(IrisRiverSurfaceSample::waterSurfaceY) + .cache2DDouble("riverWaterSurfaceStream", engine, cacheSize); + heightFluidStream = ProceduralStream.ofDouble((x, z) -> Math.max( + heightStream.get(x, z), + riverWaterSurfaceStream.get(x, z) + )) + .cache2DDouble("heightFluidStream", engine, cacheSize); maxHeightStream = ProceduralStream.ofDouble((x, z) -> height); terrainSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize); @@ -290,6 +368,90 @@ public class IrisComplex implements DataProvider { //@done } + private boolean blockingRiverRoutingPossible(Engine engine) { + if (blocksRiverRouting(focusRegion == null ? null : focusRegion.getRiverOverride()) + || blocksRiverRouting(focusBiome == null ? null : focusBiome.getRiverOverride())) { + return true; + } + KList regions = engine.getDimension().getAllRegions(engine); + for (IrisRegion loadedRegion : regions) { + if (loadedRegion != null && blocksRiverRouting(loadedRegion.getRiverOverride())) { + return true; + } + } + KList biomes = engine.getDimension().getReachableBiomes(engine); + for (IrisBiome biome : biomes) { + if (biome != null && blocksRiverRouting(biome.getRiverOverride())) { + return true; + } + } + return false; + } + + private static boolean blocksRiverRouting(IrisRiverOverride override) { + return override != null && override.getRoutingPolicy() == IrisRiverRoutingPolicy.BLOCK; + } + + private boolean variableMaxIncisionPossible(Engine engine) { + if (changesMaxIncision(focusRegion == null ? null : focusRegion.getRiverOverride()) + || changesMaxIncision(focusBiome == null ? null : focusBiome.getRiverOverride())) { + return true; + } + KList regions = engine.getDimension().getAllRegions(engine); + for (IrisRegion loadedRegion : regions) { + if (loadedRegion != null && changesMaxIncision(loadedRegion.getRiverOverride())) { + return true; + } + } + KList biomes = engine.getDimension().getReachableBiomes(engine); + for (IrisBiome biome : biomes) { + if (biome != null && changesMaxIncision(biome.getRiverOverride())) { + return true; + } + } + return false; + } + + static boolean changesMaxIncision(IrisRiverOverride override) { + if (override == null || override.getMaxIncisionMultiplier() == null) { + return false; + } + double multiplier = override.getMaxIncisionMultiplier(); + return Double.isFinite(multiplier) && Double.compare(Math.max(0D, multiplier), 1D) != 0; + } + + static boolean biomeRiverOverridesPossible(IrisBiome focusBiome, Iterable naturalBiomes) { + if (focusBiome != null) { + return focusBiome.getRiverOverride() != null; + } + for (IrisBiome biome : naturalBiomes) { + if (biome != null && biome.getRiverOverride() != null) { + return true; + } + } + return false; + } + + static ProceduralStream createNaturalOceanStream( + ProceduralStream naturalHeightStream, + ProceduralStream bridgeStream, + IrisBiome focusBiome, + double fluidHeight, + IrisRiverWaterMode waterMode + ) { + if (focusBiome != null) { + boolean ocean = focusBiome.getInferredType() == InferredType.SEA; + return ProceduralStream.of((x, z) -> ocean, Interpolated.BOOLEAN); + } + if (waterMode == IrisRiverWaterMode.SEA_LEVEL) { + return bridgeStream.convert(type -> type == InferredType.SEA); + } + return ProceduralStream.of( + (x, z) -> naturalHeightStream.getDouble(x, z) < fluidHeight - 1D, + Interpolated.BOOLEAN + ); + } + public ProceduralStream getBiomeStream(InferredType type) { switch (type) { case CAVE: @@ -343,6 +505,63 @@ public class IrisComplex implements DataProvider { return null; } + private IrisBiome resolveRiverSurfaceBiome(IrisRiverSurfaceSample sample, double x, double z) { + if (riverRuntime != null) { + if (sample.subterranean()) { + return fixBiomeType( + sample.naturalHeight(), + baseBiomeStream.get(x, z), + regionStream.get(x, z), + x, + z, + fluidHeight + ); + } + IrisBiome riverBiome = riverRuntime.selectSurfaceBiome(sample, x, z); + if (riverBiome != null) { + return implode(riverBiome, x, z); + } + InferredType directFallback = directRiverFallback(sample.river()); + if (directFallback != null) { + IrisBiome baseBiome = baseBiomeStream.get(x, z); + return implode(baseBiome.withInferredType(directFallback), x, z); + } + if (sample.river().present() + && sample.river().state() == RiverRouteState.WET + && sample.river().section() == RiverSection.BANK) { + return fixBiomeType( + sample.terrainHeight(), + baseBiomeStream.get(x, z), + regionStream.get(x, z), + x, + z, + sample.waterSurfaceY() + ); + } + } + return fixBiomeType( + sample.terrainHeight(), + baseBiomeStream.get(x, z), + regionStream.get(x, z), + x, + z, + fluidHeight + ); + } + + static InferredType directRiverFallback(RiverSample river) { + if (!river.present()) { + return null; + } + if (river.state() == RiverRouteState.DRY) { + return InferredType.LAND; + } + return switch (river.section()) { + case CHANNEL, MOUTH -> InferredType.SEA; + default -> null; + }; + } + private IrisBiome fixBiomeType(Double height, IrisBiome biome, IrisRegion region, Double x, Double z, double fluidHeight) { IrisBiome resolved = resolveSurfaceBiome( height, @@ -491,6 +710,36 @@ public class IrisComplex implements DataProvider { return h; } + private NoiseBounds naturalHeightBounds( + Engine engine, + KList overlayNoise, + double x, + double z + ) { + double minimum = fluidHeight; + double maximum = fluidHeight; + for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) { + NoiseBounds bounds = gridSampleBounds( + engine, + frozenInterpolators[interpolatorIndex], + interpolatorIndex, + frozenGenerators[interpolatorIndex], + x, + z + ); + minimum += Math.min(bounds.min(), bounds.max()); + maximum += Math.max(bounds.min(), bounds.max()); + } + for (IrisShapedGeneratorStyle style : overlayNoise) { + minimum += Math.min(style.getMin(), style.getMax()); + maximum += Math.max(style.getMin(), style.getMax()); + } + return new NoiseBounds( + Math.max(0D, Math.min(engine.getHeight(), minimum)), + Math.max(0D, Math.min(engine.getHeight(), maximum)) + ); + } + private double getHeight(Engine engine, IrisBiome b, double x, double z, long seed) { return Math.max(Math.min(getInterpolatedHeight(engine, x, z, seed) + fluidHeight + overlayStream.get(x, z), engine.getHeight()), 0); } @@ -922,6 +1171,8 @@ public class IrisComplex implements DataProvider { } public void close() { - + if (riverRuntime != null) { + riverRuntime.close(); + } } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java index 58690dae0..831ffd8e9 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -30,7 +30,9 @@ import art.arcane.iris.engine.mantle.MantlePass; import art.arcane.iris.engine.mantle.components.MantleCarvingComponent; import art.arcane.iris.engine.mantle.components.MantleFloatingObjectComponent; import art.arcane.iris.engine.mantle.components.MantleObjectComponent; +import art.arcane.iris.engine.mantle.components.MantleRiverHydrologyComponent; import art.arcane.iris.engine.mantle.components.IrisStructureComponent; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.matter.IrisMatterContext; @@ -88,6 +90,7 @@ public class IrisEngineMantle implements EngineMantle { this.mantle = createMantle(engine); components = new KMap<>(); registerComponent(new MantleCarvingComponent(this)); + registerComponent(new MantleRiverHydrologyComponent(this)); object = new MantleObjectComponent(this); registerComponent(object); registerComponent(new MantleFloatingObjectComponent(this)); @@ -126,9 +129,14 @@ public class IrisEngineMantle implements EngineMantle { .mapToInt(MantleComponent::getRadius) .max() .orElse(0); - int cumulative = downstreamBlockRadius + passBlockRadius; - built[i] = new MantlePass(pass, Math.ceilDiv(cumulative, 16), downstreamBlockRadius); - downstreamBlockRadius = cumulative; + int passInputRadius = pass.stream() + .filter(MantleComponent::isEnabled) + .mapToInt(MantleComponent::getInputRadius) + .max() + .orElse(0); + int invocationRadius = downstreamBlockRadius + passBlockRadius; + built[i] = new MantlePass(pass, Math.ceilDiv(invocationRadius, 16), downstreamBlockRadius); + downstreamBlockRadius = invocationRadius + passInputRadius; } return List.of(built); @@ -157,7 +165,7 @@ public class IrisEngineMantle implements EngineMantle { @Override public void hotload() { disabledFlags.reset(); - for (var component : registeredComponents.values()) { + for (MantleComponent component : registeredComponents.values()) { component.hotload(); component.setEnabled(!getDisabledFlags().contains(component.getFlag())); } @@ -171,10 +179,22 @@ public class IrisEngineMantle implements EngineMantle { if (!getDimension().isCarvingEnabled()) { disabled.addIfMissing(ReservedFlag.CARVED); } + if (disabled.contains(ReservedFlag.CARVED) + || !isRiverHydrologyEnabled(getDimension())) { + disabled.addIfMissing(ReservedFlag.RIVER_HYDROLOGY); + } return Set.copyOf(disabled); }); } + static boolean isRiverHydrologyEnabled(IrisDimension dimension) { + return MantleRiverHydrologyComponent.isEnabledFor(dimension); + } + + static boolean isRiverCaveHydrologyEnabled(IrisDimension dimension) { + return MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension); + } + @Override public MantleObjectComponent getObjectComponent() { return object; diff --git a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java index 8df80bd79..0b6297747 100644 --- a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -68,8 +68,8 @@ public class UpperDimensionContext implements DataProvider { engine.getDimension(), engine.getData(), chunkHeight, - complex.getHeightStream(), - complex.getTrueBiomeStream(), + complex.getNaturalHeightStream(), + complex.getNaturalTrueBiomeStream(), complex.getRegionStream(), complex.getRockStream(), true @@ -94,7 +94,7 @@ public class UpperDimensionContext implements DataProvider { upperDim.getRegions().forEach(regionKey -> { IrisRegion region = upperData.getRegionLoader().load(regionKey); if (region != null) { - region.getAllBiomes(dataProvider).forEach(biome -> { + region.getNaturalBiomes(dataProvider).forEach(biome -> { allBiomes.add(biome); biome.getGenerators().forEach(link -> { IrisGenerator gen = link.getCachedGenerator(dataProvider); diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java index 15b3ed91b..f17974f1c 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java @@ -27,6 +27,8 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedActuator; import art.arcane.iris.engine.framework.EngineDecorator; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.documentation.BlockCoordinates; @@ -64,6 +66,12 @@ public class IrisDecorantActuator extends EngineAssignedActuator output, boolean multicore, ChunkContext context) { @@ -86,23 +94,25 @@ public class IrisDecorantActuator extends EngineAssignedActuator biomeCache = context.getBiome(); @@ -114,7 +108,11 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator data, IrisBiome biome, int height, int max) { - if (height != getDimension().getFluidHeight()) { + double localFluidHeight = getComplex().getRiverWaterSurfaceStream().get(realX, realZ); + if (height != Math.round(localFluidHeight)) { return; } - double complexFluidHeight = getComplex().getFluidHeight(); ProceduralStream heightStream = getComplex().getHeightStream(); - if (Math.round(heightStream.get(realX1, realZ)) >= complexFluidHeight - && Math.round(heightStream.get(realX_1, realZ)) >= complexFluidHeight - && Math.round(heightStream.get(realX, realZ1)) >= complexFluidHeight - && Math.round(heightStream.get(realX, realZ_1)) >= complexFluidHeight) { + ProceduralStream fluidStream = getComplex().getRiverWaterSurfaceStream(); + if (Math.round(heightStream.get(realX1, realZ)) >= Math.round(fluidStream.get(realX1, realZ)) + && Math.round(heightStream.get(realX_1, realZ)) >= Math.round(fluidStream.get(realX_1, realZ)) + && Math.round(heightStream.get(realX, realZ1)) >= Math.round(fluidStream.get(realX, realZ1)) + && Math.round(heightStream.get(realX, realZ_1)) >= Math.round(fluidStream.get(realX, realZ_1))) { return; } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java index 225567af6..cf743a475 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java @@ -58,7 +58,7 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { @BlockCoordinates public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, Hunk data, IrisBiome biome, InferredType inferredType, int height, int max) { - int fluidHeight = getDimension().getFluidHeight(); + int fluidHeight = (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(realX, realZ)); if (inferredType == InferredType.SHORE && height < fluidHeight) { return; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 6f2adc84f..e77b35d36 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -42,6 +42,8 @@ import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; @@ -244,6 +246,14 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisBiome getCaveOrMantleBiome(int x, int y, int z) { + RiverCaveHydrology hydrology = RiverCaveHydrologyStorage.getIfPresent( + getMantle().getMantle(), x, y, z); + if (hydrology != null && !hydrology.floodedBiomeKey().isEmpty()) { + IrisBiome biome = getData().getBiomeLoader().load(hydrology.floodedBiomeKey()); + if (biome != null) { + return biome; + } + } MatterCavern m = getMantle().getMantle().get(x, y, z, MatterCavern.class); if (m != null && m.getCustomBiome() != null && !m.getCustomBiome().isEmpty()) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java index 153da99d4..ed44a5490 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java @@ -157,7 +157,10 @@ public final class NativeStructurePlacementPlanner { } static boolean isSubmerged(Engine engine, int blockX, int blockZ) { - return engine.getHeight(blockX, blockZ, true) < engine.getDimension().getFluidHeight(); + int localFluidHeight = engine.getComplex() == null + ? engine.getDimension().getFluidHeight() + : (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ)); + return engine.getHeight(blockX, blockZ, true) < localFluidHeight; } private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java index 2c120b114..2aba47f07 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java @@ -3,6 +3,8 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterCavern; @@ -286,10 +288,12 @@ public final class StructureCaveAnchorResolver { int mantleY, int blockZ ) { + RiverCaveHydrology hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ); return acceptsAnchorFluid( placement.isUnderwater(), cavern, + hydrology, mantleY, engine.getDimension().getCaveLavaHeight()); } @@ -300,6 +304,19 @@ public final class StructureCaveAnchorResolver { int mantleY, int defaultLavaHeight ) { + return acceptsAnchorFluid(underwater, cavern, null, mantleY, defaultLavaHeight); + } + + static boolean acceptsAnchorFluid( + boolean underwater, + MatterCavern cavern, + RiverCaveHydrology hydrology, + int mantleY, + int defaultLavaHeight + ) { + if (hydrology != null && hydrology.protectsPlacement()) { + return false; + } if (cavern == null || !cavern.isCavern()) { return false; } @@ -313,7 +330,15 @@ public final class StructureCaveAnchorResolver { } private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) { - return engine.getMantle().getMantle().get(blockX, mantleY, blockZ, MatterCavern.class); + MatterCavern baseline = engine.getMantle().getMantle() + .get(blockX, mantleY, blockZ, MatterCavern.class); + RiverCaveHydrology hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); + return hydrology == null ? baseline : hydrology.asCavern(); + } + + private static RiverCaveHydrology hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) { + return RiverCaveHydrologyStorage.getIfPresent( + engine.getMantle().getMantle(), blockX, mantleY, blockZ); } static int toMantleY(int worldY, int worldMinHeight) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java index a7ae4cd2d..c32142391 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementScope.java @@ -6,12 +6,17 @@ import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.volmlib.util.collection.KList; +import java.lang.ref.WeakReference; +import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; +import java.util.List; import java.util.Objects; import java.util.Set; public final class StructurePlacementScope { + private static final List SCOPE_INDEXES = new ArrayList<>(); + private StructurePlacementScope() { } @@ -22,20 +27,93 @@ public final class StructurePlacementScope { int blockZ = (chunkZ << 4) + 8; KList placements = new KList<>(); Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + ScopeIndex index = scopeIndex(activeEngine); if (complex != null) { - IrisBiome biome = complex.getTrueBiomeStream().get(blockX, blockZ); - IrisBiome caveBiome = complex.getCaveBiomeStream().get(blockX, blockZ); - IrisRegion region = complex.getRegionStream().get(blockX, blockZ); - addUnique(placements, seen, biome == null ? null : biome.getStructures()); - addCaveUnique(placements, seen, caveBiome == null ? null : caveBiome.getStructures()); - addUnique(placements, seen, region == null ? null : region.getStructures()); + if (index.biome()) { + IrisBiome biome = complex.getTrueBiomeStream().get(blockX, blockZ); + addUnique(placements, seen, biome == null ? null : biome.getStructures()); + } + if (index.caveBiome()) { + IrisBiome caveBiome = complex.getCaveBiomeStream().get(blockX, blockZ); + addCaveUnique(placements, seen, caveBiome == null ? null : caveBiome.getStructures()); + } + if (index.region()) { + IrisRegion region = complex.getRegionStream().get(blockX, blockZ); + addUnique(placements, seen, region == null ? null : region.getStructures()); + } } - if (activeEngine.getDimension() != null) { + if (index.dimension()) { addUnique(placements, seen, activeEngine.getDimension().getStructures()); } return placements; } + private static ScopeIndex scopeIndex(Engine engine) { + int revision = engine.getCacheID(); + synchronized (SCOPE_INDEXES) { + for (int i = SCOPE_INDEXES.size() - 1; i >= 0; i--) { + CachedScopeIndex cached = SCOPE_INDEXES.get(i); + Engine indexedEngine = cached.engine().get(); + if (indexedEngine == null) { + SCOPE_INDEXES.remove(i); + continue; + } + if (indexedEngine == engine) { + if (cached.revision() == revision) { + return cached.index(); + } + SCOPE_INDEXES.remove(i); + break; + } + } + ScopeIndex index = buildScopeIndex(engine); + SCOPE_INDEXES.add(new CachedScopeIndex(new WeakReference<>(engine), revision, index)); + return index; + } + } + + private static ScopeIndex buildScopeIndex(Engine engine) { + boolean biome = false; + boolean caveBiome = false; + KList allBiomes = engine.getAllBiomes(); + if (allBiomes == null) { + biome = true; + caveBiome = true; + } else { + for (IrisBiome candidate : allBiomes) { + KList structures = candidate.getStructures(); + if (structures == null || structures.isEmpty()) { + continue; + } + biome = true; + for (IrisStructurePlacement placement : structures) { + if (placement != null && placement.resolvedAnchor().isCave()) { + caveBiome = true; + break; + } + } + } + } + boolean region = false; + if (engine.getDimension() != null) { + KList allRegions = engine.getDimension().getAllRegions(engine); + if (allRegions == null) { + region = true; + } else { + for (IrisRegion candidate : allRegions) { + if (candidate.getStructures() != null && !candidate.getStructures().isEmpty()) { + region = true; + break; + } + } + } + } + boolean dimension = engine.getDimension() != null + && engine.getDimension().getStructures() != null + && !engine.getDimension().getStructures().isEmpty(); + return new ScopeIndex(biome, caveBiome, region, dimension); + } + private static void addUnique(KList destination, Set seen, KList source) { @@ -61,4 +139,10 @@ public final class StructurePlacementScope { } } } + + private record CachedScopeIndex(WeakReference engine, int revision, ScopeIndex index) { + } + + private record ScopeIndex(boolean biome, boolean caveBiome, boolean region, boolean dimension) { + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java index 0d706e139..f49a0a676 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java @@ -21,7 +21,6 @@ import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.volmlib.util.math.RNG; -import art.arcane.volmlib.util.matter.MatterCavern; import lombok.EqualsAndHashCode; import lombok.Getter; import org.bukkit.Bukkit; @@ -112,7 +111,7 @@ public class WorldObjectPlacer implements IObjectPlacer { @Override public boolean isCarved(int x, int y, int z) { - return mantle.getMantle().get(x, y, z, MatterCavern.class) != null; + return mantle.isCarved(x, y, z); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java b/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java index 2ba61eacf..efad60ace 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java @@ -18,63 +18,712 @@ package art.arcane.iris.engine.framework.render; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeGeneratorLink; -import art.arcane.iris.util.project.interpolation.IrisInterpolation; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; +import art.arcane.iris.util.project.stream.ProceduralStream; import java.awt.Color; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; -import java.util.function.BiFunction; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.function.BooleanSupplier; -public class IrisRenderer { - private static final int BLUE = Color.BLUE.getRGB(); - private static final int YELLOW = Color.YELLOW.getRGB(); - private static final int GREEN = Color.GREEN.getRGB(); +public final class IrisRenderer { + private static final int BLUE = new Color(45, 91, 156).getRGB(); + private static final int YELLOW = new Color(211, 164, 67).getRGB(); + private static final int GREEN = new Color(78, 137, 83).getRGB(); + private static final int RIVER_CHANNEL = new Color(48, 112, 190).getRGB(); + private static final int RIVER_MOUTH = new Color(54, 164, 205).getRGB(); + private static final int RIVER_BANK = new Color(92, 146, 78).getRGB(); + private static final int DRY_CHANNEL = new Color(171, 128, 68).getRGB(); + private static final int DRY_BANK = new Color(132, 105, 62).getRGB(); + private static final int NO_RIVER = new Color(28, 31, 38).getRGB(); + private static final int DEEP_WATER = new Color(20, 48, 92).getRGB(); + private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB(); + private static final int LOWLAND = new Color(78, 128, 76).getRGB(); + private static final int HIGHLAND = new Color(151, 139, 92).getRGB(); + private static final int ROCK = new Color(126, 119, 112).getRGB(); + private static final int SNOW = new Color(226, 230, 232).getRGB(); private final Engine renderer; public IrisRenderer(Engine renderer) { - this.renderer = renderer; + this.renderer = Objects.requireNonNull(renderer, "renderer"); } public BufferedImage render(double sx, double sz, double size, int resolution, RenderType currentType) { + return render(sx, sz, size, resolution, currentType, () -> false); + } + + public BufferedImage render( + double sx, + double sz, + double size, + int resolution, + RenderType currentType, + BooleanSupplier cancelled + ) { + return render(sx, sz, size, resolution, currentType, cancelled, false); + } + + public BufferedImage renderStudio( + double sx, + double sz, + double size, + int resolution, + RenderType currentType, + BooleanSupplier cancelled + ) { + return render(sx, sz, size, resolution, currentType, cancelled, true); + } + + private BufferedImage render( + double sx, + double sz, + double size, + int resolution, + RenderType currentType, + BooleanSupplier cancelled, + boolean studio + ) { + if (!Double.isFinite(sx) || !Double.isFinite(sz) || !Double.isFinite(size) || size <= 0D) { + throw new IllegalArgumentException("Vision render coordinates and size must be finite"); + } + if (resolution < 1) { + throw new IllegalArgumentException("Vision render resolution must be positive"); + } + Objects.requireNonNull(currentType, "currentType"); + Objects.requireNonNull(cancelled, "cancelled"); + checkCancelled(cancelled); + BufferedImage image = new BufferedImage(resolution, resolution, BufferedImage.TYPE_INT_RGB); int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); - BiFunction colorFunction = (d, dx) -> 0; - - switch (currentType) { - case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> - colorFunction = (x, z) -> renderer.getComplex().getTrueBiomeStream().get(x, z).getColor(renderer, currentType).getRGB(); - case BIOME_LAND -> - colorFunction = (x, z) -> renderer.getComplex().getLandBiomeStream().get(x, z).getColor(renderer, currentType).getRGB(); - case BIOME_SEA -> - colorFunction = (x, z) -> renderer.getComplex().getSeaBiomeStream().get(x, z).getColor(renderer, currentType).getRGB(); - case REGION -> - colorFunction = (x, z) -> renderer.getComplex().getRegionStream().get(x, z).getColor(renderer.getComplex(), currentType).getRGB(); - case CAVE_LAND -> - colorFunction = (x, z) -> renderer.getComplex().getCaveBiomeStream().get(x, z).getColor(renderer, currentType).getRGB(); - case HEIGHT -> - colorFunction = (x, z) -> Color.getHSBColor(renderer.getComplex().getHeightStream().get(x, z).floatValue(), 1f, 1f).getRGB(); - case CONTINENT -> colorFunction = (x, z) -> { - IrisBiome b = renderer.getBiome((int) Math.round(x), renderer.getMaxHeight() - 1, (int) Math.round(z)); - IrisBiomeGeneratorLink g = b.getGenerators().get(0); - if (g.getMax() <= 0) return BLUE; - if (g.getMin() < 0) return YELLOW; - return GREEN; - }; + double step = size / resolution; + if (studio && currentType == RenderType.HEIGHT) { + renderHeightAtlas(pixels, resolution, sx, sz, step, renderer, cancelled); + return image; } + PixelShader shader = shader(currentType, step, studio); + if (studio && currentType == RenderType.RIVER) { + Arrays.fill(pixels, NO_RIVER); + renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, false); + return image; + } + if (studio && adaptiveStudioType(currentType)) { + renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled); + if (currentType == RenderType.BIOME) { + renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, true); + } + return image; + } + int groupSize = sampleGroup(step, resolution); - double x, z; - for (int i = 0; i < resolution; i++) { - x = IrisInterpolation.lerp(sx, sx + size, (double) i / (double) resolution); - for (int j = 0; j < resolution; j++) { - z = IrisInterpolation.lerp(sz, sz + size, (double) j / (double) resolution); - pixels[j * resolution + i] = colorFunction.apply(x, z); + for (int groupZ = 0; groupZ < resolution; groupZ += groupSize) { + checkCancelled(cancelled); + int maximumZ = Math.min(resolution, groupZ + groupSize); + for (int groupX = 0; groupX < resolution; groupX += groupSize) { + checkCancelled(cancelled); + int maximumX = Math.min(resolution, groupX + groupSize); + for (int pixelZ = groupZ; pixelZ < maximumZ; pixelZ++) { + double z = sz + step * pixelZ; + int row = pixelZ * resolution; + for (int pixelX = groupX; pixelX < maximumX; pixelX++) { + checkCancelled(cancelled); + double x = sx + step * pixelX; + pixels[row + pixelX] = shader.color(x, z); + } + } } } return image; } + + public static int riverColor(RiverSection section) { + Objects.requireNonNull(section, "section"); + return switch (section) { + case CHANNEL -> RIVER_CHANNEL; + case MOUTH -> RIVER_MOUTH; + case BANK -> RIVER_BANK; + case DRY_CHANNEL -> DRY_CHANNEL; + case DRY_BANK -> DRY_BANK; + case NONE -> NO_RIVER; + }; + } + + public static int heightColor(double height, double maximumHeight, double fluidHeight) { + double boundedMaximum = Math.max(1D, maximumHeight); + double boundedHeight = clamp(height, 0D, boundedMaximum); + double boundedFluid = clamp(fluidHeight, 0D, boundedMaximum); + if (boundedHeight <= boundedFluid && boundedFluid > 0D) { + return blend(DEEP_WATER, SHALLOW_WATER, boundedHeight / boundedFluid); + } + + double landRange = Math.max(1D, boundedMaximum - boundedFluid); + double land = clamp((boundedHeight - boundedFluid) / landRange, 0D, 1D); + if (land < 0.45D) { + return blend(LOWLAND, HIGHLAND, land / 0.45D); + } + if (land < 0.78D) { + return blend(HIGHLAND, ROCK, (land - 0.45D) / 0.33D); + } + return blend(ROCK, SNOW, (land - 0.78D) / 0.22D); + } + + static int sampleGroup(double step, int resolution) { + double absoluteStep = Math.abs(step); + if (!Double.isFinite(absoluteStep) || absoluteStep <= 0D) { + return 1; + } + return Math.max(1, Math.min(resolution, (int) Math.floor(16D / absoluteStep))); + } + + private PixelShader shader(RenderType currentType, double step, boolean studio) { + IrisComplex complex = renderer.getComplex(); + return switch (currentType) { + case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> biomeShader( + studio ? complex.getBaseBiomeStream() : complex.getTrueBiomeStream(), currentType); + case BIOME_LAND -> biomeShader(complex.getLandBiomeStream(), currentType); + case BIOME_SEA -> biomeShader(complex.getSeaBiomeStream(), currentType); + case REGION -> regionShader(complex, currentType); + case CAVE_LAND -> biomeShader(complex.getCaveBiomeStream(), currentType); + case HEIGHT -> heightShader(studio ? complex.getNaturalHeightStream() : complex.getHeightStream()); + case RIVER -> (double x, double z) -> riverColor(complex, x, z, step); + case CONTINENT -> studio + ? continentShader(complex.getBaseBiomeStream()) + : this::continentColor; + }; + } + + private static boolean adaptiveStudioType(RenderType type) { + return switch (type) { + case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, + CONTINENT -> true; + case HEIGHT, RIVER -> false; + }; + } + + private static void renderAdaptiveAtlas( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + PixelShader shader, + BooleanSupplier cancelled + ) { + int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step))); + int blockPixels = Integer.highestOneBit(maximumPixels); + AdaptiveSampler sampler = new AdaptiveSampler(pixels, resolution, startX, startZ, step, shader, cancelled); + for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) { + int height = Math.min(blockPixels, resolution - pixelZ); + for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) { + sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height); + } + } + } + + private static void renderHeightAtlas( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + Engine engine, + BooleanSupplier cancelled + ) { + IrisComplex complex = engine.getComplex(); + IrisDimension dimension = engine.getDimension(); + double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight(); + int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step))); + int blockPixels = Integer.highestOneBit(maximumPixels); + HeightSampler sampler = new HeightSampler( + pixels, + resolution, + startX, + startZ, + step, + complex.getNaturalHeightStream(), + engine.getHeight(), + fluidHeight, + cancelled + ); + for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) { + int height = Math.min(blockPixels, resolution - pixelZ); + for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) { + sampler.render(pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), height); + } + } + } + + private static void renderRiverAtlas( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + IrisComplex complex, + BooleanSupplier cancelled, + boolean composite + ) { + IrisRiverRuntime runtime = complex.getRiverRuntime(); + if (runtime == null) { + return; + } + int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step))); + int blockPixels = Integer.highestOneBit(maximumPixels); + for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) { + int height = Math.min(blockPixels, resolution - pixelZ); + for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) { + renderRiverBlock( + pixels, + resolution, + startX, + startZ, + step, + runtime, + cancelled, + composite, + pixelX, + pixelZ, + Math.min(blockPixels, resolution - pixelX), + height + ); + } + } + } + + private static void renderRiverBlock( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + IrisRiverRuntime runtime, + BooleanSupplier cancelled, + boolean composite, + int pixelX, + int pixelZ, + int width, + int height + ) { + checkCancelled(cancelled); + RiverSample sample = runtime.sampleFootprint( + startX + pixelX * step, + startZ + pixelZ * step, + startX + (pixelX + width) * step, + startZ + (pixelZ + height) * step + ); + if (!sample.present()) { + return; + } + if (width == 1 && height == 1) { + int index = pixelZ * resolution + pixelX; + int color = riverColor(sample.section()); + pixels[index] = composite ? riverCompositeColor(pixels[index], sample.section(), color) : color; + return; + } + int leftWidth = Math.max(1, width / 2); + int rightWidth = width - leftWidth; + int topHeight = Math.max(1, height / 2); + int bottomHeight = height - topHeight; + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, + pixelX, pixelZ, leftWidth, topHeight); + if (rightWidth > 0) { + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, + pixelX + leftWidth, pixelZ, rightWidth, topHeight); + } + if (bottomHeight > 0) { + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, + pixelX, pixelZ + topHeight, leftWidth, bottomHeight); + if (rightWidth > 0) { + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, + pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); + } + } + } + + private static int riverCompositeColor(int base, RiverSection section, int river) { + return switch (section) { + case CHANNEL, MOUTH -> river; + case BANK -> blend(base, river, 0.68D); + case DRY_CHANNEL -> blend(base, river, 0.88D); + case DRY_BANK -> blend(base, river, 0.58D); + case NONE -> base; + }; + } + + private PixelShader biomeShader(ProceduralStream stream, RenderType currentType) { + IdentityHashMap colors = new IdentityHashMap<>(); + return (double x, double z) -> { + IrisBiome biome = stream.get(x, z); + Integer color = colors.get(biome); + if (color == null) { + color = biome.getColor(renderer, currentType).getRGB(); + colors.put(biome, color); + } + return color; + }; + } + + private PixelShader regionShader(IrisComplex complex, RenderType currentType) { + ProceduralStream stream = complex.getRegionStream(); + IdentityHashMap colors = new IdentityHashMap<>(); + return (double x, double z) -> { + IrisRegion region = stream.get(x, z); + Integer color = colors.get(region); + if (color == null) { + color = region.getColor(complex, currentType).getRGB(); + colors.put(region, color); + } + return color; + }; + } + + private PixelShader heightShader(ProceduralStream stream) { + double maximumHeight = renderer.getHeight(); + IrisDimension dimension = renderer.getDimension(); + double fluidHeight = dimension == null ? 0D : dimension.getFluidHeight(); + return (double x, double z) -> heightColor(stream.getDouble(x, z), maximumHeight, fluidHeight); + } + + private int riverColor(IrisComplex complex, double x, double z, double step) { + IrisRiverRuntime runtime = complex.getRiverRuntime(); + if (runtime == null) { + return riverColor(RiverSection.NONE); + } + double endX = x + step; + double endZ = z + step; + RiverSample sample = runtime.sampleFootprint( + StrictMath.min(x, endX), + StrictMath.min(z, endZ), + StrictMath.max(x, endX), + StrictMath.max(z, endZ) + ); + return riverColor(sample.section()); + } + + private int continentColor(double x, double z) { + IrisBiome biome = renderer.getBiome( + (int) Math.round(x), + renderer.getMaxHeight() - 1, + (int) Math.round(z) + ); + return continentColor(biome); + } + + private PixelShader continentShader(ProceduralStream stream) { + IdentityHashMap colors = new IdentityHashMap<>(); + return (double x, double z) -> { + IrisBiome biome = stream.get(x, z); + Integer color = colors.get(biome); + if (color == null) { + color = continentColor(biome); + colors.put(biome, color); + } + return color; + }; + } + + private int continentColor(IrisBiome biome) { + if (biome == null) { + return GREEN; + } + List generators = biome.getGenerators(); + if (generators.isEmpty()) { + return GREEN; + } + IrisBiomeGeneratorLink generator = generators.get(0); + if (generator.getMax() <= 0D) { + return BLUE; + } + if (generator.getMin() < 0D) { + return YELLOW; + } + return GREEN; + } + + private static int blend(int first, int second, double progress) { + double bounded = clamp(progress, 0D, 1D); + int red = (int) Math.round(((first >> 16) & 0xFF) * (1D - bounded) + ((second >> 16) & 0xFF) * bounded); + int green = (int) Math.round(((first >> 8) & 0xFF) * (1D - bounded) + ((second >> 8) & 0xFF) * bounded); + int blue = (int) Math.round((first & 0xFF) * (1D - bounded) + (second & 0xFF) * bounded); + return (red << 16) | (green << 8) | blue; + } + + private static double clamp(double value, double minimum, double maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static void checkCancelled(BooleanSupplier cancelled) { + if (cancelled.getAsBoolean() || Thread.currentThread().isInterrupted()) { + throw new CancellationException("Vision render cancelled"); + } + } + + @FunctionalInterface + private interface PixelShader { + int color(double x, double z); + } + + private static final class AdaptiveSampler { + private final int[] pixels; + private final boolean[] sampled; + private final int resolution; + private final double startX; + private final double startZ; + private final double step; + private final PixelShader shader; + private final BooleanSupplier cancelled; + + private AdaptiveSampler( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + PixelShader shader, + BooleanSupplier cancelled + ) { + this.pixels = pixels; + this.sampled = new boolean[pixels.length]; + this.resolution = resolution; + this.startX = startX; + this.startZ = startZ; + this.step = step; + this.shader = shader; + this.cancelled = cancelled; + } + + private void render(int pixelX, int pixelZ, int width, int height) { + checkCancelled(cancelled); + if (width == 1 && height == 1) { + sample(pixelX, pixelZ); + return; + } + int maximumX = pixelX + width - 1; + int maximumZ = pixelZ + height - 1; + int centerX = pixelX + width / 2; + int centerZ = pixelZ + height / 2; + int color = sample(pixelX, pixelZ); + if (sample(maximumX, pixelZ) == color + && sample(pixelX, maximumZ) == color + && sample(maximumX, maximumZ) == color + && sample(centerX, centerZ) == color) { + fill(pixelX, pixelZ, width, height, color); + return; + } + int leftWidth = Math.max(1, width / 2); + int rightWidth = width - leftWidth; + int topHeight = Math.max(1, height / 2); + int bottomHeight = height - topHeight; + render(pixelX, pixelZ, leftWidth, topHeight); + if (rightWidth > 0) { + render(pixelX + leftWidth, pixelZ, rightWidth, topHeight); + } + if (bottomHeight > 0) { + render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight); + if (rightWidth > 0) { + render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); + } + } + } + + private int sample(int pixelX, int pixelZ) { + int index = pixelZ * resolution + pixelX; + if (!sampled[index]) { + pixels[index] = shader.color(startX + pixelX * step, startZ + pixelZ * step); + sampled[index] = true; + } + return pixels[index]; + } + + private void fill(int pixelX, int pixelZ, int width, int height, int color) { + for (int row = pixelZ; row < pixelZ + height; row++) { + int start = row * resolution + pixelX; + Arrays.fill(pixels, start, start + width, color); + Arrays.fill(sampled, start, start + width, true); + } + } + } + + private static final class HeightSampler { + private static final double MAXIMUM_INTERPOLATION_ERROR = 1.5D; + + private final int[] pixels; + private final double[] heights; + private final boolean[] sampled; + private final int resolution; + private final double startX; + private final double startZ; + private final double step; + private final ProceduralStream stream; + private final double maximumHeight; + private final double fluidHeight; + private final BooleanSupplier cancelled; + + private HeightSampler( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + ProceduralStream stream, + double maximumHeight, + double fluidHeight, + BooleanSupplier cancelled + ) { + this.pixels = pixels; + this.heights = new double[pixels.length]; + this.sampled = new boolean[pixels.length]; + this.resolution = resolution; + this.startX = startX; + this.startZ = startZ; + this.step = step; + this.stream = stream; + this.maximumHeight = maximumHeight; + this.fluidHeight = fluidHeight; + this.cancelled = cancelled; + } + + private void render(int pixelX, int pixelZ, int width, int height) { + checkCancelled(cancelled); + if (width == 1 && height == 1) { + pixels[pixelZ * resolution + pixelX] = heightColor( + sample(pixelX, pixelZ), maximumHeight, fluidHeight); + return; + } + int maximumX = pixelX + width - 1; + int maximumZ = pixelZ + height - 1; + int centerX = pixelX + width / 2; + int centerZ = pixelZ + height / 2; + double topLeft = sample(pixelX, pixelZ); + double topRight = sample(maximumX, pixelZ); + double bottomLeft = sample(pixelX, maximumZ); + double bottomRight = sample(maximumX, maximumZ); + if (matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, + centerX, pixelZ) + && matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, + pixelX, centerZ) + && matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, + maximumX, centerZ) + && matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, + centerX, maximumZ) + && matchesPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight, + centerX, centerZ)) { + fillPlane(pixelX, pixelZ, width, height, topLeft, topRight, bottomLeft, bottomRight); + return; + } + int leftWidth = Math.max(1, width / 2); + int rightWidth = width - leftWidth; + int topHeight = Math.max(1, height / 2); + int bottomHeight = height - topHeight; + render(pixelX, pixelZ, leftWidth, topHeight); + if (rightWidth > 0) { + render(pixelX + leftWidth, pixelZ, rightWidth, topHeight); + } + if (bottomHeight > 0) { + render(pixelX, pixelZ + topHeight, leftWidth, bottomHeight); + if (rightWidth > 0) { + render(pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); + } + } + } + + private boolean matchesPlane( + int pixelX, + int pixelZ, + int width, + int height, + double topLeft, + double topRight, + double bottomLeft, + double bottomRight, + int sampleX, + int sampleZ + ) { + double predicted = interpolate( + pixelX, + pixelZ, + width, + height, + topLeft, + topRight, + bottomLeft, + bottomRight, + sampleX, + sampleZ + ); + return Math.abs(sample(sampleX, sampleZ) - predicted) <= MAXIMUM_INTERPOLATION_ERROR; + } + + private void fillPlane( + int pixelX, + int pixelZ, + int width, + int height, + double topLeft, + double topRight, + double bottomLeft, + double bottomRight + ) { + for (int row = pixelZ; row < pixelZ + height; row++) { + int offset = row * resolution; + for (int column = pixelX; column < pixelX + width; column++) { + double value = interpolate( + pixelX, + pixelZ, + width, + height, + topLeft, + topRight, + bottomLeft, + bottomRight, + column, + row + ); + pixels[offset + column] = heightColor(value, maximumHeight, fluidHeight); + } + } + } + + private double sample(int pixelX, int pixelZ) { + int index = pixelZ * resolution + pixelX; + if (!sampled[index]) { + heights[index] = stream.getDouble(startX + pixelX * step, startZ + pixelZ * step); + sampled[index] = true; + } + return heights[index]; + } + + private static double interpolate( + int pixelX, + int pixelZ, + int width, + int height, + double topLeft, + double topRight, + double bottomLeft, + double bottomRight, + int sampleX, + int sampleZ + ) { + double x = width <= 1 ? 0D : (sampleX - pixelX) / (double) (width - 1); + double z = height <= 1 ? 0D : (sampleZ - pixelZ) / (double) (height - 1); + double top = topLeft + (topRight - topLeft) * x; + double bottom = bottomLeft + (bottomRight - bottomLeft) * x; + return top + (bottom - top) * z; + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java b/core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java index 2ec4a17d1..50b79ce47 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java @@ -19,5 +19,5 @@ package art.arcane.iris.engine.framework.render; public enum RenderType { - BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, HEIGHT, OBJECT_LOAD, DECORATOR_LOAD, CONTINENT, LAYER_LOAD + BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, RIVER, HEIGHT, OBJECT_LOAD, DECORATOR_LOAD, CONTINENT, LAYER_LOAD } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 348ef617e..489a867a8 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -27,6 +27,8 @@ import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.mantle.components.MantleObjectComponent; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.data.B; @@ -102,7 +104,7 @@ public interface EngineMantle extends MatterGenerator { } default int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { - return ignoreFluid ? trueHeight(x, z) : Math.max(trueHeight(x, z), getEngine().getDimension().getFluidHeight()); + return ignoreFluid ? trueHeight(x, z) : Math.max(trueHeight(x, z), getFluidHeight(x, z)); } default int trueHeight(int x, int z) { @@ -110,6 +112,10 @@ public interface EngineMantle extends MatterGenerator { } default boolean isCarved(int x, int h, int z) { + RiverCaveHydrology hydrology = RiverCaveHydrologyStorage.getIfPresent(getMantle(), x, h, z); + if (hydrology != null) { + return hydrology.carves(); + } return getMantle().get(x, h, z, MatterCavern.class) != null; } @@ -125,13 +131,17 @@ public interface EngineMantle extends MatterGenerator { } default boolean isUnderwater(int x, int z) { - return getHighest(x, z, true) <= getFluidHeight(); + return getHighest(x, z, true) < getFluidHeight(x, z); } default int getFluidHeight() { return getEngine().getDimension().getFluidHeight(); } + default int getFluidHeight(int x, int z) { + return (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(x, z)); + } + default boolean isDebugSmartBore() { return getEngine().getDimension().isDebugSmartBore(); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java index cf60c9fba..8239530ee 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java @@ -34,6 +34,10 @@ public interface MantleComponent extends Comparable { int getRadius(); + default int getInputRadius() { + return 0; + } + default IrisData getData() { return getEngineMantle().getData(); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java index a17e4a4a7..8e4389547 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java @@ -31,6 +31,7 @@ import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IrisGeneratorStyle; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.function.Function3; @@ -181,6 +182,10 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (chunk == null) return; Matter matter = chunk.getOrCreate(y >> 4); + if ((t instanceof PlatformBlockState || t instanceof MatterCavern) + && hasProtectedHydrology(matter, x, y, z)) { + return; + } if (t instanceof PlatformBlockState) { clearDeferredPlacement(matter, x, y, z); } @@ -206,6 +211,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { } Matter matter = chunk.getOrCreate(y >> 4); + if (hasProtectedHydrology(matter, x, y, z)) { + return false; + } MatterCavern existing = matter.slice(MatterCavern.class).get(x & 15, y & 15, z & 15); if (existing != null) { return false; @@ -226,6 +234,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { } Matter matter = chunk.getOrCreate(y >> 4); + if (hasProtectedHydrology(matter, x, y, z)) { + return false; + } if (matter.hasSlice(PlatformBlockState.class)) { matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); } @@ -248,6 +259,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { + x + "," + y + "," + z); } Matter matter = chunk.getOrCreate(y >> 4); + if (hasProtectedHydrology(matter, x, y, z)) { + return; + } if (matter.hasSlice(PlatformBlockState.class)) { matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); } @@ -271,6 +285,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (matter == null) { return; } + if (hasProtectedHydrology(matter, x, y, z)) { + return; + } if (matter.hasSlice(PlatformBlockState.class)) { matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); } @@ -328,9 +345,22 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (matter == null || !matter.hasSlice(type)) { return; } + if ((type == PlatformBlockState.class || type == MatterCavern.class) + && hasProtectedHydrology(matter, x, y, z)) { + return; + } matter.getSlice(type).set(x & 15, y & 15, z & 15, null); } + private static boolean hasProtectedHydrology(Matter matter, int x, int y, int z) { + if (!matter.hasSlice(RiverCaveHydrology.class)) { + return false; + } + RiverCaveHydrology hydrology = matter.getSlice(RiverCaveHydrology.class) + .get(x & 15, y & 15, z & 15); + return hydrology != null && hydrology.protectsPlacement(); + } + private static void clearDeferredPlacement(Matter matter, int x, int y, int z) { if (matter.hasSlice(Identifier.class)) { matter.getSlice(Identifier.class).set(x & 15, y & 15, z & 15, null); @@ -425,6 +455,10 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { @Override public boolean isCarved(int x, int y, int z) { + RiverCaveHydrology hydrology = getDataIfPresent(x, y, z, RiverCaveHydrology.class); + if (hydrology != null) { + return hydrology.carves(); + } return getDataIfPresent(x, y, z, MatterCavern.class) != null; } @@ -449,15 +483,28 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { } Matter matter = chunk.get(section); - if (matter == null || !matter.hasSlice(MatterCavern.class)) { + if (matter == null) { continue; } - MatterSlice slice = matter.getSlice(MatterCavern.class); + MatterSlice cavernSlice = matter.hasSlice(MatterCavern.class) + ? matter.getSlice(MatterCavern.class) + : null; + MatterSlice hydrologySlice = matter.hasSlice(RiverCaveHydrology.class) + ? matter.getSlice(RiverCaveHydrology.class) + : null; + if (cavernSlice == null && hydrologySlice == null) { + continue; + } int sectionBaseY = section << 4; int sectionMaxY = Math.min(cappedHeight, sectionBaseY + 16); for (int y = sectionBaseY; y < sectionMaxY; y++) { - if (slice.get(localX, y & 15, localZ) != null) { + RiverCaveHydrology hydrology = hydrologySlice == null + ? null + : hydrologySlice.get(localX, y & 15, localZ); + if (hydrology != null) { + carvedColumn[y] = hydrology.carves() ? (byte) 1 : 0; + } else if (cavernSlice != null && cavernSlice.get(localX, y & 15, localZ) != null) { carvedColumn[y] = 1; } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java index a9e964f2e..e6d8addad 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java @@ -61,15 +61,14 @@ public final class CarveOrphanSweep { int[] surfaceHeights, int maxSurfaceBreakDepth, int worldCeilingY, - int[] surfaceFluidBoundaryStartY, - int fluidHeight + long[] surfaceFluidBoundaries ) { if (chunk == null) { return 0; } return sweep(surfaceHeights, maxSurfaceBreakDepth, 0, worldCeilingY, - new MantleCarveAccess(chunk, surfaceFluidBoundaryStartY, fluidHeight)); + new MantleCarveAccess(chunk, surfaceFluidBoundaries)); } public static int sweep(int[] surfaceHeights, int maxSurfaceBreakDepth, int worldFloorY, int worldCeilingY, CarveAccess access) { @@ -229,15 +228,13 @@ public final class CarveOrphanSweep { private static final class MantleCarveAccess implements CarveAccess { private final MantleChunk chunk; - private final int[] surfaceFluidBoundaryStartY; - private final int fluidHeight; + private final long[] surfaceFluidBoundaries; private MatterSlice cachedSlice; private int cachedSectionIndex = -1; - private MantleCarveAccess(MantleChunk chunk, int[] surfaceFluidBoundaryStartY, int fluidHeight) { + private MantleCarveAccess(MantleChunk chunk, long[] surfaceFluidBoundaries) { this.chunk = chunk; - this.surfaceFluidBoundaryStartY = surfaceFluidBoundaryStartY; - this.fluidHeight = fluidHeight; + this.surfaceFluidBoundaries = surfaceFluidBoundaries; } @Override @@ -261,7 +258,7 @@ public final class CarveOrphanSweep { @Override public boolean isProtected(int localX, int y, int localZ) { int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); - return SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, columnIndex, y, fluidHeight); + return SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, columnIndex, y); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java index fd838eb6d..9c3755002 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java @@ -23,6 +23,7 @@ import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.collection.KList; @@ -71,6 +72,12 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { discard(); return CommitResult.REJECTED_BOUNDS; } + RiverCaveHydrology hydrology = delegate.getData( + mutation.x(), mutation.y(), mutation.z(), RiverCaveHydrology.class); + if (hydrology != null && hydrology.protectsPlacement()) { + discard(); + return CommitResult.REJECTED_HYDROLOGY; + } } for (BufferedMutation mutation : mutations) { @@ -245,7 +252,8 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { enum CommitResult { COMMITTED, EMPTY, - REJECTED_BOUNDS + REJECTED_BOUNDS, + REJECTED_HYDROLOGY } private interface BufferedMutation { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java new file mode 100644 index 000000000..5a7f9a8c6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java @@ -0,0 +1,66 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.NoiseStyle; +import art.arcane.iris.engine.river.cave.RiverCaveGrottoShape; +import art.arcane.iris.engine.river.cave.RiverCavePlannerSettings; +import art.arcane.iris.engine.river.cave.RiverCaveSource; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.math.RNG; + +final class ConfiguredRiverGrottoShape implements RiverCaveGrottoShape { + private static final long SHAPE_SALT = 0x3C6EF372FE94F82BL; + private static final long WARP_X_SALT = 0xA54FF53A5F1D36F1L; + private static final long WARP_Y_SALT = 0x510E527FADE682D1L; + private static final long WARP_Z_SALT = 0x9B05688C2B3E6C1FL; + + private final CNG shape; + private final CNG warpX; + private final CNG warpY; + private final CNG warpZ; + private final double warpStrength; + + ConfiguredRiverGrottoShape( + long seed, + IrisData data, + IrisGeneratorStyle shapeStyle, + IrisGeneratorStyle warpStyle, + double warpStrength + ) { + IrisGeneratorStyle resolvedShape = shapeStyle == null + ? new IrisGeneratorStyle(NoiseStyle.FLAT) + : shapeStyle; + IrisGeneratorStyle resolvedWarp = warpStyle == null + ? new IrisGeneratorStyle(NoiseStyle.FLAT) + : warpStyle; + shape = resolvedShape.createNoCache(new RNG(seed ^ SHAPE_SALT), data); + warpX = resolvedWarp.createNoCache(new RNG(seed ^ WARP_X_SALT), data); + warpY = resolvedWarp.createNoCache(new RNG(seed ^ WARP_Y_SALT), data); + warpZ = resolvedWarp.createNoCache(new RNG(seed ^ WARP_Z_SALT), data); + this.warpStrength = Math.max(0D, warpStrength); + } + + @Override + public boolean contains( + RiverCaveSource source, + RiverCavePlannerSettings settings, + int offsetX, + int offsetY, + int offsetZ + ) { + double worldX = source.target().x() + offsetX; + double worldY = source.target().y() + offsetY; + double worldZ = source.target().z() + offsetZ; + double warpedX = offsetX + warpX.fitDouble(-warpStrength, warpStrength, worldX, worldY, worldZ); + double warpedY = offsetY + warpY.fitDouble(-warpStrength, warpStrength, worldY, worldZ, worldX); + double warpedZ = offsetZ + warpZ.fitDouble(-warpStrength, warpStrength, worldZ, worldX, worldY); + double horizontalRadius = settings.grottoHorizontalRadius(); + double verticalRadius = settings.grottoVerticalRadius(); + double normalized = (warpedX * warpedX / (horizontalRadius * horizontalRadius)) + + (warpedY * warpedY / (verticalRadius * verticalRadius)) + + (warpedZ * warpedZ / (horizontalRadius * horizontalRadius)); + double boundary = shape.fitDouble(-0.2D, 0.2D, worldX, worldY, worldZ); + return normalized <= 1D + boundary; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index a42b92f45..bebd01f88 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -197,7 +197,7 @@ public class IrisCaveCarver3D { double thresholdPenalty, IrisRange worldYRange, int[] precomputedSurfaceHeights, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, IrisRange overrideVerticalRange, CaveFluidSupportPlan fluidSupportPlan ) { @@ -318,7 +318,7 @@ public class IrisCaveCarver3D { surfaceBreakThresholdBoost, columnMaxY, fluidMaxY, - surfaceFluidBoundaryStartY, + surfaceFluidBoundaries, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, @@ -341,7 +341,7 @@ public class IrisCaveCarver3D { surfaceBreakThresholdBoost, columnMaxY, fluidMaxY, - surfaceFluidBoundaryStartY, + surfaceFluidBoundaries, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, @@ -367,7 +367,7 @@ public class IrisCaveCarver3D { surfaceBreakThresholdBoost, columnMaxY, fluidMaxY, - surfaceFluidBoundaryStartY, + surfaceFluidBoundaries, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, @@ -391,7 +391,7 @@ public class IrisCaveCarver3D { surfaceBreakThresholdBoost, columnMaxY, fluidMaxY, - surfaceFluidBoundaryStartY, + surfaceFluidBoundaries, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, @@ -422,7 +422,7 @@ public class IrisCaveCarver3D { double surfaceBreakThresholdBoost, int[] columnMaxY, int[] fluidMaxY, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, @@ -478,7 +478,7 @@ public class IrisCaveCarver3D { } int columnIndex = activeColumnIndices[activeIndex]; - if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, columnIndex, y, fluidHeight)) { + if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, columnIndex, y)) { continue; } planeColumnIndices[planeCount] = columnIndex; @@ -553,7 +553,7 @@ public class IrisCaveCarver3D { double surfaceBreakThresholdBoost, int[] columnMaxY, int[] fluidMaxY, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, @@ -615,7 +615,7 @@ public class IrisCaveCarver3D { } int columnIndex = activeColumnIndices[activeIndex]; - if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, columnIndex, y, fluidHeight)) { + if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, columnIndex, y)) { continue; } planeColumnIndices[planeCount] = columnIndex; @@ -712,7 +712,7 @@ public class IrisCaveCarver3D { double surfaceBreakThresholdBoost, int[] columnMaxY, int[] fluidMaxY, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, @@ -814,7 +814,7 @@ public class IrisCaveCarver3D { } int index = tileIndices[columnIndex]; - if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, index, yy, fluidHeight)) { + if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, index, yy)) { continue; } double localThreshold = passThreshold[index]; @@ -861,7 +861,7 @@ public class IrisCaveCarver3D { double surfaceBreakThresholdBoost, int[] columnMaxY, int[] fluidMaxY, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, @@ -909,7 +909,7 @@ public class IrisCaveCarver3D { int carveMaxY = Math.min(columnTopY, y + sampleStep - 1); for (int yy = y; yy <= carveMaxY; yy++) { - if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, index, yy, fluidHeight)) { + if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, index, yy)) { continue; } MatterCavern verticalMatter = matterByY[yy - minY]; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index dd76a03c0..aaa84ed52 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -70,7 +70,7 @@ public class IrisStructureComponent extends IrisMantleComponent { private static final MatterCavern CARVE_CAVERN = new MatterCavern(true, "", (byte) 3); public IrisStructureComponent(EngineMantle engineMantle) { - super(engineMantle, ReservedFlag.JIGSAW, 3); + super(engineMantle, ReservedFlag.JIGSAW, 4); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java index 3afd69d85..4313d0343 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java @@ -104,20 +104,19 @@ public class MantleCarvingComponent extends IrisMantleComponent { PrecisionStopwatch resolveStopwatch = PrecisionStopwatch.start(); List weightedProfiles = resolveWeightedProfiles(x, z, complex, resolverState); getEngineMantle().getEngine().getMetrics().getCarveResolve().put(resolveStopwatch.getMilliseconds()); - int fluidHeight = getDimension().getFluidHeight(); - int[] surfaceFluidBoundaryStartY = blendScratch.surfaceFluidBoundaryStartY; + long[] surfaceFluidBoundaries = blendScratch.surfaceFluidBoundaries; SurfaceFluidBoundaryPlan.fill( chunkSurfaceHeights, blendScratch.fieldSurfaceHeights, blendScratch.fieldHasFluid, + blendScratch.fieldFluidHeights, FIELD_SIZE, BLEND_RADIUS, - fluidHeight, - surfaceFluidBoundaryStartY + surfaceFluidBoundaries ); CaveFluidSupportPlan fluidSupportPlan = new CaveFluidSupportPlan(); for (WeightedProfile weightedProfile : weightedProfiles) { - carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, surfaceFluidBoundaryStartY, fluidSupportPlan); + carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, surfaceFluidBoundaries, fluidSupportPlan); } UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); @@ -132,8 +131,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { chunkSurfaceHeights, maxSurfaceBreakDepth(weightedProfiles), writer.getMantle().getWorldHeight() - 1, - surfaceFluidBoundaryStartY, - fluidHeight + surfaceFluidBoundaries ); } } @@ -148,11 +146,11 @@ public class MantleCarvingComponent extends IrisMantleComponent { @ChunkCoordinates private void carveProfile(WeightedProfile weightedProfile, MantleWriter writer, int cx, int cz, - int[] chunkSurfaceHeights, int[] surfaceFluidBoundaryStartY, + int[] chunkSurfaceHeights, long[] surfaceFluidBoundaries, CaveFluidSupportPlan fluidSupportPlan) { IrisCaveCarver3D carver = getCarver(weightedProfile.profile); carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, - weightedProfile.worldYRange, chunkSurfaceHeights, surfaceFluidBoundaryStartY, null, fluidSupportPlan); + weightedProfile.worldYRange, chunkSurfaceHeights, surfaceFluidBoundaries, null, fluidSupportPlan); } private void carveUpperTerrain(UpperDimensionContext upperCtx, List normalProfiles, @@ -475,7 +473,9 @@ public class MantleCarvingComponent extends IrisMantleComponent { private void prefillProfileFieldSamples(int startX, int startZ, IrisComplex complex, BlendScratch blendScratch) { fillFieldHeights(complex.getHeightStream(), startX, startZ, blendScratch.fieldSurfaceHeights); - fillFieldFluidPresence(complex.getFluidStream(), startX, startZ, blendScratch.fieldHasFluid); + fillFieldHeights(complex.getRiverWaterSurfaceStream(), startX, startZ, blendScratch.fieldFluidHeights); + fillFieldFluidPresence(complex.getFluidStream(), startX, startZ, blendScratch.fieldSurfaceHeights, + blendScratch.fieldFluidHeights, blendScratch.fieldHasFluid); fillFieldObjects(complex.getRegionStream(), startX, startZ, blendScratch.fieldRegions); fillFieldObjects(complex.getTrueBiomeStream(), startX, startZ, blendScratch.fieldSurfaceBiomes); fillFieldObjects(complex.getCaveBiomeStream(), startX, startZ, blendScratch.fieldCaveBiomes); @@ -499,11 +499,20 @@ public class MantleCarvingComponent extends IrisMantleComponent { } } - private void fillFieldFluidPresence(ProceduralStream stream, int startX, int startZ, boolean[] target) { + private void fillFieldFluidPresence( + ProceduralStream stream, + int startX, + int startZ, + double[] surfaceHeights, + double[] fluidHeights, + boolean[] target + ) { for (int fieldX = 0; fieldX < FIELD_SIZE; fieldX++) { int worldX = startX + fieldX; for (int fieldZ = 0; fieldZ < FIELD_SIZE; fieldZ++) { - target[(fieldX * FIELD_SIZE) + fieldZ] = B.isFluid(stream.get(worldX, startZ + fieldZ)); + int fieldIndex = (fieldX * FIELD_SIZE) + fieldZ; + target[fieldIndex] = B.isFluid(stream.get(worldX, startZ + fieldZ)) + && Math.round(surfaceHeights[fieldIndex]) < Math.round(fluidHeights[fieldIndex]); } } } @@ -697,12 +706,13 @@ public class MantleCarvingComponent extends IrisMantleComponent { private final IdentityHashMap activeProfiles = new IdentityHashMap<>(); private final List profileOrder = new ArrayList<>(); private final double[] fieldSurfaceHeights = new double[FIELD_SIZE * FIELD_SIZE]; + private final double[] fieldFluidHeights = new double[FIELD_SIZE * FIELD_SIZE]; private final boolean[] fieldHasFluid = new boolean[FIELD_SIZE * FIELD_SIZE]; + private final long[] surfaceFluidBoundaries = new long[CHUNK_AREA]; private final IrisRegion[] fieldRegions = new IrisRegion[FIELD_SIZE * FIELD_SIZE]; private final IrisBiome[] fieldSurfaceBiomes = new IrisBiome[FIELD_SIZE * FIELD_SIZE]; private final IrisBiome[] fieldCaveBiomes = new IrisBiome[FIELD_SIZE * FIELD_SIZE]; private final int[] chunkSurfaceHeights = new int[CHUNK_AREA]; - private final int[] surfaceFluidBoundaryStartY = new int[CHUNK_AREA]; private final double[] chunkSurfaceHeightSamples = new double[CHUNK_AREA]; } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index c351b8928..c1f611cc4 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -59,7 +59,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { private static final IrisObjectRotation ROTATION_NONE = IrisObjectRotation.of(0, 0, 0); public MantleFloatingObjectComponent(EngineMantle engineMantle) { - super(engineMantle, ReservedFlag.FLOATING_OBJECT, 2); + super(engineMantle, ReservedFlag.FLOATING_OBJECT, 3); } @Override diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index c20a08ac7..cfff8c348 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -48,6 +48,7 @@ import art.arcane.iris.engine.object.IrisProceduralPlacement; import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.ObjectPlaceMode; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -83,7 +84,7 @@ public class MantleObjectComponent extends IrisMantleComponent { private static final Set MISSING_LOAD_KEY_WARNED = ConcurrentHashMap.newKeySet(); public MantleObjectComponent(EngineMantle engineMantle) { - super(engineMantle, ReservedFlag.OBJECT, 1); + super(engineMantle, ReservedFlag.OBJECT, 2); } private static String placementMarker(IrisObject object, int id, String context) { @@ -581,11 +582,18 @@ public class MantleObjectComponent extends IrisMantleComponent { minDepthBelowSurface, anchorCache ); + RiverCaveHydrology hydrology = candidateY < 0 + ? null + : writer.getDataIfPresent(candidateX, candidateY, candidateZ, RiverCaveHydrology.class); + MatterCavern cavern = candidateY < 0 + ? null + : writer.getDataIfPresent(candidateX, candidateY, candidateZ, MatterCavern.class); if (candidateY < 0 || caveAnchorBiomeConflicts(candidateX, candidateY, candidateZ, expectedCaveBiomeKey) || !acceptsCaveAnchorFluid( underwater, - writer.getDataIfPresent(candidateX, candidateY, candidateZ, MatterCavern.class), + hydrology == null ? cavern : hydrology.asCavern(), + hydrology, candidateY, getDimension().getCaveLavaHeight())) { continue; @@ -596,6 +604,19 @@ public class MantleObjectComponent extends IrisMantleComponent { } static boolean acceptsCaveAnchorFluid(boolean underwater, MatterCavern cavern, int y, int lavaHeight) { + return acceptsCaveAnchorFluid(underwater, cavern, null, y, lavaHeight); + } + + static boolean acceptsCaveAnchorFluid( + boolean underwater, + MatterCavern cavern, + RiverCaveHydrology hydrology, + int y, + int lavaHeight + ) { + if (hydrology != null && hydrology.protectsPlacement()) { + return false; + } if (cavern == null || !cavern.isCavern()) { return false; } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java new file mode 100644 index 000000000..c095b66cc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java @@ -0,0 +1,157 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.river.cave.CavePosition; +import art.arcane.iris.engine.river.cave.CaveVoxel; +import art.arcane.iris.engine.river.cave.CaveVoxelView; +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.function.Function2; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; + +import java.util.Objects; + +final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.TunnelVoxelView { + private static final int CLOSED_COLUMN = Integer.MAX_VALUE; + private static final int CACHE_MISS = Integer.MIN_VALUE; + + private final Mantle mantle; + private final int worldHeight; + private final Function2 surfaceHeight; + private final Function2 compatibleFluid; + private final Long2IntOpenHashMap openFloorCache; + private final Long2IntOpenHashMap surfaceHeightCache; + + MantleRiverCaveVoxelView( + Mantle mantle, + int worldHeight, + Function2 surfaceHeight, + Function2 compatibleFluid + ) { + this.mantle = Objects.requireNonNull(mantle); + this.worldHeight = worldHeight; + this.surfaceHeight = Objects.requireNonNull(surfaceHeight); + this.compatibleFluid = Objects.requireNonNull(compatibleFluid); + openFloorCache = new Long2IntOpenHashMap(); + openFloorCache.defaultReturnValue(CACHE_MISS); + surfaceHeightCache = new Long2IntOpenHashMap(); + surfaceHeightCache.defaultReturnValue(CACHE_MISS); + } + + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > 0 && position.y() < worldHeight - 1; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + RiverCaveHydrology hydrology = dataIfPresent(position, RiverCaveHydrology.class); + if (hydrology != null && hydrology.carves()) { + return hydrology.isWet() ? CaveVoxel.COMPATIBLE_FLUID : CaveVoxel.CAVE_AIR; + } + MatterCavern cavern = dataIfPresent(position, MatterCavern.class); + if (cavern != null) { + if (cavern.isLava()) { + return CaveVoxel.LAVA; + } + if (cavern.getLiquid() == 1) { + return CaveVoxel.COMPATIBLE_FLUID; + } + return CaveVoxel.CAVE_AIR; + } + PlatformBlockState block = dataIfPresent(position, PlatformBlockState.class); + if (block == null) { + return position.y() > surfaceY(position.x(), position.z()) + ? CaveVoxel.CAVE_AIR + : CaveVoxel.SOLID; + } + if (!block.isFluid()) { + return CaveVoxel.SOLID; + } + if (IrisProceduralBlocks.materialKey(block).endsWith(":lava")) { + return CaveVoxel.LAVA; + } + PlatformBlockState expected = compatibleFluid.apply(position.x(), position.z()); + return expected != null + && IrisProceduralBlocks.materialKey(expected).equals(IrisProceduralBlocks.materialKey(block)) + ? CaveVoxel.COMPATIBLE_FLUID + : CaveVoxel.INCOMPATIBLE_FLUID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + if (!isInWorld(position) || voxelAt(position) == CaveVoxel.SOLID) { + return false; + } + if (position.y() > surfaceY(position.x(), position.z())) { + return true; + } + long key = Cache.key(position.x(), position.z()); + int openFloor = openFloorCache.get(key); + if (openFloor == CACHE_MISS) { + openFloor = resolveOpenFloor(position.x(), position.z()); + openFloorCache.put(key, openFloor); + } + return openFloor != CLOSED_COLUMN && position.y() >= openFloor; + } + + @Override + public RiverCaveAction riverActionAt(CavePosition position) { + RiverCaveHydrology hydrology = dataIfPresent(position, RiverCaveHydrology.class); + return hydrology == null ? null : hydrology.action(); + } + + private int resolveOpenFloor(int x, int z) { + int top = surfaceY(x, z); + CavePosition surface = new CavePosition(x, top, z); + if (voxelAt(surface) == CaveVoxel.SOLID) { + return CLOSED_COLUMN; + } + int y = top; + while (y > 0 && voxelAt(new CavePosition(x, y - 1, z)) != CaveVoxel.SOLID) { + y--; + } + return y; + } + + private int surfaceY(int x, int z) { + long key = Cache.key(x, z); + int cached = surfaceHeightCache.get(key); + if (cached != CACHE_MISS) { + return cached; + } + int resolved = Math.max(1, Math.min(worldHeight - 2, surfaceHeight.apply(x, z))); + surfaceHeightCache.put(key, resolved); + return resolved; + } + + private T dataIfPresent(CavePosition position, Class type) { + int chunkX = position.x() >> 4; + int chunkZ = position.z() >> 4; + TectonicPlate plate = mantle.getLoadedRegions().get(Mantle.key(chunkX >> 5, chunkZ >> 5)); + if (plate == null || plate.isClosed()) { + return null; + } + MantleChunk chunk = plate.get(chunkX & 31, chunkZ & 31); + int section = position.y() >> 4; + if (chunk == null || !chunk.exists(section)) { + return null; + } + Matter matter = chunk.get(section); + if (matter == null || !matter.hasSlice(type)) { + return null; + } + return matter.getSlice(type).get( + position.x() & 15, + position.y() & 15, + position.z() & 15 + ); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java new file mode 100644 index 000000000..40f847317 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java @@ -0,0 +1,707 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.mantle.ComponentFlag; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.IrisMantleComponent; +import art.arcane.iris.engine.mantle.MantleWriter; +import art.arcane.iris.engine.object.IrisRiverCaveFallback; +import art.arcane.iris.engine.object.IrisRiverCaveMode; +import art.arcane.iris.engine.object.IrisRiverCaves; +import art.arcane.iris.engine.object.IrisRiverExistingFluidPolicy; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRiverNetwork; +import art.arcane.iris.engine.river.RiverAnchor; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.cave.CavePosition; +import art.arcane.iris.engine.river.cave.CaveVoxel; +import art.arcane.iris.engine.river.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.river.cave.CaveVoxelView; +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveContainmentPlanner; +import art.arcane.iris.engine.river.cave.RiverCaveFluidPolicy; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.river.cave.RiverCaveMode; +import art.arcane.iris.engine.river.cave.RiverCavePlan; +import art.arcane.iris.engine.river.cave.RiverCavePlannerSettings; +import art.arcane.iris.engine.river.cave.RiverCavePlanningResult; +import art.arcane.iris.engine.river.cave.RiverCaveSource; +import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; +import art.arcane.iris.engine.river.runtime.IrisRiverTunnelSample; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@ComponentFlag(ReservedFlag.RIVER_HYDROLOGY) +public final class MantleRiverHydrologyComponent extends IrisMantleComponent { + private static final long CANDIDATE_SALT = 0x6A09E667F3BCC909L; + static final int PRIORITY = 1; + private static final int[] FALLBACK_X = {0, 1, -1, 0, 0}; + private static final int[] FALLBACK_Z = {0, 0, 0, 1, -1}; + private static final MantleFlag[] PREREQUISITES = {ReservedFlag.CARVED}; + private static final int[][] NEIGHBORS = { + {1, 0, 0}, {-1, 0, 0}, + {0, 1, 0}, {0, -1, 0}, + {0, 0, 1}, {0, 0, -1} + }; + private static final Comparator> ACTION_ORDER = Comparator + .comparingInt((Map.Entry entry) -> entry.getKey().x()) + .thenComparingInt(entry -> entry.getKey().y()) + .thenComparingInt(entry -> entry.getKey().z()); + + private final RiverCaveContainmentPlanner planner; + + public MantleRiverHydrologyComponent(EngineMantle engineMantle) { + super(engineMantle, ReservedFlag.RIVER_HYDROLOGY, PRIORITY); + planner = new RiverCaveContainmentPlanner(); + } + + @Override + public MantleFlag[] getPrerequisiteFlags() { + return PREREQUISITES; + } + + @Override + public int getInputRadius() { + if (!getDimension().isCarvingEnabled() + || getDimension().getRivers() == null + || !getDimension().getRivers().isEnabled()) { + return 0; + } + IrisRiverRuntime runtime = getComplex().getRiverRuntime(); + if (runtime == null) { + return 0; + } + return inputRadius(runtime.caveSettings(), tunnelHalo(runtime)); + } + + @Override + public void generateLayer(MantleWriter writer, int chunkX, int chunkZ, ChunkContext context) { + IrisRiverRuntime runtime = context.getComplex().getRiverRuntime(); + if (runtime == null || !getDimension().isCarvingEnabled()) { + return; + } + publishTunnels(writer, context, runtime, chunkX, chunkZ); + + IrisRiverCaves caves = runtime.caveSettings(); + if (caves.getMode() == IrisRiverCaveMode.SEALED || caves.getMaximumPerReach() <= 0) { + return; + } + + MantleRiverCaveVoxelView view = createView(writer, context); + int candidateHalo = candidateHalo(caves); + int minimumX = (chunkX << 4) - candidateHalo; + int minimumZ = (chunkZ << 4) - candidateHalo; + int maximumX = ((chunkX + 1) << 4) + candidateHalo; + int maximumZ = ((chunkZ + 1) << 4) + candidateHalo; + List anchors = runtime.candidateAnchors( + minimumX, + minimumZ, + maximumX, + maximumZ, + caves.getMinimumSpacing(), + CANDIDATE_SALT + ); + if (anchors.isEmpty()) { + return; + } + + RiverCavePlannerSettings settings = plannerSettings(caves, seed(), getData()); + List sources = new ArrayList<>(); + Map floodedBiomes = new HashMap<>(); + for (RiverAnchor anchor : anchors) { + if (!runtime.acceptsCaveAnchor(anchor)) { + continue; + } + SourceCandidate candidate = sourceFor(runtime, view, caves, anchor); + if (candidate == null) { + continue; + } + RiverCaveSource source = candidate.source(); + RiverCavePlan initial = planner.plan(view, source, settings); + if (!initial.accepted() + && caves.getFallback() == IrisRiverCaveFallback.GENERATE_GROTTO) { + source = fallbackSource(view, caves, settings, candidate, source); + } + if (source == null) { + continue; + } + sources.add(source); + floodedBiomes.put(source.sourceId(), runtime.selectFloodedCaveBiome(anchor)); + } + if (sources.isEmpty()) { + return; + } + + RiverCavePlanningResult result = planner.planAll(view, sources, settings); + MantleRiverCaveVoxelView revalidationView = createView(writer, context); + if (!preconditionsHold(revalidationView, result.baselinePreconditions())) { + return; + } + publishLocal(writer, chunkX, chunkZ, result, floodedBiomes); + } + + @Override + protected int computeRadius() { + return 0; + } + + public static boolean isEnabledFor(IrisDimension dimension) { + IrisRiverNetwork rivers = dimension.getRivers(); + if (!dimension.isUseMantle() + || !dimension.isCarvingEnabled() + || dimension.getDisabledComponents().contains(ReservedFlag.CARVED) + || dimension.getDisabledComponents().contains(ReservedFlag.RIVER_HYDROLOGY) + || rivers == null + || !rivers.isEnabled()) { + return false; + } + return true; + } + + public static boolean isCaveConnectionsEnabledFor(IrisDimension dimension) { + if (!isEnabledFor(dimension)) { + return false; + } + IrisRiverCaves caves = dimension.getRivers().getCaves(); + return caves != null + && caves.getMode() != IrisRiverCaveMode.SEALED + && caves.getMaximumPerReach() > 0; + } + + static int planningHalo(IrisRiverCaves caves) { + return cavePublicationRadius(caves) * 4; + } + + static int inputRadius(IrisRiverCaves caves, int tunnelRadius) { + if (caves.getMode() == IrisRiverCaveMode.SEALED || caves.getMaximumPerReach() <= 0) { + return tunnelRadius; + } + return Math.max(tunnelRadius, planningHalo(caves)); + } + + static int candidateHalo(IrisRiverCaves caves) { + return cavePublicationRadius(caves) * 3; + } + + static int cavePublicationRadius(IrisRiverCaves caves) { + int generatedRadius = generatedGrottoPublicationRadius(caves); + return switch (caves.getMode()) { + case SEALED -> 0; + case GENERATE_GROTTO -> generatedRadius; + case FLOOD_CLOSED_COMPONENT, GROTTO_OR_CLOSED_COMPONENT, WATERFALL_POOL -> + Math.max(closedComponentPublicationRadius(caves), generatedRadius); + }; + } + + static int closedComponentPublicationRadius(IrisRiverCaves caves) { + return caves.getMaxFloodRadius() + 1; + } + + static int generatedGrottoPublicationRadius(IrisRiverCaves caves) { + int targetOffset = caves.getFallback() == IrisRiverCaveFallback.GENERATE_GROTTO + ? caves.getThroatRadius() + 2 + : 0; + long maximumX = (long) targetOffset + caves.getGrottoHorizontalRadius() + 1L; + long maximumZ = caves.getGrottoHorizontalRadius(); + int grottoRadius = (int) StrictMath.ceil(StrictMath.sqrt( + maximumX * maximumX + maximumZ * maximumZ + )); + int throatRadius = targetOffset + caves.getThroatRadius(); + return Math.max(grottoRadius, throatRadius); + } + + static int tunnelHalo(IrisRiverRuntime runtime) { + return Math.max(1, (int) StrictMath.ceil(runtime.maximumChannelWidth() * 0.5D) + 1); + } + + static int waterHeadY(IrisRiverSurfaceSample sample, IrisRiverCaves caves) { + return (int) Math.round(sample.waterSurfaceY()) + caves.getWaterLevelOffset(); + } + + static boolean owns(int chunkX, int chunkZ, CavePosition position) { + return (position.x() >> 4) == chunkX && (position.z() >> 4) == chunkZ; + } + + static RiverCaveFluidPolicy fluidPolicy(IrisRiverExistingFluidPolicy policy) { + return switch (policy) { + case REJECT -> RiverCaveFluidPolicy.REJECT_EXISTING; + case ALLOW_SAME -> RiverCaveFluidPolicy.ALLOW_COMPATIBLE; + case REPLACE -> RiverCaveFluidPolicy.REPLACE_CONTAINED; + }; + } + + static boolean preconditionsHold( + CaveVoxelView view, + Map preconditions + ) { + for (Map.Entry entry : preconditions.entrySet()) { + CaveVoxelPrecondition expected = entry.getValue(); + if (view.voxelAt(entry.getKey()) != expected.voxel() + || view.isOpenToSurface(entry.getKey()) != expected.openToSurface()) { + return false; + } + } + return true; + } + + static TunnelPlan planTunnels( + CaveVoxelView view, + int chunkX, + int chunkZ, + int halo, + FootprintSampler footprintSampler, + TunnelSampler tunnelSampler, + SurfaceSampler surfaceSampler + ) { + int minimumX = (chunkX << 4) - halo; + int minimumZ = (chunkZ << 4) - halo; + int maximumX = ((chunkX + 1) << 4) + halo; + int maximumZ = ((chunkZ + 1) << 4) + halo; + if (!footprintSampler.sample(minimumX, minimumZ, maximumX, maximumZ).present()) { + return TunnelPlan.empty(); + } + ArrayList solidColumns = new ArrayList<>(); + for (int x = minimumX; x < maximumX; x++) { + for (int z = minimumZ; z < maximumZ; z++) { + IrisRiverTunnelSample sample = tunnelSampler.sample(x, z); + TunnelColumn column = createTunnelColumn(view, x, z, sample); + if (column != null) { + solidColumns.add(column); + } + } + } + if (solidColumns.isEmpty()) { + return TunnelPlan.empty(); + } + + Map candidateActions = mergeActions(solidColumns); + ArrayList containedColumns = new ArrayList<>(solidColumns.size()); + for (TunnelColumn column : solidColumns) { + if (isTunnelColumnContained(view, column, candidateActions.keySet(), surfaceSampler)) { + containedColumns.add(column); + } + } + Map actions = mergeActions(containedColumns); + LinkedHashMap preconditions = new LinkedHashMap<>(); + for (CavePosition position : actions.keySet()) { + preconditions.put(position, new CaveVoxelPrecondition( + view.voxelAt(position), + view.isOpenToSurface(position) + )); + } + for (CavePosition position : List.copyOf(actions.keySet())) { + for (int[] offset : NEIGHBORS) { + CavePosition neighbor = offset(position, offset); + if (actions.containsKey(neighbor) + || !view.isInWorld(neighbor) + || view.voxelAt(neighbor) != CaveVoxel.SOLID) { + continue; + } + actions.putIfAbsent(neighbor, RiverCaveAction.SEAL_GUARD); + preconditions.putIfAbsent(neighbor, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + } + } + return new TunnelPlan(Map.copyOf(actions), Map.copyOf(preconditions)); + } + + private static TunnelColumn createTunnelColumn( + CaveVoxelView view, + int x, + int z, + IrisRiverTunnelSample sample + ) { + if (sample == null) { + return null; + } + LinkedHashMap actions = new LinkedHashMap<>(); + for (int y = sample.bedY() + 1; y <= sample.ceilingY(); y++) { + CavePosition position = new CavePosition(x, y, z); + RiverCaveAction action = y <= sample.waterHeadY() + ? RiverCaveAction.WET_SOURCE + : RiverCaveAction.DRY_AIR; + if (!view.isInWorld(position) + || (view.voxelAt(position) != CaveVoxel.SOLID + && !matchesPublishedAction(view, position, action))) { + return null; + } + actions.put(position, action); + } + return actions.isEmpty() ? null : new TunnelColumn(actions); + } + + private static boolean isTunnelColumnContained( + CaveVoxelView view, + TunnelColumn column, + Set candidateActions, + SurfaceSampler surfaceSampler + ) { + for (CavePosition position : column.actions().keySet()) { + for (int[] offset : NEIGHBORS) { + CavePosition neighbor = offset(position, offset); + if (candidateActions.contains(neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return false; + } + if (view.voxelAt(neighbor) == CaveVoxel.SOLID || isSurfaceMouth(neighbor, surfaceSampler)) { + continue; + } + return false; + } + } + return true; + } + + private static boolean isSurfaceMouth(CavePosition position, SurfaceSampler surfaceSampler) { + IrisRiverSurfaceSample sample = surfaceSampler.sample(position.x(), position.z()); + if (!isWetChannelBed(sample) || sample.subterranean()) { + return false; + } + int bedY = (int) Math.round(sample.terrainHeight()); + int headY = (int) Math.round(sample.waterSurfaceY()); + return position.y() > bedY && position.y() <= headY; + } + + private static Map mergeActions(List columns) { + LinkedHashMap actions = new LinkedHashMap<>(); + for (TunnelColumn column : columns) { + actions.putAll(column.actions()); + } + return actions; + } + + private static boolean matchesPublishedAction( + CaveVoxelView view, + CavePosition position, + RiverCaveAction action + ) { + return view instanceof TunnelVoxelView tunnelView + && tunnelView.riverActionAt(position) == action; + } + + private static CavePosition offset(CavePosition position, int[] offset) { + return new CavePosition( + position.x() + offset[0], + position.y() + offset[1], + position.z() + offset[2] + ); + } + + private MantleRiverCaveVoxelView createView(MantleWriter writer, ChunkContext context) { + return new MantleRiverCaveVoxelView( + writer.getMantle(), + writer.getMantle().getWorldHeight(), + (x, z) -> context.getComplex().getRoundedHeighteightStream().get(x, z), + (x, z) -> context.getComplex().getFluidStream().get(x, z) + ); + } + + private void publishTunnels( + MantleWriter writer, + ChunkContext context, + IrisRiverRuntime runtime, + int chunkX, + int chunkZ + ) { + for (int attempt = 0; attempt < 2; attempt++) { + MantleRiverCaveVoxelView view = createView(writer, context); + TunnelPlan plan = planTunnels( + view, + chunkX, + chunkZ, + tunnelHalo(runtime), + runtime::sampleFootprint, + runtime::sampleTunnel, + runtime::sample + ); + MantleRiverCaveVoxelView revalidationView = createView(writer, context); + if (preconditionsHold(revalidationView, plan.preconditions())) { + publishTunnelLocal(writer, chunkX, chunkZ, plan); + return; + } + } + } + + static RiverCavePlannerSettings plannerSettings(IrisRiverCaves caves, long seed, IrisData data) { + int horizontalRadius = generatedGrottoPublicationRadius(caves); + int maximumDepth = caves.getMaxBoreDepth() + caves.getGrottoVerticalRadius() + 1; + int throatLength = caves.getMaxBoreDepth() + horizontalRadius; + return new RiverCavePlannerSettings( + horizontalRadius, + maximumDepth, + caves.getMaxFloodVolume(), + throatLength, + caves.getThroatRadius(), + caves.getGrottoHorizontalRadius(), + caves.getGrottoVerticalRadius(), + caves.getDryHeadroom(), + fluidPolicy(caves.getExistingFluidPolicy()), + new ConfiguredRiverGrottoShape( + seed, + data, + caves.getGrottoShapeStyle(), + caves.getGrottoWarpStyle(), + caves.getGrottoWarpStrength() + ), + caves.getMaxFloodRadius(), + caves.getMaxFloodDepth() + ); + } + + private SourceCandidate sourceFor( + IrisRiverRuntime runtime, + CaveVoxelView view, + IrisRiverCaves caves, + RiverAnchor anchor + ) { + int x = (int) StrictMath.floor(anchor.x()); + int z = (int) StrictMath.floor(anchor.z()); + IrisRiverSurfaceSample sample = runtime.sample(x, z); + if (!isWetChannelBed(sample)) { + return null; + } + int bedY = (int) Math.round(sample.terrainHeight()); + int headY = waterHeadY(sample, caves); + int entryY = Math.max(bedY, headY); + CavePosition entry = new CavePosition(x, entryY, z); + if (!view.isInWorld(entry)) { + return null; + } + + CavePosition existingTarget = findExistingTarget(view, caves, x, z, bedY, headY); + RiverCaveMode requestedMode = sourceMode( + caves.getMode(), + runtime.isTerminalCaveAnchor(anchor) + ); + CavePosition target; + RiverCaveMode sourceMode; + if (requestedMode == RiverCaveMode.GENERATED_GROTTO) { + target = findGeneratedTarget(view, caves, entry, headY, 0, 0); + sourceMode = RiverCaveMode.GENERATED_GROTTO; + } else if (requestedMode == RiverCaveMode.GROTTO_OR_CLOSED_COMPONENT) { + target = existingTarget; + sourceMode = RiverCaveMode.CLOSED_COMPONENT; + if (target == null) { + target = findGeneratedTarget(view, caves, entry, headY, 0, 0); + sourceMode = RiverCaveMode.GENERATED_GROTTO; + } + } else { + target = existingTarget; + sourceMode = requestedMode; + } + if (target == null && caves.getFallback() == IrisRiverCaveFallback.GENERATE_GROTTO) { + target = findGeneratedTarget(view, caves, entry, headY, 0, 0); + sourceMode = RiverCaveMode.GENERATED_GROTTO; + } + if (target == null) { + return null; + } + RiverCaveSource source = new RiverCaveSource(anchor.stableId(), entry, target, headY, sourceMode); + return new SourceCandidate(entry, headY, source); + } + + static boolean isWetChannelBed(IrisRiverSurfaceSample sample) { + return sample.river().present() + && sample.river().state() == RiverRouteState.WET + && sample.river().section() == RiverSection.CHANNEL + && sample.surfaceFluid(); + } + + static CavePosition findExistingTarget( + CaveVoxelView view, + IrisRiverCaves caves, + int x, + int z, + int bedY, + int headY + ) { + int maximumY = Math.min(bedY - 1, headY); + int minimumY = Math.max(1, bedY - caves.getMaxBoreDepth()); + for (int y = maximumY; y >= minimumY; y--) { + CavePosition position = new CavePosition(x, y, z); + CaveVoxel voxel = view.voxelAt(position); + if (voxel != CaveVoxel.SOLID) { + return position; + } + } + return null; + } + + static CavePosition findGeneratedTarget( + CaveVoxelView view, + IrisRiverCaves caves, + CavePosition entry, + int headY, + int offsetX, + int offsetZ + ) { + int preferredY = headY + caves.getDryHeadroom() - caves.getGrottoVerticalRadius(); + int maximumY = Math.min(Math.min(entry.y() - 1, headY), preferredY); + int minimumY = Math.max(1, entry.y() - caves.getMaxBoreDepth()); + for (int y = maximumY; y >= minimumY; y--) { + CavePosition target = new CavePosition(entry.x() + offsetX, y, entry.z() + offsetZ); + if (view.isInWorld(target) && view.voxelAt(target) == CaveVoxel.SOLID) { + return target; + } + } + return null; + } + + private RiverCaveSource fallbackSource( + CaveVoxelView view, + IrisRiverCaves caves, + RiverCavePlannerSettings settings, + SourceCandidate candidate, + RiverCaveSource rejected + ) { + int fallbackDistance = caves.getThroatRadius() + 2; + for (int index = 0; index < FALLBACK_X.length; index++) { + int offsetX = FALLBACK_X[index] * fallbackDistance; + int offsetZ = FALLBACK_Z[index] * fallbackDistance; + CavePosition target = findGeneratedTarget( + view, + caves, + candidate.entry(), + candidate.waterHeadY(), + offsetX, + offsetZ + ); + if (target == null || target.equals(rejected.target())) { + continue; + } + RiverCaveSource fallback = new RiverCaveSource( + rejected.sourceId(), + candidate.entry(), + target, + candidate.waterHeadY(), + RiverCaveMode.GENERATED_GROTTO + ); + if (planner.plan(view, fallback, settings).accepted()) { + return fallback; + } + } + return null; + } + + static RiverCaveMode sourceMode(IrisRiverCaveMode mode, boolean forcedTerminal) { + if (forcedTerminal) { + return RiverCaveMode.GENERATED_GROTTO; + } + return switch (mode) { + case FLOOD_CLOSED_COMPONENT -> RiverCaveMode.CLOSED_COMPONENT; + case GENERATE_GROTTO -> RiverCaveMode.GENERATED_GROTTO; + case GROTTO_OR_CLOSED_COMPONENT -> RiverCaveMode.GROTTO_OR_CLOSED_COMPONENT; + case WATERFALL_POOL -> RiverCaveMode.WATERFALL_POOL; + case SEALED -> throw new IllegalArgumentException("Sealed river caves do not create sources"); + }; + } + + private void publishLocal( + MantleWriter writer, + int chunkX, + int chunkZ, + RiverCavePlanningResult result, + Map floodedBiomes + ) { + Map owners = actionOwners(result); + ArrayList> actions = new ArrayList<>(result.actions().entrySet()); + actions.sort(ACTION_ORDER); + for (Map.Entry entry : actions) { + CavePosition position = entry.getKey(); + if (!owns(chunkX, chunkZ, position)) { + continue; + } + RiverCaveSource source = owners.get(position); + String biome = source == null ? "" : floodedBiomes.getOrDefault(source.sourceId(), ""); + if (entry.getValue() == RiverCaveAction.SEAL_GUARD) { + biome = ""; + } + writer.setData( + position.x(), + position.y(), + position.z(), + new RiverCaveHydrology(entry.getValue(), biome) + ); + } + } + + private void publishTunnelLocal( + MantleWriter writer, + int chunkX, + int chunkZ, + TunnelPlan plan + ) { + ArrayList> actions = new ArrayList<>(plan.actions().entrySet()); + actions.sort(ACTION_ORDER); + for (Map.Entry entry : actions) { + CavePosition position = entry.getKey(); + if (owns(chunkX, chunkZ, position)) { + writer.setData(position.x(), position.y(), position.z(), RiverCaveHydrology.of(entry.getValue())); + } + } + } + + private Map actionOwners(RiverCavePlanningResult result) { + Map owners = new LinkedHashMap<>(); + for (RiverCavePlan plan : result.plans()) { + if (!plan.accepted()) { + continue; + } + for (CavePosition position : plan.actions().keySet()) { + owners.put(position, plan.source()); + } + } + return owners; + } + + private record SourceCandidate( + CavePosition entry, + int waterHeadY, + RiverCaveSource source + ) { + } + + @FunctionalInterface + interface FootprintSampler { + RiverSample sample(double minimumX, double minimumZ, double maximumX, double maximumZ); + } + + @FunctionalInterface + interface TunnelSampler { + IrisRiverTunnelSample sample(int x, int z); + } + + @FunctionalInterface + interface SurfaceSampler { + IrisRiverSurfaceSample sample(int x, int z); + } + + interface TunnelVoxelView extends CaveVoxelView { + RiverCaveAction riverActionAt(CavePosition position); + } + + record TunnelPlan( + Map actions, + Map preconditions + ) { + static TunnelPlan empty() { + return new TunnelPlan(Map.of(), Map.of()); + } + } + + private record TunnelColumn(Map actions) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlan.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlan.java index ec0cf02e9..d01588e81 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlan.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlan.java @@ -32,16 +32,17 @@ final class SurfaceFluidBoundaryPlan { int[] chunkSurfaceHeights, double[] fieldSurfaceHeights, boolean[] fieldHasFluid, + double[] fieldFluidHeights, int fieldSize, int padding, - int fluidHeight, - int[] boundaryStartY + long[] boundaries ) { if (chunkSurfaceHeights == null || chunkSurfaceHeights.length < CHUNK_AREA - || boundaryStartY == null || boundaryStartY.length < CHUNK_AREA + || boundaries == null || boundaries.length < CHUNK_AREA || padding < 1 || fieldSize < CHUNK_SIZE + (padding * 2) || fieldSurfaceHeights == null || fieldSurfaceHeights.length < fieldSize * fieldSize - || fieldHasFluid == null || fieldHasFluid.length < fieldSize * fieldSize) { + || fieldHasFluid == null || fieldHasFluid.length < fieldSize * fieldSize + || fieldFluidHeights == null || fieldFluidHeights.length < fieldSize * fieldSize) { throw new IllegalArgumentException("Surface fluid boundary fields do not cover a padded chunk"); } @@ -51,44 +52,67 @@ final class SurfaceFluidBoundaryPlan { int fieldZ = localZ + padding; int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); int boundaryY = NO_BOUNDARY; + int boundaryEndY = Integer.MIN_VALUE; int surfaceY = chunkSurfaceHeights[columnIndex]; int fieldIndex = (fieldX * fieldSize) + fieldZ; + int fluidHeight = roundedHeight(fieldFluidHeights[fieldIndex]); if (fieldHasFluid[fieldIndex] && surfaceY < fluidHeight) { boundaryY = surfaceY; + boundaryEndY = fluidHeight; } - boundaryY = lowerBoundary(boundaryY, fieldSurfaceHeights, fieldHasFluid, - ((fieldX - 1) * fieldSize) + fieldZ, fluidHeight); - boundaryY = lowerBoundary(boundaryY, fieldSurfaceHeights, fieldHasFluid, - ((fieldX + 1) * fieldSize) + fieldZ, fluidHeight); - boundaryY = lowerBoundary(boundaryY, fieldSurfaceHeights, fieldHasFluid, - (fieldX * fieldSize) + fieldZ - 1, fluidHeight); - boundaryY = lowerBoundary(boundaryY, fieldSurfaceHeights, fieldHasFluid, - (fieldX * fieldSize) + fieldZ + 1, fluidHeight); - boundaryStartY[columnIndex] = boundaryY; + long boundary = expandBoundary(boundaryY, boundaryEndY, fieldSurfaceHeights, + fieldHasFluid, fieldFluidHeights, ((fieldX - 1) * fieldSize) + fieldZ); + boundary = expandBoundary(startY(boundary), endY(boundary), fieldSurfaceHeights, + fieldHasFluid, fieldFluidHeights, ((fieldX + 1) * fieldSize) + fieldZ); + boundary = expandBoundary(startY(boundary), endY(boundary), fieldSurfaceHeights, + fieldHasFluid, fieldFluidHeights, (fieldX * fieldSize) + fieldZ - 1); + boundaries[columnIndex] = expandBoundary(startY(boundary), endY(boundary), fieldSurfaceHeights, + fieldHasFluid, fieldFluidHeights, (fieldX * fieldSize) + fieldZ + 1); } } } - static boolean protects(int[] boundaryStartY, int columnIndex, int y, int fluidHeight) { - return boundaryStartY != null - && columnIndex >= 0 - && columnIndex < boundaryStartY.length - && y >= boundaryStartY[columnIndex] - && y <= fluidHeight; + static boolean protects(long[] boundaries, int columnIndex, int y) { + if (boundaries == null || columnIndex < 0 || columnIndex >= boundaries.length) { + return false; + } + long boundary = boundaries[columnIndex]; + return y >= startY(boundary) && y <= endY(boundary); } - private static int lowerBoundary( + static int startY(long boundary) { + return (int) (boundary >> 32); + } + + static int endY(long boundary) { + return (int) boundary; + } + + private static long expandBoundary( int currentBoundaryY, + int currentBoundaryEndY, double[] fieldSurfaceHeights, boolean[] fieldHasFluid, - int fieldIndex, - int fluidHeight + double[] fieldFluidHeights, + int fieldIndex ) { + int fluidHeight = roundedHeight(fieldFluidHeights[fieldIndex]); int neighborSurfaceY = (int) Math.round(fieldSurfaceHeights[fieldIndex]); if (!fieldHasFluid[fieldIndex] || neighborSurfaceY >= fluidHeight) { - return currentBoundaryY; + return boundary(currentBoundaryY, currentBoundaryEndY); } - return Math.min(currentBoundaryY, neighborSurfaceY + 1); + return boundary( + Math.min(currentBoundaryY, neighborSurfaceY + 1), + Math.max(currentBoundaryEndY, fluidHeight) + ); + } + + private static int roundedHeight(double height) { + return Double.isFinite(height) ? (int) Math.round(height) : Integer.MIN_VALUE; + } + + static long boundary(int startY, int endY) { + return ((long) startY << 32) | (endY & 0xffffffffL); } } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index 460042988..8ad292b6e 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -28,6 +28,8 @@ import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.documentation.ChunkCoordinates; @@ -105,49 +107,26 @@ public class IrisCarveModifier extends EngineAssignedModifier { - if (cavern == null) { - return; - } - - if (yy >= worldHeightSpan || yy <= 0) { - return; - } - - int rx = xx & 15; - int rz = zz & 15; - int columnIndex = PowerOfTwoCoordinates.packLocal16(rx, rz); - - if (upperSurfaceHeights != null && yy >= upperSurfaceHeights[columnIndex]) { - return; - } - - PlatformBlockState current = output.getRaw(rx, yy, rz); - boolean explicitCarveIntent = hasExplicitCarveIntent(cavern); - - if (shouldPreserveExistingFluid(cavern, current)) { - return; - } - - columnMasks[columnIndex].add(yy); - - if (!cavern.getCustomBiome().isEmpty()) { - scratch.customCaveBiomePresent = true; - } - - if (current.isAir() && !explicitCarveIntent) { - return; - } - - if (explicitCarveIntent) { - // Only a fluid cavern consumes the fluid sample, and on the maintenance path that - // sample is a full procedural stream evaluation, so never take it per voxel. - PlatformBlockState fluid = isFluidIntent(cavern) ? context.getFluid().get(rx, rz) : null; - output.setRaw(rx, yy, rz, resolveExplicitCarveState(cavern, fluid, LAVA, AIR)); - } else if (usesDefaultLava(caveLavaHeight, yy)) { - output.setRaw(rx, yy, rz, LAVA); - } else { - output.setRaw(rx, yy, rz, AIR); + CarveResolutionContext resolutionContext = new CarveResolutionContext( + output, + context, + scratch, + columnMasks, + upperSurfaceHeights, + worldHeightSpan, + caveLavaHeight + ); + CarveResolver carveResolver = new CarveResolver(resolutionContext); + mantleChunk.iterate(MatterCavern.class, (xx, yy, zz, cavern) -> carveResolver.apply( + xx, + yy, + zz, + cavern, + dataIfPresent(mantleChunk, xx, yy, zz, RiverCaveHydrology.class) + )); + mantleChunk.iterate(RiverCaveHydrology.class, (xx, yy, zz, hydrology) -> { + if (dataIfPresent(mantleChunk, xx, yy, zz, MatterCavern.class) == null) { + carveResolver.apply(xx, yy, zz, null, hydrology); } }); if (scratch.customCaveBiomePresent) { @@ -161,6 +140,11 @@ public class IrisCarveModifier extends EngineAssignedModifier { + RiverCaveHydrology hydrology = dataIfPresent( + mantleChunk, rx, yy, rz, RiverCaveHydrology.class); + if (hydrology != null && hydrology.protectsPlacement()) { + return; + } int worldX = rx + chunkBlockX; int worldZ = rz + chunkBlockZ; String customBiome = cavern.getCustomBiome(); @@ -179,14 +163,39 @@ public class IrisCarveModifier extends EngineAssignedModifier fluid; + case FALLING_WATER -> fallingFluidState(fluid); + case DRY_AIR -> air; + case SEAL_GUARD -> normalizeWaterlogging(current, null); + }; + } + + static PlatformBlockState normalizeWaterlogging(PlatformBlockState state, PlatformBlockState resultingFluid) { + if (state == null || B.isFluid(state) || !IrisProceduralBlocks.hasProperty(state, "waterlogged")) { + return state; + } + String target = resultingFluid != null && resultingFluid.isWater() ? "true" : "false"; + if (target.equals(IrisProceduralBlocks.propertyValue(state, "waterlogged"))) { + return state; + } + return state.withProperty("waterlogged", target); + } + + static PlatformBlockState normalizeHydrologyWaterlogging( + PlatformBlockState state, + MatterCavern baseline, + RiverCaveHydrology hydrology, + PlatformBlockState columnFluid + ) { + if (hydrology == null) { + return state; + } + MatterCavern composed = composeCavern(baseline, hydrology); + PlatformBlockState resultingFluid = isFluidIntent(composed) ? columnFluid : null; + return normalizeWaterlogging(state, resultingFluid); + } + + private static PlatformBlockState fallingFluidState(PlatformBlockState fluid) { + if (fluid == null || !IrisProceduralBlocks.hasProperty(fluid, "level")) { + return fluid; + } + if ("8".equals(IrisProceduralBlocks.propertyValue(fluid, "level"))) { + return fluid; + } + return fluid.withProperty("level", "8"); + } + + private final class CarveResolver { + private final CarveResolutionContext context; + + private CarveResolver(CarveResolutionContext context) { + this.context = context; + } + + private void apply( + int x, + int y, + int z, + MatterCavern baseline, + RiverCaveHydrology hydrology + ) { + if (y >= context.worldHeightSpan() || y <= 0) { + return; + } + + int localX = x & 15; + int localZ = z & 15; + int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); + if (context.upperSurfaceHeights() != null && y >= context.upperSurfaceHeights()[columnIndex]) { + return; + } + + PlatformBlockState current = context.output().getRaw(localX, y, localZ); + if (hydrology != null && hydrology.action() == RiverCaveAction.SEAL_GUARD) { + PlatformBlockState normalized = resolveHydrologyState(hydrology, current, null, AIR); + if (normalized != current) { + context.output().setRaw(localX, y, localZ, normalized); + } + return; + } + + MatterCavern cavern = composeCavern(baseline, hydrology); + if (cavern == null || shouldPreserveExistingFluid(cavern, current)) { + return; + } + + context.columnMasks()[columnIndex].add(y); + if (!cavern.getCustomBiome().isEmpty()) { + context.scratch().customCaveBiomePresent = true; + } + + boolean explicitCarveIntent = hasExplicitCarveIntent(cavern); + if (current.isAir() && !explicitCarveIntent) { + return; + } + + PlatformBlockState fluid = isFluidIntent(cavern) + ? context.chunkContext().getFluid().get(localX, localZ) + : null; + if (hydrology != null) { + context.output().setRaw(localX, y, localZ, + resolveHydrologyState(hydrology, current, fluid, AIR)); + return; + } + if (explicitCarveIntent) { + context.output().setRaw(localX, y, localZ, + resolveExplicitCarveState(cavern, fluid, LAVA, AIR)); + } else if (usesDefaultLava(context.caveLavaHeight(), y)) { + context.output().setRaw(localX, y, localZ, LAVA); + } else { + context.output().setRaw(localX, y, localZ, AIR); + } + } + } + + private record CarveResolutionContext( + Hunk output, + ChunkContext chunkContext, + IrisCarveScratch scratch, + CarveColumnMask[] columnMasks, + int[] upperSurfaceHeights, + int worldHeightSpan, + int caveLavaHeight + ) { + } + private void addInternalWallsFromMasks(CarveWallBuffer walls, CarveColumnMask[] columnMasks) { for (int columnIndex = 0; columnIndex < 256; columnIndex++) { CarveColumnMask columnMask = columnMasks[columnIndex]; @@ -291,18 +435,18 @@ public class IrisCarveModifier extends EngineAssignedModifier= 0) { - MatterCavern cavern = mc.get(rx, yy, rz, MatterCavern.class); + MatterCavern cavern = composedCavernAt(mc, rx, yy, rz); if (cavern != null) { - if (rz < 15 && mc.get(rx, yy, rz + 1, MatterCavern.class) == null) { + if (rz < 15 && composedCavernAt(mc, rx, yy, rz + 1) == null) { walls.put(rx, yy, rz + 1, cavern); } - if (rx < 15 && mc.get(rx + 1, yy, rz, MatterCavern.class) == null) { + if (rx < 15 && composedCavernAt(mc, rx + 1, yy, rz) == null) { walls.put(rx + 1, yy, rz, cavern); } - if (rz > 0 && mc.get(rx, yy, rz - 1, MatterCavern.class) == null) { + if (rz > 0 && composedCavernAt(mc, rx, yy, rz - 1) == null) { walls.put(rx, yy, rz - 1, cavern); } - if (rx > 0 && mc.get(rx - 1, yy, rz, MatterCavern.class) == null) { + if (rx > 0 && composedCavernAt(mc, rx - 1, yy, rz) == null) { walls.put(rx - 1, yy, rz, cavern); } } @@ -370,11 +514,11 @@ public class IrisCarveModifier extends EngineAssignedModifier mantleChunk, int x, int y, int z) { + MatterCavern baseline = dataIfPresent(mantleChunk, x, y, z, MatterCavern.class); + RiverCaveHydrology hydrology = dataIfPresent(mantleChunk, x, y, z, RiverCaveHydrology.class); + return composeCavern(baseline, hydrology); + } + + private static T dataIfPresent(MantleChunk mantleChunk, int x, int y, int z, Class type) { + int section = y >> 4; + if (y < 0 || !mantleChunk.exists(section)) { + return null; + } + Matter matter = mantleChunk.get(section); + if (matter == null || !matter.hasSlice(type)) { + return null; + } + return matter.getSlice(type).get(x & 15, y & 15, z & 15); + } + private void processColumnFromMask( Hunk output, MantleChunk mc, @@ -402,7 +564,8 @@ public class IrisCarveModifier extends EngineAssignedModifier caveBiomeCache, - Map customBiomeCache + Map customBiomeCache, + PlatformBlockState columnFluid ) { if (columnMask == null || columnMask.isEmpty()) { return; @@ -429,7 +592,8 @@ public class IrisCarveModifier extends EngineAssignedModifier output, + MantleChunk mantleChunk, CarveColumnMask boundaryMask, CarveWallBuffer walls, int columnIndex, @@ -473,18 +639,21 @@ public class IrisCarveModifier extends EngineAssignedModifier output, + MantleChunk mantleChunk, CarveWallBuffer walls, int rx, int rz, @@ -517,6 +686,11 @@ public class IrisCarveModifier extends EngineAssignedModifier= worldMaxY) { break; } + RiverCaveHydrology hydrology = dataIfPresent( + mantleChunk, rx, ceilingY, rz, RiverCaveHydrology.class); + if (hydrology != null && hydrology.protectsPlacement()) { + continue; + } PlatformBlockState existing = output.getRaw(rx, ceilingY, rz); if (!B.isSolid(existing)) { continue; @@ -565,7 +744,12 @@ public class IrisCarveModifier extends EngineAssignedModifier output, MantleChunk mc, Mantle mantle, CaveZone zone, int rx, int rz, int xx, int zz, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { + private void processZone(Hunk output, MantleChunk mc, Mantle mantle, + CaveZone zone, int rx, int rz, int xx, int zz, + IrisDimensionCarvingResolver.State resolverState, + Long2ObjectOpenHashMap caveBiomeCache, + Map customBiomeCache, + PlatformBlockState columnFluid) { int maxY = output.getHeight(); if (zone.ceiling + 1 < maxY && B.isDecorant(output.getRaw(rx, zone.ceiling + 1, rz))) { @@ -590,6 +774,7 @@ public class IrisCarveModifier extends EngineAssignedModifier= maxY) { break; } + RiverCaveHydrology hydrology = dataIfPresent(mc, rx, cy, rz, RiverCaveHydrology.class); + if (hydrology != null && hydrology.protectsPlacement()) { + continue; + } PlatformBlockState block = ceilingBlocks.get(i); PlatformBlockState existing = output.getRaw(rx, cy, rz); if (!B.isSolid(existing)) { @@ -646,10 +839,43 @@ public class IrisCarveModifier extends EngineAssignedModifier 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) { decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, ceilingBiome, InferredType.CAVE, zone.getCeiling(), zone.airThickness()); } + + normalizeCaveZoneWaterlogging(output, mc, zone, rx, rz, columnFluid); + } + + private void normalizeCaveZoneWaterlogging( + Hunk output, + MantleChunk mantleChunk, + CaveZone zone, + int localX, + int localZ, + PlatformBlockState columnFluid + ) { + int minimumY = Math.max(0, zone.floor - 1); + int maximumY = Math.min(output.getHeight() - 1, zone.ceiling + 1); + for (int y = minimumY; y <= maximumY; y++) { + RiverCaveHydrology hydrology = dataIfPresent( + mantleChunk, localX, y, localZ, RiverCaveHydrology.class); + if (hydrology == null) { + continue; + } + MatterCavern baseline = dataIfPresent( + mantleChunk, localX, y, localZ, MatterCavern.class); + PlatformBlockState current = output.getRaw(localX, y, localZ); + PlatformBlockState normalized = normalizeHydrologyWaterlogging( + current, + baseline, + hydrology, + columnFluid + ); + if (normalized != current) { + output.setRaw(localX, y, localZ, normalized); + } + } } IrisBiome resolveCaveBoundaryBiome(MantleChunk mantleChunk, int x, int y, int z, int worldX, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { - MatterCavern cavern = mantleChunk.get(x, y, z, MatterCavern.class); + MatterCavern cavern = composedCavernAt(mantleChunk, x, y, z); return resolveCaveBoundaryBiome( cavern, worldX, y, worldZ, resolverState, caveBiomeCache, customBiomeCache); } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java index 4a8e82851..896da6096 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java @@ -57,10 +57,9 @@ public class IrisPostModifier extends EngineAssignedModifier IrisDimension dimension = getDimension(); boolean walls = dimension.isPostProcessingWalls(); boolean slabs = dimension.isPostProcessingSlabs(); - int fluidHeight = dimension.getFluidHeight(); for (int i = 0; i < width; i++) { for (int j = 0; j < depth; j++) { - post(i, j, sync, i + x, j + z, context, heights, planeWidth, walls, slabs, fluidHeight); + post(i, j, sync, i + x, j + z, context, heights, planeWidth, walls, slabs); } } @@ -90,7 +89,7 @@ public class IrisPostModifier extends EngineAssignedModifier return heights; } - private void post(int currentPostX, int currentPostZ, Hunk currentData, int x, int z, ChunkContext context, int[] heights, int planeWidth, boolean walls, boolean slabs, int fluidHeight) { + private void post(int currentPostX, int currentPostZ, Hunk currentData, int x, int z, ChunkContext context, int[] heights, int planeWidth, boolean walls, boolean slabs) { // x/z are world coordinates, the hunk is indexed relative to this chunk origin. int originX = x - currentPostX; int originZ = z - currentPostZ; @@ -100,6 +99,7 @@ public class IrisPostModifier extends EngineAssignedModifier int hb = heights[center + planeWidth]; int hc = heights[center - 1]; int hd = heights[center - planeWidth]; + int fluidHeight = (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(x, z)); // Floating Nibs int g = 0; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java index 305f410d4..44a93cab8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java @@ -50,6 +50,17 @@ public interface IObjectPlacer { int getFluidHeight(); + default int getFluidHeight(int x, int z) { + Engine engine = getEngine(); + if (engine == null || engine.getComplex() == null) { + return getFluidHeight(); + } + int coordinateShift = getFluidHeight() - engine.getDimension().getFluidHeight(); + return coordinateShift + (int) Math.round( + engine.getComplex().getRiverWaterSurfaceStream().get(x, z) + ); + } + boolean isDebugSmartBore(); void setTile(int xx, int yy, int zz, TileData tile); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index ec7001c39..4841cbf1f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -130,6 +130,8 @@ public class IrisBiome extends IrisRegistrant implements IRare { private int lockLayersMax = 7; @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); + @Desc("Biome-level river routing, shape, cave-entry, and biome-pool overrides. Omit to inherit region and dimension settings.") + private IrisRiverOverride riverOverride = null; @MinNumber(1) @MaxNumber(512) @Desc("The rarity of this biome (integer)") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeColorRenderer.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeColorRenderer.java index 6ba1918dd..8a09c6374 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeColorRenderer.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeColorRenderer.java @@ -36,7 +36,7 @@ final class IrisBiomeColorRenderer { static Color getColor(IrisBiome biome, Engine engine, RenderType type) { switch (type) { - case BIOME, HEIGHT, CAVE_LAND, REGION, BIOME_SEA, BIOME_LAND -> { + case BIOME, HEIGHT, CAVE_LAND, REGION, BIOME_SEA, BIOME_LAND, RIVER -> { return biome.getCacheColor().aquire(() -> { if (biome.getColor() == null) { RandomColor randomColor = new RandomColor(biome.getName().hashCode()); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index f5147e22b..fec26549e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -164,6 +164,8 @@ public class IrisDimension extends IrisRegistrant { private KList carving = new KList<>(); @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); + @Desc("Connected surface rivers and contained river cave-water generation.") + private IrisRiverNetwork rivers = new IrisRiverNetwork(); @Desc("Refuse to place surface objects and trees over carved surface openings.") private boolean requireObjectSurfaceSupport = true; @MinNumber(0) @@ -489,6 +491,11 @@ public class IrisDimension extends IrisRegistrant { } Deque pending = new ArrayDeque<>(); + IrisRiverNetwork riverNetwork = getRivers(); + boolean riversEnabled = riverNetwork != null && riverNetwork.isEnabled(); + if (riversEnabled && riverNetwork.getBiomes() != null) { + addReachableBiomeKeys(pending, riverNetwork.getBiomes().getAllBiomeIds()); + } KList regionKeys = getRegions(); if (regionKeys != null) { for (String regionKey : regionKeys) { @@ -496,7 +503,8 @@ public class IrisDimension extends IrisRegistrant { if (region == null) { continue; } - addReachableBiomeKeys(pending, region.getAllBiomeIds()); + addReachableBiomeKeys(pending, + riversEnabled ? region.getAllBiomeIds() : region.getNaturalBiomeIds()); } } @@ -531,6 +539,9 @@ public class IrisDimension extends IrisRegistrant { biomes.put(loadKey, biome); addReachableBiomeKeys(pending, biome.getChildren()); addReachableBiomeKey(pending, biome.getCarvingBiome()); + if (riversEnabled && biome.getRiverOverride() != null) { + addReachableBiomeKeys(pending, biome.getRiverOverride().getAllBiomeIds()); + } KList floatingChildren = biome.getFloatingChildBiomes(); if (floatingChildren == null) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java index 4bdb39890..c77a9c542 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java @@ -28,6 +28,9 @@ public enum IrisEngineStreamType { @Desc("Represents the given slope at the x, z coordinates") SLOPE((f) -> f.getComplex().getSlopeStream()), + @Desc("Represents terrain height before river incision and river biome replacement.") + NATURAL_HEIGHT((f) -> f.getComplex().getNaturalHeightStream()), + @Desc("Represents the base generator height at the given position. This includes only the biome generators / interpolation and noise features but does not include carving, caves.") HEIGHT((f) -> f.getComplex().getHeightStream()), @@ -41,7 +44,19 @@ public enum IrisEngineStreamType { REGION_STYLE((f) -> f.getComplex().getRegionStyleStream()), @Desc("Represents the identity of regions. Each region has a unique number (very large numbers)") - REGION_IDENTITY((f) -> f.getComplex().getRegionIdentityStream()); + REGION_IDENTITY((f) -> f.getComplex().getRegionIdentityStream()), + + @Desc("Represents block distance from the nearest active river centerline.") + RIVER_DISTANCE((f) -> f.getComplex().getRiverDistanceStream()), + + @Desc("Represents the merged upstream flow carried by the active river reach.") + RIVER_FLOW((f) -> f.getComplex().getRiverFlowStream()), + + @Desc("Represents the normalized river terrain-incision weight.") + RIVER_CARVE_WEIGHT((f) -> f.getComplex().getRiverCarveWeightStream()), + + @Desc("Represents the solved river water-surface height.") + RIVER_WATER_SURFACE((f) -> f.getComplex().getRiverWaterSurfaceStream()); private final Function> getter; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index f8a73780e..c894795de 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -332,7 +332,8 @@ final class IrisObjectPlacementRunner { return -1; } - if (!config.isForcePlace() && !rawStructurePiece && config.isUnderwater() && y + rty + ty >= placer.getFluidHeight()) { + if (!config.isForcePlace() && !rawStructurePiece && config.isUnderwater() + && y + rty + ty >= placer.getFluidHeight(x, z)) { return -1; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index 7fd44ba09..0f2475884 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -118,6 +118,8 @@ public class IrisRegion extends IrisRegistrant implements IRare { private double caveBiomeZoom = 1; @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); + @Desc("Region-level river routing, shape, cave-entry, and biome-pool overrides. Omit to inherit dimension settings.") + private IrisRiverOverride riverOverride = null; @RegistryListResource(IrisBiome.class) @Required @ArrayType(min = 1, type = String.class) @@ -277,18 +279,33 @@ public class IrisRegion extends IrisRegistrant implements IRare { } public KSet getAllBiomeIds() { + KSet names = getNaturalBiomeIds(); + if (riverOverride != null) { + names.addAll(riverOverride.getAllBiomeIds()); + } + return names; + } + + public KSet getNaturalBiomeIds() { KSet names = new KSet<>(); names.addAll(landBiomes); names.addAll(caveBiomes); names.addAll(seaBiomes); names.addAll(shoreBiomes); - return names; } public KList getAllBiomes(DataProvider g) { + return resolveBiomes(g, getAllBiomeIds()); + } + + public KList getNaturalBiomes(DataProvider g) { + return resolveBiomes(g, getNaturalBiomeIds()); + } + + private KList resolveBiomes(DataProvider g, KSet biomeIds) { KMap b = new KMap<>(); - KSet names = getAllBiomeIds(); + KSet names = biomeIds.copy(); while (!names.isEmpty()) { for (String i : new KList<>(names)) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBiomes.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBiomes.java new file mode 100644 index 000000000..95fd64bad --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBiomes.java @@ -0,0 +1,61 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KSet; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Biome pools used by dimension-level river sections and contained river caves.") +@Data +public class IrisRiverBiomes { + @Desc("Noise used to select a biome inside the active river-section pool.") + private IrisGeneratorStyle selectionStyle = new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS_DOUBLE) + .zoomed(512D); + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Biome pool for wet river channels.") + private KList channel = new KList<>(); + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Biome pool for river banks outside the wet channel.") + private KList bank = new KList<>(); + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Biome pool for river reaches meeting natural sea.") + private KList mouth = new KList<>(); + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Biome pool for dry river channels and terminal tapers.") + private KList dry = new KList<>(); + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Cave biome pool for accepted contained river cave bodies.") + private KList floodedCave = new KList<>(); + + public KSet getAllBiomeIds() { + KSet biomeIds = new KSet<>(); + addAll(biomeIds, channel); + addAll(biomeIds, bank); + addAll(biomeIds, mouth); + addAll(biomeIds, dry); + addAll(biomeIds, floodedCave); + return biomeIds; + } + + private static void addAll(KSet destination, KList source) { + if (source != null) { + destination.addAll(source); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveFallback.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveFallback.java new file mode 100644 index 000000000..d79e9b56b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveFallback.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Selects the fallback when a requested river cave connection cannot be proven safe.") +public enum IrisRiverCaveFallback { + @Desc("Keep the rejected connection sealed.") + SEALED, + + @Desc("Try a bounded generated grotto instead of the rejected existing cave.") + GENERATE_GROTTO +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveMode.java new file mode 100644 index 000000000..40b6e3924 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveMode.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Selects the contained cave-water behavior available to river entry events.") +public enum IrisRiverCaveMode { + @Desc("Keep the surface reservoir sealed from caves.") + SEALED, + + @Desc("Flood only an existing cave component whose complete fluid-reachable boundary is proven closed.") + FLOOD_CLOSED_COMPONENT, + + @Desc("Generate a bounded grotto with a guaranteed solid shell.") + GENERATE_GROTTO, + + @Desc("Use a proven closed cave component when available, otherwise generate a bounded grotto.") + GROTTO_OR_CLOSED_COMPONENT, + + @Desc("Generate a controlled falling column into a proven contained pool.") + WATERFALL_POOL +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java new file mode 100644 index 000000000..9dc6f2621 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java @@ -0,0 +1,95 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls bounded and transactionally validated river-to-cave connections.") +@Data +public class IrisRiverCaves { + @Desc("The contained cave-water behavior available to river entry events.") + private IrisRiverCaveMode mode = IrisRiverCaveMode.SEALED; + + @Desc("Selects cave-entry stations at stable river-reach anchors.") + private IrisRiverNoiseChance entry = new IrisRiverNoiseChance() + .setChance(0.12D) + .setStyle(new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(1024D)) + .setInfluence(0.4D); + + @MinNumber(16) + @MaxNumber(4096) + @Desc("The minimum distance in blocks between cave-entry candidates.") + private int minimumSpacing = 128; + + @MinNumber(0) + @MaxNumber(16) + @Desc("The maximum entry-noise-eligible cave anchors accepted on one ordinary reach. A forced sinkhole terminal uses its reach exclusively and requires this value above zero.") + private int maximumPerReach = 1; + + @MinNumber(1) + @MaxNumber(256) + @Desc("The maximum vertical distance searched while boring from river bed to cave.") + private int maxBoreDepth = 48; + + @MinNumber(1) + @MaxNumber(16) + @Desc("The radius in blocks of a generated river-to-cave throat.") + private int throatRadius = 2; + + @MinNumber(-64) + @MaxNumber(64) + @Desc("The offset applied to river water height when filling an accepted cave body.") + private int waterLevelOffset = 0; + + @MinNumber(0) + @MaxNumber(64) + @Desc("The minimum dry headroom retained above water in generated grottos.") + private int dryHeadroom = 4; + + @MinNumber(2) + @MaxNumber(128) + @Desc("The horizontal radius in blocks of a generated sealed grotto.") + private int grottoHorizontalRadius = 12; + + @MinNumber(2) + @MaxNumber(128) + @Desc("The vertical radius in blocks of a generated sealed grotto.") + private int grottoVerticalRadius = 7; + + @Desc("Noise shaping the boundary of generated sealed grottos.") + private IrisGeneratorStyle grottoShapeStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(24D); + + @Desc("Noise warping the coordinate field used for generated sealed grottos.") + private IrisGeneratorStyle grottoWarpStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(48D); + + @MinNumber(0) + @MaxNumber(32) + @Desc("The maximum coordinate warp applied to generated sealed grottos in blocks.") + private double grottoWarpStrength = 2D; + + @MinNumber(4) + @MaxNumber(256) + @Desc("The horizontal proof radius for an existing closed cave component.") + private int maxFloodRadius = 48; + + @MinNumber(4) + @MaxNumber(256) + @Desc("The vertical proof depth for an existing closed cave component.") + private int maxFloodDepth = 32; + + @MinNumber(64) + @MaxNumber(1048576) + @Desc("The greatest cave-component volume that may be fully proven and flooded.") + private int maxFloodVolume = 8192; + + @Desc("The behavior used when a requested cave connection cannot be proven safe.") + private IrisRiverCaveFallback fallback = IrisRiverCaveFallback.SEALED; + + @Desc("The policy for fluid already present in a candidate contained cave body.") + private IrisRiverExistingFluidPolicy existingFluidPolicy = IrisRiverExistingFluidPolicy.REJECT; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverExistingFluidPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverExistingFluidPolicy.java new file mode 100644 index 000000000..356d8f71c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverExistingFluidPolicy.java @@ -0,0 +1,15 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls how river cave hydrology treats fluid already present in a candidate cave body.") +public enum IrisRiverExistingFluidPolicy { + @Desc("Reject a candidate containing any existing fluid.") + REJECT, + + @Desc("Accept only fluid compatible with the river fluid palette.") + ALLOW_SAME, + + @Desc("Replace contained existing fluid with the river fluid palette.") + REPLACE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNetwork.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNetwork.java new file mode 100644 index 000000000..623043946 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNetwork.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Dimension-owned configuration for connected surface rivers and contained river cave water.") +@Data +public class IrisRiverNetwork { + @Desc("Enable the river network for this dimension.") + private boolean enabled = false; + + @Desc("Dimension-owned connected graph and source-selection settings.") + private IrisRiverTopology topology = new IrisRiverTopology(); + + @Desc("Channel geometry, terrain incision, meanders, and terminal behavior.") + private IrisRiverTerrain terrain = new IrisRiverTerrain(); + + @Desc("River water-surface settings.") + private IrisRiverWater water = new IrisRiverWater(); + + @Desc("Dimension-level biome pools for river sections and contained river caves.") + private IrisRiverBiomes biomes = new IrisRiverBiomes(); + + @Desc("Bounded river-to-cave connection settings.") + private IrisRiverCaves caves = new IrisRiverCaves(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNoiseChance.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNoiseChance.java new file mode 100644 index 000000000..2c66d92ff --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNoiseChance.java @@ -0,0 +1,27 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("A deterministic graph-event chance modulated by configurable noise.") +@Data +public class IrisRiverNoiseChance { + @MinNumber(0) + @MaxNumber(1) + @Desc("The base probability before noise modulation.") + private double chance = 1D; + + @Desc("The noise sampled once at the stable graph-event anchor.") + private IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.FLAT); + + @MinNumber(0) + @MaxNumber(1) + @Desc("The maximum centered noise contribution added to the base probability.") + private double influence = 0D; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverOverride.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverOverride.java new file mode 100644 index 000000000..f5c7b9cf8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverOverride.java @@ -0,0 +1,103 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KSet; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Nullable river settings overridden by a region or natural biome without changing graph identity.") +@Data +public class IrisRiverOverride { + @Desc("Whether new river sources may begin in this area. Existing trunks are unaffected.") + private Boolean allowSources = null; + + @Desc("How downstream routing treats this area.") + private IrisRiverRoutingPolicy routingPolicy = null; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Multiplier applied to downstream routing cost.") + private Double routingCostMultiplier = null; + + @MinNumber(0.0001) + @MaxNumber(16) + @Desc("Multiplier applied to wet channel width.") + private Double widthMultiplier = null; + + @MinNumber(0) + @MaxNumber(16) + @Desc("Multiplier applied to river bank width.") + private Double bankWidthMultiplier = null; + + @MinNumber(0.0001) + @MaxNumber(16) + @Desc("Multiplier applied to river-bed depth.") + private Double depthMultiplier = null; + + @MinNumber(0) + @MaxNumber(16) + @Desc("Multiplier applied to maximum terrain incision.") + private Double maxIncisionMultiplier = null; + + @MinNumber(0) + @MaxNumber(16) + @Desc("Multiplier applied to reach continuation probability.") + private Double continuationChanceMultiplier = null; + + @MinNumber(0) + @MaxNumber(16) + @Desc("Multiplier applied to cave-entry probability.") + private Double caveEntryMultiplier = null; + + @Desc("Optional terminal behavior override for failed routes in this area.") + private IrisRiverTerminalMode terminalMode = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Optional replacement biome pool for wet river channels. Empty explicitly disables this pool.") + private KList channelBiomes = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Optional replacement biome pool for river banks. Empty explicitly disables this pool.") + private KList bankBiomes = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Optional replacement biome pool for river mouths. Empty explicitly disables this pool.") + private KList mouthBiomes = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Optional replacement biome pool for dry river channels. Empty explicitly disables this pool.") + private KList dryBiomes = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Optional replacement cave biome pool for accepted contained river cave bodies. Empty explicitly disables this pool.") + private KList floodedCaveBiomes = null; + + public KSet getAllBiomeIds() { + KSet biomeIds = new KSet<>(); + addAll(biomeIds, channelBiomes); + addAll(biomeIds, bankBiomes); + addAll(biomeIds, mouthBiomes); + addAll(biomeIds, dryBiomes); + addAll(biomeIds, floodedCaveBiomes); + return biomeIds; + } + + private static void addAll(KSet destination, KList source) { + if (source != null) { + destination.addAll(source); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingPolicy.java new file mode 100644 index 000000000..5b75c5ba3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingPolicy.java @@ -0,0 +1,15 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls how river routing treats a region or biome.") +public enum IrisRiverRoutingPolicy { + @Desc("Allow normal river routing through this area.") + ALLOW, + + @Desc("Increase the routing cost while still permitting established river trunks.") + AVOID, + + @Desc("Forbid river reaches from crossing this area.") + BLOCK +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerminalMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerminalMode.java new file mode 100644 index 000000000..483b30469 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerminalMode.java @@ -0,0 +1,15 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Selects how a river route ends when it cannot continue to an outlet.") +public enum IrisRiverTerminalMode { + @Desc("Suppress the failed route instead of generating it.") + SUPPRESS, + + @Desc("Continue as a dry channel that tapers back into natural terrain.") + DRY_CHANNEL, + + @Desc("End in a contained underground grotto when cave hydrology accepts the connection.") + SINKHOLE_GROTTO +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java new file mode 100644 index 000000000..aa29c9faf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java @@ -0,0 +1,99 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls river channel geometry, banks, incision, meanders, and terminal tapering.") +@Data +public class IrisRiverTerrain { + @Desc("The wet channel width in blocks before stream-order scaling.") + private IrisStyledRange channelWidth = range(8D, 20D, NoiseStyle.IRIS, 1024D); + + @Desc("The bank width outside the wet channel in blocks.") + private IrisStyledRange bankWidth = range(5D, 18D, NoiseStyle.IRIS, 1024D); + + @Desc("The wet-bed depth below the local water surface, or dry-channel depth below natural terrain, in blocks.") + private IrisStyledRange depth = range(2D, 7D, NoiseStyle.IRIS, 768D); + + @MinNumber(1) + @MaxNumber(2048) + @Desc("The final wet-channel width cap after region, biome, and stream-order scaling.") + private double maxChannelWidth = 10D; + + @MinNumber(0) + @MaxNumber(2048) + @Desc("The final bank-width cap on each side after region and biome scaling.") + private double maxBankWidth = 4D; + + @MinNumber(1) + @MaxNumber(512) + @Desc("The final river-depth cap after region, biome, and stream-order scaling.") + private double maxDepth = 10D; + + @MinNumber(0) + @MaxNumber(8) + @Desc("Additional channel-width fraction applied for each merged upstream flow order.") + private double orderWidthFactor = 0.35D; + + @MinNumber(0) + @MaxNumber(8) + @Desc("Additional river-bed depth fraction applied for each merged upstream flow order.") + private double orderDepthFactor = 0.2D; + + @Desc("Selects whether a complete graph reach may incise terrain. A rejected reach follows terminal behavior.") + private IrisRiverNoiseChance incision = new IrisRiverNoiseChance(); + + @MinNumber(0) + @MaxNumber(512) + @Desc("The greatest permitted vertical incision below natural terrain.") + private int maxIncision = 48; + + @MinNumber(0.125) + @MaxNumber(16) + @Desc("The exponent shaping the channel-to-bank cross-section transition.") + private double bankExponent = 2D; + + @Desc("Modulates perpendicular spline displacement while preserving graph endpoints.") + private IrisGeneratorStyle meanderStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(512D); + + @MinNumber(0) + @MaxNumber(1024) + @Desc("The maximum perpendicular meander displacement in blocks.") + private double meanderStrength = 72D; + + @MinNumber(1) + @MaxNumber(64) + @Desc("The number of straight segments used to flatten each meandering graph reach.") + private int meanderSubdivisions = 8; + + @Desc("Modulates small river-bed height variation after the connected channel shape is solved.") + private IrisGeneratorStyle bedRoughnessStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(96D); + + @MinNumber(0) + @MaxNumber(8) + @Desc("The maximum river-bed roughness in blocks.") + private double bedRoughness = 0.75D; + + @Desc("The behavior used when a graph route cannot continue as a wet channel.") + private IrisRiverTerminalMode terminalMode = IrisRiverTerminalMode.DRY_CHANNEL; + + @MinNumber(8) + @MaxNumber(1024) + @Desc("The distance in blocks over which a terminal channel returns to natural terrain.") + private int terminalTaper = 64; + + @MinNumber(0) + @MaxNumber(1) + @Desc("The probability that a failed wet route continues as a tapered dry channel.") + private double dryContinuationChance = 1D; + + private static IrisStyledRange range(double min, double max, NoiseStyle style, double zoom) { + return new IrisStyledRange(min, max, new IrisGeneratorStyle(style).zoomed(zoom)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java new file mode 100644 index 000000000..222c4b220 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java @@ -0,0 +1,92 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Dimension-owned settings for the deterministic connected river graph.") +@Data +public class IrisRiverTopology { + @MinNumber(64) + @MaxNumber(4096) + @Desc("The routing-cell width in blocks. This controls graph identity and cannot be overridden by regions or biomes.") + private int cellSize = 512; + + @MinNumber(1) + @MaxNumber(64) + @Desc("The number of routing cells grouped into one immutable river cache tile.") + private int tileCells = 4; + + @MinNumber(0) + @MaxNumber(0.49) + @Desc("The fraction of a routing cell used to jitter its graph node away from the center.") + private double siteJitter = 0.35D; + + @MinNumber(1) + @MaxNumber(256) + @Desc("The maximum number of directed graph reaches followed by one source route.") + private int maxRouteReaches = 16; + + @MinNumber(0) + @MaxNumber(64) + @Desc("The minimum number of noise-weighted source nodes selected in each routing tile while source chance is above zero.") + private int minimumSourcesPerTile = 0; + + @MinNumber(0) + @MaxNumber(7) + @Desc("The number of alternate downstream reaches inspected before declaring a sink.") + private int sinkSearchReaches = 4; + + @MinNumber(8) + @MaxNumber(256) + @Desc("The spacing of deterministic drainage-basin sinks in routing cells. Larger values produce longer trunks and wider tributary trees.") + private int routingBasinCells = 64; + + @MinNumber(1) + @MaxNumber(64) + @Desc("The horizontal basin-distance span in routing cells per one block of terraced water rise.") + private double routingPlateauHeight = 8D; + + @Desc("Selects complete river source routes at stable graph nodes.") + private IrisRiverNoiseChance source = new IrisRiverNoiseChance() + .setChance(0.05D) + .setStyle(new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(8192D)) + .setInfluence(0.035D); + + @Desc("Selects complete continuation reaches. A rejected reach terminates or suppresses its route rather than creating a gap.") + private IrisRiverNoiseChance continuation = new IrisRiverNoiseChance() + .setChance(0.99D) + .setStyle(new IrisGeneratorStyle(NoiseStyle.VASCULAR).zoomed(4096D)) + .setInfluence(0.01D); + + @Desc("Adds deterministic cost variation while choosing downstream graph neighbors.") + private IrisGeneratorStyle routingStyle = new IrisGeneratorStyle(NoiseStyle.VASCULAR).zoomed(8192D); + + @MinNumber(0) + @MaxNumber(1024) + @Desc("The maximum routing-cost contribution from routingStyle.") + private double routingNoiseWeight = 24D; + + @MinNumber(0) + @MaxNumber(16) + @Desc("The contribution of natural terrain height to downstream routing cost.") + private double terrainHeightWeight = 0.7D; + + @MinNumber(0) + @MaxNumber(16) + @Desc("The contribution of natural terrain slope to downstream routing cost.") + private double terrainSlopeWeight = 0.35D; + + @MinNumber(0) + @MaxNumber(16) + @Desc("The routing preference toward natural sea outlets.") + private double oceanAttraction = 1D; + + @Desc("Require every wet source route to reach natural sea or a proven sea-reaching trunk.") + private boolean requireOcean = false; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java new file mode 100644 index 000000000..34e597ea0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java @@ -0,0 +1,32 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls the river water-surface solver.") +@Data +public class IrisRiverWater { + @Desc("The strategy used to determine river water-surface height.") + private IrisRiverWaterMode mode = IrisRiverWaterMode.SEA_LEVEL; + + @MinNumber(8) + @MaxNumber(4096) + @Desc("The target length of each flat terraced pool in blocks.") + private int poolLength = 96; + + @MinNumber(0) + @MaxNumber(64) + @Desc("The greatest river water height permitted above the dimension fluid height.") + private int maximumPoolRise = 4; + + @MinNumber(1) + @MaxNumber(32) + @Desc("The vertical height of controlled drops between terraced pools.") + private int dropHeight = 1; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java new file mode 100644 index 000000000..44cf71479 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Selects how a river determines its surface fluid height.") +public enum IrisRiverWaterMode { + @Desc("Use the dimension fluid height for every wet river reach.") + SEA_LEVEL, + + @Desc("Use flat pools connected by controlled vertical drops.") + TERRACED +} diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index fa151945e..edfcb8dd0 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -32,6 +32,7 @@ import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.nms.INMS; +import art.arcane.iris.core.runtime.ObjectStudioActivation; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; import art.arcane.iris.core.service.StudioSVC; @@ -61,6 +62,7 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.M; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.hunk.view.ChunkDataHunkHolder; +import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.project.context.IrisContext; import art.arcane.volmlib.util.io.ReactiveFolder; import art.arcane.volmlib.util.scheduling.ChronoLatch; @@ -83,12 +85,17 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.PrintWriter; +import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Random; import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; @@ -102,6 +109,11 @@ import java.util.function.Supplier; @Data public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChunkGenerator, Listener { private static final int LOAD_LOCKS = Runtime.getRuntime().availableProcessors() * 4; + private static final int STUDIO_ENTRY_PRECOMPUTE_RADIUS = 2; + private static final int STUDIO_ENTRY_PRECOMPUTE_THREADS = Math.max( + 1, + Math.min(6, Runtime.getRuntime().availableProcessors() / 2)); + private static final AtomicInteger STUDIO_ENTRY_THREAD_SEQUENCE = new AtomicInteger(); private static final long HOTLOAD_LOOP_DELAY_MS = 250L; private static final long HOTLOAD_MAINTENANCE_DELAY_MS = 4000L; private final GenerationStageGate loadLock; @@ -115,6 +127,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private final AtomicBoolean setup; private final boolean studio; private final AtomicBoolean studioEntryBootstrapActive; + private final ConcurrentHashMap preparedStudioEntryChunks; private final AtomicInteger a = new AtomicInteger(0); private volatile long lastChunkGenTime = 0L; private final CompletableFuture spawnChunks = new CompletableFuture<>(); @@ -143,6 +156,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun this.hotloadChecker = new ChronoLatch(1000, false); this.studio = studio; this.studioEntryBootstrapActive = new AtomicBoolean(studio); + this.preparedStudioEntryChunks = new ConcurrentHashMap<>(); this.dataLocation = dataLocation; this.dimensionKey = dimensionKey; this.folder = new ReactiveFolder( @@ -475,6 +489,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (currentEngine != null && !currentEngine.isClosed()) { currentEngine.close(); } + preparedStudioEntryChunks.clear(); folder.clear(); populators.clear(); }); @@ -525,6 +540,124 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun studioEntryBootstrapActive.set(false); } + public CompletableFuture prepareStudioEntryChunks( + World bukkitWorld, + int centerChunkX, + int centerChunkZ + ) { + if (!studio || closing) { + return CompletableFuture.completedFuture(null); + } + Engine activeEngine = getEngine(bukkitWorld); + computeStudioGenerator(); + if (studioGenerator != null) { + return CompletableFuture.completedFuture(null); + } + + long generationSessionId = activeEngine.getGenerationSessionId(); + ConcurrentHashMap prepared = new ConcurrentHashMap<>(); + ExecutorService executor = createStudioEntryExecutor(); + int diameter = STUDIO_ENTRY_PRECOMPUTE_RADIUS * 2 + 1; + ArrayList> tasks = new ArrayList<>(diameter * diameter); + for (int offsetX = -STUDIO_ENTRY_PRECOMPUTE_RADIUS; + offsetX <= STUDIO_ENTRY_PRECOMPUTE_RADIUS; + offsetX++) { + for (int offsetZ = -STUDIO_ENTRY_PRECOMPUTE_RADIUS; + offsetZ <= STUDIO_ENTRY_PRECOMPUTE_RADIUS; + offsetZ++) { + int chunkX = centerChunkX + offsetX; + int chunkZ = centerChunkZ + offsetZ; + CompletableFuture task = CompletableFuture.runAsync( + () -> prepareStudioEntryChunk( + bukkitWorld, + activeEngine, + generationSessionId, + chunkX, + chunkZ, + prepared), + executor); + tasks.add(task); + } + } + + CompletableFuture completion = CompletableFuture.allOf( + tasks.toArray(new CompletableFuture[0])); + CompletableFuture publication = completion.thenRun(() -> { + if (closing + || engine != activeEngine + || activeEngine.getGenerationSessionId() != generationSessionId) { + throw new IllegalStateException( + "Studio entry precompute finished for a replaced engine runtime."); + } + preparedStudioEntryChunks.clear(); + preparedStudioEntryChunks.putAll(prepared); + }); + return publication.whenComplete((ignored, failure) -> executor.shutdownNow()); + } + + private ExecutorService createStudioEntryExecutor() { + return Executors.newFixedThreadPool(STUDIO_ENTRY_PRECOMPUTE_THREADS, runnable -> { + Thread thread = new Thread( + runnable, + "Iris Studio Entry-" + STUDIO_ENTRY_THREAD_SEQUENCE.incrementAndGet()); + thread.setDaemon(true); + thread.setPriority(Thread.NORM_PRIORITY); + return thread; + }); + } + + private void prepareStudioEntryChunk( + World bukkitWorld, + Engine activeEngine, + long generationSessionId, + int chunkX, + int chunkZ, + ConcurrentHashMap prepared + ) { + try (GenerationStagePermit ignored = acquireGenerationStage( + "studio_entry_chunk_precompute")) { + TerrainChunk terrainChunk = TerrainChunk.create(bukkitWorld); + ChunkDataHunkHolder blocks = new ChunkDataHunkHolder(terrainChunk.getChunkData()); + Hunk biomes = Hunk.viewBiomes(terrainChunk); + ChunkContext context = createStudioEntryContext( + activeEngine, + generationSessionId, + chunkX, + chunkZ); + activeEngine.generateMatter(chunkX, chunkZ, true, context); + try { + activeEngine.generate(chunkX << 4, chunkZ << 4, blocks, biomes, false); + } catch (WrongEngineBroException exception) { + throw new CompletionException(exception); + } + prepared.put( + chunkKey(chunkX, chunkZ), + new PreparedStudioChunk(activeEngine, generationSessionId, blocks) + ); + } + } + + private ChunkContext createStudioEntryContext( + Engine activeEngine, + long generationSessionId, + int chunkX, + int chunkZ + ) { + boolean cacheContext = !activeEngine.getPlatformHooks() + .shouldDisableChunkContextCache(activeEngine); + ChunkContext.PrefillPlan prefillPlan = cacheContext + ? ChunkContext.PrefillPlan.NO_CAVE + : ChunkContext.PrefillPlan.NONE; + return new ChunkContext( + chunkX << 4, + chunkZ << 4, + activeEngine.getComplex(), + generationSessionId, + cacheContext, + prefillPlan, + activeEngine.getMetrics()); + } + public boolean isStudioEntryBootstrapActive() { return studioEntryBootstrapActive.get(); } @@ -783,12 +916,19 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (studioGenerator != null) { studioGenerator.generateChunk(engine, tc, x, z); } else { + PreparedStudioChunk prepared = preparedStudioEntryChunks.remove(chunkKey(x, z)); + if (prepared != null + && prepared.engine() == engine + && prepared.generationSessionId() == engine.getGenerationSessionId()) { + prepared.blocks().applyTo(d); + IrisLogging.debug("Applied prepared Studio entry chunk " + x + " " + z); + return; + } ChunkDataHunkHolder blocks = new ChunkDataHunkHolder(d); Hunk biomes = Hunk.viewBiomes(tc); - boolean useMulticore = studio && !J.isFolia(); try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - engine.generate(x << 4, z << 4, blocks, biomes, useMulticore); + engine.generate(x << 4, z << 4, blocks, biomes, false); blocks.apply(); } } @@ -829,6 +969,17 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return isMaintenanceActive(); } + private static long chunkKey(int chunkX, int chunkZ) { + return ((long) chunkX << 32) ^ (chunkZ & 0xFFFFFFFFL); + } + + private record PreparedStudioChunk( + Engine engine, + long generationSessionId, + ChunkDataHunkHolder blocks + ) { + } + private boolean isMaintenanceActive() { World realWorld = BukkitWorldBinding.world(this.world); return realWorld != null && IrisToolbelt.isWorldMaintenanceActive(realWorld); @@ -910,7 +1061,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun StudioMode desired = studio ? java.util.Optional.ofNullable(getEngine().getDimension().getStudioMode()).orElse(StudioMode.NORMAL) : StudioMode.NORMAL; - if (studio && art.arcane.iris.core.runtime.ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) { + if (studio && ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) { desired = StudioMode.OBJECT_BUFFET; } if (!desired.equals(lastMode)) { diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverAnchor.java b/core/src/main/java/art/arcane/iris/engine/river/RiverAnchor.java new file mode 100644 index 000000000..80324dd24 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverAnchor.java @@ -0,0 +1,27 @@ +package art.arcane.iris.engine.river; + +import java.util.Objects; + +public record RiverAnchor( + RiverEdgeId reachId, + int index, + long stableId, + double samplingSpacing, + long samplingSalt, + double x, + double z, + double alongReach, + RiverRouteState state, + int flow, + int order +) { + public RiverAnchor { + Objects.requireNonNull(reachId); + Objects.requireNonNull(state); + if (index < 0 || !Double.isFinite(samplingSpacing) || samplingSpacing <= 0D + || !Double.isFinite(x) || !Double.isFinite(z) + || !Double.isFinite(alongReach) || alongReach < 0.0 || alongReach > 1.0) { + throw new IllegalArgumentException("River anchor index and coordinates must be valid"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverEdgeId.java b/core/src/main/java/art/arcane/iris/engine/river/RiverEdgeId.java new file mode 100644 index 000000000..9b25e5957 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverEdgeId.java @@ -0,0 +1,34 @@ +package art.arcane.iris.engine.river; + +import java.util.Objects; + +public record RiverEdgeId(RiverNodeId first, RiverNodeId second) implements Comparable { + public RiverEdgeId { + Objects.requireNonNull(first); + Objects.requireNonNull(second); + if (first.compareTo(second) >= 0) { + throw new IllegalArgumentException("River edge endpoints must be distinct and canonical"); + } + } + + public static RiverEdgeId of(RiverNodeId first, RiverNodeId second) { + Objects.requireNonNull(first); + Objects.requireNonNull(second); + if (first.equals(second)) { + throw new IllegalArgumentException("River edge endpoints must be distinct"); + } + return first.compareTo(second) < 0 + ? new RiverEdgeId(first, second) + : new RiverEdgeId(second, first); + } + + public long stableId() { + return RiverNetwork.mix(first.stableId() ^ Long.rotateLeft(second.stableId(), 29)); + } + + @Override + public int compareTo(RiverEdgeId other) { + int firstComparison = first.compareTo(other.first); + return firstComparison != 0 ? firstComparison : second.compareTo(other.second); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverMeanderContext.java b/core/src/main/java/art/arcane/iris/engine/river/RiverMeanderContext.java new file mode 100644 index 000000000..e3c2ce351 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverMeanderContext.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.river; + +import java.util.Objects; + +public record RiverMeanderContext( + RiverEdgeId reachId, + double normalizedPosition, + double x, + double z +) { + public RiverMeanderContext { + Objects.requireNonNull(reachId); + if (!Double.isFinite(normalizedPosition) || normalizedPosition < 0.0 || normalizedPosition > 1.0 + || !Double.isFinite(x) || !Double.isFinite(z)) { + throw new IllegalArgumentException("River meander context must be finite and normalized"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java new file mode 100644 index 000000000..51285a6dc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java @@ -0,0 +1,918 @@ +package art.arcane.iris.engine.river; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class RiverNetwork { + private static final long NODE_X_SALT = 0x6A09E667F3BCC909L; + private static final long NODE_Z_SALT = 0xBB67AE8584CAA73BL; + private static final long NODE_RANK_SALT = 0x3C6EF372FE94F82BL; + private static final long BASIN_X_SALT = 0xCBBB9D5DC1059ED8L; + private static final long BASIN_Z_SALT = 0x629A292A367CD507L; + private static final long DIAGONAL_SALT = 0xA54FF53A5F1D36F1L; + private static final long SOURCE_SALT = 0x510E527FADE682D1L; + private static final long SOURCE_FLOOR_SALT = 0xD6E8FEB86659FD93L; + private static final long REACH_SALT = 0x9B05688C2B3E6C1FL; + private static final long DRY_SALT = 0x1F83D9ABFB41BD6BL; + private static final long MEANDER_SALT = 0x5BE0CD19137E2179L; + + private final RiverNetworkOptions options; + + public RiverNetwork(RiverNetworkOptions options) { + this.options = Objects.requireNonNull(options); + } + + public RiverNetworkOptions options() { + return options; + } + + public RiverNode nodeAtCell(long cellX, long cellZ, RiverTerrainSampler terrain) { + return createNode(new RiverNodeId(cellX, cellZ), Objects.requireNonNull(terrain)); + } + + public RiverNode nodeAtWorld(int blockX, int blockZ, RiverTerrainSampler terrain) { + long cellX = Math.floorDiv(blockX, options.cellSize()); + long cellZ = Math.floorDiv(blockZ, options.cellSize()); + return nodeAtCell(cellX, cellZ, terrain); + } + + public int tileXForBlock(int blockX) { + return Math.floorDiv(blockX, options.cellSize() * options.tileCells()); + } + + public int tileZForBlock(int blockZ) { + return Math.floorDiv(blockZ, options.cellSize() * options.tileCells()); + } + + public RiverTile buildTileForBlock(int blockX, int blockZ, RiverTerrainSampler terrain) { + return buildTile(tileXForBlock(blockX), tileZForBlock(blockZ), terrain); + } + + public RiverSample sample(int blockX, int blockZ, RiverTerrainSampler terrain) { + return buildTileForBlock(blockX, blockZ, terrain).sample(blockX, blockZ); + } + + public List neighbors(RiverNodeId id) { + Objects.requireNonNull(id); + ArrayList neighbors = new ArrayList<>(8); + addUnique(neighbors, new RiverNodeId(id.cellX() - 1L, id.cellZ())); + addUnique(neighbors, new RiverNodeId(id.cellX() + 1L, id.cellZ())); + addUnique(neighbors, new RiverNodeId(id.cellX(), id.cellZ() - 1L)); + addUnique(neighbors, new RiverNodeId(id.cellX(), id.cellZ() + 1L)); + for (long squareX = id.cellX() - 1L; squareX <= id.cellX(); squareX++) { + for (long squareZ = id.cellZ() - 1L; squareZ <= id.cellZ(); squareZ++) { + RiverNodeId diagonal = diagonalNeighbor(id, squareX, squareZ); + if (diagonal != null) { + addUnique(neighbors, diagonal); + } + } + } + neighbors.sort(Comparator.naturalOrder()); + return List.copyOf(neighbors); + } + + public RiverNode downstream(RiverNodeId id, RiverTerrainSampler terrain) { + Objects.requireNonNull(id); + Objects.requireNonNull(terrain); + NodeResolver resolver = new NodeResolver(terrain); + RiverNode node = resolver.resolve(id); + List candidates = resolver.downstreamCandidates(node); + for (RiverNode candidate : candidates) { + RiverRoutingContext context = resolver.routingContext(node, candidate); + if (!resolver.reachFeasible(context)) { + continue; + } + return resolver.continuationPermitted(context) ? candidate : null; + } + return null; + } + + public List downstreamCandidates(RiverNodeId id, RiverTerrainSampler terrain) { + Objects.requireNonNull(id); + Objects.requireNonNull(terrain); + NodeResolver resolver = new NodeResolver(terrain); + return resolver.downstreamCandidates(resolver.resolve(id)); + } + + public RiverRoute trace(RiverNodeId source, RiverTerrainSampler terrain) { + Objects.requireNonNull(source); + Objects.requireNonNull(terrain); + return trace(source, new NodeResolver(terrain)); + } + + public RiverTile buildTile(int tileX, int tileZ, RiverTerrainSampler terrain) { + Objects.requireNonNull(terrain); + long tileWorldSize = (long) options.cellSize() * options.tileCells(); + long minimumX = (long) tileX * tileWorldSize; + long minimumZ = (long) tileZ * tileWorldSize; + long maximumX = minimumX + tileWorldSize; + long maximumZ = minimumZ + tileWorldSize; + requireWorldBounds(minimumX, minimumZ, maximumX, maximumZ); + + int geometryPadding = geometryPaddingCells(); + long targetMinimumCellX = (long) tileX * options.tileCells() - geometryPadding; + long targetMinimumCellZ = (long) tileZ * options.tileCells() - geometryPadding; + long targetMaximumCellX = (long) (tileX + 1) * options.tileCells() - 1L + geometryPadding; + long targetMaximumCellZ = (long) (tileZ + 1) * options.tileCells() - 1L + geometryPadding; + long sourceMinimumCellX = targetMinimumCellX - options.maxRouteReaches(); + long sourceMinimumCellZ = targetMinimumCellZ - options.maxRouteReaches(); + long sourceMaximumCellX = targetMaximumCellX + options.maxRouteReaches(); + long sourceMaximumCellZ = targetMaximumCellZ + options.maxRouteReaches(); + + NodeResolver resolver = new NodeResolver(terrain); + int sourceWidth = Math.toIntExact(sourceMaximumCellX - sourceMinimumCellX + 1L); + int sourceDepth = Math.toIntExact(sourceMaximumCellZ - sourceMinimumCellZ + 1L); + int sourceCount = Math.multiplyExact(sourceWidth, sourceDepth); + ArrayList routes = new ArrayList<>(sourceCount); + for (long cellX = sourceMinimumCellX; cellX <= sourceMaximumCellX; cellX++) { + for (long cellZ = sourceMinimumCellZ; cellZ <= sourceMaximumCellZ; cellZ++) { + routes.add(trace(new RiverNodeId(cellX, cellZ), resolver)); + } + } + LinkedHashMap accumulators = new LinkedHashMap<>(); + for (RiverRoute route : routes) { + accumulate(route, resolver, accumulators); + } + + ArrayList reaches = new ArrayList<>(accumulators.size()); + for (ReachAccumulator accumulator : accumulators.values()) { + if (!potentiallyIntersects( + accumulator.from, + accumulator.to, + minimumX, + minimumZ, + maximumX, + maximumZ + )) { + continue; + } + RiverReach reach = accumulator.build(); + if (intersects(reach, minimumX, minimumZ, maximumX, maximumZ)) { + reaches.add(reach); + } + } + reaches.sort(Comparator.comparing(RiverReach::id)); + return new RiverTile( + tileX, + tileZ, + (int) minimumX, + (int) minimumZ, + (int) maximumX, + (int) maximumZ, + reaches + ); + } + + public static long mix(long value) { + long mixed = value; + mixed ^= mixed >>> 30; + mixed *= 0xBF58476D1CE4E5B9L; + mixed ^= mixed >>> 27; + mixed *= 0x94D049BB133111EBL; + mixed ^= mixed >>> 31; + return mixed; + } + + private RiverNode createNode(RiverNodeId id, RiverTerrainSampler terrain) { + NodePosition position = nodePosition(id); + double x = position.x(); + double z = position.z(); + int blockX = position.blockX(); + int blockZ = position.blockZ(); + RiverTerrainNodeSample terrainSample = terrain.sampleNode(blockX, blockZ); + double naturalHeight = finiteOrZero(terrainSample.naturalHeight()); + boolean ocean = terrainSample.ocean(); + boolean riverAllowed = terrainSample.riverAllowed(); + double routingNoise = centered(hash(id, NODE_RANK_SALT)); + double routingScore = naturalHeight * options.terrainHeightWeight() + + routingNoise * options.routingNoiseWeight() + + finiteOrZero(terrainSample.routingCost()); + double drainageDistance = drainageDistance(id); + double hydraulicHeight = ocean + ? options.hydraulicBaseHeight() + : options.hydraulicBaseHeight() + + StrictMath.floor(drainageDistance / options.routingPlateauHeight()); + double rank = ocean ? -Double.MAX_VALUE : drainageDistance; + return new RiverNode( + id, + x, + z, + naturalHeight, + hydraulicHeight, + rank, + finiteOrZero(routingScore), + ocean, + riverAllowed + ); + } + + private double drainageDistance(RiverNodeId id) { + int basinCells = options.routingBasinCells(); + long basinX = Math.floorDiv(id.cellX(), basinCells); + long basinZ = Math.floorDiv(id.cellZ(), basinCells); + double nodeX = id.cellX() + 0.5D; + double nodeZ = id.cellZ() + 0.5D; + double jitterRadius = basinCells * 0.45D; + double nearestDistance = Double.MAX_VALUE; + for (long candidateX = basinX - 1L; candidateX <= basinX + 1L; candidateX++) { + for (long candidateZ = basinZ - 1L; candidateZ <= basinZ + 1L; candidateZ++) { + double siteX = (candidateX + 0.5D) * basinCells + + centered(hash(candidateX, candidateZ, BASIN_X_SALT)) * jitterRadius; + double siteZ = (candidateZ + 0.5D) * basinCells + + centered(hash(candidateX, candidateZ, BASIN_Z_SALT)) * jitterRadius; + nearestDistance = StrictMath.min( + nearestDistance, + StrictMath.hypot(nodeX - siteX, nodeZ - siteZ) + ); + } + } + return nearestDistance; + } + + private NodePosition nodePosition(RiverNodeId id) { + double centerX = ((double) id.cellX() + 0.5D) * options.cellSize(); + double centerZ = ((double) id.cellZ() + 0.5D) * options.cellSize(); + double jitterRadius = options.siteJitter() * options.cellSize() * 0.5D; + double x = centerX + centered(hash(id, NODE_X_SALT)) * jitterRadius; + double z = centerZ + centered(hash(id, NODE_Z_SALT)) * jitterRadius; + return new NodePosition( + x, + z, + clampToInt(StrictMath.round(x)), + clampToInt(StrictMath.round(z)) + ); + } + + private List computeDownstreamCandidates(RiverNode node, NodeResolver resolver) { + if (node.ocean() || !node.riverAllowed()) { + return List.of(); + } + ArrayList ranked = new ArrayList<>(8); + for (RiverNodeId neighborId : neighbors(node.id())) { + RiverNode neighbor = resolver.resolve(neighborId); + if (!neighbor.riverAllowed()) { + continue; + } + if (compareRank(neighbor, node) >= 0) { + continue; + } + RiverRoutingContext context = resolver.routingContext(node, neighbor); + double routingCost = finiteNonNegative(resolver.terrain.reachRoutingCost(context)); + double oceanAttraction = neighbor.ocean() ? options.oceanAttraction() : 0.0; + double flowAlignmentCost = flowAlignmentCost(node, neighbor, resolver); + ranked.add(new RankedCandidate( + neighbor, + neighbor.routingScore() + routingCost + flowAlignmentCost - oceanAttraction + )); + } + ranked.sort((first, second) -> { + int costComparison = Double.compare(first.cost(), second.cost()); + return costComparison != 0 ? costComparison : compareRank(first.node(), second.node()); + }); + ArrayList candidates = new ArrayList<>(ranked.size()); + for (RankedCandidate candidate : ranked) { + candidates.add(candidate.node()); + } + return List.copyOf(candidates); + } + + private RiverRoute trace(RiverNodeId sourceId, NodeResolver resolver) { + if (!resolver.sourcePermitted(sourceId)) { + return new RiverRoute(sourceId, RiverRouteState.SUPPRESSED, List.of(), false, false); + } + RiverNode source = resolver.resolve(sourceId); + + ArrayList edges = new ArrayList<>(options.maxRouteReaches()); + RiverNode current = source; + boolean reachedOcean = false; + for (int reachIndex = 0; reachIndex < options.maxRouteReaches(); reachIndex++) { + RiverNode next = null; + int examined = 0; + for (RiverNode candidate : resolver.downstreamCandidates(current)) { + if (examined >= options.downstreamCandidateLimit()) { + break; + } + examined++; + RiverRoutingContext context = resolver.routingContext(current, candidate); + if (resolver.reachFeasible(context)) { + next = candidate; + break; + } + } + if (next == null) { + break; + } + RiverEdgeId edgeId = RiverEdgeId.of(current.id(), next.id()); + if (!resolver.continuationPermitted(resolver.routingContext(current, next))) { + break; + } + edges.add(edgeId); + current = next; + if (current.ocean()) { + reachedOcean = true; + break; + } + } + + if (reachedOcean) { + return new RiverRoute(sourceId, RiverRouteState.WET, edges, true, false); + } + if (!edges.isEmpty()) { + RiverTerminalPolicy terminalPolicy = resolver.terminalPolicy(current); + if (terminalPolicy == RiverTerminalPolicy.WET + || (terminalPolicy == RiverTerminalPolicy.INHERIT && !options.requireOcean())) { + return new RiverRoute(sourceId, RiverRouteState.WET, edges, false, true); + } + if ((terminalPolicy == RiverTerminalPolicy.DRY + || terminalPolicy == RiverTerminalPolicy.INHERIT) + && resolver.dryPermitted(sourceId)) { + return new RiverRoute(sourceId, RiverRouteState.DRY, edges, false, true); + } + } + return new RiverRoute(sourceId, RiverRouteState.SUPPRESSED, List.of(), false, false); + } + + private void accumulate( + RiverRoute route, + NodeResolver resolver, + Map accumulators + ) { + if (route.state() == RiverRouteState.SUPPRESSED) { + return; + } + for (int edgeIndex = 0; edgeIndex < route.edges().size(); edgeIndex++) { + RiverEdgeId edgeId = route.edges().get(edgeIndex); + ReachAccumulator accumulator = accumulators.get(edgeId); + if (accumulator == null) { + RiverNode first = resolver.resolve(edgeId.first()); + RiverNode second = resolver.resolve(edgeId.second()); + RiverNode from = compareRank(first, second) > 0 ? first : second; + RiverNode to = from == first ? second : first; + accumulator = new ReachAccumulator( + edgeId, + from, + to, + resolver.routingContext(from, to), + resolver.terrain + ); + accumulators.put(edgeId, accumulator); + } + boolean terminal = route.terminal() && edgeIndex == route.edges().size() - 1; + accumulator.add(route.state(), terminal); + } + } + + private RiverNodeId diagonalNeighbor(RiverNodeId id, long squareX, long squareZ) { + boolean ascending = (hash(squareX, squareZ, DIAGONAL_SALT) & 1L) == 0L; + RiverNodeId first = ascending + ? new RiverNodeId(squareX, squareZ) + : new RiverNodeId(squareX, squareZ + 1L); + RiverNodeId second = ascending + ? new RiverNodeId(squareX + 1L, squareZ + 1L) + : new RiverNodeId(squareX + 1L, squareZ); + if (id.equals(first)) { + return second; + } + return id.equals(second) ? first : null; + } + + private RiverPolyline createPolyline( + RiverEdgeId id, + RiverNode from, + RiverNode to, + NodeResolver resolver + ) { + int pointCount = options.meanderSubdivisions() + 1; + double[] x = new double[pointCount]; + double[] z = new double[pointCount]; + double deltaX = to.x() - from.x(); + double deltaZ = to.z() - from.z(); + double length = StrictMath.hypot(deltaX, deltaZ); + double normalX = length == 0.0 ? 0.0 : -deltaZ / length; + double normalZ = length == 0.0 ? 0.0 : deltaX / length; + double maximumOffset = StrictMath.min(options.meanderStrength(), length * 0.35); + double directionX = length == 0D ? 1D : deltaX / length; + double directionZ = length == 0D ? 0D : deltaZ / length; + FlowTangent fromTangent = flowTangent(from, directionX, directionZ, resolver); + FlowTangent toTangent = flowTangent(to, directionX, directionZ, resolver); + for (int point = 0; point < pointCount; point++) { + double t = (double) point / (pointCount - 1); + double tSquared = t * t; + double tCubed = tSquared * t; + double fromWeight = 2D * tCubed - 3D * tSquared + 1D; + double fromTangentWeight = tCubed - 2D * tSquared + t; + double toWeight = -2D * tCubed + 3D * tSquared; + double toTangentWeight = tCubed - tSquared; + double curvedX = fromWeight * from.x() + + fromTangentWeight * fromTangent.x() * maximumOffset + + toWeight * to.x() + + toTangentWeight * toTangent.x() * maximumOffset; + double curvedZ = fromWeight * from.z() + + fromTangentWeight * fromTangent.z() * maximumOffset + + toWeight * to.z() + + toTangentWeight * toTangent.z() * maximumOffset; + double straightX = from.x() + deltaX * t; + double straightZ = from.z() + deltaZ * t; + RiverMeanderContext context = new RiverMeanderContext(id, t, straightX, straightZ); + double configuredNoise = resolver.terrain.meanderNoise(context); + double noise = Double.isFinite(configuredNoise) + ? StrictMath.max(-1.0, StrictMath.min(1.0, configuredNoise)) + : smoothEdgeNoise(id, t * 3.0); + double envelope = 16D * tSquared * (1D - t) * (1D - t); + double offset = maximumOffset * 0.5D * envelope * noise; + x[point] = curvedX + normalX * offset; + z[point] = curvedZ + normalZ * offset; + } + x[0] = from.x(); + z[0] = from.z(); + x[pointCount - 1] = to.x(); + z[pointCount - 1] = to.z(); + return new RiverPolyline(x, z); + } + + private FlowTangent flowTangent( + RiverNode node, + double fallbackX, + double fallbackZ, + NodeResolver resolver + ) { + FlowTangent preferred = resolver.flowTangent(node); + double tangentX = preferred.x(); + double tangentZ = preferred.z(); + if (tangentX == 0D && tangentZ == 0D) { + return new FlowTangent(fallbackX, fallbackZ); + } + double alignment = tangentX * fallbackX + tangentZ * fallbackZ; + if (alignment < 0D) { + tangentX = -tangentX; + tangentZ = -tangentZ; + alignment = -alignment; + } + if (alignment < 0.15D) { + return new FlowTangent(fallbackX, fallbackZ); + } + return new FlowTangent(tangentX, tangentZ); + } + + private FlowTangent resolveFlowTangent(RiverNode node, RiverTerrainSampler terrain) { + double spacing = StrictMath.max(1D, options.cellSize() * 0.5D); + double left = terrain.flowNoise(node.x() - spacing, node.z()); + double right = terrain.flowNoise(node.x() + spacing, node.z()); + double top = terrain.flowNoise(node.x(), node.z() - spacing); + double bottom = terrain.flowNoise(node.x(), node.z() + spacing); + if (!Double.isFinite(left) || !Double.isFinite(right) + || !Double.isFinite(top) || !Double.isFinite(bottom)) { + return new FlowTangent(0D, 0D); + } + double tangentX = -(bottom - top); + double tangentZ = right - left; + double tangentLength = StrictMath.hypot(tangentX, tangentZ); + if (tangentLength <= 0.0000001D) { + return new FlowTangent(0D, 0D); + } + return new FlowTangent(tangentX / tangentLength, tangentZ / tangentLength); + } + + private double flowAlignmentCost(RiverNode from, RiverNode to, NodeResolver resolver) { + if (options.flowAlignmentWeight() <= 0D) { + return 0D; + } + FlowTangent tangent = resolver.flowTangent(from); + if (tangent.x() == 0D && tangent.z() == 0D) { + return 0D; + } + double deltaX = to.x() - from.x(); + double deltaZ = to.z() - from.z(); + double length = StrictMath.hypot(deltaX, deltaZ); + if (length <= 0.0000001D) { + return options.flowAlignmentWeight(); + } + double alignment = StrictMath.abs( + tangent.x() * deltaX / length + tangent.z() * deltaZ / length + ); + return options.flowAlignmentWeight() * (1D - StrictMath.min(1D, alignment)); + } + + private double smoothEdgeNoise(RiverEdgeId id, double position) { + int lower = (int) StrictMath.floor(position); + int upper = lower + 1; + double fraction = position - lower; + double fade = fraction * fraction * (3.0 - 2.0 * fraction); + double a = centered(mix(options.seed() ^ id.stableId() ^ MEANDER_SALT ^ lower * 0x9E3779B97F4A7C15L)); + double b = centered(mix(options.seed() ^ id.stableId() ^ MEANDER_SALT ^ upper * 0x9E3779B97F4A7C15L)); + return a + (b - a) * fade; + } + + private boolean intersects( + RiverReach reach, + long minimumX, + long minimumZ, + long maximumX, + long maximumZ + ) { + double radius = reach.width() * 0.5 + reach.bankWidth(); + RiverPolyline polyline = reach.polyline(); + for (int point = 0; point < polyline.size() - 1; point++) { + double segmentMinimumX = StrictMath.min(polyline.x(point), polyline.x(point + 1)) - radius; + double segmentMaximumX = StrictMath.max(polyline.x(point), polyline.x(point + 1)) + radius; + double segmentMinimumZ = StrictMath.min(polyline.z(point), polyline.z(point + 1)) - radius; + double segmentMaximumZ = StrictMath.max(polyline.z(point), polyline.z(point + 1)) + radius; + if (segmentMaximumX >= minimumX && segmentMinimumX < maximumX + && segmentMaximumZ >= minimumZ && segmentMinimumZ < maximumZ) { + return true; + } + } + return false; + } + + private boolean potentiallyIntersects( + RiverNode from, + RiverNode to, + long minimumX, + long minimumZ, + long maximumX, + long maximumZ + ) { + double length = StrictMath.hypot(to.x() - from.x(), to.z() - from.z()); + double maximumMeander = StrictMath.min(options.meanderStrength(), length * 0.35D); + double padding = options.maximumReachRadius() + maximumMeander; + double reachMinimumX = StrictMath.min(from.x(), to.x()) - padding; + double reachMaximumX = StrictMath.max(from.x(), to.x()) + padding; + double reachMinimumZ = StrictMath.min(from.z(), to.z()) - padding; + double reachMaximumZ = StrictMath.max(from.z(), to.z()) + padding; + return reachMaximumX >= minimumX && reachMinimumX < maximumX + && reachMaximumZ >= minimumZ && reachMinimumZ < maximumZ; + } + + private int geometryPaddingCells() { + double maximumEdgeAxisDelta = options.cellSize() * (1D + options.siteJitter()); + double maximumEdgeLength = StrictMath.sqrt(2D) * maximumEdgeAxisDelta; + double maximumMeander = StrictMath.min(options.meanderStrength(), maximumEdgeLength * 0.35D); + double displacement = options.maximumReachRadius() + maximumMeander; + return 1 + (int) StrictMath.ceil(displacement / options.cellSize()); + } + + private int compareRank(RiverNode first, RiverNode second) { + if (first.ocean() != second.ocean()) { + return first.ocean() ? -1 : 1; + } + int rankComparison = Double.compare(first.rank(), second.rank()); + if (rankComparison != 0) { + return rankComparison; + } + int hydraulicComparison = Double.compare(first.hydraulicHeight(), second.hydraulicHeight()); + return hydraulicComparison != 0 ? hydraulicComparison : first.id().compareTo(second.id()); + } + + private long hash(RiverNodeId id, long salt) { + return mix(options.seed() ^ id.stableId() ^ salt); + } + + private long hash(RiverEdgeId id, long salt) { + return mix(options.seed() ^ id.stableId() ^ salt); + } + + private long hash(long x, long z, long salt) { + return mix(options.seed() ^ salt ^ mix(x * 0x9E3779B97F4A7C15L) ^ Long.rotateLeft(mix(z), 27)); + } + + private static void addUnique(List values, RiverNodeId candidate) { + if (!values.contains(candidate)) { + values.add(candidate); + } + } + + private static boolean gate(long hash, double chance) { + if (chance <= 0.0) { + return false; + } + if (chance >= 1.0) { + return true; + } + return unit(hash) < chance; + } + + private static double centered(long hash) { + return unit(hash) * 2.0 - 1.0; + } + + private static double unit(long hash) { + return (hash >>> 11) * 0x1.0p-53; + } + + private static int clampToInt(long value) { + return (int) StrictMath.max(Integer.MIN_VALUE, StrictMath.min(Integer.MAX_VALUE, value)); + } + + private static double finiteOrZero(double value) { + return Double.isFinite(value) ? value : 0.0; + } + + private static double finiteNonNegative(double value) { + return Double.isFinite(value) && value > 0.0 ? value : 0.0; + } + + private static double effectiveChance(double baseChance, double multiplier) { + if (!Double.isFinite(multiplier) || multiplier <= 0.0) { + return 0.0; + } + return StrictMath.min(1.0, baseChance * multiplier); + } + + private static void requireWorldBounds(long minimumX, long minimumZ, long maximumX, long maximumZ) { + if (minimumX < Integer.MIN_VALUE || minimumZ < Integer.MIN_VALUE + || maximumX > Integer.MAX_VALUE || maximumZ > Integer.MAX_VALUE) { + throw new IllegalArgumentException("River tile exceeds integer world coordinates"); + } + } + + private final class NodeResolver { + private final RiverTerrainSampler terrain; + private final Map nodes; + private final Map> downstreamCandidates; + private final Map sourceGates; + private final Map> minimumSources; + private final Map reachFeasibilities; + private final Map continuationGates; + private final Map dryGates; + private final Map terminalPolicies; + private final Map routingContexts; + private final Map flowTangents; + + private NodeResolver(RiverTerrainSampler terrain) { + this.terrain = terrain; + nodes = new HashMap<>(); + downstreamCandidates = new HashMap<>(); + sourceGates = new HashMap<>(); + minimumSources = new HashMap<>(); + reachFeasibilities = new HashMap<>(); + continuationGates = new HashMap<>(); + dryGates = new HashMap<>(); + terminalPolicies = new HashMap<>(); + routingContexts = new HashMap<>(); + flowTangents = new HashMap<>(); + } + + private RiverNode resolve(RiverNodeId id) { + return nodes.computeIfAbsent(id, key -> createNode(key, terrain)); + } + + private List downstreamCandidates(RiverNode node) { + return downstreamCandidates.computeIfAbsent( + node.id(), + ignored -> computeDownstreamCandidates(node, this)); + } + + private boolean sourcePermitted(RiverNodeId sourceId) { + return sourceGates.computeIfAbsent(sourceId, this::computeSourcePermitted); + } + + private boolean computeSourcePermitted(RiverNodeId sourceId) { + if (options.sourceChance() <= 0D) { + return false; + } + boolean minimumSelected = minimumSources(sourceId).contains(sourceId); + if (minimumSelected) { + return true; + } + long sourceHash = hash(sourceId, SOURCE_SALT); + double maximumMultiplier = terrain.maximumSourceChanceMultiplier(); + if (!minimumSelected + && Double.isFinite(maximumMultiplier) + && !gate(sourceHash, effectiveChance(options.sourceChance(), maximumMultiplier))) { + return false; + } + NodePosition position = nodePosition(sourceId); + RiverTerrainSourceSample sourceSample = terrain.sampleSource(position.blockX(), position.blockZ()); + double chance = effectiveChance( + options.sourceChance(), + sourceSample.chanceMultiplier() + ); + boolean selected = gate(sourceHash, chance); + boolean permitted = selected + && sourceSample.riverAllowed() + && !sourceSample.ocean(); + return permitted; + } + + private List minimumSources(RiverNodeId sourceId) { + if (options.minimumSourcesPerTile() <= 0 || options.sourceChance() <= 0D) { + return List.of(); + } + SourceTileId tileId = new SourceTileId( + Math.floorDiv(sourceId.cellX(), options.tileCells()), + Math.floorDiv(sourceId.cellZ(), options.tileCells()) + ); + return minimumSources.computeIfAbsent(tileId, this::computeMinimumSources); + } + + private List computeMinimumSources(SourceTileId tileId) { + long minimumCellX = tileId.tileX() * options.tileCells(); + long minimumCellZ = tileId.tileZ() * options.tileCells(); + int candidateCount = options.tileCells() * options.tileCells(); + int targetCount = Math.min(options.minimumSourcesPerTile(), candidateCount); + if (targetCount == candidateCount) { + ArrayList selected = new ArrayList<>(candidateCount); + for (long cellX = minimumCellX; cellX < minimumCellX + options.tileCells(); cellX++) { + for (long cellZ = minimumCellZ; cellZ < minimumCellZ + options.tileCells(); cellZ++) { + RiverNodeId candidateId = new RiverNodeId(cellX, cellZ); + if (sourceFloorEligible(candidateId)) { + selected.add(candidateId); + } + } + } + return List.copyOf(selected); + } + ArrayList candidates = new ArrayList<>(candidateCount); + for (long cellX = minimumCellX; cellX < minimumCellX + options.tileCells(); cellX++) { + for (long cellZ = minimumCellZ; cellZ < minimumCellZ + options.tileCells(); cellZ++) { + RiverNodeId candidateId = new RiverNodeId(cellX, cellZ); + candidates.add(new WeightedSource( + candidateId, + -drainageDistance(candidateId) + + unit(hash(candidateId, SOURCE_FLOOR_SALT)) * 0.25D + )); + } + } + candidates.sort(Comparator.comparingDouble(WeightedSource::priority) + .thenComparing(WeightedSource::id)); + ArrayList selected = new ArrayList<>(targetCount); + for (WeightedSource candidate : candidates) { + if (!sourceFloorEligible(candidate.id())) { + continue; + } + selected.add(candidate.id()); + if (selected.size() >= targetCount) { + break; + } + } + return List.copyOf(selected); + } + + private boolean sourceFloorEligible(RiverNodeId candidateId) { + NodePosition position = nodePosition(candidateId); + RiverTerrainSourceSample sourceSample = terrain.sampleSource(position.blockX(), position.blockZ()); + return sourceSample.riverAllowed() + && !sourceSample.ocean() + && Double.isFinite(sourceSample.chanceMultiplier()) + && sourceSample.chanceMultiplier() > 0D; + } + + private boolean reachFeasible(RiverRoutingContext context) { + return reachFeasibilities.computeIfAbsent( + context.edgeId(), + ignored -> terrain.allowsReach(context)); + } + + private boolean continuationPermitted(RiverRoutingContext context) { + return continuationGates.computeIfAbsent(context.edgeId(), ignored -> { + double chance = effectiveChance( + options.reachChance(), + terrain.reachChanceMultiplier(context.midpointX(), context.midpointZ()) + ); + return gate(hash(context.edgeId(), REACH_SALT), chance); + }); + } + + private boolean dryPermitted(RiverNodeId sourceId) { + return dryGates.computeIfAbsent( + sourceId, + ignored -> gate(hash(sourceId, DRY_SALT), options.dryChannelChance())); + } + + private RiverTerminalPolicy terminalPolicy(RiverNode terminal) { + return terminalPolicies.computeIfAbsent(terminal.id(), ignored -> { + int terminalX = clampToInt(StrictMath.round(terminal.x())); + int terminalZ = clampToInt(StrictMath.round(terminal.z())); + RiverTerminalPolicy sampled = terrain.terminalPolicy(terminalX, terminalZ); + return sampled == null ? RiverTerminalPolicy.INHERIT : sampled; + }); + } + + private RiverRoutingContext routingContext(RiverNode from, RiverNode to) { + RiverEdgeId edgeId = RiverEdgeId.of(from.id(), to.id()); + return routingContexts.computeIfAbsent(edgeId, ignored -> RiverRoutingContext.lazy( + edgeId, + from, + to, + () -> createPolyline(edgeId, from, to, this))); + } + + private FlowTangent flowTangent(RiverNode node) { + return flowTangents.computeIfAbsent( + node.id(), + ignored -> resolveFlowTangent(node, terrain)); + } + } + + private record RankedCandidate(RiverNode node, double cost) { + } + + private record NodePosition(double x, double z, int blockX, int blockZ) { + } + + private record FlowTangent(double x, double z) { + } + + private record SourceTileId(long tileX, long tileZ) { + } + + private record WeightedSource(RiverNodeId id, double priority) { + } + + private final class ReachAccumulator { + private final RiverEdgeId id; + private final RiverNode from; + private final RiverNode to; + private final RiverRoutingContext context; + private final RiverTerrainSampler terrain; + private int wetFlow; + private int dryFlow; + private int terminalWetFlow; + private int terminalDryFlow; + + private ReachAccumulator( + RiverEdgeId id, + RiverNode from, + RiverNode to, + RiverRoutingContext context, + RiverTerrainSampler terrain + ) { + this.id = id; + this.from = from; + this.to = to; + this.context = context; + this.terrain = terrain; + } + + private void add(RiverRouteState state, boolean terminal) { + if (state == RiverRouteState.WET) { + wetFlow++; + if (terminal) { + terminalWetFlow++; + } + } else if (state == RiverRouteState.DRY) { + dryFlow++; + if (terminal) { + terminalDryFlow++; + } + } + } + + private RiverReach build() { + int flow = wetFlow + dryFlow; + int order = 1 + (31 - Integer.numberOfLeadingZeros(flow)); + double baseWidth = positiveOrFallback( + terrain.channelWidth(context, options.channelWidth()), + options.channelWidth() + ); + double bankWidth = nonNegativeOrFallback( + terrain.bankWidth(context, options.bankWidth()), + options.bankWidth() + ); + double baseDepth = positiveOrFallback( + terrain.depth(context, options.depth()), + options.depth() + ); + double width = StrictMath.min( + options.maxChannelWidth(), + baseWidth * (1.0 + options.orderWidthFactor() * (order - 1)) + ); + bankWidth = StrictMath.min(options.maxBankWidth(), bankWidth); + double depth = StrictMath.min( + options.maxDepth(), + baseDepth * (1.0 + options.orderDepthFactor() * (order - 1)) + ); + RiverRouteState state = wetFlow > 0 ? RiverRouteState.WET : RiverRouteState.DRY; + return new RiverReach( + id, + from, + to, + state, + flow, + order, + width, + bankWidth, + depth, + state == RiverRouteState.WET && to.ocean(), + state == RiverRouteState.WET + ? terminalWetFlow == wetFlow + : terminalDryFlow == dryFlow, + context.polyline() + ); + } + } + + private static double positiveOrFallback(double value, double fallback) { + return Double.isFinite(value) && value > 0.0 ? value : fallback; + } + + private static double nonNegativeOrFallback(double value, double fallback) { + return Double.isFinite(value) && value >= 0.0 ? value : fallback; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java new file mode 100644 index 000000000..464202b33 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java @@ -0,0 +1,351 @@ +package art.arcane.iris.engine.river; + +public record RiverNetworkOptions( + long seed, + int cellSize, + int tileCells, + double siteJitter, + int maxRouteReaches, + int minimumSourcesPerTile, + int downstreamCandidateLimit, + int routingBasinCells, + double routingPlateauHeight, + double hydraulicBaseHeight, + boolean requireOcean, + double sourceChance, + double reachChance, + double dryChannelChance, + double terrainHeightWeight, + double routingNoiseWeight, + double flowAlignmentWeight, + double oceanAttraction, + double channelWidth, + double bankWidth, + double depth, + double maxChannelWidth, + double maxBankWidth, + double maxDepth, + double orderWidthFactor, + double orderDepthFactor, + double maximumReachRadius, + double meanderStrength, + int meanderSubdivisions +) { + public RiverNetworkOptions { + requireRange(cellSize, 8, 4096, "cellSize"); + requireRange(tileCells, 1, 64, "tileCells"); + requireRange(maxRouteReaches, 1, 256, "maxRouteReaches"); + requireRange(minimumSourcesPerTile, 0, tileCells * tileCells, "minimumSourcesPerTile"); + requireRange(downstreamCandidateLimit, 1, 8, "downstreamCandidateLimit"); + requireRange(routingBasinCells, 8, 256, "routingBasinCells"); + requirePositive(routingPlateauHeight, "routingPlateauHeight"); + requireFinite(hydraulicBaseHeight, "hydraulicBaseHeight"); + requireRange(meanderSubdivisions, 1, 64, "meanderSubdivisions"); + requireProbability(siteJitter, "siteJitter"); + requireProbability(sourceChance, "sourceChance"); + requireProbability(reachChance, "reachChance"); + requireProbability(dryChannelChance, "dryChannelChance"); + requireFiniteNonNegative(terrainHeightWeight, "terrainHeightWeight"); + requireFiniteNonNegative(routingNoiseWeight, "routingNoiseWeight"); + requireFiniteNonNegative(flowAlignmentWeight, "flowAlignmentWeight"); + requireFiniteNonNegative(oceanAttraction, "oceanAttraction"); + requirePositive(channelWidth, "channelWidth"); + requireFiniteNonNegative(bankWidth, "bankWidth"); + requirePositive(depth, "depth"); + requirePositive(maxChannelWidth, "maxChannelWidth"); + requireFiniteNonNegative(maxBankWidth, "maxBankWidth"); + requirePositive(maxDepth, "maxDepth"); + requireFiniteNonNegative(orderWidthFactor, "orderWidthFactor"); + requireFiniteNonNegative(orderDepthFactor, "orderDepthFactor"); + requireFiniteNonNegative(maximumReachRadius, "maximumReachRadius"); + requireFiniteNonNegative(meanderStrength, "meanderStrength"); + RiverTopologyComplexity.requireSafe( + cellSize, + tileCells, + siteJitter, + maxRouteReaches, + maximumReachRadius, + meanderStrength, + meanderSubdivisions + ); + } + + public static Builder builder(long seed) { + return new Builder(seed); + } + + private static void requireRange(int value, int minimum, int maximum, String name) { + if (value < minimum || value > maximum) { + throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum); + } + } + + private static void requireProbability(double value, String name) { + if (!Double.isFinite(value) || value < 0.0 || value > 1.0) { + throw new IllegalArgumentException(name + " must be finite and between 0 and 1"); + } + } + + private static void requireFiniteNonNegative(double value, String name) { + if (!Double.isFinite(value) || value < 0.0) { + throw new IllegalArgumentException(name + " must be finite and non-negative"); + } + } + + private static void requirePositive(double value, String name) { + if (!Double.isFinite(value) || value <= 0.0) { + throw new IllegalArgumentException(name + " must be finite and positive"); + } + } + + private static void requireFinite(double value, String name) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException(name + " must be finite"); + } + } + + public static final class Builder { + private final long seed; + private int cellSize; + private int tileCells; + private double siteJitter; + private int maxRouteReaches; + private int minimumSourcesPerTile; + private int downstreamCandidateLimit; + private int routingBasinCells; + private double routingPlateauHeight; + private double hydraulicBaseHeight; + private boolean requireOcean; + private double sourceChance; + private double reachChance; + private double dryChannelChance; + private double terrainHeightWeight; + private double routingNoiseWeight; + private double flowAlignmentWeight; + private double oceanAttraction; + private double channelWidth; + private double bankWidth; + private double depth; + private double maxChannelWidth; + private double maxBankWidth; + private double maxDepth; + private double orderWidthFactor; + private double orderDepthFactor; + private double maximumReachRadius; + private double meanderStrength; + private int meanderSubdivisions; + + private Builder(long seed) { + this.seed = seed; + cellSize = 512; + tileCells = 4; + siteJitter = 0.35; + maxRouteReaches = 16; + minimumSourcesPerTile = 0; + downstreamCandidateLimit = 4; + routingBasinCells = 64; + routingPlateauHeight = 8.0; + hydraulicBaseHeight = 64D; + requireOcean = false; + sourceChance = 0.12; + reachChance = 0.98; + dryChannelChance = 0.35; + terrainHeightWeight = 1.0; + routingNoiseWeight = 24.0; + flowAlignmentWeight = 0D; + oceanAttraction = 64.0; + channelWidth = 10.0; + bankWidth = 8.0; + depth = 4.0; + maxChannelWidth = 10D; + maxBankWidth = 8D; + maxDepth = 10D; + orderWidthFactor = 0.35; + orderDepthFactor = 0.2; + maximumReachRadius = Double.NaN; + meanderStrength = 40.0; + meanderSubdivisions = 8; + } + + public Builder cellSize(int value) { + cellSize = value; + return this; + } + + public Builder tileCells(int value) { + tileCells = value; + return this; + } + + public Builder siteJitter(double value) { + siteJitter = value; + return this; + } + + public Builder maxRouteReaches(int value) { + maxRouteReaches = value; + return this; + } + + public Builder minimumSourcesPerTile(int value) { + minimumSourcesPerTile = value; + return this; + } + + public Builder downstreamCandidateLimit(int value) { + downstreamCandidateLimit = value; + return this; + } + + public Builder routingBasinCells(int value) { + routingBasinCells = value; + return this; + } + + public Builder routingPlateauHeight(double value) { + routingPlateauHeight = value; + return this; + } + + public Builder hydraulicBaseHeight(double value) { + hydraulicBaseHeight = value; + return this; + } + + public Builder requireOcean(boolean value) { + requireOcean = value; + return this; + } + + public Builder sourceChance(double value) { + sourceChance = value; + return this; + } + + public Builder reachChance(double value) { + reachChance = value; + return this; + } + + public Builder dryChannelChance(double value) { + dryChannelChance = value; + return this; + } + + public Builder terrainHeightWeight(double value) { + terrainHeightWeight = value; + return this; + } + + public Builder routingNoiseWeight(double value) { + routingNoiseWeight = value; + return this; + } + + public Builder flowAlignmentWeight(double value) { + flowAlignmentWeight = value; + return this; + } + + public Builder oceanAttraction(double value) { + oceanAttraction = value; + return this; + } + + public Builder channelWidth(double value) { + channelWidth = value; + return this; + } + + public Builder bankWidth(double value) { + bankWidth = value; + return this; + } + + public Builder depth(double value) { + depth = value; + return this; + } + + public Builder maxChannelWidth(double value) { + maxChannelWidth = value; + return this; + } + + public Builder maxBankWidth(double value) { + maxBankWidth = value; + return this; + } + + public Builder maxDepth(double value) { + maxDepth = value; + return this; + } + + public Builder orderWidthFactor(double value) { + orderWidthFactor = value; + return this; + } + + public Builder orderDepthFactor(double value) { + orderDepthFactor = value; + return this; + } + + public Builder maximumReachRadius(double value) { + maximumReachRadius = value; + return this; + } + + public Builder meanderStrength(double value) { + meanderStrength = value; + return this; + } + + public Builder meanderSubdivisions(int value) { + meanderSubdivisions = value; + return this; + } + + public RiverNetworkOptions build() { + double resolvedMaximumReachRadius = Double.isFinite(maximumReachRadius) + ? maximumReachRadius + : defaultMaximumReachRadius(); + return new RiverNetworkOptions( + seed, + cellSize, + tileCells, + siteJitter, + maxRouteReaches, + minimumSourcesPerTile, + downstreamCandidateLimit, + routingBasinCells, + routingPlateauHeight, + hydraulicBaseHeight, + requireOcean, + sourceChance, + reachChance, + dryChannelChance, + terrainHeightWeight, + routingNoiseWeight, + flowAlignmentWeight, + oceanAttraction, + channelWidth, + bankWidth, + depth, + maxChannelWidth, + maxBankWidth, + maxDepth, + orderWidthFactor, + orderDepthFactor, + resolvedMaximumReachRadius, + meanderStrength, + meanderSubdivisions + ); + } + + private double defaultMaximumReachRadius() { + return maxChannelWidth * 0.5D + maxBankWidth; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNode.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNode.java new file mode 100644 index 000000000..73b895b77 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverNode.java @@ -0,0 +1,24 @@ +package art.arcane.iris.engine.river; + +import java.util.Objects; + +public record RiverNode( + RiverNodeId id, + double x, + double z, + double naturalHeight, + double hydraulicHeight, + double rank, + double routingScore, + boolean ocean, + boolean riverAllowed +) { + public RiverNode { + Objects.requireNonNull(id); + if (!Double.isFinite(x) || !Double.isFinite(z) || !Double.isFinite(naturalHeight) + || !Double.isFinite(hydraulicHeight) + || !Double.isFinite(rank) || !Double.isFinite(routingScore)) { + throw new IllegalArgumentException("River node coordinates, height, and rank must be finite"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNodeId.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNodeId.java new file mode 100644 index 000000000..352502f58 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverNodeId.java @@ -0,0 +1,13 @@ +package art.arcane.iris.engine.river; + +public record RiverNodeId(long cellX, long cellZ) implements Comparable { + public long stableId() { + return RiverNetwork.mix(cellX * 0x9E3779B97F4A7C15L ^ Long.rotateLeft(cellZ * 0xC2B2AE3D27D4EB4FL, 31)); + } + + @Override + public int compareTo(RiverNodeId other) { + int xComparison = Long.compare(cellX, other.cellX); + return xComparison != 0 ? xComparison : Long.compare(cellZ, other.cellZ); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverPolyline.java b/core/src/main/java/art/arcane/iris/engine/river/RiverPolyline.java new file mode 100644 index 000000000..9970374ca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverPolyline.java @@ -0,0 +1,48 @@ +package art.arcane.iris.engine.river; + +public final class RiverPolyline { + private final double[] x; + private final double[] z; + private final double[] cumulativeLength; + private final double length; + + public RiverPolyline(double[] x, double[] z) { + if (x.length != z.length || x.length < 2) { + throw new IllegalArgumentException("River polyline requires matching coordinate arrays and at least two points"); + } + this.x = x.clone(); + this.z = z.clone(); + cumulativeLength = new double[x.length]; + double measuredLength = 0.0; + for (int i = 0; i < x.length; i++) { + if (!Double.isFinite(x[i]) || !Double.isFinite(z[i])) { + throw new IllegalArgumentException("River polyline coordinates must be finite"); + } + if (i > 0) { + measuredLength += StrictMath.hypot(x[i] - x[i - 1], z[i] - z[i - 1]); + cumulativeLength[i] = measuredLength; + } + } + length = measuredLength; + } + + public int size() { + return x.length; + } + + public double x(int index) { + return x[index]; + } + + public double z(int index) { + return z[index]; + } + + public double cumulativeLength(int index) { + return cumulativeLength[index]; + } + + public double length() { + return length; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java b/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java new file mode 100644 index 000000000..2b795d4cf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java @@ -0,0 +1,36 @@ +package art.arcane.iris.engine.river; + +import java.util.Objects; + +public record RiverReach( + RiverEdgeId id, + RiverNode from, + RiverNode to, + RiverRouteState state, + int flow, + int order, + double width, + double bankWidth, + double depth, + boolean mouth, + boolean terminal, + RiverPolyline polyline +) { + public RiverReach { + Objects.requireNonNull(id); + Objects.requireNonNull(from); + Objects.requireNonNull(to); + Objects.requireNonNull(state); + Objects.requireNonNull(polyline); + if (state == RiverRouteState.SUPPRESSED) { + throw new IllegalArgumentException("Suppressed routes cannot produce reaches"); + } + if (flow < 1 || order < 1) { + throw new IllegalArgumentException("River reach flow and order must be positive"); + } + if (!Double.isFinite(width) || width <= 0.0 || !Double.isFinite(bankWidth) || bankWidth < 0.0 + || !Double.isFinite(depth) || depth <= 0.0) { + throw new IllegalArgumentException("River reach dimensions must be finite and valid"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverRoute.java b/core/src/main/java/art/arcane/iris/engine/river/RiverRoute.java new file mode 100644 index 000000000..1d619c05e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverRoute.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.river; + +import java.util.List; +import java.util.Objects; + +public record RiverRoute( + RiverNodeId source, + RiverRouteState state, + List edges, + boolean oceanConnected, + boolean terminal +) { + public RiverRoute { + Objects.requireNonNull(source); + Objects.requireNonNull(state); + edges = List.copyOf(edges); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverRouteState.java b/core/src/main/java/art/arcane/iris/engine/river/RiverRouteState.java new file mode 100644 index 000000000..00beebf4e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverRouteState.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.river; + +public enum RiverRouteState { + WET, + DRY, + SUPPRESSED +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverRoutingContext.java b/core/src/main/java/art/arcane/iris/engine/river/RiverRoutingContext.java new file mode 100644 index 000000000..ef9e856c1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverRoutingContext.java @@ -0,0 +1,105 @@ +package art.arcane.iris.engine.river; + +import java.util.Objects; +import java.util.function.Supplier; + +public final class RiverRoutingContext { + private final RiverEdgeId edgeId; + private final RiverNode from; + private final RiverNode to; + private final Supplier polylineSupplier; + private volatile RiverPolyline polyline; + + public RiverRoutingContext(RiverEdgeId edgeId, RiverNode from, RiverNode to, RiverPolyline polyline) { + this(edgeId, from, to, () -> polyline, Objects.requireNonNull(polyline)); + } + + static RiverRoutingContext lazy( + RiverEdgeId edgeId, + RiverNode from, + RiverNode to, + Supplier polylineSupplier + ) { + return new RiverRoutingContext(edgeId, from, to, polylineSupplier, null); + } + + private RiverRoutingContext( + RiverEdgeId edgeId, + RiverNode from, + RiverNode to, + Supplier polylineSupplier, + RiverPolyline polyline + ) { + this.edgeId = Objects.requireNonNull(edgeId); + this.from = Objects.requireNonNull(from); + this.to = Objects.requireNonNull(to); + this.polylineSupplier = Objects.requireNonNull(polylineSupplier); + this.polyline = polyline; + } + + public RiverEdgeId edgeId() { + return edgeId; + } + + public RiverNode from() { + return from; + } + + public RiverNode to() { + return to; + } + + public RiverPolyline polyline() { + RiverPolyline resolved = polyline; + if (resolved != null) { + return resolved; + } + synchronized (this) { + if (polyline == null) { + polyline = Objects.requireNonNull(polylineSupplier.get()); + } + return polyline; + } + } + + public int midpointX() { + return (int) StrictMath.max( + Integer.MIN_VALUE, + StrictMath.min(Integer.MAX_VALUE, StrictMath.round((from.x() + to.x()) * 0.5)) + ); + } + + public int midpointZ() { + return (int) StrictMath.max( + Integer.MIN_VALUE, + StrictMath.min(Integer.MAX_VALUE, StrictMath.round((from.z() + to.z()) * 0.5)) + ); + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof RiverRoutingContext context)) { + return false; + } + return edgeId.equals(context.edgeId) + && from.equals(context.from) + && to.equals(context.to) + && polyline().equals(context.polyline()); + } + + @Override + public int hashCode() { + return Objects.hash(edgeId, from, to, polyline()); + } + + @Override + public String toString() { + return "RiverRoutingContext[edgeId=" + edgeId + + ", from=" + from + + ", to=" + to + + ", polyline=" + polyline() + "]"; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverSample.java b/core/src/main/java/art/arcane/iris/engine/river/RiverSample.java new file mode 100644 index 000000000..c527910da --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverSample.java @@ -0,0 +1,37 @@ +package art.arcane.iris.engine.river; + +public record RiverSample( + boolean present, + RiverRouteState state, + RiverSection section, + double distance, + double alongReach, + double carveWeight, + int flow, + int order, + double width, + double bankWidth, + double depth, + boolean terminal, + RiverEdgeId reachId +) { + private static final RiverSample NONE = new RiverSample( + false, + RiverRouteState.SUPPRESSED, + RiverSection.NONE, + Double.POSITIVE_INFINITY, + 0.0, + 0.0, + 0, + 0, + 0.0, + 0.0, + 0.0, + false, + null + ); + + public static RiverSample none() { + return NONE; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverSection.java b/core/src/main/java/art/arcane/iris/engine/river/RiverSection.java new file mode 100644 index 000000000..452774c60 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverSection.java @@ -0,0 +1,10 @@ +package art.arcane.iris.engine.river; + +public enum RiverSection { + NONE, + CHANNEL, + MOUTH, + BANK, + DRY_CHANNEL, + DRY_BANK +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerminalPolicy.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerminalPolicy.java new file mode 100644 index 000000000..5f0563d85 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTerminalPolicy.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.river; + +public enum RiverTerminalPolicy { + INHERIT, + WET, + DRY, + SUPPRESS +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainNodeSample.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainNodeSample.java new file mode 100644 index 000000000..8fe2fbee6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainNodeSample.java @@ -0,0 +1,9 @@ +package art.arcane.iris.engine.river; + +public record RiverTerrainNodeSample( + double naturalHeight, + boolean ocean, + boolean riverAllowed, + double routingCost +) { +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java new file mode 100644 index 000000000..a414624f5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java @@ -0,0 +1,76 @@ +package art.arcane.iris.engine.river; + +public interface RiverTerrainSampler { + double naturalHeight(int blockX, int blockZ); + + boolean isOcean(int blockX, int blockZ); + + default RiverTerrainNodeSample sampleNode(int blockX, int blockZ) { + return new RiverTerrainNodeSample( + naturalHeight(blockX, blockZ), + isOcean(blockX, blockZ), + allowsRiver(blockX, blockZ), + routingCost(blockX, blockZ) + ); + } + + default RiverTerrainSourceSample sampleSource(int blockX, int blockZ) { + return new RiverTerrainSourceSample( + sourceChanceMultiplier(blockX, blockZ), + allowsRiver(blockX, blockZ), + isOcean(blockX, blockZ) + ); + } + + default double routingCost(int blockX, int blockZ) { + return 0.0; + } + + default double sourceChanceMultiplier(int blockX, int blockZ) { + return 1.0; + } + + default double maximumSourceChanceMultiplier() { + return Double.POSITIVE_INFINITY; + } + + default double reachChanceMultiplier(int blockX, int blockZ) { + return 1.0; + } + + default boolean allowsRiver(int blockX, int blockZ) { + return true; + } + + default boolean allowsReach(RiverRoutingContext context) { + return true; + } + + default double reachRoutingCost(RiverRoutingContext context) { + return 0.0; + } + + default double meanderNoise(RiverMeanderContext context) { + return Double.NaN; + } + + default double flowNoise(double x, double z) { + return Double.NaN; + } + + default double channelWidth(RiverRoutingContext context, double fallback) { + return fallback; + } + + default double bankWidth(RiverRoutingContext context, double fallback) { + return fallback; + } + + default double depth(RiverRoutingContext context, double fallback) { + return fallback; + } + + default RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { + return RiverTerminalPolicy.INHERIT; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSourceSample.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSourceSample.java new file mode 100644 index 000000000..279feb58b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSourceSample.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.river; + +public record RiverTerrainSourceSample( + double chanceMultiplier, + boolean riverAllowed, + boolean ocean +) { +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java new file mode 100644 index 000000000..7dc838858 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java @@ -0,0 +1,621 @@ +package art.arcane.iris.engine.river; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class RiverTile { + private static final int BUCKET_SIZE = 64; + + private final int tileX; + private final int tileZ; + private final int minimumX; + private final int minimumZ; + private final int maximumX; + private final int maximumZ; + private final List reaches; + private final Map reachesById; + private final Map> spatialIndex; + + public RiverTile( + int tileX, + int tileZ, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + List reaches + ) { + if (minimumX >= maximumX || minimumZ >= maximumZ) { + throw new IllegalArgumentException("River tile bounds must have positive area"); + } + this.tileX = tileX; + this.tileZ = tileZ; + this.minimumX = minimumX; + this.minimumZ = minimumZ; + this.maximumX = maximumX; + this.maximumZ = maximumZ; + this.reaches = List.copyOf(reaches); + reachesById = indexById(this.reaches); + spatialIndex = createSpatialIndex(this.reaches); + } + + public int tileX() { + return tileX; + } + + public int tileZ() { + return tileZ; + } + + public int minimumX() { + return minimumX; + } + + public int minimumZ() { + return minimumZ; + } + + public int maximumX() { + return maximumX; + } + + public int maximumZ() { + return maximumZ; + } + + public List reaches() { + return reaches; + } + + public RiverReach reach(RiverEdgeId id) { + return reachesById.get(Objects.requireNonNull(id)); + } + + public List candidateAnchors(double spacing, long salt) { + return candidateAnchors(minimumX, minimumZ, maximumX, maximumZ, spacing, salt); + } + + public List candidateAnchors( + double queryMinimumX, + double queryMinimumZ, + double queryMaximumX, + double queryMaximumZ, + double spacing, + long salt + ) { + if (!Double.isFinite(spacing) || spacing <= 0.0) { + throw new IllegalArgumentException("River anchor spacing must be finite and positive"); + } + if (!Double.isFinite(queryMinimumX) || !Double.isFinite(queryMinimumZ) + || !Double.isFinite(queryMaximumX) || !Double.isFinite(queryMaximumZ) + || queryMinimumX >= queryMaximumX || queryMinimumZ >= queryMaximumZ) { + throw new IllegalArgumentException("River anchor query bounds must be finite and have positive area"); + } + ArrayList anchors = new ArrayList<>(); + for (RiverReach reach : indexedReaches(queryMinimumX, queryMinimumZ, queryMaximumX, queryMaximumZ)) { + addAnchors( + reach, + spacing, + salt, + queryMinimumX, + queryMinimumZ, + queryMaximumX, + queryMaximumZ, + anchors + ); + } + return List.copyOf(anchors); + } + + public int sampleCandidateCount(double x, double z) { + return indexedReaches(x, z).size(); + } + + public RiverSample sample(double x, double z) { + RiverReach nearestReach = null; + double nearestDistanceSquared = Double.POSITIVE_INFINITY; + double nearestAlongReach = 0.0; + for (RiverReach reach : indexedReaches(x, z)) { + ClosestPoint closest = closestPoint(reach.polyline(), x, z); + double outerRadius = reach.width() * 0.5 + reach.bankWidth(); + if (closest.distanceSquared() > outerRadius * outerRadius) { + continue; + } + if (closest.distanceSquared() < nearestDistanceSquared + || (closest.distanceSquared() == nearestDistanceSquared + && nearestReach != null + && reach.id().compareTo(nearestReach.id()) < 0)) { + nearestReach = reach; + nearestDistanceSquared = closest.distanceSquared(); + nearestAlongReach = closest.alongReach(); + } + } + if (nearestReach == null) { + return RiverSample.none(); + } + + return createSample(nearestReach, nearestDistanceSquared, nearestAlongReach); + } + + public RiverSample sampleFootprint( + double queryMinimumX, + double queryMinimumZ, + double queryMaximumX, + double queryMaximumZ + ) { + if (!Double.isFinite(queryMinimumX) || !Double.isFinite(queryMinimumZ) + || !Double.isFinite(queryMaximumX) || !Double.isFinite(queryMaximumZ) + || queryMinimumX > queryMaximumX || queryMinimumZ > queryMaximumZ) { + throw new IllegalArgumentException("River footprint bounds must be finite and ordered"); + } + RiverReach nearestReach = null; + double nearestDistanceSquared = Double.POSITIVE_INFINITY; + double nearestAlongReach = 0.0; + for (RiverReach reach : indexedReachesInclusive( + queryMinimumX, + queryMinimumZ, + queryMaximumX, + queryMaximumZ + )) { + ClosestPoint closest = closestPoint( + reach.polyline(), + queryMinimumX, + queryMinimumZ, + queryMaximumX, + queryMaximumZ + ); + double outerRadius = reach.width() * 0.5 + reach.bankWidth(); + if (closest.distanceSquared() > outerRadius * outerRadius) { + continue; + } + if (closest.distanceSquared() < nearestDistanceSquared + || (closest.distanceSquared() == nearestDistanceSquared + && nearestReach != null + && reach.id().compareTo(nearestReach.id()) < 0)) { + nearestReach = reach; + nearestDistanceSquared = closest.distanceSquared(); + nearestAlongReach = closest.alongReach(); + } + } + if (nearestReach == null) { + return RiverSample.none(); + } + + return createSample(nearestReach, nearestDistanceSquared, nearestAlongReach); + } + + private static RiverSample createSample( + RiverReach nearestReach, + double nearestDistanceSquared, + double nearestAlongReach + ) { + + double distance = StrictMath.sqrt(nearestDistanceSquared); + double channelRadius = nearestReach.width() * 0.5; + RiverSection section = section(nearestReach, distance, channelRadius); + double carveWeight = carveWeight(distance, channelRadius, nearestReach.bankWidth()); + return new RiverSample( + true, + nearestReach.state(), + section, + distance, + nearestAlongReach, + carveWeight, + nearestReach.flow(), + nearestReach.order(), + nearestReach.width(), + nearestReach.bankWidth(), + nearestReach.depth(), + nearestReach.terminal(), + nearestReach.id() + ); + } + + private static RiverSection section(RiverReach reach, double distance, double channelRadius) { + if (distance <= channelRadius) { + if (reach.state() == RiverRouteState.DRY) { + return RiverSection.DRY_CHANNEL; + } + return reach.mouth() ? RiverSection.MOUTH : RiverSection.CHANNEL; + } + return reach.state() == RiverRouteState.DRY ? RiverSection.DRY_BANK : RiverSection.BANK; + } + + private void addAnchors( + RiverReach reach, + double spacing, + long salt, + double queryMinimumX, + double queryMinimumZ, + double queryMaximumX, + double queryMaximumZ, + List anchors + ) { + double length = reach.polyline().length(); + double firstDistance = unit(RiverNetwork.mix(reach.id().stableId() ^ salt)) * spacing; + int index = 0; + for (double distance = firstDistance; distance < length; distance += spacing) { + Position position = positionAt(reach.polyline(), distance); + if (position.x() >= minimumX && position.x() < maximumX + && position.z() >= minimumZ && position.z() < maximumZ + && position.x() >= queryMinimumX && position.x() < queryMaximumX + && position.z() >= queryMinimumZ && position.z() < queryMaximumZ) { + long stableId = RiverNetwork.mix( + reach.id().stableId() ^ salt ^ (long) index * 0x9E3779B97F4A7C15L + ); + anchors.add(new RiverAnchor( + reach.id(), + index, + stableId, + spacing, + salt, + position.x(), + position.z(), + position.alongReach(), + reach.state(), + reach.flow(), + reach.order() + )); + } + index++; + } + } + + private static Position positionAt(RiverPolyline polyline, double targetDistance) { + double traversed = 0.0; + for (int point = 0; point < polyline.size() - 1; point++) { + double startX = polyline.x(point); + double startZ = polyline.z(point); + double deltaX = polyline.x(point + 1) - startX; + double deltaZ = polyline.z(point + 1) - startZ; + double segmentLength = StrictMath.hypot(deltaX, deltaZ); + if (targetDistance <= traversed + segmentLength || point == polyline.size() - 2) { + double t = segmentLength == 0.0 ? 0.0 : (targetDistance - traversed) / segmentLength; + t = StrictMath.max(0.0, StrictMath.min(1.0, t)); + double alongReach = polyline.length() == 0.0 ? 0.0 : targetDistance / polyline.length(); + return new Position(startX + deltaX * t, startZ + deltaZ * t, alongReach); + } + traversed += segmentLength; + } + return new Position( + polyline.x(polyline.size() - 1), + polyline.z(polyline.size() - 1), + 1.0 + ); + } + + private static double unit(long hash) { + return (hash >>> 11) * 0x1.0p-53; + } + + private static double carveWeight(double distance, double channelRadius, double bankWidth) { + if (distance <= channelRadius || bankWidth == 0.0) { + return 1.0; + } + double t = StrictMath.min(1.0, (distance - channelRadius) / bankWidth); + double smooth = t * t * (3.0 - 2.0 * t); + return 1.0 - smooth; + } + + private static ClosestPoint closestPoint(RiverPolyline polyline, double x, double z) { + double nearest = Double.POSITIVE_INFINITY; + double nearestAlong = 0.0; + for (int point = 0; point < polyline.size() - 1; point++) { + SegmentPoint segmentPoint = segmentPoint( + polyline.x(point), + polyline.z(point), + polyline.x(point + 1), + polyline.z(point + 1), + x, + z + ); + if (segmentPoint.distanceSquared() < nearest) { + nearest = segmentPoint.distanceSquared(); + double segmentLength = polyline.cumulativeLength(point + 1) - polyline.cumulativeLength(point); + double alongLength = polyline.cumulativeLength(point) + segmentLength * segmentPoint.t(); + nearestAlong = polyline.length() == 0.0 ? 0.0 : alongLength / polyline.length(); + } + } + return new ClosestPoint(nearest, nearestAlong); + } + + private static ClosestPoint closestPoint( + RiverPolyline polyline, + double minimumX, + double minimumZ, + double maximumX, + double maximumZ + ) { + double nearest = Double.POSITIVE_INFINITY; + double nearestAlong = 0.0; + for (int point = 0; point < polyline.size() - 1; point++) { + SegmentPoint segmentPoint = segmentRectanglePoint( + polyline.x(point), + polyline.z(point), + polyline.x(point + 1), + polyline.z(point + 1), + minimumX, + minimumZ, + maximumX, + maximumZ + ); + if (segmentPoint.distanceSquared() < nearest) { + nearest = segmentPoint.distanceSquared(); + double segmentLength = polyline.cumulativeLength(point + 1) - polyline.cumulativeLength(point); + double alongLength = polyline.cumulativeLength(point) + segmentLength * segmentPoint.t(); + nearestAlong = polyline.length() == 0.0 ? 0.0 : alongLength / polyline.length(); + } + } + return new ClosestPoint(nearest, nearestAlong); + } + + private static SegmentPoint segmentPoint( + double startX, + double startZ, + double endX, + double endZ, + double x, + double z + ) { + double deltaX = endX - startX; + double deltaZ = endZ - startZ; + double lengthSquared = deltaX * deltaX + deltaZ * deltaZ; + if (lengthSquared == 0.0) { + return new SegmentPoint(squared(x - startX) + squared(z - startZ), 0.0); + } + double projection = ((x - startX) * deltaX + (z - startZ) * deltaZ) / lengthSquared; + double t = StrictMath.max(0.0, StrictMath.min(1.0, projection)); + double nearestX = startX + deltaX * t; + double nearestZ = startZ + deltaZ * t; + return new SegmentPoint(squared(x - nearestX) + squared(z - nearestZ), t); + } + + private static SegmentPoint segmentRectanglePoint( + double startX, + double startZ, + double endX, + double endZ, + double minimumX, + double minimumZ, + double maximumX, + double maximumZ + ) { + double intersectionPosition = segmentRectangleIntersectionPosition( + startX, + startZ, + endX, + endZ, + minimumX, + minimumZ, + maximumX, + maximumZ + ); + if (!Double.isNaN(intersectionPosition)) { + return new SegmentPoint(0.0, intersectionPosition); + } + + double nearestDistanceSquared = pointRectangleDistanceSquared( + startX, + startZ, + minimumX, + minimumZ, + maximumX, + maximumZ + ); + double nearestPosition = 0.0; + double endDistanceSquared = pointRectangleDistanceSquared( + endX, + endZ, + minimumX, + minimumZ, + maximumX, + maximumZ + ); + if (endDistanceSquared < nearestDistanceSquared) { + nearestDistanceSquared = endDistanceSquared; + nearestPosition = 1.0; + } + + SegmentPoint corner = segmentPoint(startX, startZ, endX, endZ, minimumX, minimumZ); + if (corner.distanceSquared() < nearestDistanceSquared) { + nearestDistanceSquared = corner.distanceSquared(); + nearestPosition = corner.t(); + } + corner = segmentPoint(startX, startZ, endX, endZ, minimumX, maximumZ); + if (corner.distanceSquared() < nearestDistanceSquared) { + nearestDistanceSquared = corner.distanceSquared(); + nearestPosition = corner.t(); + } + corner = segmentPoint(startX, startZ, endX, endZ, maximumX, minimumZ); + if (corner.distanceSquared() < nearestDistanceSquared) { + nearestDistanceSquared = corner.distanceSquared(); + nearestPosition = corner.t(); + } + corner = segmentPoint(startX, startZ, endX, endZ, maximumX, maximumZ); + if (corner.distanceSquared() < nearestDistanceSquared) { + nearestDistanceSquared = corner.distanceSquared(); + nearestPosition = corner.t(); + } + return new SegmentPoint(nearestDistanceSquared, nearestPosition); + } + + private static double segmentRectangleIntersectionPosition( + double startX, + double startZ, + double endX, + double endZ, + double minimumX, + double minimumZ, + double maximumX, + double maximumZ + ) { + double minimumPosition = 0.0; + double maximumPosition = 1.0; + double deltaX = endX - startX; + if (deltaX == 0.0) { + if (startX < minimumX || startX > maximumX) { + return Double.NaN; + } + } else { + double first = (minimumX - startX) / deltaX; + double second = (maximumX - startX) / deltaX; + minimumPosition = StrictMath.max(minimumPosition, StrictMath.min(first, second)); + maximumPosition = StrictMath.min(maximumPosition, StrictMath.max(first, second)); + if (minimumPosition > maximumPosition) { + return Double.NaN; + } + } + + double deltaZ = endZ - startZ; + if (deltaZ == 0.0) { + if (startZ < minimumZ || startZ > maximumZ) { + return Double.NaN; + } + } else { + double first = (minimumZ - startZ) / deltaZ; + double second = (maximumZ - startZ) / deltaZ; + minimumPosition = StrictMath.max(minimumPosition, StrictMath.min(first, second)); + maximumPosition = StrictMath.min(maximumPosition, StrictMath.max(first, second)); + if (minimumPosition > maximumPosition) { + return Double.NaN; + } + } + return minimumPosition; + } + + private static double pointRectangleDistanceSquared( + double x, + double z, + double minimumX, + double minimumZ, + double maximumX, + double maximumZ + ) { + double deltaX = x < minimumX ? minimumX - x : StrictMath.max(0.0, x - maximumX); + double deltaZ = z < minimumZ ? minimumZ - z : StrictMath.max(0.0, z - maximumZ); + return squared(deltaX) + squared(deltaZ); + } + + private static double squared(double value) { + return value * value; + } + + private static Map> createSpatialIndex(List reaches) { + HashMap> mutable = new HashMap<>(); + for (RiverReach reach : reaches) { + double radius = reach.width() * 0.5 + reach.bankWidth(); + RiverPolyline polyline = reach.polyline(); + for (int point = 0; point < polyline.size() - 1; point++) { + int minimumBucketX = bucket(StrictMath.min(polyline.x(point), polyline.x(point + 1)) - radius); + int maximumBucketX = bucket(StrictMath.max(polyline.x(point), polyline.x(point + 1)) + radius); + int minimumBucketZ = bucket(StrictMath.min(polyline.z(point), polyline.z(point + 1)) - radius); + int maximumBucketZ = bucket(StrictMath.max(polyline.z(point), polyline.z(point + 1)) + radius); + for (int bucketX = minimumBucketX; bucketX <= maximumBucketX; bucketX++) { + for (int bucketZ = minimumBucketZ; bucketZ <= maximumBucketZ; bucketZ++) { + mutable.computeIfAbsent(bucketKey(bucketX, bucketZ), ignored -> new LinkedHashSet<>()).add(reach); + } + } + } + } + HashMap> immutable = new HashMap<>(mutable.size()); + for (Map.Entry> entry : mutable.entrySet()) { + immutable.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return Map.copyOf(immutable); + } + + private static Map indexById(List reaches) { + HashMap indexed = new HashMap<>(reaches.size()); + for (RiverReach reach : reaches) { + RiverReach previous = indexed.put(reach.id(), reach); + if (previous != null) { + throw new IllegalArgumentException("River tile cannot contain duplicate reach IDs"); + } + } + return Map.copyOf(indexed); + } + + private List indexedReaches(double x, double z) { + List indexed = spatialIndex.get(bucketKey(bucket(x), bucket(z))); + return indexed == null ? List.of() : indexed; + } + + private List indexedReaches( + double queryMinimumX, + double queryMinimumZ, + double queryMaximumX, + double queryMaximumZ + ) { + LinkedHashSet indexed = new LinkedHashSet<>(); + int minimumBucketX = bucket(queryMinimumX); + int maximumBucketX = bucket(StrictMath.nextDown(queryMaximumX)); + int minimumBucketZ = bucket(queryMinimumZ); + int maximumBucketZ = bucket(StrictMath.nextDown(queryMaximumZ)); + for (int bucketX = minimumBucketX; bucketX <= maximumBucketX; bucketX++) { + for (int bucketZ = minimumBucketZ; bucketZ <= maximumBucketZ; bucketZ++) { + List bucketReaches = spatialIndex.get(bucketKey(bucketX, bucketZ)); + if (bucketReaches != null) { + indexed.addAll(bucketReaches); + } + } + } + return List.copyOf(indexed); + } + + private List indexedReachesInclusive( + double queryMinimumX, + double queryMinimumZ, + double queryMaximumX, + double queryMaximumZ + ) { + int minimumBucketX = bucket(queryMinimumX); + int maximumBucketX = bucket(queryMaximumX); + int minimumBucketZ = bucket(queryMinimumZ); + int maximumBucketZ = bucket(queryMaximumZ); + if (spatialIndex.isEmpty()) { + return List.of(); + } + if (minimumBucketX == maximumBucketX && minimumBucketZ == maximumBucketZ) { + List bucketReaches = spatialIndex.get(bucketKey(minimumBucketX, minimumBucketZ)); + return bucketReaches == null ? List.of() : bucketReaches; + } + long bucketWidth = (long) maximumBucketX - minimumBucketX + 1L; + long bucketDepth = (long) maximumBucketZ - minimumBucketZ + 1L; + if (bucketWidth > spatialIndex.size() / bucketDepth + || bucketWidth * bucketDepth >= spatialIndex.size()) { + return reaches; + } + LinkedHashSet indexed = new LinkedHashSet<>(); + for (int bucketX = minimumBucketX; bucketX <= maximumBucketX; bucketX++) { + for (int bucketZ = minimumBucketZ; bucketZ <= maximumBucketZ; bucketZ++) { + List bucketReaches = spatialIndex.get(bucketKey(bucketX, bucketZ)); + if (bucketReaches != null) { + indexed.addAll(bucketReaches); + } + } + } + return List.copyOf(indexed); + } + + private static int bucket(double coordinate) { + return (int) StrictMath.floor(coordinate / BUCKET_SIZE); + } + + private static long bucketKey(int bucketX, int bucketZ) { + return ((long) bucketX << 32) ^ (bucketZ & 0xFFFFFFFFL); + } + + private record Position(double x, double z, double alongReach) { + } + + private record ClosestPoint(double distanceSquared, double alongReach) { + } + + private record SegmentPoint(double distanceSquared, double t) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTileCache.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTileCache.java new file mode 100644 index 000000000..2f89335ee --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTileCache.java @@ -0,0 +1,200 @@ +package art.arcane.iris.engine.river; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +public final class RiverTileCache implements AutoCloseable { + private final Object lock; + private final int maxCompletedEntries; + private final Map entries; + private final LinkedHashMap completedEntries; + private TileBuilder builder; + private boolean closed; + + public RiverTileCache(int maxCompletedEntries, TileBuilder builder) { + if (maxCompletedEntries < 1) { + throw new IllegalArgumentException("River tile cache capacity must be positive"); + } + this.maxCompletedEntries = maxCompletedEntries; + this.builder = Objects.requireNonNull(builder); + lock = new Object(); + entries = new HashMap<>(maxCompletedEntries); + completedEntries = new LinkedHashMap<>(maxCompletedEntries, 0.75f, true); + } + + public RiverTile get(int tileX, int tileZ) { + TileKey key = new TileKey(tileX, tileZ); + Entry entry; + TileBuilder activeBuilder; + boolean build; + synchronized (lock) { + requireOpen(); + entry = entries.get(key); + if (entry == null) { + entry = new Entry(); + entries.put(key, entry); + activeBuilder = builder; + build = true; + } else { + if (entry.completed) { + completedEntries.get(key); + } + activeBuilder = null; + build = false; + } + } + + if (build) { + build(key, entry, activeBuilder); + } + return await(entry.future, key); + } + + public int completedSize() { + synchronized (lock) { + return completedEntries.size(); + } + } + + public boolean isClosed() { + synchronized (lock) { + return closed; + } + } + + public void clear() { + List> invalidated; + synchronized (lock) { + requireOpen(); + invalidated = clearLocked(); + } + invalidate(invalidated, "River tile cache was cleared"); + } + + @Override + public void close() { + List> invalidated; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + builder = null; + invalidated = clearLocked(); + } + invalidate(invalidated, "River tile cache was closed"); + } + + private void build(TileKey key, Entry entry, TileBuilder activeBuilder) { + try { + RiverTile tile = Objects.requireNonNull( + activeBuilder.build(key.tileX(), key.tileZ()), + "River tile builder returned null" + ); + if (tile.tileX() != key.tileX() || tile.tileZ() != key.tileZ()) { + throw new IllegalStateException( + "River tile builder returned " + tile.tileX() + "," + tile.tileZ() + + " for " + key.tileX() + "," + key.tileZ() + ); + } + publishCompleted(key, entry, tile); + } catch (Throwable failure) { + removeFailed(key, entry); + entry.future.completeExceptionally(failure); + } + } + + private void publishCompleted(TileKey key, Entry entry, RiverTile tile) { + synchronized (lock) { + if (closed || entries.get(key) != entry) { + entry.future.completeExceptionally(new IllegalStateException( + closed ? "River tile cache was closed" : "River tile cache entry was cleared" + )); + return; + } + entry.completed = true; + completedEntries.put(key, entry); + while (completedEntries.size() > maxCompletedEntries) { + Map.Entry eldest = completedEntries.entrySet().iterator().next(); + completedEntries.remove(eldest.getKey()); + entries.remove(eldest.getKey(), eldest.getValue()); + } + entry.future.complete(tile); + } + } + + private void removeFailed(TileKey key, Entry entry) { + synchronized (lock) { + entries.remove(key, entry); + completedEntries.remove(key, entry); + } + } + + private List> clearLocked() { + ArrayList> invalidated = new ArrayList<>(entries.size()); + for (Entry entry : entries.values()) { + if (!entry.future.isDone()) { + invalidated.add(entry.future); + } + } + entries.clear(); + completedEntries.clear(); + return invalidated; + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("River tile cache is closed"); + } + } + + private static RiverTile await(CompletableFuture future, TileKey key) { + try { + return future.join(); + } catch (CompletionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException( + "Failed to build river tile " + key.tileX() + "," + key.tileZ(), + cause + ); + } + } + + private static void invalidate(List> futures, String message) { + for (CompletableFuture future : futures) { + future.completeExceptionally(new IllegalStateException(message)); + } + } + + @FunctionalInterface + public interface TileBuilder { + RiverTile build(int tileX, int tileZ) throws Exception; + } + + private record TileKey(int tileX, int tileZ) { + } + + private static final class Entry { + private final CompletableFuture future; + private boolean completed; + + private Entry() { + future = new CompletableFuture<>(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java new file mode 100644 index 000000000..f6f786bf1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java @@ -0,0 +1,148 @@ +package art.arcane.iris.engine.river; + +import java.util.ArrayList; +import java.util.List; + +public final class RiverTopologyComplexity { + public static final long MAXIMUM_SOURCE_WINDOW_CELLS = 65_536L; + public static final long MAXIMUM_ROUTE_SCAN_STEPS = 65_536L; + public static final long MAXIMUM_BUCKET_WRITES_PER_REACH = 1_048_576L; + private static final int SPATIAL_BUCKET_SIZE = 64; + + private RiverTopologyComplexity() { + } + + public static Estimate estimate( + int cellSize, + int tileCells, + double siteJitter, + int maxRouteReaches, + double maximumReachRadius, + double meanderStrength, + int meanderSubdivisions + ) { + double maximumEdgeAxisDelta = cellSize * (1D + siteJitter); + double maximumEdgeLength = StrictMath.sqrt(2D) * maximumEdgeAxisDelta; + double maximumMeander = StrictMath.min(meanderStrength, maximumEdgeLength * 0.35D); + long geometryPaddingCells = 1L + ceilToLong( + (maximumReachRadius + maximumMeander) / cellSize + ); + long targetWindowAxis = saturatedAdd(tileCells, saturatedMultiply(2L, geometryPaddingCells)); + long sourceWindowAxis = saturatedAdd( + targetWindowAxis, + saturatedMultiply(2L, maxRouteReaches) + ); + long sourceWindowCells = saturatedMultiply(sourceWindowAxis, sourceWindowAxis); + long maximumRouteScanSteps = saturatedMultiply(sourceWindowCells, maxRouteReaches); + double maximumSegmentSpan = maximumEdgeAxisDelta + + maximumMeander * 2D + + maximumReachRadius * 2D; + long maximumSegmentBucketAxis = saturatedAdd( + ceilToLong(maximumSegmentSpan / SPATIAL_BUCKET_SIZE), + 1L + ); + long maximumSegmentBucketCount = saturatedMultiply( + maximumSegmentBucketAxis, + maximumSegmentBucketAxis + ); + long maximumBucketWritesPerReach = saturatedMultiply( + maximumSegmentBucketCount, + meanderSubdivisions + ); + return new Estimate( + geometryPaddingCells, + sourceWindowAxis, + sourceWindowCells, + maximumRouteScanSteps, + maximumSegmentBucketAxis, + maximumBucketWritesPerReach + ); + } + + public static void requireSafe( + int cellSize, + int tileCells, + double siteJitter, + int maxRouteReaches, + double maximumReachRadius, + double meanderStrength, + int meanderSubdivisions + ) { + Estimate estimate = estimate( + cellSize, + tileCells, + siteJitter, + maxRouteReaches, + maximumReachRadius, + meanderStrength, + meanderSubdivisions + ); + List violations = estimate.violations(); + if (!violations.isEmpty()) { + throw new IllegalArgumentException(String.join(" ", violations)); + } + } + + private static long ceilToLong(double value) { + if (!Double.isFinite(value) || value >= Long.MAX_VALUE) { + return Long.MAX_VALUE; + } + if (value <= 0D) { + return 0L; + } + return (long) StrictMath.ceil(value); + } + + private static long saturatedAdd(long first, long second) { + if (first > Long.MAX_VALUE - second) { + return Long.MAX_VALUE; + } + return first + second; + } + + private static long saturatedMultiply(long first, long second) { + if (first == 0L || second == 0L) { + return 0L; + } + if (first > Long.MAX_VALUE / second) { + return Long.MAX_VALUE; + } + return first * second; + } + + public record Estimate( + long geometryPaddingCells, + long sourceWindowAxis, + long sourceWindowCells, + long maximumRouteScanSteps, + long maximumSegmentBucketAxis, + long maximumBucketWritesPerReach + ) { + public boolean safe() { + return violations().isEmpty(); + } + + public List violations() { + ArrayList violations = new ArrayList<>(3); + if (sourceWindowCells > MAXIMUM_SOURCE_WINDOW_CELLS) { + violations.add("River topology source window requires " + sourceWindowCells + + " cells (" + sourceWindowAxis + " per axis), above the safe limit of " + + MAXIMUM_SOURCE_WINDOW_CELLS + + "; increase cellSize or reduce tileCells, maxRouteReaches, channel width, or bank width."); + } + if (maximumRouteScanSteps > MAXIMUM_ROUTE_SCAN_STEPS) { + violations.add("River topology route scan permits " + maximumRouteScanSteps + + " source-to-reach steps, above the safe limit of " + MAXIMUM_ROUTE_SCAN_STEPS + + "; reduce maxRouteReaches, tileCells, channel width, or bank width."); + } + if (maximumBucketWritesPerReach > MAXIMUM_BUCKET_WRITES_PER_REACH) { + violations.add("River topology spatial index may require " + maximumBucketWritesPerReach + + " bucket writes for one reach (" + maximumSegmentBucketAxis + + " buckets per segment axis), above the safe limit of " + + MAXIMUM_BUCKET_WRITES_PER_REACH + + "; reduce channel width, bank width, orderWidthFactor, meanderStrength, or meanderSubdivisions."); + } + return List.copyOf(violations); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/CavePosition.java b/core/src/main/java/art/arcane/iris/engine/river/cave/CavePosition.java new file mode 100644 index 000000000..38693c462 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/CavePosition.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.river.cave; + +public record CavePosition(int x, int y, int z) { + public CavePosition offset(int dx, int dy, int dz) { + return new CavePosition(x + dx, y + dy, z + dz); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxel.java b/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxel.java new file mode 100644 index 000000000..6ea8abc31 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxel.java @@ -0,0 +1,9 @@ +package art.arcane.iris.engine.river.cave; + +public enum CaveVoxel { + SOLID, + CAVE_AIR, + COMPATIBLE_FLUID, + LAVA, + INCOMPATIBLE_FLUID +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelPrecondition.java b/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelPrecondition.java new file mode 100644 index 000000000..2bd2ce6a1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelPrecondition.java @@ -0,0 +1,9 @@ +package art.arcane.iris.engine.river.cave; + +import java.util.Objects; + +public record CaveVoxelPrecondition(CaveVoxel voxel, boolean openToSurface) { + public CaveVoxelPrecondition { + Objects.requireNonNull(voxel); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelView.java new file mode 100644 index 000000000..990a05682 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelView.java @@ -0,0 +1,9 @@ +package art.arcane.iris.engine.river.cave; + +public interface CaveVoxelView { + boolean isInWorld(CavePosition position); + + CaveVoxel voxelAt(CavePosition position); + + boolean isOpenToSurface(CavePosition position); +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java new file mode 100644 index 000000000..734997b8e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.river.cave; + +public enum RiverCaveAction { + WET_SOURCE, + FALLING_WATER, + DRY_AIR, + SEAL_GUARD +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java new file mode 100644 index 000000000..7ee1065d9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java @@ -0,0 +1,941 @@ +package art.arcane.iris.engine.river.cave; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.Queue; +import java.util.Set; + +public final class RiverCaveContainmentPlanner { + private static final List DIRECTIONS = List.of( + new CavePosition(1, 0, 0), + new CavePosition(-1, 0, 0), + new CavePosition(0, 1, 0), + new CavePosition(0, -1, 0), + new CavePosition(0, 0, 1), + new CavePosition(0, 0, -1) + ); + private static final Comparator SOURCE_PRIORITY = Comparator + .comparingInt(RiverCaveSource::waterHeadY) + .reversed() + .thenComparingLong(RiverCaveSource::sourceId) + .thenComparingInt(source -> source.entry().x()) + .thenComparingInt(source -> source.entry().y()) + .thenComparingInt(source -> source.entry().z()) + .thenComparingInt(source -> source.target().x()) + .thenComparingInt(source -> source.target().y()) + .thenComparingInt(source -> source.target().z()) + .thenComparing(RiverCaveSource::mode); + + public RiverCavePlan plan( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings + ) { + Objects.requireNonNull(view); + Objects.requireNonNull(source); + Objects.requireNonNull(settings); + + RiverCaveRejection sourceRejection = validateSource(source); + if (sourceRejection != RiverCaveRejection.NONE) { + return rejected(source, sourceRejection); + } + + PathResult throat = buildThroat(view, source, settings); + if (throat.rejection() != RiverCaveRejection.NONE) { + return rejected(source, throat.rejection()); + } + + return switch (source.mode()) { + case CLOSED_COMPONENT -> planClosedComponent(view, source, settings, throat.positions()); + case GENERATED_GROTTO -> planGeneratedGrotto(view, source, settings, throat.positions()); + case GROTTO_OR_CLOSED_COMPONENT -> planGrottoOrClosedComponent( + view, + source, + settings, + throat.positions() + ); + case WATERFALL_POOL -> planWaterfallPool( + view, + source, + settings, + throat.positions() + ); + }; + } + + public RiverCavePlanningResult planAll( + CaveVoxelView view, + Collection sources, + RiverCavePlannerSettings settings + ) { + Objects.requireNonNull(view); + Objects.requireNonNull(sources); + Objects.requireNonNull(settings); + + List orderedSources = new ArrayList<>(sources); + orderedSources.sort(SOURCE_PRIORITY); + List plans = new ArrayList<>(orderedSources.size()); + Map combinedActions = new LinkedHashMap<>(); + Map claimedBy = new HashMap<>(); + Map combinedPreconditions = new LinkedHashMap<>(); + + for (RiverCaveSource source : orderedSources) { + RiverCavePlan candidate = plan(view, source, settings); + if (!candidate.accepted()) { + plans.add(candidate); + continue; + } + OptionalLong winnerSourceId = findWinningSourceId( + candidate.actions().keySet(), + claimedBy + ); + if (winnerSourceId.isPresent()) { + plans.add(rejectedOverlap(source, winnerSourceId.getAsLong())); + } else { + plans.add(candidate); + combinedActions.putAll(candidate.actions()); + combinedPreconditions.putAll(candidate.baselinePreconditions()); + } + for (CavePosition position : candidate.actions().keySet()) { + claimedBy.putIfAbsent(position, source); + } + } + + return new RiverCavePlanningResult(plans, combinedActions, combinedPreconditions); + } + + private RiverCavePlan planGrottoOrClosedComponent( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List throat + ) { + CaveVoxel targetVoxel = voxelAt(view, source.target()); + if (isFluidReachable(targetVoxel, settings)) { + return planClosedComponent(view, source, settings, throat); + } + if (targetVoxel == CaveVoxel.LAVA) { + return rejected(source, RiverCaveRejection.LAVA_CONTACT); + } + if (targetVoxel == CaveVoxel.INCOMPATIBLE_FLUID) { + return rejected(source, RiverCaveRejection.INCOMPATIBLE_FLUID); + } + return planGeneratedGrotto(view, source, settings, throat); + } + + private RiverCavePlan planWaterfallPool( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List throat + ) { + if (!view.isOpenToSurface(source.target())) { + return planGrottoOrClosedComponent(view, source, settings, throat); + } + RiverCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat); + if (dryThroatRejection != RiverCaveRejection.NONE) { + return rejected(source, dryThroatRejection); + } + RiverCaveRejection shaftRejection = validateWaterfallShaft(view, source, settings, throat); + if (shaftRejection != RiverCaveRejection.NONE) { + return rejected(source, shaftRejection); + } + CaveVoxel targetVoxel = voxelAt(view, source.target()); + if (!isFluidReachable(targetVoxel, settings)) { + return rejected(source, rejectionForTarget(targetVoxel, settings)); + } + + Map actions = new HashMap<>(); + addThroatActions(actions, throat, source); + addSealGuards(view, source, actions); + return accepted(view, source, actions); + } + + private RiverCavePlan planClosedComponent( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List throat + ) { + RiverCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat); + if (dryThroatRejection != RiverCaveRejection.NONE) { + return rejected(source, dryThroatRejection); + } + RiverCaveRejection waterfallRejection = validateWaterfallShaft(view, source, settings, throat); + if (waterfallRejection != RiverCaveRejection.NONE) { + return rejected(source, waterfallRejection); + } + + CaveVoxel targetVoxel = voxelAt(view, source.target()); + if (!isFluidReachable(targetVoxel, settings)) { + return rejected(source, rejectionForTarget(targetVoxel, settings)); + } + + ComponentResult component = resolveClosedComponent(view, source, settings, throat); + if (component.rejection() != RiverCaveRejection.NONE) { + return rejected(source, component.rejection()); + } + + Map actions = new HashMap<>(); + addThroatActions(actions, throat, source); + for (CavePosition position : component.positions()) { + actions.put(position, RiverCaveAction.WET_SOURCE); + } + addSealGuards(view, source, actions); + return accepted(view, source, actions); + } + + private RiverCaveRejection validateDryThroatContacts( + CaveVoxelView view, + RiverCaveSource source, + List throat + ) { + Set throatPositions = Set.copyOf(throat); + for (CavePosition position : throat) { + if (position.y() <= source.waterHeadY()) { + continue; + } + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return RiverCaveRejection.WORLD_BOUNDARY; + } + CaveVoxel voxel = voxelAt(view, neighbor); + if (voxel == CaveVoxel.LAVA) { + return RiverCaveRejection.LAVA_CONTACT; + } + if (voxel == CaveVoxel.COMPATIBLE_FLUID) { + return RiverCaveRejection.EXISTING_FLUID; + } + if (voxel == CaveVoxel.INCOMPATIBLE_FLUID) { + return RiverCaveRejection.INCOMPATIBLE_FLUID; + } + } + } + return RiverCaveRejection.NONE; + } + + private RiverCaveRejection validateWaterfallShaft( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List throat + ) { + if (source.mode() != RiverCaveMode.WATERFALL_POOL) { + return RiverCaveRejection.NONE; + } + + Set throatPositions = Set.copyOf(throat); + for (CavePosition position : throat) { + if (position.y() <= source.waterHeadY()) { + continue; + } + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return RiverCaveRejection.WORLD_BOUNDARY; + } + RiverCaveRejection boundsRejection = validateBounds(source, settings, neighbor); + if (boundsRejection != RiverCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, neighbor); + RiverCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != RiverCaveRejection.NONE) { + return hazard; + } + if (voxel != CaveVoxel.SOLID) { + return RiverCaveRejection.WATERFALL_SHAFT_OPEN; + } + } + } + return RiverCaveRejection.NONE; + } + + private RiverCavePlan planGeneratedGrotto( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List throat + ) { + GrottoResult grotto = buildGrotto(source, settings); + if (grotto.rejection() != RiverCaveRejection.NONE) { + return rejected(source, grotto.rejection()); + } + Set chamber = grotto.positions(); + + Set carve = new HashSet<>(chamber.size() + throat.size()); + carve.addAll(chamber); + carve.addAll(throat); + RiverCaveRejection carveRejection = validateGeneratedCarve(view, source, settings, carve); + if (carveRejection != RiverCaveRejection.NONE) { + return rejected(source, carveRejection); + } + + BoundaryResult boundary = validateGeneratedBoundary(view, source, settings, carve); + if (boundary.rejection() != RiverCaveRejection.NONE) { + return rejected(source, boundary.rejection()); + } + + Map actions = new HashMap<>(); + addChamberActions(actions, chamber, source.waterHeadY()); + addThroatActions(actions, throat, source); + for (CavePosition position : boundary.sealGuards()) { + actions.put(position, RiverCaveAction.SEAL_GUARD); + } + return accepted(view, source, actions); + } + + private ComponentResult resolveClosedComponent( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List throat + ) { + Queue queue = new ArrayDeque<>(); + Set queued = new HashSet<>(); + Set resolved = new HashSet<>(); + + queue.add(source.target()); + queued.add(source.target()); + RiverCaveRejection seedRejection = addThroatContacts(view, source, settings, throat, queue, queued); + if (seedRejection != RiverCaveRejection.NONE) { + return ComponentResult.rejected(seedRejection); + } + + while (!queue.isEmpty()) { + CavePosition position = queue.remove(); + RiverCaveRejection positionRejection = validateReachablePosition(view, source, settings, position); + if (positionRejection != RiverCaveRejection.NONE) { + return ComponentResult.rejected(positionRejection); + } + if (!resolved.add(position)) { + continue; + } + if (resolved.size() > settings.maxFloodVolume()) { + return ComponentResult.rejected(RiverCaveRejection.VOLUME_LIMIT); + } + + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + RiverCaveRejection neighborRejection = inspectReachableNeighbor( + view, + source, + settings, + neighbor, + queue, + queued + ); + if (neighborRejection != RiverCaveRejection.NONE) { + return ComponentResult.rejected(neighborRejection); + } + } + } + + return ComponentResult.accepted(resolved); + } + + private RiverCaveRejection addThroatContacts( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List throat, + Queue queue, + Set queued + ) { + for (CavePosition position : throat) { + if (position.y() > source.waterHeadY()) { + continue; + } + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + RiverCaveRejection rejection = inspectReachableNeighbor( + view, + source, + settings, + neighbor, + queue, + queued + ); + if (rejection != RiverCaveRejection.NONE) { + return rejection; + } + } + } + return RiverCaveRejection.NONE; + } + + private RiverCaveRejection inspectReachableNeighbor( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + CavePosition position, + Queue queue, + Set queued + ) { + if (position.y() > source.waterHeadY()) { + return inspectAboveHeadNeighbor(view, source, position); + } + if (!view.isInWorld(position)) { + return RiverCaveRejection.WORLD_BOUNDARY; + } + + CaveVoxel voxel = voxelAt(view, position); + RiverCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != RiverCaveRejection.NONE) { + return hazard; + } + if (!isFluidReachable(voxel, settings)) { + return RiverCaveRejection.NONE; + } + + RiverCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != RiverCaveRejection.NONE) { + return boundsRejection; + } + if (queued.add(position)) { + queue.add(position); + } + return RiverCaveRejection.NONE; + } + + private RiverCaveRejection inspectAboveHeadNeighbor( + CaveVoxelView view, + RiverCaveSource source, + CavePosition position + ) { + if (isInletOpening(source, position) || !view.isInWorld(position)) { + return RiverCaveRejection.NONE; + } + CaveVoxel voxel = voxelAt(view, position); + return switch (voxel) { + case LAVA -> RiverCaveRejection.LAVA_CONTACT; + case COMPATIBLE_FLUID -> RiverCaveRejection.EXISTING_FLUID; + case INCOMPATIBLE_FLUID -> RiverCaveRejection.INCOMPATIBLE_FLUID; + default -> RiverCaveRejection.NONE; + }; + } + + private RiverCaveRejection validateReachablePosition( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + CavePosition position + ) { + if (!view.isInWorld(position)) { + return RiverCaveRejection.WORLD_BOUNDARY; + } + RiverCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != RiverCaveRejection.NONE) { + return boundsRejection; + } + if (view.isOpenToSurface(position)) { + return RiverCaveRejection.OPEN_SURFACE; + } + CaveVoxel voxel = voxelAt(view, position); + RiverCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != RiverCaveRejection.NONE) { + return hazard; + } + return isFluidReachable(voxel, settings) + ? RiverCaveRejection.NONE + : RiverCaveRejection.NO_CAVE_TARGET; + } + + private PathResult buildThroat( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings + ) { + CavePosition entry = source.entry(); + CavePosition target = source.target(); + int deltaX = target.x() - entry.x(); + int deltaY = target.y() - entry.y(); + int deltaZ = target.z() - entry.z(); + int movesX = Math.abs(deltaX); + int movesY = Math.abs(deltaY); + int movesZ = Math.abs(deltaZ); + int length = movesX + movesY + movesZ; + if (length > settings.maxThroatLength()) { + return PathResult.rejected(RiverCaveRejection.THROAT_LIMIT); + } + + int stepX = Integer.signum(deltaX); + int stepY = Integer.signum(deltaY); + int stepZ = Integer.signum(deltaZ); + int usedX = 0; + int usedY = 0; + int usedZ = 0; + CavePosition current = entry; + List positions = new ArrayList<>(length + 1); + + while (true) { + RiverCaveRejection positionRejection = validateThroatPosition(view, source, settings, current); + if (positionRejection != RiverCaveRejection.NONE) { + return PathResult.rejected(positionRejection); + } + positions.add(current); + if (current.equals(target)) { + return expandThroat(view, source, settings, positions); + } + + int axis = selectNextAxis(source.sourceId(), movesX, movesY, movesZ, usedX, usedY, usedZ); + if (axis == 0) { + current = current.offset(stepX, 0, 0); + usedX++; + } else if (axis == 1) { + current = current.offset(0, stepY, 0); + usedY++; + } else { + current = current.offset(0, 0, stepZ); + usedZ++; + } + } + } + + private PathResult expandThroat( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + List centerline + ) { + int radius = settings.throatRadius(); + int extent = radius - 1; + int radiusSquared = radius * radius; + Set expanded = new LinkedHashSet<>(); + for (CavePosition center : centerline) { + for (int dx = -extent; dx <= extent; dx++) { + for (int dy = -extent; dy <= extent; dy++) { + for (int dz = -extent; dz <= extent; dz++) { + if ((dx * dx) + (dy * dy) + (dz * dz) >= radiusSquared) { + continue; + } + CavePosition position = center.offset(dx, dy, dz); + if (position.y() > source.entry().y()) { + continue; + } + RiverCaveRejection rejection = validateThroatPosition(view, source, settings, position); + if (rejection != RiverCaveRejection.NONE) { + return PathResult.rejected(rejection); + } + expanded.add(position); + if (expanded.size() > settings.maxFloodVolume()) { + return PathResult.rejected(RiverCaveRejection.VOLUME_LIMIT); + } + } + } + } + } + return PathResult.accepted(List.copyOf(expanded)); + } + + private int selectNextAxis( + long sourceId, + int movesX, + int movesY, + int movesZ, + int usedX, + int usedY, + int usedZ + ) { + double scoreX = nextAxisScore(movesX, usedX); + double scoreY = nextAxisScore(movesY, usedY); + double scoreZ = nextAxisScore(movesZ, usedZ); + double minimum = Math.min(scoreX, Math.min(scoreY, scoreZ)); + int tieOffset = Math.floorMod(sourceId, 3); + for (int offset = 0; offset < 3; offset++) { + int axis = (tieOffset + offset) % 3; + double score = axis == 0 ? scoreX : axis == 1 ? scoreY : scoreZ; + if (score == minimum) { + return axis; + } + } + throw new IllegalStateException("No remaining throat axis"); + } + + private double nextAxisScore(int moves, int used) { + if (used >= moves) { + return Double.POSITIVE_INFINITY; + } + return ((2D * used) + 1D) / moves; + } + + private RiverCaveRejection validateThroatPosition( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + CavePosition position + ) { + if (!view.isInWorld(position)) { + return RiverCaveRejection.WORLD_BOUNDARY; + } + RiverCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != RiverCaveRejection.NONE) { + return boundsRejection; + } + return rejectionForHazard(voxelAt(view, position), settings); + } + + private GrottoResult buildGrotto(RiverCaveSource source, RiverCavePlannerSettings settings) { + int horizontalRadius = settings.grottoHorizontalRadius(); + int verticalRadius = settings.grottoVerticalRadius(); + Set candidates = new HashSet<>(); + + for (int dx = -horizontalRadius; dx <= horizontalRadius; dx++) { + for (int dy = -verticalRadius; dy <= verticalRadius; dy++) { + for (int dz = -horizontalRadius; dz <= horizontalRadius; dz++) { + if (settings.grottoShape().contains(source, settings, dx, dy, dz)) { + candidates.add(source.target().offset(dx, dy, dz)); + if (candidates.size() > settings.maxFloodVolume()) { + return GrottoResult.rejected(RiverCaveRejection.VOLUME_LIMIT); + } + } + } + } + } + candidates.add(source.target()); + for (int offset = 1; offset <= settings.dryHeadroom(); offset++) { + CavePosition headroom = new CavePosition( + source.target().x(), source.waterHeadY() + offset, source.target().z()); + if (Math.abs(headroom.y() - source.target().y()) > verticalRadius) { + return GrottoResult.rejected(RiverCaveRejection.DRY_HEADROOM_LIMIT); + } + candidates.add(headroom); + if (candidates.size() > settings.maxFloodVolume()) { + return GrottoResult.rejected(RiverCaveRejection.VOLUME_LIMIT); + } + } + return GrottoResult.accepted(connectedGrotto(source.target(), candidates)); + } + + private Set connectedGrotto(CavePosition target, Set candidates) { + Queue queue = new ArrayDeque<>(); + Set connected = new HashSet<>(); + queue.add(target); + connected.add(target); + while (!queue.isEmpty()) { + CavePosition position = queue.remove(); + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (candidates.contains(neighbor) && connected.add(neighbor)) { + queue.add(neighbor); + } + } + } + return connected; + } + + private RiverCaveRejection validateGeneratedCarve( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + Set carve + ) { + for (CavePosition position : carve) { + if (!view.isInWorld(position)) { + return RiverCaveRejection.WORLD_BOUNDARY; + } + RiverCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != RiverCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, position); + RiverCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != RiverCaveRejection.NONE) { + return hazard; + } + if (voxel != CaveVoxel.SOLID) { + return RiverCaveRejection.GROTTO_INTERSECTION; + } + } + return RiverCaveRejection.NONE; + } + + private BoundaryResult validateGeneratedBoundary( + CaveVoxelView view, + RiverCaveSource source, + RiverCavePlannerSettings settings, + Set carve + ) { + Set guards = new HashSet<>(); + for (CavePosition position : carve) { + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (carve.contains(neighbor)) { + continue; + } + if (isInletOpening(source, neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return BoundaryResult.rejected(RiverCaveRejection.WORLD_BOUNDARY); + } + RiverCaveRejection boundsRejection = validateBounds(source, settings, neighbor); + if (boundsRejection != RiverCaveRejection.NONE) { + return BoundaryResult.rejected(boundsRejection); + } + CaveVoxel voxel = voxelAt(view, neighbor); + RiverCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != RiverCaveRejection.NONE) { + return BoundaryResult.rejected(hazard); + } + if (voxel != CaveVoxel.SOLID) { + return BoundaryResult.rejected(RiverCaveRejection.GROTTO_SHELL_OPEN); + } + guards.add(neighbor); + } + } + return BoundaryResult.accepted(guards); + } + + private void addChamberActions( + Map actions, + Collection positions, + int waterHeadY + ) { + for (CavePosition position : positions) { + RiverCaveAction action = position.y() <= waterHeadY + ? RiverCaveAction.WET_SOURCE + : RiverCaveAction.DRY_AIR; + actions.put(position, action); + } + } + + private void addThroatActions( + Map actions, + Collection throat, + RiverCaveSource source + ) { + for (CavePosition position : throat) { + RiverCaveAction action; + if (position.y() <= source.waterHeadY()) { + action = RiverCaveAction.WET_SOURCE; + } else if (source.mode() == RiverCaveMode.WATERFALL_POOL) { + action = RiverCaveAction.FALLING_WATER; + } else { + action = RiverCaveAction.DRY_AIR; + } + actions.put(position, action); + } + } + + private void addSealGuards( + CaveVoxelView view, + RiverCaveSource source, + Map actions + ) { + Set guards = new HashSet<>(); + for (CavePosition position : List.copyOf(actions.keySet())) { + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (actions.containsKey(neighbor) + || isInletOpening(source, neighbor) + || !view.isInWorld(neighbor)) { + continue; + } + if (voxelAt(view, neighbor) == CaveVoxel.SOLID) { + guards.add(neighbor); + } + } + } + for (CavePosition guard : guards) { + actions.put(guard, RiverCaveAction.SEAL_GUARD); + } + } + + private boolean isInletOpening(RiverCaveSource source, CavePosition position) { + return position.equals(source.entry().offset(0, 1, 0)); + } + + private RiverCaveRejection validateSource(RiverCaveSource source) { + if (source.entry().y() < source.waterHeadY()) { + return RiverCaveRejection.INVALID_SOURCE; + } + if (source.target().y() > source.waterHeadY()) { + return RiverCaveRejection.INVALID_SOURCE; + } + if (source.target().y() > source.entry().y()) { + return RiverCaveRejection.INVALID_SOURCE; + } + return RiverCaveRejection.NONE; + } + + private RiverCaveRejection validateBounds( + RiverCaveSource source, + RiverCavePlannerSettings settings, + CavePosition position + ) { + boolean closedComponent = source.mode() == RiverCaveMode.CLOSED_COMPONENT + || source.mode() == RiverCaveMode.WATERFALL_POOL; + int horizontalRadius = closedComponent + ? settings.maxClosedComponentHorizontalRadius() + : settings.maxHorizontalRadius(); + int maximumDepth = closedComponent + ? settings.maxClosedComponentDepth() + : settings.maxDepth(); + long deltaX = (long) position.x() - source.entry().x(); + long deltaZ = (long) position.z() - source.entry().z(); + long radiusSquared = (long) horizontalRadius * horizontalRadius; + if ((deltaX * deltaX) + (deltaZ * deltaZ) > radiusSquared) { + return RiverCaveRejection.RADIUS_LIMIT; + } + long depth = (long) source.entry().y() - position.y(); + int maximumGeneratedY = source.waterHeadY() + settings.dryHeadroom() + 1; + boolean allowedGeneratedHeadroom = !closedComponent + && position.y() <= maximumGeneratedY; + if ((depth < 0L && !allowedGeneratedHeadroom) || depth > maximumDepth) { + return RiverCaveRejection.DEPTH_LIMIT; + } + return RiverCaveRejection.NONE; + } + + private RiverCaveRejection rejectionForTarget( + CaveVoxel voxel, + RiverCavePlannerSettings settings + ) { + RiverCaveRejection hazard = rejectionForHazard(voxel, settings); + return hazard == RiverCaveRejection.NONE ? RiverCaveRejection.NO_CAVE_TARGET : hazard; + } + + private RiverCaveRejection rejectionForHazard( + CaveVoxel voxel, + RiverCavePlannerSettings settings + ) { + return switch (voxel) { + case LAVA -> RiverCaveRejection.LAVA_CONTACT; + case INCOMPATIBLE_FLUID -> settings.existingFluidPolicy() == RiverCaveFluidPolicy.REPLACE_CONTAINED + ? RiverCaveRejection.NONE + : RiverCaveRejection.INCOMPATIBLE_FLUID; + case COMPATIBLE_FLUID -> settings.existingFluidPolicy() == RiverCaveFluidPolicy.REJECT_EXISTING + ? RiverCaveRejection.EXISTING_FLUID + : RiverCaveRejection.NONE; + default -> RiverCaveRejection.NONE; + }; + } + + private boolean isFluidReachable(CaveVoxel voxel, RiverCavePlannerSettings settings) { + return voxel == CaveVoxel.CAVE_AIR + || (voxel == CaveVoxel.COMPATIBLE_FLUID + && settings.existingFluidPolicy() != RiverCaveFluidPolicy.REJECT_EXISTING) + || (voxel == CaveVoxel.INCOMPATIBLE_FLUID + && settings.existingFluidPolicy() == RiverCaveFluidPolicy.REPLACE_CONTAINED); + } + + private CaveVoxel voxelAt(CaveVoxelView view, CavePosition position) { + return Objects.requireNonNull(view.voxelAt(position)); + } + + private OptionalLong findWinningSourceId( + Set positions, + Map claimedBy + ) { + RiverCaveSource winner = null; + for (CavePosition position : positions) { + RiverCaveSource contender = claimedBy.get(position); + if (contender == null) { + continue; + } + if (winner == null || SOURCE_PRIORITY.compare(contender, winner) < 0) { + winner = contender; + } + } + return winner == null ? OptionalLong.empty() : OptionalLong.of(winner.sourceId()); + } + + private RiverCavePlan accepted( + CaveVoxelView view, + RiverCaveSource source, + Map actions + ) { + Map preconditions = new HashMap<>(actions.size()); + for (CavePosition position : actions.keySet()) { + preconditions.put( + position, + new CaveVoxelPrecondition(voxelAt(view, position), view.isOpenToSurface(position)) + ); + } + return new RiverCavePlan( + source, + RiverCaveRejection.NONE, + actions, + preconditions, + OptionalLong.empty() + ); + } + + private RiverCavePlan rejected(RiverCaveSource source, RiverCaveRejection rejection) { + return new RiverCavePlan( + source, + rejection, + Map.of(), + Map.of(), + OptionalLong.empty() + ); + } + + private RiverCavePlan rejectedOverlap(RiverCaveSource source, long winnerSourceId) { + return new RiverCavePlan( + source, + RiverCaveRejection.OVERLAPPING_SOURCE, + Map.of(), + Map.of(), + OptionalLong.of(winnerSourceId) + ); + } + + private record PathResult(List positions, RiverCaveRejection rejection) { + private static PathResult accepted(List positions) { + return new PathResult(List.copyOf(positions), RiverCaveRejection.NONE); + } + + private static PathResult rejected(RiverCaveRejection rejection) { + return new PathResult(List.of(), rejection); + } + } + + private record ComponentResult(Set positions, RiverCaveRejection rejection) { + private static ComponentResult accepted(Set positions) { + return new ComponentResult(Set.copyOf(positions), RiverCaveRejection.NONE); + } + + private static ComponentResult rejected(RiverCaveRejection rejection) { + return new ComponentResult(Set.of(), rejection); + } + } + + private record BoundaryResult(Set sealGuards, RiverCaveRejection rejection) { + private static BoundaryResult accepted(Set sealGuards) { + return new BoundaryResult(Set.copyOf(sealGuards), RiverCaveRejection.NONE); + } + + private static BoundaryResult rejected(RiverCaveRejection rejection) { + return new BoundaryResult(Set.of(), rejection); + } + } + + private record GrottoResult(Set positions, RiverCaveRejection rejection) { + private static GrottoResult accepted(Set positions) { + return new GrottoResult(Set.copyOf(positions), RiverCaveRejection.NONE); + } + + private static GrottoResult rejected(RiverCaveRejection rejection) { + return new GrottoResult(Set.of(), rejection); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidPolicy.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidPolicy.java new file mode 100644 index 000000000..c84e9c3b7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidPolicy.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.river.cave; + +public enum RiverCaveFluidPolicy { + REJECT_EXISTING, + ALLOW_COMPATIBLE, + REPLACE_CONTAINED +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveGrottoShape.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveGrottoShape.java new file mode 100644 index 000000000..5d57d209b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveGrottoShape.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.river.cave; + +@FunctionalInterface +public interface RiverCaveGrottoShape { + RiverCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> { + double horizontalRadius = settings.grottoHorizontalRadius(); + double verticalRadius = settings.grottoVerticalRadius(); + double normalized = ((double) dx * dx / (horizontalRadius * horizontalRadius)) + + ((double) dy * dy / (verticalRadius * verticalRadius)) + + ((double) dz * dz / (horizontalRadius * horizontalRadius)); + return normalized <= 1D; + }; + + boolean contains( + RiverCaveSource source, + RiverCavePlannerSettings settings, + int offsetX, + int offsetY, + int offsetZ + ); +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java new file mode 100644 index 000000000..4e8ff8d38 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java @@ -0,0 +1,82 @@ +package art.arcane.iris.engine.river.cave; + +import art.arcane.volmlib.util.matter.MatterCavern; + +import java.util.Objects; +import java.util.Optional; + +public final class RiverCaveHydrology { + private static final byte LIQUID_FLUID = 1; + private static final byte LIQUID_FORCED_AIR = 3; + + private final RiverCaveAction action; + private final String floodedBiomeKey; + private final MatterCavern cavern; + + public RiverCaveHydrology(RiverCaveAction action, String floodedBiomeKey) { + this.action = Objects.requireNonNull(action); + this.floodedBiomeKey = floodedBiomeKey == null ? "" : floodedBiomeKey.trim(); + this.cavern = switch (action) { + case WET_SOURCE, FALLING_WATER -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FLUID); + case DRY_AIR -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FORCED_AIR); + case SEAL_GUARD -> null; + }; + } + + public static RiverCaveHydrology of(RiverCaveAction action) { + return new RiverCaveHydrology(action, ""); + } + + public Optional floodedBiome() { + return floodedBiomeKey.isEmpty() ? Optional.empty() : Optional.of(floodedBiomeKey); + } + + public boolean carves() { + return action != RiverCaveAction.SEAL_GUARD; + } + + public boolean isWet() { + return action == RiverCaveAction.WET_SOURCE || action == RiverCaveAction.FALLING_WATER; + } + + public boolean isFalling() { + return action == RiverCaveAction.FALLING_WATER; + } + + public boolean protectsPlacement() { + return action == RiverCaveAction.SEAL_GUARD || isWet(); + } + + public MatterCavern asCavern() { + return cavern; + } + + public RiverCaveAction action() { + return action; + } + + public String floodedBiomeKey() { + return floodedBiomeKey; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof RiverCaveHydrology hydrology)) { + return false; + } + return action == hydrology.action && floodedBiomeKey.equals(hydrology.floodedBiomeKey); + } + + @Override + public int hashCode() { + return (31 * action.hashCode()) + floodedBiomeKey.hashCode(); + } + + @Override + public String toString() { + return "RiverCaveHydrology[action=" + action + ", floodedBiomeKey=" + floodedBiomeKey + "]"; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorage.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorage.java new file mode 100644 index 000000000..3adfaf956 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorage.java @@ -0,0 +1,41 @@ +package art.arcane.iris.engine.river.cave; + +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; +import art.arcane.volmlib.util.matter.Matter; + +public final class RiverCaveHydrologyStorage { + private RiverCaveHydrologyStorage() { + } + + public static RiverCaveHydrology getIfPresent(Mantle mantle, int x, int y, int z) { + if (y < 0 || y >= mantle.getWorldHeight()) { + return null; + } + int chunkX = x >> 4; + int chunkZ = z >> 4; + TectonicPlate plate = mantle.getLoadedRegions().get(Mantle.key(chunkX >> 5, chunkZ >> 5)); + if (plate == null || plate.isClosed()) { + return null; + } + MantleChunk chunk = plate.get(chunkX & 31, chunkZ & 31); + return getIfPresent(chunk, x, y, z); + } + + public static RiverCaveHydrology getIfPresent(MantleChunk chunk, int x, int y, int z) { + if (chunk == null || y < 0) { + return null; + } + int section = y >> 4; + if (!chunk.exists(section)) { + return null; + } + Matter matter = chunk.get(section); + if (matter == null || !matter.hasSlice(RiverCaveHydrology.class)) { + return null; + } + return matter.getSlice(RiverCaveHydrology.class) + .get(x & 15, y & 15, z & 15); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java new file mode 100644 index 000000000..b91243732 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.river.cave; + +public enum RiverCaveMode { + CLOSED_COMPONENT, + GENERATED_GROTTO, + GROTTO_OR_CLOSED_COMPONENT, + WATERFALL_POOL +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlan.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlan.java new file mode 100644 index 000000000..3c38fd0c1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlan.java @@ -0,0 +1,31 @@ +package art.arcane.iris.engine.river.cave; + +import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; + +public record RiverCavePlan( + RiverCaveSource source, + RiverCaveRejection rejection, + Map actions, + Map baselinePreconditions, + OptionalLong arbitrationWinnerSourceId +) { + public RiverCavePlan { + Objects.requireNonNull(source); + Objects.requireNonNull(rejection); + actions = Map.copyOf(Objects.requireNonNull(actions)); + baselinePreconditions = Map.copyOf(Objects.requireNonNull(baselinePreconditions)); + Objects.requireNonNull(arbitrationWinnerSourceId); + if (rejection != RiverCaveRejection.NONE && (!actions.isEmpty() || !baselinePreconditions.isEmpty())) { + throw new IllegalArgumentException("Rejected cave plans cannot contain mutations or preconditions"); + } + if (rejection != RiverCaveRejection.OVERLAPPING_SOURCE && arbitrationWinnerSourceId.isPresent()) { + throw new IllegalArgumentException("Only overlap rejections can name an arbitration winner"); + } + } + + public boolean accepted() { + return rejection == RiverCaveRejection.NONE; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlannerSettings.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlannerSettings.java new file mode 100644 index 000000000..a7aab41e5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlannerSettings.java @@ -0,0 +1,104 @@ +package art.arcane.iris.engine.river.cave; + +import java.util.Objects; + +public record RiverCavePlannerSettings( + int maxHorizontalRadius, + int maxDepth, + int maxFloodVolume, + int maxThroatLength, + int throatRadius, + int grottoHorizontalRadius, + int grottoVerticalRadius, + int dryHeadroom, + RiverCaveFluidPolicy existingFluidPolicy, + RiverCaveGrottoShape grottoShape, + int maxClosedComponentHorizontalRadius, + int maxClosedComponentDepth +) { + public RiverCavePlannerSettings( + int maxHorizontalRadius, + int maxDepth, + int maxFloodVolume, + int maxThroatLength, + int throatRadius, + int grottoHorizontalRadius, + int grottoVerticalRadius, + int dryHeadroom, + RiverCaveFluidPolicy existingFluidPolicy, + RiverCaveGrottoShape grottoShape + ) { + this( + maxHorizontalRadius, + maxDepth, + maxFloodVolume, + maxThroatLength, + throatRadius, + grottoHorizontalRadius, + grottoVerticalRadius, + dryHeadroom, + existingFluidPolicy, + grottoShape, + maxHorizontalRadius, + maxDepth + ); + } + + public RiverCavePlannerSettings( + int maxHorizontalRadius, + int maxDepth, + int maxFloodVolume, + int maxThroatLength, + int grottoHorizontalRadius, + int grottoVerticalRadius, + RiverCaveFluidPolicy existingFluidPolicy + ) { + this( + maxHorizontalRadius, + maxDepth, + maxFloodVolume, + maxThroatLength, + 1, + grottoHorizontalRadius, + grottoVerticalRadius, + 0, + existingFluidPolicy, + RiverCaveGrottoShape.ELLIPSOID + ); + } + + public RiverCavePlannerSettings { + Objects.requireNonNull(existingFluidPolicy); + Objects.requireNonNull(grottoShape); + if (maxHorizontalRadius < 1) { + throw new IllegalArgumentException("maxHorizontalRadius must be positive"); + } + if (maxDepth < 1) { + throw new IllegalArgumentException("maxDepth must be positive"); + } + if (maxFloodVolume < 1) { + throw new IllegalArgumentException("maxFloodVolume must be positive"); + } + if (maxThroatLength < 1) { + throw new IllegalArgumentException("maxThroatLength must be positive"); + } + if (throatRadius < 1) { + throw new IllegalArgumentException("throatRadius must be positive"); + } + if (grottoHorizontalRadius < 1) { + throw new IllegalArgumentException("grottoHorizontalRadius must be positive"); + } + if (grottoVerticalRadius < 1) { + throw new IllegalArgumentException("grottoVerticalRadius must be positive"); + } + if (dryHeadroom < 0) { + throw new IllegalArgumentException("dryHeadroom cannot be negative"); + } + if (maxClosedComponentHorizontalRadius < 1) { + throw new IllegalArgumentException("maxClosedComponentHorizontalRadius must be positive"); + } + if (maxClosedComponentDepth < 1) { + throw new IllegalArgumentException("maxClosedComponentDepth must be positive"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlanningResult.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlanningResult.java new file mode 100644 index 000000000..a1cd46868 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlanningResult.java @@ -0,0 +1,17 @@ +package art.arcane.iris.engine.river.cave; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record RiverCavePlanningResult( + List plans, + Map actions, + Map baselinePreconditions +) { + public RiverCavePlanningResult { + plans = List.copyOf(Objects.requireNonNull(plans)); + actions = Map.copyOf(Objects.requireNonNull(actions)); + baselinePreconditions = Map.copyOf(Objects.requireNonNull(baselinePreconditions)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveRejection.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveRejection.java new file mode 100644 index 000000000..5ae85fda6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveRejection.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.river.cave; + +public enum RiverCaveRejection { + NONE, + INVALID_SOURCE, + NO_CAVE_TARGET, + THROAT_LIMIT, + RADIUS_LIMIT, + DEPTH_LIMIT, + VOLUME_LIMIT, + WORLD_BOUNDARY, + OPEN_SURFACE, + LAVA_CONTACT, + EXISTING_FLUID, + INCOMPATIBLE_FLUID, + GROTTO_INTERSECTION, + GROTTO_SHELL_OPEN, + DRY_HEADROOM_LIMIT, + WATERFALL_SHAFT_OPEN, + OVERLAPPING_SOURCE +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveSource.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveSource.java new file mode 100644 index 000000000..592d96cf2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveSource.java @@ -0,0 +1,17 @@ +package art.arcane.iris.engine.river.cave; + +import java.util.Objects; + +public record RiverCaveSource( + long sourceId, + CavePosition entry, + CavePosition target, + int waterHeadY, + RiverCaveMode mode +) { + public RiverCaveSource { + Objects.requireNonNull(entry); + Objects.requireNonNull(target); + Objects.requireNonNull(mode); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettings.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettings.java new file mode 100644 index 000000000..acf613ff1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettings.java @@ -0,0 +1,161 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverBiomes; +import art.arcane.iris.engine.object.IrisRiverNetwork; +import art.arcane.iris.engine.object.IrisRiverOverride; +import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; +import art.arcane.iris.engine.object.IrisRiverTerminalMode; +import art.arcane.volmlib.util.collection.KList; + +import java.util.List; +import java.util.Objects; + +public record EffectiveRiverSettings( + boolean allowSources, + IrisRiverRoutingPolicy routingPolicy, + double routingCostMultiplier, + double widthMultiplier, + double bankWidthMultiplier, + double depthMultiplier, + double maxIncisionMultiplier, + double continuationChanceMultiplier, + double caveEntryMultiplier, + IrisRiverTerminalMode terminalMode, + boolean terminalModeOverridden, + List channelBiomes, + List bankBiomes, + List mouthBiomes, + List dryBiomes, + List floodedCaveBiomes +) { + public EffectiveRiverSettings { + Objects.requireNonNull(routingPolicy); + Objects.requireNonNull(terminalMode); + channelBiomes = List.copyOf(Objects.requireNonNull(channelBiomes)); + bankBiomes = List.copyOf(Objects.requireNonNull(bankBiomes)); + mouthBiomes = List.copyOf(Objects.requireNonNull(mouthBiomes)); + dryBiomes = List.copyOf(Objects.requireNonNull(dryBiomes)); + floodedCaveBiomes = List.copyOf(Objects.requireNonNull(floodedCaveBiomes)); + } + + public static EffectiveRiverSettings resolve( + IrisRiverNetwork network, + IrisRegion region, + IrisBiome naturalBiome + ) { + Objects.requireNonNull(network); + IrisRiverBiomes biomes = network.getBiomes() == null ? new IrisRiverBiomes() : network.getBiomes(); + Builder builder = new Builder( + network.getTerrain().getTerminalMode(), + copy(biomes.getChannel()), + copy(biomes.getBank()), + copy(biomes.getMouth()), + copy(biomes.getDry()), + copy(biomes.getFloodedCave()) + ); + if (region != null) { + builder.apply(region.getRiverOverride()); + } + if (naturalBiome != null) { + builder.apply(naturalBiome.getRiverOverride()); + } + return builder.build(); + } + + private static List copy(KList values) { + return values == null ? List.of() : List.copyOf(values); + } + + private static final class Builder { + private boolean allowSources = true; + private IrisRiverRoutingPolicy routingPolicy = IrisRiverRoutingPolicy.ALLOW; + private double routingCostMultiplier = 1D; + private double widthMultiplier = 1D; + private double bankWidthMultiplier = 1D; + private double depthMultiplier = 1D; + private double maxIncisionMultiplier = 1D; + private double continuationChanceMultiplier = 1D; + private double caveEntryMultiplier = 1D; + private IrisRiverTerminalMode terminalMode; + private boolean terminalModeOverridden; + private List channelBiomes; + private List bankBiomes; + private List mouthBiomes; + private List dryBiomes; + private List floodedCaveBiomes; + + private Builder( + IrisRiverTerminalMode terminalMode, + List channelBiomes, + List bankBiomes, + List mouthBiomes, + List dryBiomes, + List floodedCaveBiomes + ) { + this.terminalMode = terminalMode == null ? IrisRiverTerminalMode.DRY_CHANNEL : terminalMode; + this.channelBiomes = channelBiomes; + this.bankBiomes = bankBiomes; + this.mouthBiomes = mouthBiomes; + this.dryBiomes = dryBiomes; + this.floodedCaveBiomes = floodedCaveBiomes; + } + + private void apply(IrisRiverOverride override) { + if (override == null) { + return; + } + allowSources = override.getAllowSources() == null ? allowSources : override.getAllowSources(); + routingPolicy = override.getRoutingPolicy() == null ? routingPolicy : override.getRoutingPolicy(); + routingCostMultiplier = value(override.getRoutingCostMultiplier(), routingCostMultiplier); + widthMultiplier = value(override.getWidthMultiplier(), widthMultiplier); + bankWidthMultiplier = value(override.getBankWidthMultiplier(), bankWidthMultiplier); + depthMultiplier = value(override.getDepthMultiplier(), depthMultiplier); + maxIncisionMultiplier = value(override.getMaxIncisionMultiplier(), maxIncisionMultiplier); + continuationChanceMultiplier = value( + override.getContinuationChanceMultiplier(), + continuationChanceMultiplier + ); + caveEntryMultiplier = value(override.getCaveEntryMultiplier(), caveEntryMultiplier); + if (override.getTerminalMode() != null) { + terminalMode = override.getTerminalMode(); + terminalModeOverridden = true; + } + channelBiomes = replace(override.getChannelBiomes(), channelBiomes); + bankBiomes = replace(override.getBankBiomes(), bankBiomes); + mouthBiomes = replace(override.getMouthBiomes(), mouthBiomes); + dryBiomes = replace(override.getDryBiomes(), dryBiomes); + floodedCaveBiomes = replace(override.getFloodedCaveBiomes(), floodedCaveBiomes); + } + + private EffectiveRiverSettings build() { + return new EffectiveRiverSettings( + allowSources, + routingPolicy, + routingCostMultiplier, + widthMultiplier, + bankWidthMultiplier, + depthMultiplier, + maxIncisionMultiplier, + continuationChanceMultiplier, + caveEntryMultiplier, + terminalMode, + terminalModeOverridden, + channelBiomes, + bankBiomes, + mouthBiomes, + dryBiomes, + floodedCaveBiomes + ); + } + + private static double value(Double configured, double inherited) { + return configured == null || !Double.isFinite(configured) ? inherited : Math.max(0D, configured); + } + + private static List replace(KList configured, List inherited) { + return configured == null ? inherited : List.copyOf(configured); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java new file mode 100644 index 000000000..d72f591d9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java @@ -0,0 +1,1162 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IRare; +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverNetwork; +import art.arcane.iris.engine.object.IrisRiverCaveMode; +import art.arcane.iris.engine.object.IrisRiverCaves; +import art.arcane.iris.engine.object.IrisRiverNoiseChance; +import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; +import art.arcane.iris.engine.object.IrisRiverTerminalMode; +import art.arcane.iris.engine.object.IrisRiverTerrain; +import art.arcane.iris.engine.object.IrisRiverTopology; +import art.arcane.iris.engine.object.IrisRiverWater; +import art.arcane.iris.engine.object.IrisRiverWaterMode; +import art.arcane.iris.engine.object.IrisStyledRange; +import art.arcane.iris.engine.object.NoiseStyle; +import art.arcane.iris.engine.river.RiverAnchor; +import art.arcane.iris.engine.river.RiverEdgeId; +import art.arcane.iris.engine.river.RiverMeanderContext; +import art.arcane.iris.engine.river.RiverNetworkOptions; +import art.arcane.iris.engine.river.RiverPolyline; +import art.arcane.iris.engine.river.RiverReach; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverRoutingContext; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.RiverTerrainNodeSample; +import art.arcane.iris.engine.river.RiverTerrainSourceSample; +import art.arcane.iris.engine.river.RiverTerrainSampler; +import art.arcane.iris.engine.river.RiverTerminalPolicy; +import art.arcane.iris.engine.river.RiverTile; +import art.arcane.iris.engine.river.RiverTileCache; +import art.arcane.iris.util.project.interpolation.NoiseBounds; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public final class IrisRiverRuntime implements AutoCloseable { + static final int MAXIMUM_REACH_FEASIBILITY_SAMPLES = 65; + static final int FEASIBILITY_REJECT = 0; + static final int FEASIBILITY_ACCEPT = 1; + static final int FEASIBILITY_SAMPLE_BED = 2; + private static final long SOURCE_NOISE_SALT = 0x243F6A8885A308D3L; + private static final long CONTINUATION_NOISE_SALT = 0x13198A2E03707344L; + private static final long INCISION_NOISE_SALT = 0xA4093822299F31D0L; + private static final long INCISION_GATE_SALT = 0x082EFA98EC4E6C89L; + private static final long ROUTING_NOISE_SALT = 0x452821E638D01377L; + private static final long WIDTH_NOISE_SALT = 0xBE5466CF34E90C6CL; + private static final long BANK_NOISE_SALT = 0xC0AC29B7C97C50DDL; + private static final long DEPTH_NOISE_SALT = 0x3F84D5B5B5470917L; + private static final long MEANDER_NOISE_SALT = 0x9216D5D98979FB1BL; + private static final long BED_NOISE_SALT = 0xD1310BA698DFB5ACL; + private static final long BIOME_NOISE_SALT = 0x2FFD72DBD01ADFB7L; + private static final long CAVE_ENTRY_NOISE_SALT = 0xB8E1AFED6A267E96L; + private static final long CAVE_ENTRY_GATE_SALT = 0xBA7C9045F12C7F99L; + private static final long FLOODED_CAVE_BIOME_SALT = 0x24A19947B3916CF7L; + private static final long TERMINAL_CAVE_ANCHOR_SALT = 0x9E3779B97F4A7C15L; + private static final int TILE_CACHE_SIZE = 32; + + private final long seed; + private final IrisRiverNetwork configuration; + private final IrisData data; + private final int fluidHeight; + private final boolean boreMantleActive; + private final boolean caveHydrologyActive; + private final boolean blockingRoutingPossible; + private final boolean variableMaxIncisionPossible; + private final boolean biomeRiverOverridesPossible; + private final RiverHeightBoundsSampler naturalHeightBounds; + private final ProceduralStream naturalHeight; + private final ProceduralStream naturalSlope; + private final ProceduralStream naturalOcean; + private final ProceduralStream naturalBiome; + private final ProceduralStream region; + private final IrisRiverTerrain terrain; + private final IrisRiverWater water; + private final CNG sourceNoise; + private final CNG continuationNoise; + private final CNG incisionNoise; + private final CNG routingNoise; + private final CNG widthNoise; + private final CNG bankNoise; + private final CNG depthNoise; + private final CNG meanderNoise; + private final CNG bedNoise; + private final CNG biomeNoise; + private final CNG caveEntryNoise; + private final art.arcane.iris.engine.river.RiverNetwork network; + private final RuntimeTerrainSampler terrainSampler; + private final RiverTileCache tileCache; + private final ConcurrentHashMap settingsCache; + private final ConcurrentHashMap> biomePoolCache; + + public IrisRiverRuntime(IrisRiverRuntimeContext context) { + Objects.requireNonNull(context); + seed = context.seed(); + configuration = context.configuration(); + data = context.data(); + fluidHeight = context.fluidHeight(); + boreMantleActive = context.boreMantleActive(); + caveHydrologyActive = context.caveHydrologyActive(); + blockingRoutingPossible = context.blockingRoutingPossible(); + variableMaxIncisionPossible = context.variableMaxIncisionPossible(); + biomeRiverOverridesPossible = context.biomeRiverOverridesPossible(); + naturalHeightBounds = context.naturalHeightBounds(); + naturalHeight = context.naturalHeight(); + naturalSlope = context.naturalSlope(); + naturalOcean = context.naturalOcean(); + naturalBiome = context.naturalBiome(); + region = context.region(); + terrain = Objects.requireNonNull(configuration.getTerrain()); + water = Objects.requireNonNull(configuration.getWater()); + IrisRiverTopology topology = Objects.requireNonNull(configuration.getTopology()); + sourceNoise = noise(topology.getSource(), SOURCE_NOISE_SALT); + continuationNoise = noise(topology.getContinuation(), CONTINUATION_NOISE_SALT); + incisionNoise = noise(terrain.getIncision(), INCISION_NOISE_SALT); + routingNoise = noise(topology.getRoutingStyle(), ROUTING_NOISE_SALT); + widthNoise = noise(terrain.getChannelWidth(), WIDTH_NOISE_SALT); + bankNoise = noise(terrain.getBankWidth(), BANK_NOISE_SALT); + depthNoise = noise(terrain.getDepth(), DEPTH_NOISE_SALT); + meanderNoise = noise(terrain.getMeanderStyle(), MEANDER_NOISE_SALT); + bedNoise = noise(terrain.getBedRoughnessStyle(), BED_NOISE_SALT); + biomeNoise = noise(configuration.getBiomes().getSelectionStyle(), BIOME_NOISE_SALT); + caveEntryNoise = noise(caveSettings().getEntry(), CAVE_ENTRY_NOISE_SALT); + settingsCache = new ConcurrentHashMap<>(); + biomePoolCache = new ConcurrentHashMap<>(); + RiverNetworkOptions options = options(topology, terrain); + network = new art.arcane.iris.engine.river.RiverNetwork(options); + terrainSampler = new RuntimeTerrainSampler(topology); + tileCache = new RiverTileCache( + TILE_CACHE_SIZE, + (tileX, tileZ) -> network.buildTile(tileX, tileZ, terrainSampler) + ); + } + + public IrisRiverSurfaceSample sample(double x, double z) { + ResolvedRiverColumn column = resolveColumn(x, z); + if (column == null) { + return IrisRiverSurfaceSample.none(naturalHeight.get(x, z), fluidHeight); + } + if (column.subterranean()) { + return new IrisRiverSurfaceSample( + column.river(), + column.naturalHeight(), + column.naturalHeight(), + column.waterSurfaceY(), + true, + false + ); + } + double carveWeight = StrictMath.pow( + clamp01(column.river().carveWeight()), + Math.max(0.125D, terrain.getBankExponent()) + ); + if (column.river().terminal() && shouldTaperTerminal(column.reach())) { + carveWeight *= terminalWeight( + terrain.getTerminalTaper(), + column.reach().polyline().length(), + column.river().alongReach() + ); + } + double terrainHeight = incisedHeight( + column.naturalHeight(), + column.bedHeight(), + carveWeight, + column.maximumIncision() + ); + boolean wet = column.river().state() == RiverRouteState.WET; + boolean surfaceFluid = wet && Math.round(terrainHeight) < Math.round(column.waterSurfaceY()); + return new IrisRiverSurfaceSample( + column.river(), + column.naturalHeight(), + terrainHeight, + wet ? column.waterSurfaceY() : terrainHeight, + false, + surfaceFluid + ); + } + + public IrisRiverTunnelSample sampleTunnel(double x, double z) { + ResolvedRiverColumn column = resolveColumn(x, z); + if (column == null + || !column.subterranean() + || column.river().state() != RiverRouteState.WET + || column.river().section() != RiverSection.CHANNEL) { + return null; + } + double channelRadius = column.river().width() * 0.5D; + double normalizedDistance = channelRadius <= 0D + ? 1D + : clamp01(column.river().distance() / channelRadius); + double roofProfile = StrictMath.sqrt(Math.max(0D, 1D - normalizedDistance * normalizedDistance)); + int bedY = (int) Math.round(column.bedHeight()); + int waterHeadY = (int) Math.round(column.waterSurfaceY()); + int ceilingY = waterHeadY + (int) StrictMath.ceil(caveSettings().getDryHeadroom() * roofProfile); + if (bedY >= waterHeadY) { + return null; + } + return new IrisRiverTunnelSample(column.river(), bedY, waterHeadY, ceilingY); + } + + private ResolvedRiverColumn resolveColumn(double x, double z) { + double sampledNaturalHeight = naturalHeight.get(x, z); + RiverTile tile = tileAt(x, z); + RiverSample river = tile.sample(x, z); + if (!river.present()) { + return null; + } + RiverReach reach = tile.reach(river.reachId()); + IrisRegion sampledRegion = region.get(x, z); + IrisBiome sampledBiome = naturalBiome.get(x, z); + EffectiveRiverSettings settings = settingsFor(sampledRegion, sampledBiome); + double waterSurfaceY = river.state() == RiverRouteState.WET + ? waterSurface(reach, river.alongReach(), isNaturalOcean(sampledBiome)) + : sampledNaturalHeight; + double bedHeight = river.state() == RiverRouteState.WET + ? waterSurfaceY - river.depth() + bedRoughness(x, z) + : sampledNaturalHeight - river.depth() + bedRoughness(x, z); + double maximumIncision = Math.max(0D, terrain.getMaxIncision() * settings.maxIncisionMultiplier()); + return new ResolvedRiverColumn( + river, + reach, + sampledNaturalHeight, + waterSurfaceY, + bedHeight, + maximumIncision, + isSubterraneanSegment(reach, river.alongReach()) + ); + } + + private boolean isSubterraneanSegment(RiverReach reach, double alongReach) { + if (!boreMantleActive || reach.state() != RiverRouteState.WET) { + return false; + } + CenterlinePosition center = centerlinePosition(reach, alongReach); + IrisRegion sampledRegion = region.get(center.x(), center.z()); + IrisBiome sampledBiome = naturalBiome.get(center.x(), center.z()); + double head = waterSurface(reach, alongReach, isNaturalOcean(sampledBiome)); + double centerNaturalHeight = naturalHeight.get(center.x(), center.z()); + double centerBedHeight = head - reach.depth() + bedRoughness(center.x(), center.z()); + EffectiveRiverSettings settings = settingsFor(sampledRegion, sampledBiome); + double maximumIncision = Math.max(0D, terrain.getMaxIncision() * settings.maxIncisionMultiplier()); + double cappedSurface = incisedHeight(centerNaturalHeight, centerBedHeight, 1D, maximumIncision); + return Math.round(cappedSurface) >= Math.round(head); + } + + private static CenterlinePosition centerlinePosition(RiverReach reach, double alongReach) { + double targetDistance = clamp01(alongReach) * reach.polyline().length(); + for (int point = 0; point < reach.polyline().size() - 1; point++) { + double segmentStart = reach.polyline().cumulativeLength(point); + double segmentEnd = reach.polyline().cumulativeLength(point + 1); + if (targetDistance > segmentEnd && point < reach.polyline().size() - 2) { + continue; + } + double segmentLength = segmentEnd - segmentStart; + double t = segmentLength <= 0D ? 0D : (targetDistance - segmentStart) / segmentLength; + return new CenterlinePosition( + reach.polyline().x(point) + + (reach.polyline().x(point + 1) - reach.polyline().x(point)) * t, + reach.polyline().z(point) + + (reach.polyline().z(point + 1) - reach.polyline().z(point)) * t + ); + } + int last = reach.polyline().size() - 1; + return new CenterlinePosition(reach.polyline().x(last), reach.polyline().z(last)); + } + + public RiverTile tileAt(double x, double z) { + int blockX = clampToInt(StrictMath.floor(x)); + int blockZ = clampToInt(StrictMath.floor(z)); + return tileCache.get(network.tileXForBlock(blockX), network.tileZForBlock(blockZ)); + } + + public RiverSample sampleFootprint( + double minimumX, + double minimumZ, + double maximumX, + double maximumZ + ) { + if (!Double.isFinite(minimumX) || !Double.isFinite(minimumZ) + || !Double.isFinite(maximumX) || !Double.isFinite(maximumZ) + || minimumX > maximumX || minimumZ > maximumZ) { + throw new IllegalArgumentException("River footprint bounds must be finite and ordered"); + } + double centerX = minimumX * 0.5D + maximumX * 0.5D; + double centerZ = minimumZ * 0.5D + maximumZ * 0.5D; + return tileAt(centerX, centerZ).sampleFootprint(minimumX, minimumZ, maximumX, maximumZ); + } + + public List candidateAnchors( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + double spacing, + long salt + ) { + if (minimumX >= maximumX || minimumZ >= maximumZ) { + return List.of(); + } + int minimumTileX = network.tileXForBlock(minimumX); + int minimumTileZ = network.tileZForBlock(minimumZ); + int maximumTileX = network.tileXForBlock(maximumX - 1); + int maximumTileZ = network.tileZForBlock(maximumZ - 1); + ArrayList anchors = new ArrayList<>(); + Set seen = new HashSet<>(); + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + RiverTile tile = tileCache.get(tileX, tileZ); + List candidates = tile.candidateAnchors( + minimumX, + minimumZ, + maximumX, + maximumZ, + spacing, + salt + ); + for (RiverAnchor anchor : candidates) { + if (seen.add(anchor.stableId())) { + anchors.add(anchor); + } + } + addTerminalCaveAnchors( + tile, + minimumX, + minimumZ, + maximumX, + maximumZ, + spacing, + salt, + anchors, + seen + ); + } + } + return List.copyOf(anchors); + } + + public EffectiveRiverSettings settingsAt(double x, double z) { + IrisRegion sampledRegion = region.get(x, z); + IrisBiome sampledBiome = biomeRiverOverridesPossible ? naturalBiome.get(x, z) : null; + return settingsFor(sampledRegion, sampledBiome); + } + + private EffectiveRiverSettings settingsFor(IrisRegion sampledRegion, IrisBiome sampledBiome) { + IdentitySettingsKey key = new IdentitySettingsKey(sampledRegion, sampledBiome); + return settingsCache.computeIfAbsent( + key, + ignored -> EffectiveRiverSettings.resolve(configuration, sampledRegion, sampledBiome) + ); + } + + public IrisRiverCaves caveSettings() { + IrisRiverCaves caves = configuration.getCaves(); + return caves == null ? new IrisRiverCaves() : caves; + } + + public double maximumChannelWidth() { + return terrain.getMaxChannelWidth(); + } + + public boolean acceptsCaveAnchor(RiverAnchor anchor) { + Objects.requireNonNull(anchor); + IrisRiverCaves caves = caveSettings(); + if (anchor.state() != RiverRouteState.WET + || !caveHydrologyActive + || caves.getMode() == IrisRiverCaveMode.SEALED + || caves.getMaximumPerReach() <= 0) { + return false; + } + RiverReach reach = tileAt(anchor.x(), anchor.z()).reach(anchor.reachId()); + if (reach == null || reach.state() != RiverRouteState.WET) { + return false; + } + TerminalCaveAnchor terminal = terminalCaveAnchor(reach); + if (terminal != null) { + return anchor.stableId() == terminal.stableId() + && caves.getMode() != IrisRiverCaveMode.SEALED; + } + double firstDistance = unit(art.arcane.iris.engine.river.RiverNetwork.mix( + reach.id().stableId() ^ anchor.samplingSalt() + )) * anchor.samplingSpacing(); + double anchorDistance = firstDistance + anchor.index() * anchor.samplingSpacing(); + if (anchorDistance >= reach.polyline().length()) { + return false; + } + int accepted = 0; + for (int index = 0; index <= anchor.index(); index++) { + double distance = firstDistance + index * anchor.samplingSpacing(); + TerminalPosition position = positionAt(reach.polyline(), distance); + long stableId = art.arcane.iris.engine.river.RiverNetwork.mix( + reach.id().stableId() + ^ anchor.samplingSalt() + ^ (long) index * 0x9E3779B97F4A7C15L + ); + if (!caveEntryEligible(caves, stableId, position.x(), position.z())) { + continue; + } + if (index == anchor.index()) { + return stableId == anchor.stableId() && accepted < caves.getMaximumPerReach(); + } + accepted++; + if (accepted >= caves.getMaximumPerReach()) { + return false; + } + } + return false; + } + + public boolean isTerminalCaveAnchor(RiverAnchor anchor) { + TerminalCaveAnchor terminal = terminalCaveAnchor(anchor); + return terminal != null && anchor.stableId() == terminal.stableId(); + } + + public String selectFloodedCaveBiome(RiverAnchor anchor) { + Objects.requireNonNull(anchor); + List biomes = settingsAt(anchor.x(), anchor.z()).floodedCaveBiomes(); + if (biomes.isEmpty()) { + return ""; + } + long hash = art.arcane.iris.engine.river.RiverNetwork.mix( + seed ^ anchor.stableId() ^ FLOODED_CAVE_BIOME_SALT); + int index = Math.floorMod(hash, biomes.size()); + String selected = biomes.get(index); + return selected == null ? "" : selected.trim(); + } + + public IrisBiome selectSurfaceBiome(IrisRiverSurfaceSample sample, double x, double z) { + RiverSample river = sample.river(); + if (!river.present() || sample.subterranean()) { + return null; + } + EffectiveRiverSettings settings = settingsAt(x, z); + PoolSelection selection = poolFor(river.section(), settings); + if (selection.keys().isEmpty()) { + return null; + } + BiomePoolKey key = new BiomePoolKey(selection.keys(), selection.type()); + List candidates = biomePoolCache.computeIfAbsent(key, this::loadBiomePool); + if (candidates.isEmpty()) { + return null; + } + double selector = clamp01(biomeNoise.fitDouble(0D, 1D, x, z)); + IrisBiome selected = IRare.pick(candidates, selector); + return selected == null ? null : selected.withInferredType(selection.type()); + } + + public int completedTileCount() { + return tileCache.completedSize(); + } + + boolean allowsReach(RiverRoutingContext context) { + return terrainSampler.allowsReach(Objects.requireNonNull(context)); + } + + RiverTerrainNodeSample sampleNode(int blockX, int blockZ) { + return terrainSampler.sampleNode(blockX, blockZ); + } + + RiverTerrainSourceSample sampleSource(int blockX, int blockZ) { + return terrainSampler.sampleSource(blockX, blockZ); + } + + @Override + public void close() { + tileCache.close(); + settingsCache.clear(); + biomePoolCache.clear(); + } + + private RiverNetworkOptions options(IrisRiverTopology topology, IrisRiverTerrain riverTerrain) { + double dryChance = clamp01(riverTerrain.getDryContinuationChance()); + return RiverNetworkOptions.builder(seed) + .cellSize(topology.getCellSize()) + .tileCells(topology.getTileCells()) + .siteJitter(topology.getSiteJitter()) + .maxRouteReaches(topology.getMaxRouteReaches()) + .minimumSourcesPerTile(topology.getMinimumSourcesPerTile()) + .downstreamCandidateLimit(Math.max(1, Math.min(8, topology.getSinkSearchReaches() + 1))) + .routingBasinCells(topology.getRoutingBasinCells()) + .routingPlateauHeight(topology.getRoutingPlateauHeight()) + .hydraulicBaseHeight(fluidHeight) + .requireOcean(topology.isRequireOcean()) + .sourceChance(chance(topology.getSource())) + .reachChance(chance(topology.getContinuation())) + .dryChannelChance(dryChance) + .terrainHeightWeight(topology.getTerrainHeightWeight()) + .routingNoiseWeight(0D) + .flowAlignmentWeight(topology.getRoutingNoiseWeight()) + .oceanAttraction(topology.getOceanAttraction()) + .channelWidth(mid(riverTerrain.getChannelWidth(), 12D)) + .bankWidth(mid(riverTerrain.getBankWidth(), 8D)) + .depth(mid(riverTerrain.getDepth(), 4D)) + .maxChannelWidth(riverTerrain.getMaxChannelWidth()) + .maxBankWidth(riverTerrain.getMaxBankWidth()) + .maxDepth(riverTerrain.getMaxDepth()) + .orderWidthFactor(riverTerrain.getOrderWidthFactor()) + .orderDepthFactor(riverTerrain.getOrderDepthFactor()) + .maximumReachRadius(maximumReachRadius(topology, riverTerrain)) + .meanderStrength(riverTerrain.getMeanderStrength()) + .meanderSubdivisions(riverTerrain.getMeanderSubdivisions()) + .build(); + } + + private void addTerminalCaveAnchors( + RiverTile tile, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + double spacing, + long salt, + List anchors, + Set seen + ) { + for (RiverReach reach : tile.reaches()) { + TerminalCaveAnchor terminal = terminalCaveAnchor(reach); + if (terminal == null + || terminal.x() < minimumX + || terminal.x() >= maximumX + || terminal.z() < minimumZ + || terminal.z() >= maximumZ + || !seen.add(terminal.stableId())) { + continue; + } + anchors.add(new RiverAnchor( + reach.id(), + 0, + terminal.stableId(), + spacing, + salt, + terminal.x(), + terminal.z(), + terminal.alongReach(), + reach.state(), + reach.flow(), + reach.order() + )); + } + } + + private TerminalCaveAnchor terminalCaveAnchor(RiverAnchor anchor) { + RiverReach reach = tileAt(anchor.x(), anchor.z()).reach(anchor.reachId()); + return reach == null ? null : terminalCaveAnchor(reach); + } + + private boolean caveEntryEligible( + IrisRiverCaves caves, + long stableId, + double x, + double z + ) { + EffectiveRiverSettings settings = settingsAt(x, z); + double chance = clamp01(effectiveChance( + caves.getEntry(), + caveEntryNoise, + (int) StrictMath.floor(x), + (int) StrictMath.floor(z) + ) * settings.caveEntryMultiplier()); + long hash = art.arcane.iris.engine.river.RiverNetwork.mix( + seed ^ stableId ^ CAVE_ENTRY_GATE_SALT + ); + return unit(hash) < chance; + } + + private TerminalCaveAnchor terminalCaveAnchor(RiverReach reach) { + if (!caveHydrologyActive || !reach.terminal() || reach.state() != RiverRouteState.WET) { + return null; + } + RiverPolyline polyline = reach.polyline(); + double length = polyline.length(); + double taperDistance = Math.min(length, Math.max(0D, terrain.getTerminalTaper())); + double targetDistance = taperDistance == 0D ? length : length - (taperDistance * 0.5D); + TerminalPosition position = positionAt(polyline, targetDistance); + EffectiveRiverSettings settings = settingsAt(reach.to().x(), reach.to().z()); + if (settings.terminalMode() != IrisRiverTerminalMode.SINKHOLE_GROTTO) { + return null; + } + long stableId = art.arcane.iris.engine.river.RiverNetwork.mix( + reach.id().stableId() ^ TERMINAL_CAVE_ANCHOR_SALT + ); + return new TerminalCaveAnchor(stableId, position.x(), position.z(), position.alongReach()); + } + + private boolean shouldTaperTerminal(RiverReach reach) { + return settingsAt(reach.to().x(), reach.to().z()).terminalMode() + != IrisRiverTerminalMode.SINKHOLE_GROTTO; + } + + private TerminalPosition positionAt(RiverPolyline polyline, double targetDistance) { + double traversed = 0D; + for (int point = 0; point < polyline.size() - 1; point++) { + double startX = polyline.x(point); + double startZ = polyline.z(point); + double deltaX = polyline.x(point + 1) - startX; + double deltaZ = polyline.z(point + 1) - startZ; + double segmentLength = StrictMath.hypot(deltaX, deltaZ); + if (targetDistance <= traversed + segmentLength || point == polyline.size() - 2) { + double factor = segmentLength == 0D ? 0D : (targetDistance - traversed) / segmentLength; + factor = Math.max(0D, Math.min(1D, factor)); + double alongReach = polyline.length() == 0D ? 0D : targetDistance / polyline.length(); + return new TerminalPosition( + startX + (deltaX * factor), + startZ + (deltaZ * factor), + alongReach + ); + } + traversed += segmentLength; + } + return new TerminalPosition( + polyline.x(polyline.size() - 1), + polyline.z(polyline.size() - 1), + 1D + ); + } + + double waterSurface(RiverReach reach, double alongReach, boolean naturalOcean) { + if (reach == null || water.getMode() == IrisRiverWaterMode.SEA_LEVEL || naturalOcean) { + return fluidHeight; + } + return terracedWaterSurface( + reach.from().hydraulicHeight(), + reach.to().hydraulicHeight(), + reach.polyline().length(), + alongReach + ); + } + + double terracedWaterSurface( + double fromNaturalHeight, + double toNaturalHeight, + double reachLength, + double alongReach + ) { + int dropHeight = Math.max(1, water.getDropHeight()); + int fromHead = nodeWaterHead(fromNaturalHeight, dropHeight); + int toHead = nodeWaterHead(toNaturalHeight, dropHeight); + int headDelta = toHead - fromHead; + int availableDrops = StrictMath.abs(headDelta) / dropHeight; + if (availableDrops == 0) { + return fromHead; + } + double normalized = clamp01(alongReach); + double distance = normalized * Math.max(0D, reachLength); + double configuredPoolLength = Math.max(1D, water.getPoolLength()); + double requiredInteriorLength = configuredPoolLength * Math.max(0, availableDrops - 1); + double requiredTargetLength = configuredPoolLength * (availableDrops + 1D); + double dropSpacing; + double firstDrop; + if (reachLength >= requiredTargetLength) { + dropSpacing = configuredPoolLength; + firstDrop = (reachLength - requiredInteriorLength) * 0.5D; + } else { + dropSpacing = reachLength / (availableDrops + 1D); + firstDrop = dropSpacing; + } + int completedDrops; + if (normalized >= 1D || dropSpacing <= 0D) { + completedDrops = availableDrops; + } else if (distance < firstDrop) { + completedDrops = 0; + } else { + completedDrops = 1 + (int) StrictMath.floor((distance - firstDrop) / dropSpacing); + completedDrops = Math.min(availableDrops, completedDrops); + } + int direction = Integer.signum(headDelta); + return fromHead + direction * completedDrops * dropHeight; + } + + private double bedRoughness(double x, double z) { + return bedNoise.fitDouble( + -terrain.getBedRoughness(), + terrain.getBedRoughness(), + x, + z + ); + } + + private static double incisedHeight( + double naturalHeight, + double bedHeight, + double carveWeight, + double maximumIncision + ) { + double targetHeight = naturalHeight + (bedHeight - naturalHeight) * carveWeight; + double guardedTarget = Math.max(targetHeight, naturalHeight - maximumIncision); + return Math.min(naturalHeight, guardedTarget); + } + + private int nodeWaterHead(double naturalNodeHeight, int dropHeight) { + int availableRise = Math.max(0, water.getMaximumPoolRise()); + int maximumHead = fluidHeight + availableRise; + int naturalHead = (int) StrictMath.floor(naturalNodeHeight - 1D); + int clamped = Math.max(fluidHeight, Math.min(maximumHead, naturalHead)); + return fluidHeight + Math.floorDiv(clamped - fluidHeight, dropHeight) * dropHeight; + } + + private static boolean isNaturalOcean(IrisBiome biome) { + return biome != null && biome.getInferredType() == InferredType.SEA; + } + + static double terminalWeight(int terminalTaper, double reachLength, double alongReach) { + double taperFraction = Math.min( + 1D, + Math.max(0, terminalTaper) / Math.max(0.000001D, reachLength) + ); + double taperStart = 1D - taperFraction; + if (alongReach <= taperStart) { + return 1D; + } + return clamp01((1D - alongReach) / Math.max(0.000001D, taperFraction)); + } + + private PoolSelection poolFor(RiverSection section, EffectiveRiverSettings settings) { + return switch (section) { + case CHANNEL -> new PoolSelection(settings.channelBiomes(), InferredType.SEA); + case MOUTH -> new PoolSelection(settings.mouthBiomes(), InferredType.SEA); + case BANK -> new PoolSelection(settings.bankBiomes(), InferredType.SHORE); + case DRY_CHANNEL, DRY_BANK -> new PoolSelection(settings.dryBiomes(), InferredType.LAND); + case NONE -> new PoolSelection(List.of(), InferredType.LAND); + }; + } + + private List loadBiomePool(BiomePoolKey pool) { + KList loaded = data.getBiomeLoader().loadAll(new KList<>(pool.keys())); + ArrayList inferred = new ArrayList<>(loaded.size()); + for (IrisBiome biome : loaded) { + if (biome != null) { + inferred.add(biome.withInferredType(pool.type())); + } + } + return List.copyOf(inferred); + } + + private CNG noise(IrisRiverNoiseChance configured, long salt) { + IrisGeneratorStyle style = configured == null ? null : configured.getStyle(); + return noise(style, salt); + } + + private CNG noise(IrisStyledRange configured, long salt) { + IrisGeneratorStyle style = configured == null ? null : configured.getStyle(); + return noise(style, salt); + } + + private CNG noise(IrisGeneratorStyle configured, long salt) { + IrisGeneratorStyle style = configured == null ? new IrisGeneratorStyle(NoiseStyle.FLAT) : configured; + return style.createNoCache(new RNG(seed ^ salt), data); + } + + private double effectiveChance(IrisRiverNoiseChance configured, CNG noise, int x, int z) { + if (configured == null) { + return 1D; + } + double contribution = noise.fitDouble(-configured.getInfluence(), configured.getInfluence(), x, z); + return clamp01(configured.getChance() + contribution); + } + + private double chanceMultiplier(IrisRiverNoiseChance configured, CNG noise, int x, int z) { + double baseChance = chance(configured); + if (baseChance <= 0D) { + return 0D; + } + return effectiveChance(configured, noise, x, z) / baseChance; + } + + private static double chance(IrisRiverNoiseChance configured) { + return configured == null ? 1D : clamp01(configured.getChance()); + } + + private static double mid(IrisStyledRange range, double fallback) { + if (range == null || !Double.isFinite(range.getMin()) || !Double.isFinite(range.getMax())) { + return fallback; + } + return Math.max(0.000001D, (range.getMin() + range.getMax()) * 0.5D); + } + + private static double styled(IrisStyledRange range, CNG noise, int x, int z, double fallback) { + if (range == null || !Double.isFinite(range.getMin()) || !Double.isFinite(range.getMax())) { + return fallback; + } + double minimum = Math.min(range.getMin(), range.getMax()); + double maximum = Math.max(range.getMin(), range.getMax()); + return noise.fitDouble(minimum, maximum, x, z); + } + + private static double maximumReachRadius(IrisRiverTopology topology, IrisRiverTerrain riverTerrain) { + return riverTerrain.getMaxChannelWidth() * 0.5D + riverTerrain.getMaxBankWidth(); + } + + private static double clamp01(double value) { + return Math.max(0D, Math.min(1D, value)); + } + + private static int clampToInt(double value) { + return (int) StrictMath.max(Integer.MIN_VALUE, StrictMath.min(Integer.MAX_VALUE, value)); + } + + private static double unit(long hash) { + return (hash >>> 11) * 0x1.0p-53; + } + + private final class RuntimeTerrainSampler implements RiverTerrainSampler { + private final IrisRiverTopology topology; + + private RuntimeTerrainSampler(IrisRiverTopology topology) { + this.topology = topology; + } + + @Override + public RiverTerrainNodeSample sampleNode(int blockX, int blockZ) { + boolean naturalHeightRequired = topology.getTerrainHeightWeight() > 0D + || water.getMode() != IrisRiverWaterMode.SEA_LEVEL; + double sampledNaturalHeight = naturalHeightRequired + ? naturalHeight.get(blockX, blockZ) + : fluidHeight; + boolean sampledOcean = Boolean.TRUE.equals(naturalOcean.get(blockX, blockZ)); + IrisRegion sampledRegion = region.get(blockX, blockZ); + IrisBiome sampledBiome = biomeRiverOverridesPossible ? naturalBiome.get(blockX, blockZ) : null; + EffectiveRiverSettings settings = settingsFor(sampledRegion, sampledBiome); + return new RiverTerrainNodeSample( + sampledNaturalHeight, + sampledOcean, + settings.routingPolicy() != IrisRiverRoutingPolicy.BLOCK, + routingCost(blockX, blockZ, settings) + ); + } + + @Override + public RiverTerrainSourceSample sampleSource(int blockX, int blockZ) { + EffectiveRiverSettings settings = settingsAt(blockX, blockZ); + double chanceMultiplier = settings.allowSources() + ? chanceMultiplier(topology.getSource(), sourceNoise, blockX, blockZ) + : 0D; + return new RiverTerrainSourceSample( + chanceMultiplier, + settings.routingPolicy() != IrisRiverRoutingPolicy.BLOCK, + Boolean.TRUE.equals(naturalOcean.get(blockX, blockZ)) + ); + } + + @Override + public double naturalHeight(int blockX, int blockZ) { + return IrisRiverRuntime.this.naturalHeight.get(blockX, blockZ); + } + + @Override + public boolean isOcean(int blockX, int blockZ) { + return Boolean.TRUE.equals(naturalOcean.get(blockX, blockZ)); + } + + @Override + public double routingCost(int blockX, int blockZ) { + EffectiveRiverSettings settings = settingsAt(blockX, blockZ); + return routingCost(blockX, blockZ, settings); + } + + @Override + public double sourceChanceMultiplier(int blockX, int blockZ) { + EffectiveRiverSettings settings = settingsAt(blockX, blockZ); + if (!settings.allowSources()) { + return 0D; + } + return chanceMultiplier(topology.getSource(), sourceNoise, blockX, blockZ); + } + + @Override + public double maximumSourceChanceMultiplier() { + IrisRiverNoiseChance configured = topology.getSource(); + if (configured == null) { + return 1D; + } + double baseChance = chance(configured); + if (baseChance <= 0D) { + return 0D; + } + return clamp01(baseChance + StrictMath.abs(configured.getInfluence())) / baseChance; + } + + @Override + public double reachChanceMultiplier(int blockX, int blockZ) { + EffectiveRiverSettings settings = settingsAt(blockX, blockZ); + return chanceMultiplier(topology.getContinuation(), continuationNoise, blockX, blockZ) + * settings.continuationChanceMultiplier(); + } + + @Override + public boolean allowsRiver(int blockX, int blockZ) { + return settingsAt(blockX, blockZ).routingPolicy() != IrisRiverRoutingPolicy.BLOCK; + } + + @Override + public boolean allowsReach(RiverRoutingContext context) { + if (!incisionGate(context)) { + return false; + } + if (boreMantleActive) { + if (!blockingRoutingPossible) { + return true; + } + return RiverPolylineProbe.all( + context.polyline(), + MAXIMUM_REACH_FEASIBILITY_SAMPLES, + (x, z, alongReach) -> settingsAt(x, z).routingPolicy() + != IrisRiverRoutingPolicy.BLOCK + ); + } + double depth = depth(context, mid(terrain.getDepth(), 4D)); + return RiverPolylineProbe.all( + context.polyline(), + MAXIMUM_REACH_FEASIBILITY_SAMPLES, + (x, z, alongReach) -> allowsReachSample(context, depth, alongReach, x, z) + ); + } + + private boolean allowsReachSample( + RiverRoutingContext context, + double depth, + double alongReach, + int x, + int z + ) { + double maximumIncision = terrain.getMaxIncision(); + if (blockingRoutingPossible || variableMaxIncisionPossible) { + EffectiveRiverSettings settings = settingsAt(x, z); + if (settings.routingPolicy() == IrisRiverRoutingPolicy.BLOCK) { + return false; + } + maximumIncision *= settings.maxIncisionMultiplier(); + } + double head = configuration.getWater().getMode() == IrisRiverWaterMode.SEA_LEVEL + ? fluidHeight + : terracedWaterSurface( + context.from().hydraulicHeight(), + context.to().hydraulicHeight(), + context.polyline().length(), + alongReach + ); + int rangeDecision = boundedFeasibilityRange( + naturalHeightBounds.sample(x, z), + head, + maximumIncision + ); + if (rangeDecision == FEASIBILITY_ACCEPT) { + return true; + } + if (rangeDecision == FEASIBILITY_REJECT) { + return false; + } + double sampledNaturalHeight = naturalHeight(x, z); + int boundedDecision = boundedFeasibility( + sampledNaturalHeight, + head, + maximumIncision, + depth, + terrain.getBedRoughness()); + if (boundedDecision == FEASIBILITY_ACCEPT) { + return true; + } + if (boundedDecision == FEASIBILITY_REJECT) { + return false; + } + maximumIncision = Math.max(0D, maximumIncision); + double bedHeight = head - depth + bedRoughness(x, z); + double finalHeight = incisedHeight(sampledNaturalHeight, bedHeight, 1D, maximumIncision); + return Math.round(finalHeight) < Math.round(head); + } + + @Override + public double reachRoutingCost(RiverRoutingContext context) { + return routingCost(context.midpointX(), context.midpointZ()); + } + + @Override + public double meanderNoise(RiverMeanderContext context) { + return meanderNoise.fitDouble(-1D, 1D, context.x(), context.z()); + } + + @Override + public double flowNoise(double x, double z) { + return routingNoise.fitDouble(-1D, 1D, x, z); + } + + @Override + public double channelWidth(RiverRoutingContext context, double fallback) { + EffectiveRiverSettings settings = settingsAt(context.midpointX(), context.midpointZ()); + return styled(terrain.getChannelWidth(), widthNoise, context.midpointX(), context.midpointZ(), fallback) + * settings.widthMultiplier(); + } + + @Override + public double bankWidth(RiverRoutingContext context, double fallback) { + EffectiveRiverSettings settings = settingsAt(context.midpointX(), context.midpointZ()); + return styled(terrain.getBankWidth(), bankNoise, context.midpointX(), context.midpointZ(), fallback) + * settings.bankWidthMultiplier(); + } + + @Override + public double depth(RiverRoutingContext context, double fallback) { + EffectiveRiverSettings settings = settingsAt(context.midpointX(), context.midpointZ()); + double configuredDepth = styled( + terrain.getDepth(), + depthNoise, + context.midpointX(), + context.midpointZ(), + fallback + ) * settings.depthMultiplier(); + return Math.min( + terrain.getMaxDepth(), + Math.max(1D + terrain.getBedRoughness(), configuredDepth) + ); + } + + @Override + public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { + EffectiveRiverSettings settings = settingsAt(blockX, blockZ); + if (settings.terminalMode() == IrisRiverTerminalMode.SINKHOLE_GROTTO) { + return caveHydrologyActive ? RiverTerminalPolicy.WET : RiverTerminalPolicy.SUPPRESS; + } + if (!topology.isRequireOcean() && !settings.terminalModeOverridden()) { + return RiverTerminalPolicy.WET; + } + return switch (settings.terminalMode()) { + case SINKHOLE_GROTTO -> caveHydrologyActive + ? RiverTerminalPolicy.WET + : RiverTerminalPolicy.SUPPRESS; + case DRY_CHANNEL -> RiverTerminalPolicy.DRY; + case SUPPRESS -> RiverTerminalPolicy.SUPPRESS; + }; + } + + private double routingCost(int blockX, int blockZ, EffectiveRiverSettings settings) { + double noiseCost = routingNoise.fitDouble(0D, topology.getRoutingNoiseWeight(), blockX, blockZ); + double slopeWeight = topology.getTerrainSlopeWeight(); + double slopeCost = slopeWeight <= 0D + ? 0D + : Math.max(0D, naturalSlope.get(blockX, blockZ)) * slopeWeight; + double avoidance = settings.routingPolicy() == IrisRiverRoutingPolicy.AVOID + ? topology.getRoutingNoiseWeight() + topology.getOceanAttraction() + 64D + : 0D; + return (noiseCost + slopeCost + avoidance) * settings.routingCostMultiplier(); + } + + private boolean incisionGate(RiverRoutingContext context) { + IrisRiverNoiseChance configured = terrain.getIncision(); + double chance = effectiveChance( + configured, + incisionNoise, + context.midpointX(), + context.midpointZ() + ); + long hash = art.arcane.iris.engine.river.RiverNetwork.mix( + seed ^ context.edgeId().stableId() ^ INCISION_GATE_SALT + ); + return unit(hash) < chance; + } + + } + + static int boundedFeasibility( + double naturalHeight, + double head, + double maximumIncision, + double depth, + double maximumBedRoughness + ) { + long roundedHead = Math.round(head); + if (Math.round(naturalHeight) < roundedHead) { + return FEASIBILITY_ACCEPT; + } + double clampedIncision = Math.max(0D, maximumIncision); + if (Math.round(naturalHeight - clampedIncision) >= roundedHead) { + return FEASIBILITY_REJECT; + } + double maximumBedHeight = head - depth + Math.abs(maximumBedRoughness); + return Math.round(maximumBedHeight) < roundedHead + ? FEASIBILITY_ACCEPT + : FEASIBILITY_SAMPLE_BED; + } + + static int boundedFeasibilityRange( + NoiseBounds bounds, + double head, + double maximumIncision + ) { + double minimum = Math.min(bounds.min(), bounds.max()); + double maximum = Math.max(bounds.min(), bounds.max()); + long roundedHead = Math.round(head); + if (Math.round(maximum) < roundedHead) { + return FEASIBILITY_ACCEPT; + } + if (Math.round(minimum - Math.max(0D, maximumIncision)) >= roundedHead) { + return FEASIBILITY_REJECT; + } + return FEASIBILITY_SAMPLE_BED; + } + + private record ResolvedRiverColumn( + RiverSample river, + RiverReach reach, + double naturalHeight, + double waterSurfaceY, + double bedHeight, + double maximumIncision, + boolean subterranean + ) { + } + + private record CenterlinePosition(double x, double z) { + } + + private record PoolSelection(List keys, InferredType type) { + } + + private record TerminalPosition(double x, double z, double alongReach) { + } + + private record TerminalCaveAnchor(long stableId, double x, double z, double alongReach) { + } + + private record BiomePoolKey(List keys, InferredType type) { + private BiomePoolKey { + keys = List.copyOf(keys); + Objects.requireNonNull(type); + } + } + + private static final class IdentitySettingsKey { + private final IrisRegion region; + private final IrisBiome biome; + private final int hash; + + private IdentitySettingsKey(IrisRegion region, IrisBiome biome) { + this.region = region; + this.biome = biome; + hash = 31 * System.identityHashCode(region) + System.identityHashCode(biome); + } + + @Override + public boolean equals(Object other) { + return other instanceof IdentitySettingsKey key + && key.region == region + && key.biome == biome; + } + + @Override + public int hashCode() { + return hash; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java new file mode 100644 index 000000000..7d49a5780 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java @@ -0,0 +1,38 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverNetwork; +import art.arcane.iris.util.project.stream.ProceduralStream; + +import java.util.Objects; + +public record IrisRiverRuntimeContext( + long seed, + IrisRiverNetwork configuration, + IrisData data, + int fluidHeight, + boolean boreMantleActive, + boolean caveHydrologyActive, + boolean blockingRoutingPossible, + boolean variableMaxIncisionPossible, + boolean biomeRiverOverridesPossible, + RiverHeightBoundsSampler naturalHeightBounds, + ProceduralStream naturalHeight, + ProceduralStream naturalSlope, + ProceduralStream naturalOcean, + ProceduralStream naturalBiome, + ProceduralStream region +) { + public IrisRiverRuntimeContext { + Objects.requireNonNull(configuration); + Objects.requireNonNull(data); + Objects.requireNonNull(naturalHeightBounds); + Objects.requireNonNull(naturalHeight); + Objects.requireNonNull(naturalSlope); + Objects.requireNonNull(naturalOcean); + Objects.requireNonNull(naturalBiome); + Objects.requireNonNull(region); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverSurfaceSample.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverSurfaceSample.java new file mode 100644 index 000000000..3c14e5a9f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverSurfaceSample.java @@ -0,0 +1,33 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.engine.river.RiverSample; + +import java.util.Objects; + +public record IrisRiverSurfaceSample( + RiverSample river, + double naturalHeight, + double terrainHeight, + double waterSurfaceY, + boolean subterranean, + boolean surfaceFluid +) { + public IrisRiverSurfaceSample { + Objects.requireNonNull(river); + if (!Double.isFinite(naturalHeight) || !Double.isFinite(terrainHeight) + || !Double.isFinite(waterSurfaceY)) { + throw new IllegalArgumentException("River surface values must be finite"); + } + } + + public static IrisRiverSurfaceSample none(double naturalHeight, double naturalWaterSurfaceY) { + return new IrisRiverSurfaceSample( + RiverSample.none(), + naturalHeight, + naturalHeight, + naturalWaterSurfaceY, + false, + Math.round(naturalHeight) < Math.round(naturalWaterSurfaceY) + ); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverTunnelSample.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverTunnelSample.java new file mode 100644 index 000000000..9019cc944 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverTunnelSample.java @@ -0,0 +1,22 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.engine.river.RiverSample; + +import java.util.Objects; + +public record IrisRiverTunnelSample( + RiverSample river, + int bedY, + int waterHeadY, + int ceilingY +) { + public IrisRiverTunnelSample { + Objects.requireNonNull(river); + if (!river.present()) { + throw new IllegalArgumentException("A river tunnel sample requires a present river"); + } + if (bedY >= waterHeadY || ceilingY < waterHeadY) { + throw new IllegalArgumentException("River tunnel vertical bounds are invalid"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverHeightBoundsSampler.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverHeightBoundsSampler.java new file mode 100644 index 000000000..803a91f70 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverHeightBoundsSampler.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.util.project.interpolation.NoiseBounds; + +@FunctionalInterface +public interface RiverHeightBoundsSampler { + NoiseBounds sample(int blockX, int blockZ); +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverPolylineProbe.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverPolylineProbe.java new file mode 100644 index 000000000..028e14f7d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverPolylineProbe.java @@ -0,0 +1,56 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.engine.river.RiverPolyline; + +import java.util.Objects; + +final class RiverPolylineProbe { + private RiverPolylineProbe() { + } + + static boolean all(RiverPolyline polyline, int maximumSamples, CellPredicate predicate) { + Objects.requireNonNull(polyline); + Objects.requireNonNull(predicate); + if (maximumSamples < 2) { + throw new IllegalArgumentException("River polyline probing requires at least two samples"); + } + double totalLength = polyline.length(); + int desiredSamples = totalLength >= Integer.MAX_VALUE + ? Integer.MAX_VALUE + : (int) StrictMath.ceil(totalLength) + 1; + int sampleCount = StrictMath.max(2, StrictMath.min(maximumSamples, desiredSamples)); + int segment = 0; + for (int sample = 0; sample < sampleCount; sample++) { + double alongReach = (double) sample / (sampleCount - 1); + double targetDistance = totalLength * alongReach; + while (segment < polyline.size() - 2 + && targetDistance > polyline.cumulativeLength(segment + 1)) { + segment++; + } + double segmentStart = polyline.cumulativeLength(segment); + double segmentEnd = polyline.cumulativeLength(segment + 1); + double segmentLength = segmentEnd - segmentStart; + double factor = segmentLength <= 0D ? 0D : (targetDistance - segmentStart) / segmentLength; + double x = polyline.x(segment) + + (polyline.x(segment + 1) - polyline.x(segment)) * factor; + double z = polyline.z(segment) + + (polyline.z(segment + 1) - polyline.z(segment)) * factor; + if (!predicate.test(clampRound(x), clampRound(z), alongReach)) { + return false; + } + } + return true; + } + + private static int clampRound(double value) { + return (int) StrictMath.max( + Integer.MIN_VALUE, + StrictMath.min(Integer.MAX_VALUE, StrictMath.round(value)) + ); + } + + @FunctionalInterface + interface CellPredicate { + boolean test(int blockX, int blockZ, double alongReach); + } +} diff --git a/core/src/main/java/art/arcane/iris/util/common/director/handlers/GeneratorHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/handlers/GeneratorHandler.java index 8d26677b9..73773a857 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/handlers/GeneratorHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/handlers/GeneratorHandler.java @@ -23,7 +23,7 @@ import art.arcane.iris.util.common.director.specialhandlers.RegistrantHandler; public class GeneratorHandler extends RegistrantHandler { public GeneratorHandler() { - super(IrisGenerator.class, false); + super(IrisGenerator.class, true); } @Override diff --git a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java index c2f4fb608..662c6e5ba 100644 --- a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java +++ b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java @@ -76,7 +76,11 @@ public class ChunkDataHunkHolder extends AtomicHunk { } public void apply() { - if (INMS.get().applyChunkDataBlocks(chunk, this)) { + applyTo(chunk); + } + + public void applyTo(ChunkData target) { + if (INMS.get().applyChunkDataBlocks(target, this)) { return; } @@ -95,7 +99,7 @@ public class ChunkDataHunkHolder extends AtomicHunk { block = custom.getBase(); } if (block == null) { - flushRun(x, z, runStart, y, activeBlock); + flushRun(target, x, z, runStart, y, activeBlock); activeBlock = null; runStart = -1; continue; @@ -105,27 +109,27 @@ public class ChunkDataHunkHolder extends AtomicHunk { continue; } - flushRun(x, z, runStart, y, activeBlock); + flushRun(target, x, z, runStart, y, activeBlock); activeBlock = block; runStart = y; } - flushRun(x, z, runStart, height, activeBlock); + flushRun(target, x, z, runStart, height, activeBlock); } } } - private void flushRun(int x, int z, int startY, int endY, BlockData block) { + private void flushRun(ChunkData target, int x, int z, int startY, int endY, BlockData block) { if (block == null || startY < 0 || endY <= startY) { return; } - int minY = chunk.getMinHeight(); + int minY = target.getMinHeight(); if (endY - startY == 1) { - chunk.setBlock(x, startY + minY, z, block); + target.setBlock(x, startY + minY, z, block); return; } - chunk.setRegion(x, startY + minY, z, x + 1, endY + minY, z + 1, block); + target.setRegion(x, startY + minY, z, x + 1, endY + minY, z + 1, block); } } diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java b/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java index 8d50fffaa..b7d9a1ed2 100644 --- a/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java +++ b/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java @@ -25,6 +25,7 @@ import art.arcane.iris.util.project.matter.slices.EntityMatter; import art.arcane.iris.util.project.matter.slices.IdentifierMatter; import art.arcane.iris.util.project.matter.slices.NativeStructureOwnershipMatter; import art.arcane.iris.util.project.matter.slices.PlatformBlockMatter; +import art.arcane.iris.util.project.matter.slices.RiverCaveHydrologyMatter; import art.arcane.iris.util.project.matter.slices.SpawnerMatter; import art.arcane.iris.util.project.matter.slices.TileMatter; import art.arcane.iris.util.project.matter.slices.TreeBlockMaterialMatter; @@ -64,6 +65,7 @@ public final class IrisMatterSupport { IrisMatter.registerSliceType(new IdentifierMatter()); IrisMatter.registerSliceType(new NativeStructureOwnershipMatter()); IrisMatter.registerSliceType(new PlatformBlockMatter()); + IrisMatter.registerSliceType(new RiverCaveHydrologyMatter()); IrisMatter.registerSliceType(new SpawnerMatter()); IrisMatter.registerSliceType(new TileMatter()); IrisMatter.registerSliceType(new TreeBlockMaterialMatter()); diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java b/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java new file mode 100644 index 000000000..3d9c5807f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java @@ -0,0 +1,58 @@ +package art.arcane.iris.util.project.matter.slices; + +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.volmlib.util.data.palette.Palette; +import art.arcane.volmlib.util.matter.Sliced; +import art.arcane.volmlib.util.matter.slices.RawMatter; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +@Sliced +public final class RiverCaveHydrologyMatter extends RawMatter { + public RiverCaveHydrologyMatter() { + this(1, 1, 1); + } + + public RiverCaveHydrologyMatter(int width, int height, int depth) { + super(width, height, depth, RiverCaveHydrology.class); + } + + @Override + public Palette getGlobalPalette() { + return null; + } + + @Override + public void writeNode(RiverCaveHydrology hydrology, DataOutputStream output) throws IOException { + output.writeByte(actionCode(hydrology.action())); + output.writeUTF(hydrology.floodedBiomeKey()); + } + + @Override + public RiverCaveHydrology readNode(DataInputStream input) throws IOException { + RiverCaveAction action = actionFromCode(input.readUnsignedByte()); + return new RiverCaveHydrology(action, input.readUTF()); + } + + private int actionCode(RiverCaveAction action) { + return switch (action) { + case WET_SOURCE -> 1; + case FALLING_WATER -> 2; + case DRY_AIR -> 3; + case SEAL_GUARD -> 4; + }; + } + + private RiverCaveAction actionFromCode(int code) throws IOException { + return switch (code) { + case 1 -> RiverCaveAction.WET_SOURCE; + case 2 -> RiverCaveAction.FALLING_WATER; + case 3 -> RiverCaveAction.DRY_AIR; + case 4 -> RiverCaveAction.SEAL_GUARD; + default -> throw new IOException("Unknown river cave hydrology action code " + code); + }; + } +} diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index e6f32e4a3..0eaf44672 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Ansicht:", "iris.desktop.vision.grid": "Raster", "iris.desktop.vision.follow": "Folgen", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Wird aktualisiert", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zoom-Reset", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Folge {player}", "iris.desktop.vision.no_player": "Kein Spieler in der Welt", "iris.desktop.vision.follow_disabled": "Folgen deaktiviert", - "iris.desktop.vision.low_quality": "Niedrige Qualität", - "iris.desktop.vision.high_quality": "Hohe Qualität", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp | {width} x {height} Blöcke", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Position: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Schlüssel: {key}", "iris.desktop.vision.biome_file": "Datei: {file}", "iris.desktop.vision.velocity": "Geschwindigkeit: {velocity}", - "iris.desktop.vision.tiles": "Kacheln: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Worker: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Zentrum: {x}, {z}", "iris.desktop.vision.help.toggle": "Hilfe ein-/ausblenden", "iris.desktop.vision.help.refresh": "Kacheln aktualisieren", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Heran-/herauszoomen", "iris.desktop.vision.help.reset_zoom": "Zoom zurücksetzen", "iris.desktop.vision.help.cycle_mode": "Rendermodus wechseln", - "iris.desktop.vision.help.quality": "Kachelqualität umschalten", "iris.desktop.vision.help.fps": "30/60 FPS umschalten", "iris.desktop.vision.help.grid": "Raster umschalten", "iris.desktop.vision.help.biome": "Detaillierte Biom-Infos", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Biom-Meer", "iris.desktop.vision.mode.region": "Region", "iris.desktop.vision.mode.cave_land": "Höhlenland", + "iris.desktop.vision.mode.river": "Flussnetz", "iris.desktop.vision.mode.height": "Höhe", "iris.desktop.vision.mode.object_load": "Objektlast", "iris.desktop.vision.mode.decorator_load": "Dekorlast", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 5aead5475..1e4207341 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Vista:", "iris.desktop.vision.grid": "Cuadrícula", "iris.desktop.vision.follow": "Seguir", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Actualizando", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zoom restablecido", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Siguiendo a {player}", "iris.desktop.vision.no_player": "No hay jugadores en el mundo", "iris.desktop.vision.follow_disabled": "Seguimiento desactivado", - "iris.desktop.vision.low_quality": "Calidad baja", - "iris.desktop.vision.high_quality": "Calidad alta", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp | {width} x {height} bloques", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Posición: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Clave: {key}", "iris.desktop.vision.biome_file": "Archivo: {file}", "iris.desktop.vision.velocity": "Velocidad: {velocity}", - "iris.desktop.vision.tiles": "Teselas: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Workers: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Centro: {x}, {z}", "iris.desktop.vision.help.toggle": "Mostrar u ocultar ayuda", "iris.desktop.vision.help.refresh": "Actualizar teselas", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Acercar/alejar", "iris.desktop.vision.help.reset_zoom": "Restablecer zoom", "iris.desktop.vision.help.cycle_mode": "Cambiar modo de renderizado", - "iris.desktop.vision.help.quality": "Cambiar calidad de las teselas", "iris.desktop.vision.help.fps": "Alternar 30/60 FPS", "iris.desktop.vision.help.grid": "Alternar cuadrícula", "iris.desktop.vision.help.biome": "Información detallada del bioma", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Mar del bioma", "iris.desktop.vision.mode.region": "Región", "iris.desktop.vision.mode.cave_land": "Tierra de cuevas", + "iris.desktop.vision.mode.river": "Red fluvial", "iris.desktop.vision.mode.height": "Altura", "iris.desktop.vision.mode.object_load": "Carga de objetos", "iris.desktop.vision.mode.decorator_load": "Carga de decoradores", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index e009ab8a5..73d8e705e 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Näytä:", "iris.desktop.vision.grid": "Ruudukko", "iris.desktop.vision.follow": "Seuraa", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Virkistävä", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Lähennä nollaus", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Seurataan pelaajaa {player}", "iris.desktop.vision.no_player": "Ei pelaajaa maailmassa", "iris.desktop.vision.follow_disabled": "Seuraa pois käytöstä", - "iris.desktop.vision.low_quality": "heikkolaatuinen", - "iris.desktop.vision.high_quality": "Korkealaatuinen", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp {width} x {height} lohkot", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Sijainti: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Avain: {key}", "iris.desktop.vision.biome_file": "Tiedosto: {file}", "iris.desktop.vision.velocity": "Nopeus: {velocity}", - "iris.desktop.vision.tiles": "Hei. {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Työntekijät: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Keskus: {x}, {z}", "iris.desktop.vision.help.toggle": "Vaihda apua", "iris.desktop.vision.help.refresh": "Päivitä laatat", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Lähennä/pois", "iris.desktop.vision.help.reset_zoom": "Nollaa zoomaus", "iris.desktop.vision.help.cycle_mode": "Cycle render -tila", - "iris.desktop.vision.help.quality": "Vaihda laattojen laatu", "iris.desktop.vision.help.fps": "Vaihda 30/60 FPS", "iris.desktop.vision.help.grid": "Vaihda ruudukkoa", "iris.desktop.vision.help.biome": "Yksityiskohtainen biome info", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Biome-meri", "iris.desktop.vision.mode.region": "Alue", "iris.desktop.vision.mode.cave_land": "Luolamaa", + "iris.desktop.vision.mode.river": "Jokiverkosto", "iris.desktop.vision.mode.height": "Korkeus", "iris.desktop.vision.mode.object_load": "Kohteen kuormitus", "iris.desktop.vision.mode.decorator_load": "Koristuskuorma", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index ebf7508a3..d3f418b00 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Vue :", "iris.desktop.vision.grid": "Grille", "iris.desktop.vision.follow": "Suivre", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Actualisation", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zoom réinitialisé", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Suivi de {player}", "iris.desktop.vision.no_player": "Aucun joueur dans le monde", "iris.desktop.vision.follow_disabled": "Suivi désactivé", - "iris.desktop.vision.low_quality": "Qualité basse", - "iris.desktop.vision.high_quality": "Qualité élevée", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp | {width} x {height} blocs", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Position : {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Clé : {key}", "iris.desktop.vision.biome_file": "Fichier : {file}", "iris.desktop.vision.velocity": "Vitesse : {velocity}", - "iris.desktop.vision.tiles": "Tuiles : {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Workers : {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Centre : {x}, {z}", "iris.desktop.vision.help.toggle": "Afficher ou masquer l'aide", "iris.desktop.vision.help.refresh": "Actualiser les tuiles", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Zoom avant/arrière", "iris.desktop.vision.help.reset_zoom": "Réinitialiser le zoom", "iris.desktop.vision.help.cycle_mode": "Changer de mode de rendu", - "iris.desktop.vision.help.quality": "Changer la qualité des tuiles", "iris.desktop.vision.help.fps": "Basculer entre 30/60 FPS", "iris.desktop.vision.help.grid": "Basculer la grille", "iris.desktop.vision.help.biome": "Informations détaillées sur le biome", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Mer du biome", "iris.desktop.vision.mode.region": "Région", "iris.desktop.vision.mode.cave_land": "Terre des grottes", + "iris.desktop.vision.mode.river": "Réseau fluvial", "iris.desktop.vision.mode.height": "Hauteur", "iris.desktop.vision.mode.object_load": "Charge des objets", "iris.desktop.vision.mode.decorator_load": "Charge des décorateurs", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 6c9688837..8247b1bb8 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "תגית:", "iris.desktop.vision.grid": "גרי", "iris.desktop.vision.follow": "עקבו אחרי עקוב", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "מרעננים", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "ZOOE", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "עוקב אחר {player}", "iris.desktop.vision.no_player": "אין שחקן בעולם", "iris.desktop.vision.follow_disabled": "עקבו אחרי מוגבלויות", - "iris.desktop.vision.low_quality": "איכות נמוכה", - "iris.desktop.vision.high_quality": "איכות גבוהה", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp {width} x {height} בלוקים", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "מיקום: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "מפתח: {key}", "iris.desktop.vision.biome_file": "תגית: {file}", "iris.desktop.vision.velocity": "מחסור: {velocity}", - "iris.desktop.vision.tiles": "טיים: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "עובדים: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "מרכז: {x}, {z}", "iris.desktop.vision.help.toggle": "עזרה", "iris.desktop.vision.help.refresh": "אריחים מרעננים", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "זום ב/החוצה", "iris.desktop.vision.help.reset_zoom": "איפוס תקריב", "iris.desktop.vision.help.cycle_mode": "המונחים: space", - "iris.desktop.vision.help.quality": "איכות אריחים", "iris.desktop.vision.help.fps": "להגות החלף 30/60 FPS", "iris.desktop.vision.help.grid": "רשת", "iris.desktop.vision.help.biome": "מידע ביוגרפי מפורט", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "הים Biome", "iris.desktop.vision.mode.region": "אזור", "iris.desktop.vision.mode.cave_land": "ארץ המערה", + "iris.desktop.vision.mode.river": "רשת נהרות", "iris.desktop.vision.mode.height": "גובה", "iris.desktop.vision.mode.object_load": "עומס אובייקטים", "iris.desktop.vision.mode.decorator_load": "עומס דקורטיבי", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index e06719449..479a0317f 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Vedi:", "iris.desktop.vision.grid": "Griglia", "iris.desktop.vision.follow": "Segui", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Aggiornamento", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Ripristino dello zoom", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Stai seguendo {player}", "iris.desktop.vision.no_player": "Nessun giocatore nel mondo", "iris.desktop.vision.follow_disabled": "Segui disattivato", - "iris.desktop.vision.low_quality": "Bassa qualità", - "iris.desktop.vision.high_quality": "Alta qualità", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp | {width} x {height} blocchi", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Posizione: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Chiave: {key}", "iris.desktop.vision.biome_file": "File: {file}", "iris.desktop.vision.velocity": "Velocità: {velocity}", - "iris.desktop.vision.tiles": "Riquadri: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Worker: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Centro: {x}, {z}", "iris.desktop.vision.help.toggle": "Mostra/nascondi la guida", "iris.desktop.vision.help.refresh": "Aggiorna i riquadri", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Ingrandisci/riduci", "iris.desktop.vision.help.reset_zoom": "Ripristina lo zoom", "iris.desktop.vision.help.cycle_mode": "Cambia modalità di rendering", - "iris.desktop.vision.help.quality": "Cambia qualità dei riquadri", "iris.desktop.vision.help.fps": "Cambia tra 30/60 FPS", "iris.desktop.vision.help.grid": "Mostra/nascondi la griglia", "iris.desktop.vision.help.biome": "Informazioni dettagliate sul biome", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Mare dei biomi", "iris.desktop.vision.mode.region": "Regione", "iris.desktop.vision.mode.cave_land": "Terreno della grotta", + "iris.desktop.vision.mode.river": "Rete fluviale", "iris.desktop.vision.mode.height": "Altezza", "iris.desktop.vision.mode.object_load": "Carico oggetto", "iris.desktop.vision.mode.decorator_load": "Carico decorativo", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index 57a842e32..a4536fa75 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "表示:", "iris.desktop.vision.grid": "グリッド", "iris.desktop.vision.follow": "フォロー", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "更新中", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "ズームリセット", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "{player} を追跡中", "iris.desktop.vision.no_player": "ワールドにプレイヤーがいません", "iris.desktop.vision.follow_disabled": "追跡無効", - "iris.desktop.vision.low_quality": "低品質", - "iris.desktop.vision.high_quality": "高品質", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp | {width} x {height} ブロック", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "位置: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "キー: {key}", "iris.desktop.vision.biome_file": "ファイル: {file}", "iris.desktop.vision.velocity": "速度: {velocity}", - "iris.desktop.vision.tiles": "タイル: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "ワーカー: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "センター: {x}, {z}", "iris.desktop.vision.help.toggle": "ヘルプを切り替え", "iris.desktop.vision.help.refresh": "タイルを更新", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "ズームイン/アウト", "iris.desktop.vision.help.reset_zoom": "ズームをリセット", "iris.desktop.vision.help.cycle_mode": "描画モードを切り替え", - "iris.desktop.vision.help.quality": "タイル品質を切り替え", "iris.desktop.vision.help.fps": "30/60 FPS を切り替え", "iris.desktop.vision.help.grid": "グリッドを切り替え", "iris.desktop.vision.help.biome": "詳細なバイオメ情報", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "海洋バイオーム", "iris.desktop.vision.mode.region": "リージョン", "iris.desktop.vision.mode.cave_land": "洞窟地形", + "iris.desktop.vision.mode.river": "河川網", "iris.desktop.vision.mode.height": "高さ", "iris.desktop.vision.mode.object_load": "オブジェクト負荷", "iris.desktop.vision.mode.decorator_load": "デコレーター負荷", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index b7558331c..084ed2923 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "보기:", "iris.desktop.vision.grid": "격자", "iris.desktop.vision.follow": "추적", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "새로 고치는 중", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zoom 리셋", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "{player} 추적 중", "iris.desktop.vision.no_player": "월드에 플레이어가 없음", "iris.desktop.vision.follow_disabled": "추적 비활성화", - "iris.desktop.vision.low_quality": "낮은 품질", - "iris.desktop.vision.high_quality": "높은 품질", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp | {width} x {height} 블록", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "위치: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "주요: {key}", "iris.desktop.vision.biome_file": "파일 : {file}", "iris.desktop.vision.velocity": "속도: {velocity}", - "iris.desktop.vision.tiles": "타일: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "노동자: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "센터: {x}, {z}", "iris.desktop.vision.help.toggle": "도움말 전환", "iris.desktop.vision.help.refresh": "타일 새로 고침", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "줌 / 아웃", "iris.desktop.vision.help.reset_zoom": "확대 초기화", "iris.desktop.vision.help.cycle_mode": "렌더링 모드 전환", - "iris.desktop.vision.help.quality": "타일 품질 전환", "iris.desktop.vision.help.fps": "30/60 FPS 전환", "iris.desktop.vision.help.grid": "격자 전환", "iris.desktop.vision.help.biome": "Biome 정보", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Biome 바다", "iris.desktop.vision.mode.region": "주요 특징", "iris.desktop.vision.mode.cave_land": "동굴 땅", + "iris.desktop.vision.mode.river": "하천망", "iris.desktop.vision.mode.height": "고도:", "iris.desktop.vision.mode.object_load": "객체 부하", "iris.desktop.vision.mode.decorator_load": "장식자 짐", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 44d5798fd..e077b4446 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Rodymas:", "iris.desktop.vision.grid": "Tinklelis", "iris.desktop.vision.follow": "Sekti", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Atnaujinimas", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Mastelis iš naujo", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Sekamas {player}", "iris.desktop.vision.no_player": "Jokio žaidėjo pasaulyje", "iris.desktop.vision.follow_disabled": "Sekti išjungtą", - "iris.desktop.vision.low_quality": "Žemos kokybės", - "iris.desktop.vision.high_quality": "Aukšta kokybė", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp. {width} x {height} blokai", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Pareigos: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Raktas: {key}", "iris.desktop.vision.biome_file": "Failas: {file}", "iris.desktop.vision.velocity": "Greitis: {velocity}", - "iris.desktop.vision.tiles": "Plytelės: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Darbuotojai: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Centras: {x}, {z}", "iris.desktop.vision.help.toggle": "Perjungti pagalbą", "iris.desktop.vision.help.refresh": "Atnaujinti", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Mastelis", "iris.desktop.vision.help.reset_zoom": "Atstatyti mastelį", "iris.desktop.vision.help.cycle_mode": "Ciklo pateikimo veiksena", - "iris.desktop.vision.help.quality": "Perjungti plytelių kokybę", "iris.desktop.vision.help.fps": "Perjungti 30/60 FPS", "iris.desktop.vision.help.grid": "Perjungti tinklelį", "iris.desktop.vision.help.biome": "Detali biomo informacija", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Biominė jūra", "iris.desktop.vision.mode.region": "Regionas", "iris.desktop.vision.mode.cave_land": "Kavos žemė", + "iris.desktop.vision.mode.river": "Upių tinklas", "iris.desktop.vision.mode.height": "Aukštis", "iris.desktop.vision.mode.object_load": "Objekto apkrova", "iris.desktop.vision.mode.decorator_load": "Decorator apkrova", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 5a45f9e8b..3ac712283 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Beeld:", "iris.desktop.vision.grid": "Raster", "iris.desktop.vision.follow": "Volgen", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Verversen", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Reset zoomen", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Volgt {player}", "iris.desktop.vision.no_player": "Geen speler in de wereld", "iris.desktop.vision.follow_disabled": "Volgen uitgeschakeld", - "iris.desktop.vision.low_quality": "Van lage kwaliteit", - "iris.desktop.vision.high_quality": "Hoge kwaliteit", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp {width} x {height} blokken", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Positie: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Sleutel: {key}", "iris.desktop.vision.biome_file": "Bestand: {file}", "iris.desktop.vision.velocity": "Snelheid: {velocity}", - "iris.desktop.vision.tiles": "Tegels: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Werknemers: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Midden: {x}, {z}", "iris.desktop.vision.help.toggle": "Hulp aan/uit", "iris.desktop.vision.help.refresh": "Tegels verversen", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "In/uitzoomen", "iris.desktop.vision.help.reset_zoom": "Zoom resetten", "iris.desktop.vision.help.cycle_mode": "Cyclusweergavemodus", - "iris.desktop.vision.help.quality": "Tegelkwaliteit aan/uit", "iris.desktop.vision.help.fps": "Schakelen 30/60 FPS", "iris.desktop.vision.help.grid": "Raster aan/uit", "iris.desktop.vision.help.biome": "Gedetailleerde biome info", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Biome zee", "iris.desktop.vision.mode.region": "Gebieden", "iris.desktop.vision.mode.cave_land": "Grotland", + "iris.desktop.vision.mode.river": "Riviernetwerk", "iris.desktop.vision.mode.height": "Hoogte", "iris.desktop.vision.mode.object_load": "Objectbelasting", "iris.desktop.vision.mode.decorator_load": "Decoratorbelasting", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index d3cd67754..20c4ab757 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Widok:", "iris.desktop.vision.grid": "Sieć", "iris.desktop.vision.follow": "Śledź", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Odświeżające", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zmień powiększenie", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Śledzenie gracza {player}", "iris.desktop.vision.no_player": "Brak gracza na świecie", "iris.desktop.vision.follow_disabled": "Śledź wyłączone", - "iris.desktop.vision.low_quality": "Niska jakość", - "iris.desktop.vision.high_quality": "Wysoka jakość", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp {width} x {height} bloki", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Pozycja: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Klucz: {key}", "iris.desktop.vision.biome_file": "Plik: {file}", "iris.desktop.vision.velocity": "Prędkość: {velocity}", - "iris.desktop.vision.tiles": "Płytki: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Pracownicy: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Centrum: {x}, {z}", "iris.desktop.vision.help.toggle": "Włączanie pomocy", "iris.desktop.vision.help.refresh": "Płytki odświeżające", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Powiększenie", "iris.desktop.vision.help.reset_zoom": "Zmień powiększenie", "iris.desktop.vision.help.cycle_mode": "Tryb renderowania cyklu", - "iris.desktop.vision.help.quality": "Włączenie / wyłączenie jakości płytek", "iris.desktop.vision.help.fps": "Włączenie / wyłączenie 30/60 FPS", "iris.desktop.vision.help.grid": "Przełączanie sieci", "iris.desktop.vision.help.biome": "Szczegółowe informacje o biomie", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Morze Biome", "iris.desktop.vision.mode.region": "Obszar", "iris.desktop.vision.mode.cave_land": "Kraina jaskiń", + "iris.desktop.vision.mode.river": "Sieć rzeczna", "iris.desktop.vision.mode.height": "Wysokość", "iris.desktop.vision.mode.object_load": "Ładunek obiektu", "iris.desktop.vision.mode.decorator_load": "Obciążenie dekoratora", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index bfe6b1616..b66a37265 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Ver:", "iris.desktop.vision.grid": "Grade", "iris.desktop.vision.follow": "Seguir", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Atualizando", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Ampliação repor", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "A seguir {player}", "iris.desktop.vision.no_player": "Nenhum jogador no mundo", "iris.desktop.vision.follow_disabled": "Seguir desabilitado", - "iris.desktop.vision.low_quality": "Baixa qualidade", - "iris.desktop.vision.high_quality": "Alta qualidade", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp {width} x {height} blocos", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Posição: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Chave: {key}", "iris.desktop.vision.biome_file": "Ficheiro: {file}", "iris.desktop.vision.velocity": "Velocidade: {velocity}", - "iris.desktop.vision.tiles": "Peças: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Trabalhadores: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Centro: {x}, {z}", "iris.desktop.vision.help.toggle": "Alternar a ajuda", "iris.desktop.vision.help.refresh": "Actualizar as peças", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Ampliar/desligar", "iris.desktop.vision.help.reset_zoom": "Reiniciar zoom", "iris.desktop.vision.help.cycle_mode": "Modo de renderização do ciclo", - "iris.desktop.vision.help.quality": "Alternar a qualidade da peça", "iris.desktop.vision.help.fps": "Alternar 30/60 FPS", "iris.desktop.vision.help.grid": "Alternar grade", "iris.desktop.vision.help.biome": "Informações detalhadas sobre o bioma", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Mar de bioma", "iris.desktop.vision.mode.region": "Região", "iris.desktop.vision.mode.cave_land": "Terras das cavernas", + "iris.desktop.vision.mode.river": "Rede fluvial", "iris.desktop.vision.mode.height": "Altura", "iris.desktop.vision.mode.object_load": "Carregamento do objeto", "iris.desktop.vision.mode.decorator_load": "Carga do decorador", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index c33605e23..1f23d1aa4 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Посмотреть:", "iris.desktop.vision.grid": "сетка", "iris.desktop.vision.follow": "Следить", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "освежающий", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Сброс Zoom", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Отслеживается {player}", "iris.desktop.vision.no_player": "Нет игрока в мире", "iris.desktop.vision.follow_disabled": "Следить за инвалидами", - "iris.desktop.vision.low_quality": "Низкое качество", - "iris.desktop.vision.high_quality": "Высокое качество", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp. {width} x {height} блок", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Должность: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Ключ: {key}", "iris.desktop.vision.biome_file": "Файл: {file}", "iris.desktop.vision.velocity": "Скорость: {velocity}", - "iris.desktop.vision.tiles": "Плитка: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Рабочие: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Центр: {x}, {z}", "iris.desktop.vision.help.toggle": "Помощь в переключении", "iris.desktop.vision.help.refresh": "Обновить плитку", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Zoom в / наружу", "iris.desktop.vision.help.reset_zoom": "Сбросить зум", "iris.desktop.vision.help.cycle_mode": "Режим рендеринга цикла", - "iris.desktop.vision.help.quality": "Качество плитки переключить", "iris.desktop.vision.help.fps": "переключаться 30/60 FPS", "iris.desktop.vision.help.grid": "Сетка переключения", "iris.desktop.vision.help.biome": "Подробная информация о Biome", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Биологическое море", "iris.desktop.vision.mode.region": "регион", "iris.desktop.vision.mode.cave_land": "Пещерная земля", + "iris.desktop.vision.mode.river": "Речная сеть", "iris.desktop.vision.mode.height": "высота", "iris.desktop.vision.mode.object_load": "Объектная нагрузка", "iris.desktop.vision.mode.decorator_load": "Загрузка декоратора", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 723c3677b..0bee9564a 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Görüntüleme:", "iris.desktop.vision.grid": "Izgara", "iris.desktop.vision.follow": "Takip", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Yenileme", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "yakınlaştırma reset sıfırlama", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "{player} takip ediliyor", "iris.desktop.vision.no_player": "Hiçbir oyuncu dünyada", "iris.desktop.vision.follow_disabled": "Engellileri Takip Et", - "iris.desktop.vision.low_quality": "Düşük kaliteli", - "iris.desktop.vision.high_quality": "Yüksek kaliteli", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp | {width} x {height} bloklar blok blokları", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Pozisyon: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Anahtar: {key}", "iris.desktop.vision.biome_file": "Dosya: {file}", "iris.desktop.vision.velocity": "Velocity : {velocity}", - "iris.desktop.vision.tiles": "Malzemeler: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "İşçiler: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Merkez: {x}, {z}", "iris.desktop.vision.help.toggle": "değiştir yardım", "iris.desktop.vision.help.refresh": "Yeni kaplamalar", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Yakınlaştır/uzaklaştır", "iris.desktop.vision.help.reset_zoom": "yakınlaştırma", "iris.desktop.vision.help.cycle_mode": "Çevrim formu", - "iris.desktop.vision.help.quality": "değiştir karo kaliteli", "iris.desktop.vision.help.fps": "değiştir 30/60 FPS", "iris.desktop.vision.help.grid": "değiştir network", "iris.desktop.vision.help.biome": "Detaylı bilgi", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Biome deniz", "iris.desktop.vision.mode.region": "Bölge Bölgesi", "iris.desktop.vision.mode.cave_land": "Cave land Cave", + "iris.desktop.vision.mode.river": "Nehir ağı", "iris.desktop.vision.mode.height": "Yükseklik", "iris.desktop.vision.mode.object_load": "Object yükü", "iris.desktop.vision.mode.decorator_load": "Decorator yükü", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 3d2ffa85d..297ea5b92 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "Xem:", "iris.desktop.vision.grid": "Lưới", "iris.desktop.vision.follow": "Theo", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "Cập nhật", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Đặt lại phóng đại", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "Đang theo dõi {player}", "iris.desktop.vision.no_player": "Không có người chơi trên thế giới", "iris.desktop.vision.follow_disabled": "Tắt theo", - "iris.desktop.vision.low_quality": "Chất lượng thấp", - "iris.desktop.vision.high_quality": "Chất lượng cao", "iris.desktop.vision.status_left": "{mode} | {bpp} bpp {width} x {height} khối", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "Vị trí: {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "Khóa: {key}", "iris.desktop.vision.biome_file": "Tập tin: {file}", "iris.desktop.vision.velocity": "Tốc độ: {velocity}", - "iris.desktop.vision.tiles": "Lát: {high} HD / {low} LQ", - "iris.desktop.vision.workers": "Công nhân: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "Giữa: {x}, {z}", "iris.desktop.vision.help.toggle": "Bật/tắt trợ giúp", "iris.desktop.vision.help.refresh": "Làm tươi các miếng", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "Thu nhỏ", "iris.desktop.vision.help.reset_zoom": "Đặt lại phóng đại", "iris.desktop.vision.help.cycle_mode": "Chế độ vẽ vòng tròn", - "iris.desktop.vision.help.quality": "Bật/ tắt chất lượng ô vuông", "iris.desktop.vision.help.fps": "Bật/ tắt 30/60 FPS", "iris.desktop.vision.help.grid": "Bật/ tắt lưới", "iris.desktop.vision.help.biome": "Thông tin về sinh học chi tiết", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "Biển lấp lánh", "iris.desktop.vision.mode.region": "Vùng", "iris.desktop.vision.mode.cave_land": "Vùng đất hang động", + "iris.desktop.vision.mode.river": "Mạng lưới sông ngòi", "iris.desktop.vision.mode.height": "Chiều cao", "iris.desktop.vision.mode.object_load": "Trọng tải đối tượng", "iris.desktop.vision.mode.decorator_load": "Tải bộ phân giải", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 47a7b0f87..4a50ab152 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "视图 :", "iris.desktop.vision.grid": "网格", "iris.desktop.vision.follow": "跟着", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "刷新", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "缩放重置", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "跟踪 {player}", "iris.desktop.vision.no_player": "世界上没有玩家", "iris.desktop.vision.follow_disabled": "跟随已禁用", - "iris.desktop.vision.low_quality": "低质量", - "iris.desktop.vision.high_quality": "高质量", "iris.desktop.vision.status_left": "{mode} | {bpp} 联合国 {width} x {height} 块", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "位置 : {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "键 : {key}", "iris.desktop.vision.biome_file": "文件 : {file}", "iris.desktop.vision.velocity": "速度 : {velocity}", - "iris.desktop.vision.tiles": "线条 : {high} HD / {low} LQ", - "iris.desktop.vision.workers": "工人: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "中点 : {x}, {z}", "iris.desktop.vision.help.toggle": "切换帮助", "iris.desktop.vision.help.refresh": "刷新瓷砖", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "缩放/退出", "iris.desktop.vision.help.reset_zoom": "重置缩放", "iris.desktop.vision.help.cycle_mode": "循环渲染模式", - "iris.desktop.vision.help.quality": "切换瓦片质量", "iris.desktop.vision.help.fps": "切换 30/60 FPS", "iris.desktop.vision.help.grid": "切换网格", "iris.desktop.vision.help.biome": "详细生物信息", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "生物海", "iris.desktop.vision.mode.region": "地区", "iris.desktop.vision.mode.cave_land": "洞穴土地", + "iris.desktop.vision.mode.river": "河流网络", "iris.desktop.vision.mode.height": "高度", "iris.desktop.vision.mode.object_load": "对象负载", "iris.desktop.vision.mode.decorator_load": "加载装饰器", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index 6524911db..e1ad88a14 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1378,7 +1378,6 @@ "iris.desktop.vision.view": "檢視 :", "iris.desktop.vision.grid": "網格", "iris.desktop.vision.follow": "跟著", - "iris.desktop.vision.low_quality_short": "LQ", "iris.desktop.vision.refreshing": "重新整理", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "縮放重置", @@ -1387,8 +1386,6 @@ "iris.desktop.vision.following": "跟蹤 {player}", "iris.desktop.vision.no_player": "世界上沒有玩家", "iris.desktop.vision.follow_disabled": "跟隨已禁用", - "iris.desktop.vision.low_quality": "低質量", - "iris.desktop.vision.high_quality": "高質量", "iris.desktop.vision.status_left": "{mode} | {bpp} 聯合國 {width} x {height} 塊", "iris.desktop.vision.status_right": "X: {x} Z: {z} | {fps} FPS", "iris.desktop.vision.entity_position": "位置 : {x}, {y}, {z}", @@ -1399,8 +1396,8 @@ "iris.desktop.vision.biome_key": "鍵 : {key}", "iris.desktop.vision.biome_file": "檔案 : {file}", "iris.desktop.vision.velocity": "速度 : {velocity}", - "iris.desktop.vision.tiles": "線條 : {high} HD / {low} LQ", - "iris.desktop.vision.workers": "工人: {high} HD / {low} LQ", + "iris.desktop.vision.tiles": "Atlas: {ready} / {total}", + "iris.desktop.vision.workers": "Workers: {active} active / {queued} queued", "iris.desktop.vision.center": "中點 : {x}, {z}", "iris.desktop.vision.help.toggle": "切換幫助", "iris.desktop.vision.help.refresh": "重新整理瓷磚", @@ -1408,7 +1405,6 @@ "iris.desktop.vision.help.zoom": "縮放/退出", "iris.desktop.vision.help.reset_zoom": "重置縮放", "iris.desktop.vision.help.cycle_mode": "迴圈渲染模式", - "iris.desktop.vision.help.quality": "切換瓦片質量", "iris.desktop.vision.help.fps": "切換 30/60 FPS", "iris.desktop.vision.help.grid": "切換網格", "iris.desktop.vision.help.biome": "詳細生物資訊", @@ -1421,6 +1417,7 @@ "iris.desktop.vision.mode.biome_sea": "生物海", "iris.desktop.vision.mode.region": "地區", "iris.desktop.vision.mode.cave_land": "洞穴土地", + "iris.desktop.vision.mode.river": "河流網絡", "iris.desktop.vision.mode.height": "高度", "iris.desktop.vision.mode.object_load": "物件負載", "iris.desktop.vision.mode.decorator_load": "載入裝飾器", diff --git a/core/src/test/java/art/arcane/iris/core/IrisStartupValidationTest.java b/core/src/test/java/art/arcane/iris/core/IrisStartupValidationTest.java index 2edd2a351..c02ed9806 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisStartupValidationTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisStartupValidationTest.java @@ -22,6 +22,7 @@ public class IrisStartupValidationTest { IrisStartupValidation.disable(); assertTrue(IrisStartupValidation.isReady()); + assertFalse(IrisStartupValidation.isRestartRequired()); assertTrue(IrisStartupValidation.denialReason().isEmpty()); IrisStartupValidation.requireWorldCreationReady(); } @@ -31,6 +32,7 @@ public class IrisStartupValidationTest { IrisStartupValidation.begin(); assertFalse(IrisStartupValidation.isReady()); + assertFalse(IrisStartupValidation.isRestartRequired()); assertTrue(IrisStartupValidation.denialReason().orElseThrow().contains("external datapacks")); try { IrisStartupValidation.requireWorldCreationReady(); @@ -71,6 +73,7 @@ public class IrisStartupValidationTest { IrisStartupValidation.markPacksReady(); assertFalse(IrisStartupValidation.isReady()); + assertTrue(IrisStartupValidation.isRestartRequired()); assertEquals("restart required for registry load", IrisStartupValidation.denialReason().orElseThrow()); } diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorStartupRestartTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorStartupRestartTest.java new file mode 100644 index 000000000..dd02373fb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorStartupRestartTest.java @@ -0,0 +1,78 @@ +package art.arcane.iris.core; + +import art.arcane.iris.spi.IrisLogging; +import org.bukkit.Bukkit; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; + +public class ServerConfiguratorStartupRestartTest { + @Test + public void startupBoundaryRestartsImmediatelyAndStopsIfRestartReturns() { + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + ServerConfigurator.restartAtStartupBoundary(" updated external datapacks "); + + bukkit.verify(Bukkit::restart, times(1)); + bukkit.verify(Bukkit::shutdown, times(1)); + logging.verify(() -> IrisLogging.warn( + "updated external datapacks Restarting server before default worlds are loaded.")); + logging.verify(() -> IrisLogging.error( + "The immediate Iris startup restart returned unexpectedly; stopping the server instead.")); + } + } + + @Test + public void startupBoundaryStopsWhenImmediateRestartThrows() { + IllegalStateException failure = new IllegalStateException("restart unavailable"); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + bukkit.when(Bukkit::restart).thenThrow(failure); + + ServerConfigurator.restartAtStartupBoundary(null); + + bukkit.verify(Bukkit::restart, times(1)); + bukkit.verify(Bukkit::shutdown, times(1)); + logging.verify(() -> IrisLogging.reportError( + "Unable to restart the server at the Iris startup boundary.", failure)); + } + } + + @Test + public void startupRestartPathsPromoteTheValidationStateAndBypassTickQueues() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/ServerConfigurator.java")); + String configure = section( + source, + "public static void configure()", + "public static boolean isLoadedDatapackRuntimeReady"); + String startupRestart = section( + source, + "public static void restartAtStartupBoundary", + "public static boolean verifyDataPackInstalled"); + + int restartResult = configure.indexOf("if (result.restartRequired())"); + int validationRestart = configure.indexOf("requireDatapackRestart();", restartResult); + assertTrue(restartResult >= 0); + assertTrue(validationRestart > restartResult); + assertTrue(startupRestart.indexOf("Bukkit.restart();") + < startupRestart.indexOf("Bukkit.shutdown();")); + assertFalse(startupRestart.contains("J.s(")); + assertFalse(startupRestart.contains("dispatchCommand")); + } + + private static String section(String source, String startMarker, String endMarker) { + int start = source.indexOf(startMarker); + int end = source.indexOf(endMarker, start); + assertTrue(start >= 0); + assertTrue(end > start); + return source.substring(start, end); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/GuiHostTest.java b/core/src/test/java/art/arcane/iris/core/gui/GuiHostTest.java index c36c948dc..d18bec83d 100644 --- a/core/src/test/java/art/arcane/iris/core/gui/GuiHostTest.java +++ b/core/src/test/java/art/arcane/iris/core/gui/GuiHostTest.java @@ -2,22 +2,30 @@ package art.arcane.iris.core.gui; import org.junit.Assume; import org.junit.Test; +import org.mockito.ArgumentCaptor; import javax.swing.JFrame; import javax.swing.SwingUtilities; import java.awt.GraphicsEnvironment; import java.awt.desktop.QuitResponse; import java.awt.event.WindowEvent; +import java.awt.event.WindowListener; 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.assertTrue; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; public class GuiHostTest { @Test - public void desktopQuitIsCancelledWithoutPerformingQuit() { + public void desktopQuitDisposesManagedWindowsWithoutStoppingServer() throws Exception { + JFrame frame = mock(JFrame.class); + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(WindowListener.class); + GuiHost.prepareFrame(frame); AtomicInteger cancelled = new AtomicInteger(); AtomicInteger performed = new AtomicInteger(); QuitResponse response = new QuitResponse() { @@ -32,10 +40,15 @@ public class GuiHostTest { } }; - GuiHost.cancelDesktopQuit(response); + GuiHost.closeDesktopWindowsAndCancelQuit(response); + SwingUtilities.invokeAndWait(() -> { + }); assertEquals(1, cancelled.get()); assertEquals(0, performed.get()); + verify(frame).dispose(); + verify(frame, atLeastOnce()).addWindowListener(listenerCaptor.capture()); + listenerCaptor.getValue().windowClosed(mock(WindowEvent.class)); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/gui/NoiseExplorerGUITest.java b/core/src/test/java/art/arcane/iris/core/gui/NoiseExplorerGUITest.java new file mode 100644 index 000000000..649d218d9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/NoiseExplorerGUITest.java @@ -0,0 +1,117 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.util.function.NoiseProvider; +import org.junit.Test; + +import javax.swing.SwingUtilities; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +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.assertTrue; + +public final class NoiseExplorerGUITest { + @Test + public void openingInitialSourceBuildsSamplerExactlyOnce() throws Exception { + AtomicInteger factoryCalls = new AtomicInteger(); + CountDownLatch firstFactoryCall = new CountDownLatch(1); + CountDownLatch releaseFactory = new CountDownLatch(1); + CountDownLatch duplicateFactoryCall = new CountDownLatch(1); + AtomicReference explorerReference = new AtomicReference<>(); + try { + SwingUtilities.invokeAndWait(() -> { + NoiseExplorerGUI explorer = new NoiseExplorerGUI(sourceSeed -> { + int invocation = factoryCalls.incrementAndGet(); + if (invocation == 1) { + firstFactoryCall.countDown(); + try { + releaseFactory.await(3L, TimeUnit.SECONDS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + } else { + duplicateFactoryCall.countDown(); + } + return constantSampler(); + }, "Test source", 12345L); + explorer.buildSidebar(); + explorer.openViewer(); + explorerReference.set(explorer); + }); + + assertTrue(firstFactoryCall.await(3L, TimeUnit.SECONDS)); + releaseFactory.countDown(); + assertFalse(duplicateFactoryCall.await(300L, TimeUnit.MILLISECONDS)); + assertEquals(1, factoryCalls.get()); + } finally { + releaseFactory.countDown(); + NoiseExplorerGUI explorer = explorerReference.get(); + if (explorer != null) { + SwingUtilities.invokeAndWait(explorer::close); + } + } + } + + @Test + public void allExecutorsRegisterForPreservationAndCloseWithWindow() throws Exception { + PreservationRegistry previous = IrisServices.getOrNull(PreservationRegistry.class); + RecordingPreservationRegistry preservation = new RecordingPreservationRegistry(); + AtomicReference explorerReference = new AtomicReference<>(); + IrisServices.register(PreservationRegistry.class, preservation); + try { + SwingUtilities.invokeAndWait(() -> explorerReference.set( + new NoiseExplorerGUI(sourceSeed -> constantSampler(), "Test source", 12345L) + )); + + assertEquals(2, preservation.executors.size()); + SwingUtilities.invokeAndWait(explorerReference.get()::close); + for (ExecutorService executor : preservation.executors) { + assertTrue(executor.isShutdown()); + } + } finally { + NoiseExplorerGUI explorer = explorerReference.get(); + if (explorer != null) { + SwingUtilities.invokeAndWait(explorer::close); + } + if (previous == null) { + IrisServices.remove(PreservationRegistry.class); + } else { + IrisServices.register(PreservationRegistry.class, previous); + } + } + } + + private static NoiseProvider constantSampler() { + return (x, z) -> 0.5D; + } + + private static final class RecordingPreservationRegistry implements PreservationRegistry { + private final List executors = new ArrayList<>(); + + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + executors.add(service); + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/NoisePaletteTest.java b/core/src/test/java/art/arcane/iris/core/gui/NoisePaletteTest.java new file mode 100644 index 000000000..90deb06b4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/NoisePaletteTest.java @@ -0,0 +1,36 @@ +package art.arcane.iris.core.gui; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +public final class NoisePaletteTest { + @Test + public void invalidSamplesUseVisibleDiagnosticColor() { + assertEquals(NoisePalette.INVALID_COLOR, NoisePalette.TERRAIN.color(Double.NaN)); + assertEquals(NoisePalette.INVALID_COLOR, NoisePalette.TERRAIN.color(Double.POSITIVE_INFINITY)); + } + + @Test + public void paletteClipsValuesToItsDocumentedRange() { + assertEquals(NoisePalette.SIGNED.color(-1D), NoisePalette.SIGNED.color(-10D)); + assertEquals(NoisePalette.SIGNED.color(1D), NoisePalette.SIGNED.color(10D)); + assertNotEquals(NoisePalette.SIGNED.color(-1D), NoisePalette.SIGNED.color(1D)); + } + + @Test + public void finiteHotPathAndCachedDisplayColorsMatchCanonicalLookup() { + for (NoisePalette palette : NoisePalette.values()) { + for (int index = -200; index <= 200; index++) { + double value = index / 100D; + assertEquals(palette.color(value), palette.colorFinite(value)); + double normalized = (value - palette.minimum()) / (palette.maximum() - palette.minimum()); + assertEquals( + palette.colorNormalized(normalized), + palette.displayColorNormalized(normalized).getRGB() & 0xFFFFFF + ); + } + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/NoiseRenderCoordinatorTest.java b/core/src/test/java/art/arcane/iris/core/gui/NoiseRenderCoordinatorTest.java new file mode 100644 index 000000000..c24d40edc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/NoiseRenderCoordinatorTest.java @@ -0,0 +1,413 @@ +package art.arcane.iris.core.gui; + +import art.arcane.volmlib.util.function.NoiseProvider; +import org.junit.Test; + +import javax.swing.SwingUtilities; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +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.assertNotNull; +import static org.junit.Assert.assertTrue; + +public final class NoiseRenderCoordinatorTest { + @Test + public void adaptiveStepNeverExceedsHardSampleBudget() { + int sampleStep = NoiseRenderCoordinator.sampleStepForBudget(3840, 2160, 400_000L); + + assertTrue(sampleStep > 1); + assertTrue(NoiseRenderCoordinator.sampleCount(3840, 2160, sampleStep) <= 400_000L); + assertEquals(1, NoiseRenderCoordinator.sampleStepForBudget(320, 200, 64_000L)); + } + + @Test + public void timedBudgetStaysSmallForSlowSourcesAndCapsFastSources() { + assertEquals(24_000L, NoiseRenderCoordinator.timeBoundSampleBudget( + 18_000L, + 2_000D, + 500D, + 24_000L, + 120_000L + )); + assertEquals(120_000L, NoiseRenderCoordinator.timeBoundSampleBudget( + 18_000L, + 10D, + 500D, + 24_000L, + 120_000L + )); + } + + @Test + public void eachRefinementPassMakesVisibleProgressEvenForSlowSources() { + assertEquals(7, NoiseRenderCoordinator.nextRefinementStep(1440, 770, 7, 24_000L)); + assertEquals(2, NoiseRenderCoordinator.nextRefinementStep(1440, 770, 2, 24_000L)); + assertEquals(1, NoiseRenderCoordinator.nextRefinementStep(320, 200, 3, 400_000L)); + int boundedStep = NoiseRenderCoordinator.nextRefinementStep(3840, 2160, 64, 48_000L); + assertTrue(NoiseRenderCoordinator.sampleCount(3840, 2160, boundedStep) <= 48_000L); + } + + @Test + public void sameRevisionRefinementSupersedesAnInFlightPreview() throws Exception { + CountDownLatch previewStarted = new CountDownLatch(1); + CountDownLatch releasePreview = new CountDownLatch(1); + CountDownLatch completed = new CountDownLatch(1); + List completedSteps = new ArrayList<>(); + AtomicReference failure = new AtomicReference<>(); + NoiseRenderCoordinator coordinator = new NoiseRenderCoordinator(2, new NoiseRenderCoordinator.Listener() { + @Override + public void onRenderStarted(NoiseRenderCoordinator.Request request) { + if (request.sampleStep() == 4) { + previewStarted.countDown(); + } + } + + @Override + public void onRenderCompleted(NoiseRenderCoordinator.Result result) { + completedSteps.add(result.request().sampleStep()); + completed.countDown(); + } + + @Override + public void onRenderFailed(NoiseRenderCoordinator.Request request, Throwable error) { + failure.set(error); + completed.countDown(); + } + }); + try { + AtomicBoolean firstSample = new AtomicBoolean(true); + NoiseProvider previewSampler = (x, z) -> { + if (firstSample.compareAndSet(true, false)) { + try { + releasePreview.await(3L, TimeUnit.SECONDS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } + } + return 0.25D; + }; + NoiseRenderCoordinator.Request preview = new NoiseRenderCoordinator.Request( + 7L, + previewSampler, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.TERRAIN, + 64, + 32, + 4 + ); + NoiseRenderCoordinator.Request refinement = new NoiseRenderCoordinator.Request( + 7L, + (x, z) -> 0.75D, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.TERRAIN, + 64, + 32, + 2 + ); + + coordinator.request(preview); + assertTrue(previewStarted.await(3L, TimeUnit.SECONDS)); + coordinator.request(refinement); + releasePreview.countDown(); + + assertTrue(completed.await(3L, TimeUnit.SECONDS)); + assertEquals(null, failure.get()); + assertEquals(List.of(2), completedSteps); + } finally { + releasePreview.countDown(); + coordinator.close(); + } + } + + @Test + public void samplingRunsOffEdtAndStopsWhenRenderCompletes() throws Exception { + CountDownLatch completed = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + AtomicReference samplingThread = new AtomicReference<>(); + AtomicBoolean sampledOnEdt = new AtomicBoolean(); + AtomicInteger samples = new AtomicInteger(); + NoiseRenderCoordinator coordinator = new NoiseRenderCoordinator(2, new NoiseRenderCoordinator.Listener() { + @Override + public void onRenderStarted(NoiseRenderCoordinator.Request request) { + } + + @Override + public void onRenderCompleted(NoiseRenderCoordinator.Result rendered) { + result.set(rendered); + completed.countDown(); + } + + @Override + public void onRenderFailed(NoiseRenderCoordinator.Request request, Throwable error) { + failure.set(error); + completed.countDown(); + } + }); + try { + NoiseProvider sampler = (x, z) -> { + samples.incrementAndGet(); + samplingThread.compareAndSet(null, Thread.currentThread().getName()); + sampledOnEdt.compareAndSet(false, SwingUtilities.isEventDispatchThread()); + return x + z; + }; + NoiseRenderCoordinator.Request request = new NoiseRenderCoordinator.Request( + 1L, + sampler, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.SIGNED, + 17, + 11, + 1 + ); + + SwingUtilities.invokeAndWait(() -> coordinator.request(request)); + + assertTrue(completed.await(3L, TimeUnit.SECONDS)); + assertEquals(null, failure.get()); + assertNotNull(result.get()); + assertEquals(187, samples.get()); + assertFalse(sampledOnEdt.get()); + assertTrue(samplingThread.get().startsWith("Iris Noise Renderer")); + int completedSamples = samples.get(); + Thread.sleep(150L); + assertEquals(completedSamples, samples.get()); + } finally { + coordinator.close(); + } + } + + @Test + public void samplingTraversesEachBandInRowMajorOrder() throws Exception { + CountDownLatch completed = new CountDownLatch(1); + List coordinates = new ArrayList<>(); + AtomicReference failure = new AtomicReference<>(); + NoiseRenderCoordinator coordinator = new NoiseRenderCoordinator(1, new NoiseRenderCoordinator.Listener() { + @Override + public void onRenderStarted(NoiseRenderCoordinator.Request request) { + } + + @Override + public void onRenderCompleted(NoiseRenderCoordinator.Result result) { + completed.countDown(); + } + + @Override + public void onRenderFailed(NoiseRenderCoordinator.Request request, Throwable error) { + failure.set(error); + completed.countDown(); + } + }); + try { + NoiseProvider sampler = (x, z) -> { + coordinates.add(x + "," + z); + return 0.5D; + }; + coordinator.request(new NoiseRenderCoordinator.Request( + 1L, + sampler, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.TERRAIN, + 6, + 4, + 2 + )); + + assertTrue(completed.await(3L, TimeUnit.SECONDS)); + assertEquals(null, failure.get()); + assertEquals(List.of("-2.0,-1.0", "0.0,-1.0", "2.0,-1.0", "-2.0,1.0", "0.0,1.0", "2.0,1.0"), coordinates); + } finally { + coordinator.close(); + } + } + + @Test + public void newerRevisionDiscardsStaleResult() throws Exception { + CountDownLatch firstSample = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch latestCompleted = new CountDownLatch(1); + List completedRevisions = new ArrayList<>(); + AtomicReference failure = new AtomicReference<>(); + AtomicInteger staleSamples = new AtomicInteger(); + NoiseRenderCoordinator coordinator = new NoiseRenderCoordinator(1, new NoiseRenderCoordinator.Listener() { + @Override + public void onRenderStarted(NoiseRenderCoordinator.Request request) { + } + + @Override + public void onRenderCompleted(NoiseRenderCoordinator.Result result) { + completedRevisions.add(result.request().revision()); + if (result.request().revision() == 2L) { + latestCompleted.countDown(); + } + } + + @Override + public void onRenderFailed(NoiseRenderCoordinator.Request request, Throwable error) { + failure.set(error); + latestCompleted.countDown(); + } + }); + try { + AtomicBoolean block = new AtomicBoolean(true); + NoiseProvider slowSampler = (x, z) -> { + staleSamples.incrementAndGet(); + if (block.compareAndSet(true, false)) { + firstSample.countDown(); + while (releaseFirst.getCount() > 0L) { + Thread.onSpinWait(); + } + } + return 0.25D; + }; + NoiseRenderCoordinator.Request stale = new NoiseRenderCoordinator.Request( + 1L, + slowSampler, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.TERRAIN, + 512, + 1, + 1 + ); + NoiseRenderCoordinator.Request latest = new NoiseRenderCoordinator.Request( + 2L, + (x, z) -> 0.75D, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.TERRAIN, + 512, + 1, + 1 + ); + + coordinator.request(stale); + assertTrue(firstSample.await(3L, TimeUnit.SECONDS)); + coordinator.request(latest); + + assertTrue(latestCompleted.await(3L, TimeUnit.SECONDS)); + assertEquals(null, failure.get()); + assertEquals(List.of(2L), completedRevisions); + assertTrue(staleSamples.get() <= 32); + } finally { + releaseFirst.countDown(); + coordinator.close(); + } + } + + @Test + public void closeReturnsImmediatelyWhenSamplerIgnoresInterruption() throws Exception { + CountDownLatch sampleStarted = new CountDownLatch(1); + AtomicBoolean releaseSampler = new AtomicBoolean(); + AtomicBoolean daemonWorker = new AtomicBoolean(); + NoiseRenderCoordinator coordinator = new NoiseRenderCoordinator(1, new NoiseRenderCoordinator.Listener() { + @Override + public void onRenderStarted(NoiseRenderCoordinator.Request request) { + } + + @Override + public void onRenderCompleted(NoiseRenderCoordinator.Result result) { + } + + @Override + public void onRenderFailed(NoiseRenderCoordinator.Request request, Throwable error) { + } + }); + try { + coordinator.request(new NoiseRenderCoordinator.Request( + 1L, + (x, z) -> { + daemonWorker.set(Thread.currentThread().isDaemon()); + sampleStarted.countDown(); + while (!releaseSampler.get()) { + Thread.onSpinWait(); + } + return 0.5D; + }, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.TERRAIN, + 32, + 32, + 1 + )); + assertTrue(sampleStarted.await(1L, TimeUnit.SECONDS)); + + long started = System.nanoTime(); + coordinator.close(); + long closeMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + + assertTrue(closeMillis < 200L); + assertTrue(daemonWorker.get()); + } finally { + releaseSampler.set(true); + coordinator.close(); + } + } + + @Test + public void latestMultiBandViewCompletesAfterThreeInterruptIgnoringGenerations() throws Exception { + CountDownLatch latestCompleted = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + AtomicBoolean releaseStale = new AtomicBoolean(); + NoiseRenderCoordinator coordinator = new NoiseRenderCoordinator(2, new NoiseRenderCoordinator.Listener() { + @Override + public void onRenderStarted(NoiseRenderCoordinator.Request request) { + } + + @Override + public void onRenderCompleted(NoiseRenderCoordinator.Result result) { + if (result.request().revision() == 4L) { + latestCompleted.countDown(); + } + } + + @Override + public void onRenderFailed(NoiseRenderCoordinator.Request request, Throwable error) { + failure.set(error); + latestCompleted.countDown(); + } + }); + try { + for (long revision = 1L; revision <= 3L; revision++) { + CountDownLatch staleStarted = new CountDownLatch(2); + coordinator.request(new NoiseRenderCoordinator.Request( + revision, + (x, z) -> { + staleStarted.countDown(); + while (!releaseStale.get()) { + Thread.onSpinWait(); + } + return 0.25D; + }, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.TERRAIN, + 8, + 4, + 1 + )); + assertTrue(staleStarted.await(1L, TimeUnit.SECONDS)); + } + + coordinator.request(new NoiseRenderCoordinator.Request( + 4L, + (x, z) -> 0.75D, + new NoiseViewport(0D, 0D, 1D), + NoisePalette.TERRAIN, + 8, + 4, + 1 + )); + + assertTrue(latestCompleted.await(3L, TimeUnit.SECONDS)); + assertEquals(null, failure.get()); + } finally { + releaseStale.set(true); + coordinator.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/NoiseViewportTest.java b/core/src/test/java/art/arcane/iris/core/gui/NoiseViewportTest.java new file mode 100644 index 000000000..96e86f63b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/NoiseViewportTest.java @@ -0,0 +1,31 @@ +package art.arcane.iris.core.gui; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class NoiseViewportTest { + @Test + public void zoomKeepsCursorWorldPositionFixed() { + NoiseViewport viewport = new NoiseViewport(120D, -35D, 2.5D); + double worldX = viewport.worldX(713D, 1200); + double worldZ = viewport.worldZ(147D, 700); + + NoiseViewport zoomed = viewport.zoomAt(713D, 147D, 1200, 700, 0.4D); + + assertEquals(worldX, zoomed.worldX(713D, 1200), 0.000000001D); + assertEquals(worldZ, zoomed.worldZ(147D, 700), 0.000000001D); + assertEquals(1D, zoomed.blocksPerPixel(), 0D); + } + + @Test + public void panMovesCenterByPixelDeltaAtCurrentScale() { + NoiseViewport viewport = new NoiseViewport(10D, 20D, 2D); + + NoiseViewport panned = viewport.panPixels(7D, -3D); + + assertEquals(-4D, panned.centerX(), 0D); + assertEquals(26D, panned.centerZ(), 0D); + assertEquals(2D, panned.blocksPerPixel(), 0D); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/VisionRenderControllerTest.java b/core/src/test/java/art/arcane/iris/core/gui/VisionRenderControllerTest.java new file mode 100644 index 000000000..14e63055e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/VisionRenderControllerTest.java @@ -0,0 +1,263 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.engine.framework.render.IrisRenderer; +import art.arcane.iris.engine.framework.render.RenderType; +import org.junit.Test; + +import java.awt.image.BufferedImage; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class VisionRenderControllerTest { + @Test + public void visibleTilesAreUniqueAndOrderedCenterFirst() { + VisionRenderController.RenderSpec spec = spec(RenderType.BIOME, 7L, 0D, 0D, 4D, 1440, 792); + + List tiles = VisionRenderController.visibleTiles(spec); + + assertEquals(336, tiles.size()); + Set coordinates = new HashSet<>(); + double previousDistance = -1D; + for (VisionRenderController.VisibleTile tile : tiles) { + assertTrue(coordinates.add(tile.tileX() + ":" + tile.tileZ())); + assertTrue(tile.distanceSquared() >= previousDistance); + previousDistance = tile.distanceSquared(); + } + assertEquals(1_376_256L, VisionRenderController.sampleCount(tiles.size())); + } + + @Test + public void negativeWorldCoordinatesUseFloorBasedTileOwnership() { + VisionRenderController.RenderSpec spec = spec(RenderType.BIOME, 1L, -32D, -32D, 1D, 1, 1); + + List tiles = VisionRenderController.visibleTiles(spec); + + assertEquals(1, tiles.size()); + assertEquals(-1L, tiles.get(0).tileX()); + assertEquals(-1L, tiles.get(0).tileZ()); + } + + @Test + public void cacheIdentityContainsOnlyAtlasGenerationModeZoomAndWorldPage() { + VisionRenderController.TileKey baseline = new VisionRenderController.TileKey(4L, RenderType.BIOME, 4D, 8L, 9L); + + assertNotEquals(baseline, new VisionRenderController.TileKey(5L, RenderType.BIOME, 4D, 8L, 9L)); + assertNotEquals(baseline, new VisionRenderController.TileKey(4L, RenderType.RIVER, 4D, 8L, 9L)); + assertNotEquals(baseline, new VisionRenderController.TileKey(4L, RenderType.BIOME, 4.5D, 8L, 9L)); + assertNotEquals(baseline, new VisionRenderController.TileKey(4L, RenderType.BIOME, 4D, 7L, 9L)); + } + + @Test + public void everyPublishedPageIsFinalResolutionAndIdenticalRequestsAreCacheHits() throws Exception { + IrisRenderer renderer = mock(IrisRenderer.class); + AtomicInteger calls = new AtomicInteger(); + when(renderer.renderStudio( + anyDouble(), + anyDouble(), + anyDouble(), + anyInt(), + any(RenderType.class), + any(BooleanSupplier.class) + )).thenAnswer(invocation -> { + assertEquals(VisionRenderController.TILE_PIXELS, (int) invocation.getArgument(3)); + calls.incrementAndGet(); + return new BufferedImage( + VisionRenderController.TILE_PIXELS, + VisionRenderController.TILE_PIXELS, + BufferedImage.TYPE_INT_RGB + ); + }); + VisionRenderController controller = controller(2); + VisionRenderController.RenderSpec spec = spec(renderer, RenderType.BIOME, 1L, 0D, 0D, 4D, 128, 128); + try { + VisionRenderController.Frame first = controller.request(spec); + assertTrue(await(() -> controller.progress(first).ready() == controller.progress(first).total(), 2_000L)); + int rendered = calls.get(); + + VisionRenderController.Frame cached = controller.request(spec); + + assertEquals(rendered, calls.get()); + assertEquals(controller.progress(cached).total(), controller.progress(cached).ready()); + for (VisionRenderController.VisibleTile tile : cached.tiles()) { + assertEquals(VisionRenderController.TILE_PIXELS, controller.image(cached, tile).getWidth()); + assertEquals(VisionRenderController.TILE_PIXELS, controller.image(cached, tile).getHeight()); + } + } finally { + controller.close(); + } + } + + @Test + public void smallPanReusesEveryOverlappingWorldPage() throws Exception { + IrisRenderer renderer = exactRenderer(); + VisionRenderController controller = controller(2); + try { + VisionRenderController.Frame first = controller.request(spec(renderer, RenderType.BIOME, 1L, 16D, 16D, 4D, 128, 128)); + assertTrue(await(() -> controller.progress(first).ready() == controller.progress(first).total(), 2_000L)); + Set firstKeys = keys(first); + + VisionRenderController.Frame panned = controller.request(spec(renderer, RenderType.BIOME, 1L, 20D, 20D, 4D, 128, 128)); + + assertEquals(firstKeys, keys(panned)); + assertEquals(panned.tiles().size(), controller.progress(panned).ready()); + for (VisionRenderController.VisibleTile pannedTile : panned.tiles()) { + VisionRenderController.VisibleTile original = tile(first, panned.key(pannedTile)); + assertSame(controller.image(first, original), controller.image(panned, pannedTile)); + } + } finally { + controller.close(); + } + } + + @Test + public void newerViewPublishesWithoutWaitingForCancelledQueuedWork() throws Exception { + IrisRenderer renderer = mock(IrisRenderer.class); + CountDownLatch staleStarted = new CountDownLatch(1); + CountDownLatch latestStarted = new CountDownLatch(1); + when(renderer.renderStudio( + anyDouble(), + anyDouble(), + anyDouble(), + anyInt(), + any(RenderType.class), + any(BooleanSupplier.class) + )).thenAnswer(invocation -> { + RenderType type = invocation.getArgument(4); + BooleanSupplier cancelled = invocation.getArgument(5); + if (type == RenderType.REGION) { + staleStarted.countDown(); + while (!cancelled.getAsBoolean()) { + Thread.onSpinWait(); + } + throw new java.util.concurrent.CancellationException(); + } + latestStarted.countDown(); + return new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); + }); + VisionRenderController controller = controller(2); + try { + controller.request(spec(renderer, RenderType.REGION, 1L, 0D, 0D, 4D, 64, 64)); + assertTrue(staleStarted.await(1L, TimeUnit.SECONDS)); + + controller.request(spec(renderer, RenderType.BIOME, 1L, 0D, 0D, 4D, 64, 64)); + + assertTrue(latestStarted.await(1L, TimeUnit.SECONDS)); + } finally { + controller.close(); + } + } + + private static VisionRenderController controller(int workers) { + return new VisionRenderController( + () -> { + }, + new VisionRenderController.RuntimeOptions(workers, 8, false) + ); + } + + private static IrisRenderer exactRenderer() { + IrisRenderer renderer = mock(IrisRenderer.class); + when(renderer.renderStudio( + anyDouble(), + anyDouble(), + anyDouble(), + anyInt(), + any(RenderType.class), + any(BooleanSupplier.class) + )).thenAnswer(invocation -> new BufferedImage( + invocation.getArgument(3), + invocation.getArgument(3), + BufferedImage.TYPE_INT_RGB + )); + return renderer; + } + + private static Set keys(VisionRenderController.Frame frame) { + Set keys = new HashSet<>(); + for (VisionRenderController.VisibleTile tile : frame.tiles()) { + keys.add(frame.key(tile)); + } + return keys; + } + + private static VisionRenderController.VisibleTile tile( + VisionRenderController.Frame frame, + VisionRenderController.TileKey key + ) { + for (VisionRenderController.VisibleTile tile : frame.tiles()) { + if (frame.key(tile).equals(key)) { + return tile; + } + } + throw new AssertionError("Missing tile " + key); + } + + private static VisionRenderController.RenderSpec spec( + RenderType type, + long revision, + double centerX, + double centerZ, + double blocksPerPixel, + int width, + int height + ) { + return new VisionRenderController.RenderSpec( + mock(IrisRenderer.class), + type, + revision, + centerX, + centerZ, + blocksPerPixel, + width, + height + ); + } + + private static VisionRenderController.RenderSpec spec( + IrisRenderer renderer, + RenderType type, + long revision, + double centerX, + double centerZ, + double blocksPerPixel, + int width, + int height + ) { + return new VisionRenderController.RenderSpec( + renderer, + type, + revision, + centerX, + centerZ, + blocksPerPixel, + width, + height + ); + } + + private static boolean await(BooleanSupplier condition, long timeoutMillis) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + while (System.nanoTime() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + Thread.sleep(5L); + } + return condition.getAsBoolean(); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/VisionRenderTypeTest.java b/core/src/test/java/art/arcane/iris/core/gui/VisionRenderTypeTest.java new file mode 100644 index 000000000..d497de073 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/VisionRenderTypeTest.java @@ -0,0 +1,33 @@ +package art.arcane.iris.core.gui; + +import art.arcane.iris.core.localization.DesktopUiMessages; +import art.arcane.iris.engine.framework.render.RenderType; +import art.arcane.volmlib.util.localization.MessageKey; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +public class VisionRenderTypeTest { + @Test + public void everyRenderTypeHasALocalizedVisionLabel() { + for (RenderType type : RenderType.values()) { + MessageKey key = VisionGUI.modeKey(type); + assertNotNull(type.name(), key); + assertTrueCatalogMember(key); + } + } + + @Test + public void riverRenderTypeUsesTheRiverNetworkLabel() { + assertSame(DesktopUiMessages.VISION_MODE_RIVER, VisionGUI.modeKey(RenderType.RIVER)); + assertEquals("iris.desktop.vision.mode.river", DesktopUiMessages.VISION_MODE_RIVER.id()); + } + + private static void assertTrueCatalogMember(MessageKey key) { + assertEquals(key.id(), 1, DesktopUiMessages.keys().stream() + .filter(candidate -> candidate.id().equals(key.id())) + .count()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/gui/VisionViewportTest.java b/core/src/test/java/art/arcane/iris/core/gui/VisionViewportTest.java new file mode 100644 index 000000000..29547c91a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/VisionViewportTest.java @@ -0,0 +1,33 @@ +package art.arcane.iris.core.gui; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class VisionViewportTest { + @Test + public void granularZoomKeepsTheCursorWorldCoordinateFixed() { + VisionViewport viewport = new VisionViewport(-21_672D, -10_148D, 256D); + double cursorWorldX = viewport.worldX(1_194D, 1_440); + double cursorWorldZ = viewport.worldZ(283D, 792); + + VisionViewport zoomed = viewport.zoomAt(1_194D, 283D, 1_440, 792, Math.pow(2D, -0.08D), 1D, 4_096D); + + assertEquals(cursorWorldX, zoomed.worldX(1_194D, 1_440), 0.000000001D); + assertEquals(cursorWorldZ, zoomed.worldZ(283D, 792), 0.000000001D); + assertEquals(242.19075756175255D, zoomed.blocksPerPixel(), 0.000000001D); + } + + @Test + public void zoomClampingStillKeepsTheCursorAnchored() { + VisionViewport viewport = new VisionViewport(800D, -400D, 2D); + double cursorWorldX = viewport.worldX(925D, 1_200); + double cursorWorldZ = viewport.worldZ(127D, 700); + + VisionViewport zoomed = viewport.zoomAt(925D, 127D, 1_200, 700, 0.001D, 1D, 4_096D); + + assertEquals(1D, zoomed.blocksPerPixel(), 0D); + assertEquals(cursorWorldX, zoomed.worldX(925D, 1_200), 0.000000001D); + assertEquals(cursorWorldZ, zoomed.worldZ(127D, 700), 0.000000001D); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/nms/v1X/NMSBinding1XWorldGenerationTest.java b/core/src/test/java/art/arcane/iris/core/nms/v1X/NMSBinding1XWorldGenerationTest.java index 87caeb608..b8538cb8e 100644 --- a/core/src/test/java/art/arcane/iris/core/nms/v1X/NMSBinding1XWorldGenerationTest.java +++ b/core/src/test/java/art/arcane/iris/core/nms/v1X/NMSBinding1XWorldGenerationTest.java @@ -4,6 +4,9 @@ import art.arcane.iris.core.nms.INMSBinding; import org.bukkit.World; import org.junit.Test; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -30,6 +33,19 @@ public class NMSBinding1XWorldGenerationTest { assertFalse(INMSBinding.class.getMethod("getPossibleBiomeKeys", World.class).isDefault()); } + @Test + public void limitedBindingReportsCompletedStudioStructureBootstrap() throws NoSuchMethodException { + NMSBinding1X binding = new NMSBinding1X(); + + CompletableFuture completion = binding.completeStudioStructureBootstrap(null); + + assertEquals(CompletableFuture.class, INMSBinding.class + .getMethod("completeStudioStructureBootstrap", World.class) + .getReturnType()); + assertTrue(completion.isDone()); + assertFalse(completion.isCompletedExceptionally()); + } + @Test public void limitedBindingRefusesStructureRegistryHooks() { NMSBinding1X binding = new NMSBinding1X(); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java new file mode 100644 index 000000000..cfccdb1af --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java @@ -0,0 +1,620 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackRiverValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsEnabledNetworkWithFallbackBiomePools() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "logicalHeight": 256, + "rivers": { + "enabled": true, + "biomes": { + "channel": [], + "bank": [], + "mouth": [], + "dry": [], + "floodedCave": [] + } + } + } + """); + + PackValidationResult result = PackValidator.validate(pack); + + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + } + + @Test + public void rejectsNullEnabledNetworkSections() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "topology": null, + "terrain": null, + "water": null, + "biomes": null, + "caves": null + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "rivers.topology must be an object."); + assertContains(result.errors(), "rivers.terrain must be an object."); + assertContains(result.errors(), "rivers.water must be an object."); + assertContains(result.errors(), "rivers.biomes must be an object."); + assertContains(result.errors(), "rivers.caves must be an object."); + } + + @Test + public void rejectsInvalidFiniteRangesAndMalformedStyles() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "topology": { + "siteJitter": 1e400, + "tileCells": 1, + "minimumSourcesPerTile": 2, + "sinkSearchReaches": 8, + "routingBasinCells": 7, + "routingPlateauHeight": 0, + "routingStyle": {"zoom": 0} + }, + "terrain": { + "channelWidth": {"min": 40, "max": 12}, + "depth": {} + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "rivers.topology.siteJitter must be a number."); + assertContains(result.errors(), "rivers.topology.minimumSourcesPerTile must not exceed tileCells squared."); + assertContains(result.errors(), "rivers.topology.sinkSearchReaches must be at most 7"); + assertContains(result.errors(), "rivers.topology.routingBasinCells must be at least 8"); + assertContains(result.errors(), "rivers.topology.routingPlateauHeight must be at least 1"); + assertContains(result.errors(), "rivers.topology.routingStyle.zoom must be at least"); + assertContains(result.errors(), "rivers.terrain.channelWidth.min must not exceed"); + assertContains(result.errors(), "rivers.terrain.depth must set min and max explicitly."); + } + + @Test + public void rejectsTerraceDropsAbovePermittedRise() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "water": { + "mode": "TERRACED", + "maximumPoolRise": 2, + "dropHeight": 3 + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "rivers.water.dropHeight must not exceed maximumPoolRise"); + } + + @Test + public void rejectsPathologicalCombinedTopologyComplexity() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "topology": { + "cellSize": 64, + "tileCells": 64, + "siteJitter": 0.49, + "maxRouteReaches": 256 + }, + "terrain": { + "channelWidth": {"min": 2048, "max": 2048}, + "bankWidth": {"min": 2048, "max": 2048}, + "orderWidthFactor": 8, + "meanderStrength": 1024, + "meanderSubdivisions": 64 + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "exceeds the safe derived complexity budget"); + assertContains(result.errors(), "source window requires"); + assertContains(result.errors(), "increase cellSize or reduce tileCells"); + } + + @Test + public void rejectsRiverGeometryCapsOutsideSupportedRanges() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": { + "maxChannelWidth": 0, + "maxBankWidth": -1, + "maxDepth": 513 + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "rivers.terrain.maxChannelWidth"); + assertContains(result.errors(), "rivers.terrain.maxBankWidth"); + assertContains(result.errors(), "rivers.terrain.maxDepth"); + } + + @Test + public void rejectsMissingBiomeReferencesAndWarnsAboutInferredRoles() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "biomes": { + "channel": ["biome", "missing"], + "bank": ["biome"] + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "references missing biome 'missing'"); + assertContains(result.warnings(), "inferred river role SEA"); + assertContains(result.warnings(), "inferred river role SHORE"); + } + + @Test + public void validatesRegionAndBiomeOverridesWhenRiversAreEnabled() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": {"enabled": true} + } + """); + write(pack, "regions/region.json", """ + { + "landBiomes": ["biome"], + "riverOverride": { + "routingCostMultiplier": -1, + "channelBiomes": ["missing"] + } + } + """); + write(pack, "biomes/biome.json", """ + { + "name": "Biome", + "riverOverride": { + "allowSources": "yes", + "bankBiomes": [] + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "Region 'region' riverOverride.routingCostMultiplier must be at least"); + assertContains(result.errors(), "Region 'region' riverOverride.channelBiomes[0] references missing biome"); + assertContains(result.errors(), "Biome 'biome' riverOverride.allowSources must be a boolean"); + } + + @Test + public void rejectsZeroChannelAndDepthMultipliersInsteadOfRestoringFallbackGeometry() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": {"enabled": true} + } + """); + write(pack, "regions/region.json", """ + { + "landBiomes": ["biome"], + "riverOverride": { + "widthMultiplier": 0, + "depthMultiplier": 0 + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "riverOverride.widthMultiplier must be at least 1.0E-4"); + assertContains(result.errors(), "riverOverride.depthMultiplier must be at least 1.0E-4"); + } + + @Test + public void rejectsMathematicallyImpossibleGeneratedGrotto() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "caves": { + "mode": "GENERATE_GROTTO", + "throatRadius": 4, + "grottoHorizontalRadius": 4, + "grottoVerticalRadius": 4, + "grottoWarpStrength": 3, + "dryHeadroom": 9, + "maxFloodRadius": 6, + "maxFloodDepth": 6, + "maxFloodVolume": 64 + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "throatRadius must be smaller than both grotto radii"); + assertContains(result.errors(), "dryHeadroom must fit inside the generated grotto height"); + assertContains(result.errors(), "maxFloodRadius must be at least 8"); + assertContains(result.errors(), "maxFloodDepth must be at least 8"); + assertContains(result.errors(), "maxFloodVolume must be at least"); + } + + @Test + public void warnsWhenActiveCaveEntryGateCannotProduceConnections() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "caves": { + "mode": "FLOOD_CLOSED_COMPONENT", + "maximumPerReach": 0, + "maxBoreDepth": 64, + "maxFloodDepth": 32 + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.warnings(), "entry gate cannot accept any connections"); + assertContains(result.warnings(), "maxBoreDepth exceeds maxFloodDepth"); + } + + @Test + public void rejectsSinkholeTerminalWithoutCaveHydrology() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": {"terminalMode": "SINKHOLE_GROTTO"}, + "caves": {"mode": "SEALED"} + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "SINKHOLE_GROTTO requires a non-SEALED caves.mode"); + } + + @Test + public void rejectsSinkholeTerminalWhenPerReachCapDisablesItsForcedAnchor() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": {"terminalMode": "SINKHOLE_GROTTO"}, + "caves": { + "mode": "GENERATE_GROTTO", + "maximumPerReach": 0 + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "SINKHOLE_GROTTO requires caves.maximumPerReach above zero"); + } + + @Test + public void rejectsSinkholeTerminalWhenCarvingIsDisabled() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "carvingEnabled": false, + "rivers": { + "enabled": true, + "terrain": {"terminalMode": "SINKHOLE_GROTTO"}, + "caves": {"mode": "GENERATE_GROTTO"} + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "SINKHOLE_GROTTO requires carvingEnabled to be true"); + } + + @Test + public void forcedSinkholeValidatesGrottoEnvelopeInClosedComponentModeOnce() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "terrain": {"terminalMode": "SINKHOLE_GROTTO"}, + "caves": { + "mode": "FLOOD_CLOSED_COMPONENT", + "fallback": "SEALED", + "grottoHorizontalRadius": 12, + "grottoWarpStrength": 2, + "maxFloodRadius": 12 + } + } + } + """); + + PackRiverValidator.Validation result = validate(pack); + + String fragment = "rivers.caves.maxFloodRadius must be at least 15"; + assertContains(result.errors(), fragment); + assertTrue(result.errors().toString(), count(result.errors(), fragment) == 1); + } + + @Test + public void rejectsReachableRegionSinkholeAgainstInactiveDimensionHydrology() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "caves": {"mode": "SEALED"} + } + } + """); + write(pack, "regions/region.json", """ + { + "landBiomes": ["biome"], + "riverOverride": {"terminalMode": "SINKHOLE_GROTTO"} + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "Region 'region' riverOverride.terminalMode SINKHOLE_GROTTO requires a non-SEALED caves.mode"); + } + + @Test + public void rejectsReachableChildBiomeSinkholeAgainstDisabledCarving() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "carvingEnabled": false, + "rivers": { + "enabled": true, + "caves": {"mode": "GENERATE_GROTTO"} + } + } + """); + write(pack, "biomes/biome.json", """ + { + "name": "Biome", + "derivative": "minecraft:plains", + "children": ["child"] + } + """); + write(pack, "biomes/child.json", """ + { + "name": "Child", + "derivative": "minecraft:forest", + "riverOverride": {"terminalMode": "SINKHOLE_GROTTO"} + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "Biome 'child' riverOverride.terminalMode SINKHOLE_GROTTO requires carvingEnabled to be true"); + } + + @Test + public void rejectsTransitiveFinalTerrainExpressionDependency() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "topology": { + "routingStyle": {"expression": "route"} + } + } + } + """); + write(pack, "expressions/route.json", """ + { + "variables": [ + { + "name": "nested", + "styleValue": {"expression": "terrain"} + } + ], + "expression": "nested" + } + """); + write(pack, "expressions/terrain.json", """ + { + "functions": [ + { + "name": "height", + "engineStreamValue": "HEIGHT" + } + ], + "expression": "height(x,z)" + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "engineStreamValue 'HEIGHT'"); + assertContains(result.errors(), "would recurse during river generation"); + } + + @Test + public void rejectsFinalTerrainDependencyInsideExpressionEntrySnippet() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "topology": { + "routingStyle": {"expression": "route"} + } + } + } + """); + write(pack, "expressions/route.json", """ + { + "variables": ["snippet/expression-load/final-height"], + "expression": "height" + } + """); + write(pack, "snippet/expression-load/final-height.json", """ + { + "name": "height", + "engineStreamValue": "HEIGHT_OR_FLUID" + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "engineStreamValue 'HEIGHT_OR_FLUID'"); + } + + @Test + public void rejectsCyclicStyleSnippetDependenciesWithoutRecursing() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "topology": { + "routingStyle": "snippet/style/first" + } + } + } + """); + write(pack, "snippet/style/first.json", "{\"fracture\":\"snippet/style/second\"}"); + write(pack, "snippet/style/second.json", "{\"fracture\":\"snippet/style/first\"}"); + + PackRiverValidator.Validation result = validate(pack); + + assertContains(result.errors(), "cyclic river-noise style snippet dependency"); + } + + @Test + public void acceptsNaturalHeightExpressionDependency() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "rivers": { + "enabled": true, + "topology": { + "routingStyle": {"expression": "route"} + } + } + } + """); + write(pack, "expressions/route.json", """ + { + "variables": [ + { + "name": "height", + "engineStreamValue": "NATURAL_HEIGHT" + } + ], + "expression": "height" + } + """); + + PackRiverValidator.Validation result = validate(pack); + + assertFalse(result.errors().toString(), contains(result.errors(), "engineStreamValue")); + } + + private PackRiverValidator.Validation validate(File pack) { + File[] dimensions = new File(pack, "dimensions").listFiles( + file -> file.isFile() && file.getName().endsWith(".json")); + return PackRiverValidator.validate(pack, dimensions); + } + + private File pack(String dimensionJson) throws Exception { + File pack = temporaryFolder.newFolder("pack-" + System.nanoTime()); + write(pack, "dimensions/main.json", dimensionJson); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", + "{\"name\":\"Biome\",\"derivative\":\"minecraft:plains\"}"); + return pack; + } + + private void assertContains(List messages, String fragment) { + assertTrue(messages.toString(), contains(messages, fragment)); + } + + private boolean contains(List messages, String fragment) { + for (String message : messages) { + if (message.contains(fragment)) { + return true; + } + } + return false; + } + + private int count(List messages, String fragment) { + int matches = 0; + for (String message : messages) { + if (message.contains(fragment)) { + matches++; + } + } + return matches; + } + + private void write(File root, String relative, String content) throws Exception { + Path target = new File(root, relative).toPath(); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java new file mode 100644 index 000000000..e65427f65 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java @@ -0,0 +1,103 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisExpression; +import art.arcane.iris.engine.object.IrisRiverNetwork; +import art.arcane.iris.engine.object.IrisRiverOverride; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisRiverSchemaTest { + @Test + public void riverNetworkSchemaExposesNestedNoiseLimitsModesAndBiomePools() { + JSONObject schema = new SchemaBuilder(IrisRiverNetwork.class, schemaData()).construct(); + JSONObject definitions = schema.getJSONObject("definitions"); + JSONObject properties = schema.getJSONObject("properties"); + JSONObject topology = referencedProperties(definitions, properties.getJSONObject("topology")); + JSONObject source = referencedProperties(definitions, topology.getJSONObject("source")); + JSONObject terrain = referencedProperties(definitions, properties.getJSONObject("terrain")); + JSONObject water = referencedProperties(definitions, properties.getJSONObject("water")); + JSONObject biomes = referencedProperties(definitions, properties.getJSONObject("biomes")); + + assertEquals("boolean", properties.getJSONObject("enabled").getString("type")); + assertEquals(64, topology.getJSONObject("cellSize").getInt("minimum")); + assertEquals(4096, topology.getJSONObject("cellSize").getInt("maximum")); + assertEquals(7, topology.getJSONObject("sinkSearchReaches").getInt("maximum")); + assertEquals(8, topology.getJSONObject("routingBasinCells").getInt("minimum")); + assertEquals(256, topology.getJSONObject("routingBasinCells").getInt("maximum")); + assertEquals(0D, source.getJSONObject("chance").getDouble("minimum"), 0D); + assertEquals(1D, source.getJSONObject("chance").getDouble("maximum"), 0D); + assertEquals(1D, terrain.getJSONObject("maxChannelWidth").getDouble("minimum"), 0D); + assertEquals(2048D, terrain.getJSONObject("maxChannelWidth").getDouble("maximum"), 0D); + assertEquals(0D, terrain.getJSONObject("maxBankWidth").getDouble("minimum"), 0D); + assertEquals(512D, terrain.getJSONObject("maxDepth").getDouble("maximum"), 0D); + assertEquals(List.of("SEA_LEVEL", "TERRACED"), enumValues(definitions, water.getJSONObject("mode"))); + assertEquals("array", biomes.getJSONObject("channel").getString("type")); + assertEquals("#/definitions/erzbiomes", + biomes.getJSONObject("channel").getJSONObject("items").getString("$ref")); + assertTrue(properties.has("terrain")); + assertTrue(properties.has("caves")); + } + + @Test + public void overrideSchemaKeepsEveryFieldOptionalAndTyped() { + JSONObject schema = new SchemaBuilder(IrisRiverOverride.class, schemaData()).construct(); + JSONObject properties = schema.getJSONObject("properties"); + + assertTrue(!schema.has("required") || schema.getJSONArray("required").length() == 0); + assertEquals("boolean", properties.getJSONObject("allowSources").getString("type")); + assertEquals("number", properties.getJSONObject("routingCostMultiplier").getString("type")); + assertEquals("array", properties.getJSONObject("channelBiomes").getString("type")); + assertEquals("#/definitions/erzbiomes", + properties.getJSONObject("floodedCaveBiomes").getJSONObject("items").getString("$ref")); + } + + @SuppressWarnings("unchecked") + private static IrisData schemaData() { + IrisData data = mock(IrisData.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + ResourceLoader expressionLoader = mock(ResourceLoader.class); + KMap, ResourceLoader> loaders = new KMap<>(); + loaders.put(IrisBiome.class, biomeLoader); + loaders.put(IrisExpression.class, expressionLoader); + when(data.getLoaders()).thenReturn(loaders); + when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>()); + when(biomeLoader.getPossibleKeys()).thenReturn(new String[]{"river/channel"}); + when(biomeLoader.getFolderName()).thenReturn("biomes"); + when(biomeLoader.getResourceTypeName()).thenReturn("Biome"); + when(expressionLoader.getPossibleKeys()).thenReturn(new String[0]); + when(expressionLoader.getFolderName()).thenReturn("expressions"); + when(expressionLoader.getResourceTypeName()).thenReturn("Expression"); + return data; + } + + private static JSONObject referencedProperties(JSONObject definitions, JSONObject reference) { + String key = reference.getString("$ref").substring("#/definitions/".length()); + return definitions.getJSONObject(key).getJSONObject("properties"); + } + + private static List enumValues(JSONObject definitions, JSONObject reference) { + String key = reference.getString("$ref").substring("#/definitions/".length()); + JSONArray values = definitions.getJSONObject(key).getJSONArray("oneOf"); + List names = new ArrayList<>(values.length()); + for (int index = 0; index < values.length(); index++) { + names.add(values.getJSONObject(index).getString("const")); + } + return names; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java index 07f5f6660..284bcbc0f 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java @@ -62,6 +62,85 @@ public class StudioOpenCoordinatorOpenKindTest { assertEquals(StudioOpenCoordinator.StudioOpenKind.JIGSAW, request.openKind()); } + @Test + public void onlyAStandardPlayerOpenLoadsTheEntry() { + assertTrue(StudioOpenCoordinator.requiresLoadedEntry(request( + StudioOpenCoordinator.StudioOpenKind.STANDARD, + "Magic_Psycho"))); + assertFalse(StudioOpenCoordinator.requiresLoadedEntry(request( + StudioOpenCoordinator.StudioOpenKind.STANDARD, + null))); + assertFalse(StudioOpenCoordinator.requiresLoadedEntry(request( + StudioOpenCoordinator.StudioOpenKind.STANDARD, + " "))); + assertFalse(StudioOpenCoordinator.requiresLoadedEntry(request( + StudioOpenCoordinator.StudioOpenKind.JIGSAW, + "Magic_Psycho"))); + } + + @Test + public void activeStudioTeleportIsSerializedAndBoundedBeforeNativeDelegation() throws Exception { + String coordinator = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")) + .replace("\r\n", "\n"); + String service = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/service/StudioSVC.java")) + .replace("\r\n", "\n"); + int coordinatorStart = coordinator.indexOf( + "public CompletableFuture teleportPlayerToProject("); + int coordinatorEnd = coordinator.indexOf("private void executeOpen", coordinatorStart); + String coordinatorMethod = coordinator.substring(coordinatorStart, coordinatorEnd); + int serviceStart = service.indexOf( + "public CompletableFuture teleportToActiveProject(Player player)"); + int serviceEnd = service.indexOf("public void open(VolmitSender", serviceStart); + String serviceMethod = service.substring(serviceStart, serviceEnd); + int transitionAdmission = serviceMethod.indexOf("studioTransitions.submit(() ->"); + int projectCapture = serviceMethod.indexOf("IrisProject project = activeProject"); + int publicDeadline = serviceMethod.indexOf( + "transition.orTimeout(STUDIO_PLAYER_TELEPORT_TIMEOUT_SECONDS"); + int admissionClose = serviceMethod.indexOf( + "transition.whenComplete((ignored, failure) -> admission.set(false))"); + int nativeClaim = coordinatorMethod.indexOf( + "activeAdmission.compareAndSet(true, false)"); + int nativeDelegation = coordinatorMethod.indexOf( + "WorldRuntimeControlService.get().teleport(player, entry)"); + + assertTrue(transitionAdmission >= 0); + assertTrue(projectCapture > transitionAdmission); + assertTrue(publicDeadline > projectCapture); + assertTrue(admissionClose > publicDeadline); + assertTrue(serviceMethod.contains("deadlineNanos")); + assertTrue(coordinatorMethod.contains("beforeStudioTeleportDeadline(")); + assertTrue(coordinatorMethod.contains("CompletableFuture bounded = new CompletableFuture<>()")); + assertTrue(coordinatorMethod.contains( + "CompletableFuture.delayedExecutor(remainingNanos, TimeUnit.NANOSECONDS)")); + assertFalse(coordinatorMethod.contains("stage.orTimeout(")); + assertTrue(coordinatorMethod.contains("EntryChunkResolution entryResolution")); + assertFalse(coordinatorMethod.contains("addPluginChunkTicket(")); + assertFalse(coordinatorMethod.contains("removePluginChunkTicket(")); + assertTrue(nativeClaim >= 0); + assertTrue(nativeDelegation > nativeClaim); + } + + @Test + public void entryLoadUsesOnlyTheUrgentExactChunkRequest() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")) + .replace("\r\n", "\n"); + int loadStart = source.indexOf("private EntryChunkResolution loadEntryChunk("); + int loadEnd = source.indexOf("private void settleEntryUseAfterOperation(", loadStart); + String load = source.substring(loadStart, loadEnd); + + assertTrue(load.contains("requestChunkAsync(")); + assertTrue(load.contains("true,\n true")); + assertTrue(load.contains("J.isOwnedByCurrentRegion(world, chunkX, chunkZ)")); + assertTrue(load.contains("J.runRegion(world, chunkX, chunkZ")); + assertTrue(load.contains("WorldRuntimeControlService.findTopSafeStudioLocation(world, entryAnchor)")); + assertFalse(source.contains("resolveSafeEntry(world, entryAnchor)")); + assertFalse(source.contains("addPluginChunkTicket(")); + assertFalse(source.contains("removePluginChunkTicket(")); + } + @Test public void studioTimingSeparatesOrderedLifecyclePhases() throws Exception { String source = Files.readString(Path.of( @@ -73,6 +152,8 @@ public class StudioOpenCoordinatorOpenKindTest { "apply_world_rules", "prepare_generator", "resolve_entry_anchor", + "prepare_structure_rings", + "prepare_entry_chunks", "load_entry_chunk", "resolve_safe_entry", "teleport_standard_entry", @@ -85,16 +166,16 @@ public class StudioOpenCoordinatorOpenKindTest { } @Test - public void structureStateActivatesAfterStandardTeleportBeforeFinalization() throws Exception { + public void structureStateActivatesBeforeTheRealEntryChunkLoads() throws Exception { String source = Files.readString(Path.of( "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")).replace("\r\n", "\n"); + int completionCall = source.indexOf("endStudioEntryBootstrap(world, provider)"); int entryReady = source.indexOf( - "entryLoadFuture.get(STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)"); + "entryResolution.chunk().get(STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)", completionCall); int teleport = source.indexOf( "WorldRuntimeControlService.get().teleport(player, safeEntry)", entryReady); - int completionCall = source.indexOf("endStudioEntryBootstrap(world, provider)", teleport); int finalizeOpen = source.indexOf( - "updateStage(request, \"finalize_open\", 1.00D)", completionCall); + "updateStage(request, \"finalize_open\", 1.00D)", teleport); int futureComplete = source.indexOf( "future.complete(new StudioOpenResult(world, safeEntry))", finalizeOpen); int methodStart = source.indexOf( @@ -104,17 +185,20 @@ public class StudioOpenCoordinatorOpenKindTest { int scheduled = method.indexOf("J.sfut(() ->"); int claim = method.indexOf("activationClaim.compareAndSet(true, false)"); int activation = method.indexOf("INMS.get().completeStudioStructureBootstrap(world)"); - int gateRelease = method.indexOf("bukkitGenerator.endStudioEntryBootstrap()"); + int gateRelease = method.indexOf("bukkitGenerator::endStudioEntryBootstrap"); - assertTrue(entryReady >= 0); + assertTrue(completionCall >= 0); + assertTrue(entryReady > completionCall); assertTrue(teleport > entryReady); - assertTrue(completionCall > teleport); - assertTrue(finalizeOpen > completionCall); + assertTrue(finalizeOpen > teleport); assertTrue(futureComplete > finalizeOpen); assertTrue(scheduled >= 0); assertTrue(claim > scheduled); assertTrue(activation > claim); assertTrue(gateRelease > activation); + int ringCompletion = method.indexOf("thenCompose(nativeActivation -> nativeActivation)"); + assertTrue(ringCompletion > activation); + assertTrue(gateRelease > ringCompletion); assertTrue(source.contains("abandonStudioEntryBootstrap(world, e);")); } @@ -176,4 +260,21 @@ public class StudioOpenCoordinatorOpenKindTest { assertTrue(method.contains( "completion.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)")); } + + private static StudioOpenCoordinator.StudioOpenRequest request( + StudioOpenCoordinator.StudioOpenKind openKind, + String playerName + ) { + return new StudioOpenCoordinator.StudioOpenRequest( + "overworld", + null, + null, + 1337L, + "iris-test", + playerName, + openKind, + false, + null, + null); + } } diff --git a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorSpawnStuckRegressionTest.java b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorSpawnStuckRegressionTest.java index 8258df4d9..530abb273 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorSpawnStuckRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorSpawnStuckRegressionTest.java @@ -32,28 +32,31 @@ public class StudioOpenCoordinatorSpawnStuckRegressionTest { } @Test - public void paperEntryChunkLoadIsAsyncBeforeTheRetentionTicket() throws Exception { + public void entryChunkLoadUsesTheUrgentAsyncRequestWithoutRetentionTickets() throws Exception { String source = Files.readString(Path.of( "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")).replace("\r\n", "\n"); int asyncLoad = source.indexOf("requested = WorldRuntimeControlService.get().requestChunkAsync("); int urgentFlag = source.indexOf("true);", asyncLoad); - int retentionTicket = source.indexOf("world.addPluginChunkTicket", asyncLoad); assertTrue(asyncLoad >= 0); assertTrue(urgentFlag > asyncLoad); - assertTrue(retentionTicket > asyncLoad); + assertFalse(source.contains("addPluginChunkTicket")); + assertFalse(source.contains("removePluginChunkTicket")); } @Test - public void foliaRetainsItsNonBlockingTicketBootstrapPath() throws Exception { + public void foliaUsesTheSameNonBlockingAsyncEntryPath() throws Exception { String source = Files.readString(Path.of( "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")).replace("\r\n", "\n"); - int foliaBranch = source.indexOf("if (!J.isFolia())"); - int retentionSchedule = source.indexOf( - "return scheduleEntryChunkRetention(world, chunkX, chunkZ);", - foliaBranch); + int loadStart = source.indexOf("private EntryChunkResolution loadEntryChunk("); + int loadEnd = source.indexOf("private void settleEntryUseAfterOperation(", loadStart); + String load = source.substring(loadStart, loadEnd); - assertTrue(foliaBranch >= 0); - assertTrue(retentionSchedule > foliaBranch); + assertTrue(load.contains("requestChunkAsync(")); + assertTrue(load.contains("J.isOwnedByCurrentRegion(world, chunkX, chunkZ)")); + assertTrue(load.contains("J.runRegion(world, chunkX, chunkZ")); + assertTrue(load.contains("findTopSafeStudioLocation(world, entryAnchor)")); + assertFalse(source.contains("resolveSafeEntry(world, entryAnchor)")); + assertFalse(load.contains("J.isFolia()")); } } diff --git a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java index 3857fc5ba..9e4999b50 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java @@ -7,6 +7,7 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.block.data.Levelled; import org.bukkit.block.data.Waterlogged; import org.bukkit.util.BoundingBox; import org.bukkit.util.VoxelShape; @@ -78,7 +79,7 @@ public class WorldRuntimeControlServiceSafeEntryTest { } @Test - public void searchesOnlyTheOwnedChunkForNearbySolidGround() { + public void studioPrefersNearbyDryGroundWithinTheOwnedChunk() { World world = loadedWorld(0, 0); Block water = block(Material.WATER, true, true); Block stone = block(Material.STONE, false, false, FULL_BLOCK); @@ -98,7 +99,7 @@ public class WorldRuntimeControlServiceSafeEntryTest { }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); Location source = new Location(world, 0.5D, 63D, 0.5D); - Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); + Location result = WorldRuntimeControlService.findTopSafeStudioLocation(world, source); assertNotNull(result); assertEquals(1, result.getBlockX()); @@ -179,15 +180,16 @@ public class WorldRuntimeControlServiceSafeEntryTest { } @Test - public void returnsNullForWaterOnlyChunkAndBoundsVerticalSearch() { + public void genericSafeEntryStillRejectsWaterOnlyChunks() { World world = loadedWorld(0, 0); Block water = block(Material.WATER, true, true); + Block air = block(Material.AIR, false, true); AtomicInteger lowestReadY = new AtomicInteger(Integer.MAX_VALUE); doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); doAnswer(invocation -> { int y = invocation.getArgument(1); lowestReadY.accumulateAndGet(y, Math::min); - return water; + return y <= 62 ? water : air; }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); Location source = new Location(world, 0.5D, 63D, 0.5D); @@ -197,6 +199,98 @@ public class WorldRuntimeControlServiceSafeEntryTest { assertEquals(-1, lowestReadY.get()); } + @Test + public void studioFallsBackAboveWaterSurfaceAfterExhaustingDryCandidates() { + World world = loadedWorld(0, 0); + Block water = waterBlock(0); + Block air = block(Material.AIR, false, true); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int y = invocation.getArgument(1); + return y <= 62 ? water : air; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 0.5D, 63D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeStudioLocation(world, source); + + assertNotNull(result); + assertEquals(0, result.getBlockX()); + assertEquals(63, result.getBlockY()); + assertEquals(0, result.getBlockZ()); + } + + @Test + public void studioDoesNotFallBackAboveLavaSurface() { + World world = loadedWorld(0, 0); + Block lava = block(Material.LAVA, true, true); + Block air = block(Material.AIR, false, true); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int y = invocation.getArgument(1); + return y == 62 ? lava : air; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 0.5D, 63D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeStudioLocation(world, source); + + assertNull(result); + } + + @Test + public void studioRejectsWaterSurfaceWithoutClearHeadroom() { + World world = loadedWorld(0, 0); + Block water = waterBlock(0); + Block air = block(Material.AIR, false, true); + Block stone = block(Material.STONE, false, false, FULL_BLOCK); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int y = invocation.getArgument(1); + if (y <= 62) { + return water; + } + return y == 64 ? stone : air; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 0.5D, 63D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeStudioLocation(world, source); + + assertNull(result); + } + + @Test + public void studioRejectsFlowingWaterSurface() { + World world = loadedWorld(0, 0); + Block flowingWater = waterBlock(1); + Block air = block(Material.AIR, false, true); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int y = invocation.getArgument(1); + return y <= 62 ? flowingWater : air; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 0.5D, 63D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeStudioLocation(world, source); + + assertNull(result); + } + + @Test + public void studioRejectsUnsupportedShallowWaterSurface() { + World world = loadedWorld(0, 0); + Block water = waterBlock(0); + Block air = block(Material.AIR, false, true); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int y = invocation.getArgument(1); + return y == 62 ? water : air; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 0.5D, 63D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeStudioLocation(world, source); + + assertNull(result); + } + @Test public void returnsNullWithoutReadingAnUnloadedChunk() { World world = loadedWorld(0, 0); @@ -236,4 +330,12 @@ public class WorldRuntimeControlServiceSafeEntryTest { doReturn(blockData).when(block).getBlockData(); return block; } + + private static Block waterBlock(int level) { + Block block = block(Material.WATER, true, true); + Levelled blockData = mock(Levelled.class); + doReturn(level).when(blockData).getLevel(); + doReturn(blockData).when(block).getBlockData(); + return block; + } } diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java index 689bc1d37..956ea1f88 100644 --- a/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCWorldPackPublishTest.java @@ -416,6 +416,34 @@ public class StudioSVCWorldPackPublishTest { assertEquals("second", second.join()); } + @Test + public void externallyCompletedTransitionStillHoldsTheQueueUntilItsOperationSettles() { + StudioSVC.StudioTransitionQueue transitions = new StudioSVC.StudioTransitionQueue(); + CompletableFuture firstGate = new CompletableFuture<>(); + CompletableFuture secondGate = new CompletableFuture<>(); + List events = new ArrayList<>(); + + CompletableFuture first = transitions.submit(() -> { + events.add("first-start"); + return firstGate; + }); + CompletableFuture second = transitions.submit(() -> { + events.add("second-start"); + return secondGate; + }); + + assertTrue(first.completeExceptionally(new IllegalStateException("public timeout"))); + assertEquals(List.of("first-start"), events); + assertFalse(second.isDone()); + + firstGate.complete("late-first"); + assertEquals(List.of("first-start", "second-start"), events); + assertFalse(second.isDone()); + + secondGate.complete("second"); + assertEquals("second", second.join()); + } + private static void writeValidPack(Path packRoot) throws Exception { Files.createDirectories(packRoot.resolve("dimensions")); Files.createDirectories(packRoot.resolve("regions")); diff --git a/core/src/test/java/art/arcane/iris/core/service/TreeSVCFluidHeightTest.java b/core/src/test/java/art/arcane/iris/core/service/TreeSVCFluidHeightTest.java new file mode 100644 index 000000000..cb6d916a5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/TreeSVCFluidHeightTest.java @@ -0,0 +1,22 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimension; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class TreeSVCFluidHeightTest { + @Test + public void liveWorldTreePlacementConvertsLocalFluidHeightToWorldY() { + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getFluidHeight()).thenReturn(127); + + assertEquals(63, TreeSVC.worldFluidHeight(engine)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java index 5142dd313..01b258191 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java @@ -1,16 +1,209 @@ package art.arcane.iris.engine; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverOverride; +import art.arcane.iris.engine.object.IrisRiverWaterMode; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; import org.junit.Test; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; public class IrisComplexSurfaceBiomeTest { + @Test + public void focusBiomeWithoutOverrideIgnoresOverridesOutsideFocus() { + IrisBiome focusBiome = new IrisBiome(); + IrisBiome unreachableBiome = new IrisBiome().setRiverOverride(new IrisRiverOverride()); + + assertFalse(IrisComplex.biomeRiverOverridesPossible(focusBiome, List.of(unreachableBiome))); + } + + @Test + public void focusBiomeOverrideAlwaysEnablesBiomeSampling() { + IrisBiome focusBiome = new IrisBiome().setRiverOverride(new IrisRiverOverride()); + + assertTrue(IrisComplex.biomeRiverOverridesPossible(focusBiome, List.of())); + } + + @Test + public void nonFocusBiomeSamplingTracksReachableOverrides() { + assertFalse(IrisComplex.biomeRiverOverridesPossible(null, List.of(new IrisBiome()))); + assertTrue(IrisComplex.biomeRiverOverridesPossible( + null, + List.of(new IrisBiome().setRiverOverride(new IrisRiverOverride())) + )); + } + + @Test + public void maxIncisionCapabilityIgnoresIdentityOverrides() { + assertFalse(IrisComplex.changesMaxIncision(null)); + assertFalse(IrisComplex.changesMaxIncision(new IrisRiverOverride())); + assertFalse(IrisComplex.changesMaxIncision( + new IrisRiverOverride().setMaxIncisionMultiplier(1D))); + assertTrue(IrisComplex.changesMaxIncision( + new IrisRiverOverride().setMaxIncisionMultiplier(0.75D))); + } + + @Test + public void naturalOceanHeightMaskMatchesResolvedLandAndSeaBiomes() { + double fluidHeight = 64D; + IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); + IrisBiome sea = new IrisBiome().setInferredType(InferredType.SEA); + IrisBiome shore = new IrisBiome().setInferredType(InferredType.SHORE); + IrisRegion region = mock(IrisRegion.class); + for (double shoreHeight : new double[]{0D, 3D}) { + doReturn(shoreHeight).when(region).getShoreHeight(0D, 0D); + double[] heights = new double[]{ + fluidHeight - 2D, + fluidHeight - 1D, + fluidHeight, + fluidHeight + shoreHeight, + fluidHeight + shoreHeight + 0.000001D + }; + for (IrisBiome base : List.of(land, sea)) { + for (double height : heights) { + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + height, + base, + region, + 0D, + 0D, + fluidHeight, + constant(land), + constant(sea), + constant(shore) + ); + Boolean ocean = IrisComplex.createNaturalOceanStream( + ProceduralStream.ofDouble((x, z) -> height), + constantType(InferredType.LAND), + null, + fluidHeight, + IrisRiverWaterMode.TERRACED + ).get(0D, 0D); + + assertEquals(resolved.getInferredType() == InferredType.SEA, ocean.booleanValue()); + } + } + } + } + + @Test + public void naturalOceanHeightMaskSamplesOnlyNaturalHeight() { + AtomicInteger heightSamples = new AtomicInteger(); + ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { + heightSamples.incrementAndGet(); + return 62D; + }); + + Boolean ocean = IrisComplex.createNaturalOceanStream( + height, + constantType(InferredType.LAND), + null, + 64D, + IrisRiverWaterMode.TERRACED + ).get(8D, -3D); + + assertTrue(ocean); + assertEquals(1, heightSamples.get()); + } + + @Test + public void focusNaturalOceanMaskIgnoresHeightForEverySurfaceType() { + AtomicInteger heightSamples = new AtomicInteger(); + ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { + heightSamples.incrementAndGet(); + return -1_000D; + }); + + assertTrue(IrisComplex.createNaturalOceanStream( + height, + constantType(InferredType.LAND), + new IrisBiome().setInferredType(InferredType.SEA), + 64D, + IrisRiverWaterMode.SEA_LEVEL + ).get(0D, 0D)); + assertFalse(IrisComplex.createNaturalOceanStream( + height, + constantType(InferredType.SEA), + new IrisBiome().setInferredType(InferredType.LAND), + 64D, + IrisRiverWaterMode.SEA_LEVEL + ).get(0D, 0D)); + assertFalse(IrisComplex.createNaturalOceanStream( + height, + constantType(InferredType.SEA), + new IrisBiome().setInferredType(InferredType.SHORE), + 64D, + IrisRiverWaterMode.SEA_LEVEL + ).get(0D, 0D)); + assertEquals(0, heightSamples.get()); + } + + @Test + public void seaLevelOceanMaskUsesContinentalIntentWithoutSamplingHeight() { + AtomicInteger heightSamples = new AtomicInteger(); + ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { + heightSamples.incrementAndGet(); + return -1_000D; + }); + + Boolean ocean = IrisComplex.createNaturalOceanStream( + height, + constantType(InferredType.SEA), + null, + 64D, + IrisRiverWaterMode.SEA_LEVEL + ).get(8D, -3D); + + assertTrue(ocean); + assertEquals(0, heightSamples.get()); + } + + @Test + public void emptyRiverPoolsKeepWetChannelsAquaticAndDryChannelsLand() { + assertEquals(InferredType.SEA, IrisComplex.directRiverFallback(sample(RiverRouteState.WET, RiverSection.CHANNEL))); + assertEquals(InferredType.SEA, IrisComplex.directRiverFallback(sample(RiverRouteState.WET, RiverSection.MOUTH))); + assertEquals(InferredType.LAND, IrisComplex.directRiverFallback(sample(RiverRouteState.DRY, RiverSection.DRY_BANK))); + assertNull(IrisComplex.directRiverFallback(sample(RiverRouteState.WET, RiverSection.BANK))); + } + + @Test + public void elevatedRiverBankUsesTheLocalWaterHead() { + IrisBiome base = mock(IrisBiome.class); + IrisBiome sea = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + doReturn(false).when(base).isShore(); + doReturn(false).when(base).isAquatic(); + doReturn(3D).when(region).getShoreHeight(12D, 18D); + + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + 68D, + base, + region, + 12D, + 18D, + 70D, + constant(base), + constant(sea), + constant(base)); + + assertSame(sea, resolved); + } @Test public void shorelineHeightSelectsShoreBiome() { IrisBiome base = mock(IrisBiome.class); @@ -62,4 +255,15 @@ public class IrisComplexSurfaceBiomeTest { doReturn(biome).when(stream).get(anyDouble(), anyDouble()); return stream; } + + private static ProceduralStream constantType(InferredType type) { + return ProceduralStream.of( + (x, z) -> type, + Interpolated.of(value -> 0D, value -> type) + ); + } + + private static RiverSample sample(RiverRouteState state, RiverSection section) { + return new RiverSample(true, state, section, 0D, 0.5D, 1D, 1, 1, 10D, 5D, 3D, false, null); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapLifecycleContractTest.java b/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapLifecycleContractTest.java index 17b5a2fde..46572646b 100644 --- a/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapLifecycleContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapLifecycleContractTest.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class NativeStructureBootstrapLifecycleContractTest { diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java new file mode 100644 index 000000000..38872700c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java @@ -0,0 +1,40 @@ +package art.arcane.iris.engine.actuator; + +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisDecorantActuatorRiverTest { + @Test + public void dryRiverSurfaceDoesNotRunShorelineDecoration() { + IrisRiverSurfaceSample dry = new IrisRiverSurfaceSample( + river(RiverRouteState.DRY, RiverSection.DRY_CHANNEL), + 70D, + 65D, + 65D, + false, + false + ); + IrisRiverSurfaceSample wet = new IrisRiverSurfaceSample( + river(RiverRouteState.WET, RiverSection.CHANNEL), + 70D, + 65D, + 65D, + false, + true + ); + + assertFalse(IrisDecorantActuator.shouldDecorateShoreline(dry, 65)); + assertTrue(IrisDecorantActuator.shouldDecorateShoreline(wet, 65)); + assertTrue(IrisDecorantActuator.shouldDecorateShoreline(IrisRiverSurfaceSample.none(65D, 65D), 65)); + } + + private static RiverSample river(RiverRouteState state, RiverSection section) { + return new RiverSample(true, state, section, 0D, 0.5D, 1D, 1, 1, 10D, 5D, 3D, false, null); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java index bd2740099..897360900 100644 --- a/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java +++ b/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.decorator; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.SeedManager; import art.arcane.iris.engine.object.InferredType; @@ -10,6 +11,7 @@ import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.stream.ProceduralStream; import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -31,6 +33,11 @@ public class IrisDecoratorCaveContextTest { IrisDimension dimension = mock(IrisDimension.class); doReturn(63).when(dimension).getFluidHeight(); doReturn(dimension).when(engine).getDimension(); + IrisComplex complex = mock(IrisComplex.class); + ProceduralStream fluidStream = mock(ProceduralStream.class); + doReturn(complex).when(engine).getComplex(); + doReturn(fluidStream).when(complex).getRiverWaterSurfaceStream(); + doReturn(63D).when(fluidStream).get(anyDouble(), anyDouble()); IrisDecorator decorator = mock(IrisDecorator.class); doReturn(true).when(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any()); diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/IrisShoreLineDecoratorTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/IrisShoreLineDecoratorTest.java index 578f2eafb..101b6eee8 100644 --- a/core/src/test/java/art/arcane/iris/engine/decorator/IrisShoreLineDecoratorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/decorator/IrisShoreLineDecoratorTest.java @@ -98,6 +98,7 @@ public class IrisShoreLineDecoratorTest { IrisSlopeClip slope = mock(IrisSlopeClip.class); PlatformBlockState decorant = mock(PlatformBlockState.class); ProceduralStream heightStream = mock(ProceduralStream.class); + ProceduralStream fluidStream = mock(ProceduralStream.class); when(engine.getCacheID()).thenReturn(1); when(engine.getSeedManager()).thenReturn(seedManager); @@ -107,7 +108,9 @@ public class IrisShoreLineDecoratorTest { when(engine.getComplex()).thenReturn(complex); when(complex.getFluidHeight()).thenReturn((double) FLUID_HEIGHT); when(complex.getHeightStream()).thenReturn(heightStream); + when(complex.getRiverWaterSurfaceStream()).thenReturn(fluidStream); when(heightStream.get(anyDouble(), anyDouble())).thenReturn((double) FLUID_HEIGHT - 1); + when(fluidStream.get(anyDouble(), anyDouble())).thenReturn((double) FLUID_HEIGHT); when(engine.getData()).thenReturn(data); when(biome.getDecoratorBucket(IrisDecorationPart.SHORE_LINE)) .thenReturn(new IrisDecorator[]{decorator}); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java index a07d078ab..9ca27f964 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java @@ -417,7 +417,7 @@ public class IrisStructureLocatorContractTest { assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status()); assertFalse(result.found()); - verify(engine, times(4_096)).getComplex(); + verify(engine, times(8_192)).getComplex(); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java index 45a201202..0b0c617f7 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisNativeStructure; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisDimension; @@ -9,6 +10,7 @@ import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrainMode; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.StructureDistribution; +import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import org.junit.Test; @@ -20,6 +22,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class NativeStructurePlacementPlannerTest { @@ -112,6 +115,20 @@ public class NativeStructurePlacementPlannerTest { assertNotNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0)); } + @Test + public void submergedCheckUsesTheColumnRiverHead() { + Engine engine = engine(77L, -64, 384, 66); + IrisComplex complex = mock(IrisComplex.class); + @SuppressWarnings("unchecked") + ProceduralStream riverHead = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead); + when(riverHead.get(8, 8)).thenReturn(68D); + + assertTrue(NativeStructurePlacementPlanner.isSubmerged(engine, 8, 8)); + verify(riverHead).get(8, 8); + } + @Test public void duplicateSelectedStructureUsesOneDeterministicPlan() { Engine engine = engine(77L, -64, 384, 150); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java index 7e0456a31..b8cdc3bc3 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java @@ -3,8 +3,16 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import java.util.Arrays; @@ -19,6 +27,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; @@ -27,6 +36,22 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class StructureCaveAnchorResolverTest { + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + @Test public void floorRequiresSolidBoundaryAndUpwardClearance() { IntPredicate carved = carvedAt(10, 11, 12, 13); @@ -155,6 +180,21 @@ public class StructureCaveAnchorResolverTest { true, new MatterCavern(false, "", (byte) 0), 20, 8)); } + @Test + public void wetAndSealHydrologyCannotBecomeStructureAnchors() { + MatterCavern fluid = new MatterCavern(true, "", (byte) 1); + MatterCavern forcedAir = new MatterCavern(true, "", (byte) 3); + + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( + true, fluid, RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), 20, 8)); + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( + true, fluid, RiverCaveHydrology.of(RiverCaveAction.FALLING_WATER), 20, 8)); + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( + true, null, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD), 20, 8)); + assertTrue(StructureCaveAnchorResolver.acceptsAnchorFluid( + false, forcedAir, RiverCaveHydrology.of(RiverCaveAction.DRY_AIR), 0, 8)); + } + private static Engine engineWithFloorAnchor(MatterCavern anchorCavern, int surfaceHeight) { Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); IrisDimension dimension = mock(IrisDimension.class); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java index b06a9f6c0..20b9785de 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java @@ -14,6 +14,8 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; 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 StructurePlacementScopeTest { @@ -34,13 +36,21 @@ public class StructurePlacementScopeTest { caveBiome.getStructures().add(invalidCaveScopePlacement); IrisRegion region = new IrisRegion(); region.getStructures().add(regionPlacement); - IrisDimension dimension = new IrisDimension(); - dimension.getStructures().add(dimensionPlacement); - dimension.getStructures().add(cavePlacement); - Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); IrisComplex complex = engine.getComplex(); + IrisDimension dimension = mock(IrisDimension.class); + KList dimensionStructures = new KList<>(); + dimensionStructures.add(dimensionPlacement); + dimensionStructures.add(cavePlacement); + KList allRegions = new KList<>(); + allRegions.add(region); + KList allBiomes = new KList<>(); + allBiomes.add(surfaceBiome); + allBiomes.add(caveBiome); when(engine.getDimension()).thenReturn(dimension); + when(engine.getAllBiomes()).thenReturn(allBiomes); + when(dimension.getStructures()).thenReturn(dimensionStructures); + when(dimension.getAllRegions(engine)).thenReturn(allRegions); when(complex.getTrueBiomeStream().get(8, 8)).thenReturn(surfaceBiome); when(complex.getCaveBiomeStream().get(8, 8)).thenReturn(caveBiome); when(complex.getRegionStream().get(8, 8)).thenReturn(region); @@ -54,4 +64,26 @@ public class StructurePlacementScopeTest { assertTrue(placements.contains(dimensionPlacement)); assertFalse(placements.contains(invalidCaveScopePlacement)); } + + @Test + public void dimensionOnlyPlacementsDoNotResolveRiverInclusiveBiomeScopes() { + IrisStructurePlacement dimensionPlacement = new IrisStructurePlacement(); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + IrisComplex complex = engine.getComplex(); + IrisDimension dimension = mock(IrisDimension.class); + KList dimensionStructures = new KList<>(); + dimensionStructures.add(dimensionPlacement); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getAllBiomes()).thenReturn(new KList<>()); + when(dimension.getStructures()).thenReturn(dimensionStructures); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + + KList placements = StructurePlacementScope.placementsAt(engine, 0, 0); + + assertEquals(1, placements.size()); + assertTrue(placements.contains(dimensionPlacement)); + verify(complex.getTrueBiomeStream(), never()).get(8, 8); + verify(complex.getCaveBiomeStream(), never()).get(8, 8); + verify(complex.getRegionStream(), never()).get(8, 8); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererPerformanceContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererPerformanceContractTest.java new file mode 100644 index 000000000..39a4bac1f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererPerformanceContractTest.java @@ -0,0 +1,166 @@ +package art.arcane.iris.engine.framework.render; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +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.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class IrisRendererPerformanceContractTest { + @Test + public void cancelledRenderStopsBeforeSamplingTheEngine() { + Engine engine = mock(Engine.class); + IrisRenderer renderer = new IrisRenderer(engine); + + assertThrows( + CancellationException.class, + () -> renderer.render(0D, 0D, 128D, 128, RenderType.BIOME, () -> true) + ); + verifyNoInteractions(engine); + } + + @Test + public void invalidResolutionIsRejectedBeforeSamplingTheEngine() { + Engine engine = mock(Engine.class); + IrisRenderer renderer = new IrisRenderer(engine); + + assertThrows(IllegalArgumentException.class, () -> renderer.render(0D, 0D, 128D, 0, RenderType.BIOME)); + verifyNoInteractions(engine); + } + + @Test + public void heightPaletteIsNormalizedClampedAndReadable() { + int deepWater = IrisRenderer.heightColor(0D, 320D, 64D); + int shallowWater = IrisRenderer.heightColor(64D, 320D, 64D); + int lowland = IrisRenderer.heightColor(80D, 320D, 64D); + int highland = IrisRenderer.heightColor(190D, 320D, 64D); + int snow = IrisRenderer.heightColor(320D, 320D, 64D); + + assertNotEquals(deepWater, shallowWater); + assertNotEquals(shallowWater, lowland); + assertNotEquals(lowland, highland); + assertNotEquals(highland, snow); + assertEquals(deepWater, IrisRenderer.heightColor(-500D, 320D, 64D)); + assertEquals(snow, IrisRenderer.heightColor(900D, 320D, 64D)); + } + + @Test + public void samplingGroupsStayWithinAStreamCacheCell() { + assertEquals(16, IrisRenderer.sampleGroup(1D, 128)); + assertEquals(4, IrisRenderer.sampleGroup(4D, 128)); + assertEquals(1, IrisRenderer.sampleGroup(32D, 128)); + assertEquals(1, IrisRenderer.sampleGroup(0D, 128)); + } + + @Test + public void studioHeightUsesNaturalTerrainWhileProtocolRenderRemainsExact() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + @SuppressWarnings("unchecked") + ProceduralStream natural = mock(ProceduralStream.class); + @SuppressWarnings("unchecked") + ProceduralStream exact = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(320); + when(complex.getNaturalHeightStream()).thenReturn(natural); + when(complex.getHeightStream()).thenReturn(exact); + when(natural.getDouble(0D, 0D)).thenReturn(80D); + when(exact.getDouble(0D, 0D)).thenReturn(96D); + IrisRenderer renderer = new IrisRenderer(engine); + + renderer.renderStudio(0D, 0D, 1D, 1, RenderType.HEIGHT, () -> false); + + verify(natural).getDouble(0D, 0D); + verifyNoInteractions(exact); + + renderer.render(0D, 0D, 1D, 1, RenderType.HEIGHT); + + verify(exact).getDouble(0D, 0D); + } + + @Test + public void studioBiomeAndContinentAvoidRiverAdjustedEngineLookups() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisBiome biome = mock(IrisBiome.class); + @SuppressWarnings("unchecked") + ProceduralStream base = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getBaseBiomeStream()).thenReturn(base); + when(base.get(0D, 0D)).thenReturn(biome); + when(biome.getColor(engine, RenderType.BIOME)).thenReturn(Color.GREEN); + when(biome.getGenerators()).thenReturn(new KList<>()); + IrisRenderer renderer = new IrisRenderer(engine); + + renderer.renderStudio(0D, 0D, 1D, 1, RenderType.BIOME, () -> false); + renderer.renderStudio(0D, 0D, 1D, 1, RenderType.CONTINENT, () -> false); + + verify(base, times(2)).get(0D, 0D); + verify(engine, never()).getBiome(anyInt(), anyInt(), anyInt()); + } + + @Test + public void uniformStudioBiomePagesUseAdaptiveDomainSampling() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisBiome biome = mock(IrisBiome.class); + @SuppressWarnings("unchecked") + ProceduralStream base = mock(ProceduralStream.class); + AtomicInteger samples = new AtomicInteger(); + when(engine.getComplex()).thenReturn(complex); + when(complex.getBaseBiomeStream()).thenReturn(base); + when(base.get(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble())) + .thenAnswer(invocation -> { + samples.incrementAndGet(); + return biome; + }); + when(biome.getColor(engine, RenderType.BIOME)).thenReturn(Color.GREEN); + + new IrisRenderer(engine).renderStudio(0D, 0D, 256D, 64, RenderType.BIOME, () -> false); + + assertTrue(samples.get() < 128); + } + + @Test + public void planarStudioHeightPagesInterpolateWithoutPerPixelEngineSampling() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + @SuppressWarnings("unchecked") + ProceduralStream natural = mock(ProceduralStream.class); + AtomicInteger samples = new AtomicInteger(); + when(engine.getComplex()).thenReturn(complex); + when(engine.getHeight()).thenReturn(320); + when(complex.getNaturalHeightStream()).thenReturn(natural); + when(natural.getDouble(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble())) + .thenAnswer(invocation -> { + samples.incrementAndGet(); + double x = invocation.getArgument(0); + double z = invocation.getArgument(1); + return 80D + x * 0.05D + z * 0.025D; + }); + + BufferedImage image = new IrisRenderer(engine).renderStudio( + 0D, 0D, 256D, 64, RenderType.HEIGHT, () -> false); + + assertTrue(samples.get() < 256); + assertNotEquals(image.getRGB(0, 0), image.getRGB(63, 63)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java new file mode 100644 index 000000000..bac4ac05c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.engine.framework.render; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.EnumMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisRendererRiverTest { + @Test + public void everyRiverSectionHasItsDiagnosticColor() { + Map expected = new EnumMap<>(RiverSection.class); + expected.put(RiverSection.CHANNEL, new Color(48, 112, 190).getRGB()); + expected.put(RiverSection.MOUTH, new Color(54, 164, 205).getRGB()); + expected.put(RiverSection.BANK, new Color(92, 146, 78).getRGB()); + expected.put(RiverSection.DRY_CHANNEL, new Color(171, 128, 68).getRGB()); + expected.put(RiverSection.DRY_BANK, new Color(132, 105, 62).getRGB()); + expected.put(RiverSection.NONE, new Color(28, 31, 38).getRGB()); + + assertEquals(RiverSection.values().length, expected.size()); + for (RiverSection section : RiverSection.values()) { + assertEquals(section.name(), expected.get(section).intValue(), IrisRenderer.riverColor(section)); + } + } + + @Test + public void riverRenderTypeSamplesOneWorldFootprintPerRenderedPixel() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisRiverRuntime runtime = mock(IrisRiverRuntime.class); + RiverSample river = mock(RiverSample.class); + + when(engine.getComplex()).thenReturn(complex); + when(complex.getRiverRuntime()).thenReturn(runtime); + when(runtime.sampleFootprint(12D, -7D, 20D, 1D)).thenReturn(river); + when(river.present()).thenReturn(true); + when(river.section()).thenReturn(RiverSection.CHANNEL); + + BufferedImage image = new IrisRenderer(engine).renderStudio( + 12D, + -7D, + 8D, + 1, + RenderType.RIVER, + () -> false + ); + + assertEquals(new Color(48, 112, 190).getRGB(), image.getRGB(0, 0)); + verify(runtime).sampleFootprint(12D, -7D, 20D, 1D); + } + + @Test + public void biomeAtlasCompositesRiverChannelsOverTheFastBaseBiome() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisRiverRuntime runtime = mock(IrisRiverRuntime.class); + RiverSample river = mock(RiverSample.class); + IrisBiome biome = mock(IrisBiome.class); + @SuppressWarnings("unchecked") + ProceduralStream base = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getBaseBiomeStream()).thenReturn(base); + when(complex.getRiverRuntime()).thenReturn(runtime); + when(base.get(0D, 0D)).thenReturn(biome); + when(biome.getColor(engine, RenderType.BIOME)).thenReturn(Color.GREEN); + when(runtime.sampleFootprint(0D, 0D, 4D, 4D)).thenReturn(river); + when(river.present()).thenReturn(true); + when(river.section()).thenReturn(RiverSection.CHANNEL); + + BufferedImage image = new IrisRenderer(engine).renderStudio( + 0D, + 0D, + 4D, + 1, + RenderType.BIOME, + () -> false + ); + + assertEquals(new Color(48, 112, 190).getRGB(), image.getRGB(0, 0)); + verify(base).get(0D, 0D); + verify(runtime).sampleFootprint(0D, 0D, 4D, 4D); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java index cc1c8090f..87f62fc4e 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.engine.framework.TreeBlockMaterial; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -137,6 +138,7 @@ public class EngineMantleCleanupTest { verify(chunk).deleteSlices(MatterCavern.class); verify(chunk, never()).deleteSlices(TileWrapper.class); verify(chunk, never()).deleteSlices(Identifier.class); + verify(chunk, never()).deleteSlices(RiverCaveHydrology.class); InOrder order = inOrder(chunk); order.verify(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any()); order.verify(chunk).release(); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java index 355ee624b..cdedfb14d 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -15,6 +17,7 @@ import org.junit.Before; import org.junit.Test; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.doReturn; import static org.mockito.ArgumentMatchers.anyString; @@ -30,9 +33,11 @@ public class MantleWriterOverlayTest { private MantleWriter writer; private Matter matter; + private MantleChunk chunk; private MatterSlice blockSlice; private MatterSlice identifierSlice; private MatterSlice cavernSlice; + private MatterSlice hydrologySlice; @Before @SuppressWarnings("unchecked") @@ -47,22 +52,28 @@ public class MantleWriterOverlayTest { EngineMantle engineMantle = mock(EngineMantle.class); Mantle mantle = mock(Mantle.class); - MantleChunk chunk = mock(MantleChunk.class); + chunk = mock(MantleChunk.class); matter = mock(Matter.class); blockSlice = mock(MatterSlice.class); identifierSlice = mock(MatterSlice.class); cavernSlice = mock(MatterSlice.class); + hydrologySlice = mock(MatterSlice.class); when(mantle.getWorldHeight()).thenReturn(64); when(mantle.getChunk(0, 0)).thenReturn(chunk); when(chunk.use()).thenReturn(chunk); when(chunk.getOrCreate(0)).thenReturn(matter); + when(chunk.exists(0)).thenReturn(true); + when(chunk.get(0)).thenReturn(matter); when(matter.hasSlice(PlatformBlockState.class)).thenReturn(true); when(matter.hasSlice(Identifier.class)).thenReturn(true); + when(matter.hasSlice(MatterCavern.class)).thenReturn(true); when(matter.getSlice(PlatformBlockState.class)).thenReturn(blockSlice); when(matter.getSlice(Identifier.class)).thenReturn(identifierSlice); + when(matter.getSlice(MatterCavern.class)).thenReturn(cavernSlice); doReturn(blockSlice).when(matter).slice(PlatformBlockState.class); doReturn(cavernSlice).when(matter).slice(MatterCavern.class); + doReturn(hydrologySlice).when(matter).getSlice(RiverCaveHydrology.class); writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false); } @@ -105,4 +116,41 @@ public class MantleWriterOverlayTest { verify(identifierSlice).set(X, Y, Z, null); verify(blockSlice).set(X, Y, Z, replacement); } + + @Test + public void protectedHydrologyRejectsLaterBlockAndCavernWrites() { + PlatformBlockState replacement = mock(PlatformBlockState.class); + MatterCavern cavern = new MatterCavern(true, "", (byte) 3); + when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); + when(hydrologySlice.get(X, Y, Z)) + .thenReturn(RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD)); + + writer.setData(X, Y, Z, replacement); + assertFalse(writer.carveDataIfAbsent(X, Y, Z, cavern)); + writer.setForcedCarve(X, Y, Z, cavern); + + verify(blockSlice, never()).set(X, Y, Z, replacement); + verify(blockSlice, never()).set(X, Y, Z, null); + verify(identifierSlice, never()).set(X, Y, Z, null); + verify(cavernSlice, never()).set(X, Y, Z, cavern); + } + + @Test + public void hydrologyOverridesBaselineCarvedQueriesWithoutChangingIt() { + MatterCavern baseline = new MatterCavern(true, "", (byte) 0); + when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); + when(cavernSlice.get(X, Y, Z)).thenReturn(baseline); + when(hydrologySlice.get(X, Y, Z)) + .thenReturn(RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD)); + when(hydrologySlice.get(X, Y + 1, Z)) + .thenReturn(RiverCaveHydrology.of(RiverCaveAction.DRY_AIR)); + + assertFalse(writer.isCarved(X, Y, Z)); + assertTrue(writer.isCarved(X, Y + 1, Z)); + + byte[] column = writer.getCarvedColumn(X, Z, Y + 2); + assertEquals(0, column[Y]); + assertEquals(1, column[Y + 1]); + assertEquals(0, baseline.getLiquid()); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java index 6766dbe8d..5b9ba3af1 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java @@ -21,6 +21,8 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.spi.PlatformBlockState; import org.junit.Test; @@ -109,6 +111,21 @@ public class CaveObjectPlacementTransactionTest { assertEquals(61, MantleObjectComponent.caveAnchorScanUpperBound(128, 80, 20)); } + @Test + public void protectedHydrologyRejectsTheWholePlacement() { + IObjectPlacer delegate = createPlacer(128, 80, 20, 90); + when(delegate.getData(4, 30, 7, RiverCaveHydrology.class)) + .thenReturn(RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE)); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + + transaction.set(4, 30, 7, mock(PlatformBlockState.class)); + transaction.setData(5, 30, 7, "object@1"); + + assertEquals(CaveObjectPlacementTransaction.CommitResult.REJECTED_HYDROLOGY, transaction.commit()); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + } + private IObjectPlacer createPlacer(int worldHeight, int surfaceHeight, int caveFloor, int caveCeiling) { Engine engine = mock(Engine.class); when(engine.getHeight()).thenReturn(worldHeight); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index f862da0c5..e7a5fe3bb 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -743,10 +743,11 @@ public class IrisCaveCarver3DNearParityTest { Engine engine = createEngine(80, 70); int[] surfaceHeights = filledHeights(70); surfaceHeights[0] = 60; - int[] boundaryStartY = new int[256]; - Arrays.fill(boundaryStartY, SurfaceFluidBoundaryPlan.NO_BOUNDARY); - boundaryStartY[0] = 60; - boundaryStartY[16] = 61; + long[] boundaries = new long[256]; + Arrays.fill(boundaries, SurfaceFluidBoundaryPlan.boundary( + SurfaceFluidBoundaryPlan.NO_BOUNDARY, Integer.MIN_VALUE)); + boundaries[0] = SurfaceFluidBoundaryPlan.boundary(60, 64); + boundaries[16] = SurfaceFluidBoundaryPlan.boundary(61, 64); WriterCapture capture = createWriterCapture(80); CaveFluidSupportPlan supportPlan = new CaveFluidSupportPlan(); @@ -759,7 +760,7 @@ public class IrisCaveCarver3DNearParityTest { 0D, null, surfaceHeights, - boundaryStartY, + boundaries, null, supportPlan ); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java index 3d8205de4..cb8695dc3 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.volmlib.util.matter.MatterCavern; import org.junit.Test; @@ -46,6 +48,18 @@ public class MantleObjectComponentCaveAnchorTest { )); } + @Test + public void protectedRiverHydrologyCannotBecomeAnObjectAnchor() { + MatterCavern water = new MatterCavern(true, "", (byte) 1); + + assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( + true, water, RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), 20, 8)); + assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( + true, water, RiverCaveHydrology.of(RiverCaveAction.FALLING_WATER), 20, 8)); + assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( + true, null, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD), 20, 8)); + } + private static IrisBiome biome(String loadKey) { IrisBiome biome = new IrisBiome(); biome.setLoadKey(loadKey); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java new file mode 100644 index 000000000..6817d5c9e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java @@ -0,0 +1,37 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.river.cave.CavePosition; +import art.arcane.iris.engine.river.cave.CaveVoxel; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MantleRiverCaveVoxelViewTest { + @Test + @SuppressWarnings("unchecked") + public void absentCellsAboveLocalTerrainAreOpenAir() { + Mantle mantle = mock(Mantle.class); + when(mantle.getLoadedRegions()).thenReturn(new KMap>()); + MantleRiverCaveVoxelView view = new MantleRiverCaveVoxelView( + mantle, + 128, + (x, z) -> x < 0 ? 20 : 60, + (x, z) -> null + ); + CavePosition cliffAir = new CavePosition(-1, 21, 0); + CavePosition terrain = new CavePosition(-1, 20, 0); + + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(cliffAir)); + assertTrue(view.isOpenToSurface(cliffAir)); + assertEquals(CaveVoxel.SOLID, view.voxelAt(terrain)); + assertFalse(view.isOpenToSurface(terrain)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java new file mode 100644 index 000000000..97ac8b1ad --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java @@ -0,0 +1,731 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRiverCaveFallback; +import art.arcane.iris.engine.object.IrisRiverCaveMode; +import art.arcane.iris.engine.object.IrisRiverCaves; +import art.arcane.iris.engine.object.IrisRiverExistingFluidPolicy; +import art.arcane.iris.engine.object.NoiseStyle; +import art.arcane.iris.engine.mantle.ComponentFlag; +import art.arcane.iris.engine.river.RiverEdgeId; +import art.arcane.iris.engine.river.RiverNodeId; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverSample; +import art.arcane.iris.engine.river.RiverSection; +import art.arcane.iris.engine.river.cave.CavePosition; +import art.arcane.iris.engine.river.cave.CaveVoxel; +import art.arcane.iris.engine.river.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveContainmentPlanner; +import art.arcane.iris.engine.river.cave.RiverCaveFluidPolicy; +import art.arcane.iris.engine.river.cave.RiverCaveMode; +import art.arcane.iris.engine.river.cave.RiverCavePlan; +import art.arcane.iris.engine.river.cave.RiverCavePlannerSettings; +import art.arcane.iris.engine.river.cave.RiverCaveRejection; +import art.arcane.iris.engine.river.cave.RiverCaveSource; +import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; +import art.arcane.iris.engine.river.runtime.IrisRiverTunnelSample; +import org.junit.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +public class MantleRiverHydrologyComponentTest { + private final RiverCaveContainmentPlanner planner = new RiverCaveContainmentPlanner(); + + @Test + public void componentRunsBetweenCarvingAndPlacementWithStableFlagOrdinal() { + ComponentFlag flag = MantleRiverHydrologyComponent.class.getAnnotation(ComponentFlag.class); + + assertEquals(1, MantleRiverHydrologyComponent.PRIORITY); + assertEquals(ReservedFlag.RIVER_HYDROLOGY, flag.value()); + assertEquals(17, ReservedFlag.FLOATING_OBJECT.ordinal()); + assertEquals(18, ReservedFlag.RIVER_HYDROLOGY.ordinal()); + } + + @Test + public void enablementSeparatesSealedBoresFromOptionalCaveConnections() { + IrisDimension dimension = new IrisDimension(); + + assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); + dimension.getRivers().setEnabled(true); + assertTrue(MantleRiverHydrologyComponent.isEnabledFor(dimension)); + assertFalse(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); + dimension.getRivers().getCaves().setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT); + assertTrue(MantleRiverHydrologyComponent.isEnabledFor(dimension)); + assertTrue(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); + dimension.getRivers().getCaves().setMaximumPerReach(0); + assertTrue(MantleRiverHydrologyComponent.isEnabledFor(dimension)); + assertFalse(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); + dimension.getRivers().getCaves().setMaximumPerReach(1); + dimension.setCarvingEnabled(false); + assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); + dimension.setCarvingEnabled(true); + dimension.setUseMantle(false); + assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); + dimension.setUseMantle(true); + dimension.getDisabledComponents().add(ReservedFlag.CARVED); + assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); + dimension.getDisabledComponents().clear(); + dimension.getDisabledComponents().add(ReservedFlag.RIVER_HYDROLOGY); + assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); + } + + @Test + public void buriedChannelPlansWetCoreDryRoofAndSolidGuardShell() { + TestVoxelView view = new TestVoxelView(); + IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( + riverSample(RiverRouteState.WET, RiverSection.CHANNEL), + 10, + 12, + 14 + ); + + MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( + view, + 0, + 0, + 1, + (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( + RiverRouteState.WET, + RiverSection.CHANNEL + ), + (x, z) -> x == 0 && z == 0 ? tunnel : null, + (x, z) -> IrisRiverSurfaceSample.none(70D, 63D) + ); + + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 11, 0))); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 12, 0))); + assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(new CavePosition(0, 13, 0))); + assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(new CavePosition(0, 14, 0))); + assertEquals(RiverCaveAction.SEAL_GUARD, plan.actions().get(new CavePosition(1, 12, 0))); + assertTrue(MantleRiverHydrologyComponent.preconditionsHold(view, plan.preconditions())); + } + + @Test + public void buriedChannelCapsBeforeAnUnrelatedCave() { + TestVoxelView view = new TestVoxelView(); + view.set(new CavePosition(1, 12, 0), CaveVoxel.CAVE_AIR); + IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( + riverSample(RiverRouteState.WET, RiverSection.CHANNEL), + 10, + 12, + 14 + ); + + MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( + view, + 0, + 0, + 1, + (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( + RiverRouteState.WET, + RiverSection.CHANNEL + ), + (x, z) -> x == 0 && z == 0 ? tunnel : null, + (x, z) -> IrisRiverSurfaceSample.none(70D, 63D) + ); + + assertTrue(plan.actions().isEmpty()); + assertTrue(plan.preconditions().isEmpty()); + } + + @Test + public void buriedChannelMayOpenOnlyIntoItsExactSurfaceRiverMouth() { + TestVoxelView view = new TestVoxelView(); + view.set(new CavePosition(1, 11, 0), CaveVoxel.COMPATIBLE_FLUID); + view.set(new CavePosition(1, 12, 0), CaveVoxel.COMPATIBLE_FLUID); + IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( + riverSample(RiverRouteState.WET, RiverSection.CHANNEL), + 10, + 12, + 14 + ); + IrisRiverSurfaceSample mouth = new IrisRiverSurfaceSample( + riverSample(RiverRouteState.WET, RiverSection.CHANNEL), + 14D, + 10D, + 12D, + false, + true + ); + + MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( + view, + 0, + 0, + 1, + (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( + RiverRouteState.WET, + RiverSection.CHANNEL + ), + (x, z) -> x == 0 && z == 0 ? tunnel : null, + (x, z) -> x == 1 && z == 0 ? mouth : IrisRiverSurfaceSample.none(70D, 63D) + ); + + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 12, 0))); + assertFalse(plan.preconditions().containsKey(new CavePosition(1, 12, 0))); + } + + @Test + public void buriedChannelContinuesAcrossPreviouslyPublishedNeighborCells() { + TestVoxelView view = new TestVoxelView(); + view.publish(new CavePosition(-1, 11, 0), RiverCaveAction.WET_SOURCE); + view.publish(new CavePosition(-1, 12, 0), RiverCaveAction.WET_SOURCE); + view.publish(new CavePosition(-1, 13, 0), RiverCaveAction.DRY_AIR); + view.publish(new CavePosition(-1, 14, 0), RiverCaveAction.DRY_AIR); + IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( + riverSample(RiverRouteState.WET, RiverSection.CHANNEL), + 10, + 12, + 14 + ); + + MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( + view, + 0, + 0, + 1, + (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( + RiverRouteState.WET, + RiverSection.CHANNEL + ), + (x, z) -> (x == -1 || x == 0) && z == 0 ? tunnel : null, + (x, z) -> IrisRiverSurfaceSample.none(70D, 63D) + ); + + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 12, 0))); + assertTrue(MantleRiverHydrologyComponent.preconditionsHold(view, plan.preconditions())); + } + + @Test + public void sinkholeTerminalForcesGeneratedGrottoEvenInClosedMode() { + assertEquals( + RiverCaveMode.CLOSED_COMPONENT, + MantleRiverHydrologyComponent.sourceMode( + IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT, + false + ) + ); + assertEquals( + RiverCaveMode.GENERATED_GROTTO, + MantleRiverHydrologyComponent.sourceMode( + IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT, + true + ) + ); + } + + @Test + public void wetClosedCavePlansSourcesWithoutChangingBaseline() { + TestVoxelView view = new TestVoxelView(); + CavePosition target = new CavePosition(8, 42, 8); + view.set(target, CaveVoxel.CAVE_AIR); + Map baseline = view.snapshot(); + IrisRiverCaves caves = caves(); + RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 91L, null); + RiverCaveSource source = new RiverCaveSource( + 1L, + new CavePosition(8, 48, 8), + target, + 46, + RiverCaveMode.CLOSED_COMPONENT + ); + + RiverCavePlan plan = planner.plan(view, source, settings); + + assertTrue(plan.accepted()); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(target)); + assertEquals(baseline, view.snapshot()); + } + + @Test + public void onlyWetChannelBedsAreEligible() { + IrisRiverSurfaceSample wet = surface(RiverRouteState.WET, RiverSection.CHANNEL, true); + IrisRiverSurfaceSample dry = surface(RiverRouteState.DRY, RiverSection.DRY_CHANNEL, false); + IrisRiverSurfaceSample bank = surface(RiverRouteState.WET, RiverSection.BANK, true); + IrisRiverSurfaceSample absent = IrisRiverSurfaceSample.none(64D, 63D); + + assertTrue(MantleRiverHydrologyComponent.isWetChannelBed(wet)); + assertFalse(MantleRiverHydrologyComponent.isWetChannelBed(dry)); + assertFalse(MantleRiverHydrologyComponent.isWetChannelBed(bank)); + assertFalse(MantleRiverHydrologyComponent.isWetChannelBed(absent)); + } + + @Test + public void openLavaAndOversizeCandidatesRejectWhileSolidFallbackCanPlan() { + IrisRiverCaves caves = caves().setMaxFloodVolume(32); + RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 92L, null); + RiverCaveSource closed = new RiverCaveSource( + 2L, + new CavePosition(0, 50, 0), + new CavePosition(0, 44, 0), + 48, + RiverCaveMode.CLOSED_COMPONENT + ); + TestVoxelView open = new TestVoxelView(); + open.set(closed.target(), CaveVoxel.CAVE_AIR); + open.open(closed.target()); + TestVoxelView lava = new TestVoxelView(); + lava.set(closed.target(), CaveVoxel.LAVA); + TestVoxelView oversize = new TestVoxelView(); + for (int y = 43; y <= 44; y++) { + for (int x = -2; x <= 2; x++) { + for (int z = -2; z <= 2; z++) { + oversize.set(new CavePosition(x, y, z), CaveVoxel.CAVE_AIR); + } + } + } + + assertEquals(RiverCaveRejection.OPEN_SURFACE, planner.plan(open, closed, settings).rejection()); + assertEquals(RiverCaveRejection.LAVA_CONTACT, planner.plan(lava, closed, settings).rejection()); + assertEquals(RiverCaveRejection.VOLUME_LIMIT, planner.plan(oversize, closed, settings).rejection()); + + IrisRiverCaves fallbackCaves = caves() + .setMaxFloodVolume(4096) + .setFallback(IrisRiverCaveFallback.GENERATE_GROTTO); + RiverCavePlannerSettings fallbackSettings = MantleRiverHydrologyComponent.plannerSettings( + fallbackCaves, + 92L, + null + ); + RiverCaveSource fallback = new RiverCaveSource( + 2L, + closed.entry(), + new CavePosition(3, 46, 0), + closed.waterHeadY(), + RiverCaveMode.GENERATED_GROTTO + ); + assertEquals( + RiverCaveRejection.NONE, + planner.plan(new TestVoxelView(), fallback, fallbackSettings).rejection() + ); + } + + @Test + public void waterfallPoolPoursThroughAnOpenPitWithoutFloodingItsComponent() { + IrisRiverCaves caves = caves().setMode(IrisRiverCaveMode.WATERFALL_POOL); + RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 94L, null); + CavePosition entry = new CavePosition(0, 50, 0); + CavePosition target = new CavePosition(0, 44, 0); + CavePosition connectedPit = new CavePosition(1, 44, 0); + TestVoxelView view = new TestVoxelView(); + view.set(target, CaveVoxel.CAVE_AIR); + view.set(connectedPit, CaveVoxel.CAVE_AIR); + view.open(target); + view.open(connectedPit); + RiverCaveSource source = new RiverCaveSource( + 3L, + entry, + target, + 48, + RiverCaveMode.WATERFALL_POOL + ); + + RiverCavePlan plan = planner.plan(view, source, settings); + + assertTrue(plan.accepted()); + assertEquals(RiverCaveAction.FALLING_WATER, plan.actions().get(entry)); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(target)); + assertFalse(plan.actions().containsKey(connectedPit)); + } + + @Test + public void fluidPolicyMappingPreservesReplaceSemantics() { + assertEquals( + RiverCaveFluidPolicy.REJECT_EXISTING, + MantleRiverHydrologyComponent.fluidPolicy(IrisRiverExistingFluidPolicy.REJECT) + ); + assertEquals( + RiverCaveFluidPolicy.ALLOW_COMPATIBLE, + MantleRiverHydrologyComponent.fluidPolicy(IrisRiverExistingFluidPolicy.ALLOW_SAME) + ); + assertEquals( + RiverCaveFluidPolicy.REPLACE_CONTAINED, + MantleRiverHydrologyComponent.fluidPolicy(IrisRiverExistingFluidPolicy.REPLACE) + ); + } + + @Test + public void authoredHeadThroatAndProofLimitsMapDirectly() { + IrisRiverCaves caves = caves() + .setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT) + .setWaterLevelOffset(-3) + .setThroatRadius(5) + .setDryHeadroom(6) + .setMaxFloodRadius(23) + .setMaxFloodDepth(17); + RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 93L, null); + + assertEquals(61, MantleRiverHydrologyComponent.waterHeadY( + surface(RiverRouteState.WET, RiverSection.CHANNEL, true), + caves + )); + assertEquals(5, settings.throatRadius()); + assertEquals(6, settings.dryHeadroom()); + assertEquals(23, settings.maxClosedComponentHorizontalRadius()); + assertEquals(17, settings.maxClosedComponentDepth()); + assertEquals(24, MantleRiverHydrologyComponent.closedComponentPublicationRadius(caves)); + assertEquals(5, MantleRiverHydrologyComponent.generatedGrottoPublicationRadius(caves)); + assertEquals(24, MantleRiverHydrologyComponent.cavePublicationRadius(caves)); + assertEquals(72, MantleRiverHydrologyComponent.candidateHalo(caves)); + assertEquals(96, MantleRiverHydrologyComponent.planningHalo(caves)); + } + + @Test + public void generatedGrottosDoNotInheritClosedComponentProofRadius() { + IrisRiverCaves caves = caves() + .setMode(IrisRiverCaveMode.GENERATE_GROTTO) + .setGrottoHorizontalRadius(12) + .setThroatRadius(2) + .setMaxFloodRadius(48); + + assertEquals(18, MantleRiverHydrologyComponent.generatedGrottoPublicationRadius(caves)); + assertEquals(49, MantleRiverHydrologyComponent.closedComponentPublicationRadius(caves)); + assertEquals(18, MantleRiverHydrologyComponent.cavePublicationRadius(caves)); + assertEquals(54, MantleRiverHydrologyComponent.candidateHalo(caves)); + assertEquals(72, MantleRiverHydrologyComponent.planningHalo(caves)); + + caves.setFallback(IrisRiverCaveFallback.GENERATE_GROTTO); + + assertEquals(21, MantleRiverHydrologyComponent.generatedGrottoPublicationRadius(caves)); + assertEquals(84, MantleRiverHydrologyComponent.planningHalo(caves)); + } + + @Test + public void inputRadiusUsesOnlyCapabilitiesActiveForTheConfiguredMode() { + IrisRiverCaves caves = caves() + .setMode(IrisRiverCaveMode.GENERATE_GROTTO) + .setGrottoHorizontalRadius(12) + .setThroatRadius(2) + .setMaxFloodRadius(48); + + assertEquals(72, MantleRiverHydrologyComponent.inputRadius(caves, 6)); + + caves.setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT); + assertEquals(196, MantleRiverHydrologyComponent.inputRadius(caves, 6)); + + caves.setMode(IrisRiverCaveMode.SEALED); + assertEquals(6, MantleRiverHydrologyComponent.inputRadius(caves, 6)); + + caves.setMode(IrisRiverCaveMode.GENERATE_GROTTO).setMaximumPerReach(0); + assertEquals(6, MantleRiverHydrologyComponent.inputRadius(caves, 6)); + } + + @Test + public void absentRiverFootprintSkipsEveryTunnelColumnSample() { + AtomicInteger footprintSamples = new AtomicInteger(); + AtomicInteger tunnelSamples = new AtomicInteger(); + AtomicInteger surfaceSamples = new AtomicInteger(); + + MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( + new TestVoxelView(), + 3, + -4, + 6, + (minimumX, minimumZ, maximumX, maximumZ) -> { + footprintSamples.incrementAndGet(); + assertEquals(42D, minimumX, 0D); + assertEquals(-70D, minimumZ, 0D); + assertEquals(70D, maximumX, 0D); + assertEquals(-42D, maximumZ, 0D); + return RiverSample.none(); + }, + (x, z) -> { + tunnelSamples.incrementAndGet(); + return null; + }, + (x, z) -> { + surfaceSamples.incrementAndGet(); + return IrisRiverSurfaceSample.none(70D, 63D); + } + ); + + assertTrue(plan.actions().isEmpty()); + assertEquals(1, footprintSamples.get()); + assertEquals(0, tunnelSamples.get()); + assertEquals(0, surfaceSamples.get()); + } + + @Test + public void configuredShapeAndWarpChangeDeterministicGrottoFootprints() { + IrisRiverCaves flatCaves = caves() + .setGrottoHorizontalRadius(6) + .setGrottoVerticalRadius(5) + .setDryHeadroom(1) + .setMaxFloodVolume(10000) + .setGrottoShapeStyle(new IrisGeneratorStyle(NoiseStyle.FLAT)) + .setGrottoWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX)) + .setGrottoWarpStrength(0D); + IrisRiverCaves shapedCaves = caves() + .setGrottoHorizontalRadius(6) + .setGrottoVerticalRadius(5) + .setDryHeadroom(1) + .setMaxFloodVolume(10000) + .setGrottoShapeStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(9D)) + .setGrottoWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX)) + .setGrottoWarpStrength(0D); + IrisRiverCaves warpedCaves = caves() + .setGrottoHorizontalRadius(6) + .setGrottoVerticalRadius(5) + .setDryHeadroom(1) + .setMaxFloodVolume(10000) + .setGrottoShapeStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(9D)) + .setGrottoWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(7D)) + .setGrottoWarpStrength(3D); + RiverCaveSource source = new RiverCaveSource( + 8L, + new CavePosition(37, 58, -21), + new CavePosition(37, 52, -21), + 56, + RiverCaveMode.GENERATED_GROTTO + ); + TestVoxelView view = new TestVoxelView(); + + RiverCavePlan flat = planner.plan( + view, + source, + MantleRiverHydrologyComponent.plannerSettings(flatCaves, 555L, null) + ); + RiverCavePlannerSettings shapedSettings = MantleRiverHydrologyComponent.plannerSettings( + shapedCaves, + 555L, + null + ); + RiverCavePlan shaped = planner.plan(view, source, shapedSettings); + RiverCavePlan shapedRepeat = planner.plan(view, source, shapedSettings); + RiverCavePlan warped = planner.plan( + view, + source, + MantleRiverHydrologyComponent.plannerSettings(warpedCaves, 555L, null) + ); + + assertTrue(flat.accepted()); + assertTrue(shaped.accepted()); + assertTrue(warped.accepted()); + assertEquals(shaped, shapedRepeat); + assertNotEquals(mutations(flat), mutations(shaped)); + assertNotEquals(mutations(shaped), mutations(warped)); + } + + @Test + public void chunkOwnershipIsOrderInvariantAndNonOverlapping() { + Set actions = Set.of( + new CavePosition(15, 40, 0), + new CavePosition(16, 40, 0), + new CavePosition(31, 40, 0), + new CavePosition(32, 40, 0) + ); + Set forward = partition(actions, new int[]{0, 1, 2}); + Set reverse = partition(actions, new int[]{2, 1, 0}); + + assertEquals(actions, forward); + assertEquals(forward, reverse); + } + + @Test + public void perChunkCandidateWindowsMatchGlobalDirectOverlapArbitration() { + IrisRiverCaves caves = caves() + .setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT) + .setMaxFloodRadius(16) + .setMaxFloodVolume(1024); + RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 777L, null); + TestVoxelView view = new TestVoxelView(); + view.set(new CavePosition(14, 45, 0), CaveVoxel.CAVE_AIR); + view.set(new CavePosition(16, 45, 0), CaveVoxel.CAVE_AIR); + view.set(new CavePosition(18, 45, 0), CaveVoxel.CAVE_AIR); + view.set(new CavePosition(200, 45, 0), CaveVoxel.CAVE_AIR); + List sources = List.of( + closedSource(30L, 14, 50), + closedSource(20L, 16, 51), + closedSource(10L, 18, 52), + closedSource(1L, 200, 60) + ); + Map global = planner.planAll(view, sources, settings).actions(); + + for (int chunkX = 0; chunkX <= 1; chunkX++) { + List localSources = candidateWindow(sources, caves, chunkX); + Map local = planner.planAll(view, localSources, settings).actions(); + assertEquals(owned(global, chunkX), owned(local, chunkX)); + } + assertFalse(global.containsKey(new CavePosition(14, 45, 0))); + assertTrue(global.containsKey(new CavePosition(18, 45, 0))); + } + + @Test + public void transactionRejectsAnyChangedActionOrBoundaryGuard() { + TestVoxelView view = new TestVoxelView(); + CavePosition wet = new CavePosition(0, 20, 0); + CavePosition guard = new CavePosition(1, 20, 0); + Map preconditions = Map.of( + wet, new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false), + guard, new CaveVoxelPrecondition(CaveVoxel.SOLID, false) + ); + view.set(wet, CaveVoxel.CAVE_AIR); + + assertTrue(MantleRiverHydrologyComponent.preconditionsHold(view, preconditions)); + view.set(guard, CaveVoxel.CAVE_AIR); + assertFalse(MantleRiverHydrologyComponent.preconditionsHold(view, preconditions)); + } + + private IrisRiverCaves caves() { + return new IrisRiverCaves() + .setThroatRadius(1) + .setDryHeadroom(2) + .setGrottoHorizontalRadius(3) + .setGrottoVerticalRadius(4) + .setMaxBoreDepth(24) + .setMaxFloodRadius(16) + .setMaxFloodDepth(16) + .setMaxFloodVolume(512) + .setGrottoShapeStyle(new IrisGeneratorStyle(NoiseStyle.FLAT)) + .setGrottoWarpStyle(new IrisGeneratorStyle(NoiseStyle.FLAT)) + .setGrottoWarpStrength(0D); + } + + private IrisRiverSurfaceSample surface(RiverRouteState state, RiverSection section, boolean fluid) { + return new IrisRiverSurfaceSample(riverSample(state, section), 68D, 61D, 64D, false, fluid); + } + + private RiverSample riverSample(RiverRouteState state, RiverSection section) { + RiverEdgeId edge = new RiverEdgeId(new RiverNodeId(1L, 1L), new RiverNodeId(2L, 2L)); + return new RiverSample( + true, + state, + section, + 0D, + 0.5D, + 1D, + 2, + 1, + 8D, + 4D, + 3D, + false, + edge + ); + } + + private Set mutations(RiverCavePlan plan) { + Set positions = new HashSet<>(); + for (Map.Entry entry : plan.actions().entrySet()) { + if (entry.getValue() != RiverCaveAction.SEAL_GUARD) { + positions.add(entry.getKey()); + } + } + return positions; + } + + private Set partition(Set actions, int[] order) { + Set published = new HashSet<>(); + for (int chunkX : order) { + for (CavePosition position : actions) { + if (MantleRiverHydrologyComponent.owns(chunkX, 0, position)) { + assertTrue(published.add(position)); + } + } + } + return published; + } + + private RiverCaveSource closedSource(long id, int x, int head) { + return new RiverCaveSource( + id, + new CavePosition(x, 60, 0), + new CavePosition(x, 45, 0), + head, + RiverCaveMode.CLOSED_COMPONENT + ); + } + + private List candidateWindow( + List sources, + IrisRiverCaves caves, + int chunkX + ) { + int halo = MantleRiverHydrologyComponent.candidateHalo(caves); + int minimum = (chunkX << 4) - halo; + int maximum = ((chunkX + 1) << 4) + halo; + List local = new ArrayList<>(); + for (RiverCaveSource source : sources) { + if (source.entry().x() >= minimum && source.entry().x() < maximum) { + local.add(source); + } + } + return local; + } + + private Map owned( + Map actions, + int chunkX + ) { + Map owned = new HashMap<>(); + for (Map.Entry entry : actions.entrySet()) { + if (MantleRiverHydrologyComponent.owns(chunkX, 0, entry.getKey())) { + owned.put(entry.getKey(), entry.getValue()); + } + } + return Map.copyOf(owned); + } + + private static final class TestVoxelView implements MantleRiverHydrologyComponent.TunnelVoxelView { + private final Map voxels = new HashMap<>(); + private final Map riverActions = new HashMap<>(); + private final Set open = new HashSet<>(); + + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > 0 && position.y() < 128; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return voxels.getOrDefault(position, CaveVoxel.SOLID); + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return open.contains(position); + } + + @Override + public RiverCaveAction riverActionAt(CavePosition position) { + return riverActions.get(position); + } + + private void set(CavePosition position, CaveVoxel voxel) { + voxels.put(position, voxel); + } + + private void open(CavePosition position) { + open.add(position); + } + + private void publish(CavePosition position, RiverCaveAction action) { + riverActions.put(position, action); + voxels.put( + position, + action == RiverCaveAction.WET_SOURCE + ? CaveVoxel.COMPATIBLE_FLUID + : CaveVoxel.CAVE_AIR + ); + } + + private Map snapshot() { + return Map.copyOf(voxels); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlanTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlanTest.java index ef1550777..6f243956d 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlanTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlanTest.java @@ -25,8 +25,8 @@ public class SurfaceFluidBoundaryPlanTest { int boundaryY = fixture.boundary(8, 8); assertEquals(60, boundaryY); - assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, fixture.index(8, 8), 59, FLUID_HEIGHT)); - assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, fixture.index(8, 8), 60, FLUID_HEIGHT)); + assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, fixture.index(8, 8), 59)); + assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, fixture.index(8, 8), 60)); } @Test @@ -38,10 +38,10 @@ public class SurfaceFluidBoundaryPlanTest { int columnIndex = fixture.index(8, 8); assertEquals(61, fixture.boundary(8, 8)); - assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, columnIndex, 60, FLUID_HEIGHT)); - assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, columnIndex, 61, FLUID_HEIGHT)); - assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, columnIndex, 64, FLUID_HEIGHT)); - assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, columnIndex, 65, FLUID_HEIGHT)); + assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 60)); + assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 61)); + assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 64)); + assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 65)); } @Test @@ -94,13 +94,28 @@ public class SurfaceFluidBoundaryPlanTest { new int[CHUNK_SIZE * CHUNK_SIZE], new double[FIELD_SIZE * FIELD_SIZE], new boolean[FIELD_SIZE * FIELD_SIZE], + new double[FIELD_SIZE * FIELD_SIZE], FIELD_SIZE, 0, - FLUID_HEIGHT, - new int[CHUNK_SIZE * CHUNK_SIZE] + new long[CHUNK_SIZE * CHUNK_SIZE] )); } + @Test + public void terracedReservoirProtectsOnlyToItsLocalHead() { + Fixture fixture = new Fixture(); + fixture.setFieldSurface(7, 8, 60D); + fixture.setFieldFluidHeight(7, 8, 67D); + + fixture.resolve(); + + int columnIndex = fixture.index(8, 8); + assertEquals(61, fixture.boundary(8, 8)); + assertEquals(67, fixture.boundaryEnd(8, 8)); + assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 67)); + assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 68)); + } + private void assertEdgeBoundary(int localX, int localZ, int neighborLocalX, int neighborLocalZ) { Fixture fixture = new Fixture(); fixture.setFieldSurface(neighborLocalX, neighborLocalZ, 60D); @@ -111,12 +126,14 @@ public class SurfaceFluidBoundaryPlanTest { private static final class Fixture { private final int[] chunkSurfaceHeights = new int[CHUNK_SIZE * CHUNK_SIZE]; private final double[] fieldSurfaceHeights = new double[FIELD_SIZE * FIELD_SIZE]; + private final double[] fieldFluidHeights = new double[FIELD_SIZE * FIELD_SIZE]; private final boolean[] fieldHasFluid = new boolean[FIELD_SIZE * FIELD_SIZE]; - private final int[] boundaryStartY = new int[CHUNK_SIZE * CHUNK_SIZE]; + private final long[] boundaries = new long[CHUNK_SIZE * CHUNK_SIZE]; private Fixture() { Arrays.fill(chunkSurfaceHeights, 70); Arrays.fill(fieldSurfaceHeights, 70D); + Arrays.fill(fieldFluidHeights, FLUID_HEIGHT); Arrays.fill(fieldHasFluid, true); } @@ -137,20 +154,30 @@ public class SurfaceFluidBoundaryPlanTest { fieldHasFluid[(fieldX * FIELD_SIZE) + fieldZ] = hasFluid; } + private void setFieldFluidHeight(int localX, int localZ, double fluidHeight) { + int fieldX = localX + PADDING; + int fieldZ = localZ + PADDING; + fieldFluidHeights[(fieldX * FIELD_SIZE) + fieldZ] = fluidHeight; + } + private void resolve() { SurfaceFluidBoundaryPlan.fill( chunkSurfaceHeights, fieldSurfaceHeights, fieldHasFluid, + fieldFluidHeights, FIELD_SIZE, PADDING, - FLUID_HEIGHT, - boundaryStartY + boundaries ); } private int boundary(int localX, int localZ) { - return boundaryStartY[index(localX, localZ)]; + return SurfaceFluidBoundaryPlan.startY(boundaries[index(localX, localZ)]); + } + + private int boundaryEnd(int localX, int localZ) { + return SurfaceFluidBoundaryPlan.endY(boundaries[index(localX, localZ)]); } private int index(int localX, int localZ) { diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java index 064eb0294..01279a14a 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java @@ -10,6 +10,7 @@ import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterSlice; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import org.junit.Test; @@ -45,9 +46,20 @@ public class IrisCarveModifierBoundarySupportTest { doReturn(engine).when(modifier).getEngine(); MantleChunk mantleChunk = mock(MantleChunk.class); - doReturn(new MatterCavern(true, "custom/floor", (byte) 0)) - .when(mantleChunk).get(1, 6, 2, MatterCavern.class); - doReturn(null).when(mantleChunk).get(1, 42, 2, MatterCavern.class); + Matter floorMatter = mock(Matter.class); + Matter ceilingMatter = mock(Matter.class); + MatterSlice floorSlice = mock(MatterSlice.class); + MatterSlice ceilingSlice = mock(MatterSlice.class); + doReturn(true).when(mantleChunk).exists(0); + doReturn(true).when(mantleChunk).exists(2); + doReturn(floorMatter).when(mantleChunk).get(0); + doReturn(ceilingMatter).when(mantleChunk).get(2); + doReturn(true).when(floorMatter).hasSlice(MatterCavern.class); + doReturn(true).when(ceilingMatter).hasSlice(MatterCavern.class); + doReturn(floorSlice).when(floorMatter).getSlice(MatterCavern.class); + doReturn(ceilingSlice).when(ceilingMatter).getSlice(MatterCavern.class); + doReturn(new MatterCavern(true, "custom/floor", (byte) 0)).when(floorSlice).get(1, 6, 2); + doReturn(null).when(ceilingSlice).get(1, 10, 2); Long2ObjectOpenHashMap caveBiomeCache = new Long2ObjectOpenHashMap<>(); Map customBiomeCache = new HashMap<>(); diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java index 5111ffaa6..1096c4fbb 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java @@ -15,6 +15,7 @@ import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.MatterCavern; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import org.junit.AfterClass; @@ -73,6 +74,7 @@ public class IrisCarveModifierInferenceIsolationTest { Method paintBoundaryZone = IrisCarveModifier.class.getDeclaredMethod( "paintBoundaryZone", Hunk.class, + MantleChunk.class, CarveWallBuffer.class, int.class, int.class, @@ -88,6 +90,7 @@ public class IrisCarveModifierInferenceIsolationTest { paintBoundaryZone.invoke( modifier, mock(Hunk.class), + mock(MantleChunk.class), walls, 0, 0, diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java new file mode 100644 index 000000000..4bfd6d00f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java @@ -0,0 +1,110 @@ +package art.arcane.iris.engine.modifier; + +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +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 IrisCarveModifierRiverHydrologyTest { + @Test + public void overlayCompositionDoesNotMutateBaselineCavern() { + MatterCavern baseline = new MatterCavern(true, "baseline", (byte) 2); + + assertSame(baseline, IrisCarveModifier.composeCavern(baseline, null)); + assertNull(IrisCarveModifier.composeCavern( + baseline, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD))); + + MatterCavern wet = IrisCarveModifier.composeCavern( + baseline, new RiverCaveHydrology(RiverCaveAction.WET_SOURCE, "iris:flooded")); + MatterCavern dry = IrisCarveModifier.composeCavern( + baseline, RiverCaveHydrology.of(RiverCaveAction.DRY_AIR)); + + assertEquals(1, wet.getLiquid()); + assertEquals("iris:flooded", wet.getCustomBiome()); + assertEquals(3, dry.getLiquid()); + assertEquals(2, baseline.getLiquid()); + assertEquals("baseline", baseline.getCustomBiome()); + } + + @Test + public void hydrologyActionsResolveDistinctFluidStates() { + PlatformBlockState current = mock(PlatformBlockState.class); + PlatformBlockState source = mock(PlatformBlockState.class); + PlatformBlockState falling = mock(PlatformBlockState.class); + PlatformBlockState air = mock(PlatformBlockState.class); + when(source.key()).thenReturn("minecraft:water[level=0]"); + when(source.withProperty("level", "8")).thenReturn(falling); + + assertSame(source, IrisCarveModifier.resolveHydrologyState( + RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), current, source, air)); + assertSame(falling, IrisCarveModifier.resolveHydrologyState( + RiverCaveHydrology.of(RiverCaveAction.FALLING_WATER), current, source, air)); + assertSame(air, IrisCarveModifier.resolveHydrologyState( + RiverCaveHydrology.of(RiverCaveAction.DRY_AIR), current, source, air)); + } + + @Test + public void waterloggingFollowsOnlyExplicitResultingWater() { + PlatformBlockState waterlogged = mock(PlatformBlockState.class); + PlatformBlockState dry = mock(PlatformBlockState.class); + PlatformBlockState water = mock(PlatformBlockState.class); + PlatformBlockState lava = mock(PlatformBlockState.class); + when(waterlogged.key()).thenReturn("minecraft:seagrass[waterlogged=true]"); + when(waterlogged.withProperty("waterlogged", "false")).thenReturn(dry); + when(water.isWater()).thenReturn(true); + + assertSame(waterlogged, IrisCarveModifier.normalizeWaterlogging(waterlogged, water)); + assertSame(dry, IrisCarveModifier.normalizeWaterlogging(waterlogged, null)); + assertSame(dry, IrisCarveModifier.normalizeWaterlogging(waterlogged, lava)); + assertSame(dry, IrisCarveModifier.resolveHydrologyState( + RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD), waterlogged, water, null)); + } + + @Test + public void noOverlayLeavesBaselineWaterloggingUntouched() { + PlatformBlockState waterlogged = mock(PlatformBlockState.class); + PlatformBlockState water = mock(PlatformBlockState.class); + MatterCavern flooded = new MatterCavern(true, "", (byte) 1); + + assertSame(waterlogged, IrisCarveModifier.normalizeHydrologyWaterlogging( + waterlogged, + flooded, + null, + water + )); + verify(waterlogged, never()).withProperty("waterlogged", "false"); + } + + @Test + public void explicitOverlayUsesComposedFluidIntentForWaterlogging() { + PlatformBlockState waterlogged = mock(PlatformBlockState.class); + PlatformBlockState dry = mock(PlatformBlockState.class); + PlatformBlockState water = mock(PlatformBlockState.class); + MatterCavern flooded = new MatterCavern(true, "", (byte) 1); + when(waterlogged.key()).thenReturn("minecraft:seagrass[waterlogged=true]"); + when(waterlogged.withProperty("waterlogged", "false")).thenReturn(dry); + when(water.isWater()).thenReturn(true); + + assertSame(waterlogged, IrisCarveModifier.normalizeHydrologyWaterlogging( + waterlogged, + flooded, + RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), + water + )); + assertSame(dry, IrisCarveModifier.normalizeHydrologyWaterlogging( + waterlogged, + flooded, + RiverCaveHydrology.of(RiverCaveAction.DRY_AIR), + water + )); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IObjectPlacerFluidHeightTest.java b/core/src/test/java/art/arcane/iris/engine/object/IObjectPlacerFluidHeightTest.java new file mode 100644 index 000000000..c7248e4a5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IObjectPlacerFluidHeightTest.java @@ -0,0 +1,71 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IObjectPlacerFluidHeightTest { + @Test + public void missingEngineOrComplexFallsBackToThePlacersScalarHead() { + IObjectPlacer withoutEngine = mock(IObjectPlacer.class, CALLS_REAL_METHODS); + when(withoutEngine.getFluidHeight()).thenReturn(63); + when(withoutEngine.getEngine()).thenReturn(null); + + assertEquals(63, withoutEngine.getFluidHeight(12, -7)); + + IObjectPlacer withoutComplex = mock(IObjectPlacer.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + when(withoutComplex.getFluidHeight()).thenReturn(63); + when(withoutComplex.getEngine()).thenReturn(engine); + when(engine.getComplex()).thenReturn(null); + + assertEquals(63, withoutComplex.getFluidHeight(12, -7)); + } + + @Test + public void engineLocalPlacersUseTheRoundedPerColumnRiverHead() { + IObjectPlacer placer = placer(127, 127, 130.6D); + + assertEquals(131, placer.getFluidHeight(12, -7)); + + verify(riverHead(placer)).get(12, -7); + } + + @Test + public void worldCoordinatePlacersShiftTheLocalRiverHeadExactlyOnce() { + IObjectPlacer placer = placer(63, 127, 130.6D); + + assertEquals(67, placer.getFluidHeight(12, -7)); + + verify(riverHead(placer)).get(12, -7); + } + + private static IObjectPlacer placer(int placerFluidHeight, int dimensionFluidHeight, double riverFluidHeight) { + IObjectPlacer placer = mock(IObjectPlacer.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisDimension dimension = mock(IrisDimension.class); + @SuppressWarnings("unchecked") + ProceduralStream riverHead = mock(ProceduralStream.class); + + when(placer.getFluidHeight()).thenReturn(placerFluidHeight); + when(placer.getEngine()).thenReturn(engine); + when(engine.getComplex()).thenReturn(complex); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getFluidHeight()).thenReturn(dimensionFluidHeight); + when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead); + when(riverHead.get(12, -7)).thenReturn(riverFluidHeight); + return placer; + } + + private static ProceduralStream riverHead(IObjectPlacer placer) { + return placer.getEngine().getComplex().getRiverWaterSurfaceStream(); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java new file mode 100644 index 000000000..29e73587b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java @@ -0,0 +1,212 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +import org.junit.Test; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisRiverConfigurationTest { + @Test + public void defaultsKeepRiverGenerationDisabledAndContained() { + IrisDimension dimension = new IrisDimension(); + + assertNotNull(dimension.getRivers()); + assertFalse(dimension.getRivers().isEnabled()); + assertEquals(IrisRiverWaterMode.SEA_LEVEL, dimension.getRivers().getWater().getMode()); + assertFalse(dimension.getRivers().getTopology().isRequireOcean()); + assertEquals(512, dimension.getRivers().getTopology().getCellSize()); + assertEquals(16, dimension.getRivers().getTopology().getMaxRouteReaches()); + assertEquals(0, dimension.getRivers().getTopology().getMinimumSourcesPerTile()); + assertEquals(64, dimension.getRivers().getTopology().getRoutingBasinCells()); + assertEquals(8D, dimension.getRivers().getTopology().getRoutingPlateauHeight(), 0D); + assertEquals(0.05D, dimension.getRivers().getTopology().getSource().getChance(), 0D); + assertEquals(0.035D, dimension.getRivers().getTopology().getSource().getInfluence(), 0D); + assertEquals(10D, dimension.getRivers().getTerrain().getMaxChannelWidth(), 0D); + assertEquals(4D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D); + assertEquals(10D, dimension.getRivers().getTerrain().getMaxDepth(), 0D); + assertEquals(IrisRiverCaveMode.SEALED, dimension.getRivers().getCaves().getMode()); + assertEquals(IrisRiverCaveFallback.SEALED, dimension.getRivers().getCaves().getFallback()); + assertEquals(IrisRiverExistingFluidPolicy.REJECT, + dimension.getRivers().getCaves().getExistingFluidPolicy()); + assertTrue(dimension.getRivers().getBiomes().getAllBiomeIds().isEmpty()); + assertNull(new IrisRegion().getRiverOverride()); + assertNull(new IrisBiome().getRiverOverride()); + } + + @Test + public void deserializesTypedDimensionRegionAndBiomeSettings() { + Gson gson = new Gson(); + IrisDimension dimension = gson.fromJson(""" + { + "rivers": { + "enabled": true, + "topology": { + "cellSize": 512, + "minimumSourcesPerTile": 2, + "routingBasinCells": 96, + "routingPlateauHeight": 12, + "requireOcean": false, + "source": {"chance": 0.27, "influence": 0.4} + }, + "terrain": { + "maxChannelWidth": 9, + "maxBankWidth": 2.5, + "maxDepth": 8, + "maxIncision": 36, + "terminalMode": "SUPPRESS" + }, + "water": {"mode": "TERRACED", "poolLength": 80}, + "biomes": { + "channel": ["river/channel"], + "floodedCave": ["river/grotto"] + }, + "caves": { + "mode": "FLOOD_CLOSED_COMPONENT", + "maxFloodVolume": 2048, + "existingFluidPolicy": "ALLOW_SAME" + } + } + } + """, IrisDimension.class); + IrisRegion region = gson.fromJson(""" + { + "riverOverride": { + "allowSources": false, + "routingPolicy": "AVOID", + "widthMultiplier": 0.75, + "bankBiomes": ["river/region-bank"] + } + } + """, IrisRegion.class); + IrisBiome biome = gson.fromJson(""" + { + "riverOverride": { + "routingPolicy": "BLOCK", + "caveEntryMultiplier": 0.2, + "floodedCaveBiomes": [] + } + } + """, IrisBiome.class); + + assertTrue(dimension.getRivers().isEnabled()); + assertEquals(512, dimension.getRivers().getTopology().getCellSize()); + assertEquals(2, dimension.getRivers().getTopology().getMinimumSourcesPerTile()); + assertEquals(96, dimension.getRivers().getTopology().getRoutingBasinCells()); + assertEquals(12D, dimension.getRivers().getTopology().getRoutingPlateauHeight(), 0D); + assertFalse(dimension.getRivers().getTopology().isRequireOcean()); + assertEquals(0.27D, dimension.getRivers().getTopology().getSource().getChance(), 0D); + assertEquals(9D, dimension.getRivers().getTerrain().getMaxChannelWidth(), 0D); + assertEquals(2.5D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D); + assertEquals(8D, dimension.getRivers().getTerrain().getMaxDepth(), 0D); + assertEquals(36, dimension.getRivers().getTerrain().getMaxIncision()); + assertEquals(IrisRiverTerminalMode.SUPPRESS, + dimension.getRivers().getTerrain().getTerminalMode()); + assertEquals(IrisRiverWaterMode.TERRACED, dimension.getRivers().getWater().getMode()); + assertEquals(Set.of("river/channel", "river/grotto"), + Set.copyOf(dimension.getRivers().getBiomes().getAllBiomeIds())); + assertEquals(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT, + dimension.getRivers().getCaves().getMode()); + assertEquals(IrisRiverExistingFluidPolicy.ALLOW_SAME, + dimension.getRivers().getCaves().getExistingFluidPolicy()); + + assertEquals(Boolean.FALSE, region.getRiverOverride().getAllowSources()); + assertEquals(IrisRiverRoutingPolicy.AVOID, region.getRiverOverride().getRoutingPolicy()); + assertEquals(Double.valueOf(0.75D), region.getRiverOverride().getWidthMultiplier()); + assertNull(region.getRiverOverride().getChannelBiomes()); + assertEquals(new KList<>("river/region-bank"), region.getRiverOverride().getBankBiomes()); + + assertEquals(IrisRiverRoutingPolicy.BLOCK, biome.getRiverOverride().getRoutingPolicy()); + assertNull(biome.getRiverOverride().getChannelBiomes()); + assertNotNull(biome.getRiverOverride().getFloodedCaveBiomes()); + assertTrue(biome.getRiverOverride().getFloodedCaveBiomes().isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + public void resolvesRiverPoolsOnlyWhenRiversAreEnabled() { + IrisRiverBiomes dimensionBiomes = new IrisRiverBiomes() + .setChannel(new KList<>("dimension-channel")); + IrisRiverOverride regionOverride = new IrisRiverOverride() + .setBankBiomes(new KList<>("region-bank")); + IrisRiverOverride biomeOverride = new IrisRiverOverride() + .setMouthBiomes(new KList<>("biome-mouth")) + .setFloodedCaveBiomes(new KList<>("biome-grotto")); + IrisDimension dimension = new IrisDimension() + .setRegions(new KList<>("region")) + .setRivers(new IrisRiverNetwork().setEnabled(true).setBiomes(dimensionBiomes)); + IrisRegion region = new IrisRegion() + .setLandBiomes(new KList<>("natural")) + .setRiverOverride(regionOverride); + IrisBiome natural = biome("natural").setRiverOverride(biomeOverride); + + IrisData data = mock(IrisData.class); + ResourceLoader regionLoader = mock(ResourceLoader.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(regionLoader.load("region")).thenReturn(region); + when(biomeLoader.load("natural")).thenReturn(natural); + when(biomeLoader.load("dimension-channel")).thenReturn(biome("dimension-channel")); + when(biomeLoader.load("region-bank")).thenReturn(biome("region-bank")); + when(biomeLoader.load("biome-mouth")).thenReturn(biome("biome-mouth")); + when(biomeLoader.load("biome-grotto")).thenReturn(biome("biome-grotto")); + + Set enabledKeys = keys(dimension.getReachableBiomes(() -> data)); + dimension.getRivers().setEnabled(false); + Set disabledKeys = keys(dimension.getReachableBiomes(() -> data)); + + assertEquals(Set.of("natural", "dimension-channel", "region-bank", "biome-mouth", "biome-grotto"), + enabledKeys); + assertEquals(Set.of("natural"), disabledKeys); + } + + @Test + @SuppressWarnings("unchecked") + public void separatesNaturalAndRiverOnlyRegionBiomes() { + IrisRegion region = new IrisRegion() + .setLandBiomes(new KList<>("natural-parent")) + .setRiverOverride(new IrisRiverOverride() + .setChannelBiomes(new KList<>("river-parent"))); + IrisBiome naturalParent = biome("natural-parent").setChildren(new KList<>("natural-child")); + IrisBiome naturalChild = biome("natural-child"); + IrisBiome riverParent = biome("river-parent").setChildren(new KList<>("river-child")); + IrisBiome riverChild = biome("river-child"); + + IrisData data = mock(IrisData.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(biomeLoader.load("natural-parent")).thenReturn(naturalParent); + when(biomeLoader.load("natural-child")).thenReturn(naturalChild); + when(biomeLoader.load("river-parent")).thenReturn(riverParent); + when(biomeLoader.load("river-child")).thenReturn(riverChild); + + assertEquals(Set.of("natural-parent"), Set.copyOf(region.getNaturalBiomeIds())); + assertEquals(Set.of("natural-parent", "river-parent"), Set.copyOf(region.getAllBiomeIds())); + assertEquals(Set.of("natural-parent", "natural-child"), + keys(region.getNaturalBiomes(() -> data))); + assertEquals(Set.of("natural-parent", "natural-child", "river-parent", "river-child"), + keys(region.getAllBiomes(() -> data))); + } + + private static IrisBiome biome(String loadKey) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(loadKey); + return biome; + } + + private static Set keys(KList biomes) { + return biomes.stream().map(IrisBiome::getLoadKey).collect(Collectors.toSet()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java index 3ba1c7fa4..5c785901f 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java @@ -55,4 +55,5 @@ public class BukkitChunkGeneratorInitializationModeTest { assertFalse(BukkitChunkGenerator.shouldPersistWorldRegistration(true)); assertTrue(BukkitChunkGenerator.shouldPersistWorldRegistration(false)); } + } diff --git a/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java b/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java new file mode 100644 index 000000000..68512354a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java @@ -0,0 +1,1095 @@ +package art.arcane.iris.engine.river; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class RiverNetworkTest { + @Test + public void mapsNegativeWorldCoordinatesWithFloorDivision() { + RiverNetwork network = new RiverNetwork(options(1L).build()); + RiverTerrainSampler terrain = slopedTerrain(false); + + assertEquals(new RiverNodeId(-1L, -1L), network.nodeAtWorld(-1, -1, terrain).id()); + assertEquals(new RiverNodeId(-1L, 0L), network.nodeAtWorld(-64, 0, terrain).id()); + assertEquals(new RiverNodeId(0L, 0L), network.nodeAtWorld(0, 0, terrain).id()); + assertEquals(-1, network.tileXForBlock(-1)); + assertEquals(0, network.tileXForBlock(0)); + } + + @Test + public void graphHasReciprocalCardinalsAndOneStableDiagonalPerSquare() { + RiverNetwork network = new RiverNetwork(options(2L).build()); + RiverNodeId origin = new RiverNodeId(0L, 0L); + List neighbors = network.neighbors(origin); + + assertTrue(neighbors.contains(new RiverNodeId(-1L, 0L))); + assertTrue(neighbors.contains(new RiverNodeId(1L, 0L))); + assertTrue(neighbors.contains(new RiverNodeId(0L, -1L))); + assertTrue(neighbors.contains(new RiverNodeId(0L, 1L))); + assertTrue(neighbors.size() >= 4 && neighbors.size() <= 8); + for (RiverNodeId neighbor : neighbors) { + assertTrue(network.neighbors(neighbor).contains(origin)); + } + + boolean ascending = network.neighbors(new RiverNodeId(0L, 0L)).contains(new RiverNodeId(1L, 1L)); + boolean descending = network.neighbors(new RiverNodeId(0L, 1L)).contains(new RiverNodeId(1L, 0L)); + assertNotEquals(ascending, descending); + } + + @Test + public void downstreamAlwaysLowersStrictRankAndCannotCycle() { + RiverNetwork network = new RiverNetwork(options(3L).routingNoiseWeight(20.0).build()); + RiverTerrainSampler terrain = flatTerrain(false); + RiverNode current = network.nodeAtCell(12L, -7L, terrain); + Set visited = new HashSet<>(); + + for (int step = 0; step < 64; step++) { + assertTrue(visited.add(current.id())); + RiverNode next = network.downstream(current.id(), terrain); + if (next == null) { + break; + } + assertTrue(compareRank(next, current) < 0); + assertTrue(next.ocean() || next.hydraulicHeight() <= current.hydraulicHeight()); + current = next; + } + } + + @Test + public void routingPlateausCanCrossSmallNaturalRisesWithoutRaisingTheHydraulicHead() { + RiverNetwork network = new RiverNetwork(options(71L) + .siteJitter(0D) + .routingPlateauHeight(16D) + .routingNoiseWeight(0D) + .build()); + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double naturalHeight(int blockX, int blockZ) { + if (blockX >= 0 && blockX < 64 && blockZ >= 0 && blockZ < 64) { + return 68D; + } + return blockX >= 64 ? 70D : 80D; + } + + @Override + public double routingCost(int blockX, int blockZ) { + return blockX >= 64 ? 0D : 10D; + } + }; + + RiverNode source = network.nodeAtCell(0L, 0L, terrain); + RiverNode next = network.downstream(source.id(), terrain); + + assertNotNull(next); + assertTrue(next.naturalHeight() > source.naturalHeight()); + assertTrue(next.hydraulicHeight() <= source.hydraulicHeight()); + } + + @Test + public void longRoutesConvergeIntoDeterministicBranches() { + RiverNetwork network = new RiverNetwork(options(72L) + .tileCells(4) + .maxRouteReaches(16) + .routingPlateauHeight(8D) + .requireOcean(false) + .build()); + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double naturalHeight(int blockX, int blockZ) { + return 512D - blockX * 0.25D; + } + + @Override + public double routingCost(int blockX, int blockZ) { + return StrictMath.abs(blockZ) * 0.5D; + } + + @Override + public double flowNoise(double x, double z) { + return StrictMath.sin(x / 384D) + StrictMath.cos(z / 512D); + } + }; + + RiverRoute route = network.trace(new RiverNodeId(0L, 4L), terrain); + RiverTile tile = network.buildTile(0, 0, terrain); + HashMap incoming = new HashMap<>(); + for (RiverReach reach : tile.reaches()) { + incoming.merge(reach.to().id(), 1, Integer::sum); + } + + assertEquals(16, route.edges().size()); + assertTrue(incoming.values().stream().anyMatch(count -> count >= 2)); + assertEquals(digest(tile), digest(network.buildTile(0, 0, terrain))); + } + + @Test + public void candidateRankingAndTracingDoNotMaterializeMeanderGeometry() { + RiverNetwork network = new RiverNetwork(options(75L) + .requireOcean(false) + .meanderSubdivisions(32) + .build()); + int[] meanderSamples = new int[1]; + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double reachRoutingCost(RiverRoutingContext context) { + return context.midpointX() + context.midpointZ(); + } + + @Override + public double meanderNoise(RiverMeanderContext context) { + meanderSamples[0]++; + return 0D; + } + }; + + assertFalse(network.downstreamCandidates(new RiverNodeId(0L, 0L), terrain).isEmpty()); + assertEquals(0, meanderSamples[0]); + assertFalse(network.trace(new RiverNodeId(0L, 0L), terrain).edges().isEmpty()); + assertEquals(0, meanderSamples[0]); + + RiverTile tile = network.buildTile(0, 0, terrain); + + assertFalse(tile.reaches().isEmpty()); + assertTrue(meanderSamples[0] > 0); + assertEquals(0, meanderSamples[0] % 33); + assertTrue(meanderSamples[0] / 33 <= 256); + } + + @Test + public void routingFieldAlignmentSelectsTheMostParallelDownstreamReach() { + RiverNetwork reference = new RiverNetwork(options(74L) + .siteJitter(0D) + .flowAlignmentWeight(0D) + .build()); + RiverNodeId sourceId = new RiverNodeId(0L, 0L); + RiverTerrainSampler flat = flatTerrain(false); + RiverNode source = reference.nodeAtCell(sourceId.cellX(), sourceId.cellZ(), flat); + List candidates = reference.downstreamCandidates(sourceId, flat); + assertTrue(candidates.size() > 1); + RiverNode target = null; + double targetGap = 0D; + for (RiverNode proposed : candidates) { + double proposedX = proposed.x() - source.x(); + double proposedZ = proposed.z() - source.z(); + double proposedLength = StrictMath.hypot(proposedX, proposedZ); + double firstAlignment = -1D; + double secondAlignment = -1D; + RiverNode first = null; + for (RiverNode candidate : candidates) { + double candidateX = candidate.x() - source.x(); + double candidateZ = candidate.z() - source.z(); + double candidateLength = StrictMath.hypot(candidateX, candidateZ); + double alignment = StrictMath.abs( + proposedX * candidateX + proposedZ * candidateZ + ) / (proposedLength * candidateLength); + if (alignment > firstAlignment) { + secondAlignment = firstAlignment; + firstAlignment = alignment; + first = candidate; + } else if (alignment > secondAlignment) { + secondAlignment = alignment; + } + } + double gap = firstAlignment - secondAlignment; + if (gap > targetGap) { + targetGap = gap; + target = first; + } + } + assertNotNull(target); + assertTrue(targetGap > 0.000001D); + double tangentX = target.x() - source.x(); + double tangentZ = target.z() - source.z(); + RiverTerrainSampler guidedTerrain = new TestTerrain(false) { + @Override + public double flowNoise(double x, double z) { + return x * tangentZ - z * tangentX; + } + }; + RiverNetwork guided = new RiverNetwork(options(74L) + .siteJitter(0D) + .flowAlignmentWeight(10_000D) + .build()); + + RiverNode downstream = guided.downstream(sourceId, guidedTerrain); + + assertNotNull(downstream); + assertEquals(target.id(), downstream.id()); + } + + @Test + public void basinPotentialCarriesLongRoutesAcrossLocalTerrainRises() { + RiverNetwork network = new RiverNetwork(options(73L) + .siteJitter(0D) + .maxRouteReaches(16) + .routingPlateauHeight(8D) + .terrainHeightWeight(0D) + .requireOcean(false) + .build()); + RiverTerrainSampler flat = flatTerrain(false); + RiverRoute baseline = network.trace(new RiverNodeId(0L, 0L), flat); + HashSet raisedNodes = new HashSet<>(); + RiverNodeId baselineNode = new RiverNodeId(0L, 0L); + for (int edgeIndex = 0; edgeIndex < baseline.edges().size(); edgeIndex++) { + RiverEdgeId edge = baseline.edges().get(edgeIndex); + baselineNode = edge.first().equals(baselineNode) ? edge.second() : edge.first(); + if ((edgeIndex & 1) == 0) { + raisedNodes.add(baselineNode); + } + } + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double naturalHeight(int blockX, int blockZ) { + RiverNodeId id = new RiverNodeId(Math.floorDiv(blockX, 64), Math.floorDiv(blockZ, 64)); + return raisedNodes.contains(id) ? 104D : 64D; + } + }; + + RiverRoute route = network.trace(new RiverNodeId(0L, 0L), terrain); + RiverNode current = network.nodeAtCell(0L, 0L, terrain); + boolean crossedNaturalRise = false; + for (RiverEdgeId edge : route.edges()) { + RiverNode next = network.downstream(current.id(), terrain); + assertNotNull(next); + assertEquals(edge, RiverEdgeId.of(current.id(), next.id())); + crossedNaturalRise |= next.naturalHeight() > current.naturalHeight(); + assertTrue(next.rank() < current.rank()); + assertTrue(next.hydraulicHeight() <= current.hydraulicHeight()); + current = next; + } + + assertEquals(16, route.edges().size()); + assertTrue(crossedNaturalRise); + } + + @Test + public void rejectedBestReachReroutesToAnotherStrictlyDownhillCandidate() { + RiverNetwork network = new RiverNetwork(options(31L).downstreamCandidateLimit(4).build()); + RiverNodeId source = new RiverNodeId(0L, 0L); + RiverTerrainSampler terrain = slopedTerrain(false); + List candidates = network.downstreamCandidates(source, terrain); + assertTrue(candidates.size() > 1); + RiverEdgeId rejected = RiverEdgeId.of(source, candidates.getFirst().id()); + + RiverTerrainSampler reroutingTerrain = new TestTerrain(false) { + @Override + public boolean allowsReach(RiverRoutingContext context) { + return !context.edgeId().equals(rejected); + } + }; + RiverNode rerouted = network.downstream(source, reroutingTerrain); + assertNotNull(rerouted); + assertNotEquals(candidates.getFirst().id(), rerouted.id()); + assertTrue(rerouted.rank() < network.nodeAtCell(0L, 0L, reroutingTerrain).rank()); + } + + @Test + public void failedContinuationStopsAtTheFirstPhysicallyValidReach() { + RiverNetwork network = new RiverNetwork(options(32L).reachChance(0D).build()); + int[] feasibilityChecks = new int[1]; + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public boolean allowsReach(RiverRoutingContext context) { + feasibilityChecks[0]++; + return true; + } + }; + + assertEquals(null, network.downstream(new RiverNodeId(0L, 0L), terrain)); + assertEquals(1, feasibilityChecks[0]); + } + + @Test + public void classifiesCompleteOceanRoutesAsWetAndIncompleteRoutesAsDryOrSuppressed() { + RiverNodeId source = new RiverNodeId(0L, 0L); + RiverNetwork wetNetwork = new RiverNetwork(options(4L).maxRouteReaches(16).build()); + RiverTerrainSampler oceanNeighbor = new TestTerrain(false) { + @Override + public boolean isOcean(int blockX, int blockZ) { + return Math.floorDiv(blockX, 64) != 0 || Math.floorDiv(blockZ, 64) != 0; + } + }; + RiverRoute wet = wetNetwork.trace(source, oceanNeighbor); + assertEquals(RiverRouteState.WET, wet.state()); + assertFalse(wet.edges().isEmpty()); + + RiverNetwork dryNetwork = new RiverNetwork(options(4L).maxRouteReaches(6).build()); + RiverRoute dry = dryNetwork.trace(source, slopedTerrain(false)); + assertEquals(RiverRouteState.DRY, dry.state()); + assertFalse(dry.edges().isEmpty()); + + RiverNetwork suppressedNetwork = new RiverNetwork( + options(4L).maxRouteReaches(6).dryChannelChance(0.0).build() + ); + RiverRoute suppressed = suppressedNetwork.trace(source, slopedTerrain(false)); + assertEquals(RiverRouteState.SUPPRESSED, suppressed.state()); + assertTrue(suppressed.edges().isEmpty()); + } + + @Test + public void oceanOptionalRoutesRemainWetAndExposeTerminalState() { + RiverNetwork network = new RiverNetwork( + options(41L).maxRouteReaches(3).requireOcean(false).build() + ); + RiverRoute route = network.trace(new RiverNodeId(0L, 0L), slopedTerrain(false)); + assertEquals(RiverRouteState.WET, route.state()); + assertFalse(route.oceanConnected()); + assertTrue(route.terminal()); + assertFalse(route.edges().isEmpty()); + } + + @Test + public void sampledTerminalPoliciesOverrideTheNetworkFallback() { + RiverNodeId source = new RiverNodeId(0L, 0L); + RiverNetwork requiredOcean = new RiverNetwork(options(42L).maxRouteReaches(3).requireOcean(true).build()); + RiverTerrainSampler wetTerminal = new TestTerrain(false) { + @Override + public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { + return RiverTerminalPolicy.WET; + } + }; + RiverTerrainSampler suppressedTerminal = new TestTerrain(false) { + @Override + public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { + return RiverTerminalPolicy.SUPPRESS; + } + }; + + assertEquals(RiverRouteState.WET, requiredOcean.trace(source, wetTerminal).state()); + assertEquals(RiverRouteState.SUPPRESSED, requiredOcean.trace(source, suppressedTerminal).state()); + + RiverNetwork optionalOcean = new RiverNetwork(options(43L).maxRouteReaches(3).requireOcean(false).build()); + RiverTerrainSampler dryTerminal = new TestTerrain(false) { + @Override + public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { + return RiverTerminalPolicy.DRY; + } + }; + assertEquals(RiverRouteState.DRY, optionalOcean.trace(source, dryTerminal).state()); + } + + @Test + public void sourceAndReachPoliciesGateWholeGraphEvents() { + RiverNetwork sourceNetwork = new RiverNetwork(options(5L).build()); + RiverRoute sourceSuppressed = sourceNetwork.trace(new RiverNodeId(0L, 0L), new TestTerrain(false) { + @Override + public double sourceChanceMultiplier(int blockX, int blockZ) { + return 0.0; + } + }); + assertEquals(RiverRouteState.SUPPRESSED, sourceSuppressed.state()); + assertTrue(sourceSuppressed.edges().isEmpty()); + + RiverNetwork reachNetwork = new RiverNetwork(options(5L).reachChance(0.0).build()); + RiverRoute reachSuppressed = reachNetwork.trace(new RiverNodeId(0L, 0L), slopedTerrain(false)); + assertEquals(RiverRouteState.SUPPRESSED, reachSuppressed.state()); + assertTrue(reachSuppressed.edges().isEmpty()); + + RiverTerrainSampler blocked = new TestTerrain(false) { + @Override + public boolean allowsRiver(int blockX, int blockZ) { + return blockX < 0; + } + }; + RiverNode blockedNode = sourceNetwork.nodeAtCell(0L, 0L, blocked); + assertFalse(blockedNode.riverAllowed()); + assertEquals(null, sourceNetwork.downstream(blockedNode.id(), blocked)); + } + + @Test + public void impossibleSourceRollsSkipExpensiveTerrainSettings() { + int[] sampledSettings = new int[1]; + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double maximumSourceChanceMultiplier() { + return 0D; + } + + @Override + public double sourceChanceMultiplier(int blockX, int blockZ) { + sampledSettings[0]++; + return 1D; + } + + @Override + public double naturalHeight(int blockX, int blockZ) { + throw new AssertionError("A source rejected by its upper bound must not sample terrain"); + } + }; + RiverNetwork network = new RiverNetwork(options(53L) + .sourceChance(0.05D) + .minimumSourcesPerTile(0) + .build()); + + RiverRoute route = network.trace(new RiverNodeId(0L, 0L), terrain); + + assertEquals(RiverRouteState.SUPPRESSED, route.state()); + assertTrue(route.edges().isEmpty()); + assertEquals(0, sampledSettings[0]); + } + + @Test + public void minimumSourceFloorKeepsEligibleRoutingTilesActive() { + RiverNetwork network = new RiverNetwork(options(52L) + .maxRouteReaches(4) + .minimumSourcesPerTile(1) + .sourceChance(0.000001D) + .requireOcean(false) + .build()); + RiverTerrainSampler terrain = slopedTerrain(false); + + for (int tileX = -2; tileX <= 2; tileX++) { + for (int tileZ = -2; tileZ <= 2; tileZ++) { + assertFalse(network.buildTile(tileX, tileZ, terrain).reaches().isEmpty()); + } + } + + RiverNetwork disabled = new RiverNetwork(options(52L) + .maxRouteReaches(4) + .minimumSourcesPerTile(1) + .sourceChance(0D) + .requireOcean(false) + .build()); + assertTrue(disabled.buildTile(0, 0, terrain).reaches().isEmpty()); + } + + @Test + public void minimumSourceFloorDoesNotResolveEveryRandomCandidate() { + int[] sampledSettings = new int[1]; + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double maximumSourceChanceMultiplier() { + return 1D; + } + + @Override + public double sourceChanceMultiplier(int blockX, int blockZ) { + sampledSettings[0]++; + return 1D; + } + }; + RiverNetwork network = new RiverNetwork(options(54L) + .maxRouteReaches(1) + .minimumSourcesPerTile(1) + .sourceChance(0.000000001D) + .requireOcean(false) + .build()); + + RiverTile tile = network.buildTile(0, 0, terrain); + + assertFalse(tile.reaches().isEmpty()); + assertTrue(sampledSettings[0] <= 25); + } + + @Test + public void minimumSourceFloorRejectsNonFiniteLocalChanceMultipliers() { + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double sourceChanceMultiplier(int blockX, int blockZ) { + return Double.NaN; + } + }; + RiverNetwork network = new RiverNetwork(options(55L) + .maxRouteReaches(4) + .minimumSourcesPerTile(1) + .sourceChance(1D) + .requireOcean(false) + .build()); + + assertTrue(network.buildTile(0, 0, terrain).reaches().isEmpty()); + } + + @Test + public void tileBuildIsDeterministicUnderParallelEvaluation() throws Exception { + RiverNetwork network = new RiverNetwork(options(6L).build()); + RiverTerrainSampler terrain = slopedTerrain(false); + long expected = digest(network.buildTile(-1, 0, terrain)); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + ArrayList> tasks = new ArrayList<>(); + for (int task = 0; task < 12; task++) { + tasks.add(() -> digest(network.buildTile(-1, 0, terrain))); + } + List> results = executor.invokeAll(tasks); + for (Future result : results) { + assertEquals(expected, result.get().longValue()); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + public void tileBuildPreservesPinnedOutputDigests() { + RiverTerrainSampler terrain = slopedTerrain(false); + List actual = List.of( + digest(new RiverNetwork(options(6L).build()).buildTile(-1, 0, terrain)), + digest(new RiverNetwork(options(6L).build()).buildTile(0, 0, terrain)), + digest(new RiverNetwork(options(6L).build()).buildTile(1, 1, terrain)), + digest(new RiverNetwork(options(61L).build()).buildTile(-2, -1, terrain)) + ); + + assertEquals(List.of( + -6381557853649488119L, + 8976828799821705624L, + 6774369135184562919L, + -6208289191927305511L + ), actual); + } + + @Test + public void adjacentTilesShareIdenticalPinnedReachGeometry() { + RiverNetwork network = new RiverNetwork(options(7L).build()); + RiverTerrainSampler terrain = slopedTerrain(false); + RiverTile first = network.buildTile(0, 0, terrain); + RiverTile second = network.buildTile(1, 0, terrain); + HashMap secondById = new HashMap<>(); + for (RiverReach reach : second.reaches()) { + secondById.put(reach.id(), reach); + } + int sharedCount = 0; + for (RiverReach candidate : first.reaches()) { + RiverReach matching = secondById.get(candidate.id()); + if (matching == null) { + continue; + } + sharedCount++; + assertReachEquals(candidate, matching); + assertEquals(candidate.from().x(), candidate.polyline().x(0), 0.0); + assertEquals(candidate.from().z(), candidate.polyline().z(0), 0.0); + int last = candidate.polyline().size() - 1; + assertEquals(candidate.to().x(), candidate.polyline().x(last), 0.0); + assertEquals(candidate.to().z(), candidate.polyline().z(last), 0.0); + } + assertTrue(sharedCount > 0); + } + + @Test + public void geometryEnvelopeKeepsWideReachesIdenticalAcrossTileBoundaries() { + RiverNetwork network = new RiverNetwork(options(71L) + .maxRouteReaches(1) + .siteJitter(0D) + .maximumReachRadius(256D) + .channelWidth(512D) + .maxChannelWidth(512D) + .bankWidth(0D) + .meanderStrength(0D) + .meanderSubdivisions(1) + .build()); + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double sourceChanceMultiplier(int blockX, int blockZ) { + return blockX == -96 ? 1D : 0D; + } + }; + RiverTile west = network.buildTile(0, 0, terrain); + RiverTile east = network.buildTile(1, 0, terrain); + + boolean present = false; + for (int z = 0; z < 128; z++) { + RiverSample westSample = west.sample(128D, z); + RiverSample eastSample = east.sample(128D, z); + assertEquals(westSample, eastSample); + present |= westSample.present(); + } + assertTrue(present); + } + + @Test + public void mergedReachesIncreaseFlowAndOrderAndCanBeSampled() { + RiverNetwork network = new RiverNetwork(options(8L).sourceChance(1.0).build()); + RiverTile tile = network.buildTile(0, 0, slopedTerrain(false)); + boolean merged = false; + boolean sampled = false; + + for (RiverReach reach : tile.reaches()) { + assertEquals(1 + (31 - Integer.numberOfLeadingZeros(reach.flow())), reach.order()); + if (reach.flow() > 1) { + merged = true; + } + int middle = reach.polyline().size() / 2; + RiverSample sample = tile.sample(reach.polyline().x(middle), reach.polyline().z(middle)); + if (sample.present()) { + sampled = true; + assertTrue(sample.carveWeight() > 0.0); + assertTrue(sample.alongReach() >= 0.0 && sample.alongReach() <= 1.0); + } + } + + assertTrue(merged); + assertTrue(sampled); + assertThrows(UnsupportedOperationException.class, () -> tile.reaches().add(tile.reaches().getFirst())); + } + + @Test + public void candidateAnchorsAreStableUniqueAndOwnedByOneTile() { + RiverNetwork network = new RiverNetwork(options(9L).build()); + RiverTile tile = network.buildTile(-1, -1, slopedTerrain(false)); + List first = tile.candidateAnchors(24.0, 77L); + List second = tile.candidateAnchors(24.0, 77L); + Set identities = new HashSet<>(); + + assertEquals(first, second); + for (RiverAnchor anchor : first) { + assertTrue(identities.add(anchor.stableId())); + assertTrue(anchor.x() >= tile.minimumX() && anchor.x() < tile.maximumX()); + assertTrue(anchor.z() >= tile.minimumZ() && anchor.z() < tile.maximumZ()); + assertTrue(anchor.alongReach() >= 0.0 && anchor.alongReach() <= 1.0); + } + assertThrows(UnsupportedOperationException.class, () -> first.add(first.getFirst())); + } + + @Test + public void spatialIndexReducesColumnCandidatesAndBoundsAnchorQueries() { + RiverNetwork network = new RiverNetwork(options(51L).tileCells(8).build()); + RiverTile tile = network.buildTile(0, 0, slopedTerrain(false)); + int minimumCandidates = Integer.MAX_VALUE; + for (int x = tile.minimumX() + 32; x < tile.maximumX(); x += 64) { + for (int z = tile.minimumZ() + 32; z < tile.maximumZ(); z += 64) { + minimumCandidates = StrictMath.min(minimumCandidates, tile.sampleCandidateCount(x, z)); + } + } + assertTrue(tile.reaches().size() > 8); + assertTrue(minimumCandidates < tile.reaches().size() / 2); + + double queryMaximumX = tile.minimumX() + 64.0; + double queryMaximumZ = tile.minimumZ() + 64.0; + List anchors = tile.candidateAnchors( + tile.minimumX(), + tile.minimumZ(), + queryMaximumX, + queryMaximumZ, + 12.0, + 12L + ); + for (RiverAnchor anchor : anchors) { + assertTrue(anchor.x() >= tile.minimumX() && anchor.x() < queryMaximumX); + assertTrue(anchor.z() >= tile.minimumZ() && anchor.z() < queryMaximumZ); + } + } + + @Test + public void sampleReportsNormalizedClosestPositionAndTerminalMetadata() { + RiverNode from = new RiverNode( + new RiverNodeId(0L, 0L), + 0.0, + 0.0, + 20.0, + 20.0, + 20.0, + 20.0, + false, + true + ); + RiverNode to = new RiverNode( + new RiverNodeId(1L, 0L), + 100.0, + 0.0, + 10.0, + 10.0, + 10.0, + 10.0, + false, + true + ); + RiverReach reach = new RiverReach( + RiverEdgeId.of(from.id(), to.id()), + from, + to, + RiverRouteState.DRY, + 1, + 1, + 10.0, + 5.0, + 3.0, + false, + true, + new RiverPolyline(new double[]{0.0, 100.0}, new double[]{0.0, 0.0}) + ); + RiverTile tile = new RiverTile(0, 0, 0, -32, 128, 32, List.of(reach)); + + RiverSample sample = tile.sample(75.0, 0.0); + assertTrue(sample.present()); + assertEquals(0.75, sample.alongReach(), 0.0000001); + assertEquals(RiverSection.DRY_CHANNEL, sample.section()); + assertTrue(sample.terminal()); + } + + @Test + public void uncoveredNarrowReachDoesNotMaskCoveringWideReach() { + RiverNode narrowFrom = node(0L, 0L, 0D, 0D); + RiverNode narrowTo = node(1L, 0L, 100D, 0D); + RiverNode wideFrom = node(0L, 1L, 0D, 4D); + RiverNode wideTo = node(1L, 1L, 100D, 4D); + RiverReach narrow = reach(narrowFrom, narrowTo, 2D, 0D); + RiverReach wide = reach(wideFrom, wideTo, 8D, 0D); + RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(narrow, wide)); + + RiverSample sample = tile.sample(50D, 1.5D); + + assertTrue(sample.present()); + assertEquals(wide.id(), sample.reachId()); + } + + @Test + public void footprintSamplingFindsChannelThatPointSamplingMisses() { + RiverNode from = node(0L, 0L, 0D, 0D); + RiverNode to = node(1L, 0L, 100D, 0D); + RiverReach reach = reach(from, to, 2D, 0D); + RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); + + assertFalse(tile.sample(50D, 2.5D).present()); + RiverSample sample = tile.sampleFootprint(48D, 0.5D, 52D, 4.5D); + + assertTrue(sample.present()); + assertEquals(RiverSection.CHANNEL, sample.section()); + assertEquals(0.5D, sample.distance(), 0D); + } + + @Test + public void footprintSamplingFindsBankThatPointSamplingMisses() { + RiverNode from = node(0L, 0L, 0D, 0D); + RiverNode to = node(1L, 0L, 100D, 0D); + RiverReach reach = reach(from, to, 2D, 3D); + RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); + + assertFalse(tile.sample(50D, 5D).present()); + RiverSample sample = tile.sampleFootprint(48D, 2D, 52D, 8D); + + assertTrue(sample.present()); + assertEquals(RiverSection.BANK, sample.section()); + assertEquals(2D, sample.distance(), 0D); + } + + @Test + public void footprintSamplingRejectsBoundingBoxOnlyCornerOverlap() { + RiverNode from = node(0L, 0L, 0D, 0D); + RiverNode to = node(1L, 0L, 100D, 0D); + RiverReach reach = reach(from, to, 1D, 2D); + RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); + + RiverSample sample = tile.sampleFootprint(101.8D, 1.8D, 104D, 4D); + + assertFalse(sample.present()); + } + + @Test + public void terrainSamplerDimensionsAreClampedAfterOrderScaling() { + RiverNetwork network = new RiverNetwork(options(61L) + .maxChannelWidth(10D) + .maxBankWidth(3D) + .maxDepth(6D) + .build()); + RiverTerrainSampler styled = new TestTerrain(false) { + @Override + public double meanderNoise(RiverMeanderContext context) { + return 0.0; + } + + @Override + public double channelWidth(RiverRoutingContext context, double fallback) { + return 20.0; + } + + @Override + public double bankWidth(RiverRoutingContext context, double fallback) { + return 9.0; + } + + @Override + public double depth(RiverRoutingContext context, double fallback) { + return 7.0; + } + }; + RiverTile tile = network.buildTile(0, 0, styled); + RiverReach reach = tile.reaches().getFirst(); + double deltaX = reach.to().x() - reach.from().x(); + double deltaZ = reach.to().z() - reach.from().z(); + + assertEquals(10D, reach.width(), 0D); + assertEquals(3D, reach.bankWidth(), 0D); + assertEquals(6D, reach.depth(), 0D); + for (int point = 0; point < reach.polyline().size(); point++) { + double pointDeltaX = reach.polyline().x(point) - reach.from().x(); + double pointDeltaZ = reach.polyline().z(point) - reach.from().z(); + assertEquals(0.0, pointDeltaX * deltaZ - pointDeltaZ * deltaX, 0.0000001); + } + } + + @Test + public void reachFeasibilityReceivesTheFinalPinnedMeanderPolyline() { + RiverNetwork network = new RiverNetwork(options(62L).meanderStrength(20D).build()); + boolean[] observedMeander = new boolean[1]; + RiverTerrainSampler terrain = new TestTerrain(false) { + @Override + public double meanderNoise(RiverMeanderContext context) { + return 1D; + } + + @Override + public boolean allowsReach(RiverRoutingContext context) { + RiverPolyline polyline = context.polyline(); + double deltaX = context.to().x() - context.from().x(); + double deltaZ = context.to().z() - context.from().z(); + for (int point = 1; point < polyline.size() - 1; point++) { + double pointX = polyline.x(point) - context.from().x(); + double pointZ = polyline.z(point) - context.from().z(); + if (StrictMath.abs(pointX * deltaZ - pointZ * deltaX) > 0.000001D) { + observedMeander[0] = true; + return false; + } + } + return true; + } + }; + + RiverNode downstream = network.downstream(new RiverNodeId(0L, 0L), terrain); + + assertTrue(observedMeander[0]); + assertEquals(null, downstream); + } + + @Test + public void optionsRejectNonFiniteOrUnsafeValues() { + assertThrows(IllegalArgumentException.class, () -> options(10L).sourceChance(Double.NaN).build()); + assertThrows(IllegalArgumentException.class, () -> options(10L).cellSize(0).build()); + assertThrows(IllegalArgumentException.class, () -> options(10L).routingBasinCells(7).build()); + assertThrows(IllegalArgumentException.class, () -> options(10L).routingPlateauHeight(0D).build()); + assertThrows(IllegalArgumentException.class, () -> options(10L).hydraulicBaseHeight(Double.NaN).build()); + assertThrows(IllegalArgumentException.class, () -> options(10L).channelWidth(-1.0).build()); + assertThrows(IllegalArgumentException.class, () -> options(10L) + .tileCells(1) + .minimumSourcesPerTile(2) + .build()); + } + + @Test + public void derivedComplexityAllowsDefaultsAndRejectsExpensiveTopologies() { + double defaultRadius = 10D * 0.5D + 4D; + RiverTopologyComplexity.Estimate defaults = RiverTopologyComplexity.estimate( + 768, + 4, + 0.35D, + 4, + defaultRadius, + 72D, + 8 + ); + assertTrue(defaults.violations().toString(), defaults.safe()); + assertEquals(2L, defaults.geometryPaddingCells()); + assertEquals(16L, defaults.sourceWindowAxis()); + assertEquals(256L, defaults.sourceWindowCells()); + assertEquals(1_024L, defaults.maximumRouteScanSteps()); + + double expensiveRadius = defaultRadius; + RiverTopologyComplexity.Estimate expensive = RiverTopologyComplexity.estimate( + 768, + 4, + 0.35D, + 48, + expensiveRadius, + 72D, + 8 + ); + assertFalse(expensive.safe()); + assertTrue(expensive.violations().toString(), expensive.maximumRouteScanSteps() + > RiverTopologyComplexity.MAXIMUM_ROUTE_SCAN_STEPS); + + double pathologicalRadius = 2048D * 0.5D + 2048D; + RiverTopologyComplexity.Estimate pathological = RiverTopologyComplexity.estimate( + 64, + 64, + 0.49D, + 256, + pathologicalRadius, + 1024D, + 64 + ); + assertFalse(pathological.safe()); + assertTrue(pathological.sourceWindowCells() > 400_000L); + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> options(10L) + .cellSize(64) + .tileCells(64) + .siteJitter(0.49D) + .maxRouteReaches(256) + .maximumReachRadius(pathologicalRadius) + .meanderStrength(1024D) + .meanderSubdivisions(64) + .build() + ); + assertTrue(failure.getMessage(), failure.getMessage().contains("source window")); + } + + private static RiverNetworkOptions.Builder options(long seed) { + return RiverNetworkOptions.builder(seed) + .cellSize(64) + .tileCells(2) + .siteJitter(0.25) + .maxRouteReaches(12) + .sourceChance(1.0) + .reachChance(1.0) + .dryChannelChance(1.0) + .requireOcean(true) + .terrainHeightWeight(1.0) + .routingNoiseWeight(0.0) + .oceanAttraction(256.0) + .channelWidth(8.0) + .bankWidth(6.0) + .depth(3.0) + .meanderStrength(12.0) + .meanderSubdivisions(6); + } + + private static RiverNode node(long cellX, long cellZ, double x, double z) { + return new RiverNode( + new RiverNodeId(cellX, cellZ), + x, + z, + 64D, + 64D, + 64D, + 64D, + false, + true + ); + } + + private static RiverReach reach(RiverNode from, RiverNode to, double width, double bankWidth) { + return new RiverReach( + RiverEdgeId.of(from.id(), to.id()), + from, + to, + RiverRouteState.WET, + 1, + 1, + width, + bankWidth, + 3D, + false, + false, + new RiverPolyline( + new double[]{from.x(), to.x()}, + new double[]{from.z(), to.z()} + ) + ); + } + + private static RiverTerrainSampler slopedTerrain(boolean ocean) { + return new TestTerrain(ocean); + } + + private static RiverTerrainSampler flatTerrain(boolean ocean) { + return new RiverTerrainSampler() { + @Override + public double naturalHeight(int blockX, int blockZ) { + return 64.0; + } + + @Override + public boolean isOcean(int blockX, int blockZ) { + return ocean && blockX >= 224; + } + }; + } + + private static int compareRank(RiverNode first, RiverNode second) { + if (first.ocean() != second.ocean()) { + return first.ocean() ? -1 : 1; + } + int comparison = Double.compare(first.rank(), second.rank()); + if (comparison != 0) { + return comparison; + } + int hydraulicComparison = Double.compare(first.hydraulicHeight(), second.hydraulicHeight()); + return hydraulicComparison != 0 ? hydraulicComparison : first.id().compareTo(second.id()); + } + + private static long digest(RiverTile tile) { + long hash = 0xCBF29CE484222325L; + for (RiverReach reach : tile.reaches()) { + hash = RiverNetwork.mix(hash ^ reach.id().stableId()); + hash = digestNode(hash, reach.from()); + hash = digestNode(hash, reach.to()); + hash = RiverNetwork.mix(hash ^ reach.flow()); + hash = RiverNetwork.mix(hash ^ reach.order()); + hash = RiverNetwork.mix(hash ^ reach.state().ordinal()); + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.width())); + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bankWidth())); + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.depth())); + hash = RiverNetwork.mix(hash ^ (reach.mouth() ? 1L : 0L)); + hash = RiverNetwork.mix(hash ^ (reach.terminal() ? 1L : 0L)); + for (int point = 0; point < reach.polyline().size(); point++) { + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.polyline().x(point))); + hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.polyline().z(point))); + } + } + return hash; + } + + private static long digestNode(long hash, RiverNode node) { + long result = RiverNetwork.mix(hash ^ node.id().stableId()); + result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.x())); + result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.z())); + result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.naturalHeight())); + result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.hydraulicHeight())); + result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.rank())); + result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.routingScore())); + result = RiverNetwork.mix(result ^ (node.ocean() ? 1L : 0L)); + return RiverNetwork.mix(result ^ (node.riverAllowed() ? 1L : 0L)); + } + + private static void assertReachEquals(RiverReach first, RiverReach second) { + assertEquals(first.id(), second.id()); + assertEquals(first.state(), second.state()); + assertEquals(first.flow(), second.flow()); + assertEquals(first.order(), second.order()); + assertEquals(first.width(), second.width(), 0.0); + assertEquals(first.bankWidth(), second.bankWidth(), 0.0); + assertEquals(first.depth(), second.depth(), 0.0); + assertEquals(first.mouth(), second.mouth()); + assertEquals(first.terminal(), second.terminal()); + assertEquals(first.from(), second.from()); + assertEquals(first.to(), second.to()); + assertEquals(first.polyline().size(), second.polyline().size()); + for (int point = 0; point < first.polyline().size(); point++) { + assertEquals(first.polyline().x(point), second.polyline().x(point), 0.0); + assertEquals(first.polyline().z(point), second.polyline().z(point), 0.0); + } + } + + private static class TestTerrain implements RiverTerrainSampler { + private final boolean ocean; + + private TestTerrain(boolean ocean) { + this.ocean = ocean; + } + + @Override + public double naturalHeight(int blockX, int blockZ) { + return 256.0 - blockX; + } + + @Override + public boolean isOcean(int blockX, int blockZ) { + return ocean && blockX >= 224; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java new file mode 100644 index 000000000..25334414f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java @@ -0,0 +1,305 @@ +package art.arcane.iris.engine.river; + +import org.junit.Test; + +import java.util.List; +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.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 RiverTileCacheTest { + @Test + public void concurrentRequestsBuildOneTileOnce() throws Exception { + int requestCount = 16; + AtomicInteger builds = new AtomicInteger(); + CountDownLatch ready = new CountDownLatch(requestCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch builderEntered = new CountDownLatch(1); + CountDownLatch releaseBuilder = new CountDownLatch(1); + RiverTileCache cache = new RiverTileCache(8, (tileX, tileZ) -> { + builds.incrementAndGet(); + builderEntered.countDown(); + assertTrue(releaseBuilder.await(5, TimeUnit.SECONDS)); + return emptyTile(tileX, tileZ); + }); + ExecutorService executor = Executors.newFixedThreadPool(requestCount); + try { + @SuppressWarnings("unchecked") + Future[] futures = new Future[requestCount]; + for (int request = 0; request < requestCount; request++) { + futures[request] = executor.submit(() -> { + ready.countDown(); + assertTrue(start.await(5, TimeUnit.SECONDS)); + return cache.get(-7, -11); + }); + } + assertTrue(ready.await(5, TimeUnit.SECONDS)); + start.countDown(); + assertTrue(builderEntered.await(5, TimeUnit.SECONDS)); + releaseBuilder.countDown(); + + RiverTile first = futures[0].get(5, TimeUnit.SECONDS); + for (Future future : futures) { + assertSame(first, future.get(5, TimeUnit.SECONDS)); + } + assertEquals(-7, first.tileX()); + assertEquals(-11, first.tileZ()); + assertEquals(1, builds.get()); + assertEquals(1, cache.completedSize()); + } finally { + releaseBuilder.countDown(); + executor.shutdownNow(); + cache.close(); + } + } + + @Test + public void differentKeysBuildConcurrentlyOutsideCacheLock() throws Exception { + CountDownLatch buildersEntered = new CountDownLatch(2); + CountDownLatch releaseBuilders = new CountDownLatch(1); + RiverTileCache cache = new RiverTileCache(4, (tileX, tileZ) -> { + buildersEntered.countDown(); + assertTrue(releaseBuilders.await(5, TimeUnit.SECONDS)); + return emptyTile(tileX, tileZ); + }); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> cache.get(1, 1)); + Future second = executor.submit(() -> cache.get(2, 2)); + assertTrue(buildersEntered.await(5, TimeUnit.SECONDS)); + releaseBuilders.countDown(); + assertEquals(1, first.get(5, TimeUnit.SECONDS).tileX()); + assertEquals(2, second.get(5, TimeUnit.SECONDS).tileX()); + } finally { + releaseBuilders.countDown(); + executor.shutdownNow(); + cache.close(); + } + } + + @Test + public void completedEntriesUseBoundedAccessOrderEviction() { + AtomicInteger builds = new AtomicInteger(); + RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> { + builds.incrementAndGet(); + return emptyTile(tileX, tileZ); + }); + try { + RiverTile zero = cache.get(0, 0); + RiverTile one = cache.get(1, 0); + assertSame(zero, cache.get(0, 0)); + cache.get(2, 0); + assertEquals(2, cache.completedSize()); + assertSame(zero, cache.get(0, 0)); + + RiverTile rebuiltOne = cache.get(1, 0); + assertFalse(one == rebuiltOne); + assertEquals(4, builds.get()); + assertEquals(2, cache.completedSize()); + } finally { + cache.close(); + } + } + + @Test + public void failedBuildsAreRemovedAndRetryable() { + AtomicInteger attempts = new AtomicInteger(); + RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> { + if (attempts.incrementAndGet() == 1) { + throw new IllegalArgumentException("expected failure"); + } + return emptyTile(tileX, tileZ); + }); + try { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> cache.get(-3, 4) + ); + assertEquals("expected failure", failure.getMessage()); + assertEquals(0, cache.completedSize()); + + RiverTile retried = cache.get(-3, 4); + assertEquals(-3, retried.tileX()); + assertEquals(2, attempts.get()); + assertEquals(1, cache.completedSize()); + } finally { + cache.close(); + } + } + + @Test + public void mismatchedBuilderResultIsRemovedAndRetryable() { + AtomicInteger attempts = new AtomicInteger(); + RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> attempts.incrementAndGet() == 1 + ? emptyTile(tileX + 1, tileZ) + : emptyTile(tileX, tileZ)); + try { + assertThrows(IllegalStateException.class, () -> cache.get(5, 6)); + RiverTile retried = cache.get(5, 6); + assertEquals(5, retried.tileX()); + assertEquals(2, attempts.get()); + } finally { + cache.close(); + } + } + + @Test + public void clearAndCloseReleaseEntriesAndEnforceLifecycle() { + AtomicInteger builds = new AtomicInteger(); + RiverTileCache cache = new RiverTileCache(4, (tileX, tileZ) -> { + builds.incrementAndGet(); + return emptyTile(tileX, tileZ); + }); + cache.get(0, 0); + cache.get(1, 0); + assertEquals(2, cache.completedSize()); + + cache.clear(); + assertEquals(0, cache.completedSize()); + cache.get(0, 0); + assertEquals(3, builds.get()); + + cache.close(); + cache.close(); + assertTrue(cache.isClosed()); + assertEquals(0, cache.completedSize()); + assertThrows(IllegalStateException.class, () -> cache.get(0, 0)); + assertThrows(IllegalStateException.class, cache::clear); + } + + @Test + public void closeInvalidatesInflightBuildWithoutRetainingItsResult() throws Exception { + CountDownLatch builderEntered = new CountDownLatch(1); + CountDownLatch releaseBuilder = new CountDownLatch(1); + RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> { + builderEntered.countDown(); + assertTrue(releaseBuilder.await(5, TimeUnit.SECONDS)); + return emptyTile(tileX, tileZ); + }); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future result = executor.submit(() -> cache.get(9, -9)); + assertTrue(builderEntered.await(5, TimeUnit.SECONDS)); + cache.close(); + releaseBuilder.countDown(); + + ExecutionException failure = assertThrows( + ExecutionException.class, + () -> result.get(5, TimeUnit.SECONDS) + ); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertEquals(0, cache.completedSize()); + } finally { + releaseBuilder.countDown(); + executor.shutdownNow(); + cache.close(); + } + } + + @Test + public void clearInvalidatesInflightBuildAndFreshRequestPublishesNewEntry() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + CountDownLatch firstBuilderEntered = new CountDownLatch(1); + CountDownLatch releaseFirstBuilder = new CountDownLatch(1); + RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> { + if (attempts.incrementAndGet() == 1) { + firstBuilderEntered.countDown(); + assertTrue(releaseFirstBuilder.await(5, TimeUnit.SECONDS)); + } + return emptyTile(tileX, tileZ); + }); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future invalidated = executor.submit(() -> cache.get(4, -2)); + assertTrue(firstBuilderEntered.await(5, TimeUnit.SECONDS)); + cache.clear(); + + RiverTile replacement = cache.get(4, -2); + releaseFirstBuilder.countDown(); + + ExecutionException failure = assertThrows( + ExecutionException.class, + () -> invalidated.get(5, TimeUnit.SECONDS) + ); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertEquals(4, replacement.tileX()); + assertEquals(-2, replacement.tileZ()); + assertEquals(2, attempts.get()); + assertEquals(1, cache.completedSize()); + assertSame(replacement, cache.get(4, -2)); + } finally { + releaseFirstBuilder.countDown(); + executor.shutdownNow(); + cache.close(); + } + } + + @Test + public void tileProvidesConstantTimeReachIdentityLookup() { + RiverReach reach = reach(); + RiverTile tile = new RiverTile(0, 0, 0, 0, 64, 64, List.of(reach)); + + assertSame(reach, tile.reach(reach.id())); + assertNull(tile.reach(RiverEdgeId.of(new RiverNodeId(2L, 0L), new RiverNodeId(3L, 0L)))); + assertThrows( + IllegalArgumentException.class, + () -> new RiverTile(0, 0, 0, 0, 64, 64, List.of(reach, reach)) + ); + } + + private static RiverTile emptyTile(int tileX, int tileZ) { + int minimumX = tileX * 64; + int minimumZ = tileZ * 64; + return new RiverTile(tileX, tileZ, minimumX, minimumZ, minimumX + 64, minimumZ + 64, List.of()); + } + + private static RiverReach reach() { + RiverNode from = new RiverNode( + new RiverNodeId(0L, 0L), + 8.0, + 8.0, + 20.0, + 20.0, + 20.0, + 20.0, + false, + true + ); + RiverNode to = new RiverNode( + new RiverNodeId(1L, 0L), + 56.0, + 8.0, + 10.0, + 10.0, + 10.0, + 10.0, + false, + true + ); + return new RiverReach( + RiverEdgeId.of(from.id(), to.id()), + from, + to, + RiverRouteState.WET, + 1, + 1, + 8.0, + 4.0, + 3.0, + false, + false, + new RiverPolyline(new double[]{8.0, 56.0}, new double[]{8.0, 8.0}) + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java new file mode 100644 index 000000000..208f15af4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java @@ -0,0 +1,672 @@ +package art.arcane.iris.engine.river.cave; + +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class RiverCaveContainmentPlannerTest { + private static final List DIRECTIONS = List.of( + new CavePosition(1, 0, 0), + new CavePosition(-1, 0, 0), + new CavePosition(0, 1, 0), + new CavePosition(0, -1, 0), + new CavePosition(0, 0, 1), + new CavePosition(0, 0, -1) + ); + + private final RiverCaveContainmentPlanner planner = new RiverCaveContainmentPlanner(); + + @Test + public void closedComponentProducesConnectedThroatPoolAndGuards() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(1, 6, 0)); + RiverCaveSource source = source(14L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); + + RiverCavePlan plan = planner.plan(view, source, settings()); + + assertTrue(plan.accepted()); + assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(position(0, 12, 0))); + assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(position(0, 11, 0))); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(1, 6, 0))); + assertTrue(plan.actions().containsValue(RiverCaveAction.SEAL_GUARD)); + assertMutationConnected(plan, source.entry(), position(1, 6, 0)); + assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); + assertEquals(CaveVoxel.CAVE_AIR, plan.baselinePreconditions().get(position(1, 6, 0)).voxel()); + assertEquals(CaveVoxel.SOLID, plan.baselinePreconditions().get(position(0, 12, 0)).voxel()); + } + + @Test + public void compatibleFluidFollowsExplicitPolicy() { + TestVoxelView view = new TestVoxelView(); + CavePosition target = position(0, 7, 0); + view.set(CaveVoxel.COMPATIBLE_FLUID, target); + RiverCaveSource source = source(1L, 10, RiverCaveMode.CLOSED_COMPONENT, target); + + RiverCavePlan allowed = planner.plan(view, source, settings(RiverCaveFluidPolicy.ALLOW_COMPATIBLE)); + RiverCavePlan rejected = planner.plan(view, source, settings(RiverCaveFluidPolicy.REJECT_EXISTING)); + + assertTrue(allowed.accepted()); + assertEquals(RiverCaveAction.WET_SOURCE, allowed.actions().get(target)); + assertEquals(RiverCaveRejection.EXISTING_FLUID, rejected.rejection()); + assertTrue(rejected.actions().isEmpty()); + } + + @Test + public void replacementPolicyIsDistinctAndStillRejectsLava() { + CavePosition target = position(0, 7, 0); + TestVoxelView compatibleView = new TestVoxelView(); + compatibleView.set(CaveVoxel.COMPATIBLE_FLUID, target); + TestVoxelView incompatibleView = new TestVoxelView(); + incompatibleView.set(CaveVoxel.INCOMPATIBLE_FLUID, target); + TestVoxelView lavaView = new TestVoxelView(); + lavaView.set(CaveVoxel.LAVA, target); + RiverCaveSource source = source(101L, 10, RiverCaveMode.CLOSED_COMPONENT, target); + + RiverCavePlan rejectedCompatible = planner.plan( + compatibleView, + source, + settings(RiverCaveFluidPolicy.REJECT_EXISTING) + ); + RiverCavePlan allowedCompatible = planner.plan( + compatibleView, + source, + settings(RiverCaveFluidPolicy.ALLOW_COMPATIBLE) + ); + RiverCavePlan rejectedIncompatible = planner.plan( + incompatibleView, + source, + settings(RiverCaveFluidPolicy.ALLOW_COMPATIBLE) + ); + RiverCavePlan replacedIncompatible = planner.plan( + incompatibleView, + source, + settings(RiverCaveFluidPolicy.REPLACE_CONTAINED) + ); + RiverCavePlan rejectedLava = planner.plan( + lavaView, + source, + settings(RiverCaveFluidPolicy.REPLACE_CONTAINED) + ); + + assertEquals(RiverCaveRejection.EXISTING_FLUID, rejectedCompatible.rejection()); + assertTrue(allowedCompatible.accepted()); + assertEquals(RiverCaveRejection.INCOMPATIBLE_FLUID, rejectedIncompatible.rejection()); + assertTrue(replacedIncompatible.accepted()); + assertEquals(RiverCaveAction.WET_SOURCE, replacedIncompatible.actions().get(target)); + assertEquals(RiverCaveRejection.LAVA_CONTACT, rejectedLava.rejection()); + } + + @Test + public void throatRadiusExpandsTheConnectedBoreFootprint() { + TestVoxelView thinView = new TestVoxelView(); + TestVoxelView thickView = new TestVoxelView(); + CavePosition target = position(0, 7, 0); + thinView.set(CaveVoxel.CAVE_AIR, target); + thickView.set(CaveVoxel.CAVE_AIR, target); + RiverCaveSource source = source(102L, 10, RiverCaveMode.CLOSED_COMPONENT, target); + RiverCavePlannerSettings thinSettings = detailedSettings( + 1, + 0, + RiverCaveGrottoShape.ELLIPSOID, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + RiverCavePlannerSettings thickSettings = detailedSettings( + 3, + 0, + RiverCaveGrottoShape.ELLIPSOID, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + RiverCavePlan thin = planner.plan(thinView, source, thinSettings); + RiverCavePlan thick = planner.plan(thickView, source, thickSettings); + + assertTrue(thin.accepted()); + assertTrue(thick.accepted()); + assertFalse(thin.actions().containsKey(position(2, 10, 0))); + assertEquals(RiverCaveAction.WET_SOURCE, thick.actions().get(position(2, 10, 0))); + assertTrue(mutationCount(thick) > mutationCount(thin)); + } + + @Test + public void generatedGrottoRetainsConfiguredDryHeadroom() { + TestVoxelView view = new TestVoxelView(); + RiverCaveSource source = source(103L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)); + RiverCavePlannerSettings acceptedSettings = detailedSettings( + 1, + 2, + RiverCaveGrottoShape.ELLIPSOID, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + RiverCavePlannerSettings rejectedSettings = detailedSettings( + 1, + 5, + RiverCaveGrottoShape.ELLIPSOID, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + RiverCavePlan accepted = planner.plan(view, source, acceptedSettings); + RiverCavePlan rejected = planner.plan(view, source, rejectedSettings); + + assertTrue(accepted.accepted()); + assertEquals(RiverCaveAction.DRY_AIR, accepted.actions().get(position(0, 11, 0))); + assertEquals(RiverCaveAction.DRY_AIR, accepted.actions().get(position(0, 12, 0))); + assertEquals(RiverCaveRejection.DRY_HEADROOM_LIMIT, rejected.rejection()); + } + + @Test + public void configuredGrottoPredicateChangesFootprintDeterministically() { + TestVoxelView view = new TestVoxelView(); + RiverCaveSource source = source(104L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)); + RiverCavePlannerSettings ellipsoid = detailedSettings( + 1, + 0, + RiverCaveGrottoShape.ELLIPSOID, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + RiverCaveGrottoShape compactShape = (candidate, settings, dx, dy, dz) -> { + double horizontal = settings.grottoHorizontalRadius(); + double vertical = settings.grottoVerticalRadius(); + double normalized = (dx * dx / (horizontal * horizontal)) + + (dy * dy / (vertical * vertical)) + + (dz * dz / (horizontal * horizontal)); + return normalized <= 0.5D; + }; + RiverCavePlannerSettings compact = detailedSettings( + 1, + 0, + compactShape, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + RiverCavePlan full = planner.plan(view, source, ellipsoid); + RiverCavePlan firstCompact = planner.plan(view, source, compact); + RiverCavePlan secondCompact = planner.plan(view, source, compact); + + assertTrue(full.accepted()); + assertTrue(firstCompact.accepted()); + assertEquals(firstCompact, secondCompact); + assertTrue(mutationCount(full) > mutationCount(firstCompact)); + } + + @Test + public void radiusLimitRejectsWholeComponentWithoutClipping() { + TestVoxelView view = new TestVoxelView(); + for (int x = 0; x <= 3; x++) { + view.set(CaveVoxel.CAVE_AIR, position(x, 7, 0)); + } + RiverCavePlannerSettings settings = new RiverCavePlannerSettings( + 2, + 20, + 64, + 32, + 1, + 1, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + RiverCavePlan plan = planner.plan( + view, + source(2L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), + settings + ); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.RADIUS_LIMIT); + } + + @Test + public void depthLimitRejectsWholeComponentWithoutClipping() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 8, 0), position(0, 7, 0)); + RiverCavePlannerSettings settings = new RiverCavePlannerSettings( + 8, + 4, + 64, + 32, + 1, + 1, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + RiverCavePlan plan = planner.plan( + view, + source(3L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 8, 0)), + settings + ); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.DEPTH_LIMIT); + } + + @Test + public void volumeLimitRejectsWholeComponentWithoutClipping() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); + RiverCavePlannerSettings settings = new RiverCavePlannerSettings( + 8, + 20, + 2, + 32, + 1, + 1, + RiverCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + RiverCavePlan plan = planner.plan( + view, + source(4L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), + settings + ); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.VOLUME_LIMIT); + } + + @Test + public void worldBoundaryRejectsWholeComponent() { + TestVoxelView view = new TestVoxelView(-16, 0, 0, 32, -16, 16); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + + RiverCavePlan plan = planner.plan( + view, + source(5L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), + settings() + ); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.WORLD_BOUNDARY); + } + + @Test + public void openSurfaceRejectsWholeComponent() { + TestVoxelView view = new TestVoxelView(); + CavePosition target = position(0, 7, 0); + view.set(CaveVoxel.CAVE_AIR, target); + view.openToSurface(target); + + RiverCavePlan plan = planner.plan( + view, + source(6L, 10, RiverCaveMode.CLOSED_COMPONENT, target), + settings() + ); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.OPEN_SURFACE); + } + + @Test + public void lavaAndIncompatibleFluidContactsRejectWholeComponent() { + TestVoxelView lavaView = new TestVoxelView(); + lavaView.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + lavaView.set(CaveVoxel.LAVA, position(1, 7, 0)); + TestVoxelView fluidView = new TestVoxelView(); + fluidView.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + fluidView.set(CaveVoxel.INCOMPATIBLE_FLUID, position(1, 7, 0)); + RiverCaveSource source = source(7L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); + + RiverCavePlan lavaPlan = planner.plan(lavaView, source, settings()); + RiverCavePlan fluidPlan = planner.plan(fluidView, source, settings()); + + assertRejectedWithoutPublication(lavaPlan, RiverCaveRejection.LAVA_CONTACT); + assertRejectedWithoutPublication(fluidPlan, RiverCaveRejection.INCOMPATIBLE_FLUID); + } + + @Test + public void fallingHazardAbovePoolRejectsWholeComponent() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + view.set(CaveVoxel.LAVA, position(1, 11, 0)); + + RiverCavePlan plan = planner.plan( + view, + source(15L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), + settings() + ); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.LAVA_CONTACT); + } + + @Test + public void generatedGrottoHasBoundedWetDryAndGuardActions() { + TestVoxelView view = new TestVoxelView(); + RiverCaveSource source = source(8L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)); + + RiverCavePlan plan = planner.plan(view, source, settings()); + + assertTrue(plan.accepted()); + assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(position(0, 12, 0))); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 8, 0))); + assertTrue(plan.actions().containsValue(RiverCaveAction.SEAL_GUARD)); + assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); + assertFalse(plan.actions().containsKey(position(0, 13, 0))); + assertMutationConnected(plan, source.entry(), source.target()); + } + + @Test + public void generatedGrottoRejectsAnOpenShell() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(3, 8, 0)); + + RiverCavePlan plan = planner.plan( + view, + source(9L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)), + settings() + ); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.GROTTO_SHELL_OPEN); + } + + @Test + public void combinedModeUsesClosedCaveOrGeneratedGrottoFromBaseline() { + TestVoxelView caveView = new TestVoxelView(); + CavePosition target = position(0, 8, 0); + caveView.set(CaveVoxel.CAVE_AIR, target); + TestVoxelView solidView = new TestVoxelView(); + RiverCaveSource source = source(10L, 10, RiverCaveMode.GROTTO_OR_CLOSED_COMPONENT, target); + + RiverCavePlan cavePlan = planner.plan(caveView, source, settings()); + RiverCavePlan grottoPlan = planner.plan(solidView, source, settings()); + + assertTrue(cavePlan.accepted()); + assertTrue(grottoPlan.accepted()); + assertEquals(CaveVoxel.CAVE_AIR, cavePlan.baselinePreconditions().get(target).voxel()); + assertEquals(CaveVoxel.SOLID, grottoPlan.baselinePreconditions().get(target).voxel()); + } + + @Test + public void waterfallKeepsColumnDistinctFromSourcePoolAndDryAir() { + TestVoxelView view = new TestVoxelView(); + RiverCaveSource source = source(11L, 10, RiverCaveMode.WATERFALL_POOL, position(0, 8, 0)); + + RiverCavePlan plan = planner.plan(view, source, settings()); + + assertTrue(plan.accepted()); + assertEquals(RiverCaveAction.FALLING_WATER, plan.actions().get(position(0, 12, 0))); + assertEquals(RiverCaveAction.FALLING_WATER, plan.actions().get(position(0, 11, 0))); + assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); + assertFalse(plan.actions().containsValue(RiverCaveAction.DRY_AIR)); + } + + @Test + public void waterfallRejectsAnUnsealedFallingColumn() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 8, 0), position(1, 11, 0)); + RiverCaveSource source = source(13L, 10, RiverCaveMode.WATERFALL_POOL, position(0, 8, 0)); + + RiverCavePlan plan = planner.plan(view, source, settings()); + + assertRejectedWithoutPublication(plan, RiverCaveRejection.WATERFALL_SHAFT_OPEN); + } + + @Test + public void overlapArbitrationIsOrderIndependentAndNamesWinner() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); + RiverCaveSource lowerHead = new RiverCaveSource( + 1L, + position(2, 12, 0), + position(2, 7, 0), + 10, + RiverCaveMode.CLOSED_COMPONENT + ); + RiverCaveSource higherHead = new RiverCaveSource( + 20L, + position(0, 12, 0), + position(0, 7, 0), + 11, + RiverCaveMode.CLOSED_COMPONENT + ); + + RiverCavePlanningResult forward = planner.planAll(view, List.of(lowerHead, higherHead), settings()); + RiverCavePlanningResult reverse = planner.planAll(view, List.of(higherHead, lowerHead), settings()); + + assertEquals(forward, reverse); + assertEquals(higherHead, forward.plans().get(0).source()); + assertTrue(forward.plans().get(0).accepted()); + assertEquals(RiverCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); + assertEquals(20L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); + assertEquals(forward.actions().keySet(), forward.baselinePreconditions().keySet()); + } + + @Test + public void equalHeadOverlapUsesLowestStableSourceId() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); + RiverCaveSource largerId = new RiverCaveSource( + 20L, + position(0, 12, 0), + position(0, 7, 0), + 10, + RiverCaveMode.CLOSED_COMPONENT + ); + RiverCaveSource smallerId = new RiverCaveSource( + 1L, + position(2, 12, 0), + position(2, 7, 0), + 10, + RiverCaveMode.CLOSED_COMPONENT + ); + + RiverCavePlanningResult result = planner.planAll(view, List.of(largerId, smallerId), settings()); + + assertEquals(smallerId, result.plans().get(0).source()); + assertTrue(result.plans().get(0).accepted()); + assertEquals(1L, result.plans().get(1).arbitrationWinnerSourceId().getAsLong()); + } + + @Test + public void arbitrationUsesDirectHigherPriorityPlansEvenWhenTheyAreRejected() { + TestVoxelView view = new TestVoxelView(); + view.set( + CaveVoxel.CAVE_AIR, + position(0, 7, 0), + position(2, 7, 0), + position(4, 7, 0) + ); + RiverCaveSource lowest = new RiverCaveSource( + 30L, + position(0, 12, 0), + position(0, 7, 0), + 10, + RiverCaveMode.CLOSED_COMPONENT + ); + RiverCaveSource middle = new RiverCaveSource( + 20L, + position(2, 12, 0), + position(2, 7, 0), + 11, + RiverCaveMode.CLOSED_COMPONENT + ); + RiverCaveSource highest = new RiverCaveSource( + 10L, + position(4, 12, 0), + position(4, 7, 0), + 12, + RiverCaveMode.CLOSED_COMPONENT + ); + + RiverCavePlanningResult forward = planner.planAll( + view, + List.of(lowest, middle, highest), + settings() + ); + RiverCavePlanningResult reverse = planner.planAll( + view, + List.of(highest, middle, lowest), + settings() + ); + + assertEquals(forward, reverse); + assertTrue(forward.plans().get(0).accepted()); + assertEquals(RiverCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); + assertEquals(10L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); + assertEquals(RiverCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(2).rejection()); + assertEquals(20L, forward.plans().get(2).arbitrationWinnerSourceId().getAsLong()); + assertFalse(forward.actions().containsKey(position(0, 7, 0))); + assertTrue(forward.actions().containsKey(position(4, 7, 0))); + } + + @Test + public void planOutputsAreImmutable() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + RiverCaveSource source = source(12L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); + RiverCavePlanningResult result = planner.planAll(view, List.of(source), settings()); + RiverCavePlan plan = result.plans().get(0); + + assertThrows( + UnsupportedOperationException.class, + () -> plan.actions().put(position(9, 9, 9), RiverCaveAction.WET_SOURCE) + ); + assertThrows( + UnsupportedOperationException.class, + () -> plan.baselinePreconditions().put( + position(9, 9, 9), + new CaveVoxelPrecondition(CaveVoxel.SOLID, false) + ) + ); + assertThrows(UnsupportedOperationException.class, () -> result.plans().add(plan)); + assertThrows( + UnsupportedOperationException.class, + () -> result.actions().put(position(9, 9, 9), RiverCaveAction.WET_SOURCE) + ); + } + + private void assertMutationConnected(RiverCavePlan plan, CavePosition start, CavePosition expected) { + Set mutations = new HashSet<>(); + for (Map.Entry entry : plan.actions().entrySet()) { + if (entry.getValue() != RiverCaveAction.SEAL_GUARD) { + mutations.add(entry.getKey()); + } + } + Queue queue = new ArrayDeque<>(); + Set visited = new HashSet<>(); + queue.add(start); + visited.add(start); + while (!queue.isEmpty()) { + CavePosition position = queue.remove(); + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (mutations.contains(neighbor) && visited.add(neighbor)) { + queue.add(neighbor); + } + } + } + assertTrue(visited.contains(expected)); + assertEquals(mutations, visited); + } + + private int mutationCount(RiverCavePlan plan) { + int count = 0; + for (RiverCaveAction action : plan.actions().values()) { + if (action != RiverCaveAction.SEAL_GUARD) { + count++; + } + } + return count; + } + + private void assertRejectedWithoutPublication(RiverCavePlan plan, RiverCaveRejection rejection) { + assertFalse(plan.accepted()); + assertEquals(rejection, plan.rejection()); + assertTrue(plan.actions().isEmpty()); + assertTrue(plan.baselinePreconditions().isEmpty()); + } + + private RiverCaveSource source(long sourceId, int waterHeadY, RiverCaveMode mode, CavePosition target) { + return new RiverCaveSource(sourceId, position(0, 12, 0), target, waterHeadY, mode); + } + + private RiverCavePlannerSettings settings() { + return settings(RiverCaveFluidPolicy.ALLOW_COMPATIBLE); + } + + private RiverCavePlannerSettings settings(RiverCaveFluidPolicy policy) { + return new RiverCavePlannerSettings(12, 20, 64, 32, 2, 2, policy); + } + + private RiverCavePlannerSettings detailedSettings( + int throatRadius, + int dryHeadroom, + RiverCaveGrottoShape shape, + RiverCaveFluidPolicy policy + ) { + return new RiverCavePlannerSettings( + 12, + 20, + 2048, + 32, + throatRadius, + 4, + 4, + dryHeadroom, + policy, + shape + ); + } + + private CavePosition position(int x, int y, int z) { + return new CavePosition(x, y, z); + } + + private static final class TestVoxelView implements CaveVoxelView { + private final int minX; + private final int maxX; + private final int minY; + private final int maxY; + private final int minZ; + private final int maxZ; + private final Map voxels = new HashMap<>(); + private final Set surfaceOpenings = new HashSet<>(); + + private TestVoxelView() { + this(-32, 32, 0, 32, -32, 32); + } + + private TestVoxelView(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { + this.minX = minX; + this.maxX = maxX; + this.minY = minY; + this.maxY = maxY; + this.minZ = minZ; + this.maxZ = maxZ; + } + + @Override + public boolean isInWorld(CavePosition position) { + return position.x() >= minX + && position.x() <= maxX + && position.y() >= minY + && position.y() <= maxY + && position.z() >= minZ + && position.z() <= maxZ; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return voxels.getOrDefault(position, CaveVoxel.SOLID); + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return surfaceOpenings.contains(position); + } + + private void set(CaveVoxel voxel, CavePosition... positions) { + for (CavePosition position : positions) { + voxels.put(position, voxel); + } + } + + private void openToSurface(CavePosition position) { + surfaceOpenings.add(position); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorageTest.java b/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorageTest.java new file mode 100644 index 000000000..217d0fb4a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorageTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.engine.river.cave; + +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +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 RiverCaveHydrologyStorageTest { + @Test + @SuppressWarnings("unchecked") + public void presentReadDoesNotMaterializeASectionOrSlice() { + MantleChunk chunk = mock(MantleChunk.class); + Matter matter = mock(Matter.class); + MatterSlice slice = mock(MatterSlice.class); + RiverCaveHydrology expected = RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE); + when(chunk.exists(2)).thenReturn(true); + when(chunk.get(2)).thenReturn(matter); + when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); + when(matter.getSlice(RiverCaveHydrology.class)).thenReturn(slice); + when(slice.get(3, 1, 5)).thenReturn(expected); + + assertSame(expected, RiverCaveHydrologyStorage.getIfPresent(chunk, 3, 33, 5)); + verify(chunk, never()).getOrCreate(2); + verify(matter, never()).slice(RiverCaveHydrology.class); + } + + @Test + @SuppressWarnings("unchecked") + public void absentSliceReturnsWithoutCreatingIt() { + MantleChunk chunk = mock(MantleChunk.class); + Matter matter = mock(Matter.class); + when(chunk.exists(2)).thenReturn(true); + when(chunk.get(2)).thenReturn(matter); + + assertNull(RiverCaveHydrologyStorage.getIfPresent(chunk, 3, 33, 5)); + verify(matter, never()).slice(RiverCaveHydrology.class); + verify(matter, never()).getSlice(RiverCaveHydrology.class); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettingsTest.java new file mode 100644 index 000000000..4ba2cc289 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettingsTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverBiomes; +import art.arcane.iris.engine.object.IrisRiverNetwork; +import art.arcane.iris.engine.object.IrisRiverOverride; +import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; +import art.arcane.iris.engine.object.IrisRiverTerminalMode; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class EffectiveRiverSettingsTest { + @Test + public void appliesRegionThenBiomeOverridesWithoutChangingGraphSettings() { + IrisRiverNetwork network = new IrisRiverNetwork() + .setBiomes(new IrisRiverBiomes() + .setChannel(new KList<>("dimension-channel")) + .setBank(new KList<>("dimension-bank"))); + IrisRegion region = new IrisRegion().setRiverOverride(new IrisRiverOverride() + .setAllowSources(false) + .setRoutingPolicy(IrisRiverRoutingPolicy.AVOID) + .setWidthMultiplier(1.5D) + .setChannelBiomes(new KList<>("region-channel"))); + IrisBiome biome = new IrisBiome().setRiverOverride(new IrisRiverOverride() + .setRoutingPolicy(IrisRiverRoutingPolicy.BLOCK) + .setWidthMultiplier(0.5D) + .setChannelBiomes(new KList<>()) + .setTerminalMode(IrisRiverTerminalMode.SUPPRESS)); + + EffectiveRiverSettings settings = EffectiveRiverSettings.resolve(network, region, biome); + + assertFalse(settings.allowSources()); + assertEquals(IrisRiverRoutingPolicy.BLOCK, settings.routingPolicy()); + assertEquals(0.5D, settings.widthMultiplier(), 0D); + assertTrue(settings.channelBiomes().isEmpty()); + assertEquals(List.of("dimension-bank"), settings.bankBiomes()); + assertEquals(IrisRiverTerminalMode.SUPPRESS, settings.terminalMode()); + assertTrue(settings.terminalModeOverridden()); + } + + @Test + public void inheritsDimensionDefaultsWhenOverridesAreAbsent() { + IrisRiverNetwork network = new IrisRiverNetwork() + .setBiomes(new IrisRiverBiomes().setDry(new KList<>("dry"))); + + EffectiveRiverSettings settings = EffectiveRiverSettings.resolve(network, null, null); + + assertTrue(settings.allowSources()); + assertEquals(IrisRiverRoutingPolicy.ALLOW, settings.routingPolicy()); + assertEquals(1D, settings.routingCostMultiplier(), 0D); + assertEquals(List.of("dry"), settings.dryBiomes()); + assertEquals(IrisRiverTerminalMode.DRY_CHANNEL, settings.terminalMode()); + assertFalse(settings.terminalModeOverridden()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java new file mode 100644 index 000000000..bda60f5cb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java @@ -0,0 +1,891 @@ +package art.arcane.iris.engine.river.runtime; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisGeneratorStyle; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverNetwork; +import art.arcane.iris.engine.object.IrisRiverCaveMode; +import art.arcane.iris.engine.object.IrisRiverOverride; +import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; +import art.arcane.iris.engine.object.IrisRiverTerminalMode; +import art.arcane.iris.engine.object.IrisRiverWaterMode; +import art.arcane.iris.engine.object.IrisStyledRange; +import art.arcane.iris.engine.object.NoiseStyle; +import art.arcane.iris.engine.river.RiverEdgeId; +import art.arcane.iris.engine.river.RiverNode; +import art.arcane.iris.engine.river.RiverNodeId; +import art.arcane.iris.engine.river.RiverPolyline; +import art.arcane.iris.engine.river.RiverReach; +import art.arcane.iris.engine.river.RiverRouteState; +import art.arcane.iris.engine.river.RiverAnchor; +import art.arcane.iris.engine.river.RiverRoutingContext; +import art.arcane.iris.engine.river.RiverTerrainNodeSample; +import art.arcane.iris.engine.river.RiverTerrainSourceSample; +import art.arcane.iris.util.project.interpolation.NoiseBounds; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class IrisRiverRuntimeTest { + @Test + public void terminalTaperUsesMeasuredReachLength() { + assertEquals(1D, IrisRiverRuntime.terminalWeight(40, 200D, 0.8D), 0D); + assertEquals(0.5D, IrisRiverRuntime.terminalWeight(40, 200D, 0.9D), 0.0000001D); + assertEquals(0.5D, IrisRiverRuntime.terminalWeight(40, 20D, 0.5D), 0.0000001D); + } + + @Test + public void footprintSamplingBuildsOnlyItsCenterTile() { + IrisRiverNetwork configuration = configuration(false); + + try (IrisRiverRuntime runtime = runtime(configuration)) { + runtime.sampleFootprint(-4096D, -4096D, 4096D, 4096D); + + assertEquals(1, runtime.completedTileCount()); + } + } + + @Test + public void settingsAtSkipsNaturalBiomeWhenBiomeOverridesAreUnreachable() { + IrisRiverNetwork configuration = configuration(false); + IrisRegion region = new IrisRegion().setRiverOverride( + new IrisRiverOverride().setWidthMultiplier(1.75D) + ); + IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND); + AtomicInteger biomeSamples = new AtomicInteger(); + + try (IrisRiverRuntime runtime = runtime( + configuration, + constantHeight(80D), + countedBiome(biome, biomeSamples), + region, + false, + true, + true, + false + )) { + EffectiveRiverSettings settings = runtime.settingsAt(12D, -7D); + + assertEquals(1.75D, settings.widthMultiplier(), 0D); + assertEquals(0, biomeSamples.get()); + } + } + + @Test + public void settingsAtSamplesNaturalBiomeWhenBiomeOverrideIsReachable() { + IrisRiverNetwork configuration = configuration(false); + IrisRegion region = new IrisRegion().setRiverOverride( + new IrisRiverOverride().setWidthMultiplier(1.75D) + ); + IrisBiome biome = new IrisBiome() + .setInferredType(InferredType.LAND) + .setRiverOverride(new IrisRiverOverride().setWidthMultiplier(2.5D)); + AtomicInteger biomeSamples = new AtomicInteger(); + + try (IrisRiverRuntime runtime = runtime( + configuration, + constantHeight(80D), + countedBiome(biome, biomeSamples), + region, + false, + true, + true, + true + )) { + EffectiveRiverSettings settings = runtime.settingsAt(12D, -7D); + + assertEquals(2.5D, settings.widthMultiplier(), 0D); + assertEquals(1, biomeSamples.get()); + } + } + + @Test + public void nodeSamplingResolvesInputsOnceAndSkipsZeroWeightSlope() { + IrisRiverNetwork configuration = configuration(false); + configuration.getTopology().setTerrainHeightWeight(0D); + configuration.getTopology().setTerrainSlopeWeight(0D); + IrisRegion region = new IrisRegion().setRiverOverride( + new IrisRiverOverride().setRoutingCostMultiplier(2D) + ); + IrisBiome biome = new IrisBiome() + .setInferredType(InferredType.LAND) + .setRiverOverride(new IrisRiverOverride() + .setRoutingPolicy(IrisRiverRoutingPolicy.BLOCK) + .setRoutingCostMultiplier(0D)); + AtomicInteger heightSamples = new AtomicInteger(); + AtomicInteger slopeSamples = new AtomicInteger(); + AtomicInteger oceanSamples = new AtomicInteger(); + AtomicInteger biomeSamples = new AtomicInteger(); + AtomicInteger regionSamples = new AtomicInteger(); + ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { + heightSamples.incrementAndGet(); + return 80D; + }); + ProceduralStream slope = ProceduralStream.ofDouble((x, z) -> { + slopeSamples.incrementAndGet(); + return 0.25D; + }); + ProceduralStream ocean = ProceduralStream.of((x, z) -> { + oceanSamples.incrementAndGet(); + return false; + }, Interpolated.BOOLEAN); + ProceduralStream biomes = countedBiome(biome, biomeSamples); + ProceduralStream regions = ProceduralStream.of( + (x, z) -> { + regionSamples.incrementAndGet(); + return region; + }, + Interpolated.of(value -> 0D, value -> region) + ); + + try (IrisRiverRuntime runtime = new IrisRiverRuntime(new IrisRiverRuntimeContext( + 4829759234L, + configuration, + mock(IrisData.class), + 63, + false, + true, + true, + true, + true, + (x, z) -> new NoiseBounds(0D, 512D), + height, + slope, + ocean, + biomes, + regions + ))) { + RiverTerrainNodeSample sample = runtime.sampleNode(12, -7); + RiverTerrainSourceSample sourceSample = runtime.sampleSource(13, -8); + + assertEquals(63D, sample.naturalHeight(), 0D); + assertFalse(sample.ocean()); + assertFalse(sample.riverAllowed()); + assertEquals(0D, sample.routingCost(), 0D); + assertFalse(sourceSample.ocean()); + assertFalse(sourceSample.riverAllowed()); + assertEquals(0, heightSamples.get()); + assertEquals(0, slopeSamples.get()); + assertEquals(2, oceanSamples.get()); + assertEquals(2, biomeSamples.get()); + assertEquals(2, regionSamples.get()); + } + } + + @Test + public void wetTerminalRiverCarvesDownAndPublishesAFluidHead() { + IrisRiverNetwork configuration = configuration(false); + + try (IrisRiverRuntime runtime = runtime(configuration)) { + IrisRiverSurfaceSample sample = findRiver(runtime, RiverRouteState.WET); + + assertNotNull(sample); + assertTrue(sample.river().present()); + assertTrue(sample.surfaceFluid()); + assertTrue(sample.terrainHeight() <= sample.naturalHeight()); + assertTrue(sample.waterSurfaceY() >= sample.terrainHeight()); + assertTrue(runtime.completedTileCount() <= 32); + } + } + + @Test + public void failedOceanRouteCanProduceDryTerrainWithoutSurfaceWater() { + IrisRiverNetwork configuration = configuration(true); + + try (IrisRiverRuntime runtime = runtime(configuration)) { + IrisRiverSurfaceSample sample = findRiver(runtime, RiverRouteState.DRY); + + assertNotNull(sample); + assertFalse(sample.surfaceFluid()); + assertTrue(sample.terrainHeight() <= sample.naturalHeight()); + assertTrue(sample.waterSurfaceY() == sample.terrainHeight()); + } + } + + @Test + public void localSinkholeOverrideMakesRequiredOceanTerminalWet() { + IrisRiverNetwork configuration = configuration(true); + IrisBiome land = new IrisBiome() + .setInferredType(InferredType.LAND) + .setRiverOverride(new IrisRiverOverride().setTerminalMode(IrisRiverTerminalMode.SINKHOLE_GROTTO)); + + try (IrisRiverRuntime runtime = runtime(configuration, land, new IrisRegion())) { + IrisRiverSurfaceSample sample = findRiver(runtime, RiverRouteState.WET); + + assertNotNull(sample); + assertTrue(sample.surfaceFluid()); + } + } + + @Test + public void sinkholeTerminalPublishesAGuaranteedSpecificCaveAnchor() { + IrisRiverNetwork configuration = configuration(true); + configuration.getTopology().setMaxRouteReaches(1); + configuration.getCaves() + .setMode(IrisRiverCaveMode.GROTTO_OR_CLOSED_COMPONENT) + .setMaximumPerReach(1); + configuration.getCaves().getEntry() + .setChance(0D) + .setInfluence(0D) + .setStyle(flat()); + IrisBiome land = new IrisBiome() + .setInferredType(InferredType.LAND) + .setRiverOverride(new IrisRiverOverride().setTerminalMode(IrisRiverTerminalMode.SINKHOLE_GROTTO)); + + try (IrisRiverRuntime runtime = runtime(configuration, land, new IrisRegion())) { + List anchors = runtime.candidateAnchors(-128, -128, 256, 256, 16D, 994L); + RiverAnchor terminal = null; + for (RiverAnchor anchor : anchors) { + if (runtime.isTerminalCaveAnchor(anchor)) { + terminal = anchor; + break; + } + } + + assertNotNull(terminal); + assertTrue(runtime.acceptsCaveAnchor(terminal)); + IrisRiverSurfaceSample terminalSample = runtime.sample( + StrictMath.floor(terminal.x()), + StrictMath.floor(terminal.z()) + ); + assertTrue(terminalSample.surfaceFluid()); + assertTrue(Math.round(terminalSample.terrainHeight()) + < Math.round(terminalSample.waterSurfaceY())); + for (RiverAnchor anchor : anchors) { + if (anchor.reachId().equals(terminal.reachId()) + && !runtime.isTerminalCaveAnchor(anchor)) { + assertFalse(runtime.acceptsCaveAnchor(anchor)); + } + } + configuration.getCaves().setMaximumPerReach(0); + assertFalse(runtime.acceptsCaveAnchor(terminal)); + } + } + + @Test + public void localSuppressOverrideRemovesDimensionSinkholeTerminalRoute() { + IrisRiverNetwork configuration = configuration(true); + configuration.getTerrain().setTerminalMode(IrisRiverTerminalMode.SINKHOLE_GROTTO); + IrisBiome land = new IrisBiome() + .setInferredType(InferredType.LAND) + .setRiverOverride(new IrisRiverOverride().setTerminalMode(IrisRiverTerminalMode.SUPPRESS)); + + try (IrisRiverRuntime runtime = runtime(configuration, land, new IrisRegion())) { + assertFalse(hasRiver(runtime)); + } + } + + @Test + public void inactiveCaveHydrologySuppressesLocalSinkholeTerminalRoute() { + IrisRiverNetwork configuration = configuration(true); + IrisBiome land = new IrisBiome() + .setInferredType(InferredType.LAND) + .setRiverOverride(new IrisRiverOverride().setTerminalMode(IrisRiverTerminalMode.SINKHOLE_GROTTO)); + + try (IrisRiverRuntime runtime = runtime(configuration, land, new IrisRegion(), false)) { + assertFalse(hasRiver(runtime)); + } + } + + @Test + public void caveEntryGateHonorsWetStateAndMaximumPerReach() { + IrisRiverNetwork configuration = configuration(false); + configuration.getCaves().setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT); + configuration.getCaves().setMaximumPerReach(1); + configuration.getCaves().getEntry() + .setChance(0.35D) + .setInfluence(0D) + .setStyle(flat()); + + try (IrisRiverRuntime runtime = runtime(configuration)) { + List anchors = runtime.candidateAnchors(0, 0, 256, 256, 16D, 773L); + Map acceptedPerReach = new HashMap<>(); + boolean acceptedAfterRejectedRawIndex = false; + for (RiverAnchor anchor : anchors) { + if (runtime.acceptsCaveAnchor(anchor)) { + acceptedPerReach.merge(anchor.reachId(), 1, Integer::sum); + acceptedAfterRejectedRawIndex |= anchor.index() >= 1; + } + } + + assertFalse(acceptedPerReach.isEmpty()); + for (int accepted : acceptedPerReach.values()) { + assertEquals(1, accepted); + } + assertTrue(acceptedAfterRejectedRawIndex); + } + } + + @Test + public void finalPolylineSupercoverRejectsOneBlockedColumnMissedByWidthSpacing() { + IrisRiverNetwork configuration = configuration(false); + IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); + IrisBiome blocked = new IrisBiome() + .setInferredType(InferredType.LAND) + .setRiverOverride(new IrisRiverOverride().setRoutingPolicy(IrisRiverRoutingPolicy.BLOCK)); + ProceduralStream biomes = ProceduralStream.of( + (x, z) -> x == 1D && z == 0D ? blocked : land, + Interpolated.of(value -> 0D, value -> land) + ); + + try (IrisRiverRuntime runtime = runtime(configuration, constantHeight(80D), biomes)) { + assertFalse(runtime.allowsReach(straightReach(80D, 79D))); + } + } + + @Test + public void finalPolylineSupercoverRejectsOneUnincisableTerrainSpike() { + IrisRiverNetwork configuration = configuration(false); + configuration.getTerrain().setMaxIncision(48); + ProceduralStream height = ProceduralStream.of( + (x, z) -> x == 1D && z == 0D ? 200D : 80D, + Interpolated.DOUBLE + ); + + try (IrisRiverRuntime runtime = runtime(configuration, height, constantLandBiome())) { + assertFalse(runtime.allowsReach(straightReach(80D, 79D))); + } + } + + @Test + public void longReachFeasibilityHasADeterministicSampleCeiling() { + IrisRiverNetwork configuration = configuration(false); + int[] heightSamples = new int[1]; + ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { + heightSamples[0]++; + return 80D; + }); + + try (IrisRiverRuntime runtime = runtime(configuration, height, constantLandBiome())) { + heightSamples[0] = 0; + + assertTrue(runtime.allowsReach(straightReach(80D, 79D, 4_096D))); + assertEquals(IrisRiverRuntime.MAXIMUM_REACH_FEASIBILITY_SAMPLES, heightSamples[0]); + } + } + + @Test + public void invariantFeasibilitySettingsAreResolvedOncePerReach() { + IrisRiverNetwork configuration = configuration(false); + AtomicInteger regionSamples = new AtomicInteger(); + IrisRegion region = new IrisRegion(); + ProceduralStream regions = ProceduralStream.of( + (x, z) -> { + regionSamples.incrementAndGet(); + return region; + }, + Interpolated.of(value -> 0D, value -> region) + ); + ProceduralStream height = constantHeight(80D); + ProceduralStream biome = constantLandBiome(); + ProceduralStream oceans = ProceduralStream.of( + (x, z) -> false, + Interpolated.BOOLEAN + ); + try (IrisRiverRuntime runtime = new IrisRiverRuntime(new IrisRiverRuntimeContext( + 4829759234L, + configuration, + mock(IrisData.class), + 63, + false, + true, + false, + false, + false, + (x, z) -> new NoiseBounds(0D, 512D), + height, + ProceduralStream.ofDouble((x, z) -> 0.025D), + oceans, + biome, + regions + ))) { + assertTrue(runtime.allowsReach(straightReach(80D, 79D, 4_096D))); + assertEquals(1, regionSamples.get()); + } + } + + @Test + public void feasibilityBoundsMatchFullIncisionAcrossRoundingBoundaries() { + double[] heads = new double[]{62.5D, Math.nextDown(62.5D), Math.nextUp(62.5D), 63D}; + double[] heights = new double[]{62.49D, 62.5D, 63D, 80D}; + double[] incisions = new double[]{0D, 0.5D, 16D, 512D}; + double[] depths = new double[]{0.25D, 1D, 4D}; + double[] roughnessBounds = new double[]{0D, 0.75D, 2D}; + double[] bedNoiseValues = new double[]{-1D, 0D, 1D}; + for (double head : heads) { + for (double naturalHeight : heights) { + for (double maximumIncision : incisions) { + for (double depth : depths) { + for (double roughnessBound : roughnessBounds) { + int decision = IrisRiverRuntime.boundedFeasibility( + naturalHeight, + head, + maximumIncision, + depth, + roughnessBound); + if (decision == IrisRiverRuntime.FEASIBILITY_SAMPLE_BED) { + continue; + } + for (double bedNoise : bedNoiseValues) { + double bedHeight = head - depth + roughnessBound * bedNoise; + double finalHeight = Math.min( + naturalHeight, + Math.max( + bedHeight, + naturalHeight - Math.max(0D, maximumIncision))); + boolean expected = Math.round(finalHeight) < Math.round(head); + assertEquals(expected, decision == IrisRiverRuntime.FEASIBILITY_ACCEPT); + } + } + } + } + } + } + } + + @Test + public void mantleBoreSkipsSurfaceFeasibilitySampling() { + IrisRiverNetwork configuration = configuration(false); + int[] heightSamples = new int[1]; + ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { + heightSamples[0]++; + return 512D; + }); + + try (IrisRiverRuntime runtime = runtime( + configuration, + height, + constantLandBiome(), + new IrisRegion(), + true, + false, + false, + true + )) { + heightSamples[0] = 0; + + assertTrue(runtime.allowsReach(straightReach(80D, 79D, 4_096D))); + assertEquals(0, heightSamples[0]); + } + } + + @Test + public void mantleBoreStillHonorsBlockedRoutingPolicy() { + IrisRiverNetwork configuration = configuration(false); + IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); + IrisBiome blocked = new IrisBiome() + .setInferredType(InferredType.LAND) + .setRiverOverride(new IrisRiverOverride().setRoutingPolicy(IrisRiverRoutingPolicy.BLOCK)); + ProceduralStream biomes = ProceduralStream.of( + (x, z) -> x == 1D && z == 0D ? blocked : land, + Interpolated.of(value -> 0D, value -> land) + ); + + try (IrisRiverRuntime runtime = runtime( + configuration, + constantHeight(512D), + biomes, + new IrisRegion(), + true, + false, + true, + true + )) { + assertFalse(runtime.allowsReach(straightReach(80D, 79D))); + } + } + + @Test + public void regionalDepthMultiplierCannotCollapseWetChannelBelowOneBlock() { + IrisRiverNetwork configuration = configuration(false); + configuration.getTerrain() + .setDepth(range(2D)) + .setBedRoughness(0.65D) + .setBedRoughnessStyle(flat()); + IrisRegion shallow = new IrisRegion() + .setRiverOverride(new IrisRiverOverride().setDepthMultiplier(0.1D)); + + try (IrisRiverRuntime runtime = runtime( + configuration, + constantHeight(80D), + constantLandBiome(), + shallow + )) { + assertTrue(runtime.allowsReach(straightReach(80D, 79D))); + } + } + + @Test + public void incisionLimitTurnsADeepWetReachIntoAHiddenMantleTunnel() { + IrisRiverNetwork configuration = configuration(false); + configuration.getTerrain().setMaxIncision(10); + ProceduralStream height = constantHeight(100D); + + try (IrisRiverRuntime runtime = runtime( + configuration, + height, + constantLandBiome(), + new IrisRegion(), + true, + false + )) { + IrisRiverSurfaceSample sample = null; + IrisRiverTunnelSample tunnel = null; + for (int x = -128; x < 192 && tunnel == null; x += 2) { + for (int z = -128; z < 192; z += 2) { + IrisRiverSurfaceSample candidate = runtime.sample(x, z); + IrisRiverTunnelSample candidateTunnel = runtime.sampleTunnel(x, z); + if (candidate.river().present() + && candidate.river().state() == RiverRouteState.WET + && candidateTunnel != null) { + sample = candidate; + tunnel = candidateTunnel; + break; + } + } + } + + assertNotNull(sample); + assertTrue(sample.subterranean()); + assertFalse(sample.surfaceFluid()); + assertEquals(sample.naturalHeight(), sample.terrainHeight(), 0D); + assertNotNull(tunnel); + assertTrue(tunnel.bedY() < tunnel.waterHeadY()); + assertTrue(tunnel.ceilingY() >= tunnel.waterHeadY()); + } + } + + @Test + public void terracedWaterUsesFixedInteriorPoolsAndPreservesNodeHeads() { + IrisRiverNetwork configuration = configuration(false); + configuration.getWater() + .setMode(IrisRiverWaterMode.TERRACED) + .setMaximumPoolRise(8) + .setDropHeight(2) + .setPoolLength(96); + + try (IrisRiverRuntime runtime = runtime(configuration)) { + assertEquals(71D, runtime.terracedWaterSurface(72D, 64D, 600D, 155D / 600D), 0D); + assertEquals(69D, runtime.terracedWaterSurface(72D, 64D, 600D, 156D / 600D), 0D); + assertEquals(69D, runtime.terracedWaterSurface(72D, 64D, 600D, 251D / 600D), 0D); + assertEquals(67D, runtime.terracedWaterSurface(72D, 64D, 600D, 252D / 600D), 0D); + assertEquals(65D, runtime.terracedWaterSurface(72D, 64D, 600D, 443D / 600D), 0D); + assertEquals(63D, runtime.terracedWaterSurface(72D, 64D, 600D, 444D / 600D), 0D); + assertEquals(63D, runtime.terracedWaterSurface(72D, 64D, 600D, 1D), 0D); + assertEquals( + runtime.terracedWaterSurface(72D, 64D, 600D, 1D), + runtime.terracedWaterSurface(64D, 63D, 600D, 0D), + 0D + ); + assertEquals(67D, runtime.terracedWaterSurface(68D, 64D, 100D, 0.33D), 0D); + assertEquals(65D, runtime.terracedWaterSurface(68D, 64D, 100D, 0.34D), 0D); + assertEquals(65D, runtime.terracedWaterSurface(68D, 64D, 100D, 0.66D), 0D); + assertEquals(63D, runtime.terracedWaterSurface(68D, 64D, 100D, 0.67D), 0D); + } + } + + @Test + public void terracedRiverMouthCannotRiseAboveNaturalOcean() { + IrisRiverNetwork configuration = configuration(false); + configuration.getWater() + .setMode(IrisRiverWaterMode.TERRACED) + .setMaximumPoolRise(8) + .setDropHeight(1) + .setPoolLength(128); + RiverNode from = new RiverNode( + new RiverNodeId(0L, 0L), + -600D, + 0D, + 72D, + 72D, + 2D, + 0D, + false, + true + ); + RiverNode to = new RiverNode( + new RiverNodeId(1L, 0L), + 0D, + 0D, + 63D, + 63D, + -Double.MAX_VALUE, + 0D, + true, + true + ); + RiverReach mouth = new RiverReach( + RiverEdgeId.of(from.id(), to.id()), + from, + to, + RiverRouteState.WET, + 1, + 1, + 4D, + 2D, + 4D, + true, + false, + new RiverPolyline(new double[]{-600D, 0D}, new double[]{0D, 0D}) + ); + + try (IrisRiverRuntime runtime = runtime( + configuration, + constantHeight(80D), + constantLandBiome(), + new IrisRegion() + )) { + assertEquals(71D, runtime.waterSurface(mouth, 0.1D, false), 0D); + assertEquals(63D, runtime.waterSurface(mouth, 0.9D, true), 0D); + } + } + + private static IrisRiverRuntime runtime(IrisRiverNetwork configuration) { + IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); + IrisRegion region = new IrisRegion(); + return runtime(configuration, land, region); + } + + private static IrisRiverRuntime runtime(IrisRiverNetwork configuration, IrisBiome land, IrisRegion region) { + return runtime(configuration, land, region, true); + } + + private static IrisRiverRuntime runtime( + IrisRiverNetwork configuration, + IrisBiome land, + IrisRegion region, + boolean caveHydrologyActive + ) { + ProceduralStream height = ProceduralStream.of( + (x, z) -> 180D - x * 0.01D - z * 0.015D, + Interpolated.DOUBLE + ); + ProceduralStream biome = ProceduralStream.of( + (x, z) -> land, + Interpolated.of(value -> 0D, value -> land) + ); + return runtime(configuration, height, biome, region, caveHydrologyActive); + } + + private static IrisRiverRuntime runtime( + IrisRiverNetwork configuration, + ProceduralStream height, + ProceduralStream biome + ) { + return runtime(configuration, height, biome, new IrisRegion()); + } + + private static IrisRiverRuntime runtime( + IrisRiverNetwork configuration, + ProceduralStream height, + ProceduralStream biome, + IrisRegion region + ) { + return runtime(configuration, height, biome, region, true); + } + + private static IrisRiverRuntime runtime( + IrisRiverNetwork configuration, + ProceduralStream height, + ProceduralStream biome, + IrisRegion region, + boolean caveHydrologyActive + ) { + return runtime(configuration, height, biome, region, false, caveHydrologyActive); + } + + private static IrisRiverRuntime runtime( + IrisRiverNetwork configuration, + ProceduralStream height, + ProceduralStream biome, + IrisRegion region, + boolean boreMantleActive, + boolean caveHydrologyActive + ) { + return runtime( + configuration, + height, + biome, + region, + boreMantleActive, + caveHydrologyActive, + true, + true + ); + } + + private static IrisRiverRuntime runtime( + IrisRiverNetwork configuration, + ProceduralStream height, + ProceduralStream biome, + IrisRegion region, + boolean boreMantleActive, + boolean caveHydrologyActive, + boolean blockingRoutingPossible, + boolean biomeRiverOverridesPossible + ) { + ProceduralStream slope = ProceduralStream.ofDouble((x, z) -> 0.025D); + ProceduralStream oceans = ProceduralStream.of( + (x, z) -> height.getDouble(x, z) < 62D, + Interpolated.BOOLEAN + ); + ProceduralStream regions = ProceduralStream.of( + (x, z) -> region, + Interpolated.of(value -> 0D, value -> region) + ); + return new IrisRiverRuntime(new IrisRiverRuntimeContext( + 4829759234L, + configuration, + mock(IrisData.class), + 63, + boreMantleActive, + caveHydrologyActive, + blockingRoutingPossible, + true, + biomeRiverOverridesPossible, + (x, z) -> new NoiseBounds(0D, 512D), + height, + slope, + oceans, + biome, + regions + )); + } + + private static RiverRoutingContext straightReach(double fromHeight, double toHeight) { + return straightReach(fromHeight, toHeight, 32D); + } + + private static RiverRoutingContext straightReach(double fromHeight, double toHeight, double length) { + RiverNode from = new RiverNode( + new RiverNodeId(0L, 0L), + 0D, + 0D, + fromHeight, + fromHeight, + fromHeight, + fromHeight, + false, + true + ); + RiverNode to = new RiverNode( + new RiverNodeId(1L, 0L), + length, + 0D, + toHeight, + toHeight, + toHeight, + toHeight, + false, + true + ); + return new RiverRoutingContext( + RiverEdgeId.of(from.id(), to.id()), + from, + to, + new RiverPolyline(new double[]{0D, length}, new double[]{0D, 0D}) + ); + } + + private static ProceduralStream constantHeight(double height) { + return ProceduralStream.of((x, z) -> height, Interpolated.DOUBLE); + } + + private static ProceduralStream constantLandBiome() { + IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); + return ProceduralStream.of( + (x, z) -> land, + Interpolated.of(value -> 0D, value -> land) + ); + } + + private static ProceduralStream countedBiome(IrisBiome biome, AtomicInteger samples) { + return ProceduralStream.of( + (x, z) -> { + samples.incrementAndGet(); + return biome; + }, + Interpolated.of(value -> 0D, value -> biome) + ); + } + + private static IrisRiverNetwork configuration(boolean requireOcean) { + IrisRiverNetwork configuration = new IrisRiverNetwork().setEnabled(true); + configuration.getTopology() + .setCellSize(64) + .setTileCells(1) + .setSiteJitter(0D) + .setMaxRouteReaches(4) + .setSinkSearchReaches(3) + .setRequireOcean(requireOcean); + configuration.getTopology().getSource() + .setChance(1D) + .setInfluence(0D) + .setStyle(flat()); + configuration.getTopology().getContinuation() + .setChance(1D) + .setInfluence(0D) + .setStyle(flat()); + configuration.getTopology().setRoutingStyle(flat()); + configuration.getTerrain() + .setChannelWidth(range(24D)) + .setBankWidth(range(12D)) + .setDepth(range(5D)) + .setMaxIncision(512) + .setMeanderStrength(0D) + .setMeanderStyle(flat()) + .setBedRoughness(0D) + .setBedRoughnessStyle(flat()) + .setDryContinuationChance(1D); + configuration.getTerrain().getIncision() + .setChance(1D) + .setInfluence(0D) + .setStyle(flat()); + configuration.getBiomes().setSelectionStyle(flat()); + return configuration; + } + + private static IrisRiverSurfaceSample findRiver(IrisRiverRuntime runtime, RiverRouteState state) { + for (int x = -128; x < 192; x += 2) { + for (int z = -128; z < 192; z += 2) { + IrisRiverSurfaceSample sample = runtime.sample(x, z); + if (sample.river().present() && sample.river().state() == state) { + return sample; + } + } + } + return null; + } + + private static boolean hasRiver(IrisRiverRuntime runtime) { + for (int x = 0; x < 64; x += 2) { + for (int z = 0; z < 64; z += 2) { + if (runtime.sample(x, z).river().present()) { + return true; + } + } + } + return false; + } + + private static IrisStyledRange range(double value) { + return new IrisStyledRange(value, value, flat()); + } + + private static IrisGeneratorStyle flat() { + return new IrisGeneratorStyle(NoiseStyle.FLAT); + } +} diff --git a/core/src/test/java/art/arcane/iris/util/common/director/handlers/GeneratorHandlerTest.java b/core/src/test/java/art/arcane/iris/util/common/director/handlers/GeneratorHandlerTest.java new file mode 100644 index 000000000..876c6a3a9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/director/handlers/GeneratorHandlerTest.java @@ -0,0 +1,12 @@ +package art.arcane.iris.util.common.director.handlers; + +import org.junit.Test; + +import static org.junit.Assert.assertNull; + +public class GeneratorHandlerTest { + @Test + public void nullDefaultOpensTheBuiltInNoiseExplorer() throws Exception { + assertNull(new GeneratorHandler().parse("null", true)); + } +} diff --git a/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java b/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java new file mode 100644 index 000000000..0260b69cf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java @@ -0,0 +1,44 @@ +package art.arcane.iris.util.project.matter.slices; + +import art.arcane.iris.engine.river.cave.RiverCaveAction; +import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class RiverCaveHydrologyMatterTest { + @Test + public void everyActionAndBiomeRoundTrips() throws IOException { + RiverCaveHydrologyMatter matter = new RiverCaveHydrologyMatter(); + for (RiverCaveAction action : RiverCaveAction.values()) { + RiverCaveHydrology expected = new RiverCaveHydrology(action, "iris:flooded_grotto"); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.writeNode(expected, new DataOutputStream(bytes)); + + RiverCaveHydrology actual = matter.readNode( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); + + assertEquals(expected, actual); + } + } + + @Test + public void emptyBiomeAndInvalidActionRemainUnambiguous() throws IOException { + RiverCaveHydrologyMatter matter = new RiverCaveHydrologyMatter(); + RiverCaveHydrology expected = RiverCaveHydrology.of(RiverCaveAction.DRY_AIR); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.writeNode(expected, new DataOutputStream(bytes)); + + assertEquals(expected, matter.readNode( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())))); + assertThrows(IOException.class, () -> matter.readNode( + new DataInputStream(new ByteArrayInputStream(new byte[]{99})))); + } +}