From 998a5c9f5f4a6fc2cd34b3d527cd84f2fe3303f2 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Mon, 10 Aug 2026 15:47:57 -0400 Subject: [PATCH] dwa (removed ai file from when i generated docs) --- .gitignore | 2 + AGENTS.md | 77 - .../DatapackStructureStateFilter.java | 289 + .../core/nms/v26_2_R1/IrisChunkGenerator.java | 320 +- .../iris/core/nms/v26_2_R1/NMSBinding.java | 130 +- ...IrisChunkGeneratorFailureContractTest.java | 306 +- .../NMSBindingDatapackStructureScopeTest.java | 334 + ...NMSBindingTileDeserializeContractTest.java | 30 + ...terListPoolElementRuntimeContractTest.java | 294 + .../iris/core/PendingWorldDeleteQueue.java | 127 +- .../iris/core/commands/CommandIris.java | 1 + .../iris/core/commands/CommandJigsaw.java | 2187 +++++ .../iris/core/commands/CommandObject.java | 7 +- .../iris/core/commands/CommandStructure.java | 22 +- .../arcane/iris/core/service/CommandSVC.java | 23 +- .../service/DatapackStructureScopeSVC.java | 91 + .../iris/core/service/IrisEngineSVC.java | 54 +- .../core/PendingWorldDeleteQueueTest.java | 33 + .../commands/CommandJigsawContractTest.java | 589 ++ ...risStructureLocateCommandContractTest.java | 32 + .../BukkitEngineLifecycleContractTest.java | 6 + .../DatapackStructureScopeSVCTest.java | 41 + .../service/IrisApiWiringContractTest.java | 6 +- .../IrisEngineLifecycleContractTest.java | 23 +- adapters/fabric/logs/latest.log | 488 + adapters/forge/logs/debug-1.log.gz | Bin 2908 -> 2912 bytes adapters/forge/logs/debug-2.log.gz | Bin 2919 -> 2926 bytes adapters/forge/logs/debug-3.log.gz | Bin 2922 -> 2924 bytes adapters/forge/logs/debug-4.log.gz | Bin 2832 -> 2913 bytes adapters/forge/logs/debug-5.log.gz | Bin 2297 -> 2916 bytes adapters/forge/logs/debug.log | 44 +- adapters/forge/logs/latest.log | 38 +- .../arcane/iris/modded/ModdedParityProbe.java | 2 +- .../iris/modded/ModdedWorldEngines.java | 4 +- .../command/ModdedStructureCommands.java | 13 +- .../service/ModdedChunkUpdateService.java | 41 +- .../service/ModdedChunkUpdateServiceTest.java | 74 + .../NativeStructureReferenceRepairTest.java | 2 +- core/purity-allowlist.txt | 7 + .../iris/core/IrisDatapackCompiler.java | 201 +- .../arcane/iris/core/ServerConfigurator.java | 326 +- .../core/SnapshotDirectoryTreeDeleter.java | 91 + .../core/datapack/DatapackIngestService.java | 449 +- .../datapack/DatapackStructureScopeIndex.java | 131 + .../lifecycle/IrisWorldRemovalService.java | 25 +- .../core/lifecycle/WorldLifecycleService.java | 9 + .../WorldUnloadBoundaryRegistry.java | 41 + .../art/arcane/iris/core/loader/IrisData.java | 27 + .../nms/DatapackStructureScopeResult.java | 7 + .../art/arcane/iris/core/nms/INMSBinding.java | 12 + .../iris/core/nms/v1X/NMSBinding1X.java | 20 + .../pack/DefaultPackBootstrapProvisioner.java | 40 +- .../pack/StructureGraphPackValidator.java | 18 +- .../core/pack/StructurePackageClosure.java | 113 +- .../arcane/iris/core/project/IrisProject.java | 21 +- .../core/runtime/StudioOpenCoordinator.java | 469 +- .../runtime/WorldRuntimeControlBackend.java | 36 + .../runtime/WorldRuntimeControlService.java | 10 + .../runtime/jigsaw/JigsawPlanarArchetype.java | 78 + .../runtime/jigsaw/JigsawPlanarDirection.java | 38 + .../runtime/jigsaw/JigsawPlanarTopology.java | 100 + .../jigsaw/JigsawPlanarTopologyKind.java | 10 + .../jigsaw/JigsawStudioActivation.java | 409 + .../jigsaw/JigsawStudioAuthoringAccess.java | 31 + .../core/runtime/jigsaw/JigsawStudioBay.java | 62 + .../runtime/jigsaw/JigsawStudioBayKind.java | 6 + .../runtime/jigsaw/JigsawStudioBounds.java | 37 + .../jigsaw/JigsawStudioCellDimensions.java | 30 + .../JigsawStudioCompatibilityTarget.java | 6 + .../jigsaw/JigsawStudioControlPosition.java | 4 + .../jigsaw/JigsawStudioGraphEditor.java | 1872 ++++ .../jigsaw/JigsawStudioGraphMapper.java | 381 + .../runtime/jigsaw/JigsawStudioLayout.java | 298 + .../jigsaw/JigsawStudioMarkerKeyCodec.java | 35 + .../core/runtime/jigsaw/JigsawStudioMode.java | 6 + .../jigsaw/JigsawStudioPieceRules.java | 34 + .../jigsaw/JigsawStudioPoolEditor.java | 442 + .../jigsaw/JigsawStudioPoolMembership.java | 22 + .../jigsaw/JigsawStudioProjectCreator.java | 227 + .../JigsawStudioProjectDeletionService.java | 435 + .../runtime/jigsaw/JigsawStudioSession.java | 666 ++ .../jigsaw/JigsawStudioStructureEditor.java | 465 + .../jigsaw/JigsawStudioToolAction.java | 45 + .../jigsaw/JigsawStudioToolPayload.java | 111 + .../JigsawStudioTripleSneakTracker.java | 112 + .../runtime/jigsaw/JigsawStudioVariant.java | 141 + .../jigsaw/JigsawStudioVariantCatalog.java | 95 + .../jigsaw/JigsawStudioWorkcellSpec.java | 20 + .../arcane/iris/core/service/BoardSVC.java | 282 +- .../service/JigsawStudioBoardContext.java | 56 + .../core/service/JigsawStudioBoardState.java | 21 + .../JigsawStudioDisabledWorkcellRenderer.java | 272 + .../service/JigsawStudioEvaluationState.java | 9 + .../service/JigsawStudioGraphEvaluation.java | 50 + .../service/JigsawStudioMarkerParser.java | 105 + .../service/JigsawStudioMenuController.java | 3058 +++++++ .../core/service/JigsawStudioMenuState.java | 289 + .../service/JigsawStudioPreviewRenderer.java | 455 + .../JigsawStudioResourceBundleAssembler.java | 185 + .../core/service/JigsawStudioService.java | 7976 +++++++++++++++++ .../core/service/JigsawStudioToolCodec.java | 150 + .../arcane/iris/core/service/StudioSVC.java | 91 +- .../core/structure/BulkStructureImporter.java | 126 +- .../structure/StructureCaptureImporter.java | 46 +- .../core/structure/StructureImporter.java | 141 +- .../iris/core/structure/VillageImporter.java | 424 +- .../authoring/StructureOwnershipManifest.java | 142 +- .../StructureTransactionReadSet.java | 147 + .../authoring/StructureTransactionWriter.java | 684 +- .../authoring/StructureWriteOptions.java | 17 +- .../authoring/StructureWriteResult.java | 30 +- .../IrisStructureAdoptionDiagnostic.java | 88 + .../IrisStructureAdoptionDisposition.java | 7 + .../IrisStructureAdoptionInputKind.java | 6 + .../IrisStructureAdoptionLimits.java | 56 + .../conversion/IrisStructureAdoptionPlan.java | 107 + .../IrisStructureAdoptionReceipt.java | 82 + .../IrisStructureAdoptionRequest.java | 54 + .../IrisStructureAdoptionResult.java | 54 + .../IrisStructureAdoptionService.java | 1313 +++ .../IrisStructureAdoptionStrategy.java | 7 + .../export/AtomicDatapackPublisher.java | 160 + .../structure/export/VanillaBlockState.java | 88 + .../export/VanillaJigsawDatapackExporter.java | 74 + .../export/VanillaJigsawExportCompiler.java | 815 ++ .../export/VanillaJigsawExportDiagnostic.java | 58 + .../export/VanillaJigsawExportFormat.java | 6 + .../export/VanillaJigsawExportRequest.java | 113 + .../export/VanillaJigsawExportResult.java | 38 + .../export/VanillaJigsawExportSettings.java | 243 + .../export/VanillaJigsawExportSource.java | 69 + .../export/VanillaJigsawExportValidation.java | 22 + .../export/VanillaResourceIdentifier.java | 56 + .../VanillaStructureTemplateEncoder.java | 176 + .../arcane/iris/core/tools/IrisCreator.java | 56 +- .../arcane/iris/engine/EngineHotloader.java | 2 + .../iris/engine/EngineShutdownSequence.java | 1 + .../art/arcane/iris/engine/IrisEngine.java | 93 +- .../NativeStructureBootstrapBarrier.java | 95 + .../framework/IrisStructureLocator.java | 84 +- .../NativeStructurePlacementPlanner.java | 5 + .../engine/framework/StructureAssembler.java | 1146 ++- .../StructureCaveAnchorResolver.java | 359 + .../framework/StructurePlacementGrid.java | 36 + .../framework/StructurePlacementScope.java | 15 + .../structure/JigsawPoolSelection.java | 65 + .../PlanarJigsawWorkcellResolver.java | 139 + .../structure/StructureAssemblyResult.java | 71 + .../structure/StructureAssemblyStatus.java | 17 + .../StructureGraphAssemblySample.java | 22 +- .../structure/StructureGraphCatalog.java | 6 +- .../structure/StructureGraphCompilation.java | 89 +- .../structure/StructureGraphCompiler.java | 767 +- .../structure/StructureGraphDiagnostic.java | 15 + .../StructureGraphValidationException.java | 11 + .../StructureResourceBundleGraphCompiler.java | 52 +- .../iris/engine/mantle/EngineMantle.java | 6 - .../components/IrisStructureComponent.java | 8 +- .../iris/engine/object/IrisDimension.java | 2 +- .../object/IrisJigsawBranchFailurePolicy.java | 12 + .../object/IrisJigsawCompatibility.java | 12 + .../engine/object/IrisJigsawConnector.java | 34 + .../iris/engine/object/IrisJigsawMode.java | 12 + .../iris/engine/object/IrisJigsawPiece.java | 32 + .../engine/object/IrisJigsawPieceEntry.java | 23 +- .../engine/object/IrisJigsawPieceRules.java | 52 + .../iris/engine/object/IrisJigsawPool.java | 7 + .../engine/object/IrisJigsawThemeSet.java | 24 + .../engine/object/IrisJigsawWorkcell.java | 40 + .../object/IrisJigsawWorkcellArchetype.java | 77 + .../object/IrisObjectPlacementRunner.java | 22 +- .../iris/engine/object/IrisStructure.java | 40 + .../object/IrisStructureAnchorMode.java | 34 + .../engine/object/IrisStructurePlacement.java | 42 +- .../engine/platform/BukkitChunkGenerator.java | 370 +- .../iris/engine/platform/EngineBukkitOps.java | 141 +- .../platform/studio/StudioGenerator.java | 7 + .../generators/BiomeBuffetGenerator.java | 34 +- .../generators/JigsawStudioGenerator.java | 874 ++ ...sDatapackCompilerInputFingerprintTest.java | 171 + .../iris/core/IrisSettingsDefaultsTest.java | 16 + ...erConfiguratorDatapackFingerprintTest.java | 122 +- .../SnapshotDirectoryTreeDeleterTest.java | 68 + ...DatapackIngestRuntimeInvalidationTest.java | 51 + .../datapack/DatapackIngestServiceTest.java | 216 +- .../DatapackStructureScopeIndexTest.java | 117 + .../WorldLifecycleUnloadAsyncTest.java | 23 + .../WorldUnloadBoundaryRegistryTest.java | 67 + .../IrisDataDatapackCompilerCacheTest.java | 12 + .../pack/StructureGraphPackValidatorTest.java | 30 +- .../StructurePackageClosureLimitsTest.java | 59 + .../pack/StructurePackageClosureTest.java | 11 +- .../core/project/SchemaBuilderParityTest.java | 11 + ...tudioOpenCoordinatorCloseSequenceTest.java | 126 + .../StudioOpenCoordinatorOpenKindTest.java | 134 + ...enCoordinatorSpawnStuckRegressionTest.java | 29 + ...WorldRuntimeControlBackendUrgencyTest.java | 80 + .../jigsaw/JigsawPlanarTopologyTest.java | 102 + .../jigsaw/JigsawStudioActivationTest.java | 233 + .../JigsawStudioGraphEditorResizeTest.java | 262 + .../jigsaw/JigsawStudioGraphMapperTest.java | 338 + .../jigsaw/JigsawStudioLayoutTest.java | 175 + ...JigsawStudioManagedAuthoringGuardTest.java | 177 + .../JigsawStudioPersistenceEditorsTest.java | 624 ++ .../JigsawStudioProjectCreatorTest.java | 497 + .../jigsaw/JigsawStudioSessionTest.java | 289 + .../JigsawStudioTripleSneakTrackerTest.java | 108 + .../service/BoardSVCJigsawContextTest.java | 195 + .../BoardSVCStudioEligibilityTest.java | 19 - ...sawStudioDisabledWorkcellRendererTest.java | 89 + .../service/JigsawStudioLifecycleTest.java | 317 + .../service/JigsawStudioMarkerParserTest.java | 104 + .../JigsawStudioMenuControllerTest.java | 555 ++ .../JigsawStudioPreviewRendererTest.java | 135 + ...gsawStudioResourceBundleAssemblerTest.java | 295 + .../JigsawStudioServiceCaptureTest.java | 1445 +++ .../service/JigsawStudioToolCodecTest.java | 196 + .../StudioSVCJigsawProtectionTest.java | 124 + ...BulkStructureImporterTemplateNameTest.java | 17 + ...ureCaptureImporterTargetSelectionTest.java | 73 + .../StructureImporterCaptureTest.java | 29 + .../structure/StructureImporterModeTest.java | 32 + .../structure/VillageImporterBundleTest.java | 3 + ...VillageImporterConnectorRoundTripTest.java | 156 + .../VillageImporterFailureLogTest.java | 3 +- .../VillageImporterListPoolElementTest.java | 421 + .../StructureOwnershipProvenanceTest.java | 138 + .../StructureTransactionClaimTest.java | 114 + .../StructureTransactionWriterTest.java | 122 +- .../IrisStructureAdoptionServiceTest.java | 376 + .../VanillaJigsawDatapackExporterTest.java | 419 + .../IrisCreatorDatapackPreparationTest.java | 20 + .../NativeStructureBootstrapBarrierTest.java | 134 + ...ructureBootstrapLifecycleContractTest.java | 106 + .../IrisStructureLocatorContractTest.java | 46 +- .../NativeStructurePlacementPlannerTest.java | 17 + .../framework/StructureAssemblerTest.java | 808 +- .../StructureCaveAnchorResolverTest.java | 195 + .../framework/StructurePlacementGridTest.java | 62 + .../StructurePlacementScopeTest.java | 57 + .../structure/JigsawPoolSelectionTest.java | 57 + .../PlanarJigsawWorkcellResolverTest.java | 75 + .../StructureAssemblyResultTest.java | 79 + .../structure/StructureGraphCompilerTest.java | 567 +- ...uctureResourceBundleGraphCompilerTest.java | 87 +- .../mantle/EngineMantleCleanupTest.java | 95 + .../object/IrisJigsawModelMetadataTest.java | 93 + .../object/IrisJigsawRulesModelTest.java | 147 + ...IrisObjectSurfaceSupportPlacementTest.java | 75 +- .../object/IrisStructureAnchorModeTest.java | 33 + ...ChunkGeneratorGenerationStageGateTest.java | 412 + ...tChunkGeneratorInitializationModeTest.java | 52 + ...eBukkitOpsDeferredMaterializationTest.java | 118 + .../BiomeBuffetGeneratorPreparationTest.java | 52 + .../generators/JigsawStudioGeneratorTest.java | 583 ++ .../arcane/iris/probe/GenerationProbe.java | 2 +- 256 files changed, 49776 insertions(+), 1221 deletions(-) delete mode 100644 AGENTS.md create mode 100644 adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingTileDeserializeContractTest.java create mode 100644 adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/structure/VillageImporterListPoolElementRuntimeContractTest.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java create mode 100644 adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/DatapackStructureScopeSVCTest.java create mode 100644 core/src/main/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleter.java create mode 100644 core/src/main/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndex.java create mode 100644 core/src/main/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistry.java create mode 100644 core/src/main/java/art/arcane/iris/core/nms/DatapackStructureScopeResult.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarArchetype.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarDirection.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopology.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyKind.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivation.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioAuthoringAccess.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBay.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBayKind.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBounds.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCellDimensions.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCompatibilityTarget.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioControlPosition.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditor.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapper.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayout.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMarkerKeyCodec.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMode.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPieceRules.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolEditor.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolMembership.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreator.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectDeletionService.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSession.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioStructureEditor.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolAction.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolPayload.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTracker.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariant.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariantCatalog.java create mode 100644 core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioWorkcellSpec.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardContext.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardState.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRenderer.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioEvaluationState.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioGraphEvaluation.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioMarkerParser.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuState.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssembler.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java create mode 100644 core/src/main/java/art/arcane/iris/core/service/JigsawStudioToolCodec.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionReadSet.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDiagnostic.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDisposition.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionInputKind.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionLimits.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionPlan.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionReceipt.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionRequest.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionResult.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionService.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionStrategy.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/AtomicDatapackPublisher.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaBlockState.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporter.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportCompiler.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportDiagnostic.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportFormat.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportRequest.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportResult.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSettings.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSource.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportValidation.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaResourceIdentifier.java create mode 100644 core/src/main/java/art/arcane/iris/core/structure/export/VanillaStructureTemplateEncoder.java create mode 100644 core/src/main/java/art/arcane/iris/engine/NativeStructureBootstrapBarrier.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolver.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResult.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyStatus.java create mode 100644 core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphValidationException.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisJigsawBranchFailurePolicy.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisJigsawCompatibility.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisJigsawMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceRules.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisJigsawThemeSet.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcell.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcellArchetype.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisStructureAnchorMode.java create mode 100644 core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java create mode 100644 core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleterTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestRuntimeInvalidationTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndexTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistryTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureLimitsTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackendUrgencyTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivationTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditorResizeTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapperTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayoutTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioManagedAuthoringGuardTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPersistenceEditorsTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreatorTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSessionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTrackerTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/BoardSVCJigsawContextTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRendererTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/JigsawStudioLifecycleTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/JigsawStudioMarkerParserTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/JigsawStudioMenuControllerTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/JigsawStudioPreviewRendererTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssemblerTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/JigsawStudioServiceCaptureTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/JigsawStudioToolCodecTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/service/StudioSVCJigsawProtectionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/structure/StructureCaptureImporterTargetSelectionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/structure/VillageImporterConnectorRoundTripTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/structure/VillageImporterListPoolElementTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/structure/authoring/StructureOwnershipProvenanceTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionClaimTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionServiceTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporterTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/tools/IrisCreatorDatapackPreparationTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapBarrierTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapLifecycleContractTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelectionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolverTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResultTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisJigsawModelMetadataTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisJigsawRulesModelTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisStructureAnchorModeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGeneratorPreparationTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGeneratorTest.java diff --git a/.gitignore b/.gitignore index 7242fab8f..9b7484762 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ __pycache__/ # Doc authoring scratch .docs-wip/ + +AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 210b047c3..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,77 +0,0 @@ -# Iris Agent Guide - -Iris is a world generation engine for Minecraft servers and mod loaders. It generates terrain, biomes, caves, structures, objects, and entities from editable JSON packs, with an in-game studio authoring workflow. The same engine runs as a Bukkit-family plugin and as a Fabric, Forge, or NeoForge server mod. Read this file before making any change; the workspace-level `../AGENTS.md` also applies when working inside the VolmitSoftware workspace. - -## Documentation Policy (mandatory) - -- `docs/` is the authoritative reference for every feature of this plugin/mod. Files are flat (no subfolders) and numbered `NN - Title.md`, ordered for someone new to Iris; API docs always keep the highest numbers. -- ANY change that alters a feature, command, permission, setting, pack JSON contract, studio/editor workflow, pregen behavior, structure/object system, integration, localization, client HUD/protocol, or public API surface MUST update the matching numbered doc in the same workstream. A behavior change with stale docs is an incomplete change — do not finish work without the doc update. -- Docs state actual runtime behavior, not intended behavior. If a change fixes a documented quirk, update or remove that quirk entry. If a change introduces surprising behavior, document it plainly. -- Docs are purely factual reference material: no marketing language, no emojis, no filler. Each file opens with a 1–4 sentence summary. -- Cross-references use exact filenames (for example `see "04 - Commands & Permissions.md"`). When adding or renumbering files, fix every cross-reference. -- Hosted external docs are not authority; this `docs/` tree is. -- Maintainer-only checklists use high numbers before the API series and are titled `Maintainer — …`. - -## Doc Index - -| File | Covers | -|------|--------| -| `00 - Overview.md` | What Iris is, feature map, doc index, project layout | -| `01 - Installation & Platforms.md` | Plugin/mod install, data dirs, first boot, platforms, native worldgen matrix | -| `02 - Getting Started.md` | First world, teleport, basic pregen, first studio | -| `03 - Configuration.md` | `settings.json` keys, defaults, hotload | -| `04 - Commands & Permissions.md` | Full `/iris` tree, Bukkit vs modded, permissions | -| `05 - Concepts & Pack Layout.md` | Pack roots, keys, folders, snippets, world snapshot vs studio | -| `06 - Worlds & Lifecycle.md` | create/load/unload/remove, main world, Folia, pack copy | -| `07 - Pregeneration.md` | pregen ops, cache, mantle, HUD | -| `08 - Localization.md` | locales, overrides, client lang | -| `09 - PlaceholderAPI.md` | `%iris_…%` keys and migration | -| `10 - Studio & VSCode Schemas.md` | Studio workflow, schemas, hotload | -| `11 - Dimensions.md` | Dimension JSON, modes, height, imports | -| `12 - Regions.md` | Regions and region-level content | -| `13 - Biomes.md` | Biome JSON, layers, custom biomes, spawns | -| `14 - Generators & Noise.md` | Generators, styles, expressions, images | -| `15 - Caves & Carving.md` | Cave profiles, field modules, carving | -| `16 - Surfaces, Decorators & Deposits.md` | Decorators, deposits, palettes | -| `17 - Trees, Fungi, Coral, Crystals, Formations, Ruins.md` | Procedural decoration systems | -| `18 - Structures Overview.md` | Objects vs jigsaw vs native structures | -| `19 - Objects.md` | Creating and importing `.iob` objects | -| `20 - Object Placement.md` | Placing objects in biomes and regions | -| `21 - Jigsaw Structures.md` | Iris multi-piece structures | -| `22 - Native Structures & Datapacks.md` | Vanilla/datapack structures on Iris | -| `23 - Loot, Entities, Spawners, Markers.md` | Pack entities and loot | -| `24 - Pack Mods & Snippets.md` | Injectors/replacers and snippets | -| `25 - Pack Management.md` | Download, validate, cleanup, package, update-world | -| `26 - Example - Minimal Dimension.md` | Minimal pack walkthrough | -| `27 - Example - Configuring Overworld.md` | Editing the shipping overworld | -| `28 - Integrations.md` | WorldEdit, Multiverse, Mythic, item plugins, tree feller | -| `29 - Client HUD & Protocol.md` | Client mod HUD and protocol | -| `30 - Platform Differences.md` | Bukkit vs Fabric/Forge/NeoForge matrix | -| `31 - Operator Runbooks & Smoke Tests.md` | Manual verification | -| `32 - Determinism & Goldenhash.md` | Cross-platform parity gate | -| `33 - Performance Tuning.md` | Threads, mantle, SIMD, pregen caps | -| `85 - Maintainer - MC Version Bump.md` | Version bump procedure | -| `86 - Maintainer - Release Checklist.md` | Release steps | -| `87 - Maintainer - Release Readiness.md` | Living readiness tracker | -| `90 - API - Getting Started.md` | Bukkit public API setup | -| `91 - API - Terrain.md` | Terrain query service | -| `92 - API - World Events.md` | Engine and pregen events | -| `93 - API - Tree Feller.md` | Tree feller service | -| `94 - API - Modded.md` | Modded public API | - -Docs `00`–`33` serve operators and pack authors in reading order; `85`–`87` are maintainer; `90`–`94` serve plugin and mod developers. - -## Build and Platforms - -- Java 25 required. Independent Gradle build from `Iris/`: `./gradlew build`, `./gradlew test`. -- Artifacts: Bukkit-family plugin jar; Fabric, Forge, and NeoForge mod jars under `dist/` when built. -- Modules: `core` (engine), `spi` (platform SPI), `adapters/bukkit/plugin` (plugin + Bukkit API), `adapters/modded-common` + loader adapters, `probe` (offline tooling). -- Default pack downloads at first boot from the IrisDimensions overworld release; packs live under the platform data directory `packs//`. - -## Content Model (brief) - -- **Pack** — directory of JSON and `.iob` resources under `packs//` with at least `dimensions/*.json`. -- **Dimension** — root config for a world type (height, modes, regions, imports). -- **Region / Biome / Generator** — spatial and terrain authoring units. -- **Object / Structure** — placed content (`.iob`, Iris jigsaw, native/datapack structures). -- **Studio** — transient authoring world with live pack hotload and VSCode schemas. diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java new file mode 100644 index 000000000..3d139090d --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java @@ -0,0 +1,289 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.core.datapack.DatapackStructureScopeIndex; +import net.minecraft.core.Holder; +import net.minecraft.core.Vec3i; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +final class DatapackStructureStateFilter { + private DatapackStructureStateFilter() { + } + + static Selection filter( + List> structureSets, + DatapackStructureScopeIndex scopeIndex, + Set declaredSources + ) { + Map> holdersByKey = new HashMap<>(); + for (Holder holder : structureSets) { + String key = structureSetKey(holder); + if (key != null) { + holdersByKey.putIfAbsent(key, holder); + } + } + Map, Holder> scopedByIdentity = new IdentityHashMap<>(); + Set> visiting = Collections.newSetFromMap(new IdentityHashMap<>()); + List> filteredSets = new ArrayList<>(structureSets.size()); + int retainedManagedSets = 0; + int excludedManagedSets = 0; + for (Holder holder : structureSets) { + String setKey = structureSetKey(holder); + boolean managedSet = setKey != null && scopeIndex.isManagedStructureSet(setKey); + Holder scopedHolder = scopeHolder( + holder, + scopeIndex, + declaredSources, + holdersByKey, + scopedByIdentity, + visiting); + if (scopedHolder == null) { + if (managedSet) { + excludedManagedSets++; + } + continue; + } + if (managedSet) { + retainedManagedSets++; + } + filteredSets.add(scopedHolder); + } + return new Selection( + List.copyOf(filteredSets), + retainedManagedSets, + excludedManagedSets); + } + + static String structureSetKey(Holder holder) { + Optional> key = holder.unwrapKey(); + if (key.isPresent()) { + return key.get().identifier().toString(); + } + if (holder.value().placement() + instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) { + return keyedPlacement.key.identifier().toString(); + } + return null; + } + + private static List filterEntries( + List entries, + DatapackStructureScopeIndex scopeIndex, + Set declaredSources + ) { + List filtered = new ArrayList<>(entries.size()); + for (StructureSet.StructureSelectionEntry entry : entries) { + String structureKey = entry.structure().unwrapKey() + .map(key -> key.identifier().toString()) + .orElse(null); + if (structureKey == null || scopeIndex.allowsStructure(structureKey, declaredSources)) { + filtered.add(entry); + } + } + return filtered; + } + + private static Holder scopeHolder( + Holder holder, + DatapackStructureScopeIndex scopeIndex, + Set declaredSources, + Map> holdersByKey, + Map, Holder> scopedByIdentity, + Set> visiting + ) { + if (scopedByIdentity.containsKey(holder)) { + return scopedByIdentity.get(holder); + } + if (!visiting.add(holder)) { + return null; + } + try { + String setKey = structureSetKey(holder); + if (setKey != null && scopeIndex.isManagedStructureSet(setKey) + && !scopeIndex.allowsStructureSet(setKey, declaredSources)) { + scopedByIdentity.put(holder, null); + return null; + } + StructureSet originalSet = holder.value(); + List entries = filterEntries( + originalSet.structures(), scopeIndex, declaredSources); + if (entries.isEmpty()) { + scopedByIdentity.put(holder, null); + return null; + } + StructurePlacement placement = scopePlacement( + originalSet.placement(), + scopeIndex, + declaredSources, + holdersByKey, + scopedByIdentity, + visiting); + if (entries.size() == originalSet.structures().size() + && placement == originalSet.placement()) { + scopedByIdentity.put(holder, holder); + return holder; + } + Holder scoped = Holder.direct(new StructureSet(entries, placement)); + scopedByIdentity.put(holder, scoped); + return scoped; + } finally { + visiting.remove(holder); + } + } + + static Optional exclusionZone(StructurePlacement placement) { + Object value = declaredFieldValue(StructurePlacement.class, placement, Optional.class); + if (value instanceof Optional optional && (optional.isEmpty() + || optional.get() instanceof StructurePlacement.ExclusionZone)) { + @SuppressWarnings("unchecked") + Optional resolved = + (Optional) optional; + return resolved; + } + throw new IllegalStateException("Could not read structure placement exclusion zone from " + + placement.getClass().getName()); + } + + private static StructurePlacement scopePlacement( + StructurePlacement placement, + DatapackStructureScopeIndex scopeIndex, + Set declaredSources, + Map> holdersByKey, + Map, Holder> scopedByIdentity, + Set> visiting + ) { + Optional currentZone = exclusionZone(placement); + if (currentZone.isEmpty()) { + return placement; + } + + Holder target = currentZone.get().otherSet(); + String targetKey = structureSetKey(target); + if (targetKey != null) { + target = holdersByKey.getOrDefault(targetKey, target); + } + Holder scopedTarget = scopeHolder( + target, + scopeIndex, + declaredSources, + holdersByKey, + scopedByIdentity, + visiting); + Optional scopedZone = Optional.empty(); + if (scopedTarget != null) { + scopedZone = Optional.of(new StructurePlacement.ExclusionZone( + scopedTarget, + currentZone.get().chunkCount())); + } + if (scopedTarget == currentZone.get().otherSet()) { + return placement; + } + return copyPlacement(placement, scopedZone); + } + + private static StructurePlacement copyPlacement( + StructurePlacement placement, + Optional exclusionZone + ) { + Vec3i locateOffset = (Vec3i) declaredFieldValue( + StructurePlacement.class, placement, Vec3i.class); + StructurePlacement.FrequencyReductionMethod frequencyReductionMethod = + (StructurePlacement.FrequencyReductionMethod) declaredFieldValue( + StructurePlacement.class, + placement, + StructurePlacement.FrequencyReductionMethod.class); + float frequency = (float) declaredFieldValue( + StructurePlacement.class, placement, float.class); + int salt = (int) declaredFieldValue( + StructurePlacement.class, placement, int.class); + + if (placement instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) { + return new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement( + keyedPlacement.key, + locateOffset, + frequencyReductionMethod, + frequency, + salt, + exclusionZone, + keyedPlacement.spacing(), + keyedPlacement.separation(), + keyedPlacement.spreadType()); + } + if (placement instanceof RandomSpreadStructurePlacement randomSpread) { + return new RandomSpreadStructurePlacement( + locateOffset, + frequencyReductionMethod, + frequency, + salt, + exclusionZone, + randomSpread.spacing(), + randomSpread.separation(), + randomSpread.spreadType()); + } + if (placement instanceof ConcentricRingsStructurePlacement rings) { + return new ConcentricRingsStructurePlacement( + locateOffset, + frequencyReductionMethod, + frequency, + salt, + exclusionZone, + rings.distance(), + rings.spread(), + rings.count(), + rings.preferredBiomes()); + } + throw new IllegalStateException("Unsupported structure placement with an exclusion zone: " + + placement.getClass().getName()); + } + + private static Object declaredFieldValue( + Class declaringType, + Object target, + Class fieldType + ) { + Field match = null; + for (Field field : declaringType.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType() != fieldType) { + continue; + } + if (match != null) { + throw new IllegalStateException("Ambiguous " + fieldType.getName() + " field on " + + declaringType.getName()); + } + match = field; + } + if (match == null) { + throw new IllegalStateException("Missing " + fieldType.getName() + " field on " + + declaringType.getName()); + } + try { + match.setAccessible(true); + return match.get(target); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Could not read " + fieldType.getName() + " field on " + + declaringType.getName(), e); + } + } + + record Selection( + List> structureSets, + int retainedManagedSets, + int excludedManagedSets + ) { + } +} 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 a4ec5b763..fae97e3a1 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 @@ -7,6 +7,8 @@ import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord; import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisNativeStructureDecision; @@ -25,6 +27,7 @@ import art.arcane.iris.nativegen.NativeStructureVerticalPlacer; import art.arcane.iris.nativegen.NativeStructureVanillaLocator; import art.arcane.iris.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.reflect.WrappedField; @@ -43,6 +46,7 @@ import net.minecraft.core.SectionPos; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ChunkMap; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.WorldGenRegion; import net.minecraft.util.random.Weighted; @@ -91,6 +95,8 @@ import javax.annotation.Nullable; import java.lang.ref.WeakReference; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -98,23 +104,32 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.IntBinaryOperator; public class IrisChunkGenerator extends CustomChunkGenerator { private static final WrappedField BIOME_SOURCE; private static final WrappedReturningMethod SET_HEIGHT; + private static final Runnable NO_OP = () -> { + }; private final ChunkGenerator delegate; private final Engine engine; private final CustomBiomeSource customBiomeSource; + private final ServerLevel runtimeLevel; + private final @Nullable BukkitChunkGenerator platformGenerator; private final int runtimeMinY; private final int runtimeHeight; private final int runtimeSeaLevel; private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); private final ImportedFeatureStage importedFeatures; + private final AtomicReference retainedStudioStructureState = new AtomicReference<>(); private volatile ReachableStructureCache reachableStructureCache; private volatile StructureStepCache structureStepCache; @@ -129,6 +144,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator { this.customBiomeSource = customBiomeSource; this.importedFeatures = new ImportedFeatureStage(engine); ServerLevel level = ((CraftWorld) world).getHandle(); + this.runtimeLevel = level; + this.platformGenerator = world.getGenerator() instanceof BukkitChunkGenerator bukkitGenerator + ? bukkitGenerator + : null; this.runtimeMinY = level.getMinY(); this.runtimeHeight = level.getHeight(); this.runtimeSeaLevel = runtimeMinY + engine.getDimension().getFluidHeight(); @@ -154,6 +173,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public @Nullable Pair> findNearestMapStructure(ServerLevel level, HolderSet holders, BlockPos pos, int radius, boolean findUnexplored) { + if (platformGenerator != null && !platformGenerator.shouldGenerateStructures()) { + return null; + } + if (level != runtimeLevel || level.getChunkSource().getGenerator() != this) { + return null; + } try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { HolderSet reachable = filterReachableStructures(level, holders); @@ -325,7 +350,15 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess access, StructureTemplateManager templateManager, ResourceKey levelKey) { - try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_structures"); + if (platformGenerator != null && !platformGenerator.shouldGenerateStructures()) { + return; + } + if (runtimeLevel.getChunkSource().getGenerator() != this + || runtimeLevel.getChunkSource().getGeneratorState() != structureState) { + return; + } + try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_structures"); + GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_structures"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { Map previousStarts = new HashMap<>(access.getAllStarts()); super.createStructures(registryAccess, structureState, structureManager, access, templateManager, levelKey); @@ -409,9 +442,192 @@ public class IrisChunkGenerator extends CustomChunkGenerator { return delegate.createState(holderlookup, randomstate, i, conf); } + void retainStudioStructureState( + ServerLevel level, + ChunkMap chunkMap, + ChunkGeneratorStructureState structureState + ) { + requireCurrentStructureOwner(level, chunkMap); + StudioStructureState retained = new StudioStructureState( + level, + chunkMap, + Objects.requireNonNull(structureState, "Studio native structure state")); + if (!retainedStudioStructureState.compareAndSet(null, retained)) { + throw new IllegalStateException("Studio native structure state is already retained."); + } + } + + StudioStructureState retainedStudioStructureState(ServerLevel level, ChunkMap chunkMap) { + StudioStructureState retained = retainedStudioStructureState.get(); + if (retained == null) { + return null; + } + requireCurrentStructureOwner(level, chunkMap); + if (retained.level() != level || retained.chunkMap() != chunkMap) { + throw new IllegalStateException("Retained Studio native structure state belongs to another world runtime."); + } + if (level.getChunkSource().getGeneratorState() != retained.structureState()) { + throw new IllegalStateException("Studio native structure state is no longer current."); + } + return retained; + } + + void claimStudioStructureState(StudioStructureState retained) { + if (!retainedStudioStructureState.compareAndSet(retained, null)) { + throw new IllegalStateException("Studio native structure state changed before activation began."); + } + } + + void abandonStudioStructureState() { + retainedStudioStructureState.set(null); + } + + CompletableFuture initializeAndPublishStructureState( + ChunkGeneratorStructureState structureState, + StructureStatePublisher publisher + ) { + return startStructureStateBootstrap( + structureState, + NO_OP, + () -> publishStructureState(publisher)); + } + + CompletableFuture activateStudioStructureState(StudioStructureState retained) { + Objects.requireNonNull(retained, "Retained Studio native structure state"); + return startStructureStateBootstrap( + retained.structureState(), + () -> { + StudioStructureState current = retainedStudioStructureState( + retained.level(), retained.chunkMap()); + if (current != retained) { + throw new IllegalStateException("Studio native structure state changed before activation."); + } + claimStudioStructureState(retained); + }, + NO_OP); + } + + private CompletableFuture startStructureStateBootstrap( + ChunkGeneratorStructureState structureState, + Runnable claim, + Runnable activation + ) { + if (!(engine instanceof IrisEngine irisEngine)) { + throw new IllegalStateException("Native structure bootstrap requires an IrisEngine runtime."); + } + AtomicReference> registeredCompletion = new AtomicReference<>(); + CompletableFuture completion = irisEngine.startNativeStructureBootstrap( + claim, + () -> { + CompletableFuture rings = initializeStructureState(structureState); + registeredCompletion.set(rings); + return rings; + }, + () -> { + CompletableFuture rings = Objects.requireNonNull( + registeredCompletion.get(), + "Registered native structure ring completion"); + if (rings.isCompletedExceptionally()) { + throw new IllegalStateException( + "Minecraft native structure ring bootstrap failed before activation."); + } + activation.run(); + }); + completion.whenComplete((ignored, failure) -> { + if (failure != null) { + Throwable cause = failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() + : failure; + IrisLogging.reportError("Native structure ring bootstrap failed for world '" + + runtimeLevel.getWorld().getName() + "'.", cause); + } + }); + return completion; + } + + private CompletableFuture initializeStructureState(ChunkGeneratorStructureState structureState) { + Map ringPositions = structureRingPositions(structureState); + structureState.ensureStructuresGenerated(); + return structureRingCompletion(ringPositions); + } + + private Map structureRingPositions(ChunkGeneratorStructureState structureState) { + try { + Field field = structureRingPositionsField(); + field.setAccessible(true); + Object value = field.get(structureState); + if (!(value instanceof Map ringPositions)) { + throw new IllegalStateException("Minecraft native structure ring state is unavailable."); + } + return ringPositions; + } catch (IllegalAccessException e) { + throw new IllegalStateException("Could not bind Minecraft native structure ring completions.", e); + } + } + + private CompletableFuture structureRingCompletion(Map ringPositions) { + List> futures = new ArrayList<>(ringPositions.size()); + for (Object candidate : ringPositions.values()) { + if (candidate instanceof CompletableFuture future) { + futures.add(future); + } else { + throw new IllegalStateException( + "Minecraft native structure ring completion is not a future."); + } + } + CompletableFuture[] completions = futures.toArray(new CompletableFuture[0]); + return CompletableFuture.allOf(completions); + } + + private Field structureRingPositionsField() { + List candidates = new ArrayList<>(1); + for (Field field : ChunkGeneratorStructureState.class.getDeclaredFields()) { + if (!Map.class.isAssignableFrom(field.getType())) { + continue; + } + Type genericType = field.getGenericType(); + if (!(genericType instanceof ParameterizedType parameterizedType)) { + continue; + } + Type[] arguments = parameterizedType.getActualTypeArguments(); + if (arguments.length == 2 + && arguments[1].getTypeName().contains(CompletableFuture.class.getName())) { + candidates.add(field); + } + } + if (candidates.size() != 1) { + throw new IllegalStateException("Expected one Minecraft native structure ring-future map, found " + + candidates.size() + "."); + } + return candidates.getFirst(); + } + + private void requireCurrentStructureOwner(ServerLevel level, ChunkMap chunkMap) { + if (runtimeLevel != level + || level.getChunkSource().chunkMap != chunkMap + || level.getChunkSource().getGenerator() != this) { + throw new IllegalStateException("Iris native structure state no longer belongs to the active world runtime."); + } + } + + private void publishStructureState(StructureStatePublisher publisher) { + try { + publisher.publish(); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Could not publish Minecraft native structure state.", e); + } + } + @Override public void createReferences(WorldGenLevel generatoraccessseed, StructureManager structuremanager, ChunkAccess ichunkaccess) { - try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_references"); + if (platformGenerator != null && !platformGenerator.shouldGenerateStructures()) { + return; + } + if (runtimeLevel.getChunkSource().getGenerator() != this) { + return; + } + try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_create_references"); + GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_references"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { NativeStructureReferenceRepair.createReferences( engine, generatoraccessseed, structuremanager, ichunkaccess); @@ -420,7 +636,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public CompletableFuture createBiomes(RandomState randomstate, Blender blender, StructureManager structuremanager, ChunkAccess ichunkaccess) { - try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_biomes"); + 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()); return CompletableFuture.completedFuture(ichunkaccess); @@ -439,17 +656,52 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public CompletableFuture fillFromNoise(Blender blender, RandomState randomstate, StructureManager structuremanager, ChunkAccess ichunkaccess) { - return delegate.fillFromNoise(blender, randomstate, structuremanager, ichunkaccess) - .thenApply(filled -> { - try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_worldgen_heightmaps"); - IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - primeWorldgenHeightmaps(filled); - return filled; - } catch (GenerationSessionException e) { - throw new IllegalStateException( - "Iris worldgen heightmap priming could not acquire its engine runtime.", e); - } - }); + BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage( + ichunkaccess.getPos(), + "bukkit_nms_chunk_pipeline"); + GenerationSessionLease lease; + try { + lease = requireGenerationLease("bukkit_nms_chunk_pipeline"); + } catch (RuntimeException | Error failure) { + stage.close(); + throw failure; + } + try { + CompletableFuture pipeline = delegate + .fillFromNoise(blender, randomstate, structuremanager, ichunkaccess) + .thenApply(filled -> { + try (IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + primeWorldgenHeightmaps(filled); + return filled; + } + }); + CompletableFuture completion = new CompletableFuture<>(); + pipeline.whenComplete((filled, failure) -> { + boolean cancelled = isCancellationFailure(failure); + lease.close(); + stage.close(); + if (failure == null) { + completion.complete(filled); + } else if (cancelled) { + completion.cancel(false); + } else { + completion.completeExceptionally(failure); + } + }); + return completion; + } catch (RuntimeException | Error failure) { + lease.close(); + stage.close(); + throw failure; + } + } + + private static boolean isCancellationFailure(Throwable failure) { + Throwable current = failure; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current instanceof CancellationException; } private void primeWorldgenHeightmaps(ChunkAccess chunkAccess) { @@ -502,11 +754,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla) { - // Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a - // feature-order cycle is reported once and degraded to features-off. - importedFeatures.prepare(generatoraccessseed); - try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration"); + try (BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_biome_decoration"); + GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + // Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a + // feature-order cycle is reported once and degraded to features-off. + importedFeatures.prepare(generatoraccessseed); addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager); placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); // Vanilla's placed-feature pass, on THIS thread. The delegate is still called with @@ -833,6 +1086,25 @@ public class IrisChunkGenerator extends CustomChunkGenerator { } } + private BukkitChunkGenerator.GenerationStagePermit requireGenerationStage(String operation) { + return platformGenerator == null + ? BukkitChunkGenerator.GenerationStagePermit.noop() + : platformGenerator.acquireGenerationStage(operation); + } + + private BukkitChunkGenerator.GenerationStagePermit requireNoiseGenerationStage( + ChunkPos chunkPos, + String operation + ) { + return platformGenerator == null + ? BukkitChunkGenerator.GenerationStagePermit.noop() + : platformGenerator.acquireNoiseGenerationStage( + engine, + chunkPos.x(), + chunkPos.z(), + operation); + } + @Override public Optional getTypeNameForDataFixer() { return delegate.getTypeNameForDataFixer(); @@ -894,6 +1166,18 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private record StructureStepCache(Registry registry, List> structures) { } + record StudioStructureState( + ServerLevel level, + ChunkMap chunkMap, + ChunkGeneratorStructureState structureState + ) { + } + + @FunctionalInterface + interface StructureStatePublisher { + void publish() throws IllegalAccessException; + } + private record NativePlacement(StructureStart start, IrisNativeStructureDecision decision) { } 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 ed16cdbd3..3eabdfc20 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 @@ -4,6 +4,8 @@ import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO; import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager; import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.NewChunkHolder; import com.mojang.brigadier.exceptions.CommandSyntaxException; +import art.arcane.iris.core.datapack.DatapackStructureScopeIndex; +import art.arcane.iris.core.nms.DatapackStructureScopeResult; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.nms.INMSBinding; import art.arcane.iris.core.nms.MinecraftVersion; @@ -17,6 +19,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.NativeStructureVolume; import art.arcane.iris.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; +import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.nativegen.NativeStructureFactory; import art.arcane.iris.nativegen.NativeStructureGenerationException; @@ -95,6 +98,7 @@ import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.Property; import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.level.chunk.LevelChunkSection; import net.minecraft.world.level.chunk.ProtoChunk; @@ -156,6 +160,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.List; +import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -313,8 +318,14 @@ public class NMSBinding implements INMSBinding { return; } - var level = ((CraftWorld) pos.getWorld()).getHandle(); - var blockPos = new BlockPos(pos.getBlockX(), pos.getBlockY(), pos.getBlockZ()); + ServerLevel level = ((CraftWorld) pos.getWorld()).getHandle(); + BlockPos blockPos = new BlockPos(pos.getBlockX(), pos.getBlockY(), pos.getBlockZ()); + int chunkX = pos.getBlockX() >> 4; + int chunkZ = pos.getBlockZ() >> 4; + if (J.isOwnedByCurrentRegion(pos.getWorld(), chunkX, chunkZ)) { + merge(level, blockPos, tag); + return; + } if (!J.runAt(pos, () -> merge(level, blockPos, tag))) { IrisLogging.warn("[NMS] Failed to schedule tile deserialize at " + blockPos + " in world " + pos.getWorld().getName()); } @@ -1201,6 +1212,121 @@ public class NMSBinding implements INMSBinding { retargetStructureCheck(level, irisGenerator); } + @Override + public DatapackStructureScopeResult scopeDatapackStructures( + World world, + DatapackStructureScopeIndex scopeIndex, + Set declaredSources + ) throws NoSuchFieldException, IllegalAccessException { + ServerLevel level = ((CraftWorld) world).getHandle(); + ChunkMap chunkMap = level.getChunkSource().chunkMap; + ChunkGeneratorStructureState currentState = level.getChunkSource().getGeneratorState(); + net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator(); + ChunkGeneratorStructureState scopedState = createStructureState(level, generator, currentState); + DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter( + scopedState.possibleStructureSets(), scopeIndex, declaredSources); + + Field possibleSetsField = getField(scopedState.getClass(), List.class); + possibleSetsField.setAccessible(true); + possibleSetsField.set(scopedState, selection.structureSets()); + + Field stateField = getField(chunkMap.getClass(), ChunkGeneratorStructureState.class); + stateField.setAccessible(true); + BukkitChunkGenerator platformGenerator = world.getGenerator() instanceof BukkitChunkGenerator bukkitGenerator + ? bukkitGenerator + : null; + boolean studioBootstrap = platformGenerator != null + && platformGenerator.isStudioEntryBootstrapActive(); + boolean jigsawStudio = platformGenerator != null + && platformGenerator.isJigsawStudioActive(); + if (jigsawStudio) { + requireIrisGenerator(generator); + if (currentState.possibleStructureSets().isEmpty()) { + currentState.ensureStructuresGenerated(); + } else { + ChunkGeneratorStructureState bootstrapState = createStructureState(level, generator, currentState); + Field bootstrapSetsField = getField(bootstrapState.getClass(), List.class); + bootstrapSetsField.setAccessible(true); + bootstrapSetsField.set(bootstrapState, List.of()); + bootstrapState.ensureStructuresGenerated(); + stateField.set(chunkMap, bootstrapState); + } + } else if (studioBootstrap) { + IrisChunkGenerator irisGenerator = requireIrisGenerator(generator); + irisGenerator.retainStudioStructureState(level, chunkMap, scopedState); + try { + stateField.set(chunkMap, scopedState); + } catch (IllegalAccessException | RuntimeException | Error failure) { + irisGenerator.abandonStudioStructureState(); + throw failure; + } + } else { + initializeAndPublishStructureState( + generator, + scopedState, + () -> stateField.set(chunkMap, scopedState)); + } + return new DatapackStructureScopeResult( + selection.retainedManagedSets(), + selection.excludedManagedSets()); + } + + @Override + public void 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; + } + generator.activateStudioStructureState(retained); + } + + @Override + public void abandonStudioStructureBootstrap(World world) { + ServerLevel level = ((CraftWorld) world).getHandle(); + net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator(); + if (generator instanceof IrisChunkGenerator irisGenerator) { + irisGenerator.abandonStudioStructureState(); + } + } + + private ChunkGeneratorStructureState createStructureState( + ServerLevel level, + net.minecraft.world.level.chunk.ChunkGenerator generator, + ChunkGeneratorStructureState currentState + ) { + return generator.createState( + level.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET), + currentState.randomState(), + currentState.getLevelSeed(), + currentState.conf); + } + + private void initializeAndPublishStructureState( + net.minecraft.world.level.chunk.ChunkGenerator generator, + ChunkGeneratorStructureState structureState, + IrisChunkGenerator.StructureStatePublisher publisher + ) throws IllegalAccessException { + if (generator instanceof IrisChunkGenerator irisGenerator) { + irisGenerator.initializeAndPublishStructureState(structureState, publisher); + return; + } + structureState.ensureStructuresGenerated(); + publisher.publish(); + } + + private IrisChunkGenerator requireIrisGenerator( + net.minecraft.world.level.chunk.ChunkGenerator generator + ) { + if (generator instanceof IrisChunkGenerator irisGenerator) { + return irisGenerator; + } + throw new IllegalStateException("Studio native structure state is not owned by the active Iris generator."); + } + private void validateDimensionContract(Engine engine, World world, ServerLevel level) { DimensionType actualType = level.dimensionType(); String actualTypeKey = level.dimensionTypeRegistration().unwrapKey() 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 265a5da93..6f9a0480c 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 @@ -5,9 +5,21 @@ import org.junit.Test; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; 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 IrisChunkGeneratorFailureContractTest { @@ -20,9 +32,17 @@ public class IrisChunkGeneratorFailureContractTest { int reachabilityStart = source.indexOf("private Set reachableStructureKeys"); int reachabilityEnd = source.indexOf("protected MapCodec", reachabilityStart); String reachability = source.substring(reachabilityStart, reachabilityEnd); + int bootstrapGate = locate.indexOf("!platformGenerator.shouldGenerateStructures()"); + int generatorIdentity = locate.indexOf("level.getChunkSource().getGenerator() != this"); + int lease = locate.indexOf("requireGenerationLease(\"bukkit_nms_structure_locate\")"); + int nativePrediction = locate.indexOf("NativeStructureVanillaLocator.predict("); assertTrue(locate.contains("reached its safety limit")); assertTrue(locate.contains("unregistered structure holder")); + assertTrue(bootstrapGate >= 0); + assertTrue(generatorIdentity > bootstrapGate); + assertTrue(lease > generatorIdentity); + assertTrue(nativePrediction > lease); assertFalse(locate.contains("catch (Throwable")); assertFalse(locate.contains("IrisLogging.reportError")); assertFalse(reachability.contains("catch (Throwable")); @@ -78,13 +98,101 @@ public class IrisChunkGeneratorFailureContractTest { int referencesEnd = source.indexOf("public CompletableFuture createBiomes", referencesStart); String references = source.substring(referencesStart, referencesEnd); - assertTrue(references.contains("requireGenerationLease(\"bukkit_nms_create_references\")")); + int nativeStructureGate = references.indexOf("!platformGenerator.shouldGenerateStructures()"); + int generatorIdentity = references.indexOf("runtimeLevel.getChunkSource().getGenerator() != this"); + int stage = references.indexOf("requireGenerationStage(\"bukkit_nms_create_references\")"); + int lease = references.indexOf("requireGenerationLease(\"bukkit_nms_create_references\")"); + int repair = references.indexOf("NativeStructureReferenceRepair.createReferences("); + + assertTrue(nativeStructureGate >= 0); + assertTrue(generatorIdentity > nativeStructureGate); + assertTrue(stage > generatorIdentity); + assertTrue(lease > stage); + assertTrue(repair > lease); assertTrue(references.contains("IrisContext.open(engine, lease.sessionId(), null)")); - assertTrue(references.contains("NativeStructureReferenceRepair.createReferences(")); assertFalse(references.contains("delegate.createReferences(")); assertFalse(references.contains("catch (")); } + @Test + public void structureGenerationAcquiresBootstrapGateBeforeUsingPublishedState() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int generationStart = source.indexOf("public void createStructures("); + int generationEnd = source.indexOf("private void adjustGeneratedStructures", generationStart); + String generation = source.substring(generationStart, generationEnd); + int gate = generation.indexOf("!platformGenerator.shouldGenerateStructures()"); + int currentGenerator = generation.indexOf( + "runtimeLevel.getChunkSource().getGenerator() != this"); + int stateIdentity = generation.indexOf( + "runtimeLevel.getChunkSource().getGeneratorState() != structureState"); + int stage = generation.indexOf( + "requireGenerationStage(\"bukkit_nms_create_structures\")"); + int lease = generation.indexOf( + "requireGenerationLease(\"bukkit_nms_create_structures\")"); + int generationCall = generation.indexOf( + "super.createStructures(registryAccess, structureState"); + + assertTrue(gate >= 0); + assertTrue(currentGenerator > gate); + assertTrue(stateIdentity > currentGenerator); + assertTrue(stage > stateIdentity); + assertTrue(lease > stage); + assertTrue(generationCall > lease); + assertTrue(generation.contains("structureState,\n structureManager")); + assertFalse(generation.contains("resolvePublishedStructureState")); + } + + @Test + public void nativeRingBootstrapTracksTheExactMinecraftFutures() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int initializeStart = source.indexOf("private CompletableFuture initializeStructureState("); + int initializeEnd = source.indexOf("private Map structureRingPositions", initializeStart); + String initialize = source.substring(initializeStart, initializeEnd); + int completionStart = source.indexOf( + "private CompletableFuture structureRingCompletion", initializeEnd); + int completionEnd = source.indexOf("private void requireCurrentStructureOwner", completionStart); + String completion = source.substring(completionStart, completionEnd); + + assertBefore(initialize, + "structureRingPositions(structureState)", + "structureState.ensureStructuresGenerated()"); + assertBefore(initialize, + "structureState.ensureStructuresGenerated()", + "structureRingCompletion(ringPositions)"); + assertFalse(initialize.contains("catch (")); + assertTrue(completion.contains("for (Object candidate : ringPositions.values())")); + assertTrue(completion.contains("CompletableFuture.allOf(completions)")); + assertTrue(completion.contains("throw new IllegalStateException(")); + assertFalse(completion.contains("join()")); + assertFalse(completion.contains("get()")); + } + + @Test + public void standardActivationClaimsTheExactPublishedStateBeforeInitialization() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int activationStart = source.indexOf("CompletableFuture activateStudioStructureState("); + int activationEnd = source.indexOf( + "private CompletableFuture startStructureStateBootstrap", activationStart); + String activation = source.substring(activationStart, activationEnd); + int bootstrapStart = activationEnd; + int bootstrapEnd = source.indexOf( + "private CompletableFuture initializeStructureState", bootstrapStart); + String bootstrap = source.substring(bootstrapStart, bootstrapEnd); + + assertTrue(activation.contains("retained.structureState()")); + assertBefore(activation, + "retainedStudioStructureState(", + "claimStudioStructureState(retained)"); + assertBefore(bootstrap, + "claim,", + "initializeStructureState("); + assertBefore(bootstrap, + "initializeStructureState(", + "activation.run()"); + assertFalse(activation.contains("publishStructureState(")); + assertFalse(source.contains("ChunkGeneratorStructureState fullState")); + } + @Test public void terrainWritesPrimeTheWorldgenHeightmapsForEveryChunk() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); @@ -107,10 +215,171 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(placement.contains("WorldgenTerrainHeightmaps.primeStructurePlacement(")); assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(") < placement.indexOf("prepareSurfaceStructures")); - assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_worldgen_heightmaps\")")); + assertTrue(fill.contains("requireGenerationLease(\"bukkit_nms_chunk_pipeline\")")); assertTrue(source.contains("int minY = chunk.getMinY() + 1;")); } + @Test + public void fillFromNoiseLeaseSpansTheDelegateAndHeightmapPipeline() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + 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); + assertTrue(completionStart >= 0); + assertTrue(completionEnd > completionStart); + String completion = fill.substring(completionStart, completionEnd); + + assertBefore(fill, + "BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage(", + "GenerationSessionLease lease"); + assertBefore(fill, + "requireNoiseGenerationStage(", + "\"bukkit_nms_chunk_pipeline\")"); + assertBefore(fill, + "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<>()")); + assertBefore(completion, "boolean cancelled = isCancellationFailure(failure);", "lease.close();"); + assertBefore(completion, "lease.close();", "stage.close();"); + assertBefore(completion, "stage.close();", "completion.complete(filled)"); + assertBefore(completion, "stage.close();", "completion.cancel(false)"); + assertBefore(completion, "stage.close();", "completion.completeExceptionally(failure)"); + 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")); + } + + @Test + public void releasedNoiseAdmissionLetsSynchronousDependentWaitForQueuedExclusive() throws Exception { + Semaphore admission = new Semaphore(1, true); + admission.acquire(); + CountDownLatch exclusiveEntered = new CountDownLatch(1); + CountDownLatch releaseExclusive = new CountDownLatch(1); + AtomicBoolean dependentFinished = new AtomicBoolean(false); + ExecutorService executor = Executors.newSingleThreadExecutor(); + boolean stageReleased = false; + + try { + Future exclusive = executor.submit(() -> { + boolean acquired = false; + try { + admission.acquire(); + acquired = true; + exclusiveEntered.countDown(); + assertTrue(releaseExclusive.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } finally { + if (acquired) { + admission.release(); + } + } + }); + awaitQueueLength(admission, 1); + CompletableFuture outward = new CompletableFuture<>(); + outward.thenRun(() -> { + try { + assertTrue(exclusiveEntered.await(2, TimeUnit.SECONDS)); + dependentFinished.set(true); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + }); + + admission.release(); + stageReleased = true; + assertTrue(outward.complete(null)); + assertTrue(dependentFinished.get()); + releaseExclusive.countDown(); + exclusive.get(2, TimeUnit.SECONDS); + assertEquals(1, admission.availablePermits()); + } finally { + if (!stageReleased) { + admission.release(); + } + releaseExclusive.countDown(); + executor.shutdownNow(); + } + } + + @Test + public void transformedDelegateCancellationIsDetectedThroughItsCompletionFailure() throws IOException { + CompletableFuture delegate = new CompletableFuture<>(); + CompletableFuture transformed = delegate.thenApply(value -> value); + + assertTrue(delegate.cancel(false)); + assertFalse(transformed.isCancelled()); + CompletionException failure = assertThrows(CompletionException.class, transformed::join); + assertTrue(failure.getCause() instanceof CancellationException); + + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + String cancellation = method( + source, + "private static boolean isCancellationFailure", + "private void primeWorldgenHeightmaps"); + assertTrue(cancellation.contains("current instanceof CompletionException")); + assertTrue(cancellation.contains("current instanceof CancellationException")); + } + + @Test + public void noiseAdmissionCanPrepareStudioBeforeAcquiringTheGenerationLease() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + String fill = method( + source, + "public CompletableFuture fillFromNoise", + "private static boolean isCancellationFailure"); + String admission = method( + source, + "private BukkitChunkGenerator.GenerationStagePermit requireNoiseGenerationStage", + "public Optional getTypeNameForDataFixer"); + + assertBefore(fill, + "requireNoiseGenerationStage(", + "requireGenerationLease(\"bukkit_nms_chunk_pipeline\")"); + assertTrue(admission.contains("platformGenerator.acquireNoiseGenerationStage(")); + assertBefore(admission, "engine,", "chunkPos.x(),"); + assertBefore(admission, "chunkPos.x(),", "chunkPos.z(),"); + assertFalse(fill.contains("requireGenerationStage(\"bukkit_nms_chunk_pipeline\")")); + } + + @Test + public void everyTopLevelMoonriseStageEntersTheFairGateBeforeItsGenerationLease() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + String structures = method(source, "public void createStructures(", "private void adjustGeneratedStructures"); + String references = method(source, "public void createReferences(", "public CompletableFuture createBiomes"); + String biomes = method(source, "public CompletableFuture createBiomes", "public void buildSurface"); + 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"); + + assertStageBeforeLease(structures, "bukkit_nms_create_structures"); + assertStageBeforeLease(references, "bukkit_nms_create_references"); + assertStageBeforeLease(biomes, "bukkit_nms_create_biomes"); + assertBefore(noise, + "requireNoiseGenerationStage(", + "requireGenerationLease(\"bukkit_nms_chunk_pipeline\")"); + assertStageBeforeLease(decoration, "bukkit_nms_biome_decoration"); + assertBefore(decoration, + "requireGenerationLease(\"bukkit_nms_biome_decoration\")", + "importedFeatures.prepare(generatoraccessseed)"); + assertEquals(4, occurrences(source, "requireGenerationStage(\"bukkit_nms_")); + assertEquals(2, occurrences(source, "requireNoiseGenerationStage(")); + assertFalse(source.contains("GenerationSessionManager")); + } + @Test public void worldgenHeightmapPrimingLivesInTheSharedNativegenSources() throws IOException { Path nativegen = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")).getParent(); @@ -174,6 +443,37 @@ public class IrisChunkGeneratorFailureContractTest { return count; } + private static void awaitQueueLength(Semaphore semaphore, int expected) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (semaphore.getQueueLength() < expected && System.nanoTime() < deadline) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + assertTrue("Expected at least " + expected + " queued semaphore threads", + semaphore.getQueueLength() >= expected); + } + + private static String method(String source, String startToken, String endToken) { + int start = source.indexOf(startToken); + int end = source.indexOf(endToken, start); + assertTrue("Missing source contract token: " + startToken, start >= 0); + assertTrue("Missing source contract token: " + endToken, end > start); + return source.substring(start, end); + } + + private static void assertStageBeforeLease(String source, String operation) { + assertBefore(source, + "requireGenerationStage(\"" + operation + "\")", + "requireGenerationLease(\"" + operation + "\")"); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } + @Test public void vanillaChunkGenerationMobsUseTheVisibleBiomesVanillaDerivative() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); 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 new file mode 100644 index 000000000..66df3bc28 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java @@ -0,0 +1,334 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.core.datapack.DatapackIngestService; +import art.arcane.iris.core.datapack.DatapackStructureScopeIndex; +import com.mojang.datafixers.util.Either; +import net.minecraft.SharedConstants; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderOwner; +import net.minecraft.core.Vec3i; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.tags.TagKey; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; +import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; +import org.junit.Test; +import org.junit.BeforeClass; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class NMSBindingDatapackStructureScopeTest { + private static final String SOURCE = "https://example.test/managed.zip"; + + @BeforeClass + public static void bootstrapMinecraft() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void managedSetContainingVanillaStructureIsAbsentOutsideDeclaringDimension() { + Holder vanillaStructure = structureHolder("minecraft:pillager_outpost"); + Holder managedSet = structureSetHolder( + "managed:illager_barracks", vanillaStructure); + DatapackStructureScopeIndex index = index( + List.of(), + List.of("managed:illager_barracks")); + + DatapackStructureStateFilter.Selection vanilla = DatapackStructureStateFilter.filter( + List.of(managedSet), index, Set.of()); + DatapackStructureStateFilter.Selection declaring = DatapackStructureStateFilter.filter( + List.of(managedSet), index, index.declaredSources(List.of(SOURCE))); + + assertEquals(0, vanilla.structureSets().size()); + assertEquals(1, vanilla.excludedManagedSets()); + assertEquals(1, declaring.structureSets().size()); + assertSame(managedSet, declaring.structureSets().getFirst()); + } + + @Test + public void unmanagedSetRetainsOnlyDefinitionsAllowedInTheWorld() { + Holder vanillaStructure = structureHolder("minecraft:village_plains"); + Holder managedStructure = structureHolder("managed:tavern"); + Holder vanillaSet = structureSetHolder( + "minecraft:villages", vanillaStructure, managedStructure); + DatapackStructureScopeIndex index = index( + List.of("managed:tavern"), + List.of()); + + DatapackStructureStateFilter.Selection vanilla = DatapackStructureStateFilter.filter( + List.of(vanillaSet), index, Set.of()); + DatapackStructureStateFilter.Selection declaring = DatapackStructureStateFilter.filter( + List.of(vanillaSet), index, index.declaredSources(List.of(SOURCE))); + + assertEquals(1, vanilla.structureSets().size()); + assertEquals(1, vanilla.structureSets().getFirst().value().structures().size()); + assertSame(vanillaStructure, + vanilla.structureSets().getFirst().value().structures().getFirst().structure()); + assertSame(vanillaSet.value().placement(), + vanilla.structureSets().getFirst().value().placement()); + assertSame(vanillaSet, declaring.structureSets().getFirst()); + } + + @Test + public void setWithNoAllowedDefinitionsIsRemoved() { + Holder unmanagedSet = structureSetHolder( + "minecraft:custom", structureHolder("managed:only")); + DatapackStructureScopeIndex index = index(List.of("managed:only"), List.of()); + + DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter( + List.of(unmanagedSet), index, Set.of()); + + assertEquals(0, selection.structureSets().size()); + } + + @Test + public void spigotDirectHolderRetainsItsStructureSetKey() { + ResourceKey key = ResourceKey.create( + Registries.STRUCTURE_SET, + Identifier.parse("minecraft:villages")); + ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement placement = + new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement( + key, + Vec3i.ZERO, + StructurePlacement.FrequencyReductionMethod.DEFAULT, + 1.0F, + 10387312, + Optional.empty(), + 34, + 8, + RandomSpreadType.LINEAR); + StructureSet structureSet = new StructureSet( + List.of(new StructureSet.StructureSelectionEntry( + structureHolder("minecraft:village_plains"), 1)), + placement); + + assertEquals("minecraft:villages", + DatapackStructureStateFilter.structureSetKey(Holder.direct(structureSet))); + } + + @Test + public void excludedManagedSetCannotSuppressAnAllowedSetThroughExclusionZone() { + Holder managedSet = structureSetHolder( + "managed:blocked", + structureHolder("managed:blocked")); + RandomSpreadStructurePlacement originalPlacement = new RandomSpreadStructurePlacement( + Vec3i.ZERO, + StructurePlacement.FrequencyReductionMethod.DEFAULT, + 1.0F, + 4567, + Optional.of(new StructurePlacement.ExclusionZone(managedSet, 1)), + 32, + 8, + RandomSpreadType.LINEAR); + Holder vanillaSet = structureSetHolder( + "minecraft:allowed", + originalPlacement, + structureHolder("minecraft:village_plains")); + DatapackStructureScopeIndex index = index( + List.of("managed:blocked"), + List.of("managed:blocked")); + + DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter( + List.of(vanillaSet, managedSet), index, Set.of()); + + assertEquals(1, selection.structureSets().size()); + StructurePlacement scopedPlacement = selection.structureSets().getFirst().value().placement(); + assertEquals(0, DatapackStructureStateFilter.exclusionZone(scopedPlacement).stream().count()); + } + + @Test + public void standardPublishesOneDeferredStateWhileJigsawPublishesInitializedEmptyState() throws IOException { + Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); + String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")); + int methodStart = source.indexOf("public DatapackStructureScopeResult scopeDatapackStructures("); + int methodEnd = source.indexOf("\n @Override\n public void completeStudioStructureBootstrap", methodStart); + + assertTrue(methodStart >= 0); + assertTrue(methodEnd > methodStart); + String method = source.substring(methodStart, methodEnd); + int filteredState = method.indexOf("possibleSetsField.set(scopedState, selection.structureSets());"); + int jigsawMode = method.indexOf( + "boolean jigsawStudio = platformGenerator != null"); + int jigsawOnly = method.indexOf("if (jigsawStudio)"); + int emptyCreation = method.indexOf("ChunkGeneratorStructureState bootstrapState = createStructureState("); + int emptyFiltering = method.indexOf("bootstrapSetsField.set(bootstrapState, List.of());"); + int emptyInitialization = method.indexOf("bootstrapState.ensureStructuresGenerated();"); + int emptyPublication = method.indexOf("stateField.set(chunkMap, bootstrapState);"); + int standardOnly = method.indexOf("else if (studioBootstrap)"); + int retention = method.indexOf("irisGenerator.retainStudioStructureState("); + int standardPublication = method.indexOf("stateField.set(chunkMap, scopedState);"); + int immediateInitialization = method.indexOf("initializeAndPublishStructureState("); + + assertTrue(filteredState >= 0); + assertTrue(jigsawMode > filteredState); + assertTrue(jigsawOnly > jigsawMode); + assertTrue(emptyCreation > jigsawOnly); + assertTrue(emptyFiltering > emptyCreation); + assertTrue(emptyInitialization > emptyFiltering); + assertTrue(emptyPublication > emptyInitialization); + assertTrue(standardOnly > emptyPublication); + assertTrue(retention > standardOnly); + assertTrue(standardPublication > retention); + assertTrue(immediateInitialization > standardPublication); + assertFalse(method.contains("scopedState.ensureStructuresGenerated();")); + assertFalse(method.contains("if (studioBootstrap && platformGenerator.isJigsawStudioActive())")); + } + + @Test + public void standardCompletionActivatesTheAlreadyPublishedStateWithoutReplacingIt() throws IOException { + Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); + String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")); + int methodStart = source.indexOf("public void 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);"); + + assertTrue(retained >= 0); + assertTrue(activation > retained); + assertFalse(method.contains("stateField.set(")); + assertFalse(method.contains("retained.fullState()")); + } + + private static DatapackStructureScopeIndex index( + List structureKeys, + List structureSetKeys + ) { + return DatapackStructureScopeIndex.create(List.of( + new DatapackIngestService.StructureScopeResources( + SOURCE, + structureKeys, + structureSetKeys))); + } + + private static Holder structureHolder(String key) { + return new KeyedHolder<>(ResourceKey.create(Registries.STRUCTURE, Identifier.parse(key)), null); + } + + private static Holder structureSetHolder( + String key, + Holder... structures + ) { + return structureSetHolder( + key, + new RandomSpreadStructurePlacement(32, 8, RandomSpreadType.LINEAR, 12345), + structures); + } + + private static Holder structureSetHolder( + String key, + StructurePlacement placement, + Holder... structures + ) { + List entries = Stream.of(structures) + .map(structure -> new StructureSet.StructureSelectionEntry(structure, 1)) + .toList(); + StructureSet value = new StructureSet(entries, placement); + return new KeyedHolder<>( + ResourceKey.create(Registries.STRUCTURE_SET, Identifier.parse(key)), + value); + } + + private static final class KeyedHolder implements Holder { + private final ResourceKey key; + private final T value; + + private KeyedHolder(ResourceKey key, T value) { + this.key = key; + this.value = value; + } + + @Override + public T value() { + return value; + } + + @Override + public boolean isBound() { + return true; + } + + @Override + public boolean areComponentsBound() { + return true; + } + + @Override + public boolean is(Identifier identifier) { + return key.identifier().equals(identifier); + } + + @Override + public boolean is(ResourceKey candidate) { + return key.equals(candidate); + } + + @Override + public boolean is(Predicate> predicate) { + return predicate.test(key); + } + + @Override + public boolean is(TagKey tag) { + return false; + } + + @Override + public boolean is(Holder holder) { + return holder == this; + } + + @Override + public Stream> tags() { + return Stream.empty(); + } + + @Override + public DataComponentMap components() { + return DataComponentMap.EMPTY; + } + + @Override + public Either, T> unwrap() { + return Either.left(key); + } + + @Override + public Optional> unwrapKey() { + return Optional.of(key); + } + + @Override + public Kind kind() { + return Kind.REFERENCE; + } + + @Override + public boolean canSerializeIn(HolderOwner owner) { + return true; + } + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingTileDeserializeContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingTileDeserializeContractTest.java new file mode 100644 index 000000000..5ca0418e6 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingTileDeserializeContractTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class NMSBindingTileDeserializeContractTest { + @Test + public void ownedRegionTileDataMergesBeforeScheduledFallback() throws IOException { + Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); + String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")); + int methodStart = source.indexOf("public void deserializeTile("); + int methodEnd = source.indexOf("\n private void merge(", methodStart); + String method = source.substring(methodStart, methodEnd); + + int ownershipCheck = method.indexOf("J.isOwnedByCurrentRegion("); + int synchronousMerge = method.indexOf("merge(level, blockPos, tag);", ownershipCheck); + int synchronousReturn = method.indexOf("return;", synchronousMerge); + int scheduledFallback = method.indexOf("J.runAt(pos, () -> merge(level, blockPos, tag))"); + + assertTrue(ownershipCheck >= 0); + assertTrue(synchronousMerge > ownershipCheck); + assertTrue(synchronousReturn > synchronousMerge); + assertTrue(scheduledFallback > synchronousReturn); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/structure/VillageImporterListPoolElementRuntimeContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/structure/VillageImporterListPoolElementRuntimeContractTest.java new file mode 100644 index 000000000..3c472a150 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/structure/VillageImporterListPoolElementRuntimeContractTest.java @@ -0,0 +1,294 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure; + +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureLoss; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class VillageImporterListPoolElementRuntimeContractTest { + @BeforeClass + public static void bootstrapMinecraft() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void pillagerOutpostTowerCompositeProducesOnePhysicalTowerChoice() throws Exception { + StructurePoolElement tower = StructurePoolElement.legacy("minecraft:pillager_outpost/watchtower") + .apply(StructureTemplatePool.Projection.RIGID); + StructurePoolElement overgrown = StructurePoolElement.legacy("minecraft:pillager_outpost/watchtower_overgrown") + .apply(StructureTemplatePool.Projection.RIGID); + ListPoolElement towers = new ListPoolElement( + List.of(tower, overgrown), + StructureTemplatePool.Projection.RIGID + ); + + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(towers); + Map entry = VillageImporter.piecePoolEntry( + "qa/pillager_outpost/piece/minecraft/pillager_outpost/watchtower", + 1 + ); + StructureLoss loss = VillageImporter.listElementFallbackLoss( + resolution, + "minecraft:pillager_outpost/towers" + ); + Map basePlatePiece = VillageImporter.pieceJson( + "qa/pillager_outpost/piece/minecraft/pillager_outpost/base_plate", + List.of(Map.of("name", "minecraft:bottom")), + 0 + ); + Map watchtowerPiece = VillageImporter.pieceJson( + "qa/pillager_outpost/piece/minecraft/pillager_outpost/watchtower", + List.of(), + 1155 + ); + VillageImporter.PoolMemberNormalization connectorScaffold = VillageImporter.normalizePoolMember( + "minecraft:pillager_outpost/features", + "qa/pillager_outpost/pool/minecraft/pillager_outpost/features", + false, + 2, + "minecraft:empty", + "minecraft:pillager_outpost/feature_plate", + "qa/pillager_outpost/piece/minecraft/pillager_outpost/feature_plate", + 1, + 0, + List.of(Map.of("name", "minecraft:bottom"))); + + assertSame(tower, resolution.physicalElement()); + assertEquals("minecraft:pillager_outpost/watchtower", resolution.templateLocation()); + assertEquals(1, resolution.omittedElements()); + assertEquals("qa/pillager_outpost/piece/minecraft/pillager_outpost/watchtower", entry.get("piece")); + assertEquals(1, entry.get("weight")); + assertEquals(StructureCapability.LIST_ELEMENTS, loss.capability()); + assertEquals("list_pool_overlays_not_imported", loss.code()); + assertEquals(false, basePlatePiece.get("collidable")); + assertFalse(watchtowerPiece.containsKey("collidable")); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, connectorScaffold.disposition()); + assertTrue(connectorScaffold.losses().isEmpty()); + } + + @Test + public void actualWaystoneLikeAllAirMemberNormalizesToEmpty() throws Exception { + StructurePoolElement waystone = StructurePoolElement + .legacy("dungeons_and_taverns:waystones/waystone_scaffold") + .apply(StructureTemplatePool.Projection.RIGID); + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(waystone); + VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember( + "dungeons_and_taverns:waystones", + "qa/pool/dungeons_and_taverns/waystones", + false, + 1, + "dungeons_and_taverns:waystones", + resolution.templateLocation(), + "qa/piece/dungeons_and_taverns/waystones/waystone_scaffold", + 2, + 0, + List.of()); + + assertSame(waystone, resolution.physicalElement()); + assertEquals("dungeons_and_taverns:waystones/waystone_scaffold", resolution.templateLocation()); + assertEquals(VillageImporter.PoolMemberDisposition.EMPTY, normalization.disposition()); + assertEquals(true, normalization.poolEntry().get("empty")); + assertEquals(2, normalization.poolEntry().get("weight")); + assertEquals("connectorless_all_air_member_normalized_empty", + normalization.losses().getFirst().code()); + } + + @Test + public void actualNonAirOrphanMemberIsOmitted() throws Exception { + StructurePoolElement orphan = StructurePoolElement + .legacy("test:orphan_house") + .apply(StructureTemplatePool.Projection.RIGID); + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(orphan); + VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember( + "test:orphan_pool", + "qa/pool/test/orphan_pool", + false, + 1, + null, + resolution.templateLocation(), + "qa/piece/test/orphan_house", + 4, + 37, + List.of()); + + assertSame(orphan, resolution.physicalElement()); + assertEquals("test:orphan_house", resolution.templateLocation()); + assertEquals(VillageImporter.PoolMemberDisposition.OMITTED, normalization.disposition()); + assertTrue(normalization.poolEntry().isEmpty()); + assertEquals("connectorless_non_air_member_omitted", + normalization.losses().getFirst().code()); + assertTrue(normalization.losses().getFirst().detail().contains("37 non-air block(s)")); + assertTrue(normalization.losses().getFirst().detail().contains("no source fallback")); + assertTrue(normalization.losses().getFirst().detail().contains("selection weights")); + assertTrue(normalization.losses().getFirst().detail().contains("RNG consumption")); + } + + @Test + public void actualConnectorlessStartMemberRemainsPhysical() throws Exception { + StructurePoolElement start = StructurePoolElement + .legacy("test:air_start") + .apply(StructureTemplatePool.Projection.RIGID); + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(start); + VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember( + "test:start", + "qa/pool/test/start", + true, + 2, + "test:fallback", + resolution.templateLocation(), + "qa/piece/test/air_start", + 6, + 0, + List.of()); + + assertSame(start, resolution.physicalElement()); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, normalization.disposition()); + assertEquals("qa/piece/test/air_start", normalization.poolEntry().get("piece")); + assertTrue(normalization.losses().isEmpty()); + } + + @Test + public void actualMixedPoolAllAirMemberIsOmittedWithoutBecomingEmpty() throws Exception { + StructurePoolElement inert = StructurePoolElement + .legacy("test:mixed_air_inert") + .apply(StructureTemplatePool.Projection.RIGID); + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(inert); + VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember( + "test:mixed", + "qa/pool/test/mixed", + false, + 4, + "test:mixed", + resolution.templateLocation(), + "qa/piece/test/mixed_air_inert", + 9, + 0, + List.of()); + + assertSame(inert, resolution.physicalElement()); + assertEquals(VillageImporter.PoolMemberDisposition.OMITTED, normalization.disposition()); + assertTrue(normalization.poolEntry().isEmpty()); + assertEquals("connectorless_all_air_mixed_member_omitted", + normalization.losses().getFirst().code()); + assertTrue(normalization.losses().getFirst().detail().contains("selection weights")); + assertTrue(normalization.losses().getFirst().detail().contains("RNG consumption")); + } + + @Test + public void actualMixedDistinctFallbackRetainsAllConnectorlessMembers() throws Exception { + StructurePoolElement allAirElement = StructurePoolElement + .legacy("test:mixed_all_air_primary") + .apply(StructureTemplatePool.Projection.RIGID); + StructurePoolElement nonAirElement = StructurePoolElement + .legacy("test:mixed_non_air_primary") + .apply(StructureTemplatePool.Projection.RIGID); + VillageImporter.PoolElementResolution allAirResolution = VillageImporter.resolvePoolElement(allAirElement); + VillageImporter.PoolElementResolution nonAirResolution = VillageImporter.resolvePoolElement(nonAirElement); + VillageImporter.PoolMemberNormalization allAir = VillageImporter.normalizePoolMember( + "test:mixed", + "qa/pool/test/mixed", + false, + 4, + "test:fallback", + allAirResolution.templateLocation(), + "qa/piece/test/mixed_all_air_primary", + 9, + 0, + List.of()); + VillageImporter.PoolMemberNormalization nonAir = VillageImporter.normalizePoolMember( + "test:mixed", + "qa/pool/test/mixed", + false, + 4, + "test:fallback", + nonAirResolution.templateLocation(), + "qa/piece/test/mixed_non_air_primary", + 12, + 41, + List.of()); + + assertSame(allAirElement, allAirResolution.physicalElement()); + assertSame(nonAirElement, nonAirResolution.physicalElement()); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, allAir.disposition()); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, nonAir.disposition()); + assertEquals("qa/piece/test/mixed_all_air_primary", allAir.poolEntry().get("piece")); + assertEquals("qa/piece/test/mixed_non_air_primary", nonAir.poolEntry().get("piece")); + assertTrue(allAir.losses().isEmpty()); + assertTrue(nonAir.losses().isEmpty()); + } + + @Test + public void actualSingletonDistinctFallbackRetainsAllAirAndNonAirMembers() throws Exception { + StructurePoolElement allAirElement = StructurePoolElement + .legacy("test:all_air_primary") + .apply(StructureTemplatePool.Projection.RIGID); + StructurePoolElement nonAirElement = StructurePoolElement + .legacy("test:non_air_primary") + .apply(StructureTemplatePool.Projection.RIGID); + VillageImporter.PoolElementResolution allAirResolution = VillageImporter.resolvePoolElement(allAirElement); + VillageImporter.PoolElementResolution nonAirResolution = VillageImporter.resolvePoolElement(nonAirElement); + VillageImporter.PoolMemberNormalization allAir = VillageImporter.normalizePoolMember( + "test:primary", + "qa/pool/test/primary", + false, + 1, + "test:fallback", + allAirResolution.templateLocation(), + "qa/piece/test/all_air_primary", + 5, + 0, + List.of()); + VillageImporter.PoolMemberNormalization nonAir = VillageImporter.normalizePoolMember( + "test:primary", + "qa/pool/test/primary", + false, + 1, + "test:fallback", + nonAirResolution.templateLocation(), + "qa/piece/test/non_air_primary", + 7, + 29, + List.of()); + + assertSame(allAirElement, allAirResolution.physicalElement()); + assertSame(nonAirElement, nonAirResolution.physicalElement()); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, allAir.disposition()); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, nonAir.disposition()); + assertEquals("qa/piece/test/all_air_primary", allAir.poolEntry().get("piece")); + assertEquals("qa/piece/test/non_air_primary", nonAir.poolEntry().get("piece")); + assertTrue(allAir.losses().isEmpty()); + assertTrue(nonAir.losses().isEmpty()); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java index faa1f70f7..0361bf3db 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java @@ -37,14 +37,11 @@ import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.DirectoryStream; -import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; -import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; @@ -341,40 +338,31 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue { LinkedHashMap remaining ) { try { - QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName()); - List targets = entry.targets(levelRoot); - if (targets.stream().anyMatch(PendingWorldDeleteQueue::isLoaded)) { + EntryDeletionResult result = attemptEntry( + levelRoot, + worldName, + PendingWorldDeleteQueue::isLoaded + ); + if (result.loaded()) { Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded."); remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); return; } - boolean foundAny = false; - boolean deletedAll = true; - for (DeleteTarget target : targets) { - Path worldFolder = target.path(); - if (!Files.exists(worldFolder, LinkOption.NOFOLLOW_LINKS)) { - continue; - } - if (Files.isSymbolicLink(worldFolder) || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("Queued world target is not a safe directory: " + worldFolder); - } - - foundAny = true; - try { - deleteTree(worldFolder); - Iris.info("Deleted queued world folder \"" + worldFolder.getFileName() + "\"."); - } catch (IOException failure) { - deletedAll = false; - Iris.reportError("Failed to delete queued world folder \"" + worldFolder + "\".", failure); - } + for (Path deleted : result.deleted()) { + Iris.info("Deleted queued world folder \"" + deleted.getFileName() + "\"."); } - - if (!foundAny) { + for (DeletionFailure deletionFailure : result.failures()) { + Iris.reportError( + "Failed to delete queued world folder \"" + deletionFailure.path() + "\".", + deletionFailure.failure() + ); + } + if (!result.foundAny()) { Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing)."); return; } - if (!deletedAll) { + if (result.retainQueueEntry()) { remaining.put(worldName.toLowerCase(Locale.ROOT), worldName); } } catch (Throwable failure) { @@ -383,12 +371,51 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue { } } - private static boolean isLoaded(DeleteTarget target) { - if (target.key() != null && WorldIdentity.resolve(target.key()).isPresent()) { + static EntryDeletionResult attemptEntry( + File levelRoot, + String worldName, + LoadedTargetCheck loadedTargetCheck + ) throws IOException { + QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName()); + List targets = entry.targets(levelRoot); + if (targets.stream().anyMatch(target -> loadedTargetCheck.isLoaded(target.key(), target.path()))) { + return new EntryDeletionResult(true, false, List.of(), List.of()); + } + + boolean foundAny = false; + ArrayList deleted = new ArrayList<>(targets.size()); + ArrayList failures = new ArrayList<>(targets.size()); + for (DeleteTarget target : targets) { + Path worldFolder = target.path(); + if (!Files.exists(worldFolder, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + if (Files.isSymbolicLink(worldFolder) || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Queued world target is not a safe directory: " + worldFolder); + } + + foundAny = true; + try { + SnapshotDirectoryTreeDeleter.delete(worldFolder); + deleted.add(worldFolder); + } catch (IOException failure) { + failures.add(new DeletionFailure(worldFolder, failure)); + } + } + return new EntryDeletionResult( + false, + foundAny, + List.copyOf(deleted), + List.copyOf(failures) + ); + } + + private static boolean isLoaded(@Nullable NamespacedKey key, Path path) { + if (key != null && WorldIdentity.resolve(key).isPresent()) { return true; } - Path targetPath = target.path().toAbsolutePath().normalize(); + Path targetPath = path.toAbsolutePath().normalize(); for (World world : Bukkit.getWorlds()) { if (world.getWorldFolder().toPath().toAbsolutePath().normalize().equals(targetPath)) { return true; @@ -397,25 +424,6 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue { return false; } - private static void deleteTree(Path target) throws IOException { - Files.walkFileTree(target, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - Files.delete(file); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException { - if (failure != null) { - throw failure; - } - Files.delete(directory); - return FileVisitResult.CONTINUE; - } - }); - } - private enum QueueEntryType { EXACT, LOGICAL, @@ -479,4 +487,23 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue { private record DeleteTarget(@Nullable NamespacedKey key, Path path) { } + + @FunctionalInterface + interface LoadedTargetCheck { + boolean isLoaded(@Nullable NamespacedKey key, Path path); + } + + record EntryDeletionResult( + boolean loaded, + boolean foundAny, + List deleted, + List failures + ) { + boolean retainQueueEntry() { + return loaded || !failures.isEmpty(); + } + } + + record DeletionFailure(Path path, IOException failure) { + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index 7fcab57b5..a0c1339ad 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -98,6 +98,7 @@ public class CommandIris implements DirectorExecutor { private CommandPregen pregen; private CommandObject object; private CommandStructure structure; + private CommandJigsaw jigsaw; private CommandWhat what; private CommandEdit edit; private CommandDeveloper developer; diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java new file mode 100644 index 000000000..694daba0d --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java @@ -0,0 +1,2187 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.Iris; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.runtime.WorldRuntimeControlService; +import art.arcane.iris.core.runtime.StudioOpenCoordinator; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphMapper; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioProjectCreator; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioStructureEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.core.service.JigsawStudioGraphEvaluation; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.core.service.StudioSVC; +import art.arcane.iris.core.structure.StructureImporter; +import art.arcane.iris.core.structure.VillageImporter; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionDiagnostic; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionInputKind; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionPlan; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionRequest; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionResult; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionService; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionStrategy; +import art.arcane.iris.core.structure.export.VanillaJigsawDatapackExporter; +import art.arcane.iris.core.structure.export.VanillaJigsawExportDiagnostic; +import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat; +import art.arcane.iris.core.structure.export.VanillaJigsawExportRequest; +import art.arcane.iris.core.structure.export.VanillaJigsawExportResult; +import art.arcane.iris.core.structure.export.VanillaJigsawExportSource; +import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisJigsawCompatibility; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.iris.util.common.format.C; +import art.arcane.iris.util.common.plugin.VolmitSender; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.DirectorParameterHandler; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.type.Jigsaw; +import org.bukkit.entity.Player; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; + +@Director(name = "jigsaw", aliases = {"jig", "jgs"}, description = "Iris Jigsaw Studio tools", + origin = DirectorOrigin.PLAYER) +public class CommandJigsaw implements DirectorExecutor { + private static final long DEFAULT_STUDIO_SEED = 1337L; + static final StudioOpenCoordinator.StudioOpenKind STUDIO_OPEN_KIND = + StudioOpenCoordinator.StudioOpenKind.JIGSAW; + private static final Pattern EXPORT_OUTPUT_NAME = Pattern.compile("[a-zA-Z0-9_-][a-zA-Z0-9._-]{0,127}"); + private static final Map PLAYER_PACKS = new ConcurrentHashMap<>(); + private static final Map ADOPTION_PLANS = new ConcurrentHashMap<>(); + private static final Map ADOPTION_SERVICES = new ConcurrentHashMap<>(); + private static final Set EXPORTING_PLAYERS = ConcurrentHashMap.newKeySet(); + private static final Set EXPORT_DESTINATIONS = ConcurrentHashMap.newKeySet(); + + private CommandJigsawPiece piece; + private CommandJigsawPool pool; + private CommandJigsawConnector connector; + private CommandJigsawVariant variant; + private CommandJigsawWorkcell workcell; + private CommandJigsawRules rules; + private CommandJigsawPreview preview; + private CommandJigsawAdopt adopt; + + @Director(description = "Create and open a new atomic Jigsaw Studio project", sync = true, + origin = DirectorOrigin.PLAYER) + public void create( + @Param(description = "Pack dimension") IrisDimension dimension, + @Param(name = "key", aliases = {"structure", "name"}, + description = "New key written as structures/.json") String structure, + @Param(description = "planar or spatial", defaultValue = "planar", + customHandler = JigsawModeHandler.class) String mode, + @Param(description = "iris or vanilla", defaultValue = "iris", + customHandler = JigsawCompatibilityHandler.class) String compatibility, + @Param(description = "Cell width", defaultValue = "16") int width, + @Param(description = "Cell height", defaultValue = "16") int height, + @Param(description = "Cell depth", defaultValue = "16") int depth, + @Param(description = "Studio world seed", defaultValue = "1337") long seed + ) { + IrisData data = requireData(dimension); + if (data == null) { + return; + } + JigsawStudioMode studioMode; + JigsawStudioCompatibilityTarget target; + JigsawStudioCellDimensions dimensions; + try { + studioMode = parseMode(mode); + target = parseCompatibility(compatibility); + dimensions = new JigsawStudioCellDimensions(width, height, depth); + } catch (IllegalArgumentException exception) { + sendError(exception.getMessage()); + return; + } + + StructureWriteResult result; + try { + result = JigsawStudioProjectCreator.create( + data.getDataFolder().toPath(), + new JigsawStudioProjectCreator.Options(structure, studioMode, target, dimensions)); + } catch (IOException | RuntimeException exception) { + Iris.reportError("Failed to create Jigsaw Studio project '" + structure + "'.", exception); + sendError("Jigsaw project creation failed: " + exception.getMessage()); + return; + } + if (!result.successful()) { + sendError("Jigsaw project was not created: " + result.status()); + if (!result.conflicts().isEmpty()) { + sendError(result.conflicts().getFirst().relativePath() + ": " + + result.conflicts().getFirst().reason()); + } + return; + } + data.invalidateStructureResources(); + sender().sendMessage(C.GREEN + "Created Jigsaw project '" + structure + "' atomically."); + open(dimension, structure, seed); + } + + @Director(description = "Import a registered vanilla or datapack jigsaw graph into Iris Studio", + aliases = {"import", "import-vanilla"}, sync = true, origin = DirectorOrigin.PLAYER) + public void convert( + @Param(description = "Pack dimension") IrisDimension dimension, + @Param(description = "Registered structure key such as minecraft:village_plains") String source, + @Param(description = "New Iris structure key, or auto", defaultValue = "auto") String target, + @Param(description = "Studio world seed", defaultValue = "1337") long seed + ) { + if (studioIsActiveOrOpening()) { + sendError("Close the active or opening Jigsaw Studio before importing another graph."); + return; + } + IrisData data = requireData(dimension); + if (data == null) { + return; + } + NamespacedKey sourceKey; + String targetKey; + try { + sourceKey = parseRegisteredStructureKey(source); + targetKey = resolveConversionTarget(sourceKey, target); + } catch (IllegalArgumentException exception) { + sendError(exception.getMessage()); + return; + } + + VillageImporter.Result result = VillageImporter.importVillage( + data, + sourceKey, + targetKey, + StructureImporter.Mode.ADD_ONLY); + if (!result.success()) { + sendError(conversionFailureMessage(result)); + return; + } + data.invalidateStructureResources(); + VolmitSender commandSender = sender(); + commandSender.sendMessage(C.GREEN + result.message()); + openProject(player(), commandSender, dimension, targetKey, seed); + } + + @Director(description = "Open an existing Iris jigsaw graph in transient Studio", + aliases = {"edit", "reopen"}, sync = true, origin = DirectorOrigin.PLAYER) + public void open( + @Param(description = "Pack dimension") IrisDimension dimension, + @Param(name = "key", aliases = {"structure", "name"}, + description = "Existing key loaded from structures/.json") String structure, + @Param(description = "Studio world seed", defaultValue = "1337") long seed + ) { + openProject(player(), sender(), dimension, structure, seed); + } + + private static void openProject( + Player targetPlayer, + VolmitSender commandSender, + IrisDimension dimension, + String structure, + long seed + ) { + UUID playerId = targetPlayer.getUniqueId(); + UUID ownerId = JigsawStudioActivation.activeOwnerId(); + if (ownerId != null && !ownerId.equals(playerId)) { + sendError(commandSender, "Jigsaw Studio is already owned by another player session."); + return; + } + UUID openingOwner = JigsawStudioActivation.openingOwnerId(); + if (openingOwner != null) { + sendError(commandSender, openingOwner.equals(playerId) + ? "Your Jigsaw Studio is already opening." + : "Jigsaw Studio is already opening for another player."); + return; + } + ActiveContext previous = active(targetPlayer); + if (ownerId != null && previous == null) { + sendError(commandSender, "Your active Jigsaw Studio binding is unavailable. Close it before opening another project."); + return; + } + ActiveContext dirty = findDirtyContext(); + if (dirty != null) { + sendError(commandSender, + "The current Jigsaw Studio is waiting for autosave. Wait for it to finish or close with discard=true."); + return; + } + IrisData data = requireData(dimension, commandSender); + if (data == null) { + return; + } + IrisStructure irisStructure = data.load(IrisStructure.class, structure, false); + if (irisStructure == null) { + sendError(commandSender, "No Iris jigsaw structure '" + structure + "' exists in this pack."); + return; + } + + JigsawStudioLayout layout; + try { + layout = JigsawStudioGraphMapper.map(data, irisStructure); + } catch (RuntimeException exception) { + Iris.reportError("Failed to map Jigsaw Studio graph '" + structure + "'.", exception); + sendError(commandSender, "Could not map this jigsaw graph: " + exception.getMessage()); + return; + } + String packKey = dimension.getLoadKey(); + if (!JigsawStudioActivation.tryBeginOpen(playerId)) { + sendError(commandSender, + "Jigsaw Studio began opening for another player. Try again after it finishes."); + return; + } + JigsawStudioService studioService = JigsawStudioService.get(); + UUID closingRequestId = previous == null ? null : previous.request().requestId(); + if (closingRequestId != null) { + JigsawStudioService.CloseStart closeStart = studioService.tryBeginClose( + closingRequestId, + playerId, + false); + if (closeStart != JigsawStudioService.CloseStart.STARTED) { + JigsawStudioActivation.finishOpen(playerId); + sendError(commandSender, closeStart == JigsawStudioService.CloseStart.SAVE_IN_PROGRESS + ? "The current Jigsaw Studio is saving. Wait for it to finish before opening another project." + : closeStart == JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS + ? "The current Jigsaw Studio is loading a variant. Wait for it to finish before opening another project." + : closeStart == JigsawStudioService.CloseStart.DIRTY + ? "The current Jigsaw Studio is waiting for autosave. Wait for it to finish before opening another project." + : "The current Jigsaw Studio could not be reserved for replacement."); + return; + } + } + JigsawStudioActivation.StagedActivation staged; + try { + staged = JigsawStudioActivation.stage( + packKey, + structure, + layout.mode(), + compatibilityOf(irisStructure), + layout.cellDimensions(), + data, + layout, + playerId, + closingRequestId); + } catch (RuntimeException exception) { + JigsawStudioActivation.finishOpen(playerId); + studioService.cancelClose(closingRequestId); + Iris.reportError("Failed to stage Jigsaw Studio for '" + structure + "'.", exception); + sendError(commandSender, "Jigsaw Studio staging failed: " + exception.getMessage()); + return; + } + commandSender.sendMessage(C.GREEN + "Opening transient Jigsaw Studio for '" + structure + "' with " + + layout.bays().size() + " workcells."); + + try { + Iris.service(StudioSVC.class).openTracked( + commandSender, + seed, + packKey, + STUDIO_OPEN_KIND, + () -> beginStagedOpen(staged), + world -> finishOpen(targetPlayer, world, staged, studioService)) + .whenComplete((result, throwable) -> J.s(() -> finishOpenAttempt( + commandSender, + playerId, + structure, + closingRequestId, + studioService, + staged, + result, + throwable))); + } catch (Throwable exception) { + JigsawStudioActivation.finishOpen(playerId); + studioService.cancelClose(closingRequestId); + JigsawStudioActivation.rollback(staged); + Iris.reportError("Failed to open Jigsaw Studio for '" + structure + "'.", exception); + sendError(commandSender, "Jigsaw Studio open failed: " + exception.getMessage()); + } + } + + @Director(description = "Close the active Jigsaw Studio", sync = true, + origin = DirectorOrigin.PLAYER) + public void close( + @Param(description = "Discard unsaved Studio layout changes", defaultValue = "false") boolean discard + ) { + Player targetPlayer = player(); + ActiveContext context = active(targetPlayer); + if (context == null) { + sendError("You do not have an active Jigsaw Studio session."); + return; + } + if (context.session().isDirty() && !discard) { + sendError("This Jigsaw Studio is waiting for autosave. Wait for it to finish or run close with discard=true."); + return; + } + JigsawStudioService studioService = JigsawStudioService.get(); + UUID requestId = context.request().requestId(); + JigsawStudioService.CloseStart closeStart = studioService.tryBeginClose( + requestId, + targetPlayer.getUniqueId(), + discard); + if (closeStart != JigsawStudioService.CloseStart.STARTED) { + sendError(closeStart == JigsawStudioService.CloseStart.SAVE_IN_PROGRESS + ? "This Jigsaw Studio is saving. Wait for the save to finish before closing it." + : closeStart == JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS + ? "This Jigsaw Studio is loading a variant. Wait for it to finish before closing it." + : closeStart == JigsawStudioService.CloseStart.DIRTY + ? "This Jigsaw Studio is waiting for autosave. Wait for it to finish or run close with discard=true." + : closeStart == JigsawStudioService.CloseStart.NOT_OWNER + ? "This Jigsaw Studio is owned by another player session." + : "This Jigsaw Studio session is no longer active."); + return; + } + VolmitSender commandSender = sender(); + try { + Iris.service(StudioSVC.class).close().whenComplete((result, throwable) -> J.s(() -> { + if (throwable != null || result == null || result.failureCause() != null) { + studioService.cancelClose(requestId); + Throwable failure = throwable != null ? throwable : result == null ? null : result.failureCause(); + commandSender.sendMessage(C.RED + "Jigsaw Studio close failed: " + + (failure == null ? "unknown failure" : failure.getMessage())); + return; + } + JigsawStudioActivation.deactivate(context.packKey(), requestId); + PLAYER_PACKS.remove(targetPlayer.getUniqueId(), + new SessionBinding(context.packKey(), requestId)); + commandSender.sendMessage(C.GREEN + "Jigsaw Studio closed."); + })); + } catch (Throwable exception) { + studioService.cancelClose(requestId); + Iris.reportError("Failed to close Jigsaw Studio '" + context.request().structureKey() + "'.", exception); + sendError("Jigsaw Studio close failed: " + exception.getMessage()); + } + } + + @Director(name = "delete", aliases = "remove", description = "Delete the active owned jigsaw project", + sync = true, origin = DirectorOrigin.PLAYER) + public void delete( + @Param(description = "Confirm permanent project deletion", defaultValue = "false") boolean confirm + ) { + if (requireActive() == null) { + return; + } + if (!confirm) { + sendError("Project deletion removes every resource owned by this jigsaw. Run again with confirm=true."); + return; + } + JigsawStudioService.get().deleteProject(player()); + } + + @Director(description = "Show active Jigsaw Studio and dynamic evaluation state", sync = true, + origin = DirectorOrigin.PLAYER) + public void status() { + ActiveContext context = requireActive(); + if (context == null) { + return; + } + JigsawStudioLayout layout = context.session().layout(); + String selection = context.session().selectedBayId().orElse("none"); + JigsawStudioVariant activeVariant = "none".equals(selection) + ? null + : context.session().activeVariant(selection).orElse(null); + JigsawStudioBay selectedBay = "none".equals(selection) ? null : layout.get(selection); + JigsawStudioCellDimensions selectedDimensions = selectedBay == null + ? null + : selectedBay.bounds().dimensions(); + Optional evaluation = JigsawStudioService.get().evaluation(player()); + sender().sendMessage(C.AQUA + "Jigsaw Studio: " + context.request().structureKey()); + sender().sendMessage(C.GRAY + "mode=" + layout.mode() + + ", compatibility=" + context.request().compatibilityTarget() + + ", defaultCell=" + layout.cellDimensions().width() + "x" + + layout.cellDimensions().height() + "x" + layout.cellDimensions().depth() + + ", workcells=" + layout.bays().size() + + ", variants=" + layout.variantCatalog().size()); + sender().sendMessage(C.GRAY + "selected=" + selection + + ", activeVariant=" + (activeVariant == null ? "none" : activeVariant.pieceKey()) + + ", size=" + (selectedDimensions == null ? "none" : selectedDimensions.width() + "x" + + selectedDimensions.height() + "x" + selectedDimensions.depth()) + + ", enabled=" + (selectedBay == null ? "n/a" : selectedBay.enabled()) + + ", autosavePending=" + context.session().isDirty()); + if (evaluation.isPresent()) { + JigsawStudioGraphEvaluation current = evaluation.get(); + sender().sendMessage(C.GRAY + "evaluation=" + current.state() + + ", seed=" + current.seed() + + ", theme=" + (current.selectedTheme().isBlank() ? "unthemed" : current.selectedTheme()) + + ", pieces=" + current.pieceCount() + + ", detail=" + current.detail()); + } else { + sender().sendMessage(C.GRAY + "evaluation=PENDING, seed=" + DEFAULT_STUDIO_SEED); + } + } + + @Director(description = "Open the Jigsaw Studio workcell and variant menu", sync = true, + origin = DirectorOrigin.PLAYER) + public void menu() { + if (requireActive() != null) { + JigsawStudioService.get().openControlMenu(player()); + } + } + + @Director(description = "Select the Jigsaw Studio workcell containing you", sync = true, + origin = DirectorOrigin.PLAYER) + public void select() { + ActiveContext context = requireActive(); + if (context == null) { + return; + } + Location location = player().getLocation(); + JigsawStudioBay bay = context.session().layout().findAt( + location.getBlockX(), location.getBlockY(), location.getBlockZ()); + if (bay == null) { + sendError("Stand inside a Jigsaw Studio workcell before selecting it."); + return; + } + context.session().selectBay(bay.stableId()); + sender().sendMessage(C.GREEN + "Selected " + bay.stableId() + " (" + bay.kind() + ")."); + } + + @Director(name = "bounds", aliases = {"cell", "resize"}, + description = "Set the selected Studio workcell capacity", + sync = true, origin = DirectorOrigin.PLAYER) + public void bounds( + @Param(description = "Cell width") int width, + @Param(description = "Cell height") int height, + @Param(description = "Cell depth") int depth + ) { + ActiveContext context = requireActive(); + if (context == null) { + return; + } + JigsawStudioBay selected = selectedBay(context); + if (selected == null) { + sendError("Select a Jigsaw Studio workcell before changing its capacity."); + return; + } + JigsawStudioCellDimensions dimensions; + try { + dimensions = new JigsawStudioCellDimensions(width, height, depth); + } catch (IllegalArgumentException exception) { + sendError(exception.getMessage()); + return; + } + if (dimensions.equals(selected.capacity())) { + sender().sendMessage(C.YELLOW + "Workcell capacity is unchanged."); + return; + } + JigsawStudioService.get().updateWorkcellDimensions(player(), selected.stableId(), dimensions); + } + + @Director(description = "Flush the automatic save for a workcell now", sync = true, + origin = DirectorOrigin.PLAYER) + public void save( + @Param(description = "Workcell ID or selected", defaultValue = "selected") String bay + ) { + ActiveContext context = requireActive(); + if (context == null) { + return; + } + if ("selected".equalsIgnoreCase(bay)) { + JigsawStudioBay selected = selectedBay(context); + if (selected == null) { + sendError("Select a Jigsaw Studio workcell before flushing its autosave."); + return; + } + JigsawStudioService.get().flushAutosave(player(), selected.stableId()); + return; + } + JigsawStudioService.get().flushAutosave(player(), bay); + } + + @Director(name = "goto", aliases = "teleport", description = "Select and teleport to a Studio workcell", + sync = true, origin = DirectorOrigin.PLAYER) + public void gotoBay( + @Param(description = "Workcell ID") String bay + ) { + if (requireActive() != null) { + JigsawStudioService.get().teleportTo(player(), bay); + } + } + + @Director(description = "Enable or disable Jigsaw Studio particles", sync = true, + origin = DirectorOrigin.PLAYER) + public void particles( + @Param(description = "Whether particles are visible") boolean visible + ) { + if (requireActive() != null) { + JigsawStudioService.get().setParticles(player(), visible); + } + } + + @Director(description = "Export the clean active graph as a vanilla datapack", sync = true, + origin = DirectorOrigin.PLAYER) + public void export( + @Param(description = "Datapack namespace", defaultValue = "iris") String namespace, + @Param(description = "Output name under the Studio exports folder", defaultValue = "jigsaw-export") + String output, + @Param(description = "directory or zip", defaultValue = "zip") String format, + @Param(description = "Replace an existing export", defaultValue = "false") boolean replace + ) { + ActiveContext context = requireActive(); + if (context == null) { + return; + } + if (context.session().isDirty()) { + sendError("Wait for the pending autosave or discard the edits before exporting the on-disk graph."); + return; + } + VanillaJigsawExportFormat exportFormat; + try { + exportFormat = parseFormat(format); + } catch (IllegalArgumentException exception) { + sendError(exception.getMessage()); + return; + } + Path exportRoot = new File(Iris.service(StudioSVC.class).getWorkspaceFolder(), "exports") + .toPath().toAbsolutePath().normalize(); + Path destination; + try { + destination = resolveExportDestination(exportRoot, output, exportFormat); + } catch (IllegalArgumentException exception) { + sendError(exception.getMessage()); + return; + } + VanillaJigsawExportRequest request; + try { + request = VanillaJigsawExportRequest.builder( + VanillaJigsawExportSource.forData( + context.request().source(), context.request().structureKey()), destination) + .namespace(namespace) + .resourcePath(context.request().structureKey()) + .format(exportFormat) + .replaceExisting(replace) + .build(); + } catch (IllegalArgumentException exception) { + sendError(exception.getMessage()); + return; + } + Player targetPlayer = player(); + UUID playerId = targetPlayer.getUniqueId(); + UUID requestId = context.request().requestId(); + JigsawStudioService studioService = JigsawStudioService.get(); + JigsawStudioService.ExportStart exportStart = studioService.tryBeginExport(requestId, playerId); + if (exportStart != JigsawStudioService.ExportStart.STARTED) { + sendError(exportStartError(exportStart)); + return; + } + if (!beginExport(playerId, destination)) { + studioService.finishExport(requestId); + sendError("A vanilla Jigsaw export for this player or output is already in progress."); + return; + } + ExportLease lease = new ExportLease(() -> { + finishExport(playerId, destination); + studioService.finishExport(requestId); + }); + ExportOperation operation = new ExportOperation( + targetPlayer, + sender(), + requestId, + context.request().structureKey(), + request, + lease); + sender().sendMessage(C.GRAY + "Exporting '" + operation.structureKey() + + "' in the background to " + destination + "..."); + try { + J.a(() -> runExport(operation)); + } catch (RuntimeException exception) { + operation.lease().release(); + Iris.reportError("Failed to schedule vanilla Jigsaw export for '" + + operation.structureKey() + "'.", exception); + sendError("Vanilla export could not be scheduled: " + exception.getMessage()); + } + } + + static String exportStartError(JigsawStudioService.ExportStart exportStart) { + Objects.requireNonNull(exportStart, "Jigsaw Studio export start status"); + return switch (exportStart) { + case NOT_ACTIVE -> "The active Jigsaw Studio is no longer available."; + case NOT_OWNER -> "Only the Jigsaw Studio owner can export this project."; + case DIRTY -> "Wait for the pending autosave or discard the edits before exporting the on-disk graph."; + case CLOSING -> "The active Jigsaw Studio is closing and cannot be exported."; + case SAVE_IN_PROGRESS -> "Wait for the current Jigsaw Studio save to finish before exporting."; + case OPERATION_IN_PROGRESS -> "Wait for the current Jigsaw Studio operation to finish before exporting."; + case IN_PROGRESS -> "A Jigsaw Studio export is already in progress."; + case STARTED -> throw new IllegalArgumentException("STARTED is not an export failure"); + }; + } + + static boolean beginExport(UUID playerId, Path destination) { + Objects.requireNonNull(playerId); + Path normalizedDestination = Objects.requireNonNull(destination).toAbsolutePath().normalize(); + if (!EXPORTING_PLAYERS.add(playerId)) { + return false; + } + if (EXPORT_DESTINATIONS.add(normalizedDestination)) { + return true; + } + EXPORTING_PLAYERS.remove(playerId); + return false; + } + + static void finishExport(UUID playerId, Path destination) { + EXPORTING_PLAYERS.remove(Objects.requireNonNull(playerId)); + EXPORT_DESTINATIONS.remove(Objects.requireNonNull(destination).toAbsolutePath().normalize()); + } + + private static void pruneAdoptionPlanBindings() { + for (Map.Entry entry : ADOPTION_PLANS.entrySet()) { + AdoptionPlanBinding binding = entry.getValue(); + if (binding.service().plan(entry.getKey()).isEmpty()) { + ADOPTION_PLANS.remove(entry.getKey(), binding); + } + } + } + + private static boolean studioIsActiveOrOpening() { + return JigsawStudioActivation.activeOwnerId() != null + || JigsawStudioActivation.openingOwnerId() != null; + } + + private static void runAdoptionApply( + Player targetPlayer, + VolmitSender commandSender, + UUID planId, + AdoptionPlanBinding binding + ) { + if (studioIsActiveOrOpening()) { + ADOPTION_PLANS.putIfAbsent(planId, binding); + J.runEntity(targetPlayer, () -> sendError(commandSender, + "Close the active or opening Jigsaw Studio before applying an adoption plan.")); + return; + } + IrisStructureAdoptionResult result; + Throwable failure = null; + try { + result = binding.service().apply(binding.plan()); + } catch (Throwable throwable) { + result = null; + failure = throwable; + Iris.reportError("Failed to apply Jigsaw adoption plan '" + planId + "'.", throwable); + } + IrisStructureAdoptionResult completedResult = result; + Throwable completedFailure = failure; + J.runEntity(targetPlayer, () -> finishAdoptionApply( + targetPlayer, + commandSender, + binding, + completedResult, + completedFailure)); + } + + private static void runAdoptionInspect( + Player targetPlayer, + VolmitSender commandSender, + UUID ownerId, + IrisDimension dimension, + IrisData data, + Path root, + String sourceKey, + Optional targetKey, + IrisStructureAdoptionStrategy strategy + ) { + IrisStructureAdoptionService service = null; + IrisStructureAdoptionPlan plan = null; + Throwable failure = null; + try { + IrisStructureAdoptionInputKind inputKind = adoptionInputKind(root, sourceKey); + service = ADOPTION_SERVICES.computeIfAbsent(root, IrisStructureAdoptionService::new); + plan = service.inspect(new IrisStructureAdoptionRequest( + sourceKey, + targetKey, + strategy, + inputKind)); + } catch (Throwable throwable) { + failure = throwable; + Iris.reportError("Failed to inspect Jigsaw adoption source '" + sourceKey + "'.", throwable); + } + IrisStructureAdoptionService completedService = service; + IrisStructureAdoptionPlan completedPlan = plan; + Throwable completedFailure = failure; + J.runEntity(targetPlayer, () -> finishAdoptionInspect( + commandSender, + ownerId, + dimension, + data, + completedService, + completedPlan, + completedFailure)); + } + + private static void finishAdoptionInspect( + VolmitSender commandSender, + UUID ownerId, + IrisDimension dimension, + IrisData data, + IrisStructureAdoptionService service, + IrisStructureAdoptionPlan plan, + Throwable failure + ) { + if (failure != null || service == null || plan == null) { + sendError(commandSender, "Jigsaw adoption inspection failed: " + + (failure == null ? "no result" : failure.getMessage())); + return; + } + pruneAdoptionPlanBindings(); + if (service.plan(plan.planId()).isPresent()) { + ADOPTION_PLANS.put(plan.planId(), new AdoptionPlanBinding( + ownerId, dimension, data, service, plan)); + } + reportAdoptionPlan(commandSender, plan); + } + + private static void finishAdoptionApply( + Player targetPlayer, + VolmitSender commandSender, + AdoptionPlanBinding binding, + IrisStructureAdoptionResult result, + Throwable failure + ) { + if (failure != null || result == null) { + sendError(commandSender, "Jigsaw adoption failed: " + + (failure == null ? "no result" : failure.getMessage())); + return; + } + reportAdoptionResult(commandSender, result); + if (!result.successful()) { + return; + } + StructureKey target = result.receipt().orElseThrow().targetStructure(); + binding.data().invalidateStructureResources(); + openProject(targetPlayer, commandSender, binding.dimension(), target.path(), DEFAULT_STUDIO_SEED); + } + + private static void reportAdoptionPlan(VolmitSender commandSender, IrisStructureAdoptionPlan plan) { + C statusColor = plan.canApply() ? C.GREEN : C.RED; + commandSender.sendMessage(statusColor + "Adoption plan " + plan.planId() + " -> " + + plan.disposition() + " for " + plan.targetStructure().value() + "."); + commandSender.sendMessage(C.GRAY + "Resources=" + plan.resourceCount() + + ", bytes=" + plan.totalSourceBytes() + + ", errors=" + plan.errorCount() + + ", warnings=" + plan.warningCount() + "."); + for (IrisStructureAdoptionDiagnostic diagnostic : plan.diagnostics()) { + commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary()); + } + if (plan.canApply()) { + commandSender.sendMessage(C.AQUA + "Apply with /iris jigsaw adopt apply " + plan.planId()); + } + } + + private static void reportAdoptionResult(VolmitSender commandSender, IrisStructureAdoptionResult result) { + commandSender.sendMessage((result.successful() ? C.GREEN : C.RED) + + "Adoption plan " + result.planId() + ": " + result.status() + "."); + for (IrisStructureAdoptionDiagnostic diagnostic : result.diagnostics()) { + commandSender.sendMessage(adoptionColor(diagnostic).toString() + diagnostic.summary()); + } + } + + private static C adoptionColor(IrisStructureAdoptionDiagnostic diagnostic) { + return switch (diagnostic.severity()) { + case ERROR -> C.RED; + case WARNING -> C.YELLOW; + case INFO -> C.GRAY; + }; + } + + private static void runExport(ExportOperation operation) { + VanillaJigsawExportResult result = null; + Throwable failure = null; + try { + result = new VanillaJigsawDatapackExporter().export(operation.request()); + } catch (Throwable throwable) { + failure = throwable; + Iris.reportError("Vanilla Jigsaw export failed for '" + operation.structureKey() + "'.", throwable); + } finally { + operation.lease().release(); + } + VanillaJigsawExportResult completedResult = result; + Throwable completedFailure = failure; + Runnable response = () -> reportExport(operation, completedResult, completedFailure); + J.runEntity(operation.player(), response); + } + + private static void reportExport( + ExportOperation operation, + VanillaJigsawExportResult result, + Throwable failure + ) { + if (failure != null || result == null) { + operation.sender().sendMessage(C.RED + "Background vanilla export for '" + + operation.structureKey() + "' failed: " + + (failure == null ? "no result" : failure.getMessage())); + return; + } + for (VanillaJigsawExportDiagnostic diagnostic : result.diagnostics()) { + C color = diagnostic.isBlocking() ? C.RED : C.YELLOW; + operation.sender().sendMessage(color.toString() + diagnostic.code() + ": " + diagnostic.message()); + } + ActiveContext current = active(operation.player()); + boolean sameSession = current != null + && current.request().requestId().equals(operation.requestId()); + String contextLabel = sameSession ? "Vanilla export" : "Background vanilla export for '" + + operation.structureKey() + "'"; + operation.sender().sendMessage((result.isSuccess() ? C.GREEN : C.RED) + contextLabel + " " + + result.status() + ": " + result.output()); + } + + static ActiveContext active(Player player) { + if (player == null) { + return null; + } + SessionBinding binding = PLAYER_PACKS.get(player.getUniqueId()); + if (binding == null) { + return null; + } + ActiveContext context = context(binding); + if (context == null) { + PLAYER_PACKS.remove(player.getUniqueId(), binding); + return null; + } + return context; + } + + private static ActiveContext context(SessionBinding binding) { + JigsawStudioActivation.Request request = JigsawStudioActivation.getRequest(binding.packKey()); + JigsawStudioSession session = JigsawStudioActivation.getSession(binding.packKey()); + if (request == null || session == null + || !binding.requestId().equals(request.requestId()) + || !request.requestId().equals(session.sessionId())) { + return null; + } + return new ActiveContext(binding.packKey(), request, session); + } + + private static ActiveContext findDirtyContext() { + for (SessionBinding binding : PLAYER_PACKS.values()) { + JigsawStudioActivation.Request request = JigsawStudioActivation.getRequest(binding.packKey()); + JigsawStudioSession session = JigsawStudioActivation.getSession(binding.packKey()); + if (request != null && session != null + && binding.requestId().equals(request.requestId()) + && session.isDirty()) { + return new ActiveContext(binding.packKey(), request, session); + } + } + return null; + } + + private ActiveContext requireActive() { + ActiveContext context = active(player()); + if (context == null) { + sendError("Open a Jigsaw Studio project first."); + } + return context; + } + + private IrisData requireData(IrisDimension dimension) { + return requireData(dimension, sender()); + } + + private static IrisData requireData(IrisDimension dimension, VolmitSender commandSender) { + IrisData data = dimension == null ? null : dimension.getLoader(); + if (data == null) { + sendError(commandSender, "Could not resolve the requested Iris pack dimension."); + } + return data; + } + + private static void beginStagedOpen(JigsawStudioActivation.StagedActivation staged) { + if (!JigsawStudioActivation.beginStagedGeneration(staged)) { + throw new IllegalStateException("The staged Jigsaw Studio activation is no longer current"); + } + } + + private static void finishOpenAttempt( + VolmitSender commandSender, + UUID playerId, + String structureKey, + UUID closingRequestId, + JigsawStudioService studioService, + JigsawStudioActivation.StagedActivation staged, + StudioOpenCoordinator.StudioOpenResult result, + Throwable throwable + ) { + JigsawStudioActivation.finishOpen(playerId); + if (throwable == null && result != null) { + studioService.cancelClose(closingRequestId); + return; + } + studioService.cancelClose(closingRequestId); + JigsawStudioActivation.rollback(staged); + Throwable failure = unwrapCompletionFailure(throwable == null + ? new IllegalStateException("Studio open completed without a result") + : throwable); + Iris.reportError("Failed to open Jigsaw Studio for '" + structureKey + "'.", failure); + sendError(commandSender, "Jigsaw Studio open failed: " + failure.getMessage()); + } + + private static void finishOpen( + Player targetPlayer, + World world, + JigsawStudioActivation.StagedActivation staged, + JigsawStudioService studioService + ) { + if (world == null) { + throw new IllegalStateException("Jigsaw Studio opened without a world"); + } + JigsawStudioActivation.Request request = staged.request(); + JigsawStudioSession session = staged.session(); + try { + WorldRuntimeControlService.get().applyObjectStudioWorldRules(world); + } catch (Throwable exception) { + Iris.reportError("Failed to apply Jigsaw Studio world rules for '" + world.getName() + "'.", exception); + } + JigsawStudioBay destination = initialBay(session.layout()); + Location target = destination == null + ? new Location(world, 0.5D, 66D, 0.5D) + : new Location( + world, + destination.bounds().originX() + destination.bounds().dimensions().width() / 2.0D, + destination.bounds().maxY() + 2.0D, + destination.bounds().originZ() + destination.bounds().dimensions().depth() / 2.0D); + if (destination != null) { + session.selectBay(destination.stableId()); + } + if (!JigsawStudioActivation.commit(staged)) { + throw new IllegalStateException("The staged Jigsaw Studio activation could not be committed"); + } + studioService.activationCommitted(world, request.requestId()); + PLAYER_PACKS.put(targetPlayer.getUniqueId(), new SessionBinding( + request.packKey(), request.requestId())); + try { + J.runEntity(targetPlayer, () -> BukkitPlatform.teleportAsync(targetPlayer, target) + .thenRun(() -> J.runEntity(targetPlayer, () -> targetPlayer.setGameMode(GameMode.CREATIVE)))); + } catch (RuntimeException exception) { + Iris.reportError("Failed to move the player to the committed Jigsaw Studio workcell.", exception); + } + } + + private static JigsawStudioBay initialBay(JigsawStudioLayout layout) { + for (JigsawStudioBay bay : layout.bays()) { + if (layout.defaultVariant(bay).isPresent()) { + return bay; + } + } + return layout.bays().isEmpty() ? null : layout.bays().getFirst(); + } + + private static Throwable unwrapCompletionFailure(Throwable failure) { + Throwable current = failure; + while (current instanceof CompletionException && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private void sendError(String message) { + sender().sendMessage(C.RED + message); + } + + private static void sendError(VolmitSender commandSender, String message) { + commandSender.sendMessage(C.RED + message); + } + + private static JigsawStudioMode parseMode(String value) { + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "planar", "2d", "planar_jigsaw" -> JigsawStudioMode.PLANAR_JIGSAW; + case "spatial", "3d", "spatial_jigsaw" -> JigsawStudioMode.SPATIAL_JIGSAW; + default -> throw new IllegalArgumentException("Mode must be planar or spatial."); + }; + } + + private static JigsawStudioCompatibilityTarget parseCompatibility(String value) { + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "extended", "iris", "iris_extended" -> JigsawStudioCompatibilityTarget.IRIS_EXTENDED; + case "vanilla", "portable", "vanilla_portable" -> JigsawStudioCompatibilityTarget.VANILLA_PORTABLE; + default -> throw new IllegalArgumentException("Compatibility must be iris or vanilla."); + }; + } + + private static JigsawStudioCompatibilityTarget compatibilityOf(IrisStructure structure) { + return structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE + ? JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + : JigsawStudioCompatibilityTarget.IRIS_EXTENDED; + } + + static NamespacedKey parseRegisteredStructureKey(String value) { + String normalized = Objects.requireNonNull(value, "Registered structure key").trim() + .toLowerCase(Locale.ROOT); + NamespacedKey key = NamespacedKey.fromString(normalized); + if (key == null) { + throw new IllegalArgumentException( + "Registered structure source must use a valid namespace:path key."); + } + return key; + } + + static String resolveConversionTarget(NamespacedKey source, String value) { + Objects.requireNonNull(source, "Registered structure key"); + String normalized = Objects.requireNonNull(value, "Conversion target").trim() + .toLowerCase(Locale.ROOT); + if (normalized.isEmpty() || "auto".equals(normalized)) { + return StructureImporter.deriveName(source); + } + StructureKey target = StructureKey.parse(normalized, "iris"); + if (!"iris".equals(target.namespace())) { + throw new IllegalArgumentException("Converted Studio targets must use the iris namespace."); + } + return target.path(); + } + + static String conversionFailureMessage(VillageImporter.Result result) { + String message = Objects.requireNonNull(result, "Village import result").message(); + String obsoleteInstruction = "; use 'import' for single-template structures"; + int instructionIndex = message.indexOf(obsoleteInstruction); + if (instructionIndex < 0) { + return message; + } + return message.substring(0, instructionIndex) + + ". This Studio converter accepts registered jigsaw structures only."; + } + + static String parseAdoptionSource(String value) { + String normalized = Objects.requireNonNull(value, "Adoption source").trim() + .toLowerCase(Locale.ROOT); + StructureKey source = StructureKey.parse(normalized, "iris"); + if (!"iris".equals(source.namespace())) { + throw new IllegalArgumentException("Adoption sources must use the iris namespace."); + } + return source.path(); + } + + static Optional parseAdoptionTarget(String value) { + String normalized = Objects.requireNonNull(value, "Adoption target").trim() + .toLowerCase(Locale.ROOT); + if (normalized.isEmpty() || "auto".equals(normalized)) { + return Optional.empty(); + } + StructureKey target = StructureKey.parse(normalized, "iris"); + if (!"iris".equals(target.namespace())) { + throw new IllegalArgumentException("Adoption targets must use the iris namespace."); + } + return Optional.of(target); + } + + private static IrisStructureAdoptionStrategy parseAdoptionStrategy(String value) { + return switch (Objects.requireNonNull(value, "Adoption strategy").trim().toLowerCase(Locale.ROOT)) { + case "auto" -> IrisStructureAdoptionStrategy.AUTO; + case "in-place", "in_place", "inplace", "claim" -> IrisStructureAdoptionStrategy.IN_PLACE; + case "clone", "copy" -> IrisStructureAdoptionStrategy.CLONE; + default -> throw new IllegalArgumentException("Adoption strategy must be auto, in-place, or clone."); + }; + } + + static IrisStructureAdoptionInputKind adoptionInputKind(Path packRoot, String source) throws IOException { + Path root = canonicalAdoptionRoot(packRoot); + StructureKey sourceKey = new StructureKey("iris", parseAdoptionSource(source)); + Path manifestPath = new StructureTransactionWriter(root).ownershipManifestPath(sourceKey); + if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + return IrisStructureAdoptionInputKind.UNOWNED_IRIS; + } + if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Ownership manifest is not a regular file: " + manifestPath); + } + StructureOwnershipManifest manifest; + try { + manifest = StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath)); + } catch (RuntimeException exception) { + throw new IOException("Invalid ownership manifest at " + manifestPath, exception); + } + if (!manifest.structure().equals(sourceKey)) { + throw new IOException("Ownership manifest at " + manifestPath + + " belongs to " + manifest.structure().value()); + } + return manifest.provenance().origin() == StructureOwnershipManifest.Origin.MANAGED_DATAPACK + ? IrisStructureAdoptionInputKind.MANAGED_DATAPACK + : IrisStructureAdoptionInputKind.UNOWNED_IRIS; + } + + private static Path canonicalAdoptionRoot(Path packRoot) throws IOException { + Path normalized = Objects.requireNonNull(packRoot, "Adoption pack root").toAbsolutePath().normalize(); + return Files.exists(normalized, LinkOption.NOFOLLOW_LINKS) ? normalized.toRealPath() : normalized; + } + + private static VanillaJigsawExportFormat parseFormat(String value) { + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "zip" -> VanillaJigsawExportFormat.ZIP; + case "directory", "dir", "folder" -> VanillaJigsawExportFormat.DIRECTORY; + default -> throw new IllegalArgumentException("Export format must be directory or zip."); + }; + } + + static Path resolveExportDestination( + Path exportRoot, + String output, + VanillaJigsawExportFormat format + ) { + Path root = Objects.requireNonNull(exportRoot, "Jigsaw export root").toAbsolutePath().normalize(); + String requested = Objects.requireNonNull(output, "Jigsaw export output name"); + String name = requested.trim(); + if (!name.equals(requested) || !EXPORT_OUTPUT_NAME.matcher(name).matches()) { + throw new IllegalArgumentException( + "Export output must be one file name using letters, numbers, '.', '_', or '-'."); + } + String fileName = format == VanillaJigsawExportFormat.ZIP && !name.endsWith(".zip") + ? name + ".zip" : name; + Path destination = root.resolve(fileName).normalize(); + if (destination.equals(root) || !root.equals(destination.getParent())) { + throw new IllegalArgumentException("Export output must be one artifact inside the Studio exports folder."); + } + return destination; + } + + static JigsawStudioBay selectedBay(ActiveContext context) { + String selected = context.session().selectedBayId().orElse(null); + return selected == null ? null : context.session().layout().get(selected); + } + + static JigsawStudioVariant selectedVariant(ActiveContext context) { + JigsawStudioBay selected = selectedBay(context); + return selected == null + ? null + : context.session().activeVariant(selected.stableId()).orElse(null); + } + + static boolean runGraphMutation( + Player player, + ActiveContext context, + JigsawStudioService.CommandGraphMutation mutation + ) { + return JigsawStudioService.get().runCommandGraphMutation( + player, + context.request().requestId(), + mutation); + } + + static JigsawStudioLayout loadPersistedLayout(ActiveContext context) throws IOException { + IrisData source = context.request().source(); + source.invalidateStructureResources(); + IrisStructure structure = source.load(IrisStructure.class, context.request().structureKey(), false); + if (structure == null) { + throw new IOException("The structure could not be reloaded after the graph transaction"); + } + return JigsawStudioGraphMapper.map(source, structure); + } + + static JigsawStudioService.CommandGraphMutationResult mappedMutationResult( + ActiveContext context, + String activateWorkcellId, + String activatePieceKey, + String message + ) throws IOException { + return new JigsawStudioService.CommandGraphMutationResult( + loadPersistedLayout(context), + activateWorkcellId, + activatePieceKey, + message); + } + + static String workcellIdForVariant(JigsawStudioLayout layout, String pieceKey) throws IOException { + JigsawStudioVariant variant = layout.variantCatalog().find(pieceKey).orElse(null); + if (variant == null) { + throw new IOException("Persisted variant '" + pieceKey + "' did not map to a Studio workcell"); + } + return variant.archetype() + .map(archetype -> archetype.stableId()) + .orElse(JigsawStudioLayout.SPATIAL_WORKCELL_ID); + } + + static PieceWorkcellResolution resolvePieceWorkcell( + JigsawStudioLayout layout, + IrisJigsawPiece piece, + IrisObject object + ) { + JigsawStudioLayout activeLayout = Objects.requireNonNull(layout, "Jigsaw Studio layout"); + IrisJigsawPiece activePiece = Objects.requireNonNull(piece, "Jigsaw piece"); + IrisObject activeObject = Objects.requireNonNull(object, "Jigsaw piece object"); + String workcellId; + IrisPosition requiredDimensions; + if (activeLayout.mode() == JigsawStudioMode.PLANAR_JIGSAW) { + IrisJigsawWorkcellArchetype modelArchetype = IrisJigsawWorkcellArchetype.fromPiece(activePiece); + workcellId = JigsawPlanarArchetype.fromModel(modelArchetype).stableId(); + requiredDimensions = PlanarJigsawWorkcellResolver.canonicalDimensions(activePiece, activeObject); + } else { + workcellId = JigsawStudioLayout.SPATIAL_WORKCELL_ID; + requiredDimensions = new IrisPosition( + activeObject.getW(), + activeObject.getH(), + activeObject.getD()); + } + JigsawStudioBay workcell = activeLayout.get(workcellId); + if (workcell == null) { + throw new IllegalArgumentException("Workcell '" + workcellId + "' is not configured"); + } + return new PieceWorkcellResolution(workcell, requiredDimensions); + } + + static String nextThemeKey(ActiveContext context) throws IOException { + IrisStructure structure = context.request().source().load( + IrisStructure.class, + context.request().structureKey(), + false); + if (structure == null) { + throw new IOException("The active jigsaw structure could not be loaded"); + } + Set declared = new HashSet<>(); + if (structure.getThemeSets() != null) { + for (IrisJigsawThemeSet themeSet : structure.getThemeSets()) { + if (themeSet != null && themeSet.getKey() != null) { + declared.add(themeSet.getKey()); + } + } + } + int candidate = 1; + while (candidate > 0) { + String key = "variant-" + candidate; + if (!declared.contains(key)) { + return key; + } + candidate = Math.incrementExact(candidate); + } + throw new IOException("Jigsaw Studio cannot allocate another numbered family key"); + } + + static void sendError(DirectorExecutor executor, String message) { + executor.sender().sendMessage(C.RED + message); + } + + public record ActiveContext( + String packKey, + JigsawStudioActivation.Request request, + JigsawStudioSession session + ) { + } + + record PieceWorkcellResolution( + JigsawStudioBay workcell, + IrisPosition requiredDimensions + ) { + PieceWorkcellResolution { + workcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell"); + requiredDimensions = Objects.requireNonNull(requiredDimensions, "Jigsaw piece dimensions"); + } + + boolean fits() { + JigsawStudioCellDimensions capacity = workcell.capacity(); + return requiredDimensions.getX() <= capacity.width() + && requiredDimensions.getY() <= capacity.height() + && requiredDimensions.getZ() <= capacity.depth(); + } + } + + private record SessionBinding(String packKey, UUID requestId) { + } + + private record ExportOperation( + Player player, + VolmitSender sender, + UUID requestId, + String structureKey, + VanillaJigsawExportRequest request, + ExportLease lease + ) { + } + + static final class ExportLease { + private final Runnable release; + private final AtomicBoolean released; + + ExportLease(Runnable release) { + this.release = Objects.requireNonNull(release, "Jigsaw export lease release action"); + this.released = new AtomicBoolean(); + } + + void release() { + if (released.compareAndSet(false, true)) { + release.run(); + } + } + } + + private record AdoptionPlanBinding( + UUID ownerId, + IrisDimension dimension, + IrisData data, + IrisStructureAdoptionService service, + IrisStructureAdoptionPlan plan + ) { + } + + public static final class JigsawModeHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + return new KList<>("planar", "spatial"); + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String input, boolean force) throws DirectorParsingException { + try { + return parseMode(input) == JigsawStudioMode.PLANAR_JIGSAW ? "planar" : "spatial"; + } catch (IllegalArgumentException exception) { + throw new DirectorParsingException(exception.getMessage()); + } + } + + @Override + public boolean supports(Class type) { + return type == String.class; + } + } + + public static final class JigsawCompatibilityHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + return new KList<>("iris", "vanilla"); + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String input, boolean force) throws DirectorParsingException { + try { + return parseCompatibility(input) == JigsawStudioCompatibilityTarget.IRIS_EXTENDED + ? "iris" : "vanilla"; + } catch (IllegalArgumentException exception) { + throw new DirectorParsingException(exception.getMessage()); + } + } + + @Override + public boolean supports(Class type) { + return type == String.class; + } + } + + public static final class JigsawAdoptionStrategyHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + return new KList<>("auto", "in-place", "clone"); + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String input, boolean force) throws DirectorParsingException { + try { + return switch (parseAdoptionStrategy(input)) { + case AUTO -> "auto"; + case IN_PLACE -> "in-place"; + case CLONE -> "clone"; + }; + } catch (IllegalArgumentException exception) { + throw new DirectorParsingException(exception.getMessage()); + } + } + + @Override + public boolean supports(Class type) { + return type == String.class; + } + } + + public static final class JigsawAdoptionPlanHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + return new KList<>(); + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String input, boolean force) throws DirectorParsingException { + try { + return UUID.fromString(input).toString(); + } catch (IllegalArgumentException exception) { + throw new DirectorParsingException("Adoption plan ID must be a UUID."); + } + } + + @Override + public boolean supports(Class type) { + return type == String.class; + } + } + + @Director(name = "adopt", description = "Inspect and apply safe ownership adoption plans", + origin = DirectorOrigin.PLAYER) + public static class CommandJigsawAdopt implements DirectorExecutor { + @Director(description = "Inspect an Iris graph for safe Studio ownership adoption", sync = true, + origin = DirectorOrigin.PLAYER) + public void inspect( + @Param(description = "Pack dimension") IrisDimension dimension, + @Param(description = "Existing Iris structure key") String source, + @Param(description = "New owned Iris key, or auto", defaultValue = "auto") String target, + @Param(description = "auto, in-place, or clone", defaultValue = "auto", + customHandler = JigsawAdoptionStrategyHandler.class) String strategy + ) { + VolmitSender commandSender = sender(); + IrisData data = requireData(dimension, commandSender); + if (data == null) { + return; + } + String sourceKey; + Optional targetKey; + IrisStructureAdoptionStrategy adoptionStrategy; + Path root; + try { + sourceKey = parseAdoptionSource(source); + targetKey = parseAdoptionTarget(target); + adoptionStrategy = parseAdoptionStrategy(strategy); + root = canonicalAdoptionRoot(data.getDataFolder().toPath()); + } catch (IOException | IllegalArgumentException exception) { + sendError(commandSender, "Could not inspect adoption source: " + exception.getMessage()); + return; + } + Player targetPlayer = player(); + UUID ownerId = targetPlayer.getUniqueId(); + commandSender.sendMessage(C.GRAY + "Inspecting Jigsaw adoption source '" + sourceKey + + "' asynchronously..."); + try { + J.a(() -> runAdoptionInspect( + targetPlayer, + commandSender, + ownerId, + dimension, + data, + root, + sourceKey, + targetKey, + adoptionStrategy)); + } catch (RuntimeException exception) { + Iris.reportError("Failed to schedule Jigsaw adoption inspection for '" + + sourceKey + "'.", exception); + sendError(commandSender, "Jigsaw adoption inspection could not be scheduled: " + + exception.getMessage()); + } + } + + @Director(description = "Apply one inspected adoption plan without overwriting", sync = true, + origin = DirectorOrigin.PLAYER) + public void apply( + @Param(description = "Plan UUID returned by adopt inspect", + customHandler = JigsawAdoptionPlanHandler.class) String planId + ) { + VolmitSender commandSender = sender(); + UUID parsedPlanId; + try { + parsedPlanId = UUID.fromString(planId); + } catch (IllegalArgumentException exception) { + sendError(commandSender, "Adoption plan ID must be a UUID."); + return; + } + if (studioIsActiveOrOpening()) { + sendError(commandSender, + "Close the active or opening Jigsaw Studio before applying an adoption plan."); + return; + } + pruneAdoptionPlanBindings(); + AdoptionPlanBinding binding = ADOPTION_PLANS.get(parsedPlanId); + UUID playerId = player().getUniqueId(); + if (binding == null || !binding.ownerId().equals(playerId)) { + sendError(commandSender, "No active adoption plan '" + parsedPlanId + + "' belongs to your player session. Run adopt inspect again."); + return; + } + if (binding.service().plan(parsedPlanId).isEmpty()) { + ADOPTION_PLANS.remove(parsedPlanId, binding); + sendError(commandSender, "Adoption plan '" + parsedPlanId + + "' expired or was already consumed. Run adopt inspect again."); + return; + } + if (!ADOPTION_PLANS.remove(parsedPlanId, binding)) { + sendError(commandSender, "Adoption plan '" + parsedPlanId + "' is already being applied."); + return; + } + Player targetPlayer = player(); + commandSender.sendMessage(C.GRAY + "Applying adoption plan '" + parsedPlanId + + "' asynchronously without overwrite..."); + try { + J.a(() -> runAdoptionApply(targetPlayer, commandSender, parsedPlanId, binding)); + } catch (RuntimeException exception) { + ADOPTION_PLANS.putIfAbsent(parsedPlanId, binding); + Iris.reportError("Failed to schedule Jigsaw adoption plan '" + parsedPlanId + "'.", exception); + sendError(commandSender, "Jigsaw adoption could not be scheduled: " + exception.getMessage()); + } + } + } + + @Director(name = "connector", description = "Inspect and configure targeted jigsaw connectors", + origin = DirectorOrigin.PLAYER) + public static class CommandJigsawConnector implements DirectorExecutor { + @Director(description = "Set the Iris-only channel on the targeted jigsaw marker", sync = true, + origin = DirectorOrigin.PLAYER) + public void channel( + @Param(description = "Exact channel, or none to clear") String channel + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + Block target = player().getTargetBlockExact(8); + if (target == null || !(target.getBlockData() instanceof Jigsaw)) { + sendError(this, "Look directly at a jigsaw marker within eight blocks."); + return; + } + JigsawStudioBay bay = context.session().layout().findAt( + target.getX(), target.getY(), target.getZ()); + JigsawStudioVariant activeVariant = bay == null + ? null + : context.session().activeVariant(bay.stableId()).orElse(null); + if (activeVariant == null) { + sendError(this, "The targeted marker must be inside a workcell with an active variant."); + return; + } + if (!activeVariant.owned()) { + sendError(this, "The active variant is read-only. Adopt it into this project before editing it."); + return; + } + IrisObject object = context.request().source().getObjectLoader().load(activeVariant.objectKey()); + if (object == null) { + sendError(this, "The active variant object '" + activeVariant.objectKey() + "' is missing."); + return; + } + IrisPosition canonicalPosition = new IrisPosition( + target.getX() - bay.bounds().originX(), + target.getY() - bay.bounds().originY(), + target.getZ() - bay.bounds().originZ()); + IrisPosition position; + try { + position = activeVariant.canonicalToSourcePosition( + canonicalPosition, + new JigsawStudioCellDimensions(object.getW(), object.getH(), object.getD())); + } catch (IllegalArgumentException exception) { + sendError(this, "Connector channel update failed: " + exception.getMessage()); + return; + } + String normalized = channel == null || channel.isBlank() || "none".equalsIgnoreCase(channel) + ? "none" + : channel.trim(); + runGraphMutation(player(), context, () -> { + JigsawStudioGraphEditor.updateConnectorChannel( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + activeVariant.pieceKey(), + position, + channel); + return mappedMutationResult( + context, + "", + "", + "Persisted connector channel '" + normalized + "' on piece '" + + activeVariant.pieceKey() + "' at " + position.getX() + "," + position.getY() + + "," + position.getZ() + "."); + }); + } + } + + @Director(name = "pool", description = "Manage owned jigsaw pools", origin = DirectorOrigin.PLAYER) + public static class CommandJigsawPool implements DirectorExecutor { + @Director(description = "Create an empty owned jigsaw pool", sync = true, + origin = DirectorOrigin.PLAYER) + public void create( + @Param(description = "New owned pool resource key") String poolKey, + @Param(description = "Owned fallback pool key, or none", defaultValue = "none") + String fallbackPoolKey + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey; + runGraphMutation(player(), context, () -> { + JigsawStudioGraphEditor.createPool( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + fallback); + return mappedMutationResult( + context, + "", + "", + "Created owned pool '" + poolKey + "' with fallback '" + + (fallback.isBlank() ? "none" : fallback) + "'."); + }); + } + } + + @Director(name = "piece", description = "Manage contextual Studio pieces", origin = DirectorOrigin.PLAYER) + public static class CommandJigsawPiece implements DirectorExecutor { + @Director(description = "Create an owned piece from the selected Studio topology", sync = true, + origin = DirectorOrigin.PLAYER) + public void create( + @Param(description = "Owned pool resource key") String poolKey, + @Param(description = "New owned piece and object key") String pieceKey, + @Param(description = "Positive selection weight", defaultValue = "1") int weight + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawPlanarTopology topology = null; + JigsawStudioBay targetWorkcell = context.session().layout().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID); + if (context.session().layout().mode() == JigsawStudioMode.PLANAR_JIGSAW) { + Location location = player().getLocation(); + JigsawStudioBay contextual = context.session().layout().findAt( + location.getBlockX(), location.getBlockY(), location.getBlockZ()); + if (contextual == null) { + contextual = selectedBay(context); + } else { + context.session().selectBay(contextual.stableId()); + } + if (contextual == null || contextual.topology().isEmpty()) { + sendError(this, "Stand in or select a planar topology workcell before creating a variant."); + return; + } + topology = contextual.topology().get(); + targetWorkcell = contextual; + } + if (targetWorkcell == null) { + sendError(this, "The selected Jigsaw Studio workcell is no longer available."); + return; + } + JigsawPlanarTopology selectedTopology = topology; + String activationWorkcellId = targetWorkcell.stableId(); + JigsawStudioCellDimensions dimensions = targetWorkcell.capacity(); + String topologyText = selectedTopology == null ? "blank spatial" : selectedTopology.name(); + runGraphMutation(player(), context, () -> { + JigsawStudioGraphEditor.createPiece( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + pieceKey, + weight, + dimensions, + selectedTopology); + return mappedMutationResult( + context, + activationWorkcellId, + pieceKey, + "Created owned " + topologyText + " piece '" + pieceKey + + "' and added it to pool '" + poolKey + "' atomically."); + }); + } + + @Director(description = "Add an existing jigsaw piece to an owned pool transactionally", sync = true, + origin = DirectorOrigin.PLAYER) + public void add( + @Param(description = "Owned pool resource key") String poolKey, + @Param(description = "Existing jigsaw piece key") String pieceKey, + @Param(description = "Positive selection weight", defaultValue = "1") int weight + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + IrisJigsawPiece piece = context.request().source().getJigsawPieceLoader().load(pieceKey); + if (piece == null) { + sendError(this, "No jigsaw piece '" + pieceKey + "' exists in this pack."); + return; + } + if (piece.getObject() == null || piece.getObject().isBlank()) { + sendError(this, "Jigsaw piece '" + pieceKey + "' does not declare an object."); + return; + } + IrisObject object = context.request().source().getObjectLoader().load(piece.getObject()); + if (object == null) { + sendError(this, "Jigsaw piece '" + pieceKey + "' has no loadable object."); + return; + } + PieceWorkcellResolution resolution; + try { + resolution = resolvePieceWorkcell(context.session().layout(), piece, object); + } catch (IllegalArgumentException exception) { + sendError(this, "Jigsaw piece '" + pieceKey + "' cannot be assigned to a Studio workcell: " + + exception.getMessage()); + return; + } + if (!resolution.fits()) { + IrisPosition required = resolution.requiredDimensions(); + JigsawStudioCellDimensions capacity = resolution.workcell().capacity(); + sendError(this, "Jigsaw piece '" + pieceKey + "' requires " + required.getX() + "x" + + required.getY() + "x" + required.getZ() + " in " + resolution.workcell().displayName() + + ", but that workcell capacity is " + capacity.width() + "x" + capacity.height() + + "x" + capacity.depth() + "."); + return; + } + String objectKey = piece.getObject(); + runGraphMutation(player(), context, () -> { + if (!JigsawStudioGraphEditor.ownsPiece( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + pieceKey, + objectKey)) { + throw new IOException("Piece add accepts only piece/object resources already owned by this project; " + + "use piece create for a new owned piece"); + } + JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.addPiece( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + pieceKey, + weight); + if (!update.changed()) { + throw new IOException("The owned pool is missing or already references '" + pieceKey + "'"); + } + JigsawStudioLayout layout = loadPersistedLayout(context); + String activationWorkcellId = workcellIdForVariant(layout, pieceKey); + return new JigsawStudioService.CommandGraphMutationResult( + layout, + activationWorkcellId, + pieceKey, + "Added '" + pieceKey + "' to pool '" + poolKey + "' in one graph transaction."); + }); + } + + @Director(description = "Remove the selected piece from an owned pool transactionally", sync = true, + origin = DirectorOrigin.PLAYER) + public void remove( + @Param(description = "Owned pool resource key") String poolKey + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioVariant selected = selectedVariant(context); + if (selected == null) { + sendError(this, "Select a workcell with an active variant before removing its pool entry."); + return; + } + String pieceKey = selected.pieceKey(); + runGraphMutation(player(), context, () -> { + JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.removePiece( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + pieceKey); + if (!update.changed()) { + throw new IOException("The owned pool does not reference selected piece '" + pieceKey + "'"); + } + return mappedMutationResult( + context, + "", + "", + "Removed '" + pieceKey + "' from pool '" + poolKey + "' in one graph transaction."); + }); + } + + @Director(description = "Allow or forbid cardinal rotation of the selected piece", sync = true, + origin = DirectorOrigin.PLAYER) + public void rotatable( + @Param(description = "Whether the selected piece may rotate") boolean rotatable + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioVariant selected = selectedVariant(context); + if (selected == null) { + sendError(this, "Select a workcell with an active variant before changing rotation."); + return; + } + if (!rotatable + && context.request().compatibilityTarget() + == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) { + sendError(this, "Vanilla-portable pieces must remain rotatable."); + return; + } + String pieceKey = selected.pieceKey(); + runGraphMutation(player(), context, () -> { + JigsawStudioGraphEditor.updateRotatable( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + pieceKey, + rotatable); + return mappedMutationResult( + context, + "", + "", + "Persisted piece '" + pieceKey + "' rotatable=" + rotatable + "."); + }); + } + + @Director(description = "Resize the selected piece object exactly to workcell capacity", sync = true, + origin = DirectorOrigin.PLAYER) + public void expand() { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay workcell = selectedBay(context); + if (workcell == null) { + sendError(this, "Select a workcell before expanding its active variant."); + return; + } + JigsawStudioService.get().expandVariantToCell(player(), workcell.stableId()); + } + } + + @Director(name = "workcell", aliases = "cell", description = "Manage the selected Studio workcell", + origin = DirectorOrigin.PLAYER) + public static class CommandJigsawWorkcell implements DirectorExecutor { + @Director(description = "Set capacity without resizing any variant object", sync = true, + origin = DirectorOrigin.PLAYER) + public void capacity( + @Param(description = "Capacity width") int width, + @Param(description = "Capacity height") int height, + @Param(description = "Capacity depth") int depth + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selected = selectedBay(context); + if (selected == null) { + sendError(this, "Select a workcell before changing its capacity."); + return; + } + JigsawStudioCellDimensions dimensions; + try { + dimensions = new JigsawStudioCellDimensions(width, height, depth); + } catch (IllegalArgumentException exception) { + sendError(this, exception.getMessage()); + return; + } + JigsawStudioService.get().updateWorkcellDimensions( + player(), + selected.stableId(), + dimensions); + } + + @Director(description = "Set the selected workcell's author-facing label", sync = true, + origin = DirectorOrigin.PLAYER) + public void label( + @Param(description = "Display label; quote labels containing spaces") String displayName + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selected = selectedBay(context); + if (selected == null) { + sendError(this, "Select a workcell before renaming it."); + return; + } + JigsawStudioService.get().updateWorkcellDisplayName( + player(), + selected.stableId(), + displayName); + } + + @Director(name = "label-reset", aliases = "reset-label", + description = "Reset the selected workcell to its canonical solver label", sync = true, + origin = DirectorOrigin.PLAYER) + public void labelReset() { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selected = selectedBay(context); + if (selected == null) { + sendError(this, "Select a workcell before resetting its label."); + return; + } + JigsawStudioService.get().updateWorkcellDisplayName(player(), selected.stableId(), ""); + } + } + + @Director(name = "variant", description = "Manage selected jigsaw variants", origin = DirectorOrigin.PLAYER) + public static class CommandJigsawVariant implements DirectorExecutor { + @Director(description = "Set the selected piece weight in a pool", sync = true, + origin = DirectorOrigin.PLAYER) + public void weight( + @Param(description = "Pool resource key") String poolKey, + @Param(description = "Positive selection weight") int weight + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioVariant selected = selectedVariant(context); + if (selected == null) { + sendError(this, "Select a workcell with an active variant before changing its weight."); + return; + } + String pieceKey = selected.pieceKey(); + runGraphMutation(player(), context, () -> { + JigsawStudioPoolEditor.WeightUpdate update = JigsawStudioPoolEditor.updateWeight( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + pieceKey, + weight); + if (!update.changed()) { + throw new IOException("Pool '" + poolKey + "' does not reference selected piece '" + + pieceKey + "'"); + } + return mappedMutationResult( + context, + "", + "", + "Updated " + update.changedEntries() + " pool entry weight atomically in " + + update.poolPath() + "."); + }); + } + + @Director(description = "Resize only the active variant object within workcell capacity", sync = true, + origin = DirectorOrigin.PLAYER) + public void resize( + @Param(description = "Variant width") int width, + @Param(description = "Variant height") int height, + @Param(description = "Variant depth") int depth + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selectedWorkcell = selectedBay(context); + JigsawStudioVariant selectedVariant = selectedVariant(context); + if (selectedWorkcell == null || selectedVariant == null) { + sendError(this, "Select a workcell with an active variant before resizing it."); + return; + } + JigsawStudioCellDimensions dimensions; + try { + dimensions = new JigsawStudioCellDimensions(width, height, depth); + } catch (IllegalArgumentException exception) { + sendError(this, exception.getMessage()); + return; + } + JigsawStudioService.get().resizeVariant( + player(), + selectedWorkcell.stableId(), + selectedVariant.pieceKey(), + dimensions); + } + + @Director(description = "Set the active variant's author-facing label", sync = true, + origin = DirectorOrigin.PLAYER) + public void label( + @Param(description = "Display label; quote labels containing spaces") String displayName + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selectedWorkcell = selectedBay(context); + JigsawStudioVariant selectedVariant = selectedVariant(context); + if (selectedWorkcell == null || selectedVariant == null) { + sendError(this, "Select a workcell with an active variant before renaming it."); + return; + } + JigsawStudioService.get().updateVariantDisplayName( + player(), + selectedWorkcell.stableId(), + selectedVariant.pieceKey(), + displayName); + } + + @Director(name = "label-reset", aliases = "reset-label", + description = "Reset the active variant to its resource-key label", sync = true, + origin = DirectorOrigin.PLAYER) + public void labelReset() { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selectedWorkcell = selectedBay(context); + JigsawStudioVariant selectedVariant = selectedVariant(context); + if (selectedWorkcell == null || selectedVariant == null) { + sendError(this, "Select a workcell with an active variant before resetting its label."); + return; + } + JigsawStudioService.get().updateVariantDisplayName( + player(), + selectedWorkcell.stableId(), + selectedVariant.pieceKey(), + ""); + } + + @Director(description = "Duplicate the active variant in only this workcell", sync = true, + origin = DirectorOrigin.PLAYER) + public void duplicate() { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + JigsawStudioBay selected = selectedBay(context); + if (selected == null || selectedVariant(context) == null) { + sendError(this, "Select a workcell with an active variant before duplicating it."); + return; + } + JigsawStudioService.get().createVariant(player(), selected.stableId(), true); + } + + @Director(name = "duplicate-family", aliases = "family", + description = "Duplicate every enabled workcell's active variant as one coherent family", + sync = true, origin = DirectorOrigin.PLAYER) + public void duplicateFamily( + @Param(description = "Family key or next", defaultValue = "next") String themeKey + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + String selectedTheme = themeKey; + if ("next".equalsIgnoreCase(themeKey)) { + try { + selectedTheme = nextThemeKey(context); + } catch (IOException | ArithmeticException exception) { + sendError(this, exception.getMessage()); + return; + } + } + JigsawStudioService.get().duplicateActiveFamily(player(), selectedTheme); + } + } + + @Director(name = "rules", aliases = "rule", description = "Manage structure-wide jigsaw rules", + origin = DirectorOrigin.PLAYER) + public static class CommandJigsawRules implements DirectorExecutor { + @Director(description = "Set maximum expansion depth and horizontal radius", sync = true, + origin = DirectorOrigin.PLAYER) + public void limits( + @Param(description = "Maximum expansion depth") int maxDepth, + @Param(description = "Maximum radius in chunks") int maxSizeChunks + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + if (context.request().compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + && (maxDepth > 20 || maxSizeChunks > 8)) { + sendError(this, "Vanilla-portable rules require maxDepth <= 20 and maxSizeChunks <= 8."); + return; + } + runGraphMutation(player(), context, () -> { + JigsawStudioStructureEditor.updateLimits( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + maxDepth, + maxSizeChunks); + return mappedMutationResult( + context, + "", + "", + "Persisted jigsaw limits: maxDepth=" + maxDepth + + ", maxSizeChunks=" + maxSizeChunks + "."); + }); + } + + @Director(description = "Set or clear one owned pool's direct fallback", sync = true, + origin = DirectorOrigin.PLAYER) + public void fallback( + @Param(description = "Owned pool resource key") String poolKey, + @Param(description = "Owned fallback pool key, or none") String fallbackPoolKey + ) { + ActiveContext context = active(player()); + if (context == null) { + sendError(this, "Open a Jigsaw Studio project first."); + return; + } + String fallback = "none".equalsIgnoreCase(fallbackPoolKey) ? "" : fallbackPoolKey; + runGraphMutation(player(), context, () -> { + JigsawStudioPoolEditor.PoolUpdate update = JigsawStudioPoolEditor.updateFallback( + context.request().source().getDataFolder().toPath(), + context.request().structureKey(), + poolKey, + fallback); + if (!update.changed()) { + return mappedMutationResult(context, "", "", "Pool fallback is unchanged."); + } + return mappedMutationResult( + context, + "", + "", + "Persisted pool '" + poolKey + "' fallback as '" + + (fallback.isBlank() ? "none" : fallback) + "'."); + }); + } + } + + @Director(name = "preview", description = "Assemble a read-only jigsaw preview", + origin = DirectorOrigin.PLAYER) + public static class CommandJigsawPreview implements DirectorExecutor { + @Director(name = "goto", aliases = "teleport", description = "Teleport to the live seed-1337 preview", + sync = true, origin = DirectorOrigin.PLAYER) + public void gotoPreview() { + JigsawStudioService.get().goToPreview(player()); + } + + @Director(description = "Assemble a deterministic preview at your location", sync = true, + origin = DirectorOrigin.PLAYER) + public void assemble( + @Param(description = "Assembly seed", defaultValue = "1337") long seed + ) { + JigsawStudioService.get().previewStudio(player(), seed); + } + + } +} 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 2af7c9ca1..2a95e8a6e 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 @@ -182,8 +182,7 @@ public class CommandObject implements DirectorExecutor { private static final Set skipBlocks = Set.of(Materials.GRASS, Material.SNOW, Material.VINE, Material.TORCH, Material.DEAD_BUSH, Material.POPPY, Material.DANDELION); - public static IObjectPlacer createPlacer(World world, Map futureBlockChanges) { - + public static IObjectPlacer createPlacer(World world, Map futureBlockChanges, Engine targetEngine) { return new IObjectPlacer() { @Override public int getHighest(int x, int z, IrisData data) { @@ -263,7 +262,7 @@ public class CommandObject implements DirectorExecutor { @Override public Engine getEngine() { - return null; + return targetEngine; } }; } @@ -565,7 +564,7 @@ public class CommandObject implements DirectorExecutor { // Block writes must run on the thread owning the target chunk; the undo log stays global. final IrisObject placed = o; if (!J.runAt(block, () -> { - placed.place(block.getBlockX(), block.getBlockY() + (int) placed.getCenter().getY(), block.getBlockZ(), createPlacer(block.getWorld(), futureChanges), placement, new RNG(), null); + placed.place(block.getBlockX(), block.getBlockY() + (int) placed.getCenter().getY(), block.getBlockZ(), createPlacer(block.getWorld(), futureChanges, null), placement, new RNG(), null); J.runGlobal(() -> Iris.service(ObjectSVC.class).addChanges(futureChanges)); if (!edit) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java index 84d4a78dd..cfed437fd 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java @@ -30,6 +30,7 @@ import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructureAssembler; import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisNativeStructureDecision; @@ -61,6 +62,7 @@ import org.bukkit.generator.structure.Structure; import java.io.File; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -99,14 +101,15 @@ public class CommandStructure implements DirectorExecutor { BulkStructureImporter.Report jigsaws = BulkStructureImporter.importAllVanilla(data, StructureImporter.Mode.OVERWRITE, true, sender()); BulkStructureImporter.Report templates = BulkStructureImporter.importAllTemplates(data, StructureImporter.Mode.OVERWRITE, sender()); BulkStructureImporter.Report groups = BulkStructureImporter.importTemplateGroups(data, StructureImporter.Mode.OVERWRITE, sender()); - StructureCaptureImporter.Report captured = StructureCaptureImporter.importAllStructures(data, StructureImporter.Mode.OVERWRITE, sender()); + StructureCaptureImporter.Report captured = StructureCaptureImporter.importStructures( + data, StructureImporter.Mode.OVERWRITE, sender(), jigsaws.captureCandidates()); int imported = jigsaws.imported() + templates.imported() + groups.imported() + captured.imported(); int failed = jigsaws.failed() + templates.failed() + groups.failed() + captured.failed(); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STRUCTURE_IMPORT_COMPLETE_STRUCTURES_OBJECTS_WRITTEN_FAILED, MessageArgument.untrusted("imported", imported), MessageArgument.untrusted("failed", failed))); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STRUCTURE_REFERENCE_THEM_FROM_BIOME_REGION_DIMENSION_STRUCTURES_LIST_RUN_IRIS, MessageArgument.untrusted("value", dimension.getLoadKey()))); } - @Director(name = "capture", description = "Capture code-generated structures that have no NBT template (swamp huts, igloos, etc.) into editable Iris objects by generating each one in a throwaway scratch world and reading back its blocks. Skips structures that already import as a structure, structures wider/taller than the capture cap (strongholds, mansions, monuments stay vanilla), and anything that will not generate in a flat overworld. Each captured structure becomes a single-piece Iris structure you can place from a biome/region/dimension 'structures' list. Runs automatically as the last pass of /iris structure import.", descriptionKey = "iris.director.commandstructure.director.capture_code_generated_structures_that_have_no_nbt_template_swamp_huts_igloos", aliases = {"cap"}, origin = DirectorOrigin.BOTH) + @Director(name = "capture", description = "Capture live registered structures into editable Iris objects by generating each one in a throwaway scratch world and reading back its blocks. This standalone command overwrites its owned outputs; structures wider/taller than the capture cap and anything that will not generate in a flat overworld are skipped. Each captured structure becomes a single-piece Iris structure you can place from a biome/region/dimension 'structures' list. /iris structure import runs a restricted final capture pass only for non-jigsaw structures that have no loadable NBT template.", descriptionKey = "iris.director.commandstructure.director.capture_code_generated_structures_that_have_no_nbt_template_swamp_huts_igloos", aliases = {"cap"}, origin = DirectorOrigin.BOTH) public void capture( @Param(description = "The dimension whose pack to capture into", descriptionKey = "iris.director.commandstructure.param.dimension_whose_pack_capture_into", aliases = "dim") IrisDimension dimension @@ -305,8 +308,9 @@ public class CommandStructure implements DirectorExecutor { } StructureAssembler assembler = StructureAssembler.forData( data, s, new IrisPosition(0, 64, 0)); - KList pieces = assembler.assemble(new RNG(1234)); - if (pieces == null || pieces.isEmpty()) { + StructureAssemblyResult assembly = assembler.assemble(new RNG(1234)); + List pieces = assembly.pieces(); + if (!assembly.hasOutput()) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STRUCTURE_STRUCTURE_ASSEMBLED_0_PIECES_CHECK_STARTPOOL, MessageArgument.untrusted("structure", structure), MessageArgument.untrusted("value", s.getStartPool()))); return; } @@ -344,13 +348,17 @@ public class CommandStructure implements DirectorExecutor { StructureAssembler assembler = StructureAssembler.forData( data, s, new IrisPosition(loc.getBlockX(), loc.getBlockY(), loc.getBlockZ())); RNG rng = new RNG((long) loc.getBlockX() * 341873128712L + loc.getBlockZ()); - KList pieces = assembler.assemble(rng); - if (pieces == null || pieces.isEmpty()) { + StructureAssemblyResult assembly = assembler.assemble(rng); + List pieces = assembly.pieces(); + if (!assembly.hasOutput()) { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STRUCTURE_STRUCTURE_ASSEMBLED_0_PIECES, MessageArgument.untrusted("structure", structure))); return; } Map future = new HashMap<>(); - IObjectPlacer placer = CommandObject.createPlacer(player().getWorld(), future); + World targetWorld = player().getWorld(); + PlatformChunkGenerator targetGenerator = IrisToolbelt.access(targetWorld); + Engine targetEngine = targetGenerator == null ? null : targetGenerator.getEngine(); + IObjectPlacer placer = CommandObject.createPlacer(targetWorld, future, targetEngine); for (PlacedStructurePiece p : pieces) { IrisObjectPlacement config = new IrisObjectPlacement(); config.setMode(ObjectPlaceMode.STRUCTURE_PIECE); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java index ae72e1ad3..ad40767dc 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java @@ -67,6 +67,7 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D private static final String ROOT_PERMISSION = "iris.all"; private final transient AtomicCache directorCache = new AtomicCache<>(); + private final transient ThreadLocal dispatchSenders = new ThreadLocal<>(); @Override public void onEnable() { @@ -120,11 +121,18 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D } private void dispatchDirector(DirectorExecutionMode mode, Runnable runnable) { - if (mode == DirectorExecutionMode.SYNC) { - J.s(runnable); - } else { + if (mode != DirectorExecutionMode.SYNC) { runnable.run(); + return; } + CommandSender sender = dispatchSenders.get(); + if (sender instanceof Player player) { + if (!J.runEntity(player, runnable)) { + throw new IllegalStateException("Failed to schedule player command on its entity thread"); + } + return; + } + J.s(runnable); } @Override @@ -241,11 +249,14 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D } private DirectorExecutionResult runDirector(CommandSender sender, String label, String[] args) { + dispatchSenders.set(sender); try { return getDirector().execute(new DirectorInvocation(new BukkitDirectorSender(sender), label, Arrays.asList(args))); } catch (Throwable e) { Iris.warn("Director command execution failed: " + e.getClass().getSimpleName() + " " + e.getMessage()); return DirectorExecutionResult.notHandled(); + } finally { + dispatchSenders.remove(); } } @@ -267,7 +278,8 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D } if (sender instanceof Player player) { - J.s(() -> playSounds(player, Sound.BLOCK_AMETHYST_CLUSTER_BREAK, 0.77f, 0.25f, Sound.BLOCK_BEACON_DEACTIVATE, 0.2f, 0.45f)); + J.runEntity(player, () -> playSounds(player, Sound.BLOCK_AMETHYST_CLUSTER_BREAK, 0.77f, 0.25f, + Sound.BLOCK_BEACON_DEACTIVATE, 0.2f, 0.45f)); } } @@ -277,7 +289,8 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D } if (sender instanceof Player player) { - J.s(() -> playSounds(player, Sound.BLOCK_AMETHYST_CLUSTER_BREAK, 0.77f, 1.65f, Sound.BLOCK_RESPAWN_ANCHOR_CHARGE, 0.125f, 2.99f)); + J.runEntity(player, () -> playSounds(player, Sound.BLOCK_AMETHYST_CLUSTER_BREAK, 0.77f, 1.65f, + Sound.BLOCK_RESPAWN_ANCHOR_CHARGE, 0.125f, 2.99f)); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java new file mode 100644 index 000000000..d9a5c0ecf --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/DatapackStructureScopeSVC.java @@ -0,0 +1,91 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.datapack.DatapackIngestService; +import art.arcane.iris.core.datapack.DatapackStructureScopeIndex; +import art.arcane.iris.core.nms.DatapackStructureScopeResult; +import art.arcane.iris.core.nms.INMS; +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.spi.IrisLogging; +import art.arcane.iris.util.common.plugin.IrisService; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.world.WorldInitEvent; +import org.bukkit.event.world.WorldUnloadEvent; + +import java.io.IOException; +import java.util.Set; + +public final class DatapackStructureScopeSVC implements IrisService { + private DatapackStructureScopeIndex scopeIndex = DatapackStructureScopeIndex.create(null); + + @Override + public void onEnable() { + try { + scopeIndex = DatapackStructureScopeIndex.create( + DatapackIngestService.installedStructureScopeResources()); + } catch (IOException e) { + throw new IllegalStateException( + "Iris could not establish ownership for installed datapack structure sets", e); + } + if (scopeIndex.isEmpty()) { + return; + } + for (World world : Bukkit.getWorlds()) { + applyScope(world); + } + } + + @Override + public void onDisable() { + for (World world : Bukkit.getWorlds()) { + INMS.get().abandonStudioStructureBootstrap(world); + } + scopeIndex = DatapackStructureScopeIndex.create(null); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onWorldInit(WorldInitEvent event) { + boolean studioEntryBootstrap = event.getWorld().getGenerator() + instanceof BukkitChunkGenerator generator + && generator.isStudioEntryBootstrapActive(); + if (shouldApplyScope(scopeIndex.isEmpty(), studioEntryBootstrap)) { + applyScope(event.getWorld()); + } + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onWorldUnload(WorldUnloadEvent event) { + INMS.get().abandonStudioStructureBootstrap(event.getWorld()); + } + + static boolean shouldApplyScope(boolean scopeIndexEmpty, boolean studioEntryBootstrap) { + return !scopeIndexEmpty || studioEntryBootstrap; + } + + private void applyScope(World world) { + Set declaredSources = declaredSources(world); + try { + DatapackStructureScopeResult result = INMS.get().scopeDatapackStructures( + world, scopeIndex, declaredSources); + IrisLogging.info("Scoped Iris-managed datapack structure sets for world '" + + world.getName() + "': " + result.retainedManagedSets() + " retained, " + + result.excludedManagedSets() + " excluded."); + } catch (Throwable error) { + throw new IllegalStateException("Could not scope Iris-managed datapack structure sets for world '" + + world.getName() + "'", error); + } + } + + private Set declaredSources(World world) { + PlatformChunkGenerator generator = IrisToolbelt.access(world); + if (generator == null) { + return Set.of(); + } + return scopeIndex.declaredSources( + generator.getTarget().getDimension().getDatapackImports()); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java index 3c0960193..87e7515eb 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.service; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.lifecycle.WorldUnloadBoundaryRegistry; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.pregenerator.MantleHeapPressure; import art.arcane.iris.core.tools.IrisToolbelt; @@ -36,6 +37,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.RejectedExecutionException; @@ -157,7 +159,10 @@ public final class IrisEngineSVC implements IrisService { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onWorldUnload(WorldUnloadEvent event) { - remove(event.getWorld()); + World world = event.getWorld(); + CompletionStage unloadBoundary = WorldUnloadBoundaryRegistry.claim( + WorldIdentity.serialize(world)); + remove(world, unloadBoundary); } @EventHandler @@ -230,7 +235,7 @@ public final class IrisEngineSVC implements IrisService { } } - private void remove(World world) { + private void remove(World world, CompletionStage unloadBoundary) { if (world == null) { return; } @@ -247,10 +252,53 @@ public final class IrisEngineSVC implements IrisService { } if (closing != null) { phases.closing(world); - startClose(registered, closing); + deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary); } } + private void deferCloseUntilWorldUnload( + World world, + Registered registered, + ClosingGenerator closing, + CompletionStage unloadBoundary + ) { + if (unloadBoundary == null) { + J.sfut(() -> startClose(registered, closing), 1) + .whenComplete((ignored, failure) -> { + if (failure != null) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + reportFailure("Failed to defer generator close for " + registered.name(), cause); + completeClose(closing, cause); + } + }); + return; + } + unloadBoundary.whenComplete((unloaded, failure) -> { + if (failure == null && Boolean.TRUE.equals(unloaded)) { + startClose(registered, closing); + return; + } + abandonClose(world, closing); + }); + } + + private void abandonClose(World world, ClosingGenerator closing) { + synchronized (registrationLock) { + closingGenerators.remove(closing); + } + closing.completion().complete(null); + J.sfut(() -> { + if (isCurrentWorld(world)) { + add(world); + } + }, 1).whenComplete((ignored, failure) -> { + if (failure != null) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + reportFailure("Failed to restore generator maintenance for " + world.getName(), cause); + } + }); + } + private ClosingGenerator reserveClose(Registered registered) { ClosingGenerator closing = new ClosingGenerator( registered.registrationIdentity(), diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java index 420be0cb4..9cf089b32 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java @@ -173,4 +173,37 @@ public class PendingWorldDeleteQueueTest { levelRoot.toPath().resolve("dimensions/iris/alpha_the_end").toAbsolutePath() ), family); } + + @Test + public void failedSafeDeletionSignalsQueueRetentionAndSucceedsOnRetry() throws IOException { + File levelRoot = temporaryFolder.newFolder("retry-world"); + Path quarantine = levelRoot.toPath().resolve("dimensions/iris").resolve(QUARANTINE_NAME); + Files.createDirectories(quarantine); + Path external = temporaryFolder.newFolder("retry-external").toPath(); + Path link = Files.createSymbolicLink(quarantine.resolve("unsafe-link"), external); + + PendingWorldDeleteQueue.EntryDeletionResult first = PendingWorldDeleteQueue.attemptEntry( + levelRoot, + QUARANTINE_NAME, + (key, path) -> false + ); + + assertTrue(first.retainQueueEntry()); + assertEquals(1, first.failures().size()); + assertTrue(Files.isSymbolicLink(link)); + assertTrue(Files.exists(external)); + + Files.delete(link); + Files.writeString(quarantine.resolve("safe.dat"), "safe"); + + PendingWorldDeleteQueue.EntryDeletionResult retry = PendingWorldDeleteQueue.attemptEntry( + levelRoot, + QUARANTINE_NAME, + (key, path) -> false + ); + + assertFalse(retry.retainQueueEntry()); + assertTrue(retry.failures().isEmpty()); + assertFalse(Files.exists(quarantine)); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java new file mode 100644 index 000000000..499e9a477 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandJigsawContractTest.java @@ -0,0 +1,589 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.runtime.StudioOpenCoordinator; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioWorkcellSpec; +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.conversion.IrisStructureAdoptionInputKind; +import art.arcane.iris.core.structure.export.VanillaJigsawExportFormat; +import art.arcane.iris.core.tools.IrisCreator; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.volmlib.util.director.DirectorOrigin; +import art.arcane.volmlib.util.director.annotations.Director; +import art.arcane.volmlib.util.director.annotations.Param; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class CommandJigsawContractTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void commandIrisRegistersJigsawTree() throws Exception { + Field field = CommandIris.class.getDeclaredField("jigsaw"); + + assertEquals(CommandJigsaw.class, field.getType()); + assertNotNull(CommandJigsaw.class.getDeclaredField("piece")); + assertNotNull(CommandJigsaw.class.getDeclaredField("pool")); + assertNotNull(CommandJigsaw.class.getDeclaredField("connector")); + assertNotNull(CommandJigsaw.class.getDeclaredField("variant")); + assertNotNull(CommandJigsaw.class.getDeclaredField("workcell")); + assertNotNull(CommandJigsaw.class.getDeclaredField("rules")); + assertNotNull(CommandJigsaw.class.getDeclaredField("preview")); + assertNotNull(CommandJigsaw.class.getDeclaredField("adopt")); + } + + @Test + public void exposesStudioLifecycleAndAuthoringCommands() throws Exception { + assertCommand("create", IrisDimension.class, String.class, String.class, String.class, + int.class, int.class, int.class, long.class); + assertCommand("convert", IrisDimension.class, String.class, String.class, long.class); + assertCommand("open", IrisDimension.class, String.class, long.class); + assertCommand("close", boolean.class); + assertCommand("delete", boolean.class); + assertCommand("status"); + assertCommand("menu"); + assertCommand("select"); + assertCommand("bounds", int.class, int.class, int.class); + assertCommand("save", String.class); + assertCommand("gotoBay", String.class); + assertCommand("particles", boolean.class); + assertCommand("export", String.class, String.class, String.class, boolean.class); + } + + @Test + public void jigsawStudioUsesItsDedicatedOpenLifecycle() { + assertEquals( + StudioOpenCoordinator.StudioOpenKind.JIGSAW, + CommandJigsaw.STUDIO_OPEN_KIND); + assertFalse(CommandJigsaw.STUDIO_OPEN_KIND.openWorkspace()); + assertFalse(CommandJigsaw.STUDIO_OPEN_KIND.teleportThroughStandardEntry()); + assertEquals( + IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY, + CommandJigsaw.STUDIO_OPEN_KIND.datapackPreparation()); + } + + @Test + public void committedActivationStartsInitialEvaluationBeforePlayerBinding() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")); + int commit = source.indexOf("JigsawStudioActivation.commit(staged)"); + int evaluation = source.indexOf( + "studioService.activationCommitted(world, request.requestId())", + commit); + int binding = source.indexOf("PLAYER_PACKS.put", evaluation); + + assertTrue(commit >= 0); + assertTrue(evaluation > commit); + assertTrue(binding > evaluation); + } + + @Test + public void convertIsAddOnlyWorkflowWithAliasesAndDefaults() throws Exception { + Method convert = CommandJigsaw.class.getDeclaredMethod( + "convert", IrisDimension.class, String.class, String.class, long.class); + Director command = convert.getAnnotation(Director.class); + Parameter[] parameters = convert.getParameters(); + + assertEquals(List.of("import", "import-vanilla"), List.of(command.aliases())); + assertParameter(parameters[2], "auto", null); + assertParameter(parameters[3], "1337", null); + NamespacedKey source = CommandJigsaw.parseRegisteredStructureKey("minecraft:village_plains"); + assertEquals("minecraft_village_plains", CommandJigsaw.resolveConversionTarget(source, "auto")); + assertEquals("villages/plains", CommandJigsaw.resolveConversionTarget(source, "iris:villages/plains")); + assertThrows(IllegalArgumentException.class, + () -> CommandJigsaw.resolveConversionTarget(source, "custom:village")); + } + + @Test + public void adoptionCommandsExposeTwoStepPlanContract() throws Exception { + Method inspect = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod( + "inspect", IrisDimension.class, String.class, String.class, String.class); + Method apply = CommandJigsaw.CommandJigsawAdopt.class.getDeclaredMethod("apply", String.class); + Parameter[] inspectParameters = inspect.getParameters(); + + assertNotNull(inspect.getAnnotation(Director.class)); + assertNotNull(apply.getAnnotation(Director.class)); + assertParameter(inspectParameters[2], "auto", null); + assertParameter(inspectParameters[3], "auto", CommandJigsaw.JigsawAdoptionStrategyHandler.class); + assertEquals(CommandJigsaw.JigsawAdoptionPlanHandler.class, + apply.getParameters()[0].getAnnotation(Param.class).customHandler()); + + CommandJigsaw.JigsawAdoptionStrategyHandler strategyHandler = + new CommandJigsaw.JigsawAdoptionStrategyHandler(); + assertEquals(List.of("auto", "in-place", "clone"), strategyHandler.getPossibilities()); + assertEquals("in-place", strategyHandler.parse("claim", false)); + assertEquals("clone", strategyHandler.parse("copy", false)); + assertThrows(DirectorParsingException.class, () -> strategyHandler.parse("overwrite", false)); + } + + @Test + public void adoptionInputKindUsesOnlyOwnershipProvenance() throws Exception { + Path root = temporaryFolder.newFolder("adoption-provenance").toPath(); + + assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS, + CommandJigsaw.adoptionInputKind(root, "unowned")); + writeManifest(root, "datapack-created", StructureSource.Kind.DATAPACK, + StructureOwnershipManifest.Provenance.created()); + assertEquals(IrisStructureAdoptionInputKind.UNOWNED_IRIS, + CommandJigsaw.adoptionInputKind(root, "datapack-created")); + writeManifest(root, "managed-provenance", StructureSource.Kind.IRIS, managedProvenance()); + assertEquals(IrisStructureAdoptionInputKind.MANAGED_DATAPACK, + CommandJigsaw.adoptionInputKind(root, "managed-provenance")); + } + + @Test + public void legacyGraphWritersShareTheServiceMutationContract() throws Exception { + Method mutation = CommandJigsaw.class.getDeclaredMethod( + "runGraphMutation", + Player.class, + CommandJigsaw.ActiveContext.class, + JigsawStudioService.CommandGraphMutation.class); + + assertEquals(boolean.class, mutation.getReturnType()); + assertNotNull(JigsawStudioService.CommandGraphMutation.class.getDeclaredMethod("run")); + assertNotNull(JigsawStudioService.CommandGraphMutationResult.class.getDeclaredConstructor( + JigsawStudioLayout.class, + String.class, + String.class, + String.class)); + } + + @Test + public void exposesAutosaveDynamicEvaluationAndSelectedWorkcellResize() throws Exception { + Method save = CommandJigsaw.class.getDeclaredMethod("save", String.class); + Method status = CommandJigsaw.class.getDeclaredMethod("status"); + Method bounds = CommandJigsaw.class.getDeclaredMethod( + "bounds", int.class, int.class, int.class); + + assertEquals("Flush the automatic save for a workcell now", + save.getAnnotation(Director.class).description()); + assertEquals("Show active Jigsaw Studio and dynamic evaluation state", + status.getAnnotation(Director.class).description()); + assertEquals("Set the selected Studio workcell capacity", + bounds.getAnnotation(Director.class).description()); + assertThrows(NoSuchMethodException.class, + () -> CommandJigsaw.class.getDeclaredMethod("validate")); + } + + @Test + public void createDefaultsToPlanarIrisWithCompleteCellAndSeedDefaults() throws Exception { + Method create = CommandJigsaw.class.getDeclaredMethod( + "create", + IrisDimension.class, + String.class, + String.class, + String.class, + int.class, + int.class, + int.class, + long.class); + Parameter[] parameters = create.getParameters(); + + assertParameter(parameters[2], "planar", CommandJigsaw.JigsawModeHandler.class); + assertParameter(parameters[3], "iris", CommandJigsaw.JigsawCompatibilityHandler.class); + assertParameter(parameters[4], "16", null); + assertParameter(parameters[5], "16", null); + assertParameter(parameters[6], "16", null); + assertParameter(parameters[7], "1337", null); + } + + @Test + public void structureKeysExplainTheirResourceAndOpenSupportsEditingAliases() throws Exception { + Method create = CommandJigsaw.class.getDeclaredMethod( + "create", + IrisDimension.class, + String.class, + String.class, + String.class, + int.class, + int.class, + int.class, + long.class); + Method open = CommandJigsaw.class.getDeclaredMethod( + "open", IrisDimension.class, String.class, long.class); + Param createKey = create.getParameters()[1].getAnnotation(Param.class); + Param openKey = open.getParameters()[1].getAnnotation(Param.class); + Director openCommand = open.getAnnotation(Director.class); + + assertEquals("key", createKey.name()); + assertEquals(List.of("structure", "name"), List.of(createKey.aliases())); + assertEquals("New key written as structures/.json", createKey.description()); + assertEquals("key", openKey.name()); + assertEquals(List.of("structure", "name"), List.of(openKey.aliases())); + assertEquals("Existing key loaded from structures/.json", openKey.description()); + assertEquals(List.of("edit", "reopen"), List.of(openCommand.aliases())); + } + + @Test + public void createChoiceHandlersExposeCanonicalCompletionsAndRetainAliases() throws Exception { + CommandJigsaw.JigsawModeHandler modeHandler = new CommandJigsaw.JigsawModeHandler(); + CommandJigsaw.JigsawCompatibilityHandler compatibilityHandler = + new CommandJigsaw.JigsawCompatibilityHandler(); + + assertEquals(List.of("planar", "spatial"), modeHandler.getPossibilities()); + assertEquals("planar", modeHandler.parse("2d", false)); + assertEquals("spatial", modeHandler.parse("3d", false)); + assertEquals(List.of("iris", "vanilla"), compatibilityHandler.getPossibilities()); + assertEquals("iris", compatibilityHandler.parse("extended", false)); + assertEquals("vanilla", compatibilityHandler.parse("portable", false)); + assertThrows(DirectorParsingException.class, () -> modeHandler.parse("volume", false)); + assertThrows(DirectorParsingException.class, () -> compatibilityHandler.parse("mixed", false)); + } + + @Test + public void exposesNestedPieceVariantAndPreviewCommands() throws Exception { + assertNotNull(CommandJigsaw.CommandJigsawPool.class + .getDeclaredMethod("create", String.class, String.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawConnector.class + .getDeclaredMethod("channel", String.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawPiece.class + .getDeclaredMethod("create", String.class, String.class, int.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawPiece.class + .getDeclaredMethod("add", String.class, String.class, int.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawPiece.class + .getDeclaredMethod("remove", String.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawPiece.class + .getDeclaredMethod("rotatable", boolean.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawPiece.class + .getDeclaredMethod("expand") + .getAnnotation(Director.class)); + assertEquals("Resize the selected piece object exactly to workcell capacity", + CommandJigsaw.CommandJigsawPiece.class + .getDeclaredMethod("expand") + .getAnnotation(Director.class) + .description()); + assertNotNull(CommandJigsaw.CommandJigsawVariant.class + .getDeclaredMethod("weight", String.class, int.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawVariant.class + .getDeclaredMethod("resize", int.class, int.class, int.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawVariant.class + .getDeclaredMethod("label", String.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawVariant.class + .getDeclaredMethod("labelReset") + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawVariant.class + .getDeclaredMethod("duplicate") + .getAnnotation(Director.class)); + Method duplicateFamily = CommandJigsaw.CommandJigsawVariant.class + .getDeclaredMethod("duplicateFamily", String.class); + assertNotNull(duplicateFamily.getAnnotation(Director.class)); + assertParameter(duplicateFamily.getParameters()[0], "next", null); + assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class + .getDeclaredMethod("capacity", int.class, int.class, int.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class + .getDeclaredMethod("label", String.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawWorkcell.class + .getDeclaredMethod("labelReset") + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawRules.class + .getDeclaredMethod("limits", int.class, int.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawRules.class + .getDeclaredMethod("fallback", String.class, String.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawPreview.class + .getDeclaredMethod("assemble", long.class) + .getAnnotation(Director.class)); + assertNotNull(CommandJigsaw.CommandJigsawPreview.class + .getDeclaredMethod("gotoPreview") + .getAnnotation(Director.class)); + } + + @Test + public void pieceCreateUsesResolvedWorkcellCapacityInsteadOfLayoutDefault() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")); + int pieceCommands = source.indexOf("public static class CommandJigsawPiece"); + int create = source.indexOf("public void create(", pieceCommands); + int add = source.indexOf("public void add(", create); + String createSource = source.substring(create, add); + + assertTrue(createSource.contains("targetWorkcell = contextual;")); + assertTrue(createSource.contains("JigsawStudioCellDimensions dimensions = targetWorkcell.capacity();")); + assertFalse(createSource.contains("layout().cellDimensions()")); + } + + @Test + public void pieceAddUsesCanonicalAxesForRotatedRectangularPlanarWorkcells() { + JigsawStudioLayout layout = nonuniformPlanarLayout(); + IrisJigsawPiece eastEnd = new IrisJigsawPiece(); + eastEnd.getConnectors().add(new IrisJigsawConnector().setDirection(IrisDirection.EAST_POSITIVE_X)); + IrisObject exactObject = new IrisObject(7, 5, 13); + + CommandJigsaw.PieceWorkcellResolution exact = CommandJigsaw.resolvePieceWorkcell( + layout, + eastEnd, + exactObject); + CommandJigsaw.PieceWorkcellResolution oversized = CommandJigsaw.resolvePieceWorkcell( + layout, + eastEnd, + new IrisObject(8, 5, 13)); + + assertEquals(JigsawPlanarArchetype.END.stableId(), exact.workcell().stableId()); + assertEquals(new IrisPosition(13, 5, 7), exact.requiredDimensions()); + assertEquals(new JigsawStudioCellDimensions(13, 5, 7), exact.workcell().capacity()); + assertTrue(exact.fits()); + assertTrue(exactObject.getD() > exact.workcell().capacity().depth()); + assertFalse(oversized.fits()); + } + + @Test + public void pieceAddKeepsRawObjectAxesForSpatialWorkcells() { + JigsawStudioCellDimensions capacity = new JigsawStudioCellDimensions(7, 5, 13); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.SPATIAL_JIGSAW, + capacity, + JigsawStudioVariantCatalog.empty()); + IrisJigsawPiece spatialPiece = new IrisJigsawPiece(); + spatialPiece.getConnectors().add( + new IrisJigsawConnector().setDirection(IrisDirection.EAST_POSITIVE_X)); + + CommandJigsaw.PieceWorkcellResolution resolution = CommandJigsaw.resolvePieceWorkcell( + layout, + spatialPiece, + new IrisObject(7, 5, 13)); + + assertEquals(JigsawStudioLayout.SPATIAL_WORKCELL_ID, resolution.workcell().stableId()); + assertEquals(new IrisPosition(7, 5, 13), resolution.requiredDimensions()); + assertEquals(capacity, resolution.workcell().capacity()); + assertTrue(resolution.fits()); + } + + @Test + public void everyExecutableCommandRejectsNonPlayerOrigins() { + Class[] commandTypes = { + CommandJigsaw.class, + CommandJigsaw.CommandJigsawConnector.class, + CommandJigsaw.CommandJigsawPool.class, + CommandJigsaw.CommandJigsawPiece.class, + CommandJigsaw.CommandJigsawVariant.class, + CommandJigsaw.CommandJigsawWorkcell.class, + CommandJigsaw.CommandJigsawRules.class, + CommandJigsaw.CommandJigsawPreview.class, + CommandJigsaw.CommandJigsawAdopt.class + }; + + for (Class commandType : commandTypes) { + for (Method method : commandType.getDeclaredMethods()) { + Director director = method.getAnnotation(Director.class); + if (director != null) { + assertEquals(method.toString(), DirectorOrigin.PLAYER, director.origin()); + } + } + } + } + + @Test + public void exportOutputIsOneSafeChildArtifact() { + Path root = Path.of("build", "jigsaw-exports").toAbsolutePath().normalize(); + + assertEquals(root.resolve("village.zip"), CommandJigsaw.resolveExportDestination( + root, "village", VanillaJigsawExportFormat.ZIP)); + assertEquals(root.resolve("village"), CommandJigsaw.resolveExportDestination( + root, "village", VanillaJigsawExportFormat.DIRECTORY)); + assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination( + root, "", VanillaJigsawExportFormat.DIRECTORY)); + assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination( + root, ".", VanillaJigsawExportFormat.DIRECTORY)); + assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination( + root, "../all-exports", VanillaJigsawExportFormat.DIRECTORY)); + assertThrows(IllegalArgumentException.class, () -> CommandJigsaw.resolveExportDestination( + root, "nested/export", VanillaJigsawExportFormat.DIRECTORY)); + } + + @Test + public void exportLeaseRejectsDuplicatePlayerAndDestination() { + UUID firstPlayer = UUID.randomUUID(); + UUID secondPlayer = UUID.randomUUID(); + Path firstDestination = Path.of("build", "jigsaw-exports", "first.zip"); + Path secondDestination = Path.of("build", "jigsaw-exports", "second.zip"); + + assertEquals(true, CommandJigsaw.beginExport(firstPlayer, firstDestination)); + try { + assertEquals(false, CommandJigsaw.beginExport(firstPlayer, secondDestination)); + assertEquals(false, CommandJigsaw.beginExport(secondPlayer, firstDestination)); + } finally { + CommandJigsaw.finishExport(firstPlayer, firstDestination); + } + + assertEquals(true, CommandJigsaw.beginExport(secondPlayer, firstDestination)); + CommandJigsaw.finishExport(secondPlayer, firstDestination); + } + + @Test + public void exportStartFailuresHavePreciseOperatorMessages() { + assertEquals("The active Jigsaw Studio is no longer available.", + CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_ACTIVE)); + assertEquals("Only the Jigsaw Studio owner can export this project.", + CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.NOT_OWNER)); + assertEquals("Wait for the pending autosave or discard the edits before exporting the on-disk graph.", + CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.DIRTY)); + assertEquals("The active Jigsaw Studio is closing and cannot be exported.", + CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.CLOSING)); + assertEquals("Wait for the current Jigsaw Studio save to finish before exporting.", + CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.SAVE_IN_PROGRESS)); + assertEquals("Wait for the current Jigsaw Studio operation to finish before exporting.", + CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.OPERATION_IN_PROGRESS)); + assertEquals("A Jigsaw Studio export is already in progress.", + CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.IN_PROGRESS)); + assertThrows(IllegalArgumentException.class, + () -> CommandJigsaw.exportStartError(JigsawStudioService.ExportStart.STARTED)); + } + + @Test + public void exportLeaseReleaseActionRunsExactlyOnce() { + AtomicInteger releases = new AtomicInteger(); + CommandJigsaw.ExportLease lease = new CommandJigsaw.ExportLease(releases::incrementAndGet); + + lease.release(); + lease.release(); + + assertEquals(1, releases.get()); + } + + @Test + public void exportSourceAcquiresStudioLeaseBeforeStaticLeaseAndReleasesBothPaths() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/commands/CommandJigsaw.java")); + int serviceLease = source.indexOf("studioService.tryBeginExport(requestId, playerId)"); + int staticLease = source.indexOf("beginExport(playerId, destination)", serviceLease); + int dispatch = source.indexOf("J.a(() -> runExport(operation))", staticLease); + int schedulingRelease = source.indexOf("operation.lease().release()", dispatch); + int exporter = source.indexOf("new VanillaJigsawDatapackExporter().export(operation.request())", dispatch); + int completionRelease = source.indexOf("operation.lease().release()", exporter); + + assertTrue(serviceLease >= 0); + assertTrue(staticLease > serviceLease); + assertTrue(dispatch > staticLease); + assertTrue(schedulingRelease > dispatch); + assertTrue(exporter > schedulingRelease); + assertTrue(completionRelease > exporter); + } + + private static void assertCommand(String name, Class... parameterTypes) throws Exception { + Method method = CommandJigsaw.class.getDeclaredMethod(name, parameterTypes); + assertNotNull(method.getAnnotation(Director.class)); + } + + private static JigsawStudioLayout nonuniformPlanarLayout() { + List workcells = List.of( + workcell(JigsawPlanarArchetype.BLANK, 3, 1, 3), + workcell(JigsawPlanarArchetype.END, 13, 5, 7), + workcell(JigsawPlanarArchetype.STRAIGHT, 5, 2, 11), + workcell(JigsawPlanarArchetype.CORNER, 9, 3, 6), + workcell(JigsawPlanarArchetype.TEE, 12, 4, 8), + workcell(JigsawPlanarArchetype.CROSS, 10, 6, 10)); + return JigsawStudioLayout.createPlanar( + new JigsawStudioCellDimensions(3, 1, 3), + workcells, + JigsawStudioVariantCatalog.empty()); + } + + private static JigsawStudioWorkcellSpec workcell( + JigsawPlanarArchetype archetype, + int width, + int height, + int depth + ) { + return new JigsawStudioWorkcellSpec( + archetype, + "", + new JigsawStudioCellDimensions(width, height, depth), + true); + } + + private static void assertParameter(Parameter parameter, String defaultValue, Class customHandler) { + Param annotation = parameter.getAnnotation(Param.class); + + assertNotNull(annotation); + assertEquals(defaultValue, annotation.defaultValue()); + if (customHandler != null) { + assertEquals(customHandler, annotation.customHandler()); + } + } + + private static void writeManifest( + Path root, + String structure, + StructureSource.Kind sourceKind, + StructureOwnershipManifest.Provenance provenance + ) throws Exception { + StructureKey structureKey = new StructureKey("iris", structure); + StructureOwnershipManifest manifest = new StructureOwnershipManifest( + StructureOwnershipManifest.CURRENT_SCHEMA_VERSION, + structureKey, + StructureSource.of(sourceKind, new StructureKey("iris", "source/" + structure)), + StructureBackend.IRIS_ASSEMBLY, + List.of(StructureCapability.BLOCKS, StructureCapability.CONNECTORS), + List.of(), + Map.of("structures/" + structure + ".json", StructureHash.sha256( + structure.getBytes(StandardCharsets.UTF_8))), + provenance); + Path manifestPath = new StructureTransactionWriter(root).ownershipManifestPath(structureKey); + Files.createDirectories(manifestPath.getParent()); + Files.write(manifestPath, manifest.toJson()); + } + + private static StructureOwnershipManifest.Provenance managedProvenance() { + String path = "structures/source.json"; + String hash = StructureHash.sha256("source".getBytes(StandardCharsets.UTF_8)); + return new StructureOwnershipManifest.Provenance( + StructureOwnershipManifest.Origin.MANAGED_DATAPACK, + UUID.randomUUID().toString(), + StructureHash.sha256("plan".getBytes(StandardCharsets.UTF_8)), + StructureHash.sha256("closure".getBytes(StandardCharsets.UTF_8)), + 1L, + Map.of(path, hash), + Map.of(path, path), + StructureOwnershipManifest.RollbackDisposition.NONE); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java index 2b8654c0d..ee76a6d64 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java @@ -238,6 +238,38 @@ public class IrisStructureLocateCommandContractTest { assertFalse(source.contains("at[2] + 8")); } + @Test + public void bulkStructureImportRestrictsCaptureWithoutChangingStandaloneCapture() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource"))); + int importStart = source.indexOf("public void importAll("); + int importEnd = source.indexOf("@Director(name = \"capture\"", importStart); + int captureStart = source.indexOf("public void capture(", importEnd); + int captureEnd = source.indexOf("@Director(description = \"Verify", captureStart); + + assertTrue(importStart >= 0); + assertTrue(importEnd > importStart); + assertTrue(captureStart > importEnd); + assertTrue(captureEnd > captureStart); + String importMethod = source.substring(importStart, importEnd); + String captureMethod = source.substring(captureStart, captureEnd); + assertTrue(importMethod.contains("StructureCaptureImporter.importStructures(")); + assertTrue(importMethod.contains("jigsaws.captureCandidates()")); + assertFalse(importMethod.contains("StructureCaptureImporter.importAllStructures(")); + assertTrue(captureMethod.contains("StructureCaptureImporter.importAllStructures(")); + } + + @Test + public void structurePlaceSeparatesPackContentFromTargetWorldEngine() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource"))); + int methodStart = source.indexOf("public void place("); + assertTrue(methodStart >= 0); + String method = source.substring(methodStart); + assertTrue(method.contains("IrisData data = dimension.getLoader()")); + assertTrue(method.contains("PlatformChunkGenerator targetGenerator = IrisToolbelt.access(targetWorld)")); + assertTrue(method.contains("CommandObject.createPlacer(targetWorld, future, targetEngine)")); + assertFalse(method.contains("data.getEngine()")); + } + @Test public void structureVerifyPartitionsPolicyBeforeNativeReachability() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource"))); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java index 7968db370..bb119fd67 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/runtime/BukkitEngineLifecycleContractTest.java @@ -20,6 +20,12 @@ public class BukkitEngineLifecycleContractTest { assertTrue(closeAsync.contains("operation.whenComplete(")); assertFalse(closeAsync.contains("!existing.isDone()")); + String exclusiveFuture = method(source, "public CompletableFuture withExclusiveControlFuture(Runnable r)"); + assertTrue(exclusiveFuture.contains("J.a(() -> completeExclusiveControlFuture(loadLock, r, future))")); + String exclusiveCompletion = method(source, "static void completeExclusiveControlFuture("); + assertBefore(exclusiveCompletion, "activeGate.releaseExclusive();", "outward.complete(null);"); + assertBefore(exclusiveCompletion, "activeGate.releaseExclusive();", "outward.completeExceptionally(failure);"); + String baseHeight = method(source, "public int getBaseHeight("); assertTrue(baseHeight.contains("currentEngine.acquireGenerationLease(\"bukkit_base_height\")")); assertTrue(baseHeight.contains("IrisContext.open(currentEngine, lease.sessionId(), null)")); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/DatapackStructureScopeSVCTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/DatapackStructureScopeSVCTest.java new file mode 100644 index 000000000..94d564b65 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/DatapackStructureScopeSVCTest.java @@ -0,0 +1,41 @@ +package art.arcane.iris.core.service; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.world.WorldInitEvent; +import org.bukkit.event.world.WorldUnloadEvent; +import org.junit.Test; + +import java.lang.reflect.Method; + +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 class DatapackStructureScopeSVCTest { + @Test + public void structureScopeRunsAfterIrisGeneratorInjectionAndBeforeSpawnChunks() throws NoSuchMethodException { + Method handler = DatapackStructureScopeSVC.class.getMethod("onWorldInit", WorldInitEvent.class); + EventHandler annotation = handler.getAnnotation(EventHandler.class); + + assertNotNull(annotation); + assertEquals(EventPriority.HIGHEST, annotation.priority()); + } + + @Test + public void unloadAbandonsRetainedStudioStateBeforeEngineTeardown() throws NoSuchMethodException { + Method handler = DatapackStructureScopeSVC.class.getMethod("onWorldUnload", WorldUnloadEvent.class); + EventHandler annotation = handler.getAnnotation(EventHandler.class); + + assertNotNull(annotation); + assertEquals(EventPriority.LOWEST, annotation.priority()); + } + + @Test + public void emptyScopeStillAppliesToJigsawStudioBootstrap() { + assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, true)); + assertFalse(DatapackStructureScopeSVC.shouldApplyScope(true, false)); + assertTrue(DatapackStructureScopeSVC.shouldApplyScope(false, false)); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java index 8b1f47262..03adff440 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisApiWiringContractTest.java @@ -75,9 +75,11 @@ public class IrisApiWiringContractTest { assertBefore(add, "catch (RejectedExecutionException exception)", "phases.ready(world)"); assertBefore(add, "registered = true;", "phases.ready(world)"); - String remove = method(source, "private void remove(World world)"); + String remove = method(source, + "private void remove(World world, CompletionStage unloadBoundary)"); assertBefore(remove, "registered = worlds.remove(world)", "phases.closing(world)"); - assertBefore(remove, "phases.closing(world)", "startClose(registered, closing)"); + assertBefore(remove, "phases.closing(world)", + "deferCloseUntilWorldUnload(world, registered, closing, unloadBoundary)"); } @Test diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java index badf81957..2496a3f1a 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisEngineLifecycleContractTest.java @@ -49,13 +49,14 @@ public class IrisEngineLifecycleContractTest { public void registrationRetryWaitsAsynchronouslyForClose() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource"))); String add = method(source, "private void add(World world)"); - String remove = method(source, "private void remove(World world)"); + String remove = method(source, "private void remove(World world, CompletionStage unloadBoundary)"); String completeClose = method(source, "private void completeClose("); String retry = method(source, "private void retryRegistrationAfterClose("); String invokeClose = method(source, "private CompletableFuture invokeGeneratorClose()"); assertBefore(add, "findClosingGenerator(registrationIdentity)", "new Registered("); - assertBefore(remove, "reserveClose(registered)", "startClose(registered, closing)"); + assertBefore(remove, "registered.close()", "reserveClose(registered)"); + assertBefore(remove, "reserveClose(registered)", "deferCloseUntilWorldUnload("); assertBefore(completeClose, "if (failure == null)", "closingGenerators.remove(closing)"); assertBefore(completeClose, "closingGenerators.remove(closing)", "closing.completion().complete(null)"); assertBefore(completeClose, "} else {", "closing.completion().completeExceptionally(failure)"); @@ -68,6 +69,24 @@ public class IrisEngineLifecycleContractTest { assertTrue(invokeClose.contains("future.whenComplete(")); } + @Test + public void worldUnloadDefersGeneratorCloseUntilTheRawBoundaryCompletesTrue() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.engineSvcSource"))); + String handler = method(source, "public void onWorldUnload(WorldUnloadEvent event)"); + String remove = method(source, "private void remove(World world, CompletionStage unloadBoundary)"); + String defer = method(source, "private void deferCloseUntilWorldUnload("); + + assertBefore(handler, "WorldUnloadBoundaryRegistry.claim(", "remove(world, unloadBoundary)"); + assertBefore(remove, "registered.close()", "deferCloseUntilWorldUnload("); + assertFalse(remove.contains("startClose(registered, closing)")); + assertTrue(defer.contains("J.sfut(() -> startClose(registered, closing), 1)")); + assertTrue(defer.contains("unloadBoundary.whenComplete(")); + String managedBoundary = defer.substring(defer.indexOf("unloadBoundary.whenComplete(")); + assertBefore(managedBoundary, "failure == null && Boolean.TRUE.equals(unloaded)", + "startClose(registered, closing)"); + assertBefore(managedBoundary, "startClose(registered, closing)", "abandonClose(world, closing)"); + } + private static void assertMonitorUnloadHandler(Method method) { EventHandler eventHandler = method.getAnnotation(EventHandler.class); assertNotNull(eventHandler); diff --git a/adapters/fabric/logs/latest.log b/adapters/fabric/logs/latest.log index e69de29bb..c4c3d950b 100644 --- a/adapters/fabric/logs/latest.log +++ b/adapters/fabric/logs/latest.log @@ -0,0 +1,488 @@ +[12:40:12] [Test worker/WARN]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0 +[12:40:12] [Test worker/WARN]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes +[12:40:12] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:definitely_not_a_real_block +[12:40:12] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:minecraft:definitely_not_a_real_block +[12:40:12] [Test worker/INFO]: [STDERR]: [Iris/WARN] Block 'minecraft:oak_log' rejected state 'not_a_property=x'; using its default state +[12:40:12] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test' +[12:40:12] [Test worker/WARN]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial16265972129816775503/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial16265972129816775503/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"} +[12:40:12] [Test worker/ERROR]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot11106486503678824408/iris-dimensions.json is corrupt; quarantining it and continuing boot +java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot11106486503678824408/iris-dimensions.json could not be read; refusing to discard persistent worlds + at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) + at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) + at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) + at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot(ModdedDimensionRegistryStoreTest.java:99) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must end with '}' at 34 [character 35 line 1] + at art.arcane.volmlib.util.json.JSONTokener.syntaxError(JSONTokener.java:414) + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:145) + at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:345) + at art.arcane.volmlib.util.json.JSONArray.(JSONArray.java:111) + at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:348) + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:159) + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:260) + at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) + ... 45 more +[12:40:12] [Test worker/ERROR]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost +[12:40:12] [Test worker/ERROR]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot11106486503678824408/iris-dimensions.json.broken-1786380012686 +[12:40:12] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: second disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[12:40:12] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +java.lang.RuntimeException: first disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[12:40:12] [Test worker/ERROR]: Iris disabled all services with 2 failure(s) +java.lang.RuntimeException: second disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) + Suppressed: java.lang.RuntimeException: first disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) + ... 42 more +[12:40:12] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: cleanup failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[12:40:12] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +java.lang.RuntimeException: enable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) + Suppressed: java.lang.RuntimeException: cleanup failed + at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) + ... 42 more +[12:40:12] [Test worker/ERROR]: [worldcheck] server stop request failed +java.lang.IllegalStateException: stop request failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144) + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:235) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[12:40:12] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed +java.lang.IllegalStateException: shutdown wait failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159) + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:262) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[12:40:12] [Test worker/ERROR]: [worldcheck] check failed +java.lang.IllegalStateException: check failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139) + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:222) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[12:40:12] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success' +[12:40:12] [Test worker/WARN]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider) +java.lang.RuntimeException: provider init failed + at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[12:40:12] [Test worker/INFO]: Iris object undo service ready (bounded to 32 paste(s) per player) +[12:40:12] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered +[12:40:12] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered diff --git a/adapters/forge/logs/debug-1.log.gz b/adapters/forge/logs/debug-1.log.gz index 7a0c252d56ee06eec05fa312d3fb858e3ec148ad..650259dbf9e86e499ecfcde457889946f5c9591a 100644 GIT binary patch delta 2732 zcmV;d3RCsm7T^|tABzY8000000RQcs{cqa38pnS=`zx%fQkis{k^rG)SNDkZ<(y7i zy9?Xh57ji8#L%=R&aoX@c69FFejmpPPzV%S*1ID;lUBfSp6_G(@k>4qGN#<&Vlar} zfpeZGyob+Bp3y)HIX?K>BA*o;Cf)aE^>>6SqOTbVS)Zm{ll)xLmvqRC+^efnmyXtQ zsl${=Wx(DudV9h&GaeWR_D?4H_~cL|V)!&-yhT(rOgMX4X-(yT=?)8o3{{KNOVoYG zuQun@zqC7l7m#!=BW28+R}#=T7Me(Kfsa^Lzr9Ed8HqyD7n1A^qnHJf_H`@aG>X+8 z=`lE9vwkQJ62;_*Ndgw>h(?_DID13VtnnuHs!RNwkC_#IvN#N(6qADyl?Q#n!3TA~-GhP8Wi4pj zd;a%t!*Hnk-}TYprYEcLZxA1RIxv3jPi*cNHun=Mbws(}@VtiSRvU-4de!yahCg?P za&8oV_yRsg@rZJ0^~z7+&NU1j(9)CS*Kf!zOEiJV=usUFTjbB3DBNl7n6q0wDQ;Q3 z^N*c`2DgwXAR9CW4|o15Z!>>BN=`eS%g$zvM)me;2gU`NCVIaogm%4Jtx<3Kp5Hh; zs{74qb$OHJ=e;3+q*T&aM_Ct%G@)^r#dH*Zr&*5%yRUAbKRKM5!JBh7puDT0U!L9v zP_bb3?@{PLq&y^MWa*iKgob8p=-Y8in}AUPmEmkDvOrL;egW4Z1|2G4WMYmn?hHj3 zve5aEy_^(Eo!r4LjOxtEM+Q|{nak8|%WYI&lmA`~Vbncn{rdIEl3dVm4Y`KDHMKK; zAt4x~PLnls{iAK@n%?IqEBYQ>_;(S(Otp_~WEA<&V`xblUiQymK7fXP3jwpz&&+n_ z>rv*b8NDaIX5FAi6+5^IZ>I|zh$0TNwQ~|lc=^EewFrNQZp2wT2$)hrLf#RKGnExN zY#3DQM)cPb!ATD+)U1`la{eiA-NpldD?$#On>3EJBhxsB@HUmqxN}x4rtHbPt~uVZ z=A#iirkq#i^5gyBGC)=xPn-6@CSacg}SsX|T zQ?NsmXgYvvs&18IrhUUItY&83tPD=?S&+gq!A_Kaq>xluJeZH>i<9uv^-QQHOSKN` zFD6xQK3S^WgH;v{^u+QB>%&!txq$d6MZOG5?7N4DD{Gxc8LpHPs)Os?j9JM9G=o*v;ALD{IAZ67rNOgkyI2T{_@qcGj#WyI z*+KR2$J%#~*0+CbF8=OyHlv%16ebsSR^-X&==Jp=`tHZY>FtmvkYTs@3_(6^3mZI> zxGFm_A66b#r-|FdWOfgKrf;3y?ELzYZt^+4jm2F|x*7C*Lf5pA#W7~TWN%?wH6#8s zp%`wLw*4m79hQxDtR06R;3`$a|4-M36Z&P&9esyU8Y{kC6)O_BWQ;FPY zU>0OfJL?;EVt_YuD&Mr0%|JP`(#*uFu1D5(DUz4r4+IfL$u!5j!fZv43+jDxL#n3MXGH}t1TrR&z8t0g7IipVD&zR7XROU499L+?Zb-4uQeM-z7GX0t$?OiTc-ldu4xo7 zlC5?PaV32kac1{~SH@J=p80{sbikw$+U+uksN%rPzELNQHI!p!L|0i(v+XjY`FI>J zYC5@zwuUKZH__^J#2{{MwsQz%_^8;WgvNqZ-G%wsfvXcK?uwumr>2*j| z-(>u$K~)`-+6N>l)SY8em8_y(;|)?j(b@sI6LKfy zPLm%98(8VZxYIA)G)W}bXJLzMeq+*4MTq9^OtRk@^_G8Uv{_s0OPj@wU{Y}2qrvUV zd}T4Mc!c@_or`VpGk{D7@GwlZfH6FyeOH4l@W2pb?FghL32 z5Dp>Swr>8aZTN#)7V^9doMQ2FZxj0gY|2HG*a#hekWC?*LN@gyZxEZmVd*EKLUVU4 zJ$_SI(|d%wUEP^S^}>}@aQn4*47k!FA=0ieiyD|#){+Mqm!0%zt$i`#$3{FWB&Wk( zNOz5?e1V%+%$a}zTwRFxJCmZrRLb?PTPd|+=i=Jo0zX^R5fjVMLb-kyTv^b`yKy~R z@~r<3Ox`(1*DB@8GUAs=kr9)52_RRucK=$lu?a`Jqmc>p?7p>vsa>Z!Gy~1rJgqpv z7<(>I%WZuDQ)R5_^ghzN)m5y?U;Lr`b$J>!1uz^9hAbnr45wLU^K_iHMj2O@5WfwI zG!1DQ(lnFq2^%xEJ(_m32~Eph*5+mpazBxmWxvoCXUSb1ah|XJl%ZIwIhpr#PPX=a zXSL^nvQ>p!Su~ST3K^4G3JZT*17t_lIY4%PadtWXYiW`VBB)i#LK37(iy;Zj_!(;g z9*-iZS+Zwn&^H(p17RLa@J*%J6uGxVb^ZwgU)r6EdDUf76OrOa+vo~E@&|eDk;)Qs zWxBIvQOB9yv6bs&MV4dZfdCaDrUXz4Ll5jdUTWJtNg#b6MG z1LrhN_za(!JfWTza=iDwMZPLHOuC;>+)somqOTe8S)aySlk{BDmvqRC+`Fqhm-fqr zT%ZjO4x zsk)6h^>6Ke&KV?~%0LMGI-&umJj9GCZ-oRQ*&9*0*B2aoP_{=0)MNuWzKH(q_}0_ z)<3o)>fJ)3fNanl+~4{uztx)au-#wpn`K2l7WuB@K0ubdo?58v03pR0m<4^l7mB?go04!>Jj*IcEdPyBd1t z@x2EX3s(Ogh4w_uePYIzo*7E0Z$^i{9l5j#80Asv&z2$y1oiG0a2<{0D7 zQ20LcoiEADQKr;M9qhum&YW~?P^Fc*Ox?Di2=B>%uZA%0?zMjX{$xoms9!*?-q@Ib z+KG@53{uBY0o_J(6S}7NIm(KD1{eOFMKDwCLmL@c{?iazlKSWU6POX8p+7>vtn@Rn zo%q^Jd^O|u#Mh)7^r&KcH~#H(X#-KjVZL^b0tqi)n7$VN@6e4nYkMA3N=V2%f^nv_ zB8LrwvTHU?z`2RTKsz!HLkMqE$&5QE*>cLByj?NJ zJJx(KVuzIT(p;WYAgP+fWy`*u*PK?<1M!DCX0kRJN;%24W~ysQt}JF+ zW5@Rq7YcOvcu64}&ncub1LV;3epvAuz0@>=nKFh$r3b0Di2F|{;suo;(n&slZP%ve zXVb0uT6vuUW%fUE;oW}v3Q6uEr%I(tm8nvGjx3(b&n%Nkgw=9CuRx8>704Vuybiek zNO5Yftk`&p;DeZOlWvt$d$r1QO;;S{T{A!CdEvId&KR+*<&z46q&)wt&laG`*t-ou znq&V-!S$ees;hcqwfXFpeSzM8K41SihTY2gjyp+oADU%=F*^NZdsS`yf$tHEm3kqI z14&^Dc4!n#2XIZ*t#Zt?Z)kL0EFZBxTy>ZWhzlvwWl&__Jv>}0bgJ#PPpS2PZX9T~o<}QB zvFnH0vZdVLZS9y=;KXORQi`bdu2VB+CF9WqS6PGSVQJxrofDP@&!X*YAt=J5EGauy z$~k5S)q|I{UuhP%e`qfL?sYPwn~M}C7j;(TN$2R|dJuj0=O9oF3{Ix=dT?@7;7%M@~){_D={BpKp zA62u3asFw>%72w+Qf!F2_07m+&WBKG|AFfTwpgUvrnK5p((!DGY(lyyue@S`4ql~= zEC_pPnQEiFuFQ)tY73~Q^wx14;?~I!a^&un)XMcDdnH_dK!!^0?m4I zEg~ZA3Nx#LX=N>Wka6BgkJj2ZBYtSalR|Pj?D=%tm`WG8X~mof7{Jw;2!ApuI!wh} zZ&ymWHtbwnJ6Pan1syT53@w!EcfpkfowOUf5Xq9K_%nIu94%DJm1V>)k&}H1AYhRZ zZtVWG=41D-J>Y0}G%$gl+_#o7wd+{>W}qp|)3OtcvF8G{)YdmJmB*Tn?*qMEUd5Uk zi$9cKl&4Ws0K?H>$TC99aGGT{PseEm%DA$G_-#<6X-LzMrXfv3nue3^2^KfBP0=*B z{Me%a9?-Ppr7$;pko$?eEcu1DFiGy}fb(?irwqkf&B>}y=VXQNJF7hpl&vb<%A%7< z3Lbw5$PkbrAln=uYt8|()3cNF`Cm(uY!E=LO6C(UR$2^+XU5M^6YzKxK+TdJLxX<6 znCJ=fV1geiPNvA6f~w!zowIq>Wl|H7>_^+^3O~{ZdG3(X5^`m_vt?0-iQb`=>m)^% zW8>tu`E!vftbQQq#1AtJ7hr=0)E#T(C@US(Mcm_ox;D3)X*0dde#!UpnjO+uvC?BU z?IhPklLZSOJt1d9&bBGeR&@(-HqpC*L7Bv%FQ$*rgswRzrJjIP<`XSQtx6&am0wct VNyH?HI2{`<^*@&nH^T(X007*FTRi{( diff --git a/adapters/forge/logs/debug-2.log.gz b/adapters/forge/logs/debug-2.log.gz index 8f6e1abfb5bf46648e9098dd9475d56671d0bb42..fa8fa6617ef3d0d2631b97c3043db293d8b6a60e 100644 GIT binary patch delta 2752 zcmV;x3P1Jd7VZ{*ABzY8000000RQcsYj4{)7KT4he+3Z)hzo2K>tZKy7dvosnMu(m zS-ahS2#SK1Xh#VxX-LY6JDC0Ldnih_W6O@5xY-PF2AIaCsPi6@hZjARtSh_TP6tli zX%gFR)t#2xwA_aCja+?ZQjxn@+%mCueDeOwZ;%$oR>YKl8e6FfxfMo=iHLHG$Adu_ z4Xm>~;Uj!!;*|O-7UR8dE%HUeVbcA0>U|(o5_Qc;!1^@hisa{#zMw;<<=$MCy5v=I zsl#NP3ZH#s^!Au3rajOO>>f|@@yVeuj^Nvf@fMNcFyZWFr4!l?A4d}c~IR?7NtWbsG9Xuut6x57_Z@& zU@8@nb}ovI6v-8u7dEbl!Ot66XsZ{ZQkoYmgbEHTj;!zlY{7wiNzeD6Q z0olGQu((sI=+@l|wUIC(wIoOc;gCfVqGDPPkPQFYB#g-3h>E>_%)tk_$L+mAlMB~x z+&j%5--p3a^?#_t!A(!p;lDw&_jynIxjV7BTiD!9s8Av0hs|c)vG)&c?{L4d?>246 zojXN;S~n7$0Vl&~L^(8j?KSvw4TA@?^(6W2J95hsMIbhMRE5J9`Ex4_wpv^I>{d_2 zw=CNF$5ul9TS$x{8#D(Gw*D$_HT!U`)lNE{i_Ut@M%Cu32Sx^wCTh1A$I5OVI)`qv zzVA7%W7j>;wGWmyT7Kd?@<&PqjZ~QRl28$U8U}DR{tiY_T!WXM2{{#J($oyj}Lt_b}1b)(x)PrEk+g!^35;cJA|P{1&mJ2 z(#EY}90V+|K4pK73#Cr(VHZYrX62)UtgXzYE4O7g>Tk(^uZA%4?zMjX_GC#O)1ZQX zT%)-$wbNKYGzgU@6?B`2o6yz$&yq&;Be?MIB7~W2ADPf7@}EV}k~FyJpTe8~4gDS> zW~HB*?abH1%vU{vPkhb#L61tdcN5%B7dH?^9A<3mI27>b6I0i5@H_M)&f31uq>KgR z9l=ObTam+rLEY1$zm^D2x?rJZtQ3}i^KW_UHXc}s#lX5rqfl8QjUotdUCE4Fr^SNG zoV;!8;~ir@9I+$Hd2KFFE|67CW^&iFY}axiU9`-{dCh4xJrIAWqo-?~p;nS?YQ|YZ za%C~|8aug9cq~DOkCzm(`J6&ZJwgt3?}s(7*2_&Zm@88_RC|zWin#xTB3@E|0V193 z+qOG3KbvmMyY+PnRM>ydWB>NU7fAAsIF&M2vZqSrIf{6$KeJ3a5!TE7vH~?XS0H`( z=sM)VBgJuFSh4vO!3QznW!xE@rGv#K}3n&lOL_V)SuFEQ-d>pN~`(S4+s0ov&Fo9R{V`UBr17VSnQ zivvMn3btqxP6u#B<*l^zw6A%E(ag;2mBGn9^HW$Rn2GX_6q0I-2lLT)kX|Y(0-w zo?_My?y{xa-)-%fSK!2FxKc`}@~?9vN-Tslj_`6rxjIJ+Im{`p&TQ{ZKU&+_`QDOl@+G>B;=71+Gw}I@u4yofrOkfLKEkxB zNBn6*G2AX~`*o~aEF0}uI|@F*RjPvjKVNHBa1K`pi=s|*lcJ0E>q`S*Bleq1&F8H- zmB@|ydO>Ejv%X;_2KX?iQhs#inreN)?ieoPhwUWcp^4#{E|c4T_x06kg?xF#Ij_T! z{fBA5y=9t5G?gqMy>V+6{fuHh zbg&xGKlc^Y2fDh(&ESJzoH4^Q;6F7VN^c8|Ay*^Y&%;_lwW7 z75k`~DU9_`Jy!m!Hj`pP)N5=;rf@!jO8XC7FR;ZTxtr2z3qi-TB{B*5qTGJL0v)_a z8yOJxvt^uSeO;NK!l(_Pn$la#vWV9pL&#CSTjI>`s+I14mhc!d*rnSCHTjnGMLf** z`f}<&*-~hcY!64DzC^xRMSd8MU_6=?SiO;<;{WqThGllGc45iWboV{GaR3!vUIY7; zYCvzDhCU#rTSJQ49-5%nb|&2puI zTL8EPfLj2#1%O)sxCMY)0JsH!TL8EPfLj2#1%O)sxCMY)0JsH!TL4ek0w_mSmF}$G zD5^RQg;Z5YRbOX()u75lP=%lhK^1~31XT#C5L6+P^#>3pA*e!7wJA__F#pphrJyP& zJpq9lE~u3>x2ifBK}rRy$+vie)GL!F2q;pen`z&aY^>0jiRfK5n&Ls2DsI%gs z(Su44jTSe8iJ0>q^>1J1kwu>~8l}l|P@HyHz6Xy_q6`q`StQi!B{aGI6nqD=Gi>Tk$S%sp5nPv%)NDU|LyAUS(W% z(xbKZRf`{K@vM-X4toLJ)~50WZeB5`F%01HJdS=a5qFqOx!SgCr8dl5>>e!evx<)B zScVqL^}FE8f==Fzy==)-{iVEf2#!`N<;pVRLz99DBU78Yf6aUJm6#7W+8qsbplA23 z6-@0qRe>I8D)Y4B1a0iOKrOfR6-=)XiEV-*f&hxdO z)D&wqCv%?8$tvG><~|RUtt#Bgq9GtdK!$(}0T}`^lT8X4f7=)!^X4DhJUc(VnE$mj z!3H7Js$c=}Q>o%1@%8u_DFPmkLa14?qiN807!&AYT!9T% zP`8Yg<_+ru6hO`;hPFYj#9a$!d?;G?UyRlMxFYLWZ0TIU91eO>ws7 z{EcX3oGtF%z@SXhD2S)8&xEcyCbeD+sVpEWCayyg3YA}zZ%M)gNjM#AF7-b{f?Q4q G%m4uRkX8r) delta 2757 zcmV;$3Oe=f7Uvd!ABzY8000000RQcsZExE+631U}KLt?)hznd4%aWbgJ>0_0!)_OC z(zDy^i=ZfIiFTCGqL!qbWEXp%{f44sJGSh|iIeLBQ?#{BQS%>?!w3B+TNm|4Hyt(V zjTWibJHFTPeaCIqzmbd2Olfi>QkNQ) zTxhj9woz&5Q@efxY8tK0ZAZi5Naj81E1jjT6pZR$5azV!FoyAw$(6eu;Y1 zX}A0Z^{?H3-Weplmyt5&%_|9L91Be(xWFeYtKUH+#*9QE845{u$5G4zNr$?Va2myG zmkbyjuvtGA2Z>^G!XyEUbV4Ie2b{enY1VjCd-WB5wyOKd;&jX;)$^W8Hj0#%cN;h+ znNDSFoQs9EqDp*gId!+W;M@DN)AL2&tOHE4kOZlJ(qc>k0r4Dbl1MR$LXh8$Amew4 zTqYsgcMTSI%M}}Lh1y7%R7Mgc!f4E51yM1p2S|qhT@uA)e?sN{P;l@;?Q?g3)Z)?$ znzxPCAK%B}SP%cu?a|dh*5S`6-v7LB{M?(`+$(JEB~+v)OF7 z{pP}dIm)?G;0<^g#S_Y*-D^LDLzggoKxIcRKnI?K~AcQ{f8Vwix zJ!l@lU(a>j)s2>)_m=#gQb}VSW!)swgvMci7S~anX1yBhzPf@w<#1{SaL(C?^1gUi8;o& zGZtaULg!=la$G3&atHe`vNI?E>pK1m{8x4|6Yt?x<@uLiu~_mXh|BL4^LrEfQEht0khW6 z%y#B$JM-0y;8S0-ZZM#V?O%o0v&9WW5r-MuIgTW}d}R7kgug;J;;b74OerBD?*zt~ z+L|0T4C=lS{kcSN+5-zUYo)N9f67~bw|UQskR#_RjU(;IG>##>O(iq#oE8fzd-9HJ zj`ytjXu^&t=e31Axj?L~I%c{y8EPfTwq_a|NUkkr zUSlV>2^R`<_}z*^ww_W*Wd_Kx>HVeq_63ssBTkjdmE4(9d5$8U>rX6`Nrd(Cu&h9>?G?xzKDvx} z_&{;I7glUNM(|!t_*u6qsXfnqs%h6z!6ge*UKVZ#n~V{wT0W~FNGkKc@niv-jlDY% zq$Tzr6#cihRM`^YQZ?i6ROEd ztwaCCr2K-?mdJuiLcg5-5kS35}r}zv(K5Gjb zJkz);J24+t?pLRY+r(slcK2s*ot^A_e?>R>5?{ySCMNw1d_JOUTFBxUvtP5fFs+&q zf0j@Tw=3Iz6YCDkMmyGx!;f&4s^Q<~OT!Az;0j?`w9(q8=(7F#$^h7k{nkqJd27xj za-)G+kU8C~Z`g?e-Ylq;ADz3V+FY=I2bb~VZj$iG#_(L1$?XS!=4!Pjs#r({cb$3kGZB2u zqk-KgF|^M4t$%(t2N=7=B%BYfU|c3<&?MtjnSFwg+(i14?EbZD2&3ndi{O%)wE~mG zYZ4yCjJQjvsa)@WvthyDs=9-oe|sXOkhS^rd;y%zvkFvLxChs6l<)P=x}W;Lo`0UN z*ay{YVVr-OvGQNFxfC0seseoAh4V2~+JE4Bfh`xw+m=>WN_sb6BAbve%H0<%(BX@; zkp*GTEz@Y#HtE_WE+@ zKiyI2kZcFXoW4SSabvVpek>+19?c7^-^bA6|GSUj*e$C)Sn)Jle)ABb!}rQ7;P9YI z1(==FC}1R8?Hb}rhBV^L?hCJtslGk)BaP{ZNh7q=XAn`vfw_I7UK(pC$J~gnvYcky zWlrk8ovMwDAf=Mk)Em4(>Iai12q;ylpC*X}`z-8`&2LQlp$O5! zok{jPqm4D+8Esekea+%VFex}6(BS%IzOoo_M&mSj3W_rht9Rh>Nt^+~Jd1*Q{RB;J ze#BNCS(#Id2_LFruYnK_lb{G8B5rFpf3-LJmZF6`F9WAo{M6gTo`Frd$fl4@A)7)r zg=`9w-3S?fp7RE=#T%A>5Gu5A$I^p0g}Dztbot`OJgOJ2q=M^j#iPKL773Adg;~_V ztg==-$hhpJ2W#!C5kE5GSs^(c4??SngSS( z21Aw+T7}arvw1vDt5C+ZCB$!oB27b@hLimX9W!l>rup6`G%b6n%+2oQek3o;exWVS zlKVR1JYV}ML$TI#vc}^%S>^lAyr+S(b%k47G?QEk8Gl1ShJXwK83Hl{WLpDd&H4f$ zdw+I%zW8ftl8qv$RmnmUq)Ll13C#EzYXTlmBB)uiYiQ7S7!w0w9!&6ErP&m@yF%4n zQ2pBNoh_=alA4MXKiWpu_>n)zbC=XskSo)jEsHwJ^p31tFDtUVGfwW9|32jk>mLX@ z^}`Iq6)f0b1$D<-IWCIyDIM@gU7Fj?w3%M!zvTP*k{!`hvD!m6?IhPm&W4-~IUAD? z3mrMOEzZ{RD{wY3xPn2Mq;V)_kI#gzc}Hpk0jVq`S`e>65(<@HQ*TJZBuO~EGhFI_ LbqL}?2Fw5exK~T5 diff --git a/adapters/forge/logs/debug-3.log.gz b/adapters/forge/logs/debug-3.log.gz index 4ddff9d3465bf7295fecc94ff8308caf6b2649cd..009841989bb85bc9e0266e9d9645693b767f4234 100644 GIT binary patch delta 2734 zcmV;f3Q_gy7VH*(ABzY8000000RQcsZBOI47RO)jdAB5X&8gKJ-^t|#Q<~fgdBfz+;nByhzd>3goS12Ueea|?;!YH6CS%GSF8ci_ z?mH)W!e{u-&P=qCvvh9BKDuq&1a&rrRtKGE`0C zm#F(r&G)8%)W5abr;v0aBW28+za*e>EHshe0w1!he!GzvFcO8NCnVV#L@^5_?dfL1 zX%wq1(q(YKX8lqeB#Ox)lLRc%A&ofga`u6wS>uiDRpj9GCUzbEN*%?x~(-RzgP&?e+>DRgR zg5A4X{pXKCIMBVHy3xPx$}0TVk9RJ1jGx;ho7;uW?Sx7lQSR0o_5FtLyL+zh?R&d! z)pw_V&QZ>_0&l>}C>~M{?Ou5k4qd_U0j)hse*b~outXDxjxN>Fph^DRh{BELhB>?0 zmEwlQ8~@lyXmA6G0#H9a8)Ta3?XD2IUa#&q z>W!*f_jYUh4cGJLH(Gw)2l7WsC5?5Ib(2Vc6B>tETt{)5^=h#D?i%`(!>Jj-IcI&! zI~w}t(Om!)3s(Ogg$_i@Lt;jkt{F^dXvT-09lNv%7!^<%PL?7I1oiG0a2=x1p%O+X z<{0D7K!hO+oiEv+!$PT@JJ^AdojLjFpehSet)tg7c^Xd zLT)_*(>R9kHkHh{b6hN_ z?8%$1Io`JBqaiz>oL8puEZOznH zkX%^IyvC015-t?z@cTK1tUsra$_$VL)B9n~YxHu{45rEy4pknc+9K{fp@`>yRDwu5 z`?l$g%}=IV^Imn80u}Z@auM8o`U*+@0jEmkO72*xJVz1F)n}H;B*J>RS5~0<`U+$Y zA6!K|e55$uD=XHYBKROC{H$A*)Sl-)*R<=X;EIJQFAKN5RmO;UEuU2oB$fGJd$s`0 z#@jmJMLuBePEUW#_0H)?N#3D1K%SS z-QA@u_9cZW*r7=@9>6tKH_9>7zTp*CGc#{i21j=+NMV^^C(1ukNUF>p%t!OtN%-k{ zCRCHTTKoR1N%?zEmTGljl|=(RvV6#TaMfWhAkL-8mqCgB^zd+HsZ(u#w|z>jchX3+ z)jV2wid{c=^Okadx3yzlfg_*cN-3c_xXR6#l}tc0U}X)S#g&;Oc21ZZJhQfog`kKJ zi=^UMrR10$RQF%jzS~&d{(-spyVKc>ZZ1-ooYh&7C!eF2*MsQ0yDv^}g*1T-o5gns z@^M?(;2FhL*@^kE@~}F8P246Xv%53?=xk=^`*XU<*Z3wDw=wBt;PVMx(?S-JnGtg5<}~pPo1;V3BcGQCgH4m4dXH~gC-fI%Ip(_SJgfA{JSG5g{(}b=L_I$o>id2!fm*AqkOw_+B)z2 zes(cgv5%_R!Z`mlW97dp6Dc-C{oVD*6wb#`Y5#%i1vXnGZ(Uj~De3)WiEKi?D0g46 zK>M%KMizvq;Q}(arMm|e^`7*k z7-V~WIrJaxC^Sj7gJVuFmv^UkgiGZIVhH2Wq`=~R3@!e@`xuVhvf73fPu;8U*Y|y> z=kf}uPwz37R)E<#jRHop)vh6~q(>vp?7r~AnCjRwztEWWnKVM19R?9q9GKWQYNxS= za!icqD$8kqwp}JPACKc%O(!?e(lF&FS{)4;#I4PC0)Y%)6+4sASg@))HDB9sbt1)W z5%eV{?P_HgcK~n)0Cxaz2LN{fa0dW)0B{EYcK~n)0Cxaz2LN{fa0dW)0B{EYcL1KS z15k>pW}e=7c~o^ANu`$|RXvmOO@k^QK^1~31XT!=;0GKT)!IPSQqTYAq$ea$!v(cc z_D~#XsL<3MOOM_Z=DUw^ zx651es$RH~3U0m^uL4(EBt+U3W>Ev<%9`^c|E^a&+xM)9Wk*CEtKnbz?B)Dyc_%3l4tpM@=gjl zx>P9_mJuH!MU#gK9axbOA|qVa{cC>BTZNrV-j6D~q z<+i?osWR4dbQkH(>LS)upZ%fyWqBGk1uz^9hAbmA52smX^K_iHL>U*B5T6D`nue42 z2_HLKA5Gg^g{EbHmgZ&;azBxmWxvoCXUQEMah|XJl%ZIQIa%%LoNVd)&b;S=vPFek zSTqD=2*?nSAs~}Z3LJlH17v=63Xq+g9-mGBTAF122x?WbkOZmHVn6~je#V-B$HNF} zmTVas^aIAkK$sU3{7`8&MQ+Veb*EInwc4lCs`I2qBE^rk(FK0wFY??Xl{w_fbZ5(= z4l=z1E7#77Ebonzo93VMTw(DGK}UX=VR#8PxP-c6tsE9bI!`6LJW^NYb~A0Jm&u2G zFRs`DO%v>;xMBor#YqTZ8)Ns@4SZ@AR|0HJZOcm~V>0L0=?i2wiq delta 2744 zcmV;p3P<(q7U~v%ABzY8000000RQcsZExbZ7RO)jdcrh|dCgX}>NM(&@8t4=DNSyLykTV6-BKnGvfc0s@HObB;eMyJR$i2HPbg8+L zOC6>}B7OFm(VIi2neo6luzNVp$H#|45y7Vs<1M1XVa(aR7)H#Ow69w+r(vXa zNRPn*oApb6kSHccOcJn2M>OQL$Jqyxq>VSWS6<*}WpzJUlnj}qdfHRT2BFgOehtSY z(}|3XbCIxCRH1JbZ?+CkPLI!KeX|ZQ$pYdhN{bPwE}Oz%P<;I4((og6AoR$@BytoPJaJ^+^|>^h>jlB;jl&i+zNxO)|NTD)sy0e zMO**aim87Ci2|}ggYaPMui|#Ac{A;H)ajgc)@wMbH&;I}HpnE_yFDSa+h~@{^}1X3 zJg?$<^@>|r*l5{#AIKjGl{C^}+D$@DXcVM>aUDiU+N;6tyKCrE4yR@S=bQ~F?`r6q zM|VC{ELi<}6xtUF4~Q9EdS)=8ff*nAcI?t7V3bc~FkOl?5Y)S0z;%d1he{Zom}87P zLlFclaK5B3hq+QGbFd2|J9DzpL6w%~GIiT>YvuRkzn4Q8dG}hset)tg7c^Kwu2$cF znA)+B5DZc$@d~>2<|cGa?{kzD{R}SrJC9(d+6Oi=^86Ij0h{KHS9EK8JzA$|yg5RMVan|;Irj(G7cLd{1 zX-N(n2IZ;|{k1@F+yiqpYbCdweac#YxAwq_kOSvBi9+qjB#I!sO(iq#9Onxvd-Aqx zj(4p2aKsKM=cSoEnLt`Ksma}{<9bd7(q+rOozgA zR%1tZF&7GS`2B)H)}K>IWd_Kh>HV|B3|(FABHDI%CA5mQO1Pl8XGVJX?UKWA8Qu zX@UJG1=oY>sVwV_mFBZs_62%>`)vIe7_PeOJMN^>ePEUW#^~ge?N#3T1K%SS-P%eP z2a>`R?9e!z4B(oo8|9d3-|z~nnVL5%gQGjZ$e6RPn- zt^Mk&NmUz9mTLE4l|_9$wtUF?aMfWhAkL-8mO-(7_waCOrBiLUeM+r=cau=F^*ma7 zid{c=iJ*MsQ0yU$N=2Q-EZ+xce*@=06R z;2FnN(TUlx@~}Eh+{PwujgzR~K}XuhC5;ZX?o7!RHgYriCnyG5alj3)89@ z@h1uSaJ#VW*Rk%fbhKmbDEIeTEU&)(!4kktQD9f zUXAcDV#J+8O~rbD7ls9ctLh$l{@sz3LYAh}vjuQE&ni%1!7g08QNG(fZJ&34Kf9Q& z*hkfDVVr-OvGQM~sT3Qc)!Jrca_1wcwEw{M0-GoupY8O^KwR**C?l+*Ig%#kr`>Rxd z**Oh;M$*-;A+DrPL(c5J@Y0y-+B3h%?fI9%V1AsdKxC4MY0JsByI{>%?fI9%V1AsdKPuKw{L{*iQSlvcZ z)p015UWHWkO~yA3s;UU85L6+kLQsXE3PBZuDg;%M2n-?>f+_@62&y&)s%C$;p%7GM zq$eOy!#TB5_DIEQIrD0EQ^AAJ%=VYKVqv6tjw{+gpXCRS3wAe zlcNYBAGfKSzr5MUUOn0TuJ#i-}6U-D=lIo?Fuumfk|a8c#v_?NsrdrHzR&v#M44@ zI_w2>+nCB0xLL&<3mCxFsfd0uDLPCgTyMLjLK}82_V(xa*@}*sScVqL^t<57oKDt_ zt7&Vj{!HE(M^`H43eqy-mq?KjA|phThzT82H+KJ8qrMJDyQQHC^z^>9oT*(UIxqvx z$~-MU!5DikP|Iw615-t;>F6%h+vR1fsXqTh`K$6YY6@UD8Vp%RXc11U%{(M^U7WT; z8JCt2zYU6${RtmILz=cRnzsMgGb&Mi zy2Ov{L7qFLw18Zh?rd4qL8^CP|tH`8W%nf{XR#T7fCiDIS4Y}!e#iJT2N8*(<23=17QZ9|-`THarQvx(j{ y49X;l0x@}fCUnhvQtAmvWdYHGcoh;;sQi+8PhuuX%;~-1QvU;F$czC7%m4tQYeH23 diff --git a/adapters/forge/logs/debug-4.log.gz b/adapters/forge/logs/debug-4.log.gz index a9b0837da7525929dce3d3ebcc1216b4b443a71b..b9e015d4cc64d58574013ee3ed682242d349d2ee 100644 GIT binary patch delta 2750 zcmV;v3PJUd7U33uABzY8000000RQcsYj4{)7KT4he+5wlhzo2KOTHv_7dvosnMu(m zT|3==2#SK1Xh#VxYDmgSI+*?Kdnih_W6O@5IN1zv3N*GU>b!^K;YAN+>%y&elA%}i znxxulSKW54=D4lqcXDyglqNSqUNgCSc=X}xZ;%!WCt_NE-#Lj6xf4d3$%t}?i{UVg zhR#Wr@CiOMc}#sRzy>tonq=pafutj5Cr8Sj9rn}4+GEi+&FHmnd zRd-(fTc>+}3P~q2RK~n{B|eQJp@{?+_?V^j+YiNvkuV?wA<6bAjF>O!K(}K~!$@tD zK7#``>*xF+QB01RBw&$_X~=1xv-c!P8*gH-`gA{8l#H09de&3PhN05(ZVksI(}|3X zbCIxCRIzV+tvTOLPLI##eX|ZQ$pYdhN{bQk1;lfIq)9BrI1E63JA{lsAaa?6Y(F$u z+%8t!Zi><-IwB;MQ{J{ zHm3eHBnrp|4Z?%Be-*ZyTdmHu+flcB)?KgRsNP)tz}O&@Snu?O(5=Q^z2$nd3h8&clW&^M26 zeW+Nl`u8ZbFA^RQGrIK6U_t{kJ`C*GrA@#npUPmi6lox+H@|@E5QPqvFgh{E7eai+2& zhYf>j-H85LAUNrPxtg_-Th2aZt=qVNXGO@NbCpD)c4QJo5Z#<06jGT1a%6fxta*)IW}3lNnZTjSy;NJoy+;)B3zZcNS&;CeaNApFj9Aw4X$3)2k^kP41!y|E39T}-mDCcZkeCJGQm!ien|o%-+Q!Frw^+v>g$Q+LpFe`4s!wVMT%@06x(=yqVM)DKfM#s7&2_-pCQPnZDE6F z5?4hhX2Z(;>NIg1o6PQi?)0s*m7ZT;(oMcb*O9o1NG}DSkLa2fvN*=YD|b(@Fb=ofzQFoJ#T0nQN-`1$%dJ89(a8F%NAF&vcp0zHhF7R!ii|8qRwij_lp{ zBhxD3&Tr{qYsy4w+9?(iC(z}>n%lX_wdDpR4aZrcxmRDNc|a4z0@A;0&!V5P;3FRP z?LLX2b=2W1*1v*rnV3P7j1pz`2?BBx>Pxcy*R~;ywK}=*FR588FiC0+ z!o!FWcL6mO>zx~a77VVcJLvhhM^Xw|nN80Y!09}zK!pW6aP3CU`E12L zsAdb}{L_q;|EkQS*buEZHY1ZeA3>%42d)>`Vv%Z_(&|V_?`BJ66S76Q`+^19e~~t_ zAndtiyk>P>nP*|t7En#;t>ZYP-XJ5$v1G4gewVm#uY?PK$lw<49#qsjGLT}F?)7ER zf3l;{Cg~22Iem%zUK#n37{hopE3kSWLyQ0KK89nrtaf0<)2ua{`>h7lb8!VUYGo?G z?3{)^Bk5|_5LYswA!l}9cx6oW?3tfwM2Acop{*W+h{_Ml>>G8HNJBYhMs$_tG}|sS znh(eEqNbC7ndoSka$~KI#th=tW;=mEhL4J!NoXus)t#D;UAQ`t;wBIJ0+V*N(!d=6 z+yTHH0Neq<9RS<`z#RbG0l*yq+yTHH0Neq<9RS<`z#RbG0l*!AN9+I;qpJDa;h!H> z9fwlsGNfu?GQMh1RYy>Tpb9}1f+_@62&xcNA*e!=mIn|fLQu6SP}TIxfU1o21O#e0 zr&h||sp_Q!DUqzA-r)^WuW0Rn+zFG&2Om`QV%+JMUJ}O=?6aUv*1s|7RS}}OJCp2p zM!gl^8Ew|rxe-hX&imBAewnW<`kc`yiJyYvjKk^!czhJ4fH2FVpkBW~lbav0RR>n) z*kZzms<`HpLJ9x7p|oI>+kuaz?Bv;k#>cd*TA&0mORL~=%fd0?VAxl zFyd(;IUV%_x@Amd3*4+?js*QqEOnG{{760W!0N}&xq7uWU|_*qFuOe{kSW%@mE zWkDzF#`Sc`Q~sH}bB>lODlP{!o5do<>ap3`c_@%LpyQ zX_nbM9;cNkS7LXQyABzY8000000RQcsZExDh7RO(2KgB4D6e`^~7z_k@byt)myQ$hF z7uwqwQB@8008R#vcVv%;V2DX*ZdN*#8t{00{%2;+Joq;TuFG~i8dl1c zIIQd3YgY^Q!COwxBNge8BlIj|sF8j#n?FrM&cwihjJelR=vs1nZ;Ap~lizt5_a`v*)n#v*5UFHhusTOgT)ty$k zY&mvyN&WnP;_MQV&ZV!6dGksn8~O_Bo_WNLlon&+3TTT!lTeC@?=eXZe8~76YMn{Q z_FaR;gM7uxa_fI>cQ2L|Lnf7x1PRX{vp_+KQa9w3Kr;Nbi64-o36)0!!NCW0#O?ybJ@}!U!<)V=!@ps0^!dp6c{sCwd6?Ne45`#U<#wZLS8I)Gqfu)% zYL#ZW)@&|aqpTYR-hh{WFrnP{NaHB7g4r-d=0Z zT;1wRam#|ef9!?Sy@f;p*`PtVxA#|myH$^u+U>O4z3gt+a8&QEeju)86zapi5ZZ23 zY^Ujel$*9~LmgF{&HCC#ORsxJ{)nigf%XG4KKPo@z>DMB4LN&^5i!Qi}xaj)r{aXU*m4jr-~ijc(?P38KQ{8Y-64H5?($rJrdsU(2Y22yDn3I zN=V2%fpMm^Ax9DhWygsAnj<*tftgxjC9|9!rL9|gU`5EGbrS`?wqz6p5ZH{W+}jUdAj#=)s#L0E&z16XWbs^nW|>SPtn~(Y1*-3^ zK<0F3=i%!_zlxHYyJBi6NiTtSe3l;?lt z*#a~kd-owobL>AUxE@qbWm9j2g~}`J?aTF_V;F9gwl-|V(cLl20AqB1l=Q0V_5k&El>%b7ksza{b z5jATCCP}qMxF0ZLub`%Uz0ZaPgR5!^J^%hxN+C;&>FEMEo@W)PFz*oV-6%ioU9_)y zzh8b{tk_4@lEPU3G-KtzN((7Th&r|1$Yjn3P-*{x`vtaIr0TA;+EUW#Vu_N3bWv`< zV1bTbq>T~~_OoSwD)sWVGCze;CxB{BZ!OCrPK}HqNB&-kv%II5yH~;mWUzC04@&AS z8AvgX_cBxHKig4gk$4BkT)sxWQAB<$CNLf?3T)oT(Bl8Qk6|TSR)?_Ssnsjxx>JUF z&aHr2d3jeew*nNC6W?VdUhNvjx$+Z#4d{?bBedUR5K-BQg?*!L z6lf^N!icWooMzi)LG$rAUe&Zx6KxGsZm8AighAXU+0G%5;ZU(l35^A-x(jpIg}W0e z?y{iIF`2AZYPbV{I{>%?fI9%V1AsdKxC4MY0JsByI{>%?fI9%V1AsdKxC4MY0JsD2 zgdKo!Uet{bf=R)7pSrg%^N~fDGa5wUb5NXdSiJ|2PlFf`rdbr!>lHLP95K~NWX=*y z_*fNJD+u8b!jqW@AQ^V{@K>Q9f0=(uF@ZcU1E*N<+{eUz0-Lgv)(9kj{mci%mLFJp zB~)nXiKWLM3ai&1;cnM==BIk$PRhOg7V|eIiP9n@GFf3}H88KNH9usWchRG@_SJ}Y zjCfo~PRD(Z?i*9-0ynLgGXVp*x)8w+CPkO2i0gg3lxriIi>t>g{H&lOCYGUvQvDvd zvZ9lAV<&En;=jqeW_gvbb!ehC?WaA(i2)tlRJv^(mXK#!kW z%b40I(w-S;3iGt=0%PpCKrOZP6-?!^rqg?0@0T~Rruym^~|v+D{pZwV9Jup3cb%KX+Dr9w^&XxQ#`V zP6`}<2*?nSAs|CQwlhF>Y%c+_^NX{~sv2=PFg${DYt~Kg=*(fDINSP`45*Cs~oMqCWT4$UJVQ&GfSPA>Yp< z)}fJNrN?Y0lUx&%1`8oS4LRG6I9t8E{Pk#goK5s^U{EGe;EDOqXF}JUl2TtlD)WdI eq*@^%g~~6fwYQ0l98Olm>8x~SA|s=^ z$X+2?A?uKp{C=TqV)~>tCFA-1^kFY>h!_x5(y1Mz%tyS~iVjocuNGyuc{ zUgAr`bqNbPNZNOLANIe4?jyuD584{vBQGSFZ0!bZ@H~4`E^T;UrRV}uOg)>8@d{Mt zVDr`I(o<{MFk&kAeC=~F_@7%Wcm9tfLE-Q)RtR6MG`m&&4TpOczX zj*_0UI{cKKhtM0oF z=H}jwR=0<&;}vsij|#O7e;Wg%$Ya~D<;dG}@?TmehNCtu{5F?WB0R1mbRsKk8=bx9 zUW`_RO$Rhr`G#!rqMc1K=kwS)p-Z2C5E{P@fY^jSk7X<#T{?E* zSqF^HHqO{l27O=ujH+L)%TloqpJJvN=`jIq@jugbxCD;TR?Kcs!qM_4n*y-R?iWI> zYTo)E{$_zK59TxPv)G;S-T7IJJ%Oh|9%$mNP-#F0#Q>=*vO^`Eg)(q~^RyYcuWHyL z1Ey3n>QGPBt;G{+W83p+_K?zmWfX^DnAaWAhn1Do(uxNE@rteY(Q-!eYn%1ar@uW- zgl@gXIlci5AOONU@zBdv=HJ)ayiG)p`JE0Ugxbx*+09%Rk1X|kOx6m4kKMFl zaK8aNuZ9Lj!q@If@7t!$@}P!YK9inoIG@}Yjt9cI5+dB-I!A05&)8cNDUVm6 zQlj*cz49Lrc8l_ER#oUh6f2|T6?vqhUGT@r#>%jDj!L+@kF%s(5hNW8#x_=a_Eat0 zhu*Q6ZP?$bhF(pbq+fcYkS)n$Tp^*{?D!B(4HkB;`_vox39YQj8C_mN^38u{jra5I zvDXj21EmP4zfOr1(@mI@lk!|pi_iS)zToOC$GUS>HqlGoQUX&>6CUNQJ;I6<*wBm2 z>`gbQD{DUuptg*Lv1Cr4^@(mR4}P;Jo^K-B8lzb*M#&6dGY(k?mo)3$*X3oiC}mT>vGjBe}6MV1c5jx&7W5G^1~$!R%uovYTiqwdwl*YtR2@hs2Ma5 zLY#+})l9VgNqEJZQ-%jS5u_o0XVi1~+H#7BrHWDZ_`~llEd!QYhe|lM>7xh$4eglo zmH5tfFlq87?IkSiOAn0FdgIi`pR_Wb6`D~qZ+>Y8_dHu~Kr+<);cbZjPsd}fGF#1W zmOQliaP)MI$Bg@`PcQ7*8B?`2=vQ=5seOl(6Bv>Vdt{z0J5?hKjCnK>2vIJqUq*;1 zuB|c=M@>#}#7keh@^0=767Q1tnf^Y&@N2KVgL0U$4TKV@ZKN7I)5VVh{gIa+D_uQK zM-wnBje9SlrJ~9kysSw)--FTAjoIZXcZMgq3y?v-u`IOEXShP)-qtkbySLyD7ss(z z8hjYGZnO0x6eky+Xra1}>S{9ED>5-m8u^=1s~I=~nmoE|#9-KHDBCJc9%nJ*Y_zz? zso*YGROa5wY97~N8_A)0wrQMbjPM$!FkxT=H?7=$%R05(YMWNy$UBJAZpo>dFd8*; ztBhW&rJ)E!UY_FmV=vF(75(zMv-bUSj*urUQdN&R_o5o$5@5&7>6+U`r%1FYL#K;F z4;nE}h~zT#ryI?jb;vUC4O55{>{b^KBT+3bqzONCZi=)@-+HeDF9|J_^Z1U+vWDTQ@F%GCjO>ve6c;>k1`D_b$o#IuMf{ z$MN5=qxn#U{mdJ95*g5zD_}v`5Fbimm*Eahhcj zF2q;-(<8FQO+0tUb(-d^@IG#gy)PfH4vx^<#D2NZ%o8lRXz?$o>B)~;6%T^6Ywb>Z zo?gzFZ zwHD}Stk2aDm<^XFTswQo6Jea4V5~kMfs-99sHcj>0OtPy{y&y$j>b(jX_>{b8Z`{0 z+lODK-?_w?Pxh7J&B0IgRO0AsIw}YV?gqSz6x+M&DK~#y;ooHd0|}* z5!Hexr*iUx74$r@eSmZ>5mC5d;y1{hjII;dJQ+qizKO~~iwZrv|N z{~ygdGL(Iw8K}0bq(#RciL5Q_`Npx7y@W94A&5@dG+YUIOz%;AarGk4FT4rNU^5eOI<{KSUm=^3o4BYw zWr#dXZ|w_7KxE;iXYzq5FynppRHqvY~+RdZC~1?SxY#4*=%g~Ia0CiWyLM3h0BB$P*NdCw)}#TosErxYDM Hm5%N|p_z&H literal 2297 zcmZw5X*?4Q0|4;!CLT(OIVNVzePvT_IiBR05tB0u6Pqh%xvwT9ch1diuH3ieddL_l z_mI1-h-8n++57vwAK&_a{DWSwp8KC)+XOqmc@^%Vf(6qemPbY)r}_>6!%+f4wEqElqm~TGEb4kYj@?2vng_blzxv%5R0A5!5Ci3oH>mM;^ zU3tM4nkSVDCC@`_a1kYidu2}7HSOj!$R7DPB(`ABRuFj0W2nIn(e%lyb&D}CH4<#L za!KVY3v49p`;}txaK79dTucReC-(^LKaZl`R}5DE7oOC&c)C8H8dKp z33(C-6Kr@fl)}!e7TR5wKIW`esWd$b0Wcevel#|Lkr157S5`*p@~s?S)?B4^Nr=+XJ5YDsLY6AF4c2~XM9@p7|rGwBuEXY2+1#0< zEx|rjzO8qnU+sI9T`@E%?fNg4(LnSx?r^5w(NI{lJ>H1(w9*n1mK>oktJ+4s>u9^6 zlk5DeaK)Bi$aU7kfBr6tg1WIVJ9K;K@u#I_ADN^buP$Du5^@Rp{xrihblkdG04JLS z!HLZk7xlnba;xGSM;pDpyswSTby4{3HK5ATp1O@@XIwh>f|{UX)VvqFtHI*Pe`;*M zZz_medJD=1gkO0h+pc48z0**11?0%iuu1S2;1(~qUroj#Oo-dy60NwGrkqK+8LCL|iKA1@57n3ye97|uMa>1)EMFR&&QW&vCRKI1zf zmEFfJjO{?5;|G4qXBF#!Q_;oCiXD|rB_^Hvyt6T1Lh!ET1tCh$E|z`ciw+HvOIxuS zuw07A*xCppxY~*^9#)y48uQTbFtXAi5PtrLsBlbn`t=((xr7|~o0iv->tE+4_Q;YZ zwrAXXk^^20Qj!QPOA_re)4OyRS^oXnRSrjsDNMe(|73W8`#JnTxL@_*zgT%O^O9Oh ziVf|@q5;pvpY_Bv)bU0>HlnZ3zVFji5TEbu_*DrD@s{toZ>G<0Pq5QYgp!)?pBl8V zwbQNa3E9w8plQO({taopnNE{Y!YQfw^Iwq5ZIoh4mG|W6dU6Z)4Qm7v{ysD-RYguf zNjr9mHY%zIe&Sc6<4ctQe&9^kuTXke%i5SA0l&)VoG)mUkTn~Wi*GAF)To}HYBuMh z0i8srHl5yWXm|!P9~-J5zv=77X&5h{L8CDOD%)g4XW8HvfXU$bf&1@LJgmkH;L3PS~aE-1l?)TV4 zH&=CBsY#2bhioDosH@UhFyP;-iue(g*Gn-&yis7F4HvTwwFynMg9mWsL_e;g3gdNE zaF5wmx%;J_S+JI0MSy<~H2~hX>R8k0m3Dqsl!dGcgTD&ZOWbWil87lL;5^-1I1e@o zccT>A;mDmGlQlAH!b$_S|0OeFJetkt!k{F==a zo-jBIPXlt&RUg* z^|cbK&_X_*;t>~fBZpuh{md>!r7s-5#WJG@YD<-p4kF9k$gwcPg(JSONObject.java:260) ~[shared-local-SNAPSHOT.jar:?] at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) ~[main/:?] ... 45 more -[02Aug2026 01:34:54.851] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost -[02Aug2026 01:34:54.851] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json.broken-1785648894851 -[02Aug2026 01:34:54.894] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[10Aug2026 12:40:44.580] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost +[10Aug2026 12:40:44.581] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json.broken-1786380044580 +[10Aug2026 12:40:44.633] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: second disable failed at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -110,7 +110,7 @@ java.lang.RuntimeException: second disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.896] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +[10Aug2026 12:40:44.635] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService java.lang.RuntimeException: first disable failed at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -155,7 +155,7 @@ java.lang.RuntimeException: first disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.899] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s) +[10Aug2026 12:40:44.638] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s) java.lang.RuntimeException: second disable failed at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -203,7 +203,7 @@ java.lang.RuntimeException: second disable failed Suppressed: java.lang.RuntimeException: first disable failed at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?] ... 42 more -[02Aug2026 01:34:54.902] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[10Aug2026 12:40:44.642] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -248,7 +248,7 @@ java.lang.RuntimeException: cleanup failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.905] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[10Aug2026 12:40:44.644] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: enable failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -296,7 +296,7 @@ java.lang.RuntimeException: enable failed Suppressed: java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] ... 42 more -[02Aug2026 01:34:54.939] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +[10Aug2026 12:40:44.683] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144) ~[main/:?] @@ -343,7 +343,7 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.942] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +[10Aug2026 12:40:44.685] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159) ~[main/:?] @@ -390,7 +390,7 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.946] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +[10Aug2026 12:40:44.690] [Test worker/ERROR] [Iris/]: [worldcheck] check failed java.lang.IllegalStateException: check failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139) ~[main/:?] @@ -437,8 +437,8 @@ java.lang.IllegalStateException: check failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.952] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' -[02Aug2026 01:34:54.953] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider) +[10Aug2026 12:40:44.704] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' +[10Aug2026 12:40:44.705] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider) java.lang.RuntimeException: provider init failed at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -483,4 +483,4 @@ java.lang.RuntimeException: provider init failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.966] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player) +[10Aug2026 12:40:44.720] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player) diff --git a/adapters/forge/logs/latest.log b/adapters/forge/logs/latest.log index c5c8db249..774b9aadc 100644 --- a/adapters/forge/logs/latest.log +++ b/adapters/forge/logs/latest.log @@ -1,9 +1,9 @@ -[02Aug2026 01:34:54.789] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0 -[02Aug2026 01:34:54.795] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes -[02Aug2026 01:34:54.825] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' -[02Aug2026 01:34:54.832] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial6681980702089307791/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial6681980702089307791/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"} -[02Aug2026 01:34:54.844] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json is corrupt; quarantining it and continuing boot -java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json could not be read; refusing to discard persistent worlds +[10Aug2026 12:40:44.506] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0 +[10Aug2026 12:40:44.507] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes +[10Aug2026 12:40:44.546] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[10Aug2026 12:40:44.555] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial8292600765329941987/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial8292600765329941987/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"} +[10Aug2026 12:40:44.572] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json is corrupt; quarantining it and continuing boot +java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json could not be read; refusing to discard persistent worlds at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) ~[main/:?] at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) ~[main/:?] at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) ~[main/:?] @@ -60,9 +60,9 @@ Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must en at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:260) ~[shared-local-SNAPSHOT.jar:?] at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) ~[main/:?] ... 45 more -[02Aug2026 01:34:54.851] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost -[02Aug2026 01:34:54.851] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot3401444738373117988/iris-dimensions.json.broken-1785648894851 -[02Aug2026 01:34:54.894] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[10Aug2026 12:40:44.580] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost +[10Aug2026 12:40:44.581] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot9980172071037313072/iris-dimensions.json.broken-1786380044580 +[10Aug2026 12:40:44.633] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: second disable failed at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -107,7 +107,7 @@ java.lang.RuntimeException: second disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.896] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +[10Aug2026 12:40:44.635] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService java.lang.RuntimeException: first disable failed at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -152,7 +152,7 @@ java.lang.RuntimeException: first disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.899] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s) +[10Aug2026 12:40:44.638] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s) java.lang.RuntimeException: second disable failed at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -200,7 +200,7 @@ java.lang.RuntimeException: second disable failed Suppressed: java.lang.RuntimeException: first disable failed at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?] ... 42 more -[02Aug2026 01:34:54.902] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[10Aug2026 12:40:44.642] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -245,7 +245,7 @@ java.lang.RuntimeException: cleanup failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.905] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[10Aug2026 12:40:44.644] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: enable failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -293,7 +293,7 @@ java.lang.RuntimeException: enable failed Suppressed: java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] ... 42 more -[02Aug2026 01:34:54.939] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +[10Aug2026 12:40:44.683] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:144) ~[main/:?] @@ -340,7 +340,7 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.942] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +[10Aug2026 12:40:44.685] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:159) ~[main/:?] @@ -387,7 +387,7 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.946] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +[10Aug2026 12:40:44.690] [Test worker/ERROR] [Iris/]: [worldcheck] check failed java.lang.IllegalStateException: check failed at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?] at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:139) ~[main/:?] @@ -434,8 +434,8 @@ java.lang.IllegalStateException: check failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.952] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' -[02Aug2026 01:34:54.953] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider) +[10Aug2026 12:40:44.704] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' +[10Aug2026 12:40:44.705] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider) java.lang.RuntimeException: provider init failed at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -480,4 +480,4 @@ java.lang.RuntimeException: provider init failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[02Aug2026 01:34:54.966] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player) +[10Aug2026 12:40:44.720] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player) diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedParityProbe.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedParityProbe.java index 94f8f5527..d02e404ac 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedParityProbe.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedParityProbe.java @@ -141,7 +141,7 @@ public final class ModdedParityProbe { .maxHeight(dimension.getMaxHeight()) .build(); EngineTarget target = new EngineTarget(world, dimension, data); - Engine engine = new IrisEngine(target, false); + Engine engine = new IrisEngine(target, IrisEngine.InitializationMode.RUNTIME); settle(); int minY = dimension.getMinHeight(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 95218bc67..19ed6d4e2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -122,7 +122,9 @@ public final class ModdedWorldEngines { .maxHeight(dimension.getMaxHeight()) .platformWorld(new ModdedPlatformWorld(level)) .build(); - Engine engine = new IrisEngine(new EngineTarget(world, dimension, data), false); + Engine engine = new IrisEngine( + new EngineTarget(world, dimension, data), + IrisEngine.InitializationMode.RUNTIME); if (engine.isClosed() || engine.getComplex() == null) { IllegalStateException failure = new IllegalStateException("Iris engine for " + level.dimension().identifier() + " did not initialize a ready biome complex"); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java index bc1ae0dcc..603d894d9 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java @@ -23,11 +23,11 @@ import art.arcane.iris.core.structure.StructureIndexService; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructureAssembler; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.ObjectPlaceMode; -import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; @@ -44,6 +44,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.function.Predicate; @@ -148,8 +149,9 @@ public final class ModdedStructureCommands { } StructureAssembler assembler = StructureAssembler.forData( data, structure, new IrisPosition(0, 64, 0)); - KList pieces = assembler.assemble(new RNG(1234)); - if (pieces == null || pieces.isEmpty()) { + StructureAssemblyResult assembly = assembler.assemble(new RNG(1234)); + List pieces = assembly.pieces(); + if (!assembly.hasOutput()) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STRUCTURE_COMMANDS_STRUCTURE_ASSEMBLED_0_PIECES_CHECK_STARTPOOL, MessageArgument.untrusted("key", key), MessageArgument.untrusted("value", structure.getStartPool()))); return 0; } @@ -191,8 +193,9 @@ public final class ModdedStructureCommands { StructureAssembler assembler = StructureAssembler.forData( data, structure, new IrisPosition(originX, originY, originZ)); RNG rng = new RNG((long) originX * 341873128712L + originZ); - KList pieces = assembler.assemble(rng); - if (pieces == null || pieces.isEmpty()) { + StructureAssemblyResult assembly = assembler.assemble(rng); + List pieces = assembly.pieces(); + if (!assembly.hasOutput()) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STRUCTURE_COMMANDS_STRUCTURE_ASSEMBLED_0_PIECES, MessageArgument.untrusted("key", key))); return 0; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java index 92fb26767..4cb118ae2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -189,30 +189,37 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } private void runTilePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk) { - int minHeight = engine.getWorld().minHeight(); int baseX = chunkX << 4; int baseZ = chunkZ << 4; - chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> { - int y = yf + minHeight; - if (y < level.getMinY() || y >= level.getMaxY()) { - return; - } - applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData()); - }); + int minHeight = engine.getWorld().minHeight(); + materializeDeferredSlice(chunk, TileWrapper.class, () -> + chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> { + int y = yf + minHeight; + if (y < level.getMinY() || y >= level.getMaxY()) { + return; + } + applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData()); + })); } private void runCustomPass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk) { - int minHeight = engine.getWorld().minHeight(); int baseX = chunkX << 4; int baseZ = chunkZ << 4; - chunk.iterate(Identifier.class, (Integer x, Integer yf, Integer z, Identifier identifier) -> { - int y = yf + minHeight; - if (y < level.getMinY() || y >= level.getMaxY()) { - return; - } - BlockPos position = new BlockPos(baseX + (x & 15), y, baseZ + (z & 15)); - ModdedCustomContentRegistry.processBlockPlacement(engine, level, position, identifier.toString()); - }); + int minHeight = engine.getWorld().minHeight(); + materializeDeferredSlice(chunk, Identifier.class, () -> + chunk.iterate(Identifier.class, (Integer x, Integer yf, Integer z, Identifier identifier) -> { + int y = yf + minHeight; + if (y < level.getMinY() || y >= level.getMaxY()) { + return; + } + BlockPos position = new BlockPos(baseX + (x & 15), y, baseZ + (z & 15)); + ModdedCustomContentRegistry.processBlockPlacement(engine, level, position, identifier.toString()); + })); + } + + static void materializeDeferredSlice(MantleChunk chunk, Class sliceType, Runnable materializer) { + materializer.run(); + chunk.deleteSlices(sliceType); } private void applyTile(ServerLevel level, int x, int y, int z, TileData tile) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java index dcd63ed74..2e4b82df6 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java @@ -1,8 +1,19 @@ package art.arcane.iris.modded.service; +import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; +import art.arcane.volmlib.util.mantle.runtime.MantleHooks; +import art.arcane.volmlib.util.matter.Matter; import org.junit.Test; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; + +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 ModdedChunkUpdateServiceTest { @@ -20,4 +31,67 @@ public class ModdedChunkUpdateServiceTest { public void skipsLevelsWithoutPlayersOrForcedChunks() { assertFalse(ModdedChunkUpdateService.hasUpdateTargets(false, false)); } + + @Test + public void deferredSliceIsDeletedAfterMaterialization() { + RecordingMantleChunk chunk = new RecordingMantleChunk(); + + ModdedChunkUpdateService.materializeDeferredSlice( + chunk, + TileWrapper.class, + () -> chunk.record("materialize") + ); + + assertEquals(List.of("materialize", "delete:" + TileWrapper.class.getName()), + chunk.operations()); + } + + @Test + public void failedMaterializationRetainsDeferredSlice() { + RecordingMantleChunk chunk = new RecordingMantleChunk(); + + assertThrows(IllegalStateException.class, () -> + ModdedChunkUpdateService.materializeDeferredSlice( + chunk, + TileWrapper.class, + () -> { + chunk.record("materialize"); + throw new IllegalStateException("materialization failure"); + } + )); + + assertEquals(List.of("materialize"), chunk.operations()); + } + + private static final class RecordingMantleChunk extends MantleChunk { + private final ArrayList operations = new ArrayList<>(); + + private RecordingMantleChunk() { + super(1, 0, 0, emptyAdapter(), MantleHooks.NONE); + } + + @Override + public void deleteSlices(Class type) { + operations.add("delete:" + type.getName()); + } + + private void record(String operation) { + operations.add(operation); + } + + private List operations() { + return List.copyOf(operations); + } + } + + @SuppressWarnings("unchecked") + private static MantleDataAdapter emptyAdapter() { + return (MantleDataAdapter) Proxy.newProxyInstance( + MantleDataAdapter.class.getClassLoader(), + new Class[]{MantleDataAdapter.class}, + (proxy, method, arguments) -> { + throw new UnsupportedOperationException(method.getName()); + } + ); + } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java index 03efa2d1a..421e59e32 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java @@ -416,7 +416,7 @@ public class NativeStructureReferenceRepairTest { private IrisData data; private TestEngine() { - super(null, false); + super(null, InitializationMode.RUNTIME); } @Override diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index e21248769..04eb94289 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -60,6 +60,12 @@ art/arcane/iris/core/service/BoardSVC.java art/arcane/iris/core/service/EntityRiseSVC.java art/arcane/iris/core/service/ExternalDataSVC.java art/arcane/iris/core/service/GlobalCacheSVC.java +art/arcane/iris/core/service/JigsawStudioMarkerParser.java +art/arcane/iris/core/service/JigsawStudioMenuController.java +art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRenderer.java +art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java +art/arcane/iris/core/service/JigsawStudioService.java +art/arcane/iris/core/service/JigsawStudioToolCodec.java art/arcane/iris/core/service/ObjectSVC.java art/arcane/iris/core/service/ObjectStudioSaveService.java art/arcane/iris/core/service/StudioSVC.java @@ -123,6 +129,7 @@ art/arcane/iris/engine/platform/BukkitChunkGenerator.java art/arcane/iris/engine/platform/DummyBiomeProvider.java art/arcane/iris/engine/platform/DummyChunkGenerator.java art/arcane/iris/engine/platform/EngineBukkitOps.java +art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java art/arcane/iris/engine/platform/studio/generators/ObjectStudioGenerator.java art/arcane/iris/platform/bukkit/BukkitBiome.java art/arcane/iris/platform/bukkit/BukkitBlockResolution.java diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java index 03ac1b90f..e1783ac12 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -1,7 +1,9 @@ package art.arcane.iris.core; +import art.arcane.iris.BuildConstants; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.core.pack.PackDirectoryResolver; @@ -12,15 +14,20 @@ import art.arcane.volmlib.util.collection.KSet; import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; +import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -30,18 +37,105 @@ import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.stream.Stream; public final class IrisDatapackCompiler { + private static final int INPUT_FINGERPRINT_SCHEMA = 1; private static final int WORLD_PACK_SCAN_DEPTH = 8; + private static final List INPUT_DIRECTORIES = List.of("dimensions", "biomes", "snippet"); private IrisDatapackCompiler() { } public static List collectPackRoots(Path dataDirectory, Path serverRoot) throws IOException { + return collectPackRoots(dataDirectory, serverRoot, true); + } + + public static List collectCompilerInputRoots(Path dataDirectory, Path serverRoot) throws IOException { + return collectPackRoots(dataDirectory, serverRoot, false); + } + + private static List collectPackRoots( + Path dataDirectory, + Path serverRoot, + boolean validateWholePack + ) throws IOException { LinkedHashMap roots = new LinkedHashMap<>(); - collectInstalledPackRoots(dataDirectory.resolve("packs"), roots); - collectWorldPackRoots(serverRoot.resolve("dimensions"), roots); + collectInstalledPackRoots(dataDirectory.resolve("packs"), roots, validateWholePack); + collectWorldPackRoots(serverRoot.resolve("dimensions"), roots, validateWholePack); return new ArrayList<>(roots.values()); } + public static String computeInputFingerprint( + List packRoots, + IDataFixer fixer, + boolean adjustVanillaHeight + ) throws IOException { + Objects.requireNonNull(fixer, "fixer"); + return computeInputFingerprint( + packRoots, + adjustVanillaHeight, + compilerIdentity(fixer)); + } + + static String computeInputFingerprint( + List packRoots, + boolean adjustVanillaHeight, + String compilerIdentity + ) throws IOException { + Objects.requireNonNull(packRoots, "packRoots"); + Objects.requireNonNull(compilerIdentity, "compilerIdentity"); + MessageDigest digest = sha256(); + updateDigestString(digest, "iris-datapack-compiler-input"); + updateDigestInt(digest, INPUT_FINGERPRINT_SCHEMA); + updateDigestString(digest, compilerIdentity); + digest.update((byte) (adjustVanillaHeight ? 1 : 0)); + updateDigestInt(digest, packRoots.size()); + + for (int index = 0; index < packRoots.size(); index++) { + File packRoot = Objects.requireNonNull(packRoots.get(index), "pack root"); + Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize(); + if (!Files.isDirectory(normalizedRoot)) { + throw new IOException("Iris datapack compiler input root is missing or unsafe: " + normalizedRoot); + } + Path realRoot = normalizedRoot.toRealPath(); + updateDigestInt(digest, index); + updateDigestString(digest, normalizedRoot.toString()); + updateDigestString(digest, realRoot.toString()); + boolean active = hasDimensions(normalizedRoot); + digest.update((byte) (active ? 1 : 0)); + if (!active) { + continue; + } + + List entries = collectCompilerInputEntries(normalizedRoot); + updateDigestInt(digest, entries.size()); + byte[] buffer = new byte[8192]; + for (CompilerInputEntry entry : entries) { + updateDigestString(digest, entry.relativePath()); + updateDigestLong(digest, Files.size(entry.source())); + try (InputStream input = Files.newInputStream(entry.source())) { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + } + } + } + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + public static String compilerIdentity(IDataFixer fixer) { + IDataFixer requiredFixer = Objects.requireNonNull(fixer, "fixer"); + return String.join( + "|", + Integer.toString(INPUT_FINGERPRINT_SCHEMA), + BuildConstants.COMMIT, + BuildConstants.MINECRAFT_VERSION, + requiredFixer.getClass().getName(), + Integer.toString(DataVersion.minSupportedPackFormat()), + Integer.toString(DataVersion.getLatest().getPackFormat())); + } + public static CompilationResult compile( List packRoots, KList datapackRoots, @@ -102,14 +196,22 @@ public final class IrisDatapackCompiler { return new CompilationResult(packCount, dimensionCount, countBiomes(biomes)); } - private static void collectInstalledPackRoots(Path packsRoot, Map roots) throws IOException { + private static void collectInstalledPackRoots( + Path packsRoot, + Map roots, + boolean validateWholePack + ) throws IOException { List candidates = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot.toFile()); for (File candidate : candidates) { - addPackRoot(candidate.toPath(), roots); + addPackRoot(candidate.toPath(), roots, validateWholePack); } } - private static void collectWorldPackRoots(Path dimensionsRoot, Map roots) throws IOException { + private static void collectWorldPackRoots( + Path dimensionsRoot, + Map roots, + boolean validateWholePack + ) throws IOException { if (Files.isSymbolicLink(dimensionsRoot) || !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) { return; @@ -133,11 +235,15 @@ public final class IrisDatapackCompiler { }); candidates.sort(Comparator.comparing(Path::toString)); for (Path candidate : candidates) { - addPackRoot(candidate, roots); + addPackRoot(candidate, roots, validateWholePack); } } - private static void addPackRoot(Path root, Map roots) throws IOException { + private static void addPackRoot( + Path root, + Map roots, + boolean validateWholePack + ) throws IOException { if (!hasDimensions(root)) { return; } @@ -145,8 +251,13 @@ public final class IrisDatapackCompiler { if (!Files.isDirectory(normalized)) { return; } - PackDirectoryResolver.requireSafePackTree(normalized.toFile()); Path identity = normalized.toRealPath(); + if (!Files.isDirectory(identity, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (validateWholePack) { + PackDirectoryResolver.requireSafePackTree(normalized.toFile()); + } roots.putIfAbsent(identity, normalized.toFile()); } @@ -165,6 +276,77 @@ public final class IrisDatapackCompiler { } } + private static List collectCompilerInputEntries(Path packRoot) throws IOException { + List entries = new ArrayList<>(); + for (String directoryName : INPUT_DIRECTORIES) { + Path directory = packRoot.resolve(directoryName); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + if (Files.isSymbolicLink(directory) + || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Iris datapack compiler input is missing or unsafe: " + directory); + } + Files.walkFileTree(directory, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path child, BasicFileAttributes attributes) throws IOException { + if (attributes.isSymbolicLink() || Files.isSymbolicLink(child)) { + throw new IOException("Iris datapack compiler input contains a symbolic link: " + child); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { + throw new IOException("Iris datapack compiler input contains a symbolic link: " + file); + } + if (!attributes.isRegularFile()) { + throw new IOException("Iris datapack compiler input contains an unsupported entry: " + file); + } + if (file.getFileName().toString().endsWith(".json")) { + String relativePath = packRoot.relativize(file).toString().replace(File.separatorChar, '/'); + entries.add(new CompilerInputEntry(file, relativePath)); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + throw new IOException("Unable to inspect Iris datapack compiler input: " + file, failure); + } + }); + } + entries.sort(Comparator.comparing(CompilerInputEntry::relativePath)); + return entries; + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 not available", exception); + } + } + + private static void updateDigestString(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + updateDigestInt(digest, bytes.length); + digest.update(bytes); + } + + private static void updateDigestInt(MessageDigest digest, int value) { + for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { + digest.update((byte) (value >>> shift)); + } + } + + private static void updateDigestLong(MessageDigest digest, long value) { + for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { + digest.update((byte) (value >>> shift)); + } + } + private static void resetOutputRoots(Collection datapackRoots) throws IOException { for (File datapackRoot : datapackRoots) { Path root = datapackRoot.toPath().toAbsolutePath().normalize(); @@ -204,6 +386,9 @@ public final class IrisDatapackCompiler { public record CompilationResult(int packCount, int dimensionCount, int biomeCount) { } + private record CompilerInputEntry(Path source, String relativePath) { + } + public static final class DimensionHeight { private final IDataFixer fixer; private final AtomicIntegerArray[] dimensions = new AtomicIntegerArray[3]; 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 aefdcfb6c..dda852282 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -21,6 +21,7 @@ package art.arcane.iris.core; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.datapack.DatapackIngestService; +import art.arcane.iris.core.datapack.DatapackIngestService.ReapplyOutcome; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; @@ -73,6 +74,7 @@ import java.util.List; import java.util.Objects; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; import java.util.stream.Stream; import art.arcane.iris.core.localization.BukkitRuntimeMessages; @@ -81,8 +83,18 @@ import art.arcane.volmlib.util.localization.MessageArgument; public class ServerConfigurator { private static final Object DATAPACK_INSTALL_LOCK = new Object(); private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace"; + private static final String COMPILER_INPUT_FINGERPRINT_CACHE = "datapack-compiler-input-fingerprint"; + private static volatile boolean loadedDatapackRuntimeReady; + private static volatile String loadedDatapackCompilerInputFingerprint = ""; + private static volatile long loadedDatapackRuntimeGeneration; + private static volatile boolean loadedDatapackRestartRequired; public static void configure() { + synchronized (DATAPACK_INSTALL_LOCK) { + invalidateLoadedDatapackRuntime(); + loadedDatapackCompilerInputFingerprint = ""; + loadedDatapackRestartRequired = false; + } IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration(); if (s.isConfigureSpigotTimeoutTime()) { J.attempt(ServerConfigurator::increaseKeepAliveSpigot); @@ -93,15 +105,91 @@ public class ServerConfigurator { } if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) { + loadedDatapackRuntimeReady = !IrisSettings.get().getGeneral().adjustVanillaHeight + && pinLoadedDatapackCompilerInputs( + DefaultPackBootstrapProvisioner.compilerInputFingerprintThisStartup()); IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install."); } else { DatapackInstallResult result = installDataPacks(true); + loadedDatapackRuntimeReady = result.succeeded() + && !result.restartRequired() + && pinLoadedDatapackCompilerInputs(); if (result.restartRequired() && IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()) { restart(); } } } + public static boolean isLoadedDatapackRuntimeReady(IrisDimension dimension) { + IrisDimension requiredDimension = Objects.requireNonNull(dimension, "Iris dimension"); + if (!loadedDatapackRuntimeReady + || loadedDatapackRestartRequired + || !BukkitPlatform.hasPlugin() + || !BukkitPlatform.plugin().isEnabled()) { + return false; + } + try { + if (!INMS.get().supportsIrisWorldGeneration() + || INMS.get().missingDimensionTypes(requiredDimension.getDimensionTypeKey())) { + return false; + } + String currentFingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer()); + return reusableRuntimeFingerprint( + loadedDatapackCompilerInputFingerprint, + currentFingerprint); + } catch (IOException | RuntimeException exception) { + IrisLogging.reportError("Unable to verify loaded Iris datapack compiler inputs.", exception); + return false; + } + } + + public static LoadedDatapackRuntimeInvalidation invalidateLoadedDatapackRuntime() { + synchronized (DATAPACK_INSTALL_LOCK) { + boolean wasReady = loadedDatapackRuntimeReady; + String fingerprint = loadedDatapackCompilerInputFingerprint; + loadedDatapackRuntimeReady = false; + loadedDatapackRuntimeGeneration++; + return new LoadedDatapackRuntimeInvalidation( + loadedDatapackRuntimeGeneration, + wasReady, + fingerprint); + } + } + + public static void requireDatapackRestart() { + synchronized (DATAPACK_INSTALL_LOCK) { + invalidateLoadedDatapackRuntime(); + loadedDatapackRestartRequired = true; + } + } + + public static void restoreLoadedDatapackRuntimeIfUnchanged( + LoadedDatapackRuntimeInvalidation invalidation + ) { + if (invalidation == null + || !invalidation.wasReady() + || invalidation.fingerprint().isBlank()) { + return; + } + String currentFingerprint; + try { + currentFingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer()); + } catch (IOException | RuntimeException exception) { + IrisLogging.reportError("Unable to restore loaded Iris datapack runtime readiness.", exception); + return; + } + synchronized (DATAPACK_INSTALL_LOCK) { + if (loadedDatapackRuntimeGeneration != invalidation.generation() + || loadedDatapackRuntimeReady + || loadedDatapackRestartRequired + || !reusableRuntimeFingerprint(invalidation.fingerprint(), currentFingerprint)) { + return; + } + loadedDatapackCompilerInputFingerprint = currentFingerprint; + loadedDatapackRuntimeReady = true; + } + } + private static void increaseKeepAliveSpigot() throws IOException, InvalidConfigurationException { File spigotConfig = new File("spigot.yml"); FileConfiguration f = new YamlConfiguration(); @@ -156,6 +244,26 @@ public class ServerConfigurator { } private static DatapackInstallResult installDataPacksLocked(IDataFixer fixer, boolean fullInstall) { + if (fixer == null) { + IrisLogging.error("Unable to install datapacks, fixer is null!"); + return DatapackInstallResult.failedResult(); + } + KList datapacksFolders = getDatapacksFolder(); + ReapplyOutcome reapply = DatapackIngestService.reapplyFromStaging(datapacksFolders); + if (!reapply.succeeded()) { + return DatapackInstallResult.failedResult(); + } + return compileDataPacksLocked(fixer, fullInstall, reapply); + } + + private static DatapackInstallResult compileDataPacksLocked( + IDataFixer fixer, + boolean fullInstall, + ReapplyOutcome reapply + ) { + if (!Objects.requireNonNull(reapply, "External datapack reapply outcome").succeeded()) { + return DatapackInstallResult.failedResult(); + } if (fixer == null) { IrisLogging.error("Unable to install datapacks, fixer is null!"); return DatapackInstallResult.failedResult(); @@ -165,18 +273,12 @@ public class ServerConfigurator { } else { IrisLogging.debug("Checking Data Packs..."); } - KList datapacksFolders = getDatapacksFolder(); - if (!DatapackIngestService.reapplyFromStaging(datapacksFolders)) { - IrisLogging.error("Unable to compile Iris datapacks while external datapack recovery is incomplete."); - return DatapackInstallResult.failedResult(); - } List packRoots; - try (Stream stream = allPacks()) { - packRoots = stream - .map(IrisData::getDataFolder) - .map(File::getAbsoluteFile) - .distinct() - .toList(); + try { + packRoots = collectCompilerPackRoots(); + } catch (IOException exception) { + IrisLogging.reportError("Unable to resolve Iris datapack compiler roots.", exception); + return DatapackInstallResult.failedResult(); } KList liveRoots = getIrisDatapackRoots(); @@ -237,7 +339,8 @@ public class ServerConfigurator { IrisLogging.debug("Data Packs Setup!"); } - boolean restartRequired = fullInstall && verifyDataPacksPost(); + boolean verifiedRestartRequired = fullInstall && verifyDataPacksPost(); + boolean restartRequired = fullInstall && (reapply.changed() || verifiedRestartRequired); return restartRequired ? DatapackInstallResult.restartRequiredResult() : DatapackInstallResult.readyResult(); @@ -264,33 +367,167 @@ public class ServerConfigurator { } public static DatapackInstallResult installDataPacksIfChanged(boolean fullInstall) { + return installDataPacksIfChanged(fullInstall, null); + } + + public static DatapackInstallResult installDataPacksIfChanged( + boolean fullInstall, + BiConsumer timingConsumer + ) { synchronized (DATAPACK_INSTALL_LOCK) { - File packsDir = IrisPlatforms.get().dataFolder("packs"); - File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint"); - FingerprintCache cached = readFingerprintCache(cacheFile.toPath()); - PackFingerprint fingerprint; - try { - fingerprint = resolvePackFingerprint(packsDir, cached.metadata(), cached.content()); - } catch (RuntimeException exception) { - IrisLogging.reportError("Unable to fingerprint Iris packs safely", exception); + long totalStart = System.nanoTime(); + File cacheFile = new File( + IrisPlatforms.get().dataFolder("cache"), + COMPILER_INPUT_FINGERPRINT_CACHE); + String cached = readCompilerInputFingerprintCache(cacheFile.toPath()); + long recoveryStart = System.nanoTime(); + ReapplyOutcome reapply = DatapackIngestService.reapplyFromStaging(getDatapacksFolder()); + reportTiming(timingConsumer, "datapack_external_recovery", recoveryStart); + if (!reapply.succeeded()) { + reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart); return DatapackInstallResult.failedResult(); } - String current = fingerprint.content(); - if (!current.isEmpty() && current.equals(cached.content())) { - if (!fingerprint.metadata().equals(cached.metadata())) { - writeFingerprintCache(cacheFile.toPath(), fingerprint); - } + if (fullInstall && loadedDatapackRestartRequired) { + reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart); + return DatapackInstallResult.restartRequiredResult(); + } + String current; + long fingerprintStart = System.nanoTime(); + try { + current = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer()); + } catch (IOException | RuntimeException exception) { + reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart); + reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart); + IrisLogging.reportError("Unable to fingerprint Iris datapack compiler inputs safely", exception); + return DatapackInstallResult.failedResult(); + } + reportTiming(timingConsumer, "datapack_compiler_input_fingerprint", fingerprintStart); + boolean loadedCompilerInputsChanged = !loadedDatapackCompilerInputFingerprint.isBlank() + && !reusableRuntimeFingerprint( + loadedDatapackCompilerInputFingerprint, + current); + if (!current.isEmpty() && current.equals(cached)) { IrisLogging.debug("Data packs unchanged, skipping install."); - return DatapackInstallResult.unchangedResult(); + DatapackInstallResult result = fullInstall && loadedCompilerInputsChanged + ? DatapackInstallResult.restartRequiredResult() + : resultForUnchangedFingerprint(fullInstall, reapply); + if (result.restartRequired()) { + requireDatapackRestart(); + } + reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart); + return result; } - DatapackInstallResult result = installDataPacksLocked(resolveDataFixer(), fullInstall); - if (result.succeeded()) { - writeFingerprintCache(cacheFile.toPath(), fingerprint); + long compileStart = System.nanoTime(); + DatapackInstallResult result = compileDataPacksLocked( + resolveDataFixer(), + fullInstall, + reapply); + if (fullInstall && loadedCompilerInputsChanged && result.succeeded()) { + result = DatapackInstallResult.restartRequiredResult(); } + if (result.restartRequired()) { + requireDatapackRestart(); + } + reportTiming(timingConsumer, "datapack_compile_publish", compileStart); + if (result.succeeded() && !result.restartRequired()) { + writeCompilerInputFingerprintCache(cacheFile.toPath(), current); + } + reportTiming(timingConsumer, "datapack_install_if_changed_total", totalStart); return result; } } + private static List collectCompilerPackRoots() throws IOException { + return IrisDatapackCompiler.collectPackRoots( + IrisPlatforms.get().dataFolder().toPath(), + IrisWorldStorage.levelRoot().toPath()); + } + + private static String computeCurrentDatapackCompilerInputFingerprint(IDataFixer fixer) throws IOException { + return IrisDatapackCompiler.computeInputFingerprint( + IrisDatapackCompiler.collectCompilerInputRoots( + IrisPlatforms.get().dataFolder().toPath(), + IrisWorldStorage.levelRoot().toPath()), + Objects.requireNonNull(fixer, "Datapack fixer"), + IrisSettings.get().getGeneral().adjustVanillaHeight); + } + + private static boolean pinLoadedDatapackCompilerInputs() { + return pinLoadedDatapackCompilerInputs(null); + } + + private static boolean pinLoadedDatapackCompilerInputs(String expectedFingerprint) { + if (loadedDatapackRestartRequired) { + return false; + } + try { + String fingerprint = computeCurrentDatapackCompilerInputFingerprint(resolveDataFixer()); + if (fingerprint.isBlank() + || expectedFingerprint != null + && !reusableRuntimeFingerprint(expectedFingerprint, fingerprint)) { + return false; + } + loadedDatapackCompilerInputFingerprint = fingerprint; + File cacheFile = new File( + IrisPlatforms.get().dataFolder("cache"), + COMPILER_INPUT_FINGERPRINT_CACHE); + writeCompilerInputFingerprintCache(cacheFile.toPath(), fingerprint); + return true; + } catch (IOException | RuntimeException exception) { + loadedDatapackCompilerInputFingerprint = ""; + IrisLogging.reportError("Unable to pin loaded Iris datapack compiler inputs.", exception); + return false; + } + } + + static boolean reusableRuntimeFingerprint(String loadedFingerprint, String currentFingerprint) { + return loadedFingerprint != null + && !loadedFingerprint.isBlank() + && loadedFingerprint.equals(currentFingerprint); + } + + private static void reportTiming( + BiConsumer timingConsumer, + String phase, + long startedAtNanos + ) { + if (timingConsumer == null) { + return; + } + try { + timingConsumer.accept(phase, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos)); + } catch (Throwable exception) { + IrisLogging.reportError("Datapack timing consumer failed during phase \"" + phase + "\".", exception); + } + } + + static DatapackInstallResult resultForUnchangedFingerprint( + boolean fullInstall, + ReapplyOutcome reapply + ) { + if (!Objects.requireNonNull(reapply, "External datapack reapply outcome").succeeded()) { + return DatapackInstallResult.failedResult(); + } + if (!reapply.changed()) { + return DatapackInstallResult.unchangedResult(); + } + return fullInstall + ? DatapackInstallResult.restartRequiredResult() + : DatapackInstallResult.readyResult(); + } + + static PackFingerprint resolvePostRecoveryPackFingerprint( + File packsDir, + String cachedMetadata, + String cachedContent, + ReapplyOutcome reapply + ) { + if (Objects.requireNonNull(reapply, "External datapack reapply outcome").changed()) { + return resolvePackFingerprint(packsDir, "", ""); + } + return resolvePackFingerprint(packsDir, cachedMetadata, cachedContent); + } + static PackFingerprint resolvePackFingerprint(File packsDir, String cachedMetadata, String cachedContent) { String metadata = computePackMetadataDigest(packsDir); if (!metadata.isEmpty() @@ -392,6 +629,17 @@ public class ServerConfigurator { } } + private static String readCompilerInputFingerprintCache(Path cacheFile) { + if (!Files.isRegularFile(cacheFile)) { + return ""; + } + try { + return Files.readString(cacheFile, StandardCharsets.UTF_8).trim(); + } catch (IOException exception) { + return ""; + } + } + private static void writeFingerprintCache(Path cacheFile, PackFingerprint fingerprint) { try { writeFingerprintAtomic(cacheFile, fingerprint.content() + "\n" + fingerprint.metadata()); @@ -400,6 +648,15 @@ public class ServerConfigurator { } } + private static void writeCompilerInputFingerprintCache(Path cacheFile, String fingerprint) { + try { + writeFingerprintAtomic(cacheFile, fingerprint); + } catch (IOException exception) { + IrisLogging.warn("Failed to write datapack compiler-input fingerprint cache: " + + exception.getMessage()); + } + } + private static void writeFingerprintAtomic(Path target, String fingerprint) throws IOException { Path absoluteTarget = target.toAbsolutePath().normalize(); Path parent = absoluteTarget.getParent(); @@ -571,6 +828,7 @@ public class ServerConfigurator { } public static void restart(String reason) { + requireDatapackRestart(); LifecycleOperationCoordinator.get().quiesceForRestart(() -> J.s(() -> { IrisLogging.warn(reason + " Restarting server to restore a safe lifecycle boundary."); J.s(() -> { @@ -659,4 +917,14 @@ public class ServerConfigurator { return key == null ? null : key.toString(); } + public record LoadedDatapackRuntimeInvalidation( + long generation, + boolean wasReady, + String fingerprint + ) { + public LoadedDatapackRuntimeInvalidation { + fingerprint = Objects.requireNonNullElse(fingerprint, ""); + } + } + } diff --git a/core/src/main/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleter.java b/core/src/main/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleter.java new file mode 100644 index 000000000..1284a370e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleter.java @@ -0,0 +1,91 @@ +package art.arcane.iris.core; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class SnapshotDirectoryTreeDeleter { + private SnapshotDirectoryTreeDeleter() { + } + + public static void delete(Path target) throws IOException { + Path root = Objects.requireNonNull(target, "target").toAbsolutePath().normalize(); + if (root.getParent() == null) { + throw new IOException("Refusing to delete a filesystem root: " + root); + } + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return; + } + requireDirectory(root); + deleteDirectory(root); + } + + private static void deleteDirectory(Path directory) throws IOException { + requireDirectory(directory); + List entries = snapshot(directory); + for (SnapshotEntry entry : entries) { + BasicFileAttributes current = requireSafeEntry(entry.path()); + if (entry.directory() != current.isDirectory() + || !sameFile(entry.fileKey(), current.fileKey())) { + throw new IOException("Directory entry changed during deletion: " + entry.path()); + } + if (entry.directory()) { + deleteDirectory(entry.path()); + } else { + Files.delete(entry.path()); + } + } + Files.delete(directory); + } + + private static List snapshot(Path directory) throws IOException { + ArrayList entries = new ArrayList<>(); + try (DirectoryStream children = Files.newDirectoryStream(directory)) { + for (Path child : children) { + Path normalized = child.toAbsolutePath().normalize(); + if (!Objects.equals(normalized.getParent(), directory)) { + throw new IOException("Directory entry escapes its parent: " + child); + } + BasicFileAttributes attributes = requireSafeEntry(normalized); + entries.add(new SnapshotEntry(normalized, attributes.isDirectory(), attributes.fileKey())); + } + } + return List.copyOf(entries); + } + + private static BasicFileAttributes requireDirectory(Path directory) throws IOException { + BasicFileAttributes attributes = requireSafeEntry(directory); + if (!attributes.isDirectory()) { + throw new IOException("Deletion target is not a directory: " + directory); + } + return attributes; + } + + private static BasicFileAttributes requireSafeEntry(Path entry) throws IOException { + BasicFileAttributes attributes = Files.readAttributes( + entry, + BasicFileAttributes.class, + LinkOption.NOFOLLOW_LINKS + ); + if (attributes.isSymbolicLink()) { + throw new IOException("Deletion target contains a symbolic link: " + entry); + } + if (!attributes.isDirectory() && !attributes.isRegularFile()) { + throw new IOException("Deletion target contains an unsafe filesystem entry: " + entry); + } + return attributes; + } + + private static boolean sameFile(Object expected, Object actual) { + return expected == null || actual == null || expected.equals(actual); + } + + private record SnapshotEntry(Path path, boolean directory, Object fileKey) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index cb4716bd1..c0279e661 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -99,6 +99,7 @@ public final class DatapackIngestService { private static final String TRANSACTION_JOURNAL_NEXT = "journal.next.json"; private static final int OWNERSHIP_SCHEMA = 1; private static final int TRANSACTION_SCHEMA = 2; + private static final int STRUCTURE_IMPORT_FORMAT_REVISION = 3; private static final int MAX_REDIRECTS = 5; private static final int MAX_ARCHIVE_ENTRIES = 100_000; private static final int MAX_CACHE_FILES = 32; @@ -157,12 +158,25 @@ public final class DatapackIngestService { } public static Report ingest(VolmitSender sender, KList urls, boolean restart) { + ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation = + ServerConfigurator.invalidateLoadedDatapackRuntime(); + Report report; TRANSACTION_LOCK.lock(); try { - return ingestLocked(sender, urls, restart); + report = ingestLocked(sender, urls, restart); } finally { TRANSACTION_LOCK.unlock(); } + if (!report.changed() && report.getFailed().isEmpty()) { + ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation); + } else if (report.changed()) { + if (restart) { + ServerConfigurator.restart(); + } else { + ServerConfigurator.requireDatapackRestart(); + } + } + return report; } private static Report ingestLocked(VolmitSender sender, KList urls, boolean restart) { @@ -266,11 +280,9 @@ public final class DatapackIngestService { if (report.changed()) { message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate."); - message(sender, C.GRAY + "After the restart they generate natively - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import , or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures."); + message(sender, C.GRAY + "After the restart they generate natively only in Iris dimensions that declare their source URL - no import needed. To get editable Iris copies (jigsaw pools, pieces & objects written into the pack) run /iris structure import , or set general.autoImportDatapackStructures=true to do it on every ingest. Place any registered key directly with a 'structures' placement using nativeStructures."); message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; deny non-minecraft datapack and mod structures explicitly with importedStructures.disabled."); - if (restart) { - ServerConfigurator.restart(); - } else { + if (!restart) { message(sender, C.GRAY + "Run with restart=true to restart now, or restart manually. After restart, run /iris structure list to see the new keys."); } } @@ -278,23 +290,40 @@ public final class DatapackIngestService { return report; } - public static boolean reapplyFromStaging(KList worldFolders) { + public static ReapplyOutcome reapplyFromStaging(KList worldFolders) { + ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation = + ServerConfigurator.invalidateLoadedDatapackRuntime(); + ReapplyOutcome outcome; TRANSACTION_LOCK.lock(); try { - return reapplyFromStagingLocked(worldFolders); + outcome = reapplyFromStagingLocked(worldFolders); } finally { TRANSACTION_LOCK.unlock(); } + if (outcome.succeeded() && !outcome.changed()) { + ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation); + } else if (outcome.changed()) { + ServerConfigurator.requireDatapackRestart(); + } + return outcome; } - private static boolean reapplyFromStagingLocked(KList worldFolders) { + private static ReapplyOutcome reapplyFromStagingLocked(KList worldFolders) { File root = IrisPlatforms.get().dataFolder("datapacks"); - if (!recoverBeforeReapply(root, worldFolders)) { - return false; + ReapplyOutcome recovery = recoverBeforeReapplyOutcome(root, worldFolders); + if (!recovery.succeeded()) { + return reportReapplyFailure(recovery); } File stagingDir = IrisPlatforms.get().dataFolderNoCreate("datapacks", "staging"); - return reapplyStagingRoot( - root, stagingDir, worldFolders, resolveStripOverrides()); + ReapplyOutcome repair = reapplyStagingRootOutcome( + root, + stagingDir, + worldFolders, + resolveStripOverrides()); + if (!repair.succeeded()) { + return reportReapplyFailure(repair); + } + return ReapplyOutcome.success(recovery.recovered(), repair.repaired()); } static boolean reapplyStagingRoot( @@ -302,23 +331,36 @@ public final class DatapackIngestService { File stagingDir, KList worldFolders, boolean stripOverrides + ) { + return reportReapplyFailure(reapplyStagingRootOutcome( + root, + stagingDir, + worldFolders, + stripOverrides)).succeeded(); + } + + static ReapplyOutcome reapplyStagingRootOutcome( + File root, + File stagingDir, + KList worldFolders, + boolean stripOverrides ) { Manifest manifest = readManifest(root); if (stagingDir == null || !Files.exists(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) { if (manifest.entries.isEmpty()) { - return true; + return ReapplyOutcome.success(false, false); } - IrisLogging.error("Managed datapack staging is missing at " - + (stagingDir == null ? new File(root, "staging").getPath() : stagingDir.getPath())); - return false; + File missing = stagingDir == null ? new File(root, "staging") : stagingDir; + return ReapplyOutcome.failed(new IOException( + "Managed datapack staging is missing at " + missing.getPath())); } if (Files.isSymbolicLink(stagingDir.toPath()) || !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) { - IrisLogging.error("Managed datapack staging is not a safe directory at " + stagingDir.getPath()); - return false; + return ReapplyOutcome.failed(new IOException( + "Managed datapack staging is not a safe directory at " + stagingDir.getPath())); } - return reapplyStagedDirectories( + return reapplyStagedDirectoriesOutcome( root, stagingDir, worldFolders, stripOverrides, manifest); } @@ -330,14 +372,19 @@ public final class DatapackIngestService { ) { if (Files.isSymbolicLink(stagingDir.toPath()) || !Files.isDirectory(stagingDir.toPath(), LinkOption.NOFOLLOW_LINKS)) { - IrisLogging.error("Managed datapack staging is not a safe directory at " + stagingDir.getPath()); - return false; + return reportReapplyFailure(ReapplyOutcome.failed(new IOException( + "Managed datapack staging is not a safe directory at " + + stagingDir.getPath()))).succeeded(); } - return reapplyStagedDirectories( - root, stagingDir, worldFolders, stripOverrides, readManifest(root)); + return reportReapplyFailure(reapplyStagedDirectoriesOutcome( + root, + stagingDir, + worldFolders, + stripOverrides, + readManifest(root))).succeeded(); } - private static boolean reapplyStagedDirectories( + private static ReapplyOutcome reapplyStagedDirectoriesOutcome( File root, File stagingDir, KList worldFolders, @@ -346,37 +393,57 @@ public final class DatapackIngestService { ) { File[] staged = stagingDir.listFiles(File::isDirectory); if (staged == null) { - IrisLogging.error("Unable to enumerate managed datapack staging at " + stagingDir.getPath()); - return false; + return ReapplyOutcome.failed(new IOException( + "Unable to enumerate managed datapack staging at " + stagingDir.getPath())); } - boolean successful = true; + boolean repaired = false; + IOException failure = null; for (Entry entry : manifest.entries) { File stagedDir = new File(stagingDir, entry.id); if (isRecordedUnchangedInstall(stagedDir, worldFolders, entry, stripOverrides)) { continue; } if (!isUsableStaging(stagedDir, entry)) { - IrisLogging.error("Managed datapack staging is unusable for '" + entry.id - + "' at " + stagedDir.getPath()); forgetInstallMetadata(entry); - successful = false; + failure = appendFailure(failure, new IOException( + "Managed datapack staging is unusable for '" + entry.id + + "' at " + stagedDir.getPath())); continue; } try { InstallResult result = install(stagedDir, worldFolders, entry, stripOverrides); if (result.changed()) { + repaired = true; IrisLogging.warn("Repaired installed datapack '" + entry.id + "' from Iris staging before datapack compilation."); } recordInstallMetadata(stagedDir, worldFolders, entry); } catch (IOException e) { - IrisLogging.reportError(e); forgetInstallMetadata(entry); - successful = false; + failure = appendFailure(failure, e); } } writeManifest(root, manifest); - return successful; + return failure == null + ? ReapplyOutcome.success(false, repaired) + : ReapplyOutcome.failed(failure); + } + + private static IOException appendFailure(IOException current, IOException additional) { + if (current == null) { + return additional; + } + current.addSuppressed(additional); + return current; + } + + private static ReapplyOutcome reportReapplyFailure(ReapplyOutcome outcome) { + if (!outcome.succeeded()) { + IrisLogging.reportError( + "External datapack recovery or staging repair failed.", + outcome.failure().orElseThrow()); + } + return outcome; } private static boolean isRecordedUnchangedInstall( @@ -501,22 +568,30 @@ public final class DatapackIngestService { } static boolean recoverBeforeReapply(File root, List worldFolders) { + return reportReapplyFailure(recoverBeforeReapplyOutcome(root, worldFolders)).succeeded(); + } + + private static ReapplyOutcome recoverBeforeReapplyOutcome(File root, List worldFolders) { try { - recoverTransactions(root, worldFolders); + return ReapplyOutcome.success(recoverTransactions(root, worldFolders), false); } catch (IOException e) { - IrisLogging.reportError("Datapack staging reapply blocked by incomplete transaction recovery.", e); - return false; + return ReapplyOutcome.failed(e); } - return true; } public static boolean remove(VolmitSender sender, String id) { + ServerConfigurator.invalidateLoadedDatapackRuntime(); + boolean removed; TRANSACTION_LOCK.lock(); try { - return removeLocked(sender, id); + removed = removeLocked(sender, id); } finally { TRANSACTION_LOCK.unlock(); } + if (removed) { + ServerConfigurator.requireDatapackRestart(); + } + return removed; } private static boolean removeLocked(VolmitSender sender, String id) { @@ -737,6 +812,7 @@ public final class DatapackIngestService { List prepared = new ArrayList<>(); Set targetIdSet = new TreeSet<>(entry.importedBundles.keySet()); targetIdSet.addAll(entry.importedTargets.keySet()); + targetIdSet.addAll(entry.importAttempts.keySet()); List targetIds = new ArrayList<>(targetIdSet); targetIds.sort(String::compareTo); try { @@ -792,7 +868,7 @@ public final class DatapackIngestService { if (ownedSource.isEmpty() || !sourceClaimsContain(bundle.getValue(), ownedSource.get())) { continue; } - removals.add(new StructureTransactionWriter.OwnedRemoval( + removals.add(StructureTransactionWriter.OwnedRemoval.managedDatapack( targetKey, ownedSource.get().kind(), ownedSource.get().key() @@ -826,6 +902,7 @@ public final class DatapackIngestService { } if (candidateRetained) { candidate.importedTargets.remove(targetId); + candidate.importAttempts.remove(targetId); candidate.structuresImported = false; } } @@ -963,6 +1040,56 @@ public final class DatapackIngestService { } } + public static List installedStructureScopeResources() throws IOException { + TRANSACTION_LOCK.lock(); + try { + File root = IrisPlatforms.get().dataFolder("datapacks"); + Manifest manifest = readManifest(root); + KList datapackFolders = ServerConfigurator.getDatapacksFolder(); + List resources = new ArrayList<>(); + for (Entry entry : manifest.entries) { + boolean found = false; + for (File datapackFolder : datapackFolders) { + File installedDirectory = new File(datapackFolder, entry.id); + if (!Files.exists(installedDirectory.toPath(), LinkOption.NOFOLLOW_LINKS)) { + continue; + } + resources.add(scanInstalledStructureScope(installedDirectory, entry)); + found = true; + } + if (!found) { + throw new IOException("Missing installed Iris-managed datapack '" + entry.id + "'"); + } + } + return List.copyOf(resources); + } finally { + TRANSACTION_LOCK.unlock(); + } + } + + static StructureScopeResources scanInstalledStructureScope(File directory, Entry entry) throws IOException { + Path path = directory.toPath(); + if (Files.isSymbolicLink(path) + || !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Invalid installed Iris-managed datapack directory " + directory.getPath()); + } + validatePackMetadata(directory); + rejectSymbolicLinks(directory); + Ownership ownership = readOwnership(directory); + if (!ownershipSourceMatches(ownership, entry)) { + throw new IOException("Installed datapack ownership mismatch at " + directory.getPath()); + } + if (!Objects.equals(ownership.contentHash, directoryHash(directory))) { + throw new IOException("Installed Iris-managed datapack is modified or corrupt at " + + directory.getPath()); + } + PackResources resources = scanPackResources(directory); + return new StructureScopeResources( + entry.url, + resources.structureKeys(), + resources.structureSetKeys()); + } + private static void ingestSingle( VolmitSender sender, String url, @@ -2258,10 +2385,11 @@ public final class DatapackIngestService { private static PackResources scanPackResources(File root) throws IOException { TreeSet structureKeys = new TreeSet<>(); + TreeSet structureSetKeys = new TreeSet<>(); TreeSet templateKeys = new TreeSet<>(); Path dataRoot = new File(root, "data").toPath(); if (!Files.isDirectory(dataRoot, LinkOption.NOFOLLOW_LINKS)) { - return new PackResources(new ArrayList<>(), new ArrayList<>()); + return new PackResources(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } try (Stream paths = Files.walk(dataRoot)) { for (Path path : paths.filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)).toList()) { @@ -2273,11 +2401,16 @@ public final class DatapackIngestService { String normalized = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/'); addResourceKey(structureKeys, namespace, normalized, "worldgen/structure/", ".json"); addResourceKey(structureKeys, namespace, normalized, "worldgen/structures/", ".json"); + addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_set/", ".json"); + addResourceKey(structureSetKeys, namespace, normalized, "worldgen/structure_sets/", ".json"); addResourceKey(templateKeys, namespace, normalized, "structure/", ".nbt"); addResourceKey(templateKeys, namespace, normalized, "structures/", ".nbt"); } } - return new PackResources(new ArrayList<>(structureKeys), new ArrayList<>(templateKeys)); + return new PackResources( + new ArrayList<>(structureKeys), + new ArrayList<>(structureSetKeys), + new ArrayList<>(templateKeys)); } private static void addResourceKey(Set keys, String namespace, String path, String prefix, String suffix) { @@ -2362,13 +2495,13 @@ public final class DatapackIngestService { Set configured = configuredImports(data); String targetId = data.getDataFolder().toPath().toAbsolutePath().normalize().toString(); for (Entry entry : manifest.entries) { - if (!configured.contains(entry.url) && entry.importedBundles.containsKey(targetId)) { + if (!configured.contains(entry.url) && hasImportState(entry, targetId)) { cleanupTargets++; } } if (!cleanupRemovedImports(data, targetId, configured, manifest.entries, manifestEntriesByUrl)) { for (Entry entry : manifest.entries) { - if (!configured.contains(entry.url) && entry.importedBundles.containsKey(targetId)) { + if (!configured.contains(entry.url) && hasImportState(entry, targetId)) { failedUrls.add(entry.url); } } @@ -2380,7 +2513,7 @@ public final class DatapackIngestService { Set pendingUrls = new HashSet<>(); for (String url : configured) { Entry entry = entriesByUrl.get(url); - if (entry != null && !importRevision(entry).equals(entry.importedTargets.get(targetId))) { + if (entry != null && importPending(entry, targetId)) { pendingUrls.add(url); } } @@ -2411,20 +2544,13 @@ public final class DatapackIngestService { } attemptedPacks++; try { - BulkStructureImporter.Report report = BulkStructureImporter.importDatapackStructures( + BulkStructureImporter.Report report = BulkStructureImporter.importManagedDatapackStructures( data, StructureImporter.Mode.OVERWRITE, BukkitPlatform.console(), structureKeys, templateKeys ); - if (report.failed() > 0) { - IrisLogging.error("Datapack structure import for pack '%s' reported %d failure(s); the manifest remains pending for retry.", - data.getDataFolder().getPath(), report.failed()); - failedUrls.addAll(pendingUrls); - reconcileFailedImportInventories(root, manifest, data, targetId, pendingUrls, entriesByUrl); - continue; - } Set successfulPendingUrls = new HashSet<>(pendingUrls); Set incompleteUrls = new HashSet<>(); for (String pendingUrl : pendingUrls) { @@ -2434,13 +2560,25 @@ public final class DatapackIngestService { successfulPendingUrls.remove(pendingUrl); incompleteUrls.add(pendingUrl); failedUrls.add(pendingUrl); - IrisLogging.error("Datapack structure import for '%s' did not prove every requested bundle in pack '%s'; the source remains pending for retry.", - pendingUrl, data.getDataFolder().getPath()); } } + if (report.failed() > 0 && incompleteUrls.isEmpty()) { + successfulPendingUrls.clear(); + incompleteUrls.addAll(pendingUrls); + failedUrls.addAll(pendingUrls); + } if (!incompleteUrls.isEmpty()) { - reconcileFailedImportInventories( + boolean reconciled = reconcileFailedImportInventories( root, manifest, data, targetId, incompleteUrls, entriesByUrl); + if (report.retryRequired() || !reconciled) { + IrisLogging.error("Datapack structure import for pack '%s' reported %d incomplete source(s) and remains pending because a retryable runtime failure occurred.", + data.getDataFolder().getPath(), incompleteUrls.size()); + } else if (recordDeterministicImportAttempts( + root, manifest, targetId, incompleteUrls, entriesByUrl)) { + IrisLogging.warn("Datapack structure import for pack '" + + data.getDataFolder().getPath() + "' left " + incompleteUrls.size() + + " source(s) incomplete after deterministic validation failures. Iris will retain the partial editable imports without retrying until the datapack source, importer format, or target pack changes."); + } } Map sharedBundles = desiredBundles(configured, entriesByUrl); boolean packCompleted = false; @@ -2457,7 +2595,7 @@ public final class DatapackIngestService { continue; } entry.importedBundles.put(targetId, desired); - entry.importedTargets.put(targetId, importRevision(entry)); + recordSuccessfulImport(entry, targetId); completedUrls.add(pendingUrl); packCompleted = true; } @@ -2492,8 +2630,29 @@ public final class DatapackIngestService { + " pack(s). Reference the imported keys from a 'structures' placement to position them manually."); } - private static String importRevision(Entry entry) { - return safe(entry.versionId) + ":" + safe(entry.sha1); + static String importRevision(Entry entry) { + return importRevision(entry, STRUCTURE_IMPORT_FORMAT_REVISION); + } + + static String importRevision(Entry entry, int importerFormatRevision) { + return "v" + importerFormatRevision + ":" + safe(entry.versionId) + ":" + safe(entry.sha1); + } + + static boolean importPending(Entry entry, String targetId) { + String revision = importRevision(entry); + return !revision.equals(entry.importedTargets.get(targetId)) + && !revision.equals(entry.importAttempts.get(targetId)); + } + + static void recordDeterministicImportAttempt(Entry entry, String targetId) { + entry.importedTargets.remove(targetId); + entry.importAttempts.put(targetId, importRevision(entry)); + entry.structuresImported = false; + } + + static void recordSuccessfulImport(Entry entry, String targetId) { + entry.importedTargets.put(targetId, importRevision(entry)); + entry.importAttempts.remove(targetId); } static void prepareImportRecoveryInventory(Entry entry, String targetId) { @@ -2502,10 +2661,11 @@ public final class DatapackIngestService { recovery.putAll(entry.importedBundles.getOrDefault(targetId, Map.of())); entry.importedBundles.put(targetId, recovery); entry.importedTargets.remove(targetId); + entry.importAttempts.remove(targetId); entry.structuresImported = false; } - private static void reconcileFailedImportInventories( + private static boolean reconcileFailedImportInventories( File root, Manifest manifest, IrisData data, @@ -2513,11 +2673,13 @@ public final class DatapackIngestService { Set pendingUrls, Map entriesByUrl ) { + boolean reconciled = true; for (String pendingUrl : pendingUrls) { Entry entry = entriesByUrl.get(pendingUrl); try { reconcileFailedImportInventory(data, entry, targetId); } catch (IOException | RuntimeException e) { + reconciled = false; IrisLogging.reportError("Could not reconcile partial editable structure imports for '" + pendingUrl + "' in pack '" + data.getDataFolder().getPath() + "'; the conservative recovery inventory remains pending.", e); @@ -2526,9 +2688,33 @@ public final class DatapackIngestService { try { writeManifestChecked(root, manifest); } catch (IOException e) { + reconciled = false; IrisLogging.reportError("Could not persist reconciled partial editable structure imports for pack '" + data.getDataFolder().getPath() + "'; the earlier recovery inventory remains durable.", e); } + return reconciled; + } + + private static boolean recordDeterministicImportAttempts( + File root, + Manifest manifest, + String targetId, + Set incompleteUrls, + Map entriesByUrl + ) { + for (String incompleteUrl : incompleteUrls) { + recordDeterministicImportAttempt(entriesByUrl.get(incompleteUrl), targetId); + } + try { + writeManifestChecked(root, manifest); + return true; + } catch (IOException e) { + for (String incompleteUrl : incompleteUrls) { + entriesByUrl.get(incompleteUrl).importAttempts.remove(targetId); + } + IrisLogging.reportError("Could not persist deterministic editable structure import attempts; the sources remain pending for retry.", e); + return false; + } } private static void reconcileFailedImportInventory( @@ -2557,6 +2743,7 @@ public final class DatapackIngestService { entry.importedBundles.put(targetId, reconciled); } entry.importedTargets.remove(targetId); + entry.importAttempts.remove(targetId); entry.structuresImported = false; } @@ -2574,36 +2761,47 @@ public final class DatapackIngestService { continue; } Map inventory = entry.importedBundles.get(targetId); - if (inventory == null) { + if (inventory == null && !hasImportState(entry, targetId)) { continue; } + Map resolvedInventory = inventory == null ? Map.of() : inventory; for (Entry retainedEntry : entries) { if (configured.contains(retainedEntry.url)) { retainedEntry.importedTargets.remove(targetId); + retainedEntry.importAttempts.remove(targetId); retainedEntry.structuresImported = false; } } - Map removable = new TreeMap<>(inventory); + Map removable = new TreeMap<>(resolvedInventory); removable.keySet().removeAll(retainedBundles.keySet()); Map remaining = cleanupImportedBundles(data, removable); if (!remaining.isEmpty()) { Map retained = new TreeMap<>(); - for (Map.Entry bundle : inventory.entrySet()) { + for (Map.Entry bundle : resolvedInventory.entrySet()) { if (retainedBundles.containsKey(bundle.getKey()) || remaining.containsKey(bundle.getKey())) { retained.put(bundle.getKey(), bundle.getValue()); } } entry.importedBundles.put(targetId, retained); + entry.importedTargets.remove(targetId); + entry.importAttempts.remove(targetId); entry.structuresImported = false; successful = false; continue; } entry.importedBundles.remove(targetId); entry.importedTargets.remove(targetId); + entry.importAttempts.remove(targetId); } return successful; } + private static boolean hasImportState(Entry entry, String targetId) { + return entry.importedBundles.containsKey(targetId) + || entry.importedTargets.containsKey(targetId) + || entry.importAttempts.containsKey(targetId); + } + private static Map cleanupImportedBundles(IrisData data, Map inventory) { Map remaining = new TreeMap<>(); StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath()); @@ -2614,7 +2812,11 @@ public final class DatapackIngestService { sourceKey = StructureKey.parse(bundle.getValue()); StructureSource.Kind sourceKind = sourceKey.namespace().equals("minecraft") ? StructureSource.Kind.VANILLA : StructureSource.Kind.DATAPACK; - removed |= writer.removeOwned(StructureKey.parse(bundle.getKey()), sourceKind, sourceKey); + removed |= writer.removeManagedDatapackOwned( + StructureKey.parse(bundle.getKey()), + sourceKind, + sourceKey + ); } catch (IOException | RuntimeException e) { remaining.put(bundle.getKey(), bundle.getValue()); IrisLogging.reportError("Preserving imported structure bundle '" + bundle.getKey() @@ -2940,6 +3142,8 @@ public final class DatapackIngestService { resolved.installMetadata, Map::of)); copy.importedTargets = new HashMap<>(Objects.requireNonNullElseGet( resolved.importedTargets, Map::of)); + copy.importAttempts = new HashMap<>(Objects.requireNonNullElseGet( + resolved.importAttempts, Map::of)); copy.importedBundles = new HashMap<>(); if (resolved.importedBundles != null) { for (Map.Entry> bundle : resolved.importedBundles.entrySet()) { @@ -3049,6 +3253,7 @@ public final class DatapackIngestService { entry.stagingMetadata = entry.stagingMetadata == null ? "" : entry.stagingMetadata.trim(); entry.installMetadata = normalizeImportedTargets(entry.installMetadata); entry.importedTargets = normalizeImportedTargets(entry.importedTargets); + entry.importAttempts = normalizeImportedTargets(entry.importAttempts); entry.importedBundles = normalizeImportedBundles(entry.importedBundles); if (!urls.add(entry.url) || !ids.add(entry.id)) { IrisLogging.warn("Ignoring duplicate datapack manifest entry for id '" + entry.id + "' and url " + entry.url); @@ -3361,13 +3566,12 @@ public final class DatapackIngestService { return path.toRealPath().toString(); } - static void recoverTransactions(File root, List worldFolders) throws IOException { - recoverStagingScratch(new File(root, "staging")); + static boolean recoverTransactions(File root, List worldFolders) throws IOException { + boolean changed = recoverStagingScratch(new File(root, "staging")); File transactionDirectory = new File(root, TRANSACTION_DIRECTORY); Path transactionPath = transactionDirectory.toPath(); if (!Files.exists(transactionPath, LinkOption.NOFOLLOW_LINKS)) { - recoverInstallScratch(root, worldFolders); - return; + return recoverInstallScratch(root, worldFolders) | changed; } verifyDirectoryContainerIfPresent(transactionDirectory, "datapack transaction"); Manifest committedManifest = readCommittedManifest(root); @@ -3390,29 +3594,32 @@ public final class DatapackIngestService { } for (Path transactionRoot : transactionRoots) { if (isHarmlessRecoveryArtifact(transactionRoot)) { - Files.deleteIfExists(transactionRoot); + changed |= Files.deleteIfExists(transactionRoot); continue; } recoverTransaction(root, worldFolders, committedManifest, transactionPath, transactionRoot); + changed = true; } - transactionDirectory.delete(); - recoverInstallScratch(root, worldFolders); + changed |= transactionDirectory.delete(); + return recoverInstallScratch(root, worldFolders) | changed; } - private static void recoverInstallScratch(File root, List worldFolders) throws IOException { + private static boolean recoverInstallScratch(File root, List worldFolders) throws IOException { Set scratchRoots = new TreeSet<>(); scratchRoots.add(installScratchRoot(new File(root, "staging")).toPath().toAbsolutePath().normalize()); for (File worldFolder : worldFolders) { scratchRoots.add(installScratchRoot(worldFolder).toPath().toAbsolutePath().normalize()); } + boolean changed = false; for (Path scratchRoot : scratchRoots) { - recoverInstallScratchRoot(scratchRoot); + changed |= recoverInstallScratchRoot(scratchRoot); } + return changed; } - private static void recoverInstallScratchRoot(Path scratchRoot) throws IOException { + private static boolean recoverInstallScratchRoot(Path scratchRoot) throws IOException { if (!Files.exists(scratchRoot, LinkOption.NOFOLLOW_LINKS)) { - return; + return false; } verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch"); List children; @@ -3431,9 +3638,10 @@ public final class DatapackIngestService { List pending = new ArrayList<>(); List backups = new ArrayList<>(); + boolean changed = false; for (Path child : children) { if (isHarmlessRecoveryArtifact(child)) { - Files.deleteIfExists(child); + changed |= Files.deleteIfExists(child); continue; } StagingScratch scratch = parseInstallScratch(scratchRoot, child); @@ -3456,8 +3664,10 @@ public final class DatapackIngestService { } for (StagingScratch scratch : pending) { deleteInstallScratch(scratch.path().toFile(), "orphan datapack install pending directory"); + changed = true; } - scratchRoot.toFile().delete(); + changed |= scratchRoot.toFile().delete(); + return changed; } private static StagingScratch parseInstallScratch(Path scratchRoot, Path child) throws IOException { @@ -3486,10 +3696,10 @@ public final class DatapackIngestService { return new StagingScratch(kind, id, normalized); } - private static void recoverStagingScratch(File stagingDirectory) throws IOException { + private static boolean recoverStagingScratch(File stagingDirectory) throws IOException { Path stagingRoot = stagingDirectory.toPath().toAbsolutePath().normalize(); if (!Files.exists(stagingRoot, LinkOption.NOFOLLOW_LINKS)) { - return; + return false; } verifyDirectoryContainerIfPresent(stagingDirectory, "datapack staging"); List children; @@ -3536,6 +3746,7 @@ public final class DatapackIngestService { } } + boolean changed = false; for (List matches : backups.values()) { StagingScratch backup = matches.getFirst(); Path target = stagingRoot.resolve(backup.id()).normalize(); @@ -3544,11 +3755,14 @@ public final class DatapackIngestService { } else { moveNew(backup.path(), target); } + changed = true; } for (StagingScratch scratch : pending) { deleteVerifiedDirectory(scratch.path().toFile()); + changed = true; } forceDirectoryIfSupported(stagingRoot); + return changed; } private static StagingScratch parseStagingScratch(Path stagingRoot, Path child) throws IOException { @@ -3825,6 +4039,7 @@ public final class DatapackIngestService { throw new IOException("Datapack transaction conflicts with the committed editable pack owner"); } addExistingPackRoots(roots, committed.importedTargets.keySet()); + addExistingPackRoots(roots, committed.importAttempts.keySet()); addExistingPackRoots(roots, committed.importedBundles.keySet()); return roots; } @@ -4125,6 +4340,7 @@ public final class DatapackIngestService { ), commit ); + IrisData.invalidateLoadedStructureResources(packRoot.toFile()); } catch (IOException | RuntimeException e) { IOException participantFailure = e instanceof IOException ioFailure ? ioFailure : new IOException("Failed resolving editable structure participant", e); @@ -4524,6 +4740,7 @@ public final class DatapackIngestService { && copyList(current.structureKeys).equals(copyList(expected.structureKeys)) && copyList(current.templateKeys).equals(copyList(expected.templateKeys)) && Objects.equals(current.importedTargets, expected.importedTargets) + && Objects.equals(current.importAttempts, expected.importAttempts) && Objects.equals(current.importedBundles, expected.importedBundles); } @@ -4610,6 +4827,7 @@ public final class DatapackIngestService { public List templateKeys = new ArrayList<>(); public Map installMetadata = new HashMap<>(); public Map importedTargets = new HashMap<>(); + public Map importAttempts = new HashMap<>(); public Map> importedBundles = new HashMap<>(); } @@ -4656,10 +4874,83 @@ public final class DatapackIngestService { static record InstallResult(boolean changed) { } + public record ReapplyOutcome( + ReapplyStatus status, + Optional failure + ) { + public ReapplyOutcome { + status = Objects.requireNonNull(status, "External datapack reapply status"); + failure = Objects.requireNonNull(failure, "External datapack reapply failure"); + if (status == ReapplyStatus.FAILED && failure.isEmpty()) { + throw new IllegalArgumentException("Failed external datapack reapply requires a cause"); + } + if (status != ReapplyStatus.FAILED && failure.isPresent()) { + throw new IllegalArgumentException("Successful external datapack reapply cannot carry a cause"); + } + } + + public static ReapplyOutcome success(boolean recovered, boolean repaired) { + ReapplyStatus status; + if (recovered && repaired) { + status = ReapplyStatus.RECOVERED_AND_REPAIRED; + } else if (recovered) { + status = ReapplyStatus.RECOVERED; + } else if (repaired) { + status = ReapplyStatus.REPAIRED; + } else { + status = ReapplyStatus.UNCHANGED; + } + return new ReapplyOutcome(status, Optional.empty()); + } + + public static ReapplyOutcome failed(Throwable failure) { + return new ReapplyOutcome( + ReapplyStatus.FAILED, + Optional.of(Objects.requireNonNull(failure, "External datapack reapply failure cause"))); + } + + public boolean succeeded() { + return status != ReapplyStatus.FAILED; + } + + public boolean changed() { + return recovered() || repaired(); + } + + public boolean recovered() { + return status == ReapplyStatus.RECOVERED + || status == ReapplyStatus.RECOVERED_AND_REPAIRED; + } + + public boolean repaired() { + return status == ReapplyStatus.REPAIRED + || status == ReapplyStatus.RECOVERED_AND_REPAIRED; + } + } + + public enum ReapplyStatus { + UNCHANGED, + RECOVERED, + REPAIRED, + RECOVERED_AND_REPAIRED, + FAILED + } + record InstallExecution(InstallResult result, DatapackCoordinator coordinator) { } - private record PackResources(List structureKeys, List templateKeys) { + private record PackResources( + List structureKeys, + List structureSetKeys, + List templateKeys + ) { + } + + public record StructureScopeResources( + String source, + List structureKeys, + List structureSetKeys + ) { } private static final class EditableImportRemoval { diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndex.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndex.java new file mode 100644 index 000000000..43a3b379d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndex.java @@ -0,0 +1,131 @@ +package art.arcane.iris.core.datapack; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public final class DatapackStructureScopeIndex { + private final Map> sourcesByStructure; + private final Map> sourcesByStructureSet; + + private DatapackStructureScopeIndex( + Map> sourcesByStructure, + Map> sourcesByStructureSet + ) { + this.sourcesByStructure = sourcesByStructure; + this.sourcesByStructureSet = sourcesByStructureSet; + } + + public static DatapackStructureScopeIndex create( + List resources + ) { + Map> mutableSourcesByStructure = new HashMap<>(); + Map> mutableSourcesBySet = new HashMap<>(); + if (resources != null) { + for (DatapackIngestService.StructureScopeResources resource : resources) { + if (resource == null) { + continue; + } + String source = normalizeSource(resource.source()); + if (source.isEmpty()) { + continue; + } + addOwnership(mutableSourcesByStructure, resource.structureKeys(), source); + addOwnership(mutableSourcesBySet, resource.structureSetKeys(), source); + } + } + + return new DatapackStructureScopeIndex( + immutableOwnership(mutableSourcesByStructure), + immutableOwnership(mutableSourcesBySet)); + } + + public Set declaredSources(Iterable datapackImports) { + if (datapackImports == null) { + return Set.of(); + } + Set declared = new HashSet<>(); + for (String source : datapackImports) { + String normalized = normalizeSource(source); + if (!normalized.isEmpty()) { + declared.add(normalized); + } + } + return declared.isEmpty() ? Set.of() : Set.copyOf(declared); + } + + public boolean allowsStructureSet(String structureSetKey, Set declaredSources) { + return allows(sourcesByStructureSet, structureSetKey, declaredSources); + } + + public boolean allowsStructure(String structureKey, Set declaredSources) { + return allows(sourcesByStructure, structureKey, declaredSources); + } + + public boolean isManagedStructureSet(String structureSetKey) { + return sourcesByStructureSet.containsKey(normalizeKey(structureSetKey)); + } + + public boolean isManagedStructure(String structureKey) { + return sourcesByStructure.containsKey(normalizeKey(structureKey)); + } + + public int managedStructureCount() { + return sourcesByStructure.size(); + } + + public int managedStructureSetCount() { + return sourcesByStructureSet.size(); + } + + public boolean isEmpty() { + return sourcesByStructure.isEmpty() && sourcesByStructureSet.isEmpty(); + } + + private static void addOwnership( + Map> ownership, + List keys, + String source + ) { + if (keys == null) { + return; + } + for (String key : keys) { + String normalizedKey = normalizeKey(key); + if (!normalizedKey.isEmpty()) { + ownership.computeIfAbsent(normalizedKey, ignored -> new HashSet<>()).add(source); + } + } + } + + private static Map> immutableOwnership(Map> ownership) { + Map> immutable = new HashMap<>(ownership.size()); + for (Map.Entry> entry : ownership.entrySet()) { + immutable.put(entry.getKey(), Set.copyOf(entry.getValue())); + } + return Map.copyOf(immutable); + } + + private static boolean allows( + Map> ownership, + String key, + Set declaredSources + ) { + Set owners = ownership.get(normalizeKey(key)); + if (owners == null) { + return true; + } + return declaredSources != null && declaredSources.containsAll(owners); + } + + private static String normalizeSource(String source) { + return source == null ? "" : source.trim(); + } + + private static String normalizeKey(String key) { + return key == null ? "" : key.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java index b1c61b078..b7891ec8f 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java @@ -3,6 +3,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.SnapshotDirectoryTreeDeleter; import art.arcane.iris.core.WorldRemovalPathPolicy; import art.arcane.iris.core.link.MultiverseCoreLink; import art.arcane.iris.core.runtime.WorldDeletionQueue; @@ -22,13 +23,10 @@ import org.bukkit.entity.Player; import java.io.IOException; import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -1062,7 +1060,7 @@ public final class IrisWorldRemovalService { private DeleteDisposition deleteQuarantine(Path quarantine) { try { - deleteTree(quarantine); + SnapshotDirectoryTreeDeleter.delete(quarantine); return new DeleteDisposition(false, quarantine); } catch (Throwable deletionFailure) { IrisLogging.reportError( @@ -1073,25 +1071,6 @@ public final class IrisWorldRemovalService { } } - private static void deleteTree(Path target) throws IOException { - Files.walkFileTree(target, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - Files.delete(file); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException { - if (failure != null) { - throw failure; - } - Files.delete(directory); - return FileVisitResult.CONTINUE; - } - }); - } - private static CompletableFuture onGlobal(Supplier supplier) { CompletableFuture result = new CompletableFuture<>(); boolean scheduled = J.runGlobal(() -> { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java index 8495b45d7..4a363681e 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleService.java @@ -118,6 +118,13 @@ public final class WorldLifecycleService { worldName, backend.backendName()); + WorldUnloadBoundaryRegistry.Boundary rawBoundary; + try { + rawBoundary = WorldUnloadBoundaryRegistry.begin(worldIdentity); + } catch (Throwable e) { + return CompletableFuture.failedFuture(e); + } + CompletableFuture unloadFuture; try { unloadFuture = backend.unloadAsync(requiredWorld, save); @@ -127,6 +134,8 @@ public final class WorldLifecycleService { } catch (Throwable e) { unloadFuture = CompletableFuture.failedFuture(e); } + unloadFuture.whenComplete((unloaded, throwable) -> + WorldUnloadBoundaryRegistry.complete(rawBoundary, unloaded, throwable)); CompletableFuture guardedFuture = guardUnloadCompletion(worldName, unloadFuture); return guardedFuture.whenComplete((unloaded, throwable) -> { diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistry.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistry.java new file mode 100644 index 000000000..546ca92ce --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistry.java @@ -0,0 +1,41 @@ +package art.arcane.iris.core.lifecycle; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; + +public final class WorldUnloadBoundaryRegistry { + private static final ConcurrentHashMap ACTIVE = new ConcurrentHashMap<>(); + + private WorldUnloadBoundaryRegistry() { + } + + static Boundary begin(String worldIdentity) { + String requiredIdentity = Objects.requireNonNull(worldIdentity, "world identity"); + Boundary boundary = new Boundary(requiredIdentity, new CompletableFuture<>()); + Boundary existing = ACTIVE.putIfAbsent(requiredIdentity, boundary); + if (existing != null) { + throw new IllegalStateException("World unload is already active for " + requiredIdentity + "."); + } + return boundary; + } + + public static CompletionStage claim(String worldIdentity) { + Boundary boundary = ACTIVE.remove(Objects.requireNonNull(worldIdentity, "world identity")); + return boundary == null ? null : boundary.completion(); + } + + static void complete(Boundary boundary, Boolean unloaded, Throwable failure) { + Objects.requireNonNull(boundary, "world unload boundary"); + ACTIVE.remove(boundary.worldIdentity(), boundary); + if (failure == null) { + boundary.completion().complete(Boolean.TRUE.equals(unloaded)); + return; + } + boundary.completion().completeExceptionally(WorldLifecycleSupport.unwrap(failure)); + } + + record Boundary(String worldIdentity, CompletableFuture completion) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 8481792fe..f7f0668d5 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -153,6 +153,22 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { return Optional.ofNullable(dataLoaders.get(dataFolder)); } + public static boolean invalidateLoadedStructureResources(File dataFolder) { + Path requested = dataFolderIdentity(Objects.requireNonNull( + dataFolder, + "Iris data folder to invalidate")); + boolean invalidated = false; + for (Map.Entry entry : dataLoaders.entrySet()) { + Path loaded = dataFolderIdentity(entry.getKey()); + if (!loaded.equals(requested)) { + continue; + } + entry.getValue().invalidateStructureResources(); + invalidated = true; + } + return invalidated; + } + public static void dereference() { dataLoaders.values().forEach(IrisData::cleanupEngine); } @@ -528,6 +544,17 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { loader.clearList(); } + private static Path dataFolderIdentity(File dataFolder) { + Path normalized = dataFolder.toPath().toAbsolutePath().normalize(); + try { + return Files.exists(normalized) ? normalized.toRealPath() : normalized; + } catch (IOException exception) { + IrisLogging.debug("Unable to resolve Iris data folder identity for " + + normalized + "; using its normalized path: " + exception.getMessage()); + return normalized; + } + } + public Set> resolveSnippets() { var result = new HashSet>(); var processed = new HashSet>(); diff --git a/core/src/main/java/art/arcane/iris/core/nms/DatapackStructureScopeResult.java b/core/src/main/java/art/arcane/iris/core/nms/DatapackStructureScopeResult.java new file mode 100644 index 000000000..005d72752 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/nms/DatapackStructureScopeResult.java @@ -0,0 +1,7 @@ +package art.arcane.iris.core.nms; + +public record DatapackStructureScopeResult( + int retainedManagedSets, + int excludedManagedSets +) { +} 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 f03ace43f..99a9f72c0 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 @@ -18,6 +18,7 @@ package art.arcane.iris.core.nms; +import art.arcane.iris.core.datapack.DatapackStructureScopeIndex; import art.arcane.iris.core.lifecycle.WorldLifecycleCaller; import art.arcane.iris.core.lifecycle.WorldLifecycleRequest; import art.arcane.iris.core.lifecycle.WorldLifecycleService; @@ -53,6 +54,7 @@ import org.bukkit.inventory.ItemStack; import java.awt.Color; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; public interface INMSBinding { @@ -226,6 +228,16 @@ public interface INMSBinding { void inject(long seed, Engine engine, World world) throws NoSuchFieldException, IllegalAccessException; + DatapackStructureScopeResult scopeDatapackStructures( + World world, + DatapackStructureScopeIndex scopeIndex, + Set declaredSources + ) throws NoSuchFieldException, IllegalAccessException; + + void completeStudioStructureBootstrap(World world) throws NoSuchFieldException, IllegalAccessException; + + void abandonStudioStructureBootstrap(World world); + Vector3d getBoundingbox(org.bukkit.entity.EntityType entity); String getEntitySpawnCategory(String key); 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 af7dc0fad..2597a86b0 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 @@ -18,6 +18,8 @@ package art.arcane.iris.core.nms.v1X; +import art.arcane.iris.core.datapack.DatapackStructureScopeIndex; +import art.arcane.iris.core.nms.DatapackStructureScopeResult; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.nms.INMSBinding; import art.arcane.iris.core.nms.container.BiomeColor; @@ -45,6 +47,7 @@ import org.bukkit.inventory.ItemStack; import java.awt.Color; import java.util.List; +import java.util.Set; import java.util.stream.StreamSupport; public class NMSBinding1X implements INMSBinding { @@ -102,6 +105,23 @@ public class NMSBinding1X implements INMSBinding { + "general.disableNMS=true cannot create or initialize an Iris world"); } + @Override + public DatapackStructureScopeResult scopeDatapackStructures( + World world, + DatapackStructureScopeIndex scopeIndex, + Set declaredSources + ) { + throw new IllegalStateException("Iris-managed datapack structure isolation requires the supported NMS binding"); + } + + @Override + public void completeStudioStructureBootstrap(World world) { + } + + @Override + public void abandonStudioStructureBootstrap(World world) { + } + public Vector3d getBoundingbox() { return null; } diff --git a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java index e2840f7cd..e5f6074fd 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java +++ b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java @@ -33,6 +33,7 @@ import java.util.Properties; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.stream.Stream; import java.util.zip.ZipEntry; @@ -41,11 +42,13 @@ import java.util.zip.ZipInputStream; public final class DefaultPackBootstrapProvisioner { private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"); private static final String WORLD_DATAPACK_DIRECTORY = "iris"; - private static final int MARKER_SCHEMA = 2; + private static final int MARKER_SCHEMA = 3; private static final int MAX_ARCHIVE_ENTRIES = 100_000; private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L; private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L; private static final AtomicBoolean PROVISIONED_THIS_STARTUP = new AtomicBoolean(false); + private static final AtomicReference PROVISIONED_COMPILER_INPUT_FINGERPRINT = + new AtomicReference<>(""); private DefaultPackBootstrapProvisioner() { } @@ -102,6 +105,12 @@ public final class DefaultPackBootstrapProvisioner { if (!Integer.toString(MARKER_SCHEMA).equals(marker.getProperty("schema"))) { return false; } + IDataFixer fixer = DataVersion.getLatest().get(); + if (fixer == null + || !IrisDatapackCompiler.compilerIdentity(fixer) + .equals(marker.getProperty("compilerIdentity"))) { + return false; + } return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint")) && directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint")) && datapackRoot.toString().equals(marker.getProperty("datapackPath")) @@ -118,7 +127,13 @@ public final class DefaultPackBootstrapProvisioner { return PROVISIONED_THIS_STARTUP.get(); } + public static String compilerInputFingerprintThisStartup() { + return PROVISIONED_COMPILER_INPUT_FINGERPRINT.get(); + } + static ProvisionResult provision(Path dataDirectory, Consumer feedback, ProvisionOptions options) throws IOException { + PROVISIONED_THIS_STARTUP.set(false); + PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(""); Path normalizedData = dataDirectory.toAbsolutePath().normalize(); Path packsRoot = normalizedData.resolve("packs"); Path packRoot = packsRoot.resolve("overworld"); @@ -174,20 +189,22 @@ public final class DefaultPackBootstrapProvisioner { if (packRoots.isEmpty()) { throw new IOException("No Iris pack roots were available for bootstrap datapack compilation"); } + IDataFixer fixer = DataVersion.getLatest().get(); + if (fixer == null) { + throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap"); + } + String compilerIdentity = IrisDatapackCompiler.compilerIdentity(fixer); String aggregateFingerprint = packRootsFingerprint(packRoots); boolean rebuildDatapack = replacePack || !existingDatapack || !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint")) + || !compilerIdentity.equals(previousMarker.getProperty("compilerIdentity")) || !datapackRoot.toString().equals(previousMarker.getProperty("datapackPath")) || !directoryFingerprint(datapackRoot).equals(previousMarker.getProperty("datapackFingerprint")); if (rebuildDatapack) { compileContainer = datapacksRoot.resolve("." + WORLD_DATAPACK_DIRECTORY + "-stage-" + UUID.randomUUID()); Files.createDirectories(compileContainer); KList outputFolders = new KList().qadd(compileContainer.toFile()); - IDataFixer fixer = DataVersion.getLatest().get(); - if (fixer == null) { - throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap"); - } IrisDatapackCompiler.compile(packRoots, outputFolders, fixer, false); if (!isDatapackRoot(compileContainer)) { throw new IOException("Canonical Iris datapack compiler produced incomplete output at " + compileContainer); @@ -202,9 +219,14 @@ public final class DefaultPackBootstrapProvisioner { throw new IOException("Bootstrap datapack output is incomplete at " + datapackRoot); } String finalPackFingerprint = directoryFingerprint(packRoot); - String finalAggregateFingerprint = packRootsFingerprint( - IrisDatapackCompiler.collectPackRoots(normalizedData, options.levelRoot()) - ); + List finalPackRoots = IrisDatapackCompiler.collectPackRoots( + normalizedData, + options.levelRoot()); + String finalAggregateFingerprint = packRootsFingerprint(finalPackRoots); + String finalCompilerInputFingerprint = IrisDatapackCompiler.computeInputFingerprint( + finalPackRoots, + fixer, + false); String finalDatapackFingerprint = directoryFingerprint(datapackRoot); Properties marker = new Properties(); marker.setProperty("schema", Integer.toString(MARKER_SCHEMA)); @@ -213,10 +235,12 @@ public final class DefaultPackBootstrapProvisioner { marker.setProperty("managedDefault", Boolean.toString(managedDefault)); marker.setProperty("defaultPackFingerprint", finalPackFingerprint); marker.setProperty("aggregateFingerprint", finalAggregateFingerprint); + marker.setProperty("compilerIdentity", compilerIdentity); marker.setProperty("datapackFingerprint", finalDatapackFingerprint); marker.setProperty("datapackPath", datapackRoot.toString()); marker.setProperty("completedAt", Long.toString(options.clock().millis())); storePropertiesAtomic(markerFile, marker); + PROVISIONED_COMPILER_INPUT_FINGERPRINT.set(finalCompilerInputFingerprint); PROVISIONED_THIS_STARTUP.set(true); deleteQuietly(packBackup, feedback); deleteQuietly(datapackBackup, feedback); diff --git a/core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java index 6af54dc66..7c5fe8692 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/StructureGraphPackValidator.java @@ -21,6 +21,7 @@ package art.arcane.iris.core.pack; import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructureAssembler; import art.arcane.iris.engine.framework.structure.IrisObjectFrameReader; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; import art.arcane.iris.engine.framework.structure.StructureGraphCompiler; import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic; @@ -31,7 +32,6 @@ import art.arcane.iris.engine.object.IrisJigsawPool; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; -import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -131,13 +131,13 @@ final class StructureGraphPackValidator { try { StructureAssembler assembler = StructureAssembler.forCompilation( compilation, new IrisPosition(0, GEOMETRY_SAMPLE_ORIGIN_Y, 0)); - KList pieces = assembler.assemble(new RNG(seed)); - if (pieces == null) { - failures.add("seed " + seed + " returned no complete assembly"); - } else if (pieces.isEmpty()) { + StructureAssemblyResult result = assembler.assemble(new RNG(seed)); + if (result.status().isFailure()) { + failures.add("seed " + seed + " returned " + result.status() + ": " + result.detail()); + } else if (!result.hasOutput()) { outputForEverySample = false; } else { - sampledVerticalEnvelopes.add(sampleVerticalEnvelope(seed, pieces)); + sampledVerticalEnvelopes.add(sampleVerticalEnvelope(seed, result.pieces())); } } catch (RuntimeException e) { failures.add("seed " + seed + " threw " + e.getClass().getSimpleName() @@ -150,7 +150,7 @@ final class StructureGraphPackValidator { private static SampledVerticalEnvelope sampleVerticalEnvelope( long seed, - KList pieces + List pieces ) { int minimumY = Integer.MAX_VALUE; int maximumY = Integer.MIN_VALUE; @@ -187,8 +187,8 @@ final class StructureGraphPackValidator { continue; } failures.add("seed " + sample.seed() + " placed " + sample.outcome().pieceKeys().size() - + " piece(s), left " + sample.outcome().unresolvedConnectorCount() - + " unresolved, cap=" + sample.outcome().pieceCapReached()); + + " piece(s), status=" + sample.outcome().status() + + ": " + sample.outcome().detail()); if (failures.size() == 3) { break; } diff --git a/core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java b/core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java index 0da4de3ab..08526c246 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java +++ b/core/src/main/java/art/arcane/iris/core/pack/StructurePackageClosure.java @@ -7,6 +7,7 @@ import art.arcane.volmlib.util.json.JSONObject; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; @@ -51,8 +52,16 @@ public final class StructurePackageClosure { } public static StructurePackageClosure collect(File sourceRoot, Collection rootStructures) { + return collect(sourceRoot, rootStructures, null); + } + + public static StructurePackageClosure collect( + File sourceRoot, + Collection rootStructures, + Limits limits + ) { Path normalizedRoot = sourceRoot.toPath().toAbsolutePath().normalize(); - MutableClosure closure = new MutableClosure(); + MutableClosure closure = new MutableClosure(limits); if (!Files.isDirectory(normalizedRoot)) { closure.errors.add("Structure package source is not a directory: " + normalizedRoot); return new StructurePackageClosure(normalizedRoot, closure); @@ -127,7 +136,7 @@ public final class StructurePackageClosure { continue; } - JSONObject structure = readJson(sourceRoot, STRUCTURES, structureKey, closure.errors); + JSONObject structure = readJson(sourceRoot, STRUCTURES, structureKey, closure.errors, closure.limits); if (structure == null) { continue; } @@ -148,7 +157,7 @@ public final class StructurePackageClosure { continue; } - JSONObject pool = readJson(sourceRoot, POOLS, poolKey, closure.errors); + JSONObject pool = readJson(sourceRoot, POOLS, poolKey, closure.errors, closure.limits); if (pool == null) { continue; } @@ -184,7 +193,7 @@ public final class StructurePackageClosure { continue; } - JSONObject piece = readJson(sourceRoot, PIECES, pieceKey, closure.errors); + JSONObject piece = readJson(sourceRoot, PIECES, pieceKey, closure.errors, closure.limits); if (piece == null) { continue; } @@ -223,19 +232,38 @@ public final class StructurePackageClosure { } } - private static JSONObject readJson(Path sourceRoot, String folder, String key, List errors) { + private static JSONObject readJson( + Path sourceRoot, + String folder, + String key, + List errors, + Limits limits + ) { Path file = resolveExisting(sourceRoot, folder, key, ".json", errors); if (file == null) { return null; } try { - return new JSONObject(Files.readString(file, StandardCharsets.UTF_8)); + String content = limits == null + ? Files.readString(file, StandardCharsets.UTF_8) + : new String(readBounded(file, limits.maxJsonBytes()), StandardCharsets.UTF_8); + return new JSONObject(content); } catch (IOException | RuntimeException e) { errors.add("Invalid " + folder + " resource '" + key + "': " + describe(e)); return null; } } + private static byte[] readBounded(Path file, int maximumBytes) throws IOException { + try (InputStream input = Files.newInputStream(file)) { + byte[] content = input.readNBytes(maximumBytes + 1); + if (content.length > maximumBytes) { + throw new IOException("JSON resource exceeds " + maximumBytes + " bytes"); + } + return content; + } + } + private static Path resolveExisting(Path sourceRoot, String folder, String key, String extension, List errors) { Path file = resolveResource(sourceRoot, folder, key, extension, errors); @@ -481,8 +509,16 @@ public final class StructurePackageClosure { if (!isEmpty) { return false; } - if (entry.has("piece")) { - errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey + "' cannot define field 'piece'."); + if (!entry.has("piece")) { + return true; + } + Object pieceValue = entry.opt("piece"); + if (!(pieceValue instanceof String piece)) { + errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey + + "' requires string field 'piece' when defined."); + } else if (!piece.isBlank()) { + errors.add("Empty piece entry " + index + " in jigsaw pool '" + poolKey + + "' cannot define non-empty field 'piece'."); } return true; } @@ -504,15 +540,66 @@ public final class StructurePackageClosure { return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; } + public record Limits(int maxResources, int maxJsonBytes) { + public Limits { + if (maxResources < 1 || maxResources > 100_000) { + throw new IllegalArgumentException("Structure closure resource limit must be between 1 and 100000"); + } + if (maxJsonBytes < 1 || maxJsonBytes > 64 * 1024 * 1024) { + throw new IllegalArgumentException("Structure closure JSON limit must be between 1 and 67108864 bytes"); + } + } + } + private static final class MutableClosure { - private final Set structures = new LinkedHashSet<>(); - private final Set pools = new LinkedHashSet<>(); - private final Set pieces = new LinkedHashSet<>(); - private final Set objects = new LinkedHashSet<>(); - private final Set loot = new LinkedHashSet<>(); + private final Limits limits; + private final Set structures; + private final Set pools; + private final Set pieces; + private final Set objects; + private final Set loot; private final List errors = new ArrayList<>(); private final Deque structureQueue = new ArrayDeque<>(); private final Deque poolQueue = new ArrayDeque<>(); private final Deque pieceQueue = new ArrayDeque<>(); + private int resources; + private boolean resourceLimitReported; + + private MutableClosure(Limits limits) { + this.limits = limits; + structures = new BudgetedSet(this); + pools = new BudgetedSet(this); + pieces = new BudgetedSet(this); + objects = new BudgetedSet(this); + loot = new BudgetedSet(this); + } + + private boolean reserveResource() { + if (limits == null || resources < limits.maxResources()) { + resources++; + return true; + } + if (!resourceLimitReported) { + errors.add("Structure closure exceeds " + limits.maxResources() + " resources."); + resourceLimitReported = true; + } + return false; + } + } + + private static final class BudgetedSet extends LinkedHashSet { + private final MutableClosure closure; + + private BudgetedSet(MutableClosure closure) { + this.closure = closure; + } + + @Override + public boolean add(String value) { + if (contains(value) || !closure.reserveResource()) { + return false; + } + return super.add(value); + } } } diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProject.java b/core/src/main/java/art/arcane/iris/core/project/IrisProject.java index 0cadae095..a07a9b713 100644 --- a/core/src/main/java/art/arcane/iris/core/project/IrisProject.java +++ b/core/src/main/java/art/arcane/iris/core/project/IrisProject.java @@ -74,20 +74,30 @@ public class IrisProject { } public void open(VolmitSender sender) throws IrisException { - open(sender, 1337, (w) -> + open(sender, 1337, StudioOpenCoordinator.StudioOpenKind.STANDARD, (w) -> { }); } - public CompletableFuture open(VolmitSender sender, long seed, Consumer onDone) throws IrisException { + public CompletableFuture open( + VolmitSender sender, + long seed, + StudioOpenCoordinator.StudioOpenKind openKind, + Consumer onDone + ) throws IrisException { if (isOpen()) { - return close().thenCompose(ignored -> openInternal(sender, seed, onDone)); + return close().thenCompose(ignored -> openInternal(sender, seed, openKind, onDone)); } - return openInternal(sender, seed, onDone); + return openInternal(sender, seed, openKind, onDone); } - private CompletableFuture openInternal(VolmitSender sender, long seed, Consumer onDone) { + private CompletableFuture openInternal( + VolmitSender sender, + long seed, + StudioOpenCoordinator.StudioOpenKind openKind, + Consumer onDone + ) { AtomicReference stage = new AtomicReference<>("Queued"); AtomicReference progress = new AtomicReference<>(0.01D); AtomicBoolean complete = new AtomicBoolean(false); @@ -97,6 +107,7 @@ public class IrisProject { this, sender, seed, + openKind, update -> { if (update.stage() != null && !update.stage().isBlank()) { stage.set(update.stage()); 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 dba6ba7b8..ff343f660 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 @@ -5,6 +5,7 @@ import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.link.MultiverseCoreLink; +import art.arcane.iris.core.nms.INMS; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.lifecycle.WorldLifecycleService; @@ -13,12 +14,15 @@ import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.project.IrisCodeWorkspace; 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; import art.arcane.volmlib.util.bukkit.WorldIdentity; import org.bukkit.Bukkit; +import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.Player; @@ -33,9 +37,13 @@ import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -46,9 +54,14 @@ 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_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS = 30L; + private static final long STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS = 120L; private static volatile StudioOpenCoordinator instance; + private final EntryLoadRegistry entryLoads; private StudioOpenCoordinator() { + entryLoads = new EntryLoadRegistry(); } public static StudioOpenCoordinator get() { @@ -93,10 +106,11 @@ public final class StudioOpenCoordinator { private void executeOpen(StudioOpenRequest request, CompletableFuture future) { World world = null; PlatformChunkGenerator provider = null; + CompletableFuture entryLoadFuture = null; try { - long openStart = System.currentTimeMillis(); + long openStart = System.nanoTime(); long t = openStart; - IrisLogging.debug("[Studio timing] ===== studio open START: " + request.worldName() + " ====="); + entryLoads.rejectNewOpen(); updateStage(request, "resolve_dimension", 0.04D); if (IrisToolbelt.getDimension(request.dimensionKey()) == null) { throw new IrisException("Dimension cannot be found for id " + request.dimensionKey() + "."); @@ -104,7 +118,7 @@ public final class StudioOpenCoordinator { updateStage(request, "prepare_world_pack", 0.10D); cleanupStaleTransientWorlds(request.worldName()); - t = logStudioPhase("resolveDimension + cleanupStaleWorlds", t, openStart); + t = logStudioPhase(request, "resolve_dimension_and_cleanup", t, openStart); updateStage(request, "install_datapacks", 0.18D); IrisCreator creator = IrisToolbelt.createWorld() @@ -113,9 +127,11 @@ public final class StudioOpenCoordinator { .studio(true) .name(request.worldName()) .dimension(request.dimensionKey()) - .studioProgressConsumer((progress, stage) -> updateStage(request, mapCreatorStage(stage), progress)); + .datapackPreparation(request.openKind().datapackPreparation()) + .studioProgressConsumer((progress, stage) -> updateStage(request, mapCreatorStage(stage), progress)) + .studioTimingConsumer((phase, duration) -> logMeasuredStudioPhase(request, phase, duration)); world = creator.create(); - t = logStudioPhase("createWorld (datapacks + bukkit world + engine setup)", t, openStart); + t = logStudioPhase(request, "create_world_total", t, openStart); provider = IrisToolbelt.access(world); if (provider == null) { throw new IllegalStateException("Studio runtime provider is unavailable for world \"" + request.worldName() + "\"."); @@ -128,28 +144,36 @@ public final class StudioOpenCoordinator { if (rulesApplied != null) { rulesApplied.get(15L, TimeUnit.SECONDS); } - t = logStudioPhase("applyStudioWorldRules", t, openStart); + t = logStudioPhase(request, "apply_world_rules", t, openStart); updateStage(request, "prepare_generator", 0.78D); - WorldRuntimeControlService.get().prepareGenerator(world); - t = logStudioPhase("prepareGenerator", t, openStart); + if (request.openKind().prepareGeneratorState()) { + WorldRuntimeControlService.get().prepareGenerator(world); + } + t = logStudioPhase(request, "prepare_generator", t, openStart); Location entryAnchor = WorldRuntimeControlService.get().resolveEntryAnchor(world); if (entryAnchor == null) { throw new IllegalStateException("Studio entry anchor could not be resolved."); } - t = logStudioPhase("resolveEntryAnchor", t, openStart); + 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 { - loadEntryChunk(world, entryChunkX, entryChunkZ).get(30L, TimeUnit.SECONDS); + 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."); + + 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("loadEntryChunk (generate spawn chunk to FULL)", t, openStart); + t = logStudioPhase(request, "load_entry_chunk", t, openStart); updateStage(request, "resolve_safe_entry", 0.84D); Location safeEntry; @@ -162,9 +186,11 @@ public final class StudioOpenCoordinator { if (safeEntry == null) { throw new IllegalStateException("Studio entry point could not be resolved for world \"" + request.worldName() + "\"."); } - t = logStudioPhase("resolveSafeEntry (generates/loads spawn chunk to FULL)", t, openStart); + t = logStudioPhase(request, "resolve_safe_entry", t, openStart); - if (request.playerName() != null && !request.playerName().isBlank()) { + if (request.openKind().teleportThroughStandardEntry() + && request.playerName() != null + && !request.playerName().isBlank()) { updateStage(request, "teleport_player", 0.96D); Player player = resolvePlayer(request.playerName()); if (player == null) { @@ -180,75 +206,297 @@ public final class StudioOpenCoordinator { if (!Boolean.TRUE.equals(teleported)) { throw new IllegalStateException("Studio teleport did not complete successfully."); } - t = logStudioPhase("teleportPlayer", t, openStart); + 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); } - if (request.openWorkspace() && request.project() != null) { + if (request.openKind().openWorkspace() && request.project() != null) { new IrisCodeWorkspace(request.project()).openVSCode(request.sender()); } if (request.onDone() != null) { request.onDone().accept(world); } - t = logStudioPhase("finalize + openVSCode", t, openStart); + t = logStudioPhase(request, "finalize_open", t, openStart); - IrisLogging.info("Studio open: " + world.getName() + " ready in " + (System.currentTimeMillis() - openStart) + "ms"); + 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) { + abandonStudioEntryBootstrap(world, e); IrisLogging.reportError("Studio open failed for world \"" + request.worldName() + "\".", e); if (!request.retainOnFailure()) { - try { - updateStage(request, "cleanup", 1.00D); - StudioCloseResult cleanupResult = closeWorldCoordinated( + updateStage(request, "cleanup", 1.00D); + if (requiresDeferredEntryCleanup(entryLoadFuture)) { + deferFailedOpenCleanup( + entryLoadFuture, provider, request.worldName(), world, - true, - request.project() - ).get(45L, TimeUnit.SECONDS); - if (cleanupResult.failureCause() != null) { - throw cleanupResult.failureCause(); + request.project()); + } else { + try { + CompletableFuture cleanup = cleanupFailedOpen( + provider, + request.worldName(), + world, + request.project()); + entryLoads.releaseAfterSuccessfulCompletion( + request.worldName(), + entryLoadFuture, + cleanup); + cleanup.get(45L, TimeUnit.SECONDS); + } catch (Throwable cleanupError) { + IrisLogging.reportError("Studio cleanup failed for world \"" + + request.worldName() + "\".", unwrapFailure(cleanupError)); } - } catch (Throwable cleanupError) { - IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError); } } future.completeExceptionally(e); } } - private long logStudioPhase(String phase, long t, long openStart) { - long now = System.currentTimeMillis(); - IrisLogging.debug("[Studio timing] " + phase + " = " + (now - t) + "ms (cumulative " + (now - openStart) + "ms)"); + private long logStudioPhase(StudioOpenRequest request, String phase, long t, 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 - t), + TimeUnit.NANOSECONDS.toMillis(now - openStart)); return now; } + private long elapsedMillis(long startedAtNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos); + } + + private void logMeasuredStudioPhase(StudioOpenRequest request, String phase, long duration) { + IrisLogging.info("[Studio timing] world=%s kind=%s phase=%s duration=%dms", + request.worldName(), + request.openKind().name().toLowerCase(Locale.ROOT), + phase, + duration); + } + private CompletableFuture loadEntryChunk(World world, int chunkX, int chunkZ) { - // A freshly created studio world has no ticking region at the entry - // chunk. On Folia getChunkAtAsync only works from the owning region - // thread, and RegionScheduler.execute never fires for a chunk no region - // owns yet — which is why resolveSafeEntry (a region task) would stall - // and time out. A plugin chunk ticket force-loads the chunk and creates - // its ticking region; we then confirm via a region task that the region - // is live before resolving the safe entry / teleporting into it. + if (!J.isFolia()) { + return loadEntryChunkAsync(world, chunkX, chunkZ); + } + return scheduleEntryChunkRetention(world, chunkX, chunkZ); + } + + private CompletableFuture loadEntryChunkAsync(World world, int chunkX, int chunkZ) { + CompletableFuture requested; + try { + requested = WorldRuntimeControlService.get().requestChunkAsync( + world, + chunkX, + chunkZ, + true, + true); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + if (requested == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Entry-chunk async request did not return a future at " + chunkX + "," + chunkZ + ".")); + } + return requested.thenCompose(chunk -> { + if (chunk == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "Entry-chunk async request returned no chunk at " + chunkX + "," + chunkZ + ".")); + } + return scheduleEntryChunkRetention(world, chunkX, chunkZ); + }); + } + + private CompletableFuture scheduleEntryChunkRetention(World world, int chunkX, int chunkZ) { CompletableFuture loaded = new CompletableFuture<>(); - J.s(() -> { - try { - world.addPluginChunkTicket(chunkX, chunkZ, art.arcane.iris.platform.bukkit.BukkitPlatform.plugin()); - } catch (Throwable t) { - loaded.completeExceptionally(t); + try { + J.s(() -> retainAndConfirmEntryChunk(world, chunkX, chunkZ) + .whenComplete((ignored, throwable) -> complete(loaded, throwable))); + } catch (Throwable throwable) { + loaded.completeExceptionally(throwable); + } + return loaded; + } + + 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); + return; + } + target.completeExceptionally(throwable); + } + + private void endStudioEntryBootstrap(World world, PlatformChunkGenerator provider) { + if (!(provider instanceof BukkitChunkGenerator bukkitGenerator)) { + throw new IllegalStateException("Studio runtime provider cannot finish its entry bootstrap."); + } + AtomicBoolean activationClaim = new AtomicBoolean(true); + CompletableFuture activation = J.sfut(() -> { + if (!activationClaim.compareAndSet(true, false)) { + INMS.get().abandonStudioStructureBootstrap(world); return; } - - if (!J.runRegion(world, chunkX, chunkZ, () -> loaded.complete(null))) { - loaded.completeExceptionally(new IllegalStateException( - "Failed to confirm entry-chunk region at " + chunkX + "," + chunkZ + ".")); + try { + INMS.get().completeStudioStructureBootstrap(world); + bukkitGenerator.endStudioEntryBootstrap(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Studio native structure state could not be activated after entry bootstrap.", e); } }); - return loaded; + try { + activation.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + activationClaim.compareAndSet(true, false); + Thread.currentThread().interrupt(); + throw new IllegalStateException("Studio native structure activation was interrupted.", e); + } catch (ExecutionException e) { + activationClaim.compareAndSet(true, false); + throw new IllegalStateException("Studio native structure activation did not complete.", + unwrapFailure(e)); + } catch (TimeoutException e) { + if (!activationClaim.compareAndSet(true, false)) { + try { + activation.get(5L, TimeUnit.SECONDS); + return; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Studio native structure activation was interrupted.", interrupted); + } catch (ExecutionException | TimeoutException settlementFailure) { + throw new IllegalStateException( + "Studio native structure activation did not settle after claiming completion.", + unwrapFailure(settlementFailure)); + } + } + throw new IllegalStateException("Studio native structure activation did not complete.", e); + } + } + + private void abandonStudioEntryBootstrap(World world, Throwable failure) { + if (world == null) { + return; + } + if (J.isPrimaryThread()) { + try { + INMS.get().abandonStudioStructureBootstrap(world); + } catch (Throwable abandonmentFailure) { + failure.addSuppressed(abandonmentFailure); + } + return; + } + CompletableFuture abandonment = J.sfut( + () -> INMS.get().abandonStudioStructureBootstrap(world)); + try { + abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + failure.addSuppressed(new IllegalStateException( + "Studio native structure abandonment was interrupted.", e)); + } catch (ExecutionException | TimeoutException e) { + failure.addSuppressed(new IllegalStateException( + "Studio native structure abandonment did not complete.", unwrapFailure(e))); + } + } + + private void deferFailedOpenCleanup( + CompletableFuture entryLoadFuture, + PlatformChunkGenerator provider, + String worldName, + World world, + IrisProject project + ) { + CompletableFuture cleanup = deferCleanupUntilEntrySettlement( + entryLoadFuture, + () -> cleanupFailedOpen(provider, worldName, world, project)); + entryLoads.releaseAfterSuccessfulCompletion(worldName, entryLoadFuture, cleanup); + cleanup.whenComplete((ignored, cleanupFailure) -> { + if (cleanupFailure != null) { + IrisLogging.reportError("Deferred Studio cleanup failed for world \"" + + worldName + "\".", unwrapFailure(cleanupFailure)); + } + }); + observeDeferredEntryCleanupBoundary(entryLoadFuture, worldName); + } + + static CompletableFuture deferCleanupUntilEntrySettlement( + CompletableFuture entryLoadFuture, + Supplier> cleanup + ) { + Objects.requireNonNull(entryLoadFuture, "Studio entry-load future"); + Objects.requireNonNull(cleanup, "Studio cleanup"); + return entryLoadFuture.handle((ignored, entryFailure) -> null) + .thenCompose(ignored -> invokePhase(cleanup)); + } + + static boolean requiresDeferredEntryCleanup(CompletableFuture entryLoadFuture) { + return entryLoadFuture != null && !entryLoadFuture.isDone(); + } + + private CompletableFuture cleanupFailedOpen( + PlatformChunkGenerator provider, + String worldName, + World world, + IrisProject project + ) { + return closeWorldCoordinated(provider, worldName, world, true, project) + .thenCompose(result -> result.failureCause() == null + ? CompletableFuture.completedFuture(null) + : CompletableFuture.failedFuture(result.failureCause())); + } + + private void observeDeferredEntryCleanupBoundary( + CompletableFuture entryLoadFuture, + String worldName + ) { + CompletableFuture.delayedExecutor( + STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS, + TimeUnit.SECONDS).execute(() -> { + if (entryLoadFuture.isDone()) { + return; + } + TimeoutException timeout = new TimeoutException( + "Studio entry generation remained active for " + + STUDIO_ENTRY_CLEANUP_BOUNDARY_SECONDS + + " seconds after the open timeout for \"" + worldName + "\"."); + boolean queued = queueStartupCleanup(worldName, timeout); + String recovery = queued + ? " The transient world is queued for deletion at the next clean startup." + : " The transient world could not be queued for startup deletion."; + IrisLogging.reportError( + "Studio world \"" + worldName + + "\" remains loaded because its entry generation is still active;" + + " Iris did not unload or close its generator." + + recovery, + timeout); + }); } private CompletableFuture closeWorldCoordinated( @@ -539,6 +787,11 @@ public final class StudioOpenCoordinator { } for (String staleWorldName : staleWorldNames) { + if (entryLoads.isFenced(staleWorldName)) { + IrisLogging.warn("Skipping stale Studio cleanup for \"" + staleWorldName + + "\" because its open or deferred cleanup is still active."); + continue; + } try { StudioCloseResult cleanupResult = closeWorldCoordinated( null, @@ -659,12 +912,23 @@ public final class StudioOpenCoordinator { long seed, String worldName, String playerName, - boolean openWorkspace, + StudioOpenKind openKind, boolean retainOnFailure, Consumer progressConsumer, Consumer onDone ) { - public static StudioOpenRequest studioProject(IrisProject project, VolmitSender sender, long seed, Consumer progressConsumer, Consumer onDone) { + public StudioOpenRequest { + openKind = Objects.requireNonNull(openKind, "Studio open kind"); + } + + public static StudioOpenRequest studioProject( + IrisProject project, + VolmitSender sender, + long seed, + StudioOpenKind openKind, + Consumer progressConsumer, + Consumer onDone + ) { String playerName = sender != null && sender.isPlayer() && sender.player() != null ? sender.player().getName() : null; return new StudioOpenRequest( project.getName(), @@ -673,7 +937,7 @@ public final class StudioOpenCoordinator { seed, "iris-" + UUID.randomUUID(), playerName, - true, + openKind, false, progressConsumer, onDone @@ -681,6 +945,49 @@ public final class StudioOpenCoordinator { } } + public enum StudioOpenKind { + STANDARD( + true, + true, + IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY), + JIGSAW( + false, + false, + IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY); + + private final boolean teleportThroughStandardEntry; + private final boolean openWorkspace; + private final IrisCreator.DatapackPreparation datapackPreparation; + + StudioOpenKind( + boolean teleportThroughStandardEntry, + boolean openWorkspace, + IrisCreator.DatapackPreparation datapackPreparation + ) { + this.teleportThroughStandardEntry = teleportThroughStandardEntry; + this.openWorkspace = openWorkspace; + this.datapackPreparation = Objects.requireNonNull( + datapackPreparation, + "Studio datapack preparation"); + } + + public boolean teleportThroughStandardEntry() { + return teleportThroughStandardEntry; + } + + public boolean openWorkspace() { + return openWorkspace; + } + + public boolean prepareGeneratorState() { + return this == STANDARD; + } + + public IrisCreator.DatapackPreparation datapackPreparation() { + return datapackPreparation; + } + } + public record StudioOpenProgress(double progress, String stage) { } @@ -698,4 +1005,64 @@ public final class StudioOpenCoordinator { return failureCause == null; } } + + static final class EntryLoadRegistry { + private final ConcurrentHashMap> entryLoads; + + EntryLoadRegistry() { + entryLoads = new ConcurrentHashMap<>(); + } + + void register(String worldName, CompletableFuture entryLoadFuture) { + Objects.requireNonNull(worldName, "Studio world name"); + Objects.requireNonNull(entryLoadFuture, "Studio entry-load future"); + CompletableFuture existing = entryLoads.putIfAbsent(worldName, entryLoadFuture); + if (existing != null) { + throw new IllegalStateException("Studio open or deferred cleanup is already active for \"" + + worldName + "\"."); + } + } + + void release( + String worldName, + CompletableFuture entryLoadFuture + ) { + if (worldName == null || entryLoadFuture == null) { + return; + } + entryLoads.remove(worldName, entryLoadFuture); + } + + void releaseAfterSuccessfulCompletion( + String worldName, + CompletableFuture entryLoadFuture, + CompletableFuture completion + ) { + Objects.requireNonNull(completion, "Studio lifecycle completion"); + completion.whenComplete((ignored, failure) -> { + if (failure == null) { + release(worldName, entryLoadFuture); + } + }); + } + + void rejectNewOpen() { + ArrayList activeWorlds = new ArrayList<>(); + for (Map.Entry> entry : entryLoads.entrySet()) { + activeWorlds.add(entry.getKey()); + } + if (activeWorlds.isEmpty()) { + return; + } + Collections.sort(activeWorlds); + throw new IllegalStateException("A previous Studio open or deferred cleanup is still active for " + + String.join(", ", activeWorlds) + + ". Wait for it to settle before opening another Studio; a reported cleanup failure" + + " requires the queued clean restart."); + } + + boolean isFenced(String worldName) { + return entryLoads.containsKey(worldName); + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java index 7721ce82f..3e662cb68 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackend.java @@ -3,6 +3,7 @@ package art.arcane.iris.core.runtime; import org.bukkit.Chunk; import org.bukkit.World; +import java.lang.reflect.Method; import java.util.OptionalLong; import java.util.concurrent.CompletableFuture; @@ -18,4 +19,39 @@ interface WorldRuntimeControlBackend { void syncTime(World world); CompletableFuture requestChunkAsync(World world, int chunkX, int chunkZ, boolean generate); + + default CompletableFuture requestChunkAsync( + World world, + int chunkX, + int chunkZ, + boolean generate, + boolean urgent + ) { + if (!urgent) { + return requestChunkAsync(world, chunkX, chunkZ, generate); + } + if (world == null) { + return CompletableFuture.failedFuture(new IllegalStateException("World is null.")); + } + try { + Method method = World.class.getMethod( + "getChunkAtAsync", + int.class, + int.class, + boolean.class, + boolean.class); + Object result = method.invoke(world, chunkX, chunkZ, generate, true); + if (result instanceof CompletableFuture future) { + @SuppressWarnings("unchecked") + CompletableFuture chunkFuture = (CompletableFuture) future; + return chunkFuture; + } + return CompletableFuture.failedFuture( + new IllegalStateException("Paper World#getChunkAtAsync returned a non-future result.")); + } catch (NoSuchMethodException exception) { + return requestChunkAsync(world, chunkX, chunkZ, generate); + } catch (Throwable exception) { + return CompletableFuture.failedFuture(exception); + } + } } 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 5a47f3e1c..ff5b87dbe 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 @@ -198,6 +198,16 @@ public final class WorldRuntimeControlService { return backend.requestChunkAsync(world, chunkX, chunkZ, generate); } + public CompletableFuture requestChunkAsync( + World world, + int chunkX, + int chunkZ, + boolean generate, + boolean urgent + ) { + return backend.requestChunkAsync(world, chunkX, chunkZ, generate, urgent); + } + public void prepareGenerator(World world) { if (world == null) { return; diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarArchetype.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarArchetype.java new file mode 100644 index 000000000..e54c875ca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarArchetype.java @@ -0,0 +1,78 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; + +import java.util.Objects; + +public enum JigsawPlanarArchetype { + BLANK("workcell/blank", JigsawPlanarTopology.BLANK), + END("workcell/end", JigsawPlanarTopology.NORTH_END), + STRAIGHT("workcell/straight", JigsawPlanarTopology.NORTH_SOUTH_STRAIGHT), + CORNER("workcell/corner", JigsawPlanarTopology.NORTH_EAST_CORNER), + TEE("workcell/tee", JigsawPlanarTopology.NORTH_EAST_WEST_TEE), + CROSS("workcell/cross", JigsawPlanarTopology.CROSS); + + private final String stableId; + private final JigsawPlanarTopology canonicalTopology; + + JigsawPlanarArchetype(String stableId, JigsawPlanarTopology canonicalTopology) { + this.stableId = stableId; + this.canonicalTopology = canonicalTopology; + } + + public static JigsawPlanarArchetype fromTopology(JigsawPlanarTopology topology) { + JigsawPlanarTopology source = Objects.requireNonNull(topology, "Planar topology"); + return switch (source.kind()) { + case BLANK -> BLANK; + case END -> END; + case STRAIGHT -> STRAIGHT; + case CORNER -> CORNER; + case TEE -> TEE; + case CROSS -> CROSS; + }; + } + + public static JigsawPlanarArchetype fromModel(IrisJigsawWorkcellArchetype archetype) { + return valueOf(Objects.requireNonNull(archetype, "Planar workcell archetype").name()); + } + + public String stableId() { + return stableId; + } + + public JigsawPlanarTopology canonicalTopology() { + return canonicalTopology; + } + + public IrisJigsawWorkcellArchetype modelArchetype() { + return IrisJigsawWorkcellArchetype.valueOf(name()); + } + + public String displayName() { + return switch (this) { + case BLANK -> "Blank"; + case END -> "End Cap"; + case STRAIGHT -> "Hallway"; + case CORNER -> "L Junction"; + case TEE -> "T Junction"; + case CROSS -> "Cross Junction"; + }; + } + + public int sourceToCanonicalQuarterTurns(JigsawPlanarTopology sourceTopology) { + JigsawPlanarTopology source = Objects.requireNonNull(sourceTopology, "Source planar topology"); + if (fromTopology(source) != this) { + throw new IllegalArgumentException("Topology " + source + " does not belong to archetype " + this); + } + for (int quarterTurns = 0; quarterTurns < 4; quarterTurns++) { + if (source.rotateClockwise(quarterTurns) == canonicalTopology) { + return quarterTurns; + } + } + throw new IllegalStateException("No canonical rotation exists for topology " + source); + } + + public int canonicalToSourceQuarterTurns(JigsawPlanarTopology sourceTopology) { + return Math.floorMod(-sourceToCanonicalQuarterTurns(sourceTopology), 4); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarDirection.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarDirection.java new file mode 100644 index 000000000..0bbb80bc0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarDirection.java @@ -0,0 +1,38 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.engine.object.IrisDirection; + +public enum JigsawPlanarDirection { + NORTH(1), + EAST(2), + SOUTH(4), + WEST(8); + + private final int bit; + + JigsawPlanarDirection(int bit) { + this.bit = bit; + } + + public int bit() { + return bit; + } + + public IrisDirection irisDirection() { + return switch (this) { + case NORTH -> IrisDirection.NORTH_NEGATIVE_Z; + case EAST -> IrisDirection.EAST_POSITIVE_X; + case SOUTH -> IrisDirection.SOUTH_POSITIVE_Z; + case WEST -> IrisDirection.WEST_NEGATIVE_X; + }; + } + + public JigsawPlanarDirection rotateClockwise(int quarterTurns) { + int normalizedTurns = Math.floorMod(quarterTurns, values().length); + return values()[(ordinal() + normalizedTurns) % values().length]; + } + + public JigsawPlanarDirection opposite() { + return rotateClockwise(2); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopology.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopology.java new file mode 100644 index 000000000..8f59b28e5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopology.java @@ -0,0 +1,100 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + +public enum JigsawPlanarTopology { + BLANK(0), + NORTH_END(1), + EAST_END(2), + NORTH_EAST_CORNER(3), + SOUTH_END(4), + NORTH_SOUTH_STRAIGHT(5), + EAST_SOUTH_CORNER(6), + NORTH_EAST_SOUTH_TEE(7), + WEST_END(8), + NORTH_WEST_CORNER(9), + EAST_WEST_STRAIGHT(10), + NORTH_EAST_WEST_TEE(11), + SOUTH_WEST_CORNER(12), + NORTH_SOUTH_WEST_TEE(13), + EAST_SOUTH_WEST_TEE(14), + CROSS(15); + + private static final JigsawPlanarTopology[] BY_MASK = buildMaskIndex(); + + private final int mask; + private final JigsawPlanarTopologyKind kind; + private final Set directions; + + JigsawPlanarTopology(int mask) { + this.mask = mask; + this.kind = resolveKind(mask); + this.directions = Collections.unmodifiableSet(resolveDirections(mask)); + } + + public static JigsawPlanarTopology fromMask(int mask) { + if (mask < 0 || mask >= BY_MASK.length) { + throw new IllegalArgumentException("Planar topology mask must be between 0 and 15"); + } + return BY_MASK[mask]; + } + + public int mask() { + return mask; + } + + public JigsawPlanarTopologyKind kind() { + return kind; + } + + public Set directions() { + return directions; + } + + public boolean connects(JigsawPlanarDirection direction) { + return directions.contains(direction); + } + + public JigsawPlanarTopology rotateClockwise(int quarterTurns) { + int normalizedTurns = Math.floorMod(quarterTurns, 4); + if (normalizedTurns == 0 || mask == 0 || mask == 15) { + return this; + } + int rotatedMask = ((mask << normalizedTurns) | (mask >>> (4 - normalizedTurns))) & 15; + return fromMask(rotatedMask); + } + + private static JigsawPlanarTopology[] buildMaskIndex() { + JigsawPlanarTopology[] index = new JigsawPlanarTopology[16]; + for (JigsawPlanarTopology topology : values()) { + index[topology.mask] = topology; + } + return index; + } + + private static EnumSet resolveDirections(int mask) { + EnumSet result = EnumSet.noneOf(JigsawPlanarDirection.class); + for (JigsawPlanarDirection direction : JigsawPlanarDirection.values()) { + if ((mask & direction.bit()) != 0) { + result.add(direction); + } + } + return result; + } + + private static JigsawPlanarTopologyKind resolveKind(int mask) { + int connectionCount = Integer.bitCount(mask); + return switch (connectionCount) { + case 0 -> JigsawPlanarTopologyKind.BLANK; + case 1 -> JigsawPlanarTopologyKind.END; + case 2 -> mask == 5 || mask == 10 + ? JigsawPlanarTopologyKind.STRAIGHT + : JigsawPlanarTopologyKind.CORNER; + case 3 -> JigsawPlanarTopologyKind.TEE; + case 4 -> JigsawPlanarTopologyKind.CROSS; + default -> throw new IllegalArgumentException("Invalid planar topology mask " + mask); + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyKind.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyKind.java new file mode 100644 index 000000000..ecf50678d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyKind.java @@ -0,0 +1,10 @@ +package art.arcane.iris.core.runtime.jigsaw; + +public enum JigsawPlanarTopologyKind { + BLANK, + END, + STRAIGHT, + CORNER, + TEE, + CROSS +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivation.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivation.java new file mode 100644 index 000000000..34132e38e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivation.java @@ -0,0 +1,409 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.loader.IrisData; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public final class JigsawStudioActivation { + private static final Map ACTIVE = new ConcurrentHashMap<>(); + private static final AtomicReference OPENING_OWNER = new AtomicReference<>(); + private static final AtomicReference STAGED = new AtomicReference<>(); + + private JigsawStudioActivation() { + } + + public static Request activate( + String packKey, + String structureKey, + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget compatibilityTarget, + JigsawStudioCellDimensions cellDimensions, + IrisData source + ) { + JigsawStudioLayout layout = JigsawStudioLayout.create( + mode, + cellDimensions, + JigsawStudioVariantCatalog.empty()); + return activate( + packKey, + structureKey, + mode, + compatibilityTarget, + cellDimensions, + source, + layout, + null + ); + } + + public static Request activate( + String packKey, + String structureKey, + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget compatibilityTarget, + JigsawStudioCellDimensions cellDimensions, + IrisData source, + JigsawStudioLayout initialLayout + ) { + return activate( + packKey, + structureKey, + mode, + compatibilityTarget, + cellDimensions, + source, + initialLayout, + null + ); + } + + public static synchronized Request activate( + String packKey, + String structureKey, + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget compatibilityTarget, + JigsawStudioCellDimensions cellDimensions, + IrisData source, + JigsawStudioLayout initialLayout, + UUID ownerId + ) { + if (STAGED.get() != null) { + throw new IllegalStateException("A Jigsaw Studio replacement is already staged"); + } + if (ownerId != null && !ownerId.equals(OPENING_OWNER.get())) { + throw new IllegalStateException("Jigsaw Studio activation does not own the active opening lease"); + } + UUID activeOwnerId = activeOwnerId(); + if (ownerId != null && activeOwnerId != null && !ownerId.equals(activeOwnerId)) { + throw new IllegalStateException("Jigsaw Studio is owned by another player session"); + } + ActiveRequest activeRequest = createActiveRequest( + packKey, + structureKey, + mode, + compatibilityTarget, + cellDimensions, + source, + initialLayout, + ownerId); + ACTIVE.put(normalize(packKey), activeRequest); + return activeRequest.request(); + } + + public static synchronized StagedActivation stage( + String packKey, + String structureKey, + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget compatibilityTarget, + JigsawStudioCellDimensions cellDimensions, + IrisData source, + JigsawStudioLayout initialLayout, + UUID ownerId, + UUID preservedRequestId + ) { + UUID openingOwner = OPENING_OWNER.get(); + UUID activeOwner = activeOwnerId(); + UUID requestedOwner = Objects.requireNonNull(ownerId, "Jigsaw Studio staged owner ID"); + if (!requestedOwner.equals(openingOwner)) { + throw new IllegalStateException("Jigsaw Studio staging does not own the active opening lease"); + } + if (activeOwner != null && !requestedOwner.equals(activeOwner)) { + throw new IllegalStateException("Jigsaw Studio is owned by another player session"); + } + if (STAGED.get() != null) { + throw new IllegalStateException("A Jigsaw Studio replacement is already staged"); + } + ActiveRequest preserved = preservedRequestId == null ? null : active(preservedRequestId); + if (preservedRequestId != null && preserved == null) { + throw new IllegalStateException("The Jigsaw Studio being replaced is no longer active"); + } + if (activeOwner != null && preserved == null) { + throw new IllegalStateException("The active Jigsaw Studio must be preserved during replacement"); + } + if (preserved != null && preserved.request().ownerId() != null + && !requestedOwner.equals(preserved.request().ownerId())) { + throw new IllegalStateException("The Jigsaw Studio being replaced belongs to another player session"); + } + ActiveRequest candidate = createActiveRequest( + packKey, + structureKey, + mode, + compatibilityTarget, + cellDimensions, + source, + initialLayout, + requestedOwner); + StagedActivation staged = new StagedActivation( + UUID.randomUUID(), candidate.request(), candidate.session()); + STAGED.set(new StagedState(staged, candidate, preserved, false)); + return staged; + } + + public static synchronized boolean beginStagedGeneration(StagedActivation expected) { + StagedState state = stagedState(expected); + if (state == null || state.generationVisible()) { + return false; + } + STAGED.set(new StagedState(state.staged(), state.candidate(), state.preserved(), true)); + return true; + } + + public static synchronized boolean commit(StagedActivation expected) { + StagedState state = stagedState(expected); + if (state == null || !state.generationVisible()) { + return false; + } + if (state.preserved() != null) { + ACTIVE.remove(normalize(state.preserved().request().packKey()), state.preserved()); + } + ACTIVE.put(normalize(state.candidate().request().packKey()), state.candidate()); + STAGED.set(null); + return true; + } + + public static synchronized boolean rollback(StagedActivation expected) { + StagedState state = stagedState(expected); + if (state == null) { + return false; + } + STAGED.set(null); + return true; + } + + public static Request getGeneratorRequest(String packKey) { + StagedState state = STAGED.get(); + if (state != null && state.generationVisible() + && normalize(state.candidate().request().packKey()).equals(normalize(packKey))) { + return state.candidate().request(); + } + return getRequest(packKey); + } + + public static JigsawStudioSession getGeneratorSession(String packKey) { + StagedState state = STAGED.get(); + if (state != null && state.generationVisible() + && normalize(state.candidate().request().packKey()).equals(normalize(packKey))) { + return state.candidate().session(); + } + return getSession(packKey); + } + + public static boolean tryBeginOpen(UUID ownerId) { + UUID requestedOwner = Objects.requireNonNull(ownerId, "Jigsaw Studio opening owner ID"); + UUID activeOwnerId = activeOwnerId(); + if (activeOwnerId != null && !requestedOwner.equals(activeOwnerId)) { + return false; + } + return OPENING_OWNER.compareAndSet(null, requestedOwner); + } + + public static void finishOpen(UUID ownerId) { + if (ownerId != null) { + OPENING_OWNER.compareAndSet(ownerId, null); + } + } + + public static UUID openingOwnerId() { + return OPENING_OWNER.get(); + } + + public static UUID activeOwnerId() { + for (ActiveRequest activeRequest : ACTIVE.values()) { + UUID ownerId = activeRequest.request().ownerId(); + if (ownerId != null) { + return ownerId; + } + } + return null; + } + + public static void deactivate(String packKey) { + if (packKey != null) { + ACTIVE.remove(normalize(packKey)); + StagedState state = STAGED.get(); + if (state != null && (normalize(state.candidate().request().packKey()).equals(normalize(packKey)) + || state.preserved() != null && normalize(state.preserved().request().packKey()) + .equals(normalize(packKey)))) { + STAGED.compareAndSet(state, null); + } + } + } + + public static boolean deactivate(String packKey, UUID requestId) { + if (packKey == null || requestId == null) { + return false; + } + StagedState staged = STAGED.get(); + if (staged != null && staged.preserved() != null + && requestId.equals(staged.preserved().request().requestId()) + && normalize(packKey).equals(normalize(staged.preserved().request().packKey()))) { + return false; + } + AtomicBoolean removed = new AtomicBoolean(false); + ACTIVE.computeIfPresent(normalize(packKey), (key, activeRequest) -> { + if (!requestId.equals(activeRequest.request().requestId())) { + return activeRequest; + } + removed.set(true); + return null; + }); + return removed.get(); + } + + public static boolean isActive(String packKey) { + return getRequest(packKey) != null; + } + + public static Request getRequest(String packKey) { + ActiveRequest activeRequest = active(packKey); + return activeRequest == null ? null : activeRequest.request(); + } + + public static JigsawStudioSession getSession(String packKey) { + ActiveRequest activeRequest = active(packKey); + return activeRequest == null ? null : activeRequest.session(); + } + + public static JigsawStudioLayout getLayout(String packKey) { + JigsawStudioSession session = getSession(packKey); + return session == null ? null : session.layout(); + } + + public static Request getRequest(UUID requestId) { + ActiveRequest activeRequest = active(requestId); + return activeRequest == null ? null : activeRequest.request(); + } + + public static JigsawStudioSession getSession(UUID requestId) { + ActiveRequest activeRequest = active(requestId); + return activeRequest == null ? null : activeRequest.session(); + } + + private static ActiveRequest active(String packKey) { + if (packKey == null) { + return null; + } + return ACTIVE.get(normalize(packKey)); + } + + private static ActiveRequest active(UUID requestId) { + if (requestId == null) { + return null; + } + for (ActiveRequest activeRequest : ACTIVE.values()) { + if (requestId.equals(activeRequest.request().requestId())) { + return activeRequest; + } + } + return null; + } + + private static ActiveRequest createActiveRequest( + String packKey, + String structureKey, + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget compatibilityTarget, + JigsawStudioCellDimensions cellDimensions, + IrisData source, + JigsawStudioLayout initialLayout, + UUID ownerId + ) { + Request request = new Request( + UUID.randomUUID(), + packKey, + structureKey, + mode, + compatibilityTarget, + cellDimensions, + source, + ownerId); + JigsawStudioLayout layout = Objects.requireNonNull(initialLayout, "Initial Jigsaw Studio layout"); + if (layout.mode() != mode) { + throw new IllegalArgumentException("Initial Jigsaw Studio layout mode does not match the request"); + } + if (!layout.cellDimensions().equals(cellDimensions)) { + throw new IllegalArgumentException("Initial Jigsaw Studio layout cell dimensions do not match the request"); + } + JigsawStudioSession session = new JigsawStudioSession( + request.requestId(), + request.packKey(), + request.structureKey(), + layout); + return new ActiveRequest(request, session); + } + + private static StagedState stagedState(StagedActivation expected) { + StagedActivation staged = Objects.requireNonNull(expected, "Staged Jigsaw Studio activation"); + StagedState state = STAGED.get(); + return state != null && state.staged().stageId().equals(staged.stageId()) ? state : null; + } + + private static String normalize(String key) { + return key.trim().toLowerCase(Locale.ROOT); + } + + private record ActiveRequest(Request request, JigsawStudioSession session) { + } + + private record StagedState( + StagedActivation staged, + ActiveRequest candidate, + ActiveRequest preserved, + boolean generationVisible + ) { + } + + public record StagedActivation( + UUID stageId, + Request request, + JigsawStudioSession session + ) { + public StagedActivation { + Objects.requireNonNull(stageId, "Jigsaw Studio stage ID"); + Objects.requireNonNull(request, "Jigsaw Studio staged request"); + Objects.requireNonNull(session, "Jigsaw Studio staged session"); + if (!request.requestId().equals(session.sessionId())) { + throw new IllegalArgumentException("Staged Jigsaw Studio request and session IDs do not match"); + } + } + } + + public record Request( + UUID requestId, + String packKey, + String structureKey, + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget compatibilityTarget, + JigsawStudioCellDimensions cellDimensions, + IrisData source, + UUID ownerId + ) { + public Request { + requestId = Objects.requireNonNull(requestId, "Jigsaw Studio request ID"); + packKey = requireKey(packKey, "pack"); + structureKey = requireKey(structureKey, "structure"); + mode = Objects.requireNonNull(mode, "Jigsaw Studio mode"); + compatibilityTarget = Objects.requireNonNull( + compatibilityTarget, + "Jigsaw Studio compatibility target" + ); + cellDimensions = Objects.requireNonNull(cellDimensions, "Jigsaw Studio cell dimensions"); + source = Objects.requireNonNull(source, "Jigsaw Studio source data"); + } + + private static String requireKey(String value, String name) { + Objects.requireNonNull(value, "Jigsaw Studio " + name + " key"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank"); + } + return normalized; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioAuthoringAccess.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioAuthoringAccess.java new file mode 100644 index 000000000..207c43d57 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioAuthoringAccess.java @@ -0,0 +1,31 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; + +import java.io.IOException; +import java.util.Objects; + +public final class JigsawStudioAuthoringAccess { + private JigsawStudioAuthoringAccess() { + } + + public static boolean isEditable(StructureOwnershipManifest manifest) { + StructureOwnershipManifest ownership = Objects.requireNonNull( + manifest, + "Jigsaw Studio ownership manifest"); + return ownership.provenance().origin() != StructureOwnershipManifest.Origin.MANAGED_DATAPACK; + } + + public static StructureOwnershipManifest requireEditable( + StructureOwnershipManifest manifest + ) throws IOException { + StructureOwnershipManifest ownership = Objects.requireNonNull( + manifest, + "Jigsaw Studio ownership manifest"); + if (!isEditable(ownership)) { + throw new IOException("This graph is read-only because it is managed by datapack ingest; " + + "adopt or clone it before making authoring changes."); + } + return ownership; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBay.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBay.java new file mode 100644 index 000000000..e239d46a5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBay.java @@ -0,0 +1,62 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.Objects; +import java.util.Optional; + +public record JigsawStudioBay( + String stableId, + JigsawStudioBayKind kind, + Optional workcellSpec, + String authorDisplayName, + JigsawStudioBounds bounds +) { + public JigsawStudioBay { + stableId = requireStableId(stableId); + kind = Objects.requireNonNull(kind, "Jigsaw Studio bay kind"); + workcellSpec = Objects.requireNonNull(workcellSpec, "Jigsaw Studio workcell specification"); + authorDisplayName = authorDisplayName == null ? "" : authorDisplayName.trim(); + bounds = Objects.requireNonNull(bounds, "Jigsaw Studio bay bounds"); + if (kind == JigsawStudioBayKind.PLANAR_WORKCELL && workcellSpec.isEmpty()) { + throw new IllegalArgumentException("Planar Jigsaw Studio workcells require an archetype"); + } + if (kind == JigsawStudioBayKind.SPATIAL_WORKCELL && workcellSpec.isPresent()) { + throw new IllegalArgumentException("Spatial Jigsaw Studio workcells cannot declare an archetype"); + } + if (workcellSpec.isPresent() && !workcellSpec.get().dimensions().equals(bounds.dimensions())) { + throw new IllegalArgumentException("Jigsaw Studio workcell specification dimensions do not match bounds"); + } + } + + public Optional archetype() { + return workcellSpec.map(JigsawStudioWorkcellSpec::archetype); + } + + public boolean enabled() { + return workcellSpec.map(JigsawStudioWorkcellSpec::enabled).orElse(true); + } + + public String canonicalDisplayName() { + return archetype().map(JigsawPlanarArchetype::displayName).orElse("Spatial"); + } + + public String displayName() { + return authorDisplayName.isEmpty() ? canonicalDisplayName() : authorDisplayName; + } + + public JigsawStudioCellDimensions capacity() { + return bounds.dimensions(); + } + + public Optional topology() { + return archetype().map(JigsawPlanarArchetype::canonicalTopology); + } + + private static String requireStableId(String value) { + Objects.requireNonNull(value, "Jigsaw Studio bay stable ID"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio bay stable ID cannot be blank"); + } + return normalized; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBayKind.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBayKind.java new file mode 100644 index 000000000..9a19e0647 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBayKind.java @@ -0,0 +1,6 @@ +package art.arcane.iris.core.runtime.jigsaw; + +public enum JigsawStudioBayKind { + PLANAR_WORKCELL, + SPATIAL_WORKCELL +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBounds.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBounds.java new file mode 100644 index 000000000..7578ee6c8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioBounds.java @@ -0,0 +1,37 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.Objects; + +public record JigsawStudioBounds( + int originX, + int originY, + int originZ, + JigsawStudioCellDimensions dimensions +) { + public JigsawStudioBounds { + dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio bounds dimensions"); + } + + public int maxX() { + return originX + dimensions.width() - 1; + } + + public int maxY() { + return originY + dimensions.height() - 1; + } + + public int maxZ() { + return originZ + dimensions.depth() - 1; + } + + public boolean contains(int worldX, int worldY, int worldZ) { + return worldX >= originX && worldX <= maxX() + && worldY >= originY && worldY <= maxY() + && worldZ >= originZ && worldZ <= maxZ(); + } + + public boolean intersectsHorizontal(int minX, int minZ, int maxX, int maxZ) { + return this.maxX() >= minX && originX <= maxX + && this.maxZ() >= minZ && originZ <= maxZ; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCellDimensions.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCellDimensions.java new file mode 100644 index 000000000..342f0e2b6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCellDimensions.java @@ -0,0 +1,30 @@ +package art.arcane.iris.core.runtime.jigsaw; + +public record JigsawStudioCellDimensions(int width, int height, int depth) { + public static final int MAX_HORIZONTAL_AXIS = 128; + public static final int MAX_HEIGHT = 192; + public static final long MAX_VOLUME = 2_097_152L; + + public JigsawStudioCellDimensions { + if (width < 1 || height < 1 || depth < 1) { + throw new IllegalArgumentException("Jigsaw Studio cell dimensions must be positive"); + } + if (width > MAX_HORIZONTAL_AXIS || depth > MAX_HORIZONTAL_AXIS) { + throw new IllegalArgumentException("Jigsaw Studio cell width and depth cannot exceed " + + MAX_HORIZONTAL_AXIS + " blocks"); + } + if (height > MAX_HEIGHT) { + throw new IllegalArgumentException("Jigsaw Studio cell height cannot exceed " + + MAX_HEIGHT + " blocks"); + } + long volume = (long) width * height * depth; + if (volume > MAX_VOLUME) { + throw new IllegalArgumentException("Jigsaw Studio cell volume cannot exceed " + + MAX_VOLUME + " blocks"); + } + } + + public long volume() { + return (long) width * height * depth; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCompatibilityTarget.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCompatibilityTarget.java new file mode 100644 index 000000000..e2262ebca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioCompatibilityTarget.java @@ -0,0 +1,6 @@ +package art.arcane.iris.core.runtime.jigsaw; + +public enum JigsawStudioCompatibilityTarget { + IRIS_EXTENDED, + VANILLA_PORTABLE +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioControlPosition.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioControlPosition.java new file mode 100644 index 000000000..25b709f54 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioControlPosition.java @@ -0,0 +1,4 @@ +package art.arcane.iris.core.runtime.jigsaw; + +public record JigsawStudioControlPosition(int worldX, int worldY, int worldZ) { +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditor.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditor.java new file mode 100644 index 000000000..9e951d11d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditor.java @@ -0,0 +1,1872 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteOptions; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class JigsawStudioGraphEditor { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private JigsawStudioGraphEditor() { + } + + public static StructureWriteResult createPiece( + Path packRoot, + String structureKey, + String poolKey, + String pieceKey, + int weight, + JigsawStudioCellDimensions dimensions, + JigsawPlanarTopology topology + ) throws IOException { + if (weight < 1) { + throw new IllegalArgumentException("Jigsaw piece weight must be positive"); + } + String normalizedPiece = JigsawStudioProjectCreator.Options.requireResourceKey(pieceKey); + String normalizedPool = JigsawStudioProjectCreator.Options.requireResourceKey(poolKey); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String poolResource = "jigsaw-pools/" + normalizedPool + ".json"; + if (!graph.manifest().resourceHashes().containsKey(poolResource)) { + throw new IOException("The target pool is not owned by this jigsaw project: " + poolKey); + } + String pieceResource = "jigsaw-pieces/" + normalizedPiece + ".json"; + String objectResource = "objects/" + normalizedPiece + ".iob"; + if (graph.manifest().resourceHashes().containsKey(pieceResource) + || graph.manifest().resourceHashes().containsKey(objectResource)) { + throw new IOException("The jigsaw project already owns piece or object '" + normalizedPiece + "'"); + } + + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + for (Map.Entry resource : graph.manifest().resourceHashes().entrySet()) { + Path resourcePath = resolveOwnedResource(graph.root(), resource.getKey()); + byte[] content = Files.readAllBytes(resourcePath); + if (resource.getKey().equals(poolResource)) { + content = addPoolEntry(content, normalizedPiece, weight, resourcePath); + } + bundle.resource(resource.getKey(), content); + } + IrisJigsawPiece piece = new IrisJigsawPiece() + .setObject(normalizedPiece) + .setRotatable(true); + if (topology != null) { + IrisPosition cellSize = new IrisPosition( + dimensions.width(), dimensions.height(), dimensions.depth()); + for (JigsawPlanarDirection planarDirection : topology.directions()) { + IrisDirection direction = planarDirection.irisDirection(); + piece.getConnectors().add(new IrisJigsawConnector() + .setPosition(IrisJigsawConnector.canonicalPlanarPosition(cellSize, direction)) + .setDirection(direction) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool(normalizedPool) + .setName("iris:planar") + .setTargetName("iris:planar") + .setJoint(JigsawJoint.ALIGNED) + .setFinalState("minecraft:structure_void")); + } + } + IrisObject object = new IrisObject(dimensions.width(), dimensions.height(), dimensions.depth()); + bundle.textResource(pieceResource, GSON.toJson(piece) + "\n"); + bundle.resource(objectResource, JigsawStudioProjectCreator.serialize(object)); + return write(graph, bundle.build()); + } + + public static StructureWriteResult duplicatePiece( + Path packRoot, + String structureKey, + String sourcePieceKey, + String targetPieceKey + ) throws IOException { + return createVariantFromPiece( + packRoot, + structureKey, + sourcePieceKey, + targetPieceKey, + VariantObjectMode.COPY_SOURCE); + } + + public static StructureWriteResult createBlankVariant( + Path packRoot, + String structureKey, + String sourcePieceKey, + String targetPieceKey + ) throws IOException { + return createVariantFromPiece( + packRoot, + structureKey, + sourcePieceKey, + targetPieceKey, + VariantObjectMode.EMPTY_SOURCE_SIZE); + } + + public static VariantFamilyCreation duplicateActiveFamily( + Path packRoot, + String structureKey, + Map sourcePieceKeysByWorkcell, + String newThemeKey + ) throws IOException { + Map requestedSources = Map.copyOf(Objects.requireNonNull( + sourcePieceKeysByWorkcell, + "Jigsaw Studio theme-set source pieces")); + String themeKey = JigsawStudioProjectCreator.Options.requireResourceKey(newThemeKey); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String structureResource = "structures/" + graph.manifest().structure().path() + ".json"; + if (!graph.manifest().resourceHashes().containsKey(structureResource)) { + throw new IOException("The owned graph manifest does not include " + structureResource); + } + IrisStructure structure = readStructure( + resolveOwnedResource(graph.root(), structureResource), + structureResource); + Map expectedSources = expectedThemeSetSources(structure); + requireExactThemeSetSources(requestedSources, expectedSources.keySet()); + + Map resources = readOwnedResources(graph); + resources.put( + structureResource, + appendThemeSet(resources.get(structureResource), themeKey, structureResource)); + Map newPieceKeysByWorkcell = new LinkedHashMap<>(); + Map newPieceKeysBySource = new LinkedHashMap<>(); + for (Map.Entry expected : expectedSources.entrySet()) { + String stableId = expected.getKey(); + String sourcePieceKey = JigsawStudioProjectCreator.Options.requireResourceKey( + requestedSources.get(stableId)); + if (newPieceKeysBySource.containsKey(sourcePieceKey)) { + throw new IOException("Theme-set source piece '" + sourcePieceKey + + "' was selected for more than one workcell"); + } + String sourcePieceResource = "jigsaw-pieces/" + sourcePieceKey + ".json"; + if (!graph.manifest().resourceHashes().containsKey(sourcePieceResource)) { + throw new IOException("Theme-set source piece '" + sourcePieceKey + + "' is not owned by this jigsaw project"); + } + IrisJigsawPiece sourcePiece = readPiece( + resolveOwnedResource(graph.root(), sourcePieceResource), + sourcePieceResource); + JigsawPlanarArchetype expectedArchetype = expected.getValue(); + if (expectedArchetype != null + && IrisJigsawWorkcellArchetype.fromPiece(sourcePiece) + != expectedArchetype.modelArchetype()) { + throw new IOException("Theme-set source piece '" + sourcePieceKey + + "' does not belong to " + stableId); + } + if (sourcePiece.getObject() == null || sourcePiece.getObject().isBlank()) { + throw new IOException("Theme-set source piece '" + sourcePieceKey + + "' does not declare an object"); + } + String sourceObjectKey = JigsawStudioProjectCreator.Options.requireResourceKey(sourcePiece.getObject()); + String sourceObjectResource = "objects/" + sourceObjectKey + ".iob"; + if (!graph.manifest().resourceHashes().containsKey(sourceObjectResource)) { + throw new IOException("Theme-set source object '" + sourceObjectKey + + "' is not owned by this jigsaw project"); + } + String variantFolder = expectedArchetype == null + ? "spatial" + : expectedArchetype.name().toLowerCase(Locale.ROOT); + String targetPieceKey = graph.manifest().structure().path() + + "/variants/" + variantFolder + "/" + themeKey; + String targetPieceResource = "jigsaw-pieces/" + targetPieceKey + ".json"; + String targetObjectResource = "objects/" + targetPieceKey + ".iob"; + requireAvailableThemeSetTarget(graph, targetPieceResource); + requireAvailableThemeSetTarget(graph, targetObjectResource); + resources.put( + targetPieceResource, + duplicatePieceForTheme( + resources.get(sourcePieceResource), + targetPieceKey, + themeKey, + sourcePieceResource)); + resources.put(targetObjectResource, resources.get(sourceObjectResource).clone()); + newPieceKeysByWorkcell.put(stableId, targetPieceKey); + newPieceKeysBySource.put(sourcePieceKey, targetPieceKey); + } + + for (Map.Entry resource : new ArrayList<>(resources.entrySet())) { + String relativePath = resource.getKey(); + if (!relativePath.startsWith("jigsaw-pools/") || !relativePath.endsWith(".json")) { + continue; + } + resources.put( + relativePath, + duplicatePoolMemberships( + resource.getValue(), + newPieceKeysBySource, + relativePath).content()); + } + + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + for (Map.Entry resource : resources.entrySet()) { + bundle.resource(resource.getKey(), resource.getValue()); + } + StructureWriteResult result = write(graph, bundle.build()); + return new VariantFamilyCreation(newPieceKeysByWorkcell, result); + } + + public static String nextVariantKey( + Path packRoot, + String structureKey, + String archetypeKey + ) throws IOException { + String normalizedStructure = JigsawStudioProjectCreator.Options.requireResourceKey(structureKey); + String normalizedArchetype = JigsawStudioProjectCreator.Options.requireResourceKey(archetypeKey); + OwnedGraph graph = loadOwnedGraph(packRoot, normalizedStructure); + String prefix = normalizedStructure + "/variants/" + normalizedArchetype + "/variant-"; + for (int index = 1; index <= 100_000; index++) { + String candidate = prefix + index; + if (!graph.manifest().resourceHashes().containsKey("jigsaw-pieces/" + candidate + ".json") + && !graph.manifest().resourceHashes().containsKey("objects/" + candidate + ".iob")) { + return candidate; + } + } + throw new IOException("No deterministic variant key remains below " + prefix); + } + + public static List ownedPoolKeys(Path packRoot, String structureKey) throws IOException { + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + List pools = new ArrayList<>(); + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + if (!relativePath.startsWith("jigsaw-pools/") || !relativePath.endsWith(".json")) { + continue; + } + pools.add(relativePath.substring("jigsaw-pools/".length(), relativePath.length() - ".json".length())); + } + pools.sort(Comparator.naturalOrder()); + return List.copyOf(pools); + } + + public static boolean ownsPiece( + Path packRoot, + String structureKey, + String pieceKey, + String objectKey + ) throws IOException { + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String normalizedPiece = JigsawStudioProjectCreator.Options.requireResourceKey(pieceKey); + String normalizedObject = JigsawStudioProjectCreator.Options.requireResourceKey(objectKey); + return graph.manifest().resourceHashes().containsKey("jigsaw-pieces/" + normalizedPiece + ".json") + && graph.manifest().resourceHashes().containsKey("objects/" + normalizedObject + ".iob"); + } + + public static StructureWriteResult createPool( + Path packRoot, + String structureKey, + String poolKey, + String fallbackPoolKey + ) throws IOException { + String normalizedPool = JigsawStudioProjectCreator.Options.requireResourceKey(poolKey); + String fallback = fallbackPoolKey == null ? "" : fallbackPoolKey.trim(); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String poolResource = "jigsaw-pools/" + normalizedPool + ".json"; + if (graph.manifest().resourceHashes().containsKey(poolResource)) { + throw new IOException("The jigsaw project already owns pool '" + normalizedPool + "'"); + } + if (!fallback.isBlank()) { + String fallbackResource = "jigsaw-pools/" + + JigsawStudioProjectCreator.Options.requireResourceKey(fallback) + ".json"; + if (!graph.manifest().resourceHashes().containsKey(fallbackResource)) { + throw new IOException("Fallback pool '" + fallback + "' is not owned by this jigsaw project"); + } + } + + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + bundle.resource(relativePath, Files.readAllBytes(resolveOwnedResource(graph.root(), relativePath))); + } + IrisJigsawPool pool = new IrisJigsawPool().setFallback(fallback); + bundle.textResource(poolResource, GSON.toJson(pool) + "\n"); + return write(graph, bundle.build()); + } + + public static StructureWriteResult updateRotatable( + Path packRoot, + String structureKey, + String pieceKey, + boolean rotatable + ) throws IOException { + String normalizedPiece = JigsawStudioProjectCreator.Options.requireResourceKey(pieceKey); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String pieceResource = "jigsaw-pieces/" + normalizedPiece + ".json"; + if (!graph.manifest().resourceHashes().containsKey(pieceResource)) { + throw new IOException("Piece '" + normalizedPiece + "' is not owned by this jigsaw project"); + } + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + byte[] content = Files.readAllBytes(resolveOwnedResource(graph.root(), relativePath)); + if (relativePath.equals(pieceResource)) { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw piece is not a JSON object: " + pieceResource); + } + JsonObject piece = parsed.getAsJsonObject(); + piece.addProperty("rotatable", rotatable); + content = (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8); + } + bundle.resource(relativePath, content); + } + return write(graph, bundle.build()); + } + + public static StructureWriteResult updatePieceThemes( + Path packRoot, + String structureKey, + String pieceKey, + List themes + ) throws IOException { + List normalizedThemes = normalizeThemes(themes); + return updateOwnedPiece( + packRoot, + structureKey, + pieceKey, + (piece, pieceResource) -> { + JsonArray values = new JsonArray(); + for (String theme : normalizedThemes) { + values.add(theme); + } + piece.add("themes", values); + }); + } + + public static StructureWriteResult updatePieceDisplayName( + Path packRoot, + String structureKey, + String pieceKey, + String displayName + ) throws IOException { + String normalizedName = normalizeDisplayName(displayName); + return updateOwnedPiece( + packRoot, + structureKey, + pieceKey, + (piece, pieceResource) -> { + if (normalizedName.isEmpty()) { + piece.remove("displayName"); + } else { + piece.addProperty("displayName", normalizedName); + } + }); + } + + public static StructureWriteResult updatePieceRules( + Path packRoot, + String structureKey, + String pieceKey, + JigsawStudioPieceRules rules + ) throws IOException { + JigsawStudioPieceRules normalizedRules = Objects.requireNonNull( + rules, + "Jigsaw Studio piece rules"); + return updateOwnedPiece( + packRoot, + structureKey, + pieceKey, + (piece, pieceResource) -> { + JsonObject values = new JsonObject(); + values.addProperty("minimumDepth", normalizedRules.minimumDepth()); + values.addProperty("maximumDepth", normalizedRules.maximumDepth()); + values.addProperty("minimumPlacements", normalizedRules.minimumPlacements()); + values.addProperty("maximumPlacements", normalizedRules.maximumPlacements()); + values.addProperty("terminal", normalizedRules.terminal()); + piece.add("rules", values); + }); + } + + public static PieceDeletionResult deletePieceVariant( + Path packRoot, + String structureKey, + String pieceKey + ) throws IOException { + String normalizedPiece = JigsawStudioProjectCreator.Options.requireResourceKey(pieceKey); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String pieceResource = "jigsaw-pieces/" + normalizedPiece + ".json"; + if (!graph.manifest().resourceHashes().containsKey(pieceResource)) { + throw new IOException("Piece '" + normalizedPiece + "' is not owned by this jigsaw project"); + } + IrisJigsawPiece targetPiece = readPiece( + resolveOwnedResource(graph.root(), pieceResource), + pieceResource); + if (targetPiece.getObject() == null || targetPiece.getObject().isBlank()) { + throw new IOException("Piece '" + normalizedPiece + "' does not declare an object"); + } + String objectKey = JigsawStudioProjectCreator.Options.requireResourceKey(targetPiece.getObject()); + String objectResource = "objects/" + objectKey + ".iob"; + boolean removeObject = graph.manifest().resourceHashes().containsKey(objectResource) + && !otherOwnedPieceReferencesObject(graph, normalizedPiece, objectKey); + requireDeletableArchetype(graph, normalizedPiece, targetPiece); + + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + int removedMemberships = 0; + int changedPools = 0; + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + if (relativePath.equals(pieceResource) || removeObject && relativePath.equals(objectResource)) { + continue; + } + byte[] content = Files.readAllBytes(resolveOwnedResource(graph.root(), relativePath)); + if (relativePath.startsWith("jigsaw-pools/") && relativePath.endsWith(".json")) { + PoolEntryRemoval removal = removePoolEntries(content, normalizedPiece, relativePath); + content = removal.content(); + removedMemberships += removal.removedEntries(); + if (removal.removedEntries() > 0) { + changedPools++; + } + } + bundle.resource(relativePath, content); + } + StructureResourceBundle updatedBundle = bundle.build(); + try { + StructureResourceBundleGraphCompiler.requireViable(updatedBundle); + } catch (RuntimeException exception) { + throw new IOException("Deleting piece '" + normalizedPiece + + "' would make the jigsaw graph non-viable: " + failureMessage(exception), exception); + } + StructureWriteResult result = writeCompiled(graph, updatedBundle); + return new PieceDeletionResult( + result, + removedMemberships, + changedPools, + 1, + removeObject ? 1 : 0); + } + + public static VariantResizeResult resizePieceObject( + Path packRoot, + String structureKey, + String pieceKey, + JigsawStudioCellDimensions dimensions + ) throws IOException { + String normalizedPiece = JigsawStudioProjectCreator.Options.requireResourceKey(pieceKey); + JigsawStudioCellDimensions targetDimensions = Objects.requireNonNull( + dimensions, + "Jigsaw Studio variant dimensions"); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String pieceResource = "jigsaw-pieces/" + normalizedPiece + ".json"; + if (!graph.manifest().resourceHashes().containsKey(pieceResource)) { + throw new IOException("Piece '" + normalizedPiece + "' is not owned by this jigsaw project"); + } + IrisJigsawPiece piece = readPiece(resolveOwnedResource(graph.root(), pieceResource), pieceResource); + if (piece.getObject() == null || piece.getObject().isBlank()) { + throw new IOException("Piece '" + normalizedPiece + "' does not declare an object"); + } + String objectKey = JigsawStudioProjectCreator.Options.requireResourceKey(piece.getObject()); + String objectResource = "objects/" + objectKey + ".iob"; + if (!graph.manifest().resourceHashes().containsKey(objectResource)) { + throw new IOException("Object '" + objectKey + "' is not owned by this jigsaw project"); + } + requireExclusiveObjectReference(graph, normalizedPiece, objectKey); + Path objectPath = resolveOwnedResource(graph.root(), objectResource); + IrisObject source = new IrisObject(); + source.read(objectPath.toFile()); + String structureResource = "structures/" + + JigsawStudioProjectCreator.Options.requireResourceKey(structureKey) + ".json"; + IrisStructure structure = readStructure( + resolveOwnedResource(graph.root(), structureResource), + structureResource); + IrisObject resizedObject; + JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions( + source.getW(), + source.getH(), + source.getD()); + JigsawStudioCellDimensions previousDimensions; + int relocatedConnectors = 0; + if (structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW) { + IrisJigsawWorkcellArchetype archetype = IrisJigsawWorkcellArchetype.fromPiece(piece); + JigsawPlanarArchetype planarArchetype = JigsawPlanarArchetype.fromModel(archetype); + int quarterTurns = archetype.sourceToCanonicalQuarterTurns(piece); + previousDimensions = canonicalDimensions(sourceDimensions, quarterTurns); + PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = + PlanarJigsawWorkcellResolver.resolve(structure).get(archetype); + if (workcell == null || !workcell.contains(dimensionsPosition(targetDimensions))) { + throw new IOException("Variant '" + normalizedPiece + "' size " + + describeDimensions(targetDimensions) + " exceeds the " + + planarArchetype.displayName() + " workcell capacity " + + describeDimensions(new JigsawStudioCellDimensions( + workcell == null ? 1 : workcell.width(), + workcell == null ? 1 : workcell.height(), + workcell == null ? 1 : workcell.depth())) + + "; increase that workcell capacity first"); + } + PlanarPieceObjectResize resized = resizePlanarPieceObject( + source, + piece, + planarArchetype, + targetDimensions, + normalizedPiece); + resizedObject = resized.object(); + relocatedConnectors = resized.relocatedConnectors(); + } else { + previousDimensions = sourceDimensions; + requireConnectorsInside(piece, targetDimensions, normalizedPiece); + resizedObject = resizeObject(source, targetDimensions, normalizedPiece); + } + + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + byte[] content; + if (relativePath.equals(objectResource)) { + content = JigsawStudioProjectCreator.serialize(resizedObject); + } else if (relativePath.equals(pieceResource)) { + content = (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8); + } else { + content = Files.readAllBytes(resolveOwnedResource(graph.root(), relativePath)); + } + bundle.resource(relativePath, content); + } + StructureWriteResult writeResult = write(graph, bundle.build()); + return new VariantResizeResult( + writeResult, + previousDimensions, + targetDimensions, + relocatedConnectors); + } + + public static WorkcellCapacityResult updatePlanarWorkcellCapacity( + Path packRoot, + String structureKey, + JigsawPlanarArchetype archetype, + JigsawStudioCellDimensions dimensions + ) throws IOException { + JigsawPlanarArchetype targetArchetype = Objects.requireNonNull( + archetype, + "Planar Jigsaw Studio archetype"); + JigsawStudioCellDimensions targetDimensions = Objects.requireNonNull( + dimensions, + "Planar Jigsaw Studio workcell dimensions"); + if (targetDimensions.width() < 3 || targetDimensions.depth() < 3) { + throw new IllegalArgumentException( + "Planar Jigsaw Studio workcell width and depth must each be at least 3 blocks"); + } + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String structureResource = "structures/" + graph.manifest().structure().path() + ".json"; + if (!graph.manifest().resourceHashes().containsKey(structureResource)) { + throw new IOException("The owned graph manifest does not include " + structureResource); + } + IrisStructure structure = readStructure( + resolveOwnedResource(graph.root(), structureResource), + structureResource); + if (structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW) { + throw new IOException("Workcell resizing requires a planar Jigsaw Studio structure"); + } + + Map resources = readOwnedResources(graph); + int checkedVariants = 0; + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) { + continue; + } + IrisJigsawPiece piece = readPiece(resolveOwnedResource(graph.root(), relativePath), relativePath); + if (IrisJigsawWorkcellArchetype.fromPiece(piece) != targetArchetype.modelArchetype()) { + continue; + } + checkedVariants++; + } + + Path structurePath = resolveOwnedResource(graph.root(), structureResource); + resources.put( + structureResource, + JigsawStudioStructureEditor.updateWorkcell( + resources.get(structureResource), + targetArchetype, + targetDimensions, + null, + null, + structurePath)); + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + for (Map.Entry resource : resources.entrySet()) { + bundle.resource(resource.getKey(), resource.getValue()); + } + StructureWriteResult writeResult; + try { + writeResult = write(graph, bundle.build()); + } catch (RuntimeException exception) { + throw new IOException("Workcell capacity " + describeDimensions(targetDimensions) + + " cannot contain every " + targetArchetype.displayName() + + " variant: " + failureMessage(exception), exception); + } + return new WorkcellCapacityResult(writeResult, checkedVariants); + } + + public static StructureWriteResult updateConnectorChannel( + Path packRoot, + String structureKey, + String pieceKey, + IrisPosition position, + String channel + ) throws IOException { + String normalizedPiece = JigsawStudioProjectCreator.Options.requireResourceKey(pieceKey); + IrisPosition connectorPosition = Objects.requireNonNull(position, "Jigsaw connector position"); + String normalizedChannel = normalizeChannel(channel); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String pieceResource = "jigsaw-pieces/" + normalizedPiece + ".json"; + if (!graph.manifest().resourceHashes().containsKey(pieceResource)) { + throw new IOException("Piece '" + normalizedPiece + "' is not owned by this jigsaw project"); + } + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + boolean found = false; + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + byte[] content = Files.readAllBytes(resolveOwnedResource(graph.root(), relativePath)); + if (relativePath.equals(pieceResource)) { + IrisJigsawPiece piece; + try { + piece = GSON.fromJson(new String(content, StandardCharsets.UTF_8), IrisJigsawPiece.class); + } catch (RuntimeException exception) { + throw new IOException("Jigsaw piece is not valid JSON: " + pieceResource, exception); + } + if (piece == null) { + throw new IOException("Jigsaw piece is empty: " + pieceResource); + } + for (IrisJigsawConnector connector : piece.getConnectors()) { + if (connectorPosition.equals(connector.getPosition())) { + connector.setChannel(normalizedChannel); + found = true; + } + } + content = (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8); + } + bundle.resource(relativePath, content); + } + if (!found) { + throw new IOException("Piece '" + normalizedPiece + "' has no connector at " + + connectorPosition.getX() + "," + connectorPosition.getY() + "," + + connectorPosition.getZ()); + } + return write(graph, bundle.build()); + } + + private static OwnedGraph loadOwnedGraph(Path packRoot, String structureKey) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + StructureKey ownershipKey = StructureKey.parse(structureKey, "iris"); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + Path manifestPath = writer.ownershipManifestPath(ownershipKey); + if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("This graph is read-only because it is not Studio-owned; create a new Jigsaw Studio project before editing pieces"); + } + byte[] manifestContent = Files.readAllBytes(manifestPath); + StructureOwnershipManifest manifest = JigsawStudioAuthoringAccess.requireEditable( + StructureOwnershipManifest.fromJson(manifestContent)); + return new OwnedGraph(root, writer, manifest, StructureHash.sha256(manifestContent)); + } + + private static Map expectedThemeSetSources( + IrisStructure structure + ) throws IOException { + Map expected = new LinkedHashMap<>(); + if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) { + expected.put(JigsawStudioLayout.SPATIAL_WORKCELL_ID, null); + return expected; + } + Map workcells; + try { + workcells = PlanarJigsawWorkcellResolver.resolve(structure); + } catch (IllegalArgumentException exception) { + throw new IOException("Planar workcell configuration is invalid: " + + exception.getMessage(), exception); + } + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = workcells.get(archetype.modelArchetype()); + if (workcell != null && workcell.enabled()) { + expected.put(archetype.stableId(), archetype); + } + } + if (expected.isEmpty()) { + throw new IOException("A planar theme set requires at least one enabled workcell"); + } + return expected; + } + + private static void requireExactThemeSetSources( + Map requestedSources, + Set expectedStableIds + ) throws IOException { + Set missing = new LinkedHashSet<>(expectedStableIds); + missing.removeAll(requestedSources.keySet()); + Set unexpected = new LinkedHashSet<>(requestedSources.keySet()); + unexpected.removeAll(expectedStableIds); + if (!missing.isEmpty() || !unexpected.isEmpty()) { + throw new IOException("Theme-set sources must match enabled workcells exactly; missing=" + + missing + ", unexpected=" + unexpected); + } + } + + private static Map readOwnedResources(OwnedGraph graph) throws IOException { + Map resources = new LinkedHashMap<>(); + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + resources.put( + relativePath, + Files.readAllBytes(resolveOwnedResource(graph.root(), relativePath))); + } + return resources; + } + + private static byte[] appendThemeSet( + byte[] content, + String themeKey, + String structureResource + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw structure is not a JSON object: " + structureResource); + } + JsonObject structure = parsed.getAsJsonObject(); + JsonArray themeSets; + if (!structure.has("themeSets")) { + themeSets = new JsonArray(); + structure.add("themeSets", themeSets); + } else if (!structure.get("themeSets").isJsonArray()) { + throw new IOException("Jigsaw structure has an invalid themeSets value: " + structureResource); + } else { + themeSets = structure.getAsJsonArray("themeSets"); + } + for (JsonElement element : themeSets) { + if (element.isJsonObject() + && element.getAsJsonObject().has("key") + && themeKey.equals(element.getAsJsonObject().get("key").getAsString())) { + throw new IOException("Jigsaw structure already declares theme '" + themeKey + "'"); + } + } + JsonObject theme = new JsonObject(); + theme.addProperty("key", themeKey); + theme.addProperty("weight", 1); + themeSets.add(theme); + return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static StructureWriteResult createVariantFromPiece( + Path packRoot, + String structureKey, + String sourcePieceKey, + String targetPieceKey, + VariantObjectMode objectMode + ) throws IOException { + String normalizedSource = JigsawStudioProjectCreator.Options.requireResourceKey(sourcePieceKey); + String normalizedTarget = JigsawStudioProjectCreator.Options.requireResourceKey(targetPieceKey); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String sourcePieceResource = "jigsaw-pieces/" + normalizedSource + ".json"; + String targetPieceResource = "jigsaw-pieces/" + normalizedTarget + ".json"; + String targetObjectResource = "objects/" + normalizedTarget + ".iob"; + if (!graph.manifest().resourceHashes().containsKey(sourcePieceResource)) { + throw new IOException("Source piece '" + normalizedSource + "' is not owned by this project"); + } + requireAvailableVariantTarget(graph, targetPieceResource); + requireAvailableVariantTarget(graph, targetObjectResource); + + Path sourcePiecePath = resolveOwnedResource(graph.root(), sourcePieceResource); + byte[] sourcePieceContent = Files.readAllBytes(sourcePiecePath); + IrisJigsawPiece sourcePiece = readPiece(sourcePiecePath, sourcePieceResource); + if (sourcePiece.getObject() == null || sourcePiece.getObject().isBlank()) { + throw new IOException("Source piece '" + normalizedSource + "' does not declare an object"); + } + String sourceObjectKey = JigsawStudioProjectCreator.Options.requireResourceKey(sourcePiece.getObject()); + String sourceObjectResource = "objects/" + sourceObjectKey + ".iob"; + if (!graph.manifest().resourceHashes().containsKey(sourceObjectResource)) { + throw new IOException("Source object '" + sourceObjectKey + "' is not owned by this project"); + } + Path sourceObjectPath = resolveOwnedResource(graph.root(), sourceObjectResource); + byte[] targetObjectContent; + if (objectMode == VariantObjectMode.COPY_SOURCE) { + targetObjectContent = Files.readAllBytes(sourceObjectPath); + } else { + IrisBlockVector sourceSize = IrisObject.sampleSize(sourceObjectPath.toFile()); + targetObjectContent = JigsawStudioProjectCreator.serialize(new IrisObject( + sourceSize.getBlockX(), + sourceSize.getBlockY(), + sourceSize.getBlockZ())); + } + + Map targetPieceKeysBySource = Map.of(normalizedSource, normalizedTarget); + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + int duplicatedMemberships = 0; + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + byte[] content = Files.readAllBytes(resolveOwnedResource(graph.root(), relativePath)); + if (relativePath.startsWith("jigsaw-pools/") && relativePath.endsWith(".json")) { + PoolMembershipDuplication duplication = duplicatePoolMemberships( + content, + targetPieceKeysBySource, + relativePath); + content = duplication.content(); + duplicatedMemberships += duplication.duplicatedEntries(); + } + bundle.resource(relativePath, content); + } + if (duplicatedMemberships == 0) { + throw new IOException("Source piece '" + normalizedSource + + "' has no owned pool membership to copy; select an owned pool explicitly"); + } + bundle.resource( + targetPieceResource, + duplicatePieceForVariant(sourcePieceContent, normalizedTarget, sourcePieceResource)); + bundle.resource(targetObjectResource, targetObjectContent); + return write(graph, bundle.build()); + } + + private static void requireAvailableVariantTarget( + OwnedGraph graph, + String relativePath + ) throws IOException { + StructureResourceBundle.validateRelativePath(relativePath); + Path target = graph.root().resolve(relativePath).normalize(); + if (!target.startsWith(graph.root())) { + throw new IOException("Variant target escapes the pack root: " + relativePath); + } + if (graph.manifest().resourceHashes().containsKey(relativePath) + || Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Variant target already exists: " + relativePath); + } + } + + private static void requireAvailableThemeSetTarget( + OwnedGraph graph, + String relativePath + ) throws IOException { + StructureResourceBundle.validateRelativePath(relativePath); + Path target = graph.root().resolve(relativePath).normalize(); + if (!target.startsWith(graph.root())) { + throw new IOException("Theme-set target escapes the pack root: " + relativePath); + } + if (graph.manifest().resourceHashes().containsKey(relativePath) + || Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Theme-set target already exists: " + relativePath); + } + } + + private static byte[] duplicatePieceForTheme( + byte[] content, + String targetPieceKey, + String themeKey, + String sourcePieceResource + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw piece is not a JSON object: " + sourcePieceResource); + } + JsonObject piece = parsed.getAsJsonObject().deepCopy(); + piece.addProperty("object", targetPieceKey); + JsonArray themes = new JsonArray(); + themes.add(themeKey); + piece.add("themes", themes); + return (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] duplicatePieceForVariant( + byte[] content, + String targetPieceKey, + String sourcePieceResource + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw piece is not a JSON object: " + sourcePieceResource); + } + JsonObject piece = parsed.getAsJsonObject().deepCopy(); + piece.addProperty("object", targetPieceKey); + return (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static PoolMembershipDuplication duplicatePoolMemberships( + byte[] content, + Map targetPieceKeysBySource, + String poolResource + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw pool is not a JSON object: " + poolResource); + } + JsonObject pool = parsed.getAsJsonObject(); + JsonArray pieces = pool.getAsJsonArray("pieces"); + if (pieces == null) { + throw new IOException("Jigsaw pool does not declare a pieces array: " + poolResource); + } + JsonArray expanded = new JsonArray(); + int duplicatedEntries = 0; + for (JsonElement element : pieces) { + expanded.add(element.deepCopy()); + if (!element.isJsonObject() || !element.getAsJsonObject().has("piece")) { + continue; + } + String sourcePieceKey = element.getAsJsonObject().get("piece").getAsString(); + String targetPieceKey = targetPieceKeysBySource.get(sourcePieceKey); + if (targetPieceKey == null) { + continue; + } + JsonObject duplicate = element.getAsJsonObject().deepCopy(); + duplicate.addProperty("piece", targetPieceKey); + expanded.add(duplicate); + duplicatedEntries++; + } + if (duplicatedEntries == 0) { + return new PoolMembershipDuplication(content, 0); + } + pool.add("pieces", expanded); + return new PoolMembershipDuplication( + (GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8), + duplicatedEntries); + } + + private static StructureWriteResult updateOwnedPiece( + Path packRoot, + String structureKey, + String pieceKey, + PieceContentEditor editor + ) throws IOException { + String normalizedPiece = JigsawStudioProjectCreator.Options.requireResourceKey(pieceKey); + OwnedGraph graph = loadOwnedGraph(packRoot, structureKey); + String pieceResource = "jigsaw-pieces/" + normalizedPiece + ".json"; + if (!graph.manifest().resourceHashes().containsKey(pieceResource)) { + throw new IOException("Piece '" + normalizedPiece + "' is not owned by this jigsaw project"); + } + StructureResourceBundle.Builder bundle = graph.bundleBuilder(); + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + byte[] content = Files.readAllBytes(resolveOwnedResource(graph.root(), relativePath)); + if (relativePath.equals(pieceResource)) { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw piece is not a JSON object: " + pieceResource); + } + JsonObject piece = parsed.getAsJsonObject(); + editor.edit(piece, pieceResource); + content = (GSON.toJson(piece) + "\n").getBytes(StandardCharsets.UTF_8); + } + bundle.resource(relativePath, content); + } + return write(graph, bundle.build()); + } + + private static List normalizeThemes(List themes) { + Objects.requireNonNull(themes, "Jigsaw Studio piece themes"); + Set normalized = new LinkedHashSet<>(); + for (String theme : themes) { + Objects.requireNonNull(theme, "Jigsaw Studio piece theme"); + String key = theme.trim(); + if (key.isEmpty() || !key.equals(theme)) { + throw new IllegalArgumentException( + "Jigsaw Studio piece themes must be non-blank and whitespace-normalized"); + } + if (!normalized.add(key)) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio piece theme '" + key + "'"); + } + } + return List.copyOf(normalized); + } + + public static String normalizeDisplayName(String displayName) { + String normalized = displayName == null ? "" : displayName.trim(); + if (normalized.codePointCount(0, normalized.length()) > 64) { + throw new IllegalArgumentException("Jigsaw Studio display names cannot exceed 64 visible characters"); + } + for (int index = 0; index < normalized.length(); ) { + int codePoint = normalized.codePointAt(index); + if (Character.isISOControl(codePoint) || codePoint == '§') { + throw new IllegalArgumentException( + "Jigsaw Studio display names cannot contain control or formatting characters"); + } + index += Character.charCount(codePoint); + } + return normalized; + } + + private static boolean otherOwnedPieceReferencesObject( + OwnedGraph graph, + String targetPieceKey, + String objectKey + ) throws IOException { + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) { + continue; + } + String pieceKey = resourceKey(relativePath, "jigsaw-pieces/", ".json"); + if (pieceKey.equals(targetPieceKey)) { + continue; + } + IrisJigsawPiece piece = readPiece(resolveOwnedResource(graph.root(), relativePath), relativePath); + if (objectKey.equals(piece.getObject())) { + return true; + } + } + return false; + } + + private static void requireDeletableArchetype( + OwnedGraph graph, + String targetPieceKey, + IrisJigsawPiece targetPiece + ) throws IOException { + String structureResource = "structures/" + graph.manifest().structure().path() + ".json"; + if (!graph.manifest().resourceHashes().containsKey(structureResource)) { + throw new IOException("The owned graph manifest does not include " + structureResource); + } + IrisStructure structure = readStructure( + resolveOwnedResource(graph.root(), structureResource), + structureResource); + if (structure.resolvedMode() == IrisJigsawMode.SPATIAL_JIGSAW) { + if (countOtherOwnedPieces(graph, targetPieceKey, null) == 0) { + throw new IOException("Cannot delete the final spatial jigsaw variant"); + } + return; + } + IrisJigsawWorkcellArchetype archetype = IrisJigsawWorkcellArchetype.fromPiece(targetPiece); + Map workcells; + try { + workcells = PlanarJigsawWorkcellResolver.resolve(structure); + } catch (IllegalArgumentException exception) { + throw new IOException("Planar workcell configuration is invalid: " + + exception.getMessage(), exception); + } + PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = workcells.get(archetype); + if (workcell != null && workcell.enabled() + && countOtherOwnedPieces(graph, targetPieceKey, archetype) == 0) { + throw new IOException("Cannot delete the final variant for enabled planar workcell " + + JigsawPlanarArchetype.fromModel(archetype).stableId()); + } + } + + private static int countOtherOwnedPieces( + OwnedGraph graph, + String targetPieceKey, + IrisJigsawWorkcellArchetype archetype + ) throws IOException { + int count = 0; + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) { + continue; + } + String pieceKey = resourceKey(relativePath, "jigsaw-pieces/", ".json"); + if (pieceKey.equals(targetPieceKey)) { + continue; + } + if (archetype == null) { + count++; + continue; + } + IrisJigsawPiece piece = readPiece(resolveOwnedResource(graph.root(), relativePath), relativePath); + if (IrisJigsawWorkcellArchetype.fromPiece(piece) == archetype) { + count++; + } + } + return count; + } + + private static PoolEntryRemoval removePoolEntries( + byte[] content, + String pieceKey, + String poolResource + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw pool is not a JSON object: " + poolResource); + } + JsonObject pool = parsed.getAsJsonObject(); + JsonArray pieces = pool.getAsJsonArray("pieces"); + if (pieces == null) { + throw new IOException("Jigsaw pool does not declare a pieces array: " + poolResource); + } + int removed = 0; + for (int index = pieces.size() - 1; index >= 0; index--) { + JsonElement entry = pieces.get(index); + if (entry.isJsonObject() + && entry.getAsJsonObject().has("piece") + && pieceKey.equals(entry.getAsJsonObject().get("piece").getAsString())) { + pieces.remove(index); + removed++; + } + } + if (removed == 0) { + return new PoolEntryRemoval(content, 0); + } + return new PoolEntryRemoval( + (GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8), + removed); + } + + private static String resourceKey(String relativePath, String prefix, String suffix) { + return relativePath.substring(prefix.length(), relativePath.length() - suffix.length()); + } + + private static String failureMessage(RuntimeException exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; + } + + private static Path resolveOwnedResource(Path root, String relativePath) throws IOException { + StructureResourceBundle.validateRelativePath(relativePath); + Path resource = root.resolve(relativePath).normalize(); + if (!resource.startsWith(root) || !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Owned graph resource is missing or unsafe: " + relativePath); + } + return resource; + } + + private static byte[] addPoolEntry( + byte[] content, + String pieceKey, + int weight, + Path poolPath + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw pool is not a JSON object: " + poolPath); + } + JsonObject pool = parsed.getAsJsonObject(); + if (!pool.has("pieces") || !pool.get("pieces").isJsonArray()) { + throw new IOException("Jigsaw pool does not declare a pieces array: " + poolPath); + } + JsonArray pieces = pool.getAsJsonArray("pieces"); + for (JsonElement element : pieces) { + if (element.isJsonObject() + && element.getAsJsonObject().has("piece") + && pieceKey.equals(element.getAsJsonObject().get("piece").getAsString())) { + throw new IOException("Pool already references piece '" + pieceKey + "'"); + } + } + JsonObject entry = new JsonObject(); + entry.addProperty("piece", pieceKey); + entry.addProperty("weight", weight); + pieces.add(entry); + return (GSON.toJson(pool) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static IrisJigsawPiece readPiece(Path path, String resource) throws IOException { + try { + IrisJigsawPiece piece = GSON.fromJson(Files.readString(path, StandardCharsets.UTF_8), + IrisJigsawPiece.class); + if (piece == null) { + throw new IOException("Jigsaw piece is empty: " + resource); + } + return piece; + } catch (RuntimeException exception) { + throw new IOException("Jigsaw piece is not valid JSON: " + resource, exception); + } + } + + private static IrisStructure readStructure(Path path, String resource) throws IOException { + try { + IrisStructure structure = GSON.fromJson( + Files.readString(path, StandardCharsets.UTF_8), + IrisStructure.class); + if (structure == null) { + throw new IOException("Jigsaw structure is empty: " + resource); + } + return structure; + } catch (RuntimeException exception) { + throw new IOException("Jigsaw structure is not valid JSON: " + resource, exception); + } + } + + private static void requireExclusiveObjectReference( + OwnedGraph graph, + String activePieceKey, + String objectKey + ) throws IOException { + for (String relativePath : graph.manifest().resourceHashes().keySet()) { + if (!relativePath.startsWith("jigsaw-pieces/") || !relativePath.endsWith(".json")) { + continue; + } + String pieceKey = relativePath.substring( + "jigsaw-pieces/".length(), + relativePath.length() - ".json".length()); + if (pieceKey.equals(activePieceKey)) { + continue; + } + IrisJigsawPiece piece = readPiece(resolveOwnedResource(graph.root(), relativePath), relativePath); + if (objectKey.equals(piece.getObject())) { + throw new IOException("Object '" + objectKey + "' is shared by piece '" + pieceKey + + "'; duplicate the active variant before resizing its object bounds"); + } + } + } + + static PlanarPieceObjectResize resizePlanarPieceObject( + IrisObject source, + IrisJigsawPiece piece, + JigsawPlanarArchetype archetype, + JigsawStudioCellDimensions dimensions, + String pieceKey + ) throws IOException { + IrisObject sourceObject = Objects.requireNonNull(source, "Planar Jigsaw Studio source object"); + IrisJigsawPiece targetPiece = Objects.requireNonNull(piece, "Planar Jigsaw Studio piece"); + JigsawPlanarArchetype targetArchetype = Objects.requireNonNull( + archetype, + "Planar Jigsaw Studio archetype"); + JigsawStudioCellDimensions targetDimensions = Objects.requireNonNull( + dimensions, + "Planar Jigsaw Studio target dimensions"); + String targetPieceKey = pieceKey == null || pieceKey.isBlank() ? "unknown" : pieceKey; + if (targetDimensions.width() < 3 || targetDimensions.depth() < 3) { + throw new IllegalArgumentException( + "Planar Jigsaw Studio workcell width and depth must each be at least 3 blocks"); + } + if (sourceObject.getW() < 1 || sourceObject.getH() < 1 || sourceObject.getD() < 1) { + throw new IOException("Planar piece '" + targetPieceKey + "' has invalid object dimensions"); + } + if (IrisJigsawWorkcellArchetype.fromPiece(targetPiece) != targetArchetype.modelArchetype()) { + throw new IOException("Planar piece '" + targetPieceKey + "' does not belong to " + + targetArchetype.stableId()); + } + + int quarterTurns = targetArchetype.modelArchetype().sourceToCanonicalQuarterTurns(targetPiece); + JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions( + sourceObject.getW(), + sourceObject.getH(), + sourceObject.getD()); + JigsawStudioCellDimensions canonicalDimensions = canonicalDimensions(sourceDimensions, quarterTurns); + Map canonicalBlocks = canonicalBlocks( + sourceObject, + quarterTurns, + targetPieceKey); + Map canonicalTiles = canonicalTiles( + sourceObject, + quarterTurns, + targetPieceKey); + List connectors = targetPiece.getConnectors(); + if (connectors == null) { + throw new IOException("Planar piece '" + targetPieceKey + "' has no connector list"); + } + List connectorResizes = planConnectorResizes( + connectors, + sourceDimensions, + canonicalDimensions, + targetDimensions, + quarterTurns, + targetPieceKey); + relocateConnectorPayloads(canonicalBlocks, canonicalTiles, connectorResizes, targetPieceKey); + requireContentInsideTarget(canonicalBlocks, canonicalTiles, targetDimensions, targetPieceKey); + + JigsawStudioCellDimensions resizedSourceDimensions = sourceDimensions(targetDimensions, quarterTurns); + IrisObject resizedObject = rebuildSourceObject( + canonicalBlocks, + canonicalTiles, + resizedSourceDimensions, + quarterTurns, + targetPieceKey); + int relocatedConnectors = 0; + for (ConnectorResize connectorResize : connectorResizes) { + LocalPosition sourcePosition = toSource( + connectorResize.targetCanonical(), + resizedSourceDimensions, + quarterTurns); + connectorResize.connector().setPosition(sourcePosition.toIrisPosition()); + if (!connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) { + relocatedConnectors++; + } + } + return new PlanarPieceObjectResize(resizedObject, relocatedConnectors); + } + + private static Map canonicalBlocks( + IrisObject source, + int quarterTurns, + String pieceKey + ) throws IOException { + Map blocks = new LinkedHashMap<>(); + for (Map.Entry entry : source.getBlocks()) { + LocalPosition sourcePosition = unsignedPosition(entry.getKey(), source); + requireInside(sourcePosition, source.getW(), source.getH(), source.getD(), + "stored block", pieceKey); + PlatformBlockState state = entry.getValue(); + if (state == null) { + throw new IOException("Planar piece '" + pieceKey + "' contains a null stored block state"); + } + LocalPosition canonicalPosition = toCanonical( + sourcePosition, + source.getW(), + source.getD(), + quarterTurns); + if (blocks.putIfAbsent(canonicalPosition, state) != null) { + throw new IOException("Planar piece '" + pieceKey + + "' maps more than one stored block to " + canonicalPosition.describe()); + } + } + return blocks; + } + + private static Map canonicalTiles( + IrisObject source, + int quarterTurns, + String pieceKey + ) throws IOException { + Map tiles = new LinkedHashMap<>(); + for (Map.Entry entry : source.getStates()) { + LocalPosition sourcePosition = unsignedPosition(entry.getKey(), source); + requireInside(sourcePosition, source.getW(), source.getH(), source.getD(), + "tile data", pieceKey); + TileData tile = entry.getValue(); + if (tile == null) { + throw new IOException("Planar piece '" + pieceKey + "' contains null tile data"); + } + LocalPosition canonicalPosition = toCanonical( + sourcePosition, + source.getW(), + source.getD(), + quarterTurns); + if (tiles.putIfAbsent(canonicalPosition, tile.clone()) != null) { + throw new IOException("Planar piece '" + pieceKey + + "' maps more than one tile payload to " + canonicalPosition.describe()); + } + } + return tiles; + } + + private static List planConnectorResizes( + List connectors, + JigsawStudioCellDimensions sourceDimensions, + JigsawStudioCellDimensions canonicalDimensions, + JigsawStudioCellDimensions targetDimensions, + int quarterTurns, + String pieceKey + ) throws IOException { + List planned = new ArrayList<>(connectors.size()); + Set sourcePositions = new LinkedHashSet<>(); + Set targetPositions = new LinkedHashSet<>(); + IrisPosition sourceSize = dimensionsPosition(sourceDimensions); + IrisPosition canonicalSize = dimensionsPosition(canonicalDimensions); + IrisPosition targetSize = dimensionsPosition(targetDimensions); + for (int index = 0; index < connectors.size(); index++) { + IrisJigsawConnector connector = connectors.get(index); + if (connector == null || connector.getPosition() == null || connector.getDirection() == null + || connector.getDirection().isVertical()) { + throw new IOException("Planar piece '" + pieceKey + "' contains invalid connector " + index); + } + LocalPosition sourcePosition = LocalPosition.from(connector.getPosition()); + requireInside( + sourcePosition, + sourceDimensions.width(), + sourceDimensions.height(), + sourceDimensions.depth(), + "connector " + index, + pieceKey); + if (!sourcePositions.add(sourcePosition)) { + throw new IOException("Planar piece '" + pieceKey + + "' has multiple connectors at " + sourcePosition.describe()); + } + LocalPosition sourceCanonical = toCanonical( + sourcePosition, + sourceDimensions.width(), + sourceDimensions.depth(), + quarterTurns); + IrisDirection canonicalDirection = rotateHorizontalDirection( + connector.getDirection(), + quarterTurns); + LocalPosition expectedSource = LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition( + sourceSize, + connector.getDirection())); + LocalPosition expectedCanonical = LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition( + canonicalSize, + canonicalDirection)); + boolean canonicalConnector = sourcePosition.equals(expectedSource) + || sourceCanonical.equals(expectedCanonical); + LocalPosition targetCanonical = canonicalConnector + ? LocalPosition.from(IrisJigsawConnector.canonicalPlanarPosition( + targetSize, + canonicalDirection)) + : sourceCanonical; + if (!inside(targetCanonical, targetDimensions)) { + throw new IOException("Planar piece '" + pieceKey + "' connector " + index + " at " + + sourceCanonical.describe() + " would be cropped by the requested workcell bounds"); + } + if (!targetPositions.add(targetCanonical)) { + throw new IOException("Planar piece '" + pieceKey + + "' would place multiple connectors at " + targetCanonical.describe()); + } + planned.add(new ConnectorResize(connector, sourceCanonical, targetCanonical)); + } + return List.copyOf(planned); + } + + private static void relocateConnectorPayloads( + Map blocks, + Map tiles, + List connectorResizes, + String pieceKey + ) throws IOException { + Set relocatedSources = new LinkedHashSet<>(); + for (ConnectorResize connectorResize : connectorResizes) { + if (tiles.containsKey(connectorResize.sourceCanonical()) + || tiles.containsKey(connectorResize.targetCanonical())) { + throw new IOException("Planar piece '" + pieceKey + "' has tile data at connector position " + + connectorResize.sourceCanonical().describe() + + "; connector tile data cannot be resized safely"); + } + if (!connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) { + relocatedSources.add(connectorResize.sourceCanonical()); + } + } + for (ConnectorResize connectorResize : connectorResizes) { + if (connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) { + continue; + } + if (blocks.containsKey(connectorResize.targetCanonical()) + && !relocatedSources.contains(connectorResize.targetCanonical())) { + throw new IOException("Planar piece '" + pieceKey + "' cannot relocate connector from " + + connectorResize.sourceCanonical().describe() + " to " + + connectorResize.targetCanonical().describe() + + " because the destination contains a stored block"); + } + } + List payloads = new ArrayList<>(relocatedSources.size()); + for (ConnectorResize connectorResize : connectorResizes) { + if (connectorResize.sourceCanonical().equals(connectorResize.targetCanonical())) { + continue; + } + boolean present = blocks.containsKey(connectorResize.sourceCanonical()); + PlatformBlockState state = blocks.remove(connectorResize.sourceCanonical()); + payloads.add(new BlockRelocation(connectorResize.targetCanonical(), state, present)); + } + for (BlockRelocation payload : payloads) { + if (!payload.present()) { + continue; + } + if (blocks.putIfAbsent(payload.target(), payload.state()) != null) { + throw new IOException("Planar piece '" + pieceKey + + "' has colliding connector block payloads at " + payload.target().describe()); + } + } + } + + private static void requireContentInsideTarget( + Map blocks, + Map tiles, + JigsawStudioCellDimensions dimensions, + String pieceKey + ) throws IOException { + for (LocalPosition position : blocks.keySet()) { + if (!inside(position, dimensions)) { + throw new IOException("Planar piece '" + pieceKey + "' has a stored block, including explicit air, at " + + position.describe() + " that would be cropped by the requested workcell bounds"); + } + } + for (LocalPosition position : tiles.keySet()) { + if (!inside(position, dimensions)) { + throw new IOException("Planar piece '" + pieceKey + "' has tile data at " + + position.describe() + " that would be cropped by the requested workcell bounds"); + } + } + } + + private static IrisObject rebuildSourceObject( + Map canonicalBlocks, + Map canonicalTiles, + JigsawStudioCellDimensions sourceDimensions, + int quarterTurns, + String pieceKey + ) throws IOException { + IrisObject resized = new IrisObject( + sourceDimensions.width(), + sourceDimensions.height(), + sourceDimensions.depth()); + Set sourcePositions = new LinkedHashSet<>(); + for (Map.Entry entry : canonicalBlocks.entrySet()) { + LocalPosition sourcePosition = toSource(entry.getKey(), sourceDimensions, quarterTurns); + if (!sourcePositions.add(sourcePosition)) { + throw new IOException("Planar piece '" + pieceKey + + "' maps multiple stored blocks to " + sourcePosition.describe()); + } + resized.setUnsigned( + sourcePosition.x(), + sourcePosition.y(), + sourcePosition.z(), + entry.getValue()); + } + sourcePositions.clear(); + for (Map.Entry entry : canonicalTiles.entrySet()) { + LocalPosition sourcePosition = toSource(entry.getKey(), sourceDimensions, quarterTurns); + if (!sourcePositions.add(sourcePosition)) { + throw new IOException("Planar piece '" + pieceKey + + "' maps multiple tile payloads to " + sourcePosition.describe()); + } + resized.setUnsignedTile( + sourcePosition.x(), + sourcePosition.y(), + sourcePosition.z(), + entry.getValue().clone()); + } + return resized; + } + + private static LocalPosition unsignedPosition(IrisBlockVector signed, IrisObject object) { + return new LocalPosition( + signed.getBlockX() + object.getCenter().getBlockX(), + signed.getBlockY() + object.getCenter().getBlockY(), + signed.getBlockZ() + object.getCenter().getBlockZ()); + } + + private static JigsawStudioCellDimensions canonicalDimensions( + JigsawStudioCellDimensions source, + int quarterTurns + ) { + return Math.floorMod(quarterTurns, 2) == 0 + ? source + : new JigsawStudioCellDimensions(source.depth(), source.height(), source.width()); + } + + private static JigsawStudioCellDimensions sourceDimensions( + JigsawStudioCellDimensions canonical, + int quarterTurns + ) { + return Math.floorMod(quarterTurns, 2) == 0 + ? canonical + : new JigsawStudioCellDimensions(canonical.depth(), canonical.height(), canonical.width()); + } + + private static LocalPosition toCanonical( + LocalPosition source, + int sourceWidth, + int sourceDepth, + int quarterTurns + ) { + return switch (Math.floorMod(quarterTurns, 4)) { + case 0 -> source; + case 1 -> new LocalPosition(sourceDepth - 1 - source.z(), source.y(), source.x()); + case 2 -> new LocalPosition( + sourceWidth - 1 - source.x(), + source.y(), + sourceDepth - 1 - source.z()); + case 3 -> new LocalPosition(source.z(), source.y(), sourceWidth - 1 - source.x()); + default -> throw new IllegalStateException("Unreachable planar object rotation"); + }; + } + + private static LocalPosition toSource( + LocalPosition canonical, + JigsawStudioCellDimensions sourceDimensions, + int quarterTurns + ) { + return switch (Math.floorMod(quarterTurns, 4)) { + case 0 -> canonical; + case 1 -> new LocalPosition( + canonical.z(), + canonical.y(), + sourceDimensions.depth() - 1 - canonical.x()); + case 2 -> new LocalPosition( + sourceDimensions.width() - 1 - canonical.x(), + canonical.y(), + sourceDimensions.depth() - 1 - canonical.z()); + case 3 -> new LocalPosition( + sourceDimensions.width() - 1 - canonical.z(), + canonical.y(), + canonical.x()); + default -> throw new IllegalStateException("Unreachable planar object rotation"); + }; + } + + private static IrisDirection rotateHorizontalDirection(IrisDirection direction, int quarterTurns) { + JigsawPlanarDirection planarDirection = switch (direction) { + case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH; + case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST; + case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH; + case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST; + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new IllegalArgumentException( + "Planar connector direction must be horizontal"); + }; + return planarDirection.rotateClockwise(quarterTurns).irisDirection(); + } + + private static IrisPosition dimensionsPosition(JigsawStudioCellDimensions dimensions) { + return new IrisPosition(dimensions.width(), dimensions.height(), dimensions.depth()); + } + + private static boolean inside(LocalPosition position, JigsawStudioCellDimensions dimensions) { + return inside(position, dimensions.width(), dimensions.height(), dimensions.depth()); + } + + private static boolean inside(LocalPosition position, int width, int height, int depth) { + return position.x() >= 0 && position.x() < width + && position.y() >= 0 && position.y() < height + && position.z() >= 0 && position.z() < depth; + } + + private static void requireInside( + LocalPosition position, + int width, + int height, + int depth, + String content, + String pieceKey + ) throws IOException { + if (!inside(position, width, height, depth)) { + throw new IOException("Planar piece '" + pieceKey + "' has " + content + " at " + + position.describe() + " outside its object bounds"); + } + } + + static IrisObject resizeObject( + IrisObject source, + JigsawStudioCellDimensions dimensions, + String pieceKey + ) throws IOException { + IrisObject object = Objects.requireNonNull(source, "Jigsaw Studio source object"); + JigsawStudioCellDimensions target = Objects.requireNonNull( + dimensions, + "Jigsaw Studio target object dimensions"); + String normalizedPiece = pieceKey == null || pieceKey.isBlank() ? "unknown" : pieceKey; + IrisObject resized = new IrisObject(target.width(), target.height(), target.depth()); + for (Map.Entry entry : object.getBlocks()) { + IrisBlockVector position = entry.getKey(); + LocalPosition unsigned = unsignedPosition(position, object); + if (!inside(unsigned, target)) { + throw new IOException("Spatial piece '" + normalizedPiece + + "' has a stored block, including explicit air, at " + unsigned.describe() + + " that would be cropped by the requested variant size"); + } + resized.setUnsigned( + unsigned.x(), + unsigned.y(), + unsigned.z(), + entry.getValue()); + } + for (Map.Entry entry : object.getStates()) { + IrisBlockVector position = entry.getKey(); + LocalPosition unsigned = unsignedPosition(position, object); + if (!inside(unsigned, target)) { + throw new IOException("Spatial piece '" + normalizedPiece + "' has tile data at " + + unsigned.describe() + " that would be cropped by the requested variant size"); + } + resized.setUnsignedTile( + unsigned.x(), + unsigned.y(), + unsigned.z(), + entry.getValue().clone()); + } + return resized; + } + + private static void requireConnectorsInside( + IrisJigsawPiece piece, + JigsawStudioCellDimensions dimensions, + String pieceKey + ) throws IOException { + if (piece.getConnectors() == null) { + throw new IOException("Spatial piece '" + pieceKey + "' has no connector list"); + } + for (int index = 0; index < piece.getConnectors().size(); index++) { + IrisJigsawConnector connector = piece.getConnectors().get(index); + if (connector == null || connector.getPosition() == null) { + throw new IOException("Spatial piece '" + pieceKey + "' contains invalid connector " + index); + } + LocalPosition position = LocalPosition.from(connector.getPosition()); + if (!inside(position, dimensions)) { + throw new IOException("Spatial piece '" + pieceKey + "' connector " + index + " at " + + position.describe() + " would be cropped by the requested variant size"); + } + } + } + + private static String describeDimensions(JigsawStudioCellDimensions dimensions) { + return dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth(); + } + + private static String normalizeChannel(String channel) { + String normalized = channel == null ? "" : channel.trim(); + if (normalized.equalsIgnoreCase("none")) { + return ""; + } + if (normalized.length() > 128) { + throw new IllegalArgumentException("Jigsaw connector channels cannot exceed 128 characters"); + } + for (int index = 0; index < normalized.length(); index++) { + if (Character.isWhitespace(normalized.charAt(index))) { + throw new IllegalArgumentException("Jigsaw connector channels cannot contain whitespace"); + } + } + return normalized; + } + + private static StructureWriteResult write(OwnedGraph graph, StructureResourceBundle bundle) throws IOException { + StructureResourceBundleGraphCompiler.requireViable(bundle); + return writeCompiled(graph, bundle); + } + + private static StructureWriteResult writeCompiled( + OwnedGraph graph, + StructureResourceBundle bundle + ) throws IOException { + StructureWriteResult result = graph.writer().write( + bundle, + StructureWriteOptions.overwriteExpected(graph.expectedManifestHash())); + if (!result.successful()) { + String conflict = result.conflicts().isEmpty() + ? result.status().name() + : result.conflicts().getFirst().relativePath() + ": " + + result.conflicts().getFirst().reason(); + throw new IOException("Atomic graph edit was rejected: " + conflict); + } + return result; + } + + public record VariantResizeResult( + StructureWriteResult writeResult, + JigsawStudioCellDimensions previousDimensions, + JigsawStudioCellDimensions dimensions, + int relocatedConnectors + ) { + public VariantResizeResult { + Objects.requireNonNull(writeResult, "Jigsaw Studio variant-resize write result"); + Objects.requireNonNull(previousDimensions, "Previous Jigsaw Studio variant dimensions"); + Objects.requireNonNull(dimensions, "Jigsaw Studio variant dimensions"); + if (relocatedConnectors < 0) { + throw new IllegalArgumentException("Jigsaw Studio relocated connector count cannot be negative"); + } + } + } + + public record WorkcellCapacityResult( + StructureWriteResult writeResult, + int checkedVariants + ) { + public WorkcellCapacityResult { + Objects.requireNonNull(writeResult, "Jigsaw Studio workcell-capacity write result"); + if (checkedVariants < 0) { + throw new IllegalArgumentException("Jigsaw Studio checked variant count cannot be negative"); + } + } + } + + public record PieceDeletionResult( + StructureWriteResult writeResult, + int removedPoolMemberships, + int changedPools, + int removedPieceResources, + int removedObjectResources + ) { + public PieceDeletionResult { + Objects.requireNonNull(writeResult, "Jigsaw Studio piece deletion write result"); + if (removedPoolMemberships < 0 || changedPools < 0 + || removedPieceResources < 0 || removedObjectResources < 0) { + throw new IllegalArgumentException("Jigsaw Studio piece deletion counts cannot be negative"); + } + } + } + + public record VariantFamilyCreation( + Map pieceKeysByWorkcell, + StructureWriteResult writeResult + ) { + public VariantFamilyCreation { + Objects.requireNonNull(pieceKeysByWorkcell, "Jigsaw Studio variant-family piece keys"); + pieceKeysByWorkcell = Collections.unmodifiableMap(new LinkedHashMap<>(pieceKeysByWorkcell)); + Objects.requireNonNull(writeResult, "Jigsaw Studio variant-family write result"); + } + } + + @FunctionalInterface + private interface PieceContentEditor { + void edit(JsonObject piece, String pieceResource) throws IOException; + } + + private record PoolEntryRemoval(byte[] content, int removedEntries) { + private PoolEntryRemoval { + Objects.requireNonNull(content, "Jigsaw Studio pool entry removal content"); + if (removedEntries < 0) { + throw new IllegalArgumentException("Removed jigsaw pool entry count cannot be negative"); + } + } + } + + private record PoolMembershipDuplication(byte[] content, int duplicatedEntries) { + private PoolMembershipDuplication { + Objects.requireNonNull(content, "Jigsaw Studio duplicated pool content"); + if (duplicatedEntries < 0) { + throw new IllegalArgumentException("Duplicated pool membership count cannot be negative"); + } + } + } + + private enum VariantObjectMode { + COPY_SOURCE, + EMPTY_SOURCE_SIZE + } + + record PlanarPieceObjectResize(IrisObject object, int relocatedConnectors) { + PlanarPieceObjectResize { + Objects.requireNonNull(object, "Resized planar Jigsaw Studio object"); + if (relocatedConnectors < 0) { + throw new IllegalArgumentException("Relocated connector count cannot be negative"); + } + } + } + + private record ConnectorResize( + IrisJigsawConnector connector, + LocalPosition sourceCanonical, + LocalPosition targetCanonical + ) { + private ConnectorResize { + Objects.requireNonNull(connector, "Planar Jigsaw Studio connector"); + Objects.requireNonNull(sourceCanonical, "Planar Jigsaw Studio source connector position"); + Objects.requireNonNull(targetCanonical, "Planar Jigsaw Studio target connector position"); + } + } + + private record BlockRelocation(LocalPosition target, PlatformBlockState state, boolean present) { + private BlockRelocation { + Objects.requireNonNull(target, "Planar Jigsaw Studio connector block target"); + if (present) { + Objects.requireNonNull(state, "Planar Jigsaw Studio connector block state"); + } + } + } + + private record LocalPosition(int x, int y, int z) { + private static LocalPosition from(IrisPosition position) { + return new LocalPosition(position.getX(), position.getY(), position.getZ()); + } + + private IrisPosition toIrisPosition() { + return new IrisPosition(x, y, z); + } + + private String describe() { + return x + "," + y + "," + z; + } + } + + private record OwnedGraph( + Path root, + StructureTransactionWriter writer, + StructureOwnershipManifest manifest, + String expectedManifestHash + ) { + private StructureResourceBundle.Builder bundleBuilder() { + return StructureResourceBundle.builder(manifest.structure()) + .source(manifest.source()) + .backend(manifest.backend()) + .capabilities(manifest.capabilities()) + .losses(manifest.losses()); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapper.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapper.java new file mode 100644 index 000000000..2239dc836 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapper.java @@ -0,0 +1,381 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +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.Optional; +import java.util.Set; +import java.util.TreeSet; + +public final class JigsawStudioGraphMapper { + private JigsawStudioGraphMapper() { + } + + public static JigsawStudioLayout map(IrisData data, IrisStructure structure) { + Objects.requireNonNull(data, "Jigsaw Studio graph mapping requires pack data"); + Objects.requireNonNull(structure, "Jigsaw Studio graph mapping requires a structure"); + JigsawStudioMode mode = structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW + ? JigsawStudioMode.PLANAR_JIGSAW + : JigsawStudioMode.SPATIAL_JIGSAW; + JigsawStudioVariantCatalog catalog = catalog(data, structure, mode); + IrisPosition configuredCell = structure.getCellSize(); + JigsawStudioCellDimensions dimensions = configuredCell == null + ? new JigsawStudioCellDimensions(16, 16, 16) + : new JigsawStudioCellDimensions( + Math.max(1, configuredCell.getX()), + Math.max(1, configuredCell.getY()), + Math.max(1, configuredCell.getZ())); + if (mode == JigsawStudioMode.SPATIAL_JIGSAW) { + dimensions = expandSpatialDimensions(data, catalog, dimensions); + return JigsawStudioLayout.createSpatial( + dimensions, + catalog, + structure.getSpatialWorkcellDisplayName()); + } + Map resolved = + PlanarJigsawWorkcellResolver.resolve(structure); + List workcells = new ArrayList<>(resolved.size()); + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = resolved.get(archetype.modelArchetype()); + workcells.add(new JigsawStudioWorkcellSpec( + archetype, + workcell.displayName(), + new JigsawStudioCellDimensions( + workcell.width(), + workcell.height(), + workcell.depth()), + workcell.enabled())); + } + return JigsawStudioLayout.createPlanar(dimensions, workcells, catalog); + } + + public static JigsawStudioVariantCatalog catalog( + IrisData data, + IrisStructure structure, + JigsawStudioMode mode + ) { + IrisData source = Objects.requireNonNull(data, "Jigsaw Studio catalog pack data"); + IrisStructure root = Objects.requireNonNull(structure, "Jigsaw Studio catalog structure"); + JigsawStudioMode activeMode = Objects.requireNonNull(mode, "Jigsaw Studio catalog mode"); + OwnedResources owned = ownedResources(source, root); + Map variants = new LinkedHashMap<>(); + Set visitedPools = new LinkedHashSet<>(); + Set queuedPools = new HashSet<>(); + ArrayDeque pools = new ArrayDeque<>(); + enqueuePool(root.getStartPool(), pools, queuedPools); + traversePools(source, activeMode, owned, pools, queuedPools, visitedPools, variants); + + for (String poolKey : owned.poolKeys()) { + enqueuePool(poolKey, pools, queuedPools); + } + traversePools(source, activeMode, owned, pools, queuedPools, visitedPools, variants); + + for (String pieceKey : owned.pieceKeys()) { + addOwnedVariant(source, activeMode, owned, pieceKey, variants); + } + + List built = new ArrayList<>(variants.size()); + for (MutableVariant variant : variants.values()) { + built.add(variant.build(source)); + } + return new JigsawStudioVariantCatalog(built, owned.editable()); + } + + public static JigsawPlanarTopology topologyOf(IrisJigsawPiece piece) { + Objects.requireNonNull(piece, "Planar topology requires a jigsaw piece"); + int mask = 0; + if (piece.getConnectors() != null) { + for (IrisJigsawConnector connector : piece.getConnectors()) { + if (connector == null || connector.getDirection() == null) { + continue; + } + mask |= directionBit(connector.getDirection()); + } + } + return JigsawPlanarTopology.fromMask(mask); + } + + static JigsawStudioCellDimensions expandSpatialDimensions( + IrisData data, + JigsawStudioVariantCatalog catalog, + JigsawStudioCellDimensions configured + ) { + if (data.getObjectLoader() == null) { + return configured; + } + int width = configured.width(); + int height = configured.height(); + int depth = configured.depth(); + for (JigsawStudioVariant variant : catalog.spatialVariants()) { + IrisObject object = data.getObjectLoader().load(variant.objectKey()); + if (object == null) { + continue; + } + int objectWidth = object.getW(); + int objectDepth = object.getD(); + if (variant.rotatable()) { + int horizontalSpan = Math.max(objectWidth, objectDepth); + objectWidth = horizontalSpan; + objectDepth = horizontalSpan; + } + width = Math.max(width, objectWidth); + height = Math.max(height, object.getH()); + depth = Math.max(depth, objectDepth); + } + return new JigsawStudioCellDimensions(width, height, depth); + } + + private static void traversePools( + IrisData data, + JigsawStudioMode mode, + OwnedResources owned, + ArrayDeque pools, + Set queuedPools, + Set visitedPools, + Map variants + ) { + while (!pools.isEmpty()) { + String poolKey = pools.removeFirst(); + if (!visitedPools.add(poolKey)) { + continue; + } + IrisJigsawPool pool = data.getJigsawPoolLoader().load(poolKey); + if (pool == null) { + continue; + } + enqueuePool(pool.getFallback(), pools, queuedPools); + if (pool.getPieces() == null) { + continue; + } + for (int entryIndex = 0; entryIndex < pool.getPieces().size(); entryIndex++) { + IrisJigsawPieceEntry entry = pool.getPieces().get(entryIndex); + if (entry == null || entry.isEmpty() || entry.getPiece() == null || entry.getPiece().isBlank()) { + continue; + } + String pieceKey = entry.getPiece(); + IrisJigsawPiece piece = data.getJigsawPieceLoader().load(pieceKey); + if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) { + continue; + } + MutableVariant variant = variants.computeIfAbsent( + pieceKey, + key -> new MutableVariant( + key, + piece, + mode, + owned.owns(key, piece.getObject()))); + variant.addMembership(new JigsawStudioPoolMembership( + poolKey, + entryIndex, + entry.getWeight(), + entry.getChance())); + enqueueConnectorPools(piece, pools, queuedPools); + } + } + } + + private static void addOwnedVariant( + IrisData data, + JigsawStudioMode mode, + OwnedResources owned, + String pieceKey, + Map variants + ) { + if (variants.containsKey(pieceKey)) { + return; + } + IrisJigsawPiece piece = data.getJigsawPieceLoader().load(pieceKey); + if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) { + return; + } + variants.put(pieceKey, new MutableVariant(pieceKey, piece, mode, owned.owns(pieceKey, piece.getObject()))); + } + + private static OwnedResources ownedResources(IrisData data, IrisStructure structure) { + String structureKey = structure.getLoadKey(); + File dataFolder = data.getDataFolder(); + if (structureKey == null || structureKey.isBlank() || dataFolder == null) { + return OwnedResources.empty(); + } + try { + Path root = dataFolder.toPath().toAbsolutePath().normalize(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + Path manifestPath = writer.ownershipManifestPath(StructureKey.parse(structureKey, "iris")); + if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + return OwnedResources.empty(); + } + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath)); + Set pieceKeys = new TreeSet<>(); + Set poolKeys = new TreeSet<>(); + Set objectKeys = new TreeSet<>(); + for (String path : manifest.resourceHashes().keySet()) { + addOwnedKey(path, "jigsaw-pieces/", ".json", pieceKeys); + addOwnedKey(path, "jigsaw-pools/", ".json", poolKeys); + addOwnedKey(path, "objects/", ".iob", objectKeys); + } + boolean editable = JigsawStudioAuthoringAccess.isEditable(manifest); + return new OwnedResources(pieceKeys, poolKeys, objectKeys, editable); + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to read Jigsaw Studio ownership for structure '" + structureKey + "'", + exception); + } + } + + private static void addOwnedKey(String path, String prefix, String suffix, Set keys) { + if (path.startsWith(prefix) && path.endsWith(suffix) && path.length() > prefix.length() + suffix.length()) { + keys.add(path.substring(prefix.length(), path.length() - suffix.length())); + } + } + + private static int directionBit(IrisDirection direction) { + return switch (direction) { + case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH.bit(); + case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST.bit(); + case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH.bit(); + case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST.bit(); + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> 0; + }; + } + + private static void enqueueConnectorPools( + IrisJigsawPiece piece, + ArrayDeque pools, + Set queuedPools + ) { + if (piece.getConnectors() == null) { + return; + } + for (IrisJigsawConnector connector : piece.getConnectors()) { + if (connector != null) { + enqueuePool(connector.getPool(), pools, queuedPools); + } + } + } + + private static void enqueuePool(String poolKey, ArrayDeque pools, Set queuedPools) { + if (poolKey == null || poolKey.isBlank() || !queuedPools.add(poolKey)) { + return; + } + pools.addLast(poolKey); + } + + private static final class MutableVariant { + private final String pieceKey; + private final IrisJigsawPiece piece; + private final JigsawStudioMode mode; + private final boolean owned; + private final List memberships = new ArrayList<>(); + + private MutableVariant( + String pieceKey, + IrisJigsawPiece piece, + JigsawStudioMode mode, + boolean owned + ) { + this.pieceKey = pieceKey; + this.piece = piece; + this.mode = mode; + this.owned = owned; + } + + private void addMembership(JigsawStudioPoolMembership membership) { + memberships.add(membership); + } + + private JigsawStudioVariant build(IrisData data) { + Optional dimensions = objectDimensions(data, piece, mode); + return new JigsawStudioVariant( + pieceKey, + piece.getObject(), + piece.getDisplayName(), + dimensions, + mode, + mode == JigsawStudioMode.PLANAR_JIGSAW + ? Optional.of(topologyOf(piece)) + : Optional.empty(), + piece.isRotatable(), + owned, + piece.getThemes() == null ? List.of() : piece.getThemes(), + JigsawStudioPieceRules.from(piece.resolvedRules()), + memberships); + } + + private static Optional objectDimensions( + IrisData data, + IrisJigsawPiece piece, + JigsawStudioMode mode + ) { + if (data.getObjectLoader() == null) { + return Optional.empty(); + } + IrisObject object = data.getObjectLoader().load(piece.getObject()); + if (object == null) { + return Optional.empty(); + } + JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions( + object.getW(), + object.getH(), + object.getD()); + if (mode == JigsawStudioMode.SPATIAL_JIGSAW) { + return Optional.of(sourceDimensions); + } + JigsawPlanarTopology topology = topologyOf(piece); + int quarterTurns = JigsawPlanarArchetype.fromTopology(topology) + .sourceToCanonicalQuarterTurns(topology); + return Math.floorMod(quarterTurns, 2) == 0 + ? Optional.of(sourceDimensions) + : Optional.of(new JigsawStudioCellDimensions( + sourceDimensions.depth(), + sourceDimensions.height(), + sourceDimensions.width())); + } + } + + private record OwnedResources( + Set pieceKeys, + Set poolKeys, + Set objectKeys, + boolean editable + ) { + private OwnedResources { + pieceKeys = Collections.unmodifiableSet(new LinkedHashSet<>(pieceKeys)); + poolKeys = Collections.unmodifiableSet(new LinkedHashSet<>(poolKeys)); + objectKeys = Collections.unmodifiableSet(new LinkedHashSet<>(objectKeys)); + } + + private boolean owns(String pieceKey, String objectKey) { + return editable && pieceKeys.contains(pieceKey) && objectKeys.contains(objectKey); + } + + private static OwnedResources empty() { + return new OwnedResources(Set.of(), Set.of(), Set.of(), false); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayout.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayout.java new file mode 100644 index 000000000..7dfc84a86 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayout.java @@ -0,0 +1,298 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class JigsawStudioLayout { + public static final int FLOOR_Y = 64; + public static final int PLANAR_COLUMNS = 3; + public static final int PLANAR_GAP = 2; + public static final int MAX_VARIANTS = 512; + public static final String SPATIAL_WORKCELL_ID = "workcell/spatial"; + + private static final int FIRST_ORIGIN = 16; + private static final JigsawStudioControlPosition CONTROL_POSITION = + new JigsawStudioControlPosition(8, FLOOR_Y + 1, 8); + + private final JigsawStudioMode mode; + private final JigsawStudioCellDimensions cellDimensions; + private final int columns; + private final int gap; + private final JigsawStudioVariantCatalog variantCatalog; + private final List bays; + private final Map byStableId; + + private JigsawStudioLayout( + JigsawStudioMode mode, + JigsawStudioCellDimensions cellDimensions, + int columns, + int gap, + JigsawStudioVariantCatalog variantCatalog, + List bays + ) { + this.mode = mode; + this.cellDimensions = cellDimensions; + this.columns = columns; + this.gap = gap; + this.variantCatalog = variantCatalog; + this.bays = Collections.unmodifiableList(new ArrayList<>(bays)); + Map index = new LinkedHashMap<>(); + for (JigsawStudioBay bay : bays) { + JigsawStudioBay previous = index.putIfAbsent(bay.stableId(), bay); + if (previous != null) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio workcell stable ID " + bay.stableId()); + } + } + this.byStableId = Collections.unmodifiableMap(index); + } + + public static JigsawStudioLayout create( + JigsawStudioMode mode, + JigsawStudioCellDimensions cellDimensions, + JigsawStudioVariantCatalog variantCatalog + ) { + JigsawStudioMode activeMode = Objects.requireNonNull(mode, "Jigsaw Studio layout mode"); + JigsawStudioCellDimensions dimensions = Objects.requireNonNull( + cellDimensions, + "Jigsaw Studio layout cell dimensions"); + JigsawStudioVariantCatalog catalog = Objects.requireNonNull( + variantCatalog, + "Jigsaw Studio variant catalog"); + validateCatalogMode(activeMode, catalog); + + if (activeMode == JigsawStudioMode.PLANAR_JIGSAW) { + return createPlanar(dimensions, uniformSpecs(dimensions), catalog); + } + + return createSpatial(dimensions, catalog, ""); + } + + public static JigsawStudioLayout createSpatial( + JigsawStudioCellDimensions cellDimensions, + JigsawStudioVariantCatalog variantCatalog, + String displayName + ) { + JigsawStudioCellDimensions dimensions = Objects.requireNonNull( + cellDimensions, + "Spatial Jigsaw Studio cell dimensions"); + JigsawStudioVariantCatalog catalog = Objects.requireNonNull( + variantCatalog, + "Spatial Jigsaw Studio variant catalog"); + validateCatalogMode(JigsawStudioMode.SPATIAL_JIGSAW, catalog); + String resolvedDisplayName = displayName == null ? "" : displayName.trim(); + + List workcells = new ArrayList<>(); + workcells.add(new JigsawStudioBay( + SPATIAL_WORKCELL_ID, + JigsawStudioBayKind.SPATIAL_WORKCELL, + Optional.empty(), + resolvedDisplayName, + new JigsawStudioBounds(FIRST_ORIGIN, FLOOR_Y + 1, FIRST_ORIGIN, dimensions))); + return new JigsawStudioLayout( + JigsawStudioMode.SPATIAL_JIGSAW, + dimensions, + 1, + PLANAR_GAP, + catalog, + workcells); + } + + public static JigsawStudioLayout createPlanar( + JigsawStudioCellDimensions defaultDimensions, + List workcellSpecs, + JigsawStudioVariantCatalog variantCatalog + ) { + JigsawStudioCellDimensions defaults = Objects.requireNonNull( + defaultDimensions, + "Jigsaw Studio default cell dimensions"); + JigsawStudioVariantCatalog catalog = Objects.requireNonNull( + variantCatalog, + "Jigsaw Studio variant catalog"); + validateCatalogMode(JigsawStudioMode.PLANAR_JIGSAW, catalog); + Map specs = indexSpecs(workcellSpecs); + int[] columnWidths = new int[PLANAR_COLUMNS]; + int[] rowDepths = new int[2]; + JigsawPlanarArchetype[] archetypes = JigsawPlanarArchetype.values(); + for (int index = 0; index < archetypes.length; index++) { + JigsawStudioCellDimensions dimensions = specs.get(archetypes[index]).dimensions(); + int column = index % PLANAR_COLUMNS; + int row = index / PLANAR_COLUMNS; + columnWidths[column] = Math.max(columnWidths[column], dimensions.width()); + rowDepths[row] = Math.max(rowDepths[row], dimensions.depth()); + } + List workcells = new ArrayList<>(archetypes.length); + for (int index = 0; index < archetypes.length; index++) { + JigsawPlanarArchetype archetype = archetypes[index]; + JigsawStudioWorkcellSpec spec = specs.get(archetype); + workcells.add(new JigsawStudioBay( + archetype.stableId(), + JigsawStudioBayKind.PLANAR_WORKCELL, + Optional.of(spec), + spec.displayName(), + planarBounds(index, spec.dimensions(), columnWidths, rowDepths))); + } + return new JigsawStudioLayout( + JigsawStudioMode.PLANAR_JIGSAW, + defaults, + PLANAR_COLUMNS, + PLANAR_GAP, + catalog, + workcells); + } + + public JigsawStudioMode mode() { + return mode; + } + + public JigsawStudioCellDimensions cellDimensions() { + return cellDimensions; + } + + public int columns() { + return columns; + } + + public int gap() { + return gap; + } + + public JigsawStudioVariantCatalog variantCatalog() { + return variantCatalog; + } + + public List bays() { + return bays; + } + + public JigsawStudioBay get(String stableId) { + return stableId == null ? null : byStableId.get(stableId); + } + + public JigsawStudioBay findAt(int worldX, int worldY, int worldZ) { + for (JigsawStudioBay bay : bays) { + if (bay.bounds().contains(worldX, worldY, worldZ)) { + return bay; + } + } + return null; + } + + public List variants(JigsawStudioBay workcell) { + JigsawStudioBay activeWorkcell = requireWorkcell(workcell); + if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) { + return variantCatalog.spatialVariants(); + } + return variantCatalog.variants(activeWorkcell.archetype().orElseThrow()); + } + + public Optional defaultVariant(JigsawStudioBay workcell) { + List variants = variants(workcell); + return variants.isEmpty() ? Optional.empty() : Optional.of(variants.getFirst()); + } + + public boolean accepts(JigsawStudioBay workcell, JigsawStudioVariant variant) { + JigsawStudioBay activeWorkcell = requireWorkcell(workcell); + JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio variant"); + if (variantCatalog.find(activeVariant.pieceKey()).filter(activeVariant::equals).isEmpty()) { + return false; + } + if (activeWorkcell.kind() == JigsawStudioBayKind.SPATIAL_WORKCELL) { + return activeVariant.mode() == JigsawStudioMode.SPATIAL_JIGSAW; + } + return activeVariant.archetype().filter(activeWorkcell.archetype().orElseThrow()::equals).isPresent(); + } + + public JigsawStudioControlPosition controlPosition() { + return CONTROL_POSITION; + } + + public int extentX() { + int maximum = CONTROL_POSITION.worldX(); + for (JigsawStudioBay bay : bays) { + maximum = Math.max(maximum, bay.bounds().maxX() + PLANAR_GAP); + } + return maximum; + } + + public int extentZ() { + int maximum = CONTROL_POSITION.worldZ(); + for (JigsawStudioBay bay : bays) { + maximum = Math.max(maximum, bay.bounds().maxZ() + PLANAR_GAP); + } + return maximum; + } + + private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) { + JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell"); + if (byStableId.get(activeWorkcell.stableId()) != activeWorkcell) { + throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout"); + } + return activeWorkcell; + } + + private static void validateCatalogMode(JigsawStudioMode mode, JigsawStudioVariantCatalog catalog) { + for (JigsawStudioVariant variant : catalog.variants()) { + if (variant.mode() != mode) { + throw new IllegalArgumentException("Jigsaw Studio variant mode does not match the layout mode"); + } + } + } + + private static List uniformSpecs(JigsawStudioCellDimensions dimensions) { + List specs = new ArrayList<>(JigsawPlanarArchetype.values().length); + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + specs.add(new JigsawStudioWorkcellSpec(archetype, "", dimensions, true)); + } + return specs; + } + + private static Map indexSpecs( + List workcellSpecs + ) { + List source = List.copyOf(Objects.requireNonNull( + workcellSpecs, + "Jigsaw Studio workcell specifications")); + Map specs = + new EnumMap<>(JigsawPlanarArchetype.class); + for (JigsawStudioWorkcellSpec spec : source) { + JigsawStudioWorkcellSpec active = Objects.requireNonNull( + spec, + "Jigsaw Studio workcell specification"); + if (specs.putIfAbsent(active.archetype(), active) != null) { + throw new IllegalArgumentException( + "Duplicate Jigsaw Studio workcell specification " + active.archetype()); + } + } + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + if (!specs.containsKey(archetype)) { + throw new IllegalArgumentException("Missing Jigsaw Studio workcell specification " + archetype); + } + } + return Collections.unmodifiableMap(specs); + } + + private static JigsawStudioBounds planarBounds( + int index, + JigsawStudioCellDimensions dimensions, + int[] columnWidths, + int[] rowDepths + ) { + int column = index % PLANAR_COLUMNS; + int row = index / PLANAR_COLUMNS; + int originX = FIRST_ORIGIN; + for (int current = 0; current < column; current++) { + originX = Math.addExact(originX, Math.addExact(columnWidths[current], PLANAR_GAP)); + } + int originZ = FIRST_ORIGIN; + for (int current = 0; current < row; current++) { + originZ = Math.addExact(originZ, Math.addExact(rowDepths[current], PLANAR_GAP)); + } + return new JigsawStudioBounds(originX, FLOOR_Y + 1, originZ, dimensions); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMarkerKeyCodec.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMarkerKeyCodec.java new file mode 100644 index 000000000..43fa4c18b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMarkerKeyCodec.java @@ -0,0 +1,35 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Pattern; + +public final class JigsawStudioMarkerKeyCodec { + private static final String STUDIO_NAMESPACE = "iris:"; + private static final Pattern INTERNAL_PATH = Pattern.compile("[a-z0-9._-]+(?:/[a-z0-9._-]+)*"); + + private JigsawStudioMarkerKeyCodec() { + } + + public static String encodePool(String internalPoolKey) { + return STUDIO_NAMESPACE + requireInternalPath(internalPoolKey, "pool"); + } + + public static String decodePool(String markerPoolKey) { + String markerKey = Objects.requireNonNull(markerPoolKey, "Jigsaw Studio marker pool key").trim(); + if (!markerKey.toLowerCase(Locale.ROOT).startsWith(STUDIO_NAMESPACE)) { + throw new IllegalArgumentException( + "Jigsaw Studio marker pools must use iris:, not '" + markerKey + "'"); + } + return requireInternalPath(markerKey.substring(STUDIO_NAMESPACE.length()), "pool"); + } + + public static String requireInternalPath(String value, String kind) { + String path = Objects.requireNonNull(value, "Jigsaw Studio " + kind + " key").trim(); + if (!INTERNAL_PATH.matcher(path).matches()) { + throw new IllegalArgumentException("Jigsaw Studio " + kind + + " keys must use lowercase [a-z0-9._-/] resource-path characters"); + } + return path; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMode.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMode.java new file mode 100644 index 000000000..e114f3ee6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioMode.java @@ -0,0 +1,6 @@ +package art.arcane.iris.core.runtime.jigsaw; + +public enum JigsawStudioMode { + PLANAR_JIGSAW, + SPATIAL_JIGSAW +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPieceRules.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPieceRules.java new file mode 100644 index 000000000..2238d8f0b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPieceRules.java @@ -0,0 +1,34 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.engine.object.IrisJigsawPieceRules; + +import java.util.Objects; + +public record JigsawStudioPieceRules( + int minimumDepth, + int maximumDepth, + int minimumPlacements, + int maximumPlacements, + boolean terminal +) { + public JigsawStudioPieceRules { + if (minimumDepth < 0 || maximumDepth < minimumDepth || maximumDepth > 30) { + throw new IllegalArgumentException("Jigsaw Studio piece depth rules are invalid"); + } + if (minimumPlacements < 0 || maximumPlacements < 0 + || minimumPlacements > 512 || maximumPlacements > 512 + || maximumPlacements != 0 && minimumPlacements > maximumPlacements) { + throw new IllegalArgumentException("Jigsaw Studio piece placement-count rules are invalid"); + } + } + + public static JigsawStudioPieceRules from(IrisJigsawPieceRules rules) { + IrisJigsawPieceRules source = Objects.requireNonNull(rules, "Jigsaw Studio piece rules"); + return new JigsawStudioPieceRules( + source.getMinimumDepth(), + source.getMaximumDepth(), + source.getMinimumPlacements(), + source.getMaximumPlacements(), + source.isTerminal()); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolEditor.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolEditor.java new file mode 100644 index 000000000..85937116e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolEditor.java @@ -0,0 +1,442 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteOptions; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; + +public final class JigsawStudioPoolEditor { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private JigsawStudioPoolEditor() { + } + + public static WeightUpdate updateWeight( + Path packRoot, + String structureKey, + String poolKey, + String pieceKey, + int weight + ) throws IOException { + if (weight < 1) { + throw new IllegalArgumentException("Jigsaw variant weight must be positive"); + } + PoolUpdate update = updateOwnedPool( + packRoot, + structureKey, + poolKey, + (content, poolPath) -> mutateWeight(content, pieceKey, weight, poolPath)); + return new WeightUpdate( + update.changed(), + update.changedEntries(), + update.poolPath(), + update.writeResult()); + } + + public static WeightUpdate updateWeightAtIndex( + Path packRoot, + String structureKey, + String poolKey, + int entryIndex, + String expectedPieceKey, + int weight + ) throws IOException { + if (weight < 1) { + throw new IllegalArgumentException("Jigsaw variant weight must be positive"); + } + if (entryIndex < 0) { + throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative"); + } + PoolUpdate update = updateOwnedPool( + packRoot, + structureKey, + poolKey, + (content, poolPath) -> mutateWeightAtIndex( + content, + entryIndex, + expectedPieceKey, + weight, + poolPath)); + return new WeightUpdate( + update.changed(), + update.changedEntries(), + update.poolPath(), + update.writeResult()); + } + + public static ChanceUpdate updateChanceAtIndex( + Path packRoot, + String structureKey, + String poolKey, + int entryIndex, + String expectedPieceKey, + double chance + ) throws IOException { + if (!Double.isFinite(chance) || chance < 0D || chance > 1D) { + throw new IllegalArgumentException("Jigsaw variant chance must be finite and within 0 and 1"); + } + if (entryIndex < 0) { + throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative"); + } + PoolUpdate update = updateOwnedPool( + packRoot, + structureKey, + poolKey, + (content, poolPath) -> mutateChanceAtIndex( + content, + entryIndex, + expectedPieceKey, + chance, + poolPath)); + return new ChanceUpdate( + update.changed(), + update.changedEntries(), + update.poolPath(), + update.writeResult()); + } + + public static PoolUpdate addPiece( + Path packRoot, + String structureKey, + String poolKey, + String pieceKey, + int weight + ) throws IOException { + if (weight < 1) { + throw new IllegalArgumentException("Jigsaw piece weight must be positive"); + } + return updateOwnedPool( + packRoot, + structureKey, + poolKey, + (content, poolPath) -> mutateAdd(content, pieceKey, weight, poolPath)); + } + + public static PoolUpdate removePiece( + Path packRoot, + String structureKey, + String poolKey, + String pieceKey + ) throws IOException { + return updateOwnedPool( + packRoot, + structureKey, + poolKey, + (content, poolPath) -> mutateRemove(content, pieceKey, poolPath)); + } + + public static PoolUpdate removeEntry( + Path packRoot, + String structureKey, + String poolKey, + int entryIndex, + String expectedPieceKey + ) throws IOException { + if (entryIndex < 0) { + throw new IllegalArgumentException("Jigsaw pool entry index cannot be negative"); + } + return updateOwnedPool( + packRoot, + structureKey, + poolKey, + (content, poolPath) -> mutateRemoveAtIndex( + content, + entryIndex, + expectedPieceKey, + poolPath)); + } + + public static PoolUpdate updateFallback( + Path packRoot, + String structureKey, + String poolKey, + String fallbackPoolKey + ) throws IOException { + String fallback = fallbackPoolKey == null ? "" : fallbackPoolKey.trim(); + return updateOwnedPool( + packRoot, + structureKey, + poolKey, + (content, poolPath) -> mutateFallback(content, fallback, poolPath)); + } + + private static PoolUpdate updateOwnedPool( + Path packRoot, + String structureKey, + String poolKey, + PoolContentEditor editor + ) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + StructureKey ownershipKey = StructureKey.parse(structureKey, "iris"); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + Path manifestPath = writer.ownershipManifestPath(ownershipKey); + if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("This graph is read-only because it is not Studio-owned; create a new Jigsaw Studio project before editing pools"); + } + byte[] manifestContent = Files.readAllBytes(manifestPath); + String expectedManifestHash = StructureHash.sha256(manifestContent); + StructureOwnershipManifest manifest = JigsawStudioAuthoringAccess.requireEditable( + StructureOwnershipManifest.fromJson(manifestContent)); + String normalizedPool = JigsawStudioProjectCreator.Options.requireResourceKey(poolKey); + String targetResource = "jigsaw-pools/" + normalizedPool + ".json"; + Path targetPath = root.resolve(targetResource).normalize(); + if (!manifest.resourceHashes().containsKey(targetResource)) { + return new PoolUpdate(false, 0, targetPath, null); + } + + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(manifest.structure()) + .source(manifest.source()) + .backend(manifest.backend()) + .capabilities(manifest.capabilities()) + .losses(manifest.losses()); + int changedEntries = 0; + for (Map.Entry resource : manifest.resourceHashes().entrySet()) { + Path resourcePath = resolveOwnedResource(root, resource.getKey()); + byte[] content = Files.readAllBytes(resourcePath); + if (resource.getKey().equals(targetResource)) { + PoolMutation mutation = editor.edit(content, resourcePath); + changedEntries = mutation.changedEntries(); + content = mutation.content(); + } + bundle.resource(resource.getKey(), content); + } + if (changedEntries == 0) { + return new PoolUpdate(false, 0, targetPath, null); + } + StructureResourceBundle updatedBundle = bundle.build(); + StructureResourceBundleGraphCompiler.requireViable(updatedBundle); + StructureWriteResult writeResult = writer.write( + updatedBundle, + StructureWriteOptions.overwriteExpected(expectedManifestHash)); + if (!writeResult.successful()) { + String conflict = writeResult.conflicts().isEmpty() + ? writeResult.status().name() + : writeResult.conflicts().getFirst().relativePath() + ": " + + writeResult.conflicts().getFirst().reason(); + throw new IOException("Atomic graph update was rejected: " + conflict); + } + return new PoolUpdate(true, changedEntries, targetPath, writeResult); + } + + private static Path resolveOwnedResource(Path root, String relativePath) throws IOException { + StructureResourceBundle.validateRelativePath(relativePath); + Path resource = root.resolve(relativePath).normalize(); + if (!resource.startsWith(root) || !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Owned graph resource is missing or unsafe: " + relativePath); + } + return resource; + } + + private static PoolMutation mutateWeight( + byte[] content, + String pieceKey, + int weight, + Path poolPath + ) throws IOException { + JsonObject root = parsePool(content, poolPath); + JsonArray pieces = root.getAsJsonArray("pieces"); + int changedEntries = 0; + for (JsonElement element : pieces) { + if (!isPiece(element, pieceKey)) { + continue; + } + element.getAsJsonObject().addProperty("weight", weight); + changedEntries++; + } + return mutation(root, changedEntries); + } + + private static PoolMutation mutateWeightAtIndex( + byte[] content, + int entryIndex, + String expectedPieceKey, + int weight, + Path poolPath + ) throws IOException { + JsonObject root = parsePool(content, poolPath); + JsonArray pieces = root.getAsJsonArray("pieces"); + JsonObject entry = requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath); + int currentWeight = entry.has("weight") ? entry.get("weight").getAsInt() : 1; + if (currentWeight == weight) { + return mutation(root, 0); + } + entry.addProperty("weight", weight); + return mutation(root, 1); + } + + private static PoolMutation mutateChanceAtIndex( + byte[] content, + int entryIndex, + String expectedPieceKey, + double chance, + Path poolPath + ) throws IOException { + JsonObject root = parsePool(content, poolPath); + JsonArray pieces = root.getAsJsonArray("pieces"); + JsonObject entry = requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath); + double currentChance = entry.has("chance") ? entry.get("chance").getAsDouble() : 1D; + if (Double.compare(currentChance, chance) == 0) { + return mutation(root, 0); + } + entry.addProperty("chance", chance); + return mutation(root, 1); + } + + private static PoolMutation mutateAdd( + byte[] content, + String pieceKey, + int weight, + Path poolPath + ) throws IOException { + JsonObject root = parsePool(content, poolPath); + JsonArray pieces = root.getAsJsonArray("pieces"); + for (JsonElement element : pieces) { + if (isPiece(element, pieceKey)) { + return mutation(root, 0); + } + } + JsonObject entry = new JsonObject(); + entry.addProperty("piece", pieceKey); + entry.addProperty("weight", weight); + pieces.add(entry); + return mutation(root, 1); + } + + private static PoolMutation mutateRemove( + byte[] content, + String pieceKey, + Path poolPath + ) throws IOException { + JsonObject root = parsePool(content, poolPath); + JsonArray pieces = root.getAsJsonArray("pieces"); + int changedEntries = 0; + for (int index = pieces.size() - 1; index >= 0; index--) { + if (isPiece(pieces.get(index), pieceKey)) { + pieces.remove(index); + changedEntries++; + } + } + return mutation(root, changedEntries); + } + + private static PoolMutation mutateRemoveAtIndex( + byte[] content, + int entryIndex, + String expectedPieceKey, + Path poolPath + ) throws IOException { + JsonObject root = parsePool(content, poolPath); + JsonArray pieces = root.getAsJsonArray("pieces"); + requirePieceEntry(pieces, entryIndex, expectedPieceKey, poolPath); + pieces.remove(entryIndex); + return mutation(root, 1); + } + + private static PoolMutation mutateFallback( + byte[] content, + String fallbackPoolKey, + Path poolPath + ) throws IOException { + JsonObject root = parsePool(content, poolPath); + String existing = root.has("fallback") && !root.get("fallback").isJsonNull() + ? root.get("fallback").getAsString().trim() + : ""; + if (existing.equals(fallbackPoolKey)) { + return mutation(root, 0); + } + root.addProperty("fallback", fallbackPoolKey); + return mutation(root, 1); + } + + private static JsonObject parsePool(byte[] content, Path poolPath) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw pool is not a JSON object: " + poolPath); + } + JsonObject root = parsed.getAsJsonObject(); + if (!root.has("pieces") || !root.get("pieces").isJsonArray()) { + throw new IOException("Jigsaw pool does not declare a pieces array: " + poolPath); + } + return root; + } + + private static boolean isPiece(JsonElement element, String pieceKey) { + return element.isJsonObject() + && element.getAsJsonObject().has("piece") + && pieceKey.equals(element.getAsJsonObject().get("piece").getAsString()); + } + + private static JsonObject requirePieceEntry( + JsonArray pieces, + int entryIndex, + String expectedPieceKey, + Path poolPath + ) throws IOException { + if (entryIndex >= pieces.size()) { + throw new IOException("Jigsaw pool entry " + entryIndex + " no longer exists in " + poolPath); + } + JsonElement element = pieces.get(entryIndex); + if (!isPiece(element, expectedPieceKey)) { + throw new IOException("Jigsaw pool entry " + entryIndex + " changed before the update in " + + poolPath); + } + return element.getAsJsonObject(); + } + + private static PoolMutation mutation(JsonObject root, int changedEntries) { + return new PoolMutation( + (GSON.toJson(root) + "\n").getBytes(StandardCharsets.UTF_8), + changedEntries); + } + + public record WeightUpdate( + boolean changed, + int changedEntries, + Path poolPath, + StructureWriteResult writeResult + ) { + } + + public record ChanceUpdate( + boolean changed, + int changedEntries, + Path poolPath, + StructureWriteResult writeResult + ) { + } + + public record PoolUpdate( + boolean changed, + int changedEntries, + Path poolPath, + StructureWriteResult writeResult + ) { + } + + @FunctionalInterface + private interface PoolContentEditor { + PoolMutation edit(byte[] content, Path poolPath) throws IOException; + } + + private record PoolMutation(byte[] content, int changedEntries) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolMembership.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolMembership.java new file mode 100644 index 000000000..8cbeed936 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPoolMembership.java @@ -0,0 +1,22 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.Objects; + +public record JigsawStudioPoolMembership(String poolKey, int entryIndex, int weight, double chance) { + public JigsawStudioPoolMembership { + Objects.requireNonNull(poolKey, "Jigsaw Studio pool key"); + poolKey = poolKey.trim(); + if (poolKey.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio pool key cannot be blank"); + } + if (entryIndex < 0) { + throw new IllegalArgumentException("Jigsaw Studio pool entry index cannot be negative"); + } + if (weight < 1) { + throw new IllegalArgumentException("Jigsaw Studio pool membership weight must be positive"); + } + if (!Double.isFinite(chance) || chance < 0D || chance > 1D) { + throw new IllegalArgumentException("Jigsaw Studio pool membership chance must be within 0 and 1"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreator.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreator.java new file mode 100644 index 000000000..598faddda --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreator.java @@ -0,0 +1,227 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; +import art.arcane.iris.engine.object.IrisJigsawCompatibility; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisJigsawWorkcell; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Objects; + +public final class JigsawStudioProjectCreator { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private JigsawStudioProjectCreator() { + } + + public static StructureWriteResult create(Path packRoot, Options options) throws IOException { + Options activeOptions = Objects.requireNonNull(options, "Jigsaw Studio project options"); + StructureResourceBundle bundle = bundle(activeOptions); + StructureResourceBundleGraphCompiler.requireViable(bundle); + return new StructureTransactionWriter(packRoot).write(bundle, StructureWriteMode.ADD_ONLY); + } + + static StructureResourceBundle bundle(Options options) throws IOException { + String resourceKey = options.structureKey(); + StructureKey ownershipKey = new StructureKey("iris", resourceKey); + IrisStructure structure = new IrisStructure() + .setStartPool(resourceKey + "/start") + .setMaxDepth(7) + .setMaxSizeChunks(8) + .setMode(toModelMode(options.mode())) + .setCompatibility(toModelCompatibility(options.compatibilityTarget())) + .setBranchFailurePolicy(toBranchFailurePolicy(options.compatibilityTarget())) + .setCellSize(new IrisPosition( + options.cellDimensions().width(), + options.cellDimensions().height(), + options.cellDimensions().depth())); + if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { + structure.getThemeSets().add(new IrisJigsawThemeSet("variant-1", 1)); + } + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(ownershipKey) + .source(StructureSource.of(StructureSource.Kind.IRIS, ownershipKey)) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capability(StructureCapability.BLOCKS) + .capability(StructureCapability.CONNECTORS) + .capability(StructureCapability.IRIS_PLACEMENT); + IrisJigsawPool pool = new IrisJigsawPool(); + if (options.mode() == JigsawStudioMode.PLANAR_JIGSAW) { + addPlanarDefaults(bundle, structure, pool, options); + } else { + addSpatialDefault(bundle, pool, options); + } + bundle.textResource("jigsaw-pools/" + resourceKey + "/start.json", GSON.toJson(pool) + "\n"); + bundle.textResource("structures/" + resourceKey + ".json", GSON.toJson(structure) + "\n"); + return bundle.build(); + } + + static byte[] serialize(IrisObject object) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + object.write(output); + return output.toByteArray(); + } + + private static IrisJigsawMode toModelMode(JigsawStudioMode mode) { + return mode == JigsawStudioMode.PLANAR_JIGSAW + ? IrisJigsawMode.PLANAR_JIGSAW + : IrisJigsawMode.SPATIAL_JIGSAW; + } + + private static IrisJigsawCompatibility toModelCompatibility( + JigsawStudioCompatibilityTarget compatibilityTarget + ) { + return compatibilityTarget == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + ? IrisJigsawCompatibility.VANILLA_PORTABLE + : IrisJigsawCompatibility.IRIS_EXTENDED; + } + + private static IrisJigsawBranchFailurePolicy toBranchFailurePolicy( + JigsawStudioCompatibilityTarget compatibilityTarget + ) { + return compatibilityTarget == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + ? IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH + : IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY; + } + + private static void addPlanarDefaults( + StructureResourceBundle.Builder bundle, + IrisStructure structure, + IrisJigsawPool pool, + Options options + ) throws IOException { + JigsawStudioCellDimensions dimensions = options.cellDimensions(); + IrisPosition size = new IrisPosition(dimensions.width(), dimensions.height(), dimensions.depth()); + String piecePoolKey = options.structureKey() + "/pieces"; + String capPoolKey = options.structureKey() + "/caps"; + IrisJigsawPool piecePool = new IrisJigsawPool().setFallback(capPoolKey); + IrisJigsawPool capPool = new IrisJigsawPool(); + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + String key = options.structureKey() + "/" + archetype.name().toLowerCase(Locale.ROOT); + IrisJigsawPiece piece = planarPiece(key, piecePoolKey, size, archetype); + if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { + piece.getThemes().add("variant-1"); + } + if (archetype == JigsawPlanarArchetype.CROSS) { + pool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); + } + if (archetype != JigsawPlanarArchetype.BLANK) { + piecePool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); + } + if (archetype == JigsawPlanarArchetype.END) { + if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { + piece.setRules(new IrisJigsawPieceRules().setTerminal(true)); + } + capPool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); + } + structure.getPlanarWorkcells().add(new IrisJigsawWorkcell( + "", + archetype.modelArchetype(), + dimensions.width(), + dimensions.height(), + dimensions.depth(), + true)); + bundle.resource("objects/" + key + ".iob", serialize(new IrisObject( + dimensions.width(), + dimensions.height(), + dimensions.depth()))); + bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n"); + } + capPool.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true)); + bundle.textResource("jigsaw-pools/" + piecePoolKey + ".json", GSON.toJson(piecePool) + "\n"); + bundle.textResource("jigsaw-pools/" + capPoolKey + ".json", GSON.toJson(capPool) + "\n"); + } + + private static void addSpatialDefault( + StructureResourceBundle.Builder bundle, + IrisJigsawPool pool, + Options options + ) throws IOException { + String key = options.structureKey() + "/start"; + JigsawStudioCellDimensions dimensions = options.cellDimensions(); + IrisJigsawPiece piece = new IrisJigsawPiece().setObject(key).setRotatable(true); + if (options.compatibilityTarget() == JigsawStudioCompatibilityTarget.IRIS_EXTENDED) { + piece.getThemes().add("variant-1"); + } + pool.getPieces().add(new IrisJigsawPieceEntry(key, 1)); + bundle.resource("objects/" + key + ".iob", serialize(new IrisObject( + dimensions.width(), + dimensions.height(), + dimensions.depth()))); + bundle.textResource("jigsaw-pieces/" + key + ".json", GSON.toJson(piece) + "\n"); + } + + private static IrisJigsawPiece planarPiece( + String objectKey, + String poolKey, + IrisPosition dimensions, + JigsawPlanarArchetype archetype + ) { + IrisJigsawPiece piece = new IrisJigsawPiece().setObject(objectKey).setRotatable(true); + for (JigsawPlanarDirection planarDirection : archetype.canonicalTopology().directions()) { + IrisDirection direction = planarDirection.irisDirection(); + piece.getConnectors().add(new IrisJigsawConnector() + .setPosition(IrisJigsawConnector.canonicalPlanarPosition(dimensions, direction)) + .setDirection(direction) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool(poolKey) + .setName("iris:planar") + .setTargetName("iris:planar") + .setJoint(JigsawJoint.ALIGNED) + .setFinalState("minecraft:structure_void")); + } + return piece; + } + + public record Options( + String structureKey, + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget compatibilityTarget, + JigsawStudioCellDimensions cellDimensions + ) { + public Options { + structureKey = requireResourceKey(structureKey); + mode = Objects.requireNonNull(mode, "Jigsaw Studio mode"); + compatibilityTarget = Objects.requireNonNull( + compatibilityTarget, + "Jigsaw Studio compatibility target"); + cellDimensions = Objects.requireNonNull(cellDimensions, "Jigsaw Studio cell dimensions"); + if (mode == JigsawStudioMode.PLANAR_JIGSAW + && (cellDimensions.width() < 3 || cellDimensions.depth() < 3)) { + throw new IllegalArgumentException( + "Planar Jigsaw Studio cells require width and depth of at least 3 blocks"); + } + } + + static String requireResourceKey(String value) { + String key = JigsawStudioMarkerKeyCodec.requireInternalPath(value, "resource"); + StructureResourceBundle.validateRelativePath("structures/" + key + ".json"); + new StructureKey("iris", key); + return key; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectDeletionService.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectDeletionService.java new file mode 100644 index 000000000..4c01e088f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectDeletionService.java @@ -0,0 +1,435 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; +import java.util.stream.Stream; + +public final class JigsawStudioProjectDeletionService { + private static final long MAX_JSON_BYTES = 16L * 1024L * 1024L; + private static final int MAX_SCANNED_FILES = 100_000; + + private JigsawStudioProjectDeletionService() { + } + + public static DeletionPlan inspect(Path packRoot, String structureKey) throws IOException { + Path root = canonicalRoot(packRoot); + StructureKey key = StructureKey.parse(structureKey, "iris"); + ManifestSnapshot snapshot = loadSnapshot(root, key); + List blockers = scanReverseReferences(root, snapshot); + return new DeletionPlan( + UUID.randomUUID(), + root, + key, + snapshot.manifest().source(), + snapshot.manifestHash(), + snapshot.sourceHash(), + snapshot.manifest().resourceHashes(), + blockers); + } + + public static ProjectDeletionResult delete(DeletionPlan plan) throws IOException { + DeletionPlan expected = Objects.requireNonNull(plan, "Jigsaw Studio project deletion plan"); + if (!expected.deletable()) { + throw new IOException("Jigsaw Studio project deletion is blocked by " + + expected.blockers().size() + " reverse references"); + } + Path root = canonicalRoot(expected.packRoot()); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureTransactionWriter.OwnedRemoval request = new StructureTransactionWriter.OwnedRemoval( + expected.structureKey(), + expected.expectedSource().kind(), + expected.expectedSource().key(), + Optional.empty(), + Optional.of(expected.expectedManifestHash())); + try (StructureTransactionWriter.PreparedRemoval removal = writer.prepareOwnedRemovals( + List.of(request), + () -> validateDeletion(root, expected))) { + if (!removal.changed()) { + throw new IOException("Jigsaw Studio project no longer exists"); + } + removal.markCommitted(); + removal.finishCommit(); + } + return new ProjectDeletionResult( + expected.planId(), + expected.structureKey(), + expected.expectedResourceHashes().size(), + true); + } + + private static void validateDeletion(Path root, DeletionPlan expected) throws IOException { + ManifestSnapshot current = loadSnapshot(root, expected.structureKey()); + if (!expected.expectedManifestHash().equals(current.manifestHash())) { + throw new IOException("Jigsaw Studio project changed after deletion was inspected"); + } + if (!expected.expectedSource().equals(current.manifest().source()) + || !expected.expectedSourceHash().equals(current.sourceHash())) { + throw new IOException("Jigsaw Studio project source changed after deletion was inspected"); + } + if (!expected.expectedResourceHashes().equals(current.manifest().resourceHashes())) { + throw new IOException("Jigsaw Studio project resources changed after deletion was inspected"); + } + List currentBlockers = scanReverseReferences(root, current); + if (!currentBlockers.isEmpty()) { + throw new IOException("Jigsaw Studio project gained " + currentBlockers.size() + + " reverse references after deletion was inspected"); + } + } + + private static Path canonicalRoot(Path packRoot) throws IOException { + Path normalized = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + Path resolved; + try { + resolved = normalized.toRealPath(); + } catch (IOException exception) { + throw new IOException("Cannot resolve Jigsaw Studio pack root " + normalized, exception); + } + if (!Files.isDirectory(resolved, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Jigsaw Studio pack root is not a directory: " + resolved); + } + return resolved; + } + + private static ManifestSnapshot loadSnapshot(Path root, StructureKey key) throws IOException { + StructureTransactionWriter writer = new StructureTransactionWriter(root); + Path manifestPath = writer.ownershipManifestPath(key); + if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("This graph is read-only because it is not Studio-owned; only Studio-owned projects can be deleted"); + } + byte[] manifestContent = readBoundedJson(manifestPath, "Jigsaw Studio ownership manifest"); + StructureOwnershipManifest manifest; + try { + manifest = JigsawStudioAuthoringAccess.requireEditable( + StructureOwnershipManifest.fromJson(manifestContent)); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid Jigsaw Studio ownership manifest at " + manifestPath, exception); + } + if (!manifest.structure().equals(key)) { + throw new IOException("Jigsaw Studio ownership manifest belongs to " + manifest.structure()); + } + String structureResource = "structures/" + manifest.structure().path() + ".json"; + String structureHash = manifest.resourceHashes().get(structureResource); + if (structureHash == null) { + throw new IOException("Jigsaw Studio ownership manifest does not include " + structureResource); + } + for (Map.Entry resource : manifest.resourceHashes().entrySet()) { + Path resourcePath = resolveOwnedResource(root, resource.getKey()); + String actualHash; + try (InputStream input = Files.newInputStream(resourcePath)) { + actualHash = StructureHash.sha256(input); + } + if (!resource.getValue().equals(actualHash)) { + throw new IOException("Owned graph resource changed outside Studio: " + resource.getKey()); + } + } + String sourceHash = manifest.source().contentHash().isEmpty() + ? structureHash + : manifest.source().contentHash(); + return new ManifestSnapshot( + manifest, + StructureHash.sha256(manifestContent), + sourceHash, + manifestPath); + } + + private static Path resolveOwnedResource(Path root, String relativePath) throws IOException { + StructureResourceBundle.validateRelativePath(relativePath); + Path resource = root.resolve(relativePath).normalize(); + if (!resource.startsWith(root) + || !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Owned graph resource is missing or unsafe: " + relativePath); + } + return resource; + } + + private static List scanReverseReferences( + Path root, + ManifestSnapshot snapshot + ) throws IOException { + Map> targetsByKey = referenceTargets(snapshot.manifest()); + Set ownedPaths = snapshot.manifest().resourceHashes().keySet(); + Set references = new LinkedHashSet<>(); + int scannedFiles = 0; + try (Stream paths = Files.walk(root)) { + Iterator iterator = paths.iterator(); + while (iterator.hasNext()) { + Path path = iterator.next(); + Path relative = root.relativize(path); + String relativePath = relative.toString().replace(path.getFileSystem().getSeparator(), "/"); + if (relativePath.isEmpty() || relativePath.startsWith(".iris/")) { + continue; + } + if (Files.isSymbolicLink(path)) { + if (relativePath.endsWith(".json") || Files.isDirectory(path)) { + throw new IOException("Cannot prove deletion safety through symbolic pack path " + + relativePath); + } + continue; + } + if (!relativePath.endsWith(".json") || ownedPaths.contains(relativePath)) { + continue; + } + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + scannedFiles++; + if (scannedFiles > MAX_SCANNED_FILES) { + throw new IOException("Jigsaw Studio project deletion scan exceeds " + + MAX_SCANNED_FILES + " JSON resources"); + } + JsonElement json = parseBoundedJson(path, relativePath); + collectReferences(json, "$", relativePath, targetsByKey, references); + } + } + scanOwnershipManifests(root, snapshot, references); + List ordered = new ArrayList<>(references); + ordered.sort(Comparator.comparing(ReverseReference::ownerPath) + .thenComparing(ReverseReference::location) + .thenComparing(ReverseReference::targetResourcePath)); + return List.copyOf(ordered); + } + + private static Map> referenceTargets(StructureOwnershipManifest manifest) { + Map> targets = new LinkedHashMap<>(); + addReferenceTarget(targets, manifest.structure().path(), + "structures/" + manifest.structure().path() + ".json"); + addReferenceTarget(targets, manifest.structure().value(), + "structures/" + manifest.structure().path() + ".json"); + for (String relativePath : manifest.resourceHashes().keySet()) { + addResourceReferenceTarget(targets, relativePath, "jigsaw-pools/", ".json"); + addResourceReferenceTarget(targets, relativePath, "jigsaw-pieces/", ".json"); + addResourceReferenceTarget(targets, relativePath, "objects/", ".iob"); + } + return targets; + } + + private static void addResourceReferenceTarget( + Map> targets, + String relativePath, + String prefix, + String suffix + ) { + if (!relativePath.startsWith(prefix) || !relativePath.endsWith(suffix)) { + return; + } + String key = relativePath.substring(prefix.length(), relativePath.length() - suffix.length()); + addReferenceTarget(targets, key, relativePath); + addReferenceTarget(targets, "iris:" + key, relativePath); + } + + private static void addReferenceTarget( + Map> targets, + String key, + String relativePath + ) { + targets.computeIfAbsent(key, ignored -> new LinkedHashSet<>()).add(relativePath); + } + + private static void collectReferences( + JsonElement element, + String location, + String ownerPath, + Map> targetsByKey, + Set references + ) { + if (element == null || element.isJsonNull()) { + return; + } + if (element.isJsonPrimitive() && element.getAsJsonPrimitive().isString()) { + Set targets = targetsByKey.get(element.getAsString()); + if (targets != null) { + for (String target : targets) { + references.add(new ReverseReference(ownerPath, location, target)); + } + } + return; + } + if (element.isJsonArray()) { + JsonArray array = element.getAsJsonArray(); + for (int index = 0; index < array.size(); index++) { + collectReferences( + array.get(index), + location + "[" + index + "]", + ownerPath, + targetsByKey, + references); + } + return; + } + if (element.isJsonObject()) { + JsonObject object = element.getAsJsonObject(); + for (Map.Entry entry : object.entrySet()) { + collectReferences( + entry.getValue(), + location + "." + entry.getKey(), + ownerPath, + targetsByKey, + references); + } + } + } + + private static void scanOwnershipManifests( + Path root, + ManifestSnapshot target, + Set references + ) throws IOException { + Path manifestRoot = root.resolve(".iris/structure-manifests"); + if (!Files.isDirectory(manifestRoot, LinkOption.NOFOLLOW_LINKS)) { + return; + } + try (Stream paths = Files.list(manifestRoot)) { + Iterator iterator = paths.iterator(); + while (iterator.hasNext()) { + Path path = iterator.next(); + if (Files.isSameFile(path, target.manifestPath()) + || !path.getFileName().toString().endsWith(".json")) { + continue; + } + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Cannot prove deletion safety through ownership manifest " + path); + } + StructureOwnershipManifest manifest; + try { + manifest = StructureOwnershipManifest.fromJson( + readBoundedJson(path, "Structure ownership manifest")); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid structure ownership manifest at " + path, exception); + } + for (String relativePath : target.manifest().resourceHashes().keySet()) { + if (manifest.resourceHashes().containsKey(relativePath)) { + references.add(new ReverseReference( + root.relativize(path).toString().replace(path.getFileSystem().getSeparator(), "/"), + "$.resourceHashes", + relativePath)); + } + } + } + } + } + + private static JsonElement parseBoundedJson(Path path, String relativePath) throws IOException { + byte[] content = readBoundedJson(path, "JSON resource"); + try { + return JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + } catch (RuntimeException exception) { + throw new IOException("Cannot prove deletion safety through invalid JSON resource " + + relativePath, exception); + } + } + + private static byte[] readBoundedJson(Path path, String kind) throws IOException { + long size = Files.size(path); + if (size > MAX_JSON_BYTES) { + throw new IOException(kind + " exceeds " + MAX_JSON_BYTES + " bytes: " + path); + } + return Files.readAllBytes(path); + } + + public record DeletionPlan( + UUID planId, + Path packRoot, + StructureKey structureKey, + StructureSource expectedSource, + String expectedManifestHash, + String expectedSourceHash, + Map expectedResourceHashes, + List blockers + ) { + public DeletionPlan { + Objects.requireNonNull(planId, "Jigsaw Studio project deletion plan ID"); + packRoot = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + Objects.requireNonNull(structureKey, "Jigsaw Studio project deletion structure key"); + Objects.requireNonNull(expectedSource, "Jigsaw Studio project deletion source"); + if (!StructureHash.isSha256(expectedManifestHash)) { + throw new IllegalArgumentException("Expected Jigsaw Studio ownership manifest hash must be SHA-256"); + } + if (!StructureHash.isSha256(expectedSourceHash)) { + throw new IllegalArgumentException("Expected Jigsaw Studio source hash must be SHA-256"); + } + Objects.requireNonNull(expectedResourceHashes, "Jigsaw Studio project deletion resources"); + expectedResourceHashes = Collections.unmodifiableMap(new TreeMap<>(expectedResourceHashes)); + blockers = List.copyOf(Objects.requireNonNull( + blockers, + "Jigsaw Studio project deletion blockers")); + } + + public boolean deletable() { + return blockers.isEmpty(); + } + } + + public record ReverseReference( + String ownerPath, + String location, + String targetResourcePath + ) { + public ReverseReference { + ownerPath = requireNonBlank(ownerPath, "reverse-reference owner path"); + location = requireNonBlank(location, "reverse-reference location"); + targetResourcePath = StructureResourceBundle.validateRelativePath(targetResourcePath); + } + } + + public record ProjectDeletionResult( + UUID planId, + StructureKey structureKey, + int removedResourceCount, + boolean manifestRemoved + ) { + public ProjectDeletionResult { + Objects.requireNonNull(planId, "Jigsaw Studio project deletion plan ID"); + Objects.requireNonNull(structureKey, "Deleted Jigsaw Studio structure key"); + if (removedResourceCount < 0) { + throw new IllegalArgumentException("Removed Jigsaw Studio resource count cannot be negative"); + } + } + } + + private static String requireNonBlank(String value, String name) { + Objects.requireNonNull(value, name); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException(name + " cannot be blank"); + } + return normalized; + } + + private record ManifestSnapshot( + StructureOwnershipManifest manifest, + String manifestHash, + String sourceHash, + Path manifestPath + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSession.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSession.java new file mode 100644 index 000000000..b5b58686d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSession.java @@ -0,0 +1,666 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public final class JigsawStudioSession { + private final UUID sessionId; + private final String packKey; + private final String structureKey; + private final Map workcells = new LinkedHashMap<>(); + private JigsawStudioLayout layout; + private String selectedBayId; + private long nextLoadGeneration; + private long nextMutationGeneration; + private long nextOperationGeneration; + private long revision; + + public JigsawStudioSession(String packKey, String structureKey, JigsawStudioLayout layout) { + this(UUID.randomUUID(), packKey, structureKey, layout); + } + + public JigsawStudioSession( + UUID sessionId, + String packKey, + String structureKey, + JigsawStudioLayout layout + ) { + this.sessionId = Objects.requireNonNull(sessionId, "Jigsaw Studio session ID"); + this.packKey = requireKey(packKey, "pack"); + this.structureKey = requireKey(structureKey, "structure"); + this.layout = Objects.requireNonNull(layout, "Jigsaw Studio session layout"); + initializeWorkcells(layout); + } + + public synchronized UUID sessionId() { + return sessionId; + } + + public synchronized String packKey() { + return packKey; + } + + public synchronized String structureKey() { + return structureKey; + } + + public synchronized JigsawStudioLayout layout() { + return layout; + } + + public synchronized Optional selectedBayId() { + return Optional.ofNullable(selectedBayId); + } + + public synchronized boolean selectBay(String stableId) { + Objects.requireNonNull(stableId, "Jigsaw Studio selected workcell ID"); + if (layout.get(stableId) == null) { + return false; + } + selectedBayId = stableId; + return true; + } + + public synchronized void clearSelection() { + selectedBayId = null; + } + + public synchronized Optional activeVariant(String workcellId) { + MutableWorkcellState state = workcells.get(workcellId); + if (state == null || state.activeVariantKey.isEmpty()) { + return Optional.empty(); + } + return layout.variantCatalog().find(state.activeVariantKey); + } + + public synchronized WorkcellSnapshot workcellSnapshot(String workcellId) { + MutableWorkcellState state = requireWorkcellState(workcellId); + return state.snapshot(workcellId); + } + + public synchronized boolean replaceLayout(JigsawStudioLayout replacement) { + JigsawStudioLayout nextLayout = Objects.requireNonNull(replacement, "Replacement Jigsaw Studio layout"); + if (layout.mode() != nextLayout.mode()) { + throw new IllegalArgumentException("Replacement Jigsaw Studio layout mode does not match the session"); + } + if (operationInProgress()) { + throw new IllegalStateException("Jigsaw Studio layout cannot change during a save or variant switch"); + } + if (layout == nextLayout) { + return false; + } + + Map updated = new LinkedHashMap<>(); + for (JigsawStudioBay workcell : nextLayout.bays()) { + MutableWorkcellState previous = workcells.get(workcell.stableId()); + String activeVariantKey = retainedVariantKey(nextLayout, workcell, previous); + if (previous != null && previous.activeVariantKey.equals(activeVariantKey)) { + updated.put(workcell.stableId(), previous.copy()); + continue; + } + updated.put(workcell.stableId(), new MutableWorkcellState( + activeVariantKey, + nextLoadGeneration(), + nextMutationGeneration(), + false)); + } + layout = nextLayout; + workcells.clear(); + workcells.putAll(updated); + if (selectedBayId != null && layout.get(selectedBayId) == null) { + selectedBayId = null; + } + revision++; + return true; + } + + public synchronized boolean replaceLayoutAndRebind( + JigsawStudioLayout replacement, + Map activeVariantsByWorkcell + ) { + JigsawStudioLayout nextLayout = Objects.requireNonNull(replacement, "Replacement Jigsaw Studio layout"); + Map bindings = Map.copyOf(Objects.requireNonNull( + activeVariantsByWorkcell, + "Replacement Jigsaw Studio active variants")); + if (layout.mode() != nextLayout.mode()) { + throw new IllegalArgumentException("Replacement Jigsaw Studio layout mode does not match the session"); + } + if (operationInProgress()) { + throw new IllegalStateException("Jigsaw Studio layout cannot change during a save or variant switch"); + } + for (Map.Entry binding : bindings.entrySet()) { + JigsawStudioBay workcell = nextLayout.get(binding.getKey()); + JigsawStudioVariant variant = nextLayout.variantCatalog().find(binding.getValue()) + .orElseThrow(() -> new IllegalArgumentException( + "Unknown replacement Jigsaw Studio variant " + binding.getValue())); + if (workcell == null || !nextLayout.accepts(workcell, variant)) { + throw new IllegalArgumentException("Replacement Jigsaw Studio variant " + binding.getValue() + + " does not belong to workcell " + binding.getKey()); + } + } + + Map updated = new LinkedHashMap<>(); + boolean changed = layout != nextLayout; + for (JigsawStudioBay workcell : nextLayout.bays()) { + MutableWorkcellState previous = workcells.get(workcell.stableId()); + String targetVariantKey = bindings.get(workcell.stableId()); + if (targetVariantKey == null) { + targetVariantKey = retainedVariantKey(nextLayout, workcell, previous); + } + if (previous != null && previous.activeVariantKey.equals(targetVariantKey)) { + updated.put(workcell.stableId(), previous.copy()); + continue; + } + changed = true; + updated.put(workcell.stableId(), new MutableWorkcellState( + targetVariantKey, + nextLoadGeneration(), + nextMutationGeneration(), + false)); + } + layout = nextLayout; + workcells.clear(); + workcells.putAll(updated); + if (selectedBayId != null && layout.get(selectedBayId) == null) { + selectedBayId = null; + } + if (changed) { + revision++; + } + return changed; + } + + public synchronized SwitchStart beginVariantSwitch( + String workcellId, + String targetPieceKey, + boolean discardDirty + ) { + return beginVariantTransition(workcellId, targetPieceKey, discardDirty, false); + } + + public synchronized SwitchStart beginVariantReload(String workcellId) { + MutableWorkcellState state = workcells.get(workcellId); + if (state == null) { + return SwitchStart.failure(SwitchStatus.UNKNOWN_WORKCELL); + } + if (state.activeVariantKey.isEmpty()) { + return SwitchStart.failure(SwitchStatus.UNKNOWN_VARIANT); + } + return beginVariantTransition(workcellId, state.activeVariantKey, false, true); + } + + private SwitchStart beginVariantTransition( + String workcellId, + String targetPieceKey, + boolean discardDirty, + boolean allowActive + ) { + MutableWorkcellState state = workcells.get(workcellId); + if (state == null) { + return SwitchStart.failure(SwitchStatus.UNKNOWN_WORKCELL); + } + Optional target = layout.variantCatalog().find(targetPieceKey); + if (target.isEmpty()) { + return SwitchStart.failure(SwitchStatus.UNKNOWN_VARIANT); + } + JigsawStudioBay workcell = layout.get(workcellId); + if (!layout.accepts(workcell, target.get())) { + return SwitchStart.failure(SwitchStatus.WRONG_WORKCELL); + } + if (state.switchInProgress) { + return SwitchStart.failure(SwitchStatus.SWITCH_IN_PROGRESS); + } + if (state.saveInProgress) { + return SwitchStart.failure(SwitchStatus.SAVE_IN_PROGRESS); + } + if (state.activeVariantKey.equals(targetPieceKey) && !allowActive) { + return SwitchStart.failure(SwitchStatus.ALREADY_ACTIVE); + } + if (state.dirty && !discardDirty) { + return SwitchStart.failure(SwitchStatus.DIRTY); + } + + JigsawStudioVariant previous = state.activeVariantKey.isEmpty() + ? null + : layout.variantCatalog().find(state.activeVariantKey).orElse(null); + long switchGeneration = nextOperationGeneration(); + state.switchInProgress = true; + state.switchGeneration = switchGeneration; + VariantSwitchToken token = new VariantSwitchToken( + sessionId, + workcellId, + Optional.ofNullable(previous), + target.get(), + state.loadGeneration, + state.mutationGeneration, + switchGeneration, + discardDirty); + revision++; + return SwitchStart.started(token); + } + + public synchronized boolean completeVariantSwitch(VariantSwitchToken expected) { + VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token"); + MutableWorkcellState state = workcells.get(token.workcellId()); + if (!validSwitchToken(state, token)) { + return false; + } + state.activeVariantKey = token.targetVariant().pieceKey(); + state.loadGeneration = nextLoadGeneration(); + state.mutationGeneration = nextMutationGeneration(); + state.dirty = false; + state.switchInProgress = false; + state.switchGeneration = 0L; + revision++; + return true; + } + + public synchronized boolean isVariantSwitchCurrent(VariantSwitchToken expected) { + VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token"); + return validSwitchToken(workcells.get(token.workcellId()), token); + } + + public synchronized boolean abortVariantSwitch(VariantSwitchToken expected) { + VariantSwitchToken token = Objects.requireNonNull(expected, "Jigsaw Studio variant switch token"); + MutableWorkcellState state = workcells.get(token.workcellId()); + if (!validSwitchToken(state, token)) { + return false; + } + state.switchInProgress = false; + state.switchGeneration = 0L; + revision++; + return true; + } + + public synchronized DirtyMark markWorkcellDirty(String workcellId) { + MutableWorkcellState state = workcells.get(workcellId); + if (state == null) { + return DirtyMark.failure(DirtyStatus.UNKNOWN_WORKCELL); + } + if (state.activeVariantKey.isEmpty()) { + return DirtyMark.failure(DirtyStatus.NO_ACTIVE_VARIANT); + } + if (state.switchInProgress) { + return DirtyMark.failure(DirtyStatus.SWITCH_IN_PROGRESS); + } + state.mutationGeneration = nextMutationGeneration(); + boolean newlyDirty = !state.dirty; + state.dirty = true; + revision++; + return DirtyMark.marked(new DirtyIdentity( + sessionId, + workcellId, + state.activeVariantKey, + state.loadGeneration, + state.mutationGeneration), newlyDirty); + } + + public synchronized boolean isDirtyCurrent(DirtyIdentity expected) { + DirtyIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio dirty identity"); + MutableWorkcellState state = workcells.get(identity.workcellId()); + return sessionId.equals(identity.sessionId()) + && state != null + && state.dirty + && state.activeVariantKey.equals(identity.variantKey()) + && state.loadGeneration == identity.loadGeneration() + && state.mutationGeneration == identity.mutationGeneration(); + } + + public synchronized SaveStart beginSave(String workcellId) { + MutableWorkcellState state = workcells.get(workcellId); + if (state == null) { + return SaveStart.failure(SaveStatus.UNKNOWN_WORKCELL); + } + if (state.activeVariantKey.isEmpty()) { + return SaveStart.failure(SaveStatus.NO_ACTIVE_VARIANT); + } + if (state.switchInProgress) { + return SaveStart.failure(SaveStatus.SWITCH_IN_PROGRESS); + } + if (state.saveInProgress) { + return SaveStart.failure(SaveStatus.SAVE_IN_PROGRESS); + } + long saveGeneration = nextOperationGeneration(); + state.saveInProgress = true; + state.saveGeneration = saveGeneration; + SaveIdentity identity = new SaveIdentity( + sessionId, + workcellId, + state.activeVariantKey, + state.loadGeneration, + state.mutationGeneration, + saveGeneration); + revision++; + return SaveStart.started(identity); + } + + public synchronized boolean markWorkcellSaved(SaveIdentity expected) { + SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity"); + MutableWorkcellState state = workcells.get(identity.workcellId()); + if (!validSaveReservation(state, identity)) { + return false; + } + boolean unchanged = state.activeVariantKey.equals(identity.variantKey()) + && state.loadGeneration == identity.loadGeneration() + && state.mutationGeneration == identity.mutationGeneration(); + state.saveInProgress = false; + state.saveGeneration = 0L; + if (unchanged) { + state.dirty = false; + } + revision++; + return unchanged; + } + + public synchronized boolean isSaveCurrent(SaveIdentity expected) { + SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity"); + MutableWorkcellState state = workcells.get(identity.workcellId()); + return validSaveReservation(state, identity) + && state.activeVariantKey.equals(identity.variantKey()) + && state.loadGeneration == identity.loadGeneration() + && state.mutationGeneration == identity.mutationGeneration(); + } + + public synchronized boolean abortSave(SaveIdentity expected) { + SaveIdentity identity = Objects.requireNonNull(expected, "Jigsaw Studio save identity"); + MutableWorkcellState state = workcells.get(identity.workcellId()); + if (!validSaveReservation(state, identity)) { + return false; + } + state.saveInProgress = false; + state.saveGeneration = 0L; + revision++; + return true; + } + + public synchronized boolean isDirty() { + for (MutableWorkcellState state : workcells.values()) { + if (state.dirty) { + return true; + } + } + return false; + } + + public synchronized List dirtyWorkcellIds() { + List dirty = new ArrayList<>(); + for (Map.Entry entry : workcells.entrySet()) { + if (entry.getValue().dirty) { + dirty.add(entry.getKey()); + } + } + return List.copyOf(dirty); + } + + public synchronized boolean operationInProgress() { + for (MutableWorkcellState state : workcells.values()) { + if (state.saveInProgress || state.switchInProgress) { + return true; + } + } + return false; + } + + public synchronized long revision() { + return revision; + } + + private void initializeWorkcells(JigsawStudioLayout initialLayout) { + for (JigsawStudioBay workcell : initialLayout.bays()) { + String variantKey = initialLayout.defaultVariant(workcell) + .map(JigsawStudioVariant::pieceKey) + .orElse(""); + workcells.put(workcell.stableId(), new MutableWorkcellState( + variantKey, + nextLoadGeneration(), + nextMutationGeneration(), + false)); + } + } + + private String retainedVariantKey( + JigsawStudioLayout nextLayout, + JigsawStudioBay workcell, + MutableWorkcellState previous + ) { + if (previous != null && previous.activeVariantKey.isEmpty()) { + return ""; + } + if (previous != null) { + Optional retained = nextLayout.variantCatalog().find(previous.activeVariantKey); + if (retained.isPresent() && nextLayout.accepts(workcell, retained.get())) { + return previous.activeVariantKey; + } + } + return nextLayout.defaultVariant(workcell).map(JigsawStudioVariant::pieceKey).orElse(""); + } + + private boolean validSwitchToken(MutableWorkcellState state, VariantSwitchToken token) { + if (!sessionId.equals(token.sessionId()) || state == null || !state.switchInProgress) { + return false; + } + String previousKey = token.previousVariant().map(JigsawStudioVariant::pieceKey).orElse(""); + return state.switchGeneration == token.switchGeneration() + && state.activeVariantKey.equals(previousKey) + && state.loadGeneration == token.loadGeneration() + && state.mutationGeneration == token.mutationGeneration(); + } + + private boolean validSaveReservation(MutableWorkcellState state, SaveIdentity identity) { + return sessionId.equals(identity.sessionId()) + && state != null + && state.saveInProgress + && state.saveGeneration == identity.saveGeneration(); + } + + private MutableWorkcellState requireWorkcellState(String workcellId) { + MutableWorkcellState state = workcells.get(Objects.requireNonNull(workcellId, "Jigsaw Studio workcell ID")); + if (state == null) { + throw new IllegalArgumentException("Unknown Jigsaw Studio workcell " + workcellId); + } + return state; + } + + private long nextLoadGeneration() { + return nextLoadGeneration = Math.incrementExact(nextLoadGeneration); + } + + private long nextMutationGeneration() { + return nextMutationGeneration = Math.incrementExact(nextMutationGeneration); + } + + private long nextOperationGeneration() { + return nextOperationGeneration = Math.incrementExact(nextOperationGeneration); + } + + private static String requireKey(String value, String name) { + Objects.requireNonNull(value, "Jigsaw Studio " + name + " key"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank"); + } + return normalized; + } + + public enum SwitchStatus { + STARTED, + UNKNOWN_WORKCELL, + UNKNOWN_VARIANT, + WRONG_WORKCELL, + ALREADY_ACTIVE, + DIRTY, + SAVE_IN_PROGRESS, + SWITCH_IN_PROGRESS + } + + public enum SaveStatus { + STARTED, + UNKNOWN_WORKCELL, + NO_ACTIVE_VARIANT, + SWITCH_IN_PROGRESS, + SAVE_IN_PROGRESS + } + + public enum DirtyStatus { + MARKED, + UNKNOWN_WORKCELL, + NO_ACTIVE_VARIANT, + SWITCH_IN_PROGRESS + } + + public record WorkcellSnapshot( + String workcellId, + String activeVariantKey, + long loadGeneration, + long mutationGeneration, + boolean dirty, + boolean saveInProgress, + boolean switchInProgress + ) { + } + + public record DirtyIdentity( + UUID sessionId, + String workcellId, + String variantKey, + long loadGeneration, + long mutationGeneration + ) { + public DirtyIdentity { + Objects.requireNonNull(sessionId, "Jigsaw Studio dirty session ID"); + Objects.requireNonNull(workcellId, "Jigsaw Studio dirty workcell ID"); + Objects.requireNonNull(variantKey, "Jigsaw Studio dirty variant key"); + } + } + + public record DirtyMark( + DirtyStatus status, + Optional identity, + boolean newlyDirty + ) { + public DirtyMark { + status = Objects.requireNonNull(status, "Jigsaw Studio dirty status"); + identity = Objects.requireNonNull(identity, "Jigsaw Studio dirty identity"); + if (status == DirtyStatus.MARKED && identity.isEmpty()) { + throw new IllegalArgumentException("A marked Jigsaw Studio workcell requires a dirty identity"); + } + if (status != DirtyStatus.MARKED && (identity.isPresent() || newlyDirty)) { + throw new IllegalArgumentException("A rejected Jigsaw Studio dirty mark cannot carry an identity"); + } + } + + private static DirtyMark marked(DirtyIdentity identity, boolean newlyDirty) { + return new DirtyMark(DirtyStatus.MARKED, Optional.of(identity), newlyDirty); + } + + private static DirtyMark failure(DirtyStatus status) { + return new DirtyMark(status, Optional.empty(), false); + } + } + + public record VariantSwitchToken( + UUID sessionId, + String workcellId, + Optional previousVariant, + JigsawStudioVariant targetVariant, + long loadGeneration, + long mutationGeneration, + long switchGeneration, + boolean discardDirty + ) { + public VariantSwitchToken { + Objects.requireNonNull(sessionId, "Jigsaw Studio switch session ID"); + Objects.requireNonNull(workcellId, "Jigsaw Studio switch workcell ID"); + previousVariant = Objects.requireNonNull(previousVariant, "Jigsaw Studio previous variant"); + targetVariant = Objects.requireNonNull(targetVariant, "Jigsaw Studio target variant"); + } + } + + public record SwitchStart(SwitchStatus status, Optional token) { + public SwitchStart { + status = Objects.requireNonNull(status, "Jigsaw Studio switch status"); + token = Objects.requireNonNull(token, "Jigsaw Studio switch token"); + } + + private static SwitchStart started(VariantSwitchToken token) { + return new SwitchStart(SwitchStatus.STARTED, Optional.of(token)); + } + + private static SwitchStart failure(SwitchStatus status) { + return new SwitchStart(status, Optional.empty()); + } + } + + public record SaveIdentity( + UUID sessionId, + String workcellId, + String variantKey, + long loadGeneration, + long mutationGeneration, + long saveGeneration + ) { + public SaveIdentity { + Objects.requireNonNull(sessionId, "Jigsaw Studio save session ID"); + Objects.requireNonNull(workcellId, "Jigsaw Studio save workcell ID"); + Objects.requireNonNull(variantKey, "Jigsaw Studio save variant key"); + } + } + + public record SaveStart(SaveStatus status, Optional identity) { + public SaveStart { + status = Objects.requireNonNull(status, "Jigsaw Studio save status"); + identity = Objects.requireNonNull(identity, "Jigsaw Studio save identity"); + } + + private static SaveStart started(SaveIdentity identity) { + return new SaveStart(SaveStatus.STARTED, Optional.of(identity)); + } + + private static SaveStart failure(SaveStatus status) { + return new SaveStart(status, Optional.empty()); + } + } + + private static final class MutableWorkcellState { + private String activeVariantKey; + private long loadGeneration; + private long mutationGeneration; + private boolean dirty; + private boolean saveInProgress; + private long saveGeneration; + private boolean switchInProgress; + private long switchGeneration; + + private MutableWorkcellState( + String activeVariantKey, + long loadGeneration, + long mutationGeneration, + boolean dirty + ) { + this.activeVariantKey = activeVariantKey; + this.loadGeneration = loadGeneration; + this.mutationGeneration = mutationGeneration; + this.dirty = dirty; + } + + private MutableWorkcellState copy() { + return new MutableWorkcellState(activeVariantKey, loadGeneration, mutationGeneration, dirty); + } + + private WorkcellSnapshot snapshot(String workcellId) { + return new WorkcellSnapshot( + workcellId, + activeVariantKey, + loadGeneration, + mutationGeneration, + dirty, + saveInProgress, + switchInProgress); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioStructureEditor.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioStructureEditor.java new file mode 100644 index 000000000..c98270d5b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioStructureEditor.java @@ -0,0 +1,465 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteOptions; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import art.arcane.iris.engine.object.IrisJigsawMode; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.util.common.math.IrisBlockVector; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public final class JigsawStudioStructureEditor { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private JigsawStudioStructureEditor() { + } + + public static StructureWriteResult updateCellSize( + Path packRoot, + String structureKey, + JigsawStudioCellDimensions dimensions + ) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + ManifestSnapshot snapshot = loadManifest(root, structureKey); + requireOwnedObjectsFit(root, snapshot.manifest(), dimensions); + return updateOwnedStructure( + root, + structureKey, + snapshot, + (content, structurePath) -> updateCellSize(content, dimensions, structurePath)); + } + + public static StructureWriteResult updateLimits( + Path packRoot, + String structureKey, + int maxDepth, + int maxSizeChunks + ) throws IOException { + if (maxDepth < 1 || maxDepth > 30) { + throw new IllegalArgumentException("Jigsaw max depth must be between 1 and 30"); + } + if (maxSizeChunks < 1 || maxSizeChunks > 32) { + throw new IllegalArgumentException("Jigsaw maximum size must be between 1 and 32 chunks"); + } + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + ManifestSnapshot snapshot = loadManifest(root, structureKey); + return updateOwnedStructure( + root, + structureKey, + snapshot, + (content, structurePath) -> updateLimits( + content, maxDepth, maxSizeChunks, structurePath)); + } + + public static StructureWriteResult updateThemeSets( + Path packRoot, + String structureKey, + List themeSets + ) throws IOException { + List normalizedThemeSets = normalizeThemeSets(themeSets); + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + ManifestSnapshot snapshot = loadManifest(root, structureKey); + return updateOwnedStructure( + root, + structureKey, + snapshot, + (content, structurePath) -> updateThemeSets( + content, + normalizedThemeSets, + structurePath)); + } + + public static StructureWriteResult updateRequireCaps( + Path packRoot, + String structureKey, + boolean requireCaps + ) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + ManifestSnapshot snapshot = loadManifest(root, structureKey); + return updateOwnedStructure( + root, + structureKey, + snapshot, + (content, structurePath) -> updateRequireCaps( + content, + requireCaps, + structurePath)); + } + + public static StructureWriteResult updateWorkcellEnabled( + Path packRoot, + String structureKey, + JigsawPlanarArchetype archetype, + boolean enabled + ) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + JigsawPlanarArchetype target = Objects.requireNonNull(archetype, "Planar Jigsaw Studio archetype"); + ManifestSnapshot snapshot = loadManifest(root, structureKey); + return updateOwnedStructure( + root, + structureKey, + snapshot, + (content, structurePath) -> updateWorkcell( + content, + target, + null, + enabled, + null, + structurePath)); + } + + public static StructureWriteResult updateWorkcellDimensions( + Path packRoot, + String structureKey, + JigsawPlanarArchetype archetype, + JigsawStudioCellDimensions dimensions + ) throws IOException { + return JigsawStudioGraphEditor.updatePlanarWorkcellCapacity( + packRoot, + structureKey, + archetype, + dimensions).writeResult(); + } + + public static StructureWriteResult updateWorkcellDisplayName( + Path packRoot, + String structureKey, + JigsawPlanarArchetype archetype, + String displayName + ) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + JigsawPlanarArchetype target = Objects.requireNonNull(archetype, "Planar Jigsaw Studio archetype"); + String normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName); + ManifestSnapshot snapshot = loadManifest(root, structureKey); + return updateOwnedStructure( + root, + structureKey, + snapshot, + (content, structurePath) -> updateWorkcell( + content, + target, + null, + null, + normalizedName, + structurePath)); + } + + public static StructureWriteResult updateSpatialWorkcellDisplayName( + Path packRoot, + String structureKey, + String displayName + ) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root") + .toAbsolutePath().normalize(); + String normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName); + ManifestSnapshot snapshot = loadManifest(root, structureKey); + return updateOwnedStructure( + root, + structureKey, + snapshot, + (content, structurePath) -> updateSpatialWorkcellDisplayName( + content, + normalizedName, + structurePath)); + } + + private static ManifestSnapshot loadManifest(Path root, String structureKey) throws IOException { + StructureKey ownershipKey = StructureKey.parse(structureKey, "iris"); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + Path manifestPath = writer.ownershipManifestPath(ownershipKey); + if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("This graph is read-only because it is not Studio-owned; create a new Jigsaw Studio project before editing rules"); + } + byte[] manifestContent = Files.readAllBytes(manifestPath); + return new ManifestSnapshot( + JigsawStudioAuthoringAccess.requireEditable( + StructureOwnershipManifest.fromJson(manifestContent)), + StructureHash.sha256(manifestContent)); + } + + private static StructureWriteResult updateOwnedStructure( + Path root, + String structureKey, + ManifestSnapshot snapshot, + StructureContentEditor editor + ) throws IOException { + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureOwnershipManifest manifest = snapshot.manifest(); + String normalizedStructure = JigsawStudioProjectCreator.Options.requireResourceKey(structureKey); + String targetResource = "structures/" + normalizedStructure + ".json"; + if (!manifest.resourceHashes().containsKey(targetResource)) { + throw new IOException("The owned graph manifest does not include " + targetResource); + } + + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(manifest.structure()) + .source(manifest.source()) + .backend(manifest.backend()) + .capabilities(manifest.capabilities()) + .losses(manifest.losses()); + for (Map.Entry resource : manifest.resourceHashes().entrySet()) { + Path resourcePath = resolveOwnedResource(root, resource.getKey()); + byte[] content = Files.readAllBytes(resourcePath); + if (resource.getKey().equals(targetResource)) { + content = editor.edit(content, resourcePath); + } + bundle.resource(resource.getKey(), content); + } + StructureResourceBundle updatedBundle = bundle.build(); + StructureResourceBundleGraphCompiler.requireViable(updatedBundle); + StructureWriteResult result = writer.write( + updatedBundle, + StructureWriteOptions.overwriteExpected(snapshot.expectedManifestHash())); + if (!result.successful()) { + String conflict = result.conflicts().isEmpty() + ? result.status().name() + : result.conflicts().getFirst().relativePath() + ": " + + result.conflicts().getFirst().reason(); + throw new IOException("Atomic graph structure update was rejected: " + conflict); + } + return result; + } + + private static Path resolveOwnedResource(Path root, String relativePath) throws IOException { + StructureResourceBundle.validateRelativePath(relativePath); + Path resource = root.resolve(relativePath).normalize(); + if (!resource.startsWith(root) || !Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Owned graph resource is missing or unsafe: " + relativePath); + } + return resource; + } + + private static void requireOwnedObjectsFit( + Path root, + StructureOwnershipManifest manifest, + JigsawStudioCellDimensions dimensions + ) throws IOException { + for (String relativePath : manifest.resourceHashes().keySet()) { + if (!relativePath.startsWith("objects/") || !relativePath.endsWith(".iob")) { + continue; + } + Path objectPath = resolveOwnedResource(root, relativePath); + IrisBlockVector size = IrisObject.sampleSize(objectPath.toFile()); + if (size.getBlockX() > dimensions.width() + || size.getBlockY() > dimensions.height() + || size.getBlockZ() > dimensions.depth()) { + throw new IOException("Owned object '" + relativePath + "' is " + + size.getBlockX() + "x" + size.getBlockY() + "x" + size.getBlockZ() + + " and does not fit the requested cell bounds"); + } + } + } + + private static byte[] updateCellSize( + byte[] content, + JigsawStudioCellDimensions dimensions, + Path structurePath + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw structure is not a JSON object: " + structurePath); + } + JsonObject cellSize = new JsonObject(); + JsonObject structure = parsed.getAsJsonObject(); + if (structure.has("mode") + && "PLANAR_JIGSAW".equals(structure.get("mode").getAsString()) + && dimensions.width() != dimensions.depth()) { + throw new IOException("Planar Jigsaw Studio cells require equal width and depth"); + } + cellSize.addProperty("x", dimensions.width()); + cellSize.addProperty("y", dimensions.height()); + cellSize.addProperty("z", dimensions.depth()); + structure.add("cellSize", cellSize); + return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] updateLimits( + byte[] content, + int maxDepth, + int maxSizeChunks, + Path structurePath + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw structure is not a JSON object: " + structurePath); + } + JsonObject structure = parsed.getAsJsonObject(); + structure.addProperty("maxDepth", maxDepth); + structure.addProperty("maxSizeChunks", maxSizeChunks); + return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static List normalizeThemeSets(List themeSets) { + Objects.requireNonNull(themeSets, "Jigsaw Studio theme sets"); + List normalized = new ArrayList<>(themeSets.size()); + Set keys = new LinkedHashSet<>(); + for (IrisJigsawThemeSet themeSet : themeSets) { + IrisJigsawThemeSet source = Objects.requireNonNull(themeSet, "Jigsaw Studio theme set"); + String key = source.getKey() == null ? "" : source.getKey().trim(); + if (key.isEmpty() || !key.equals(source.getKey())) { + throw new IllegalArgumentException( + "Jigsaw Studio theme keys must be non-blank and whitespace-normalized"); + } + if (!keys.add(key)) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio theme key '" + key + "'"); + } + if (source.getWeight() < 1) { + throw new IllegalArgumentException("Jigsaw Studio theme weights must be positive"); + } + normalized.add(new IrisJigsawThemeSet(key, source.getWeight())); + } + return List.copyOf(normalized); + } + + private static byte[] updateThemeSets( + byte[] content, + List themeSets, + Path structurePath + ) throws IOException { + JsonObject structure = parseStructure(content, structurePath); + JsonArray values = new JsonArray(); + for (IrisJigsawThemeSet themeSet : themeSets) { + JsonObject value = new JsonObject(); + value.addProperty("key", themeSet.getKey()); + value.addProperty("weight", themeSet.getWeight()); + values.add(value); + } + structure.add("themeSets", values); + return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] updateRequireCaps( + byte[] content, + boolean requireCaps, + Path structurePath + ) throws IOException { + JsonObject structure = parseStructure(content, structurePath); + structure.addProperty("requireCaps", requireCaps); + return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] updateSpatialWorkcellDisplayName( + byte[] content, + String displayName, + Path structurePath + ) throws IOException { + JsonObject structure = parseStructure(content, structurePath); + IrisStructure model; + try { + model = GSON.fromJson(structure, IrisStructure.class); + } catch (RuntimeException exception) { + throw new IOException("Jigsaw structure is not valid JSON: " + structurePath, exception); + } + if (model == null || model.resolvedMode() != IrisJigsawMode.SPATIAL_JIGSAW) { + throw new IOException("Spatial workcell labels require a spatial Jigsaw Studio structure"); + } + if (displayName.isEmpty()) { + structure.remove("spatialWorkcellDisplayName"); + } else { + structure.addProperty("spatialWorkcellDisplayName", displayName); + } + return (GSON.toJson(structure) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static JsonObject parseStructure(byte[] content, Path structurePath) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw structure is not a JSON object: " + structurePath); + } + return parsed.getAsJsonObject(); + } + + static byte[] updateWorkcell( + byte[] content, + JigsawPlanarArchetype archetype, + JigsawStudioCellDimensions dimensions, + Boolean enabled, + String displayName, + Path structurePath + ) throws IOException { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Jigsaw structure is not a JSON object: " + structurePath); + } + JsonObject structureJson = parsed.getAsJsonObject(); + IrisStructure structure; + try { + structure = GSON.fromJson(structureJson, IrisStructure.class); + } catch (RuntimeException exception) { + throw new IOException("Jigsaw structure is not valid JSON: " + structurePath, exception); + } + if (structure == null || structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW) { + throw new IOException("Workcell settings require a planar Jigsaw Studio structure"); + } + Map resolved; + try { + resolved = PlanarJigsawWorkcellResolver.resolve(structure); + } catch (IllegalArgumentException exception) { + throw new IOException("Planar workcell configuration is invalid: " + exception.getMessage(), exception); + } + JsonArray workcells = new JsonArray(); + for (JigsawPlanarArchetype current : JigsawPlanarArchetype.values()) { + PlanarJigsawWorkcellResolver.ResolvedWorkcell source = resolved.get(current.modelArchetype()); + JsonObject workcell = new JsonObject(); + workcell.addProperty("archetype", current.name()); + String resolvedDisplayName = current == archetype && displayName != null + ? displayName : source.displayName(); + if (!resolvedDisplayName.isEmpty()) { + workcell.addProperty("displayName", resolvedDisplayName); + } + workcell.addProperty("width", current == archetype && dimensions != null + ? dimensions.width() : source.width()); + workcell.addProperty("height", current == archetype && dimensions != null + ? dimensions.height() : source.height()); + workcell.addProperty("depth", current == archetype && dimensions != null + ? dimensions.depth() : source.depth()); + workcell.addProperty("enabled", current == archetype && enabled != null + ? enabled : source.enabled()); + workcells.add(workcell); + } + structureJson.add("planarWorkcells", workcells); + return (GSON.toJson(structureJson) + "\n").getBytes(StandardCharsets.UTF_8); + } + + @FunctionalInterface + private interface StructureContentEditor { + byte[] edit(byte[] content, Path structurePath) throws IOException; + } + + private record ManifestSnapshot( + StructureOwnershipManifest manifest, + String expectedManifestHash + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolAction.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolAction.java new file mode 100644 index 000000000..a94d84ffc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolAction.java @@ -0,0 +1,45 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.Objects; + +public enum JigsawStudioToolAction { + OPEN_MENU("Open Control Menu", false), + SELECT_WORKCELL("Select Workcell", false), + TOGGLE_WORKCELL("Toggle Workcell", false), + LOAD_VARIANT("Load Variant", false), + CREATE_VARIANT("New Blank Variant", false), + DUPLICATE_VARIANT("Duplicate This Cell's Variant", false), + DUPLICATE_FAMILY("Duplicate All Enabled Cells as Family", false), + PREVIEW_GRAPH("Go to Preview", false), + FLUSH_AUTOSAVE("Flush Autosave", false), + TOGGLE_ROTATION("Toggle Rotation", false), + EXPAND_TO_CELL("Resize Variant to Capacity", false), + RESIZE_VARIANT("Resize This Variant", false), + RESIZE_WORKCELL("Resize Workcell Capacity", false), + RENAME_VARIANT("Rename This Variant", false), + RENAME_WORKCELL("Rename This Workcell", false), + ADJUST_VARIANT_WEIGHT("Adjust Variant Weight", false), + ADJUST_VARIANT_CHANCE("Adjust Variant Chance", false), + SET_THEME("Set Theme", false), + SET_PIECE_RULES("Set Piece Rules", false), + TOGGLE_REQUIRE_CAPS("Toggle Required Caps", false), + UNLINK_MEMBERSHIP("Unlink Pool Entry", true), + DELETE_VARIANT("Delete Variant", true), + DELETE_PROJECT("Delete Project", true); + + private final String displayName; + private final boolean destructive; + + JigsawStudioToolAction(String displayName, boolean destructive) { + this.displayName = Objects.requireNonNull(displayName, "Jigsaw Studio tool display name"); + this.destructive = destructive; + } + + public String displayName() { + return displayName; + } + + public boolean destructive() { + return destructive; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolPayload.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolPayload.java new file mode 100644 index 000000000..9c5272d2a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioToolPayload.java @@ -0,0 +1,111 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.Objects; +import java.util.UUID; + +public record JigsawStudioToolPayload( + int schemaVersion, + JigsawStudioToolAction action, + UUID requestId, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex, + int amount +) { + public static final int CURRENT_SCHEMA_VERSION = 2; + public static final int NO_ENTRY_INDEX = -1; + + private static final int MAX_FIELD_LENGTH = 512; + + public JigsawStudioToolPayload { + if (schemaVersion < 1) { + throw new IllegalArgumentException("Jigsaw Studio tool schema version must be positive"); + } + action = Objects.requireNonNull(action, "Jigsaw Studio tool action"); + requestId = Objects.requireNonNull(requestId, "Jigsaw Studio tool request ID"); + workcellId = normalize(workcellId, "workcell ID"); + pieceKey = normalize(pieceKey, "piece key"); + poolKey = normalize(poolKey, "pool key"); + if (entryIndex < NO_ENTRY_INDEX) { + throw new IllegalArgumentException("Jigsaw Studio tool entry index cannot be lower than -1"); + } + } + + public static JigsawStudioToolPayload request( + JigsawStudioToolAction action, + UUID requestId + ) { + return new JigsawStudioToolPayload( + CURRENT_SCHEMA_VERSION, + action, + requestId, + "", + "", + "", + NO_ENTRY_INDEX, + 0); + } + + public static JigsawStudioToolPayload workcell( + JigsawStudioToolAction action, + UUID requestId, + String workcellId + ) { + return new JigsawStudioToolPayload( + CURRENT_SCHEMA_VERSION, + action, + requestId, + workcellId, + "", + "", + NO_ENTRY_INDEX, + 0); + } + + public static JigsawStudioToolPayload variant( + JigsawStudioToolAction action, + UUID requestId, + String workcellId, + String pieceKey + ) { + return new JigsawStudioToolPayload( + CURRENT_SCHEMA_VERSION, + action, + requestId, + workcellId, + pieceKey, + "", + NO_ENTRY_INDEX, + 0); + } + + public static JigsawStudioToolPayload membership( + JigsawStudioToolAction action, + UUID requestId, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex, + int amount + ) { + return new JigsawStudioToolPayload( + CURRENT_SCHEMA_VERSION, + action, + requestId, + workcellId, + pieceKey, + poolKey, + entryIndex, + amount); + } + + private static String normalize(String value, String fieldName) { + String normalized = value == null ? "" : value.trim(); + if (normalized.length() > MAX_FIELD_LENGTH) { + throw new IllegalArgumentException("Jigsaw Studio tool " + fieldName + + " cannot exceed " + MAX_FIELD_LENGTH + " characters"); + } + return normalized; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTracker.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTracker.java new file mode 100644 index 000000000..8c300a720 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTracker.java @@ -0,0 +1,112 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public final class JigsawStudioTripleSneakTracker { + public static final long DEFAULT_WINDOW_NANOS = 1_500_000_000L; + + private static final int REQUIRED_SNEAKS = 3; + + private final long windowNanos; + private final Map gestures = new HashMap<>(); + + public JigsawStudioTripleSneakTracker() { + this(DEFAULT_WINDOW_NANOS); + } + + public JigsawStudioTripleSneakTracker(long windowNanos) { + if (windowNanos <= 0L) { + throw new IllegalArgumentException("Jigsaw Studio triple-sneak window must be positive"); + } + this.windowNanos = windowNanos; + } + + public synchronized Progress recordSneak( + UUID playerId, + UUID worldId, + UUID requestId, + long nowNanos + ) { + UUID player = Objects.requireNonNull(playerId, "Jigsaw Studio gesture player ID"); + UUID world = Objects.requireNonNull(worldId, "Jigsaw Studio gesture world ID"); + UUID request = Objects.requireNonNull(requestId, "Jigsaw Studio gesture request ID"); + GestureState previous = gestures.get(player); + if (previous == null + || !previous.worldId().equals(world) + || !previous.requestId().equals(request) + || expired(previous, nowNanos)) { + gestures.put(player, new GestureState(world, request, nowNanos, nowNanos, 1)); + return Progress.FIRST; + } + + int count = previous.count() + 1; + if (count >= REQUIRED_SNEAKS) { + gestures.remove(player); + return Progress.TRIGGERED; + } + gestures.put(player, new GestureState( + world, + request, + previous.startedAtNanos(), + nowNanos, + count)); + return Progress.SECOND; + } + + public synchronized void clearPlayer(UUID playerId) { + if (playerId != null) { + gestures.remove(playerId); + } + } + + public synchronized int clearRequest(UUID requestId) { + if (requestId == null) { + return 0; + } + int removed = 0; + Iterator states = gestures.values().iterator(); + while (states.hasNext()) { + GestureState state = states.next(); + if (state.requestId().equals(requestId)) { + states.remove(); + removed++; + } + } + return removed; + } + + public synchronized void clearAll() { + gestures.clear(); + } + + public synchronized int trackedPlayers() { + return gestures.size(); + } + + private boolean expired(GestureState state, long nowNanos) { + long elapsedSinceStart = nowNanos - state.startedAtNanos(); + long elapsedSinceLast = nowNanos - state.lastSneakAtNanos(); + return elapsedSinceStart < 0L + || elapsedSinceLast < 0L + || elapsedSinceStart > windowNanos; + } + + public enum Progress { + FIRST, + SECOND, + TRIGGERED + } + + private record GestureState( + UUID worldId, + UUID requestId, + long startedAtNanos, + long lastSneakAtNanos, + int count + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariant.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariant.java new file mode 100644 index 000000000..2130d21ea --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariant.java @@ -0,0 +1,141 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.engine.object.IrisPosition; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public record JigsawStudioVariant( + String pieceKey, + String objectKey, + String displayName, + Optional dimensions, + JigsawStudioMode mode, + Optional sourceTopology, + boolean rotatable, + boolean owned, + List themes, + JigsawStudioPieceRules rules, + List memberships +) { + public JigsawStudioVariant { + pieceKey = requireKey(pieceKey, "piece"); + objectKey = requireKey(objectKey, "object"); + displayName = displayName == null ? "" : displayName.trim(); + dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio variant dimensions"); + mode = Objects.requireNonNull(mode, "Jigsaw Studio variant mode"); + sourceTopology = Objects.requireNonNull(sourceTopology, "Jigsaw Studio variant topology"); + themes = List.copyOf(Objects.requireNonNull(themes, "Jigsaw Studio variant themes")); + rules = Objects.requireNonNull(rules, "Jigsaw Studio variant rules"); + memberships = List.copyOf(Objects.requireNonNull(memberships, "Jigsaw Studio pool memberships")); + if (mode == JigsawStudioMode.PLANAR_JIGSAW && sourceTopology.isEmpty()) { + throw new IllegalArgumentException("Planar Jigsaw Studio variants require a topology"); + } + if (mode == JigsawStudioMode.SPATIAL_JIGSAW && sourceTopology.isPresent()) { + throw new IllegalArgumentException("Spatial Jigsaw Studio variants cannot declare a planar topology"); + } + } + + public String resolvedDisplayName() { + if (!displayName.isEmpty()) { + return displayName; + } + int separator = Math.max(pieceKey.lastIndexOf('/'), pieceKey.lastIndexOf(':')); + return separator < 0 ? pieceKey : pieceKey.substring(separator + 1); + } + + public Optional archetype() { + return sourceTopology.map(JigsawPlanarArchetype::fromTopology); + } + + public int sourceToCanonicalQuarterTurns() { + JigsawPlanarTopology topology = sourceTopology.orElse(null); + return topology == null ? 0 : JigsawPlanarArchetype.fromTopology(topology) + .sourceToCanonicalQuarterTurns(topology); + } + + public int canonicalToSourceQuarterTurns() { + JigsawPlanarTopology topology = sourceTopology.orElse(null); + return topology == null ? 0 : JigsawPlanarArchetype.fromTopology(topology) + .canonicalToSourceQuarterTurns(topology); + } + + public IrisPosition sourceToCanonicalPosition( + IrisPosition sourcePosition, + JigsawStudioCellDimensions sourceDimensions + ) { + return rotatePosition( + sourcePosition, + sourceDimensions, + sourceToCanonicalQuarterTurns()); + } + + public IrisPosition canonicalToSourcePosition( + IrisPosition canonicalPosition, + JigsawStudioCellDimensions sourceDimensions + ) { + JigsawStudioCellDimensions canonicalDimensions = canonicalDimensions(sourceDimensions); + return rotatePosition( + canonicalPosition, + canonicalDimensions, + canonicalToSourceQuarterTurns()); + } + + public JigsawStudioCellDimensions canonicalDimensions(JigsawStudioCellDimensions sourceDimensions) { + JigsawStudioCellDimensions dimensions = Objects.requireNonNull( + sourceDimensions, + "Jigsaw Studio source dimensions"); + return Math.floorMod(sourceToCanonicalQuarterTurns(), 2) == 0 + ? dimensions + : new JigsawStudioCellDimensions( + dimensions.depth(), + dimensions.height(), + dimensions.width()); + } + + public boolean assigned() { + return !memberships.isEmpty(); + } + + private static IrisPosition rotatePosition( + IrisPosition position, + JigsawStudioCellDimensions dimensions, + int quarterTurns + ) { + IrisPosition source = Objects.requireNonNull(position, "Jigsaw Studio variant position"); + JigsawStudioCellDimensions bounds = Objects.requireNonNull( + dimensions, + "Jigsaw Studio variant position bounds"); + if (source.getX() < 0 || source.getX() >= bounds.width() + || source.getY() < 0 || source.getY() >= bounds.height() + || source.getZ() < 0 || source.getZ() >= bounds.depth()) { + throw new IllegalArgumentException("Jigsaw Studio variant position is outside its object bounds"); + } + return switch (Math.floorMod(quarterTurns, 4)) { + case 0 -> new IrisPosition(source.getX(), source.getY(), source.getZ()); + case 1 -> new IrisPosition( + bounds.depth() - 1 - source.getZ(), + source.getY(), + source.getX()); + case 2 -> new IrisPosition( + bounds.width() - 1 - source.getX(), + source.getY(), + bounds.depth() - 1 - source.getZ()); + case 3 -> new IrisPosition( + source.getZ(), + source.getY(), + bounds.width() - 1 - source.getX()); + default -> throw new IllegalStateException("Unreachable Jigsaw Studio rotation"); + }; + } + + private static String requireKey(String value, String name) { + Objects.requireNonNull(value, "Jigsaw Studio " + name + " key"); + String normalized = value.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio " + name + " key cannot be blank"); + } + return normalized; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariantCatalog.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariantCatalog.java new file mode 100644 index 000000000..f25979904 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioVariantCatalog.java @@ -0,0 +1,95 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class JigsawStudioVariantCatalog { + private final List variants; + private final Map byPieceKey; + private final Map> byArchetype; + private final List spatialVariants; + private final boolean editableGraph; + + public JigsawStudioVariantCatalog(List variants) { + this(variants, true); + } + + public JigsawStudioVariantCatalog( + List variants, + boolean editableGraph + ) { + Objects.requireNonNull(variants, "Jigsaw Studio variants"); + if (variants.size() > JigsawStudioLayout.MAX_VARIANTS) { + throw new IllegalArgumentException("Jigsaw Studio catalogs cannot exceed " + + JigsawStudioLayout.MAX_VARIANTS + " variants"); + } + List copied = List.copyOf(variants); + Map pieceIndex = new LinkedHashMap<>(); + Map> archetypeIndex = + new EnumMap<>(JigsawPlanarArchetype.class); + List spatial = new ArrayList<>(); + for (JigsawStudioVariant variant : copied) { + JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio variant"); + JigsawStudioVariant previous = pieceIndex.putIfAbsent(activeVariant.pieceKey(), activeVariant); + if (previous != null) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio variant piece key " + + activeVariant.pieceKey()); + } + Optional archetype = activeVariant.archetype(); + if (archetype.isPresent()) { + archetypeIndex.computeIfAbsent(archetype.get(), key -> new ArrayList<>()).add(activeVariant); + } else { + spatial.add(activeVariant); + } + } + Map> immutableArchetypes = + new EnumMap<>(JigsawPlanarArchetype.class); + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + immutableArchetypes.put( + archetype, + List.copyOf(archetypeIndex.getOrDefault(archetype, List.of()))); + } + this.variants = copied; + this.byPieceKey = Collections.unmodifiableMap(pieceIndex); + this.byArchetype = Collections.unmodifiableMap(immutableArchetypes); + this.spatialVariants = List.copyOf(spatial); + this.editableGraph = editableGraph; + } + + public static JigsawStudioVariantCatalog empty() { + return new JigsawStudioVariantCatalog(List.of()); + } + + public List variants() { + return variants; + } + + public Optional find(String pieceKey) { + if (pieceKey == null) { + return Optional.empty(); + } + return Optional.ofNullable(byPieceKey.get(pieceKey)); + } + + public List variants(JigsawPlanarArchetype archetype) { + return byArchetype.get(Objects.requireNonNull(archetype, "Planar archetype")); + } + + public List spatialVariants() { + return spatialVariants; + } + + public boolean editableGraph() { + return editableGraph; + } + + public int size() { + return variants.size(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioWorkcellSpec.java b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioWorkcellSpec.java new file mode 100644 index 000000000..fe0b7f933 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioWorkcellSpec.java @@ -0,0 +1,20 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import java.util.Objects; + +public record JigsawStudioWorkcellSpec( + JigsawPlanarArchetype archetype, + String displayName, + JigsawStudioCellDimensions dimensions, + boolean enabled +) { + public JigsawStudioWorkcellSpec { + archetype = Objects.requireNonNull(archetype, "Jigsaw Studio workcell archetype"); + displayName = displayName == null ? "" : displayName.trim(); + dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio workcell dimensions"); + } + + public String resolvedDisplayName() { + return displayName.isEmpty() ? archetype.displayName() : displayName; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java index 3b07e6423..fe177d1aa 100644 --- a/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/BoardSVC.java @@ -36,13 +36,13 @@ import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.localization.MessageArgument; -import lombok.Data; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; @@ -57,9 +57,15 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; public class BoardSVC implements IrisService, BoardProvider { + private static final String SEPARATOR = "&7&m-------------------"; + private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]"); + private final Map boards = new ConcurrentHashMap<>(); + private final Map jigsawContexts = new ConcurrentHashMap<>(); + private final Map yieldedWorlds = new ConcurrentHashMap<>(); private final Set hiddenPlayers = ConcurrentHashMap.newKeySet(); private volatile BoardSettings settings; private volatile boolean boardEnabled; @@ -111,28 +117,32 @@ public class BoardSVC implements IrisService, BoardProvider { board.cancel(); } boards.clear(); + jigsawContexts.clear(); + yieldedWorlds.clear(); hiddenPlayers.clear(); settings = null; } - @EventHandler + @EventHandler(priority = EventPriority.HIGHEST) public void on(PlayerChangedWorldEvent e) { J.runEntity(e.getPlayer(), () -> updatePlayer(e.getPlayer())); } - @EventHandler + @EventHandler(priority = EventPriority.HIGHEST) public void on(PlayerJoinEvent e) { J.runEntity(e.getPlayer(), () -> updatePlayer(e.getPlayer())); } - @EventHandler + @EventHandler(priority = EventPriority.HIGHEST) public void on(PlayerQuitEvent e) { remove(e.getPlayer()); + jigsawContexts.remove(e.getPlayer().getUniqueId()); + yieldedWorlds.remove(e.getPlayer().getUniqueId()); clearPlayerPreference(e.getPlayer().getUniqueId()); } public void updatePlayer(Player p) { - if (!boardEnabled || settings == null) { + if (p == null || !boardEnabled || settings == null) { return; } @@ -141,12 +151,72 @@ public class BoardSVC implements IrisService, BoardProvider { return; } - if (isEligibleWorld(p)) { - boards.computeIfAbsent(p, PlayerBoard::new); + UUID playerId = p.getUniqueId(); + UUID worldId = p.getWorld().getUID(); + UUID yieldedWorld = yieldedWorlds.get(playerId); + if (yieldedWorld != null) { + if (yieldedWorld.equals(worldId)) { + remove(p); + return; + } + yieldedWorlds.remove(playerId, yieldedWorld); + } + + if (!isEligibleWorld(p)) { + jigsawContexts.remove(playerId); + remove(p); return; } - remove(p); + PlayerBoard playerBoard = boards.computeIfAbsent(p, PlayerBoard::new); + JigsawStudioBoardContext jigsawContext = currentJigsawContext(p); + if (jigsawContext != null) { + playerBoard.showJigsaw(jigsawContext); + } else { + playerBoard.showOrdinary(); + } + } + + public void applyJigsawContext(Player player, JigsawStudioBoardContext context) { + Objects.requireNonNull(player, "Jigsaw Studio board player"); + Objects.requireNonNull(context, "Jigsaw Studio board context"); + if (!J.isOwnedByCurrentRegion(player)) { + J.runEntity(player, () -> applyJigsawContext(player, context)); + return; + } + if (!player.isOnline()) { + return; + } + jigsawContexts.put(player.getUniqueId(), context); + if (context.worldId().equals(player.getWorld().getUID())) { + updatePlayer(player); + } + } + + public void clearJigsawContext(Player player) { + Objects.requireNonNull(player, "Jigsaw Studio board player"); + if (!J.isOwnedByCurrentRegion(player)) { + J.runEntity(player, () -> clearJigsawContext(player)); + return; + } + jigsawContexts.remove(player.getUniqueId()); + updatePlayer(player); + } + + public void refreshOrdinaryContext(Player player) { + Objects.requireNonNull(player, "Studio board player"); + if (!J.isOwnedByCurrentRegion(player)) { + J.runEntity(player, () -> refreshOrdinaryContext(player)); + return; + } + PlayerBoard previousBoard = boards.get(player); + boolean alreadyOrdinary = previousBoard != null && previousBoard.isOrdinary(); + jigsawContexts.remove(player.getUniqueId()); + updatePlayer(player); + PlayerBoard playerBoard = boards.get(player); + if (alreadyOrdinary && playerBoard == previousBoard) { + playerBoard.refreshOrdinary(); + } } private void remove(Player player) { @@ -168,6 +238,9 @@ public class BoardSVC implements IrisService, BoardProvider { public boolean toggle(Player player) { Objects.requireNonNull(player, "player"); boolean visible = togglePlayerBoard(player.getUniqueId()); + if (visible) { + yieldedWorlds.remove(player.getUniqueId()); + } updatePlayer(player); return visible; } @@ -202,6 +275,53 @@ public class BoardSVC implements IrisService, BoardProvider { && generator.getEngine() != null; } + static List jigsawLines(JigsawStudioBoardContext context) { + Objects.requireNonNull(context, "Jigsaw Studio board context"); + List lines = new ArrayList<>(11); + lines.add(SEPARATOR); + lines.add("&dJigsaw Studio"); + lines.add("&bStructure&7: " + untrustedBoardValue(context.structureKey())); + if (!context.insideWorkcell()) { + lines.add("&bMode&7: " + context.modeDisplayName()); + lines.add(SEPARATOR); + lines.add("&eWalk into a workcell"); + if (!context.controlHint().isEmpty()) { + lines.add("&7" + untrustedBoardValue(context.controlHint())); + } + lines.add(SEPARATOR); + return List.copyOf(lines); + } + + lines.add("&bWorkcell&7: " + untrustedBoardValue(context.workcellName())); + if (!context.workcellRole().isEmpty() && !context.workcellRole().equals(context.workcellName())) { + lines.add("&bRole&7: " + untrustedBoardValue(context.workcellRole())); + } + lines.add("&bVariant&7: " + untrustedBoardValue( + context.variantName().isEmpty() ? "None" : context.variantName())); + lines.add("&bState&7: " + context.state().displayName()); + lines.add(SEPARATOR); + if (!context.controlHint().isEmpty()) { + lines.add("&e" + untrustedBoardValue(context.controlHint())); + } + lines.add(SEPARATOR); + return List.copyOf(lines); + } + + static boolean shouldRenderJigsaw( + JigsawStudioBoardContext previous, + JigsawStudioBoardContext next + ) { + return !Objects.equals(previous, next); + } + + static String untrustedBoardValue(String value) { + String normalized = value == null ? "" : value.replace('\n', ' ').replace('\r', ' '); + return LEGACY_COLOR.matcher(normalized).replaceAll("") + .replace("&", "&") + .replace("<", "‹") + .replace(">", "›"); + } + boolean isPlayerBoardEnabled(UUID playerId) { return playerId != null && !hiddenPlayers.contains(playerId); } @@ -222,62 +342,120 @@ public class BoardSVC implements IrisService, BoardProvider { } } - static Scoreboard selectScoreboardToRestore(Scoreboard active, Scoreboard iris, Scoreboard previous) { - return Objects.equals(active, iris) ? previous : active; + private JigsawStudioBoardContext currentJigsawContext(Player player) { + JigsawStudioBoardContext context = jigsawContexts.get(player.getUniqueId()); + if (context == null || !context.worldId().equals(player.getWorld().getUID())) { + return null; + } + return context; } - @Data public class PlayerBoard { private final Player player; private final Board board; - private final Scoreboard previousScoreboard; - private final Scoreboard irisScoreboard; private volatile List lines; + private volatile JigsawStudioBoardContext jigsawContext; + private volatile BoardView view; private volatile boolean cancelled; + private volatile boolean ordinaryTickScheduled; public PlayerBoard(Player player) { this.player = player; - Scoreboard previous = null; - Scoreboard assigned = null; - try { - previous = player.getScoreboard(); - if (Bukkit.getScoreboardManager() != null - && Objects.equals(previous, Bukkit.getScoreboardManager().getMainScoreboard())) { - player.setScoreboard(Bukkit.getScoreboardManager().getNewScoreboard()); - } - assigned = player.getScoreboard(); - } catch (Throwable e) { - IrisLogging.reportError("Failed to prepare the Studio scoreboard for " + player.getName() + ".", e); - } - this.previousScoreboard = previous; - this.irisScoreboard = assigned; this.board = new Board(player, settings); - this.lines = new ArrayList<>(); + this.lines = List.of(); + this.jigsawContext = null; + this.view = BoardView.NONE; this.cancelled = false; - schedule(0); + this.ordinaryTickScheduled = false; } - private void schedule(int delayTicks) { - if (cancelled || !boardEnabled || !player.isOnline()) { + private void showOrdinary() { + if (cancelled) { return; } - J.runEntity(player, this::tick, delayTicks); + if (!board.ownsScoreboardAssignment()) { + yieldBoard(player, this); + return; + } + boolean switched = view != BoardView.ORDINARY; + view = BoardView.ORDINARY; + jigsawContext = null; + if (switched) { + updateOrdinary(); + board.update(); + } + scheduleOrdinaryTick(); } - private void tick() { - if (cancelled || !boardEnabled || !player.isOnline()) { + private void refreshOrdinary() { + if (cancelled || view != BoardView.ORDINARY || !board.ownsScoreboardAssignment()) { return; } + updateOrdinary(); + board.update(); + } + private boolean isOrdinary() { + return !cancelled && view == BoardView.ORDINARY; + } + + private void showJigsaw(JigsawStudioBoardContext context) { + if (cancelled) { + return; + } + if (!board.ownsScoreboardAssignment()) { + yieldBoard(player, this); + return; + } + if (view == BoardView.JIGSAW && !shouldRenderJigsaw(jigsawContext, context)) { + return; + } + view = BoardView.JIGSAW; + jigsawContext = context; + lines = jigsawLines(context); + board.update(); + } + + private void scheduleOrdinaryTick() { + if (ordinaryTickScheduled || cancelled || view != BoardView.ORDINARY + || !boardEnabled || !player.isOnline()) { + return; + } + ordinaryTickScheduled = true; + boolean scheduled = J.runEntity( + player, + () -> { + ordinaryTickScheduled = false; + ordinaryTick(); + }, + 20, + () -> ordinaryTickScheduled = false); + if (!scheduled) { + ordinaryTickScheduled = false; + } + } + + private void ordinaryTick() { + if (cancelled || view != BoardView.ORDINARY || !boardEnabled || !player.isOnline()) { + return; + } if (!isEligibleWorld(player)) { boards.remove(player, this); cancel(); return; } - - update(); + JigsawStudioBoardContext context = currentJigsawContext(player); + if (context != null) { + showJigsaw(context); + return; + } + if (!board.ownsScoreboardAssignment()) { + yieldBoard(player, this); + return; + } + updateOrdinary(); board.update(); - schedule(20); + scheduleOrdinaryTick(); } public void cancel() { @@ -293,30 +471,14 @@ public class BoardSVC implements IrisService, BoardProvider { } private void removeNow() { - Scoreboard activeScoreboard = null; try { - activeScoreboard = player.getScoreboard(); board.remove(); - if (!player.isOnline()) { - return; - } - - Scoreboard restore = selectScoreboardToRestore( - activeScoreboard, - irisScoreboard, - previousScoreboard); - if (restore != null && !Objects.equals(player.getScoreboard(), restore)) { - player.setScoreboard(restore); - } } catch (Throwable e) { IrisLogging.reportError("Failed to remove the Studio scoreboard for " + player.getName() + ".", e); - if (activeScoreboard != null && player.isOnline()) { - player.setScoreboard(activeScoreboard); - } } } - public void update() { + private void updateOrdinary() { World world = player.getWorld(); Location loc = player.getLocation(); @@ -362,4 +524,16 @@ public class BoardSVC implements IrisService, BoardProvider { this.lines = lines; } } + + private void yieldBoard(Player player, PlayerBoard playerBoard) { + yieldedWorlds.put(player.getUniqueId(), player.getWorld().getUID()); + boards.remove(player, playerBoard); + playerBoard.cancel(); + } + + private enum BoardView { + NONE, + ORDINARY, + JIGSAW + } } diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardContext.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardContext.java new file mode 100644 index 000000000..4e04ceb5f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardContext.java @@ -0,0 +1,56 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; + +import java.util.Objects; +import java.util.UUID; + +public record JigsawStudioBoardContext( + UUID worldId, + UUID requestId, + String structureKey, + JigsawStudioMode mode, + String workcellRole, + String workcellName, + String variantName, + JigsawStudioBoardState state, + String controlHint +) { + public JigsawStudioBoardContext { + worldId = Objects.requireNonNull(worldId, "Jigsaw Studio board world ID"); + requestId = Objects.requireNonNull(requestId, "Jigsaw Studio board request ID"); + structureKey = requireText(structureKey, "structure key"); + mode = Objects.requireNonNull(mode, "Jigsaw Studio board mode"); + workcellRole = optionalText(workcellRole); + workcellName = optionalText(workcellName); + variantName = optionalText(variantName); + state = Objects.requireNonNull(state, "Jigsaw Studio board state"); + controlHint = optionalText(controlHint); + if (workcellName.isEmpty() && (!workcellRole.isEmpty() || !variantName.isEmpty())) { + throw new IllegalArgumentException("Jigsaw Studio board variants require a workcell"); + } + } + + public boolean insideWorkcell() { + return !workcellName.isEmpty(); + } + + public String modeDisplayName() { + return switch (mode) { + case PLANAR_JIGSAW -> "Planar"; + case SPATIAL_JIGSAW -> "Spatial"; + }; + } + + private static String requireText(String value, String name) { + String normalized = optionalText(value); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio board " + name + " cannot be blank"); + } + return normalized; + } + + private static String optionalText(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardState.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardState.java new file mode 100644 index 000000000..b37852ecd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioBoardState.java @@ -0,0 +1,21 @@ +package art.arcane.iris.core.service; + +public enum JigsawStudioBoardState { + LOADING("Loading"), + SAVED("Saved"), + UNSAVED("Unsaved"), + SAVING("Saving"), + DISABLED("Disabled"), + INVALID("Invalid"), + READ_ONLY("Read-only"); + + private final String displayName; + + JigsawStudioBoardState(String displayName) { + this.displayName = displayName; + } + + public String displayName() { + return displayName; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRenderer.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRenderer.java new file mode 100644 index 000000000..85e9e416c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRenderer.java @@ -0,0 +1,272 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.BlockDisplay; +import org.bukkit.entity.Display; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +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.UUID; + +public final class JigsawStudioDisabledWorkcellRenderer { + private static final String ENTITY_TAG = "iris_jigsaw_disabled_workcell"; + + private final Map requests = new HashMap<>(); + + public void reconcile(World world, UUID requestId, JigsawStudioLayout layout) { + World activeWorld = Objects.requireNonNull(world, "Jigsaw Studio display world"); + UUID activeRequestId = Objects.requireNonNull(requestId, "Jigsaw Studio display request ID"); + Map desired = descriptors(Objects.requireNonNull( + layout, + "Jigsaw Studio display layout")); + List removals = new ArrayList<>(); + long generation; + synchronized (this) { + RequestDisplays state = requests.computeIfAbsent( + activeRequestId, + ignored -> new RequestDisplays(activeWorld.getUID())); + if (!state.worldId.equals(activeWorld.getUID())) { + removals.addAll(state.entities.values()); + state = new RequestDisplays(activeWorld.getUID()); + requests.put(activeRequestId, state); + } + generation = Math.incrementExact(state.generation); + state.generation = generation; + state.desired.clear(); + state.desired.putAll(desired); + for (Map.Entry entry : new ArrayList<>(state.entities.entrySet())) { + Descriptor descriptor = desired.get(entry.getKey()); + if (descriptor == null || !descriptor.equals(state.rendered.get(entry.getKey()))) { + state.entities.remove(entry.getKey()); + state.rendered.remove(entry.getKey()); + removals.add(entry.getValue()); + } + } + } + remove(removals); + for (Descriptor descriptor : desired.values()) { + scheduleSpawn(activeWorld, activeRequestId, generation, descriptor); + } + } + + public void unloadChunk(UUID requestId, int chunkX, int chunkZ) { + if (requestId == null) { + return; + } + List removals; + synchronized (this) { + RequestDisplays state = requests.get(requestId); + if (state == null) { + return; + } + removals = detachChunkDisplays(state.entities, state.rendered, chunkX, chunkZ); + } + remove(removals); + } + + public void removeRequest(UUID requestId) { + if (requestId == null) { + return; + } + RequestDisplays removed; + synchronized (this) { + removed = requests.remove(requestId); + } + if (removed != null) { + remove(new ArrayList<>(removed.entities.values())); + } + } + + public void removeAll() { + List removals = new ArrayList<>(); + synchronized (this) { + for (RequestDisplays state : requests.values()) { + removals.addAll(state.entities.values()); + } + requests.clear(); + } + remove(removals); + } + + static Map descriptors(JigsawStudioLayout layout) { + Map descriptors = new LinkedHashMap<>(); + for (JigsawStudioBay bay : layout.bays()) { + if (bay.enabled()) { + continue; + } + JigsawStudioBounds bounds = bay.bounds(); + descriptors.put(bay.stableId(), new Descriptor( + bay.stableId(), + bounds.originX(), + bounds.originY(), + bounds.originZ(), + bounds.dimensions().width(), + bounds.dimensions().height(), + bounds.dimensions().depth())); + } + return Map.copyOf(descriptors); + } + + synchronized int activeDisplayCount(UUID requestId) { + RequestDisplays state = requests.get(requestId); + return state == null ? 0 : state.entities.size(); + } + + static List detachChunkDisplays( + Map entities, + Map rendered, + int chunkX, + int chunkZ + ) { + List removals = new ArrayList<>(); + for (Map.Entry entry : new ArrayList<>(entities.entrySet())) { + Descriptor descriptor = rendered.get(entry.getKey()); + if (descriptor == null + || descriptor.originX() >> 4 != chunkX + || descriptor.originZ() >> 4 != chunkZ) { + continue; + } + entities.remove(entry.getKey()); + rendered.remove(entry.getKey()); + removals.add(entry.getValue()); + } + return List.copyOf(removals); + } + + private void scheduleSpawn( + World world, + UUID requestId, + long generation, + Descriptor descriptor + ) { + synchronized (this) { + RequestDisplays state = requests.get(requestId); + if (state == null + || state.generation != generation + || state.entities.containsKey(descriptor.workcellId()) + || !descriptor.equals(state.desired.get(descriptor.workcellId()))) { + return; + } + } + J.runRegion( + world, + descriptor.originX() >> 4, + descriptor.originZ() >> 4, + () -> spawn(world, requestId, generation, descriptor)); + } + + private void spawn( + World world, + UUID requestId, + long generation, + Descriptor descriptor + ) { + if (!world.isChunkLoaded(descriptor.originX() >> 4, descriptor.originZ() >> 4)) { + return; + } + synchronized (this) { + RequestDisplays state = requests.get(requestId); + if (state == null + || state.generation != generation + || state.entities.containsKey(descriptor.workcellId()) + || !descriptor.equals(state.desired.get(descriptor.workcellId()))) { + return; + } + } + BlockDisplay display = world.spawn( + new Location(world, descriptor.originX(), descriptor.originY(), descriptor.originZ()), + BlockDisplay.class, + entity -> configure(entity, descriptor)); + boolean retained; + synchronized (this) { + RequestDisplays state = requests.get(requestId); + retained = state != null + && state.generation == generation + && !state.entities.containsKey(descriptor.workcellId()) + && descriptor.equals(state.desired.get(descriptor.workcellId())); + if (retained) { + state.entities.put(descriptor.workcellId(), display); + state.rendered.put(descriptor.workcellId(), descriptor); + } + } + if (!retained) { + remove(display); + } + } + + private static void configure(BlockDisplay display, Descriptor descriptor) { + display.setBlock(Material.RED_STAINED_GLASS.createBlockData()); + display.setTransformation(new Transformation( + new Vector3f(), + new Quaternionf(), + new Vector3f(descriptor.width(), descriptor.height(), descriptor.depth()), + new Quaternionf())); + display.setBrightness(new Display.Brightness(15, 15)); + display.setDisplayWidth(Math.max(descriptor.width(), descriptor.depth())); + display.setDisplayHeight(descriptor.height()); + display.setViewRange(128.0F); + display.setShadowRadius(0.0F); + display.setShadowStrength(0.0F); + display.setInterpolationDuration(0); + display.setTeleportDuration(0); + display.setPersistent(false); + display.setInvulnerable(true); + display.setGravity(false); + display.setSilent(true); + display.addScoreboardTag(ENTITY_TAG); + } + + private static void remove(List displays) { + for (BlockDisplay display : displays) { + remove(display); + } + } + + private static void remove(BlockDisplay display) { + if (display != null) { + J.runEntity(display, display::remove); + } + } + + record Descriptor( + String workcellId, + int originX, + int originY, + int originZ, + int width, + int height, + int depth + ) { + Descriptor { + workcellId = Objects.requireNonNull(workcellId, "Jigsaw Studio display workcell ID"); + if (width < 1 || height < 1 || depth < 1) { + throw new IllegalArgumentException("Jigsaw Studio display dimensions must be positive"); + } + } + } + + private static final class RequestDisplays { + private final UUID worldId; + private final Map desired = new HashMap<>(); + private final Map rendered = new HashMap<>(); + private final Map entities = new HashMap<>(); + private long generation; + + private RequestDisplays(UUID worldId) { + this.worldId = worldId; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioEvaluationState.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioEvaluationState.java new file mode 100644 index 000000000..66bebb410 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioEvaluationState.java @@ -0,0 +1,9 @@ +package art.arcane.iris.core.service; + +public enum JigsawStudioEvaluationState { + PENDING, + VALID, + WARNING, + INVALID, + STALE +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioGraphEvaluation.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioGraphEvaluation.java new file mode 100644 index 000000000..6405c0555 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioGraphEvaluation.java @@ -0,0 +1,50 @@ +package art.arcane.iris.core.service; + +import java.util.Objects; +import java.util.UUID; + +public record JigsawStudioGraphEvaluation( + UUID requestId, + long generation, + long seed, + JigsawStudioEvaluationState state, + String selectedTheme, + int pieceCount, + String detail, + JigsawStudioPreviewRenderer.PreviewBounds previewBounds +) { + public JigsawStudioGraphEvaluation { + requestId = Objects.requireNonNull(requestId, "Jigsaw Studio evaluation request ID"); + if (generation < 1L) { + throw new IllegalArgumentException("Jigsaw Studio evaluation generation must be positive"); + } + state = Objects.requireNonNull(state, "Jigsaw Studio evaluation state"); + selectedTheme = normalize(selectedTheme); + if (pieceCount < 0) { + throw new IllegalArgumentException("Jigsaw Studio evaluation piece count cannot be negative"); + } + detail = normalize(detail); + previewBounds = Objects.requireNonNull(previewBounds, "Jigsaw Studio evaluation preview bounds"); + } + + public JigsawStudioGraphEvaluation stale(String reason) { + return new JigsawStudioGraphEvaluation( + requestId, + generation, + seed, + JigsawStudioEvaluationState.STALE, + selectedTheme, + pieceCount, + reason, + previewBounds); + } + + public boolean successful() { + return state == JigsawStudioEvaluationState.VALID + || state == JigsawStudioEvaluationState.WARNING; + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMarkerParser.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMarkerParser.java new file mode 100644 index 000000000..fd2c64b20 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMarkerParser.java @@ -0,0 +1,105 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMarkerKeyCodec; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.JigsawJoint; +import org.bukkit.block.Orientation; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +final class JigsawStudioMarkerParser { + private JigsawStudioMarkerParser() { + } + + static IrisJigsawConnector parse( + Map nbt, + Orientation orientation, + int x, + int y, + int z + ) { + Map properties = Objects.requireNonNull(nbt, "Jigsaw marker NBT"); + Directions directions = directions(orientation); + String jointName = requiredString(properties, "joint").toUpperCase(Locale.ROOT); + JigsawJoint joint; + try { + joint = JigsawJoint.valueOf(jointName); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Unsupported jigsaw joint '" + jointName + "'", exception); + } + return new IrisJigsawConnector() + .setPosition(new IrisPosition(x, y, z)) + .setDirection(directions.front()) + .setTop(directions.top()) + .setPool(JigsawStudioMarkerKeyCodec.decodePool(requiredString(properties, "pool"))) + .setName(requiredString(properties, "name")) + .setTargetName(requiredString(properties, "target")) + .setChannel(optionalString(properties, "channel")) + .setJoint(joint) + .setFinalState(requiredString(properties, "final_state")) + .setSelectionPriority(optionalInt(properties, "selection_priority")) + .setPlacementPriority(optionalInt(properties, "placement_priority")); + } + + static Directions directions(Orientation orientation) { + return switch (Objects.requireNonNull(orientation, "Jigsaw orientation")) { + case DOWN_EAST -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.EAST_POSITIVE_X); + case DOWN_NORTH -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.NORTH_NEGATIVE_Z); + case DOWN_SOUTH -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.SOUTH_POSITIVE_Z); + case DOWN_WEST -> new Directions(IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.WEST_NEGATIVE_X); + case UP_EAST -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.EAST_POSITIVE_X); + case UP_NORTH -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.NORTH_NEGATIVE_Z); + case UP_SOUTH -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.SOUTH_POSITIVE_Z); + case UP_WEST -> new Directions(IrisDirection.UP_POSITIVE_Y, IrisDirection.WEST_NEGATIVE_X); + case WEST_UP -> new Directions(IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y); + case EAST_UP -> new Directions(IrisDirection.EAST_POSITIVE_X, IrisDirection.UP_POSITIVE_Y); + case NORTH_UP -> new Directions(IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.UP_POSITIVE_Y); + case SOUTH_UP -> new Directions(IrisDirection.SOUTH_POSITIVE_Z, IrisDirection.UP_POSITIVE_Y); + }; + } + + private static String requiredString(Map properties, String key) { + Object value = properties.get(key); + if (!(value instanceof String stringValue) || stringValue.isBlank()) { + throw new IllegalArgumentException("Jigsaw marker requires non-empty string NBT '" + key + "'"); + } + return stringValue.trim(); + } + + private static String optionalString(Map properties, String key) { + Object value = properties.get(key); + if (value == null) { + return ""; + } + if (!(value instanceof String stringValue)) { + throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' must be a string"); + } + return stringValue.trim(); + } + + private static int optionalInt(Map properties, String key) { + Object value = properties.get(key); + if (value == null) { + return 0; + } + if (!(value instanceof Number number)) { + throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' must be an integer"); + } + long longValue = number.longValue(); + if (longValue < Integer.MIN_VALUE || longValue > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Jigsaw marker NBT '" + key + "' is outside the integer range"); + } + return (int) longValue; + } + + record Directions(IrisDirection front, IrisDirection top) { + Directions { + Objects.requireNonNull(front, "Jigsaw front direction"); + Objects.requireNonNull(top, "Jigsaw top direction"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java new file mode 100644 index 000000000..17c8ee5b0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuController.java @@ -0,0 +1,3058 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.data.MaterialBlock; +import art.arcane.volmlib.util.inventorygui.UIElement; +import art.arcane.volmlib.util.inventorygui.UIPaneDecorator; +import art.arcane.volmlib.util.inventorygui.UIWindow; +import art.arcane.volmlib.util.inventorygui.WindowResolution; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class JigsawStudioMenuController { + static final int VARIANTS_PER_PAGE = 28; + static final int MEMBERSHIPS_PER_PAGE = 28; + static final int THEME_SETS_PER_PAGE = 21; + static final int TOOLS_PER_PAGE = 28; + static final int CHANCE_STEP_PERCENTAGE_POINTS = 5; + static final int RULE_SHIFT_STEP = 5; + static final int PLACEMENT_RULE_SHIFT_STEP = 16; + + private static final long DESTRUCTIVE_CONFIRM_NANOS = 10_000_000_000L; + private static final int[] GRID_POSITIONS = {-3, -2, -1, 0, 1, 2, 3}; + + private final JavaPlugin plugin; + private final Actions actions; + private final Map windows = new ConcurrentHashMap<>(); + private final Map pendingUnlinks = new ConcurrentHashMap<>(); + private final Map pendingDeletes = new ConcurrentHashMap<>(); + private final Map pendingProjectDeletes = new ConcurrentHashMap<>(); + + public JigsawStudioMenuController(JavaPlugin plugin, Actions actions) { + this.plugin = Objects.requireNonNull(plugin, "Jigsaw Studio menu plugin"); + this.actions = Objects.requireNonNull(actions, "Jigsaw Studio menu actions"); + } + + public boolean open(Player player) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> open(player)); + } + + Optional available = actions.menuState(player); + if (available.isEmpty()) { + close(player); + player.sendMessage(ChatColor.RED + "Iris Jigsaw Studio is not active in this world."); + return false; + } + JigsawStudioMenuState state = available.get(); + if (state.workcells().isEmpty()) { + close(player); + player.sendMessage(ChatColor.RED + "This Jigsaw Studio has no workcells."); + return false; + } + + JigsawStudioMenuState.Workcell selected = state.selectedWorkcell(); + if (selected == null) { + String firstWorkcellId = state.workcells().getFirst().stableId(); + if (!actions.selectWorkcell(player, firstWorkcellId)) { + return false; + } + Optional selectedState = matchingState(player, state.requestId(), false); + if (selectedState.isEmpty()) { + return false; + } + state = selectedState.get(); + selected = state.selectedWorkcell(); + if (selected == null) { + return false; + } + } + + close(player); + UUID playerId = player.getUniqueId(); + UIWindow window = new UIWindow(plugin, player); + window.setResolution(WindowResolution.W9_H6); + window.setViewportHeight(6); + window.setDecorator(new UIPaneDecorator(Material.BLACK_STAINED_GLASS_PANE)); + window.setTitle(title(state.structureKey())); + window.onClosed(closed -> { + windows.remove(playerId, window); + pendingUnlinks.remove(playerId); + pendingDeletes.remove(playerId); + pendingProjectDeletes.remove(playerId); + }); + windows.put(playerId, window); + pendingUnlinks.remove(playerId); + pendingDeletes.remove(playerId); + pendingProjectDeletes.remove(playerId); + renderMain(window, state, selected, 0); + window.open(); + return true; + } + + public boolean openWorkcellSettings(Player player, String workcellId) { + if (player == null || workcellId == null || workcellId.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> openWorkcellSettings(player, workcellId)); + } + if (!open(player)) { + return false; + } + Optional current = actions.menuState(player); + if (current.isEmpty() || current.get().workcell(workcellId) == null) { + stale(player); + return false; + } + openWorkcellSettings(player, current.get().requestId(), workcellId); + return true; + } + + public boolean openVariantSettings(Player player, String workcellId, String pieceKey) { + if (player == null + || workcellId == null + || workcellId.isBlank() + || pieceKey == null + || pieceKey.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> openVariantSettings(player, workcellId, pieceKey)); + } + if (!open(player)) { + return false; + } + Optional current = actions.menuState(player); + if (current.isEmpty()) { + stale(player); + return false; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + if (variant == null || !variant.active() || !variant.owned()) { + stale(player); + return false; + } + if (!current.get().irisExtended()) { + player.sendMessage(ChatColor.YELLOW + + "Vanilla-portable pieces cannot encode Iris theme or piece-rule metadata."); + close(player); + return false; + } + openVariantSettings(player, current.get().requestId(), workcellId, pieceKey, 0); + return true; + } + + public boolean openVariantSizeSettings(Player player, String workcellId, String pieceKey) { + if (player == null || workcellId == null || workcellId.isBlank() + || pieceKey == null || pieceKey.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> openVariantSizeSettings(player, workcellId, pieceKey)); + } + if (!open(player)) { + return false; + } + Optional current = actions.menuState(player); + if (current.isEmpty()) { + stale(player); + return false; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + if (workcell == null || variant == null || !variant.owned() || variant.dimensions().isEmpty()) { + stale(player); + return false; + } + UIWindow window = windows.get(player.getUniqueId()); + if (window == null) { + return false; + } + renderVariantSizeSettings(window, current.get(), workcell, variant); + return true; + } + + public boolean openStructureSettings(Player player) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> openStructureSettings(player)); + } + if (!open(player)) { + return false; + } + Optional current = actions.menuState(player); + if (current.isEmpty() || current.get().selectedWorkcell() == null) { + stale(player); + return false; + } + openStructureSettings( + player, + current.get().requestId(), + current.get().selectedWorkcellId(), + 0); + return true; + } + + public void close(Player player) { + if (player == null) { + return; + } + UUID playerId = player.getUniqueId(); + UIWindow window = windows.remove(playerId); + pendingUnlinks.remove(playerId); + pendingDeletes.remove(playerId); + pendingProjectDeletes.remove(playerId); + if (window == null) { + return; + } + if (J.isOwnedByCurrentRegion(player)) { + window.close(); + return; + } + J.runEntity(player, window::close); + } + + public void closeAll() { + List activeWindows = new ArrayList<>(windows.values()); + windows.clear(); + pendingUnlinks.clear(); + pendingDeletes.clear(); + pendingProjectDeletes.clear(); + for (UIWindow window : activeWindows) { + Player player = window.getViewer(); + if (J.isOwnedByCurrentRegion(player)) { + window.close(); + } else { + J.runEntity(player, window::close); + } + } + } + + private void renderMain( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell selected, + int requestedPage + ) { + int page = clampPage(requestedPage, selected.variants().size(), VARIANTS_PER_PAGE); + window.batch(() -> { + window.clearElements(); + addWorkcells(window, state, selected.stableId()); + addFamilyControl(window, state); + addStructureControl(window, state, selected.stableId()); + addVariants(window, state, selected, page); + addMainActions(window, state, selected, page); + }); + } + + private void addFamilyControl(UIWindow window, JigsawStudioMenuState state) { + String nextThemeKey = nextThemeSetKey(state.themeSets()); + boolean available = state.irisExtended() && state.workcells().stream() + .filter(JigsawStudioMenuState.Workcell::enabled) + .allMatch(workcell -> workcell.activeVariant() != null && workcell.activeVariant().owned()); + UIElement family = element( + "duplicate-family", + available ? Material.PURPLE_DYE : Material.GRAY_DYE, + available + ? ChatColor.LIGHT_PURPLE + "Duplicate All Enabled Cells as Family" + : ChatColor.GRAY + "Duplicate All Enabled Cells as Family"); + family.addLore(ChatColor.GRAY + "Copies each enabled cell's loaded variant into one coherent family."); + family.addLore(ChatColor.GRAY + "New family: " + safe(nextThemeKey)); + if (available) { + family.addLore(ChatColor.YELLOW + "Left-click to duplicate the complete family atomically"); + family.onLeftClick(clicked -> duplicateActiveFamily( + window.getViewer(), state.requestId(), nextThemeKey)); + } else if (!state.irisExtended()) { + family.addLore(ChatColor.GRAY + "Coherent families require Iris compatibility."); + } else { + family.addLore(ChatColor.GRAY + "Load an owned variant in every enabled workcell first."); + } + window.setElement(3, 0, family); + } + + private void addStructureControl( + UIWindow window, + JigsawStudioMenuState state, + String selectedWorkcellId + ) { + UIElement structure = element( + "structure-rules", + state.irisExtended() + ? state.requireCaps() ? Material.IRON_BARS : Material.TRIPWIRE_HOOK + : Material.BARRIER, + ChatColor.LIGHT_PURPLE + "Structure Rules"); + structure.addLore(ChatColor.GRAY + "Mandatory caps: " + yesNo(state.requireCaps())); + structure.addLore(ChatColor.GRAY + "Theme sets: " + state.themeSets().size()); + structure.addLore(ChatColor.GRAY + "Compatibility: " + compatibilityName(state.compatibilityTarget())); + structure.addLore(ChatColor.YELLOW + "Left-click for themes and rules"); + structure.addLore(state.irisExtended() + ? ChatColor.YELLOW + "Right-click to toggle mandatory caps" + : ChatColor.GRAY + "Mandatory caps and themes require Iris compatibility"); + structure.onLeftClick(clicked -> openStructureSettings( + window.getViewer(), state.requestId(), selectedWorkcellId, 0)); + if (state.irisExtended()) { + structure.onRightClick(clicked -> setRequireCaps( + window.getViewer(), state.requestId(), !state.requireCaps())); + } + window.setElement(4, 0, structure); + } + + private void addWorkcells(UIWindow window, JigsawStudioMenuState state, String selectedWorkcellId) { + int count = state.workcells().size(); + int start = count == 1 ? 0 : -(count / 2); + for (int index = 0; index < count; index++) { + JigsawStudioMenuState.Workcell workcell = state.workcells().get(index); + boolean selected = workcell.stableId().equals(selectedWorkcellId); + UIElement element = element( + "workcell-" + index, + workcellMaterial(workcell, selected), + (selected ? ChatColor.AQUA : workcell.enabled() ? ChatColor.WHITE : ChatColor.GRAY) + + safe(workcell.displayName())) + .setEnchanted(selected); + element.addLore(workcell.enabled() + ? ChatColor.GREEN + "Enabled" + : ChatColor.RED + "Disabled for assembly and export"); + if (!workcell.canonicalName().equals(workcell.displayName())) { + element.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName())); + } + element.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity())); + element.addLore(ChatColor.GRAY + "Variants: " + workcell.variants().size()); + JigsawStudioMenuState.Variant active = workcell.activeVariant(); + element.addLore(ChatColor.GRAY + "Loaded: " + + (active == null ? "None" : safe(active.displayName()))); + element.addLore(workcellStatus(workcell)); + element.addLore(ChatColor.YELLOW + "Left-click to select"); + element.addLore(ChatColor.YELLOW + "Right-click for workcell settings"); + if (workcell.dirty() && !workcell.saving()) { + element.addLore(ChatColor.GOLD + "Shift-left: Flush Autosave Now"); + element.onShiftLeftClick(clicked -> flushNow( + window.getViewer(), state.requestId(), workcell.stableId())); + } + element.onLeftClick(clicked -> selectWorkcell( + window.getViewer(), + state.requestId(), + workcell.stableId())); + element.onRightClick(clicked -> J.runEntity( + window.getViewer(), + () -> openWorkcellSettings( + window.getViewer(), + state.requestId(), + workcell.stableId()), + 1)); + window.setElement(start + index, 0, element); + } + } + + private void addVariants( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + int page + ) { + List variants = page( + workcell.variants(), page, VARIANTS_PER_PAGE); + if (variants.isEmpty()) { + UIElement empty = element("no-variants", Material.BARRIER, ChatColor.RED + "No variants"); + empty.addLore(ChatColor.GRAY + "Create a variant to begin authoring this workcell."); + window.setElement(0, 2, empty); + return; + } + + for (int index = 0; index < variants.size(); index++) { + JigsawStudioMenuState.Variant variant = variants.get(index); + int position = GRID_POSITIONS[index % GRID_POSITIONS.length]; + int row = 1 + index / GRID_POSITIONS.length; + UIElement element = element( + "variant-" + index, + variantMaterial(variant), + (variant.active() ? ChatColor.GREEN : ChatColor.WHITE) + + safe(variant.displayName()) + + (variant.active() ? ChatColor.AQUA + " [Loaded]" : "")) + .setEnchanted(variant.active()); + element.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey())); + element.addLore(variant.owned() + ? ChatColor.GREEN + "Owned" + : ChatColor.GRAY + "Read-only"); + element.addLore(ChatColor.GRAY + "Rotation: " + (variant.rotatable() ? "Enabled" : "Disabled")); + element.addLore(ChatColor.GRAY + "Size: " + variant.dimensions() + .map(JigsawStudioMenuController::dimensions) + .orElse("Object missing")); + element.addLore(ChatColor.GRAY + "Themes: " + themes(variant.themes())); + element.addLore(ChatColor.GRAY + "Pool entries: " + variant.memberships().size()); + element.addLore(variant.active() + ? ChatColor.YELLOW + "Loaded; right-click for details" + : ChatColor.YELLOW + "Left-click to load"); + if (!variant.active()) { + element.addLore(ChatColor.YELLOW + "Right-click for details or deletion"); + } + element.onLeftClick(clicked -> switchVariant( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey())); + element.onRightClick(clicked -> J.runEntity( + window.getViewer(), + () -> openDetails( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + 0), + 1)); + window.setElement(position, row, element); + } + } + + private void addMainActions( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + int page + ) { + UIElement create = element("create", Material.NETHER_STAR, ChatColor.GREEN + "New Blank Variant"); + create.addLore(ChatColor.GRAY + "Create an empty owned variant with this cell's current size."); + create.onLeftClick(clicked -> createVariant( + window.getViewer(), state.requestId(), workcell.stableId(), false)); + window.setElement(-4, 5, create); + + JigsawStudioMenuState.Variant active = workcell.activeVariant(); + boolean duplicateAvailable = active != null && active.owned(); + UIElement duplicate = element( + "duplicate", + duplicateAvailable ? Material.PAPER : Material.GRAY_DYE, + duplicateAvailable + ? ChatColor.AQUA + "Duplicate This Cell's Variant" + : active == null + ? ChatColor.GRAY + "Duplicate This Cell's Variant" + : ChatColor.GRAY + "Duplicate This Cell's Variant"); + duplicate.addLore(duplicateAvailable + ? ChatColor.GRAY + "Clone the loaded variant into an owned variant." + : active == null + ? ChatColor.GRAY + "Load a variant before duplicating it." + : ChatColor.GRAY + "Adopt the read-only graph before duplicating it."); + if (duplicateAvailable) { + duplicate.onLeftClick(clicked -> createVariant( + window.getViewer(), state.requestId(), workcell.stableId(), true)); + } + window.setElement(-3, 5, duplicate); + + UIElement settings = element("settings", Material.CRAFTING_TABLE, ChatColor.GOLD + "Workcell Settings"); + settings.addLore(ChatColor.GRAY + "Enabled: " + yesNo(workcell.enabled())); + settings.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity())); + settings.onLeftClick(clicked -> openWorkcellSettings( + window.getViewer(), state.requestId(), workcell.stableId())); + window.setElement(-2, 5, settings); + + int pageCount = pageCount(workcell.variants().size(), VARIANTS_PER_PAGE); + if (page > 0) { + UIElement previous = element("previous", Material.ARROW, ChatColor.YELLOW + "Previous Page"); + previous.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), workcell.stableId(), page - 1)); + window.setElement(-1, 5, previous); + } + + UIElement pageIndicator = element("page", Material.BOOK, ChatColor.WHITE + "Page " + + (page + 1) + "/" + pageCount); + pageIndicator.addLore(ChatColor.GRAY + "" + workcell.variants().size() + " variants"); + window.setElement(0, 5, pageIndicator); + + if (page + 1 < pageCount) { + UIElement next = element("next", Material.ARROW, ChatColor.YELLOW + "Next Page"); + next.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), workcell.stableId(), page + 1)); + window.setElement(1, 5, next); + } + + window.setElement(2, 5, evaluationElement(state.evaluation())); + + UIElement preview = element("preview", Material.ENDER_EYE, ChatColor.LIGHT_PURPLE + "Go to Preview"); + preview.addLore(ChatColor.GRAY + "Open the deterministic assembly preview."); + preview.onLeftClick(clicked -> goToPreview(window.getViewer(), state.requestId())); + window.setElement(3, 5, preview); + + UIElement toolbox = element("toolbox", Material.STICK, ChatColor.AQUA + "Toolbox"); + toolbox.addLore(ChatColor.GRAY + "Take named sticks bound to this Studio session."); + toolbox.onLeftClick(clicked -> openToolbox( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(4, 5, toolbox); + } + + private void renderWorkcellSettings( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell + ) { + window.batch(() -> { + window.clearElements(); + + UIElement back = element("settings-back", Material.ARROW, ChatColor.YELLOW + "Back to Variants"); + back.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(-4, 0, back); + + UIElement identity = element( + "settings-identity", + workcell.enabled() ? Material.LIME_WOOL : Material.GRAY_WOOL, + ChatColor.AQUA + safe(workcell.displayName())); + identity.addLore(ChatColor.DARK_GRAY + safe(workcell.stableId())); + identity.addLore(workcell.enabled() + ? ChatColor.GREEN + "Enabled" + : ChatColor.RED + "Disabled for assembly and export"); + if (!workcell.canonicalName().equals(workcell.displayName())) { + identity.addLore(ChatColor.GRAY + "Solver role: " + safe(workcell.canonicalName())); + } + identity.addLore(ChatColor.GRAY + "Capacity: " + dimensions(workcell.capacity())); + identity.addLore(ChatColor.YELLOW + "Left-click for a rename stick"); + identity.addLore(ChatColor.GRAY + "Rename that stick in an anvil, then right-click it."); + identity.addLore(ChatColor.GRAY + "Sneak-right-click the stick to reset this label."); + identity.onLeftClick(clicked -> actions.giveTool( + window.getViewer(), + JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.RENAME_WORKCELL, + state.requestId(), + workcell.stableId()))); + window.setElement(0, 0, identity); + window.setElement(4, 0, evaluationElement(state.evaluation())); + + if (state.mode() == JigsawStudioMode.PLANAR_JIGSAW) { + UIElement enabled = element( + "settings-enabled", + workcell.enabled() ? Material.LEVER : Material.REDSTONE_TORCH, + workcell.enabled() + ? ChatColor.GREEN + "Workcell Enabled" + : ChatColor.RED + "Workcell Disabled"); + enabled.addLore(ChatColor.GRAY + "Disabled workcells remain editable and keep their size."); + enabled.addLore(ChatColor.YELLOW + "Left-click to " + + (workcell.enabled() ? "disable" : "enable")); + enabled.onLeftClick(clicked -> setWorkcellEnabled( + window.getViewer(), + state.requestId(), + workcell.stableId(), + !workcell.enabled())); + window.setElement(0, 1, enabled); + } else { + UIElement spatial = element( + "spatial-bounds", + Material.SCAFFOLDING, + ChatColor.AQUA + "Spatial Workcell Bounds"); + spatial.addLore(ChatColor.GRAY + "Spatial project bounds are shared by the project."); + spatial.addLore(ChatColor.GRAY + "Changing them regenerates the Studio layout."); + window.setElement(0, 1, spatial); + } + + window.setElement(-2, 2, axisElement( + window, + state, + workcell, + DimensionAxis.WIDTH, + Material.IRON_INGOT)); + window.setElement(0, 2, axisElement( + window, + state, + workcell, + DimensionAxis.HEIGHT, + Material.GOLD_INGOT)); + window.setElement(2, 2, axisElement( + window, + state, + workcell, + DimensionAxis.DEPTH, + Material.COPPER_INGOT)); + + UIElement footerBack = element("settings-footer-back", Material.ARROW, ChatColor.YELLOW + "Back"); + footerBack.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(-4, 5, footerBack); + + if (workcell.dirty() && !workcell.saving()) { + UIElement saveNow = element( + "save-now", + Material.EMERALD, + ChatColor.GOLD + "Flush Autosave Now"); + saveNow.addLore(ChatColor.GRAY + "Autosave is automatic."); + saveNow.addLore(ChatColor.GRAY + "Use this only to flush pending work or recover immediately."); + saveNow.onLeftClick(clicked -> flushNow( + window.getViewer(), state.requestId(), workcell.stableId())); + window.setElement(2, 5, saveNow); + } + + UIElement preview = element( + "settings-preview", + Material.ENDER_EYE, + ChatColor.LIGHT_PURPLE + "Go to Preview"); + preview.onLeftClick(clicked -> goToPreview(window.getViewer(), state.requestId())); + window.setElement(3, 5, preview); + + UIElement toolbox = element("settings-toolbox", Material.STICK, ChatColor.AQUA + "Toolbox"); + toolbox.onLeftClick(clicked -> openToolbox( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(4, 5, toolbox); + }); + } + + private void renderVariantSizeSettings( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant + ) { + JigsawStudioCellDimensions current = variant.dimensions().orElseThrow(); + window.batch(() -> { + window.clearElements(); + + UIElement back = element("variant-size-back", Material.ARROW, ChatColor.YELLOW + "Back to Details"); + back.onLeftClick(clicked -> openDetails( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0)); + window.setElement(-4, 0, back); + + UIElement identity = element( + "variant-size-identity", + Material.SCAFFOLDING, + ChatColor.AQUA + safe(variant.displayName()) + " Size"); + identity.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey())); + identity.addLore(ChatColor.WHITE + "Current: " + dimensions(current)); + identity.addLore(ChatColor.GRAY + "Workcell capacity: " + dimensions(workcell.capacity())); + identity.addLore(ChatColor.GRAY + "Only this variant object and its edge connectors change."); + window.setElement(0, 0, identity); + window.setElement(4, 0, evaluationElement(state.evaluation())); + + window.setElement(-2, 2, variantAxisElement( + window, state, workcell, variant, DimensionAxis.WIDTH, Material.IRON_INGOT)); + window.setElement(0, 2, variantAxisElement( + window, state, workcell, variant, DimensionAxis.HEIGHT, Material.GOLD_INGOT)); + window.setElement(2, 2, variantAxisElement( + window, state, workcell, variant, DimensionAxis.DEPTH, Material.COPPER_INGOT)); + + UIElement capacity = element( + "variant-size-capacity", + variant.resizableToCapacity() ? Material.NETHER_STAR : Material.GRAY_DYE, + variant.resizableToCapacity() + ? ChatColor.GREEN + "Resize This Variant to Capacity" + : ChatColor.GRAY + "Variant Already Matches Capacity"); + capacity.addLore(ChatColor.GRAY + dimensions(workcell.capacity())); + if (variant.resizableToCapacity()) { + capacity.addLore(ChatColor.YELLOW + "Left-click to resize only this variant"); + capacity.onLeftClick(clicked -> resizeVariant( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + workcell.capacity())); + } + window.setElement(0, 3, capacity); + + UIElement footerBack = element("variant-size-footer-back", Material.ARROW, ChatColor.YELLOW + "Back"); + footerBack.onLeftClick(clicked -> openDetails( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0)); + window.setElement(-4, 5, footerBack); + }); + } + + private UIElement variantAxisElement( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + DimensionAxis axis, + Material material + ) { + JigsawStudioCellDimensions current = variant.dimensions().orElseThrow(); + UIElement element = element( + "variant-axis-" + axis.name().toLowerCase(Locale.ROOT), + material, + ChatColor.AQUA + axis.displayName() + ": " + axis.value(current)); + element.addLore(ChatColor.GREEN + "Left-click: +1"); + element.addLore(ChatColor.YELLOW + "Right-click: -1"); + element.addLore(ChatColor.GREEN + "Shift-left: +8"); + element.addLore(ChatColor.YELLOW + "Shift-right: -8"); + element.addLore(ChatColor.GRAY + "Maximum: " + axis.value(workcell.capacity())); + element.onLeftClick(clicked -> resizeVariantAxis( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), axis, 1)); + element.onRightClick(clicked -> resizeVariantAxis( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), axis, -1)); + element.onShiftLeftClick(clicked -> resizeVariantAxis( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), axis, 8)); + element.onShiftRightClick(clicked -> resizeVariantAxis( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), axis, -8)); + return element; + } + + private void renderStructureSettings( + UIWindow window, + JigsawStudioMenuState state, + String selectedWorkcellId, + int requestedPage + ) { + int page = clampPage(requestedPage, state.themeSets().size(), THEME_SETS_PER_PAGE); + purgeExpiredConfirmations(window.getViewer().getUniqueId()); + window.batch(() -> { + window.clearElements(); + + UIElement back = element("structure-back", Material.ARROW, ChatColor.YELLOW + "Back to Variants"); + back.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), selectedWorkcellId, 0)); + window.setElement(-4, 0, back); + + UIElement identity = element( + "structure-identity", + Material.JIGSAW, + ChatColor.LIGHT_PURPLE + "Structure Themes & Caps"); + identity.addLore(ChatColor.DARK_GRAY + safe(state.structureKey())); + identity.addLore(ChatColor.GRAY + "Compatibility: " + compatibilityName(state.compatibilityTarget())); + identity.addLore(ChatColor.GRAY + "Theme sets select one coherent variant family per assembly."); + identity.addLore(ChatColor.GRAY + "Empty piece themes remain available to every family."); + window.setElement(0, 0, identity); + window.setElement(4, 0, evaluationElement(state.evaluation())); + + UIElement requireCaps = element( + "require-caps", + state.irisExtended() + ? state.requireCaps() ? Material.IRON_BARS : Material.TRIPWIRE_HOOK + : Material.BARRIER, + !state.irisExtended() + ? ChatColor.GRAY + "Mandatory Caps Require Iris Compatibility" + : state.requireCaps() + ? ChatColor.GREEN + "Mandatory Caps Enabled" + : ChatColor.YELLOW + "Mandatory Caps Disabled"); + requireCaps.addLore(!state.irisExtended() + ? ChatColor.GRAY + "Vanilla-portable export rejects Iris mandatory-cap metadata." + : state.requireCaps() + ? ChatColor.GRAY + "Every open connector must close with a terminal piece." + : ChatColor.GRAY + "Assembly may leave an open connector when no cap can fit."); + if (state.irisExtended()) { + requireCaps.addLore(ChatColor.YELLOW + "Left-click to toggle"); + requireCaps.onLeftClick(clicked -> setRequireCaps( + window.getViewer(), state.requestId(), !state.requireCaps())); + } + window.setElement(-3, 1, requireCaps); + + String nextThemeKey = nextThemeSetKey(state.themeSets()); + boolean themeEditingAvailable = state.irisExtended(); + UIElement createTheme = element( + "new-theme-set", + themeEditingAvailable ? Material.NETHER_STAR : Material.BARRIER, + themeEditingAvailable + ? ChatColor.GREEN + "Duplicate All Enabled Cells as Family: " + safe(nextThemeKey) + : ChatColor.GRAY + "Theme Sets Require Iris Compatibility"); + if (themeEditingAvailable) { + createTheme.addLore(ChatColor.GRAY + "Creates one new owned variant for every enabled workcell."); + createTheme.addLore(ChatColor.GRAY + "All created variants join " + safe(nextThemeKey) + "."); + createTheme.addLore(ChatColor.YELLOW + "Left-click to create the complete set"); + createTheme.onLeftClick(clicked -> duplicateActiveFamily( + window.getViewer(), state.requestId(), nextThemeKey)); + } else { + createTheme.addLore(ChatColor.GRAY + "Vanilla jigsaw resources cannot encode Iris theme metadata."); + } + window.setElement(0, 1, createTheme); + + boolean deleteConfirmed = isProjectDeleteConfirmed( + window.getViewer().getUniqueId(), state.requestId()); + UIElement deleteProject = element( + "delete-project", + deleteConfirmed ? Material.REDSTONE : Material.LAVA_BUCKET, + deleteConfirmed + ? ChatColor.RED + "Confirm Delete Jigsaw" + : ChatColor.RED + "Delete Jigsaw") + .setEnchanted(deleteConfirmed); + deleteProject.addLore(deleteConfirmed + ? ChatColor.RED + "Click again to delete this entire managed project." + : ChatColor.RED + "Click twice within 10 seconds to confirm."); + deleteProject.onLeftClick(clicked -> confirmOrDeleteProject( + window.getViewer(), state.requestId(), selectedWorkcellId, page)); + window.setElement(3, 1, deleteProject); + + addThemeSets(window, state, page); + + UIElement footerBack = element("structure-footer-back", Material.ARROW, ChatColor.YELLOW + "Back"); + footerBack.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), selectedWorkcellId, 0)); + window.setElement(-4, 5, footerBack); + + int pageCount = pageCount(state.themeSets().size(), THEME_SETS_PER_PAGE); + if (page > 0) { + UIElement previous = element( + "theme-previous", + Material.ARROW, + ChatColor.YELLOW + "Previous Page"); + previous.onLeftClick(clicked -> refreshStructureSettings( + window.getViewer(), state.requestId(), selectedWorkcellId, page - 1)); + window.setElement(-1, 5, previous); + } + UIElement indicator = element( + "theme-page", + Material.BOOK, + ChatColor.WHITE + "Page " + (page + 1) + "/" + pageCount); + indicator.addLore(ChatColor.GRAY + "" + state.themeSets().size() + " theme sets"); + window.setElement(0, 5, indicator); + if (page + 1 < pageCount) { + UIElement next = element("theme-next", Material.ARROW, ChatColor.YELLOW + "Next Page"); + next.onLeftClick(clicked -> refreshStructureSettings( + window.getViewer(), state.requestId(), selectedWorkcellId, page + 1)); + window.setElement(1, 5, next); + } + window.setElement(4, 5, evaluationElement(state.evaluation())); + }); + } + + private void addThemeSets( + UIWindow window, + JigsawStudioMenuState state, + int page + ) { + List themeSets = page( + state.themeSets(), page, THEME_SETS_PER_PAGE); + if (themeSets.isEmpty()) { + UIElement empty = element( + "no-theme-sets", + Material.GRAY_DYE, + ChatColor.GRAY + "Implicit Unthemed Assembly"); + empty.addLore(ChatColor.GRAY + "Create variant-1 to begin coherent theme selection."); + window.setElement(0, 3, empty); + return; + } + for (int index = 0; index < themeSets.size(); index++) { + JigsawStudioMenuState.ThemeSet themeSet = themeSets.get(index); + int position = GRID_POSITIONS[index % GRID_POSITIONS.length]; + int row = 2 + index / GRID_POSITIONS.length; + UIElement element = element( + "theme-set-" + index, + state.irisExtended() ? Material.PURPLE_DYE : Material.GRAY_DYE, + (state.irisExtended() ? ChatColor.LIGHT_PURPLE : ChatColor.GRAY) + safe(themeSet.key())); + element.addLore(ChatColor.WHITE + "Selection weight: " + themeSet.weight()); + if (state.irisExtended()) { + element.addLore(ChatColor.GREEN + "Left-click: weight +1"); + element.addLore(ChatColor.YELLOW + "Right-click: weight -1"); + element.addLore(ChatColor.GREEN + "Shift-left: weight +8"); + element.addLore(ChatColor.YELLOW + "Shift-right: weight -8"); + element.onLeftClick(clicked -> adjustThemeSetWeight( + window.getViewer(), state.requestId(), themeSet, 1)); + element.onRightClick(clicked -> adjustThemeSetWeight( + window.getViewer(), state.requestId(), themeSet, -1)); + element.onShiftLeftClick(clicked -> adjustThemeSetWeight( + window.getViewer(), state.requestId(), themeSet, 8)); + element.onShiftRightClick(clicked -> adjustThemeSetWeight( + window.getViewer(), state.requestId(), themeSet, -8)); + } else { + element.addLore(ChatColor.GRAY + "Theme metadata is unavailable for vanilla-portable graphs."); + } + window.setElement(position, row, element); + } + } + + private UIElement axisElement( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + DimensionAxis axis, + Material material + ) { + UIElement element = element( + "axis-" + axis.name().toLowerCase(Locale.ROOT), + material, + ChatColor.AQUA + axis.displayName() + " Capacity: " + axis.value(workcell.capacity())); + element.addLore(ChatColor.GREEN + "Left-click: +1"); + element.addLore(ChatColor.YELLOW + "Right-click: -1"); + element.addLore(ChatColor.GREEN + "Shift-left: +8"); + element.addLore(ChatColor.YELLOW + "Shift-right: -8"); + element.addLore(ChatColor.GRAY + "The Studio layout regenerates after resizing."); + element.onLeftClick(clicked -> resizeWorkcell( + window.getViewer(), state.requestId(), workcell.stableId(), axis, 1)); + element.onRightClick(clicked -> resizeWorkcell( + window.getViewer(), state.requestId(), workcell.stableId(), axis, -1)); + element.onShiftLeftClick(clicked -> resizeWorkcell( + window.getViewer(), state.requestId(), workcell.stableId(), axis, 8)); + element.onShiftRightClick(clicked -> resizeWorkcell( + window.getViewer(), state.requestId(), workcell.stableId(), axis, -8)); + return element; + } + + private void renderDetails( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + int requestedPage + ) { + int page = clampPage(requestedPage, variant.memberships().size(), MEMBERSHIPS_PER_PAGE); + purgeExpiredConfirmations(window.getViewer().getUniqueId()); + window.batch(() -> { + window.clearElements(); + addDetailsHeader(window, state, workcell, variant, page); + addMemberships(window, state, workcell, variant, page); + addDetailsFooter(window, state, workcell, variant, page); + }); + } + + private void addDetailsHeader( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + int page + ) { + UIElement back = element("back", Material.ARROW, ChatColor.YELLOW + "Back to Variants"); + back.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(-4, 0, back); + + UIElement identity = element("identity", Material.NAME_TAG, ChatColor.AQUA + safe(variant.displayName())); + identity.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey())); + identity.addLore(variant.owned() + ? ChatColor.GREEN + "Owned variant" + : ChatColor.GRAY + "Read-only variant"); + identity.addLore(variant.active() + ? ChatColor.GREEN + "Loaded in this workcell" + : ChatColor.GRAY + "Not currently loaded"); + identity.addLore(ChatColor.GRAY + "Size: " + variant.dimensions() + .map(JigsawStudioMenuController::dimensions) + .orElse("Object missing")); + identity.addLore(ChatColor.GRAY + "Themes: " + themes(variant.themes())); + identity.addLore(ChatColor.GRAY + "Depth: " + variant.rules().minimumDepth() + + "-" + variant.rules().maximumDepth()); + identity.addLore(ChatColor.GRAY + "Placements: " + variant.rules().minimumPlacements() + + "-" + maximumPlacements(variant.rules().maximumPlacements())); + identity.addLore(ChatColor.GRAY + "Terminal: " + yesNo(variant.rules().terminal())); + identity.addLore(ChatColor.GRAY + "Pool entries: " + variant.memberships().size()); + if (variant.owned()) { + identity.addLore(ChatColor.YELLOW + "Left-click for a rename stick"); + identity.addLore(ChatColor.GRAY + "Rename that stick in an anvil, then right-click it."); + identity.addLore(ChatColor.GRAY + "Sneak-right-click the stick to reset this label."); + identity.onLeftClick(clicked -> actions.giveTool( + window.getViewer(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.RENAME_VARIANT, + state.requestId(), + workcell.stableId(), + variant.pieceKey()))); + } + window.setElement(0, 0, identity); + + boolean activeOwned = variant.active() && variant.owned(); + boolean irisRuleEditing = activeOwned && state.irisExtended(); + UIElement settings = element( + "variant-settings", + irisRuleEditing ? Material.COMPARATOR : Material.GRAY_DYE, + irisRuleEditing + ? ChatColor.GOLD + "Themes & Piece Rules" + : ChatColor.GRAY + "Themes & Rules Unavailable"); + settings.addLore(irisRuleEditing + ? ChatColor.GRAY + "Edit this loaded variant's theme membership and placement rules." + : state.irisExtended() + ? ChatColor.GRAY + "Load an owned variant before editing its rules." + : ChatColor.GRAY + "Vanilla-portable pieces cannot encode Iris theme or rule metadata."); + if (irisRuleEditing) { + settings.addLore(ChatColor.YELLOW + "Left-click to edit"); + settings.onLeftClick(clicked -> openVariantSettings( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0)); + } + window.setElement(-2, 0, settings); + + boolean rotationEditable = variant.active() && variant.rotationEditable(); + UIElement rotation = element( + "rotation", + rotationEditable ? Material.REPEATER : Material.BARRIER, + rotationEditable + ? ChatColor.LIGHT_PURPLE + "Toggle Rotation" + : ChatColor.GRAY + "Rotation is Read-only"); + rotation.addLore(ChatColor.GRAY + "Currently: " + (variant.rotatable() ? "Enabled" : "Disabled")); + if (rotationEditable) { + rotation.addLore(ChatColor.YELLOW + "Left-click to toggle"); + rotation.onLeftClick(clicked -> toggleRotation( + window.getViewer(), state.requestId(), workcell.stableId())); + } else if (!variant.active()) { + rotation.addLore(ChatColor.GRAY + "Load this variant before editing rotation."); + } else if (variant.owned() && variant.rotatable()) { + rotation.addLore(ChatColor.GRAY + "Vanilla-portable variants must remain rotatable."); + } + window.setElement(3, 0, rotation); + + boolean sizeEditable = variant.owned() && variant.dimensions().isPresent(); + UIElement size = element( + "variant-size", + sizeEditable ? Material.SCAFFOLDING : Material.BARRIER, + sizeEditable + ? ChatColor.GREEN + "Edit This Variant's Size" + : ChatColor.GRAY + "Variant Size is Read-only"); + size.addLore(ChatColor.GRAY + "Current: " + variant.dimensions() + .map(JigsawStudioMenuController::dimensions) + .orElse("Object missing")); + size.addLore(ChatColor.GRAY + "Workcell capacity: " + dimensions(workcell.capacity())); + if (sizeEditable) { + size.addLore(ChatColor.YELLOW + "Left-click for width, height, and depth controls"); + size.onLeftClick(clicked -> openVariantSizeSettings( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey())); + if (variant.resizableToCapacity()) { + size.addLore(ChatColor.YELLOW + "Shift-left: resize this variant to capacity"); + size.onShiftLeftClick(clicked -> resizeVariant( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + workcell.capacity())); + } + } + window.setElement(2, 0, size); + + boolean lastVariant = workcell.variants().size() <= 1; + boolean deletionAvailable = variant.owned() && !variant.active() && !lastVariant; + boolean confirmed = deletionAvailable && isDeleteConfirmed( + window.getViewer().getUniqueId(), + state.requestId(), + workcell.stableId(), + variant.pieceKey()); + UIElement delete = element( + "delete-variant", + deletionAvailable ? confirmed ? Material.REDSTONE : Material.LAVA_BUCKET : Material.BARRIER, + deletionAvailable + ? confirmed ? ChatColor.RED + "Confirm Delete Variant" : ChatColor.RED + "Delete Variant" + : variant.active() + ? ChatColor.GRAY + "Loaded Variant Cannot Be Deleted" + : lastVariant + ? ChatColor.GRAY + "Last Variant Cannot Be Deleted" + : ChatColor.GRAY + "Deletion is Read-only") + .setEnchanted(confirmed); + if (deletionAvailable) { + delete.addLore(confirmed + ? ChatColor.RED + "Click again to permanently delete this owned variant." + : ChatColor.RED + "Click twice within 10 seconds to confirm."); + delete.onLeftClick(clicked -> confirmOrDeleteVariant( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + page)); + } else if (variant.active()) { + delete.addLore(ChatColor.GRAY + "Load another variant in this workcell first."); + } else if (lastVariant) { + delete.addLore(ChatColor.GRAY + "Create another variant before deleting this one."); + } + window.setElement(4, 0, delete); + } + + private void addMemberships( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + int page + ) { + List memberships = page( + variant.memberships(), page, MEMBERSHIPS_PER_PAGE); + if (memberships.isEmpty()) { + UIElement empty = element("no-memberships", Material.BARRIER, ChatColor.RED + "No Pool Entries"); + empty.addLore(ChatColor.GRAY + "This variant is not linked from an owned pool."); + window.setElement(0, 2, empty); + return; + } + + for (int index = 0; index < memberships.size(); index++) { + JigsawStudioMenuState.Membership membership = memberships.get(index); + int position = GRID_POSITIONS[index % GRID_POSITIONS.length]; + int row = 1 + index / GRID_POSITIONS.length; + boolean confirmed = variant.active() && variant.owned() && isUnlinkConfirmed( + window.getViewer().getUniqueId(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + membership); + UIElement element = element( + "membership-" + index, + confirmed ? Material.REDSTONE : Material.GOLD_NUGGET, + (confirmed ? ChatColor.RED : ChatColor.GOLD) + + safe(displayKey(membership.poolKey())) + + " [" + membership.entryIndex() + "]") + .setEnchanted(confirmed); + element.addLore(ChatColor.DARK_GRAY + safe(membership.poolKey())); + element.addLore(ChatColor.GRAY + "Exact entry: " + membership.entryIndex()); + element.addLore(ChatColor.WHITE + "Weight: " + membership.weight()); + element.addLore((state.irisExtended() ? ChatColor.WHITE : ChatColor.GRAY) + + "Chance: " + chance(membership.chance()) + + (state.irisExtended() ? "" : " (Iris only)")); + if (variant.active() && variant.owned()) { + element.addLore(ChatColor.GREEN + "Left-click: weight +1"); + element.addLore(ChatColor.YELLOW + "Right-click: weight -1"); + if (state.irisExtended()) { + element.addLore(ChatColor.GREEN + "Shift-left: chance +" + + CHANCE_STEP_PERCENTAGE_POINTS + "%"); + element.addLore(ChatColor.YELLOW + "Shift-right: chance -" + + CHANCE_STEP_PERCENTAGE_POINTS + "%"); + } else { + element.addLore(ChatColor.GRAY + "Vanilla-portable pool entries always have 100% chance."); + } + element.addLore(confirmed + ? ChatColor.RED + "Middle-click again to unlink" + : ChatColor.RED + "Middle-click twice to unlink"); + element.onLeftClick(clicked -> adjustMembershipWeight( + window.getViewer(), state.requestId(), workcell.stableId(), membership, 1)); + element.onRightClick(clicked -> adjustMembershipWeight( + window.getViewer(), state.requestId(), workcell.stableId(), membership, -1)); + if (state.irisExtended()) { + element.onShiftLeftClick(clicked -> adjustMembershipChance( + window.getViewer(), + state.requestId(), + workcell.stableId(), + membership, + CHANCE_STEP_PERCENTAGE_POINTS)); + element.onShiftRightClick(clicked -> adjustMembershipChance( + window.getViewer(), + state.requestId(), + workcell.stableId(), + membership, + -CHANCE_STEP_PERCENTAGE_POINTS)); + } + element.onMiddleClick(clicked -> confirmOrUnlink( + window.getViewer(), state.requestId(), workcell.stableId(), membership, page)); + } else { + element.addLore(variant.active() + ? ChatColor.GRAY + "Read-only pool entry" + : ChatColor.GRAY + "Load this variant to edit its pool entry"); + } + window.setElement(position, row, element); + } + } + + private void addDetailsFooter( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + int page + ) { + UIElement back = element("footer-back", Material.ARROW, ChatColor.YELLOW + "Back"); + back.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(-4, 5, back); + + int pageCount = pageCount(variant.memberships().size(), MEMBERSHIPS_PER_PAGE); + if (page > 0) { + UIElement previous = element("membership-previous", Material.ARROW, ChatColor.YELLOW + "Previous Page"); + previous.onLeftClick(clicked -> refreshDetails( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + page - 1)); + window.setElement(-1, 5, previous); + } + UIElement indicator = element("membership-page", Material.BOOK, ChatColor.WHITE + "Page " + + (page + 1) + "/" + pageCount); + indicator.addLore(ChatColor.GRAY + "" + variant.memberships().size() + " exact pool entries"); + window.setElement(0, 5, indicator); + if (page + 1 < pageCount) { + UIElement next = element("membership-next", Material.ARROW, ChatColor.YELLOW + "Next Page"); + next.onLeftClick(clicked -> refreshDetails( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + page + 1)); + window.setElement(1, 5, next); + } + window.setElement(2, 5, evaluationElement(state.evaluation())); + + UIElement toolbox = element("details-toolbox", Material.STICK, ChatColor.AQUA + "Toolbox"); + toolbox.onLeftClick(clicked -> openToolbox( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(4, 5, toolbox); + } + + private void renderVariantSettings( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + int requestedPage + ) { + int page = clampPage(requestedPage, state.themeSets().size(), THEME_SETS_PER_PAGE); + window.batch(() -> { + window.clearElements(); + + UIElement back = element("variant-settings-back", Material.ARROW, ChatColor.YELLOW + "Back to Details"); + back.onLeftClick(clicked -> openDetails( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0)); + window.setElement(-4, 0, back); + + UIElement identity = element( + "variant-settings-identity", + Material.COMPARATOR, + ChatColor.GOLD + safe(variant.displayName()) + " Rules"); + identity.addLore(ChatColor.DARK_GRAY + safe(variant.pieceKey())); + identity.addLore(ChatColor.GRAY + "Theme memberships: " + themes(variant.themes())); + identity.addLore(ChatColor.GRAY + "Use Duplicate All Enabled Cells as Family for coherent sets."); + window.setElement(0, 0, identity); + window.setElement(4, 0, evaluationElement(state.evaluation())); + + if (!variant.active() || !variant.owned() || !state.irisExtended()) { + UIElement unavailable = element( + "variant-settings-unavailable", + Material.BARRIER, + state.irisExtended() + ? ChatColor.RED + "Load an Owned Variant First" + : ChatColor.GRAY + "Iris Metadata Unavailable"); + unavailable.addLore(state.irisExtended() + ? ChatColor.GRAY + "Rules and themes can only change for the loaded owned variant." + : ChatColor.GRAY + "Vanilla-portable pieces use vanilla placement behavior."); + window.setElement(0, 2, unavailable); + } else { + window.setElement(-4, 1, ruleElement( + window, state, workcell, variant, RuleField.MINIMUM_DEPTH)); + window.setElement(-2, 1, ruleElement( + window, state, workcell, variant, RuleField.MAXIMUM_DEPTH)); + window.setElement(0, 1, ruleElement( + window, state, workcell, variant, RuleField.MINIMUM_PLACEMENTS)); + window.setElement(2, 1, ruleElement( + window, state, workcell, variant, RuleField.MAXIMUM_PLACEMENTS)); + + UIElement terminal = element( + "rule-terminal", + variant.rules().terminal() ? Material.REDSTONE_TORCH : Material.LEVER, + variant.rules().terminal() + ? ChatColor.GREEN + "Terminal Piece: Yes" + : ChatColor.YELLOW + "Terminal Piece: No"); + terminal.addLore(ChatColor.GRAY + "Terminal pieces consume a connector without expanding."); + terminal.addLore(ChatColor.YELLOW + "Left-click to toggle"); + terminal.onLeftClick(clicked -> updateVariantRules( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + withTerminal(variant.rules(), !variant.rules().terminal()))); + window.setElement(4, 1, terminal); + + addVariantThemeMemberships(window, state, workcell, variant, page); + } + + UIElement footerBack = element("variant-rules-footer-back", Material.ARROW, ChatColor.YELLOW + "Back"); + footerBack.onLeftClick(clicked -> openDetails( + window.getViewer(), state.requestId(), workcell.stableId(), variant.pieceKey(), 0)); + window.setElement(-4, 5, footerBack); + + int pageCount = pageCount(state.themeSets().size(), THEME_SETS_PER_PAGE); + if (page > 0) { + UIElement previous = element( + "variant-theme-previous", + Material.ARROW, + ChatColor.YELLOW + "Previous Page"); + previous.onLeftClick(clicked -> refreshVariantSettings( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + page - 1)); + window.setElement(-1, 5, previous); + } + UIElement indicator = element( + "variant-theme-page", + Material.BOOK, + ChatColor.WHITE + "Theme Page " + (page + 1) + "/" + pageCount); + indicator.addLore(ChatColor.GRAY + "" + state.themeSets().size() + " declared themes"); + window.setElement(0, 5, indicator); + if (page + 1 < pageCount) { + UIElement next = element( + "variant-theme-next", + Material.ARROW, + ChatColor.YELLOW + "Next Page"); + next.onLeftClick(clicked -> refreshVariantSettings( + window.getViewer(), + state.requestId(), + workcell.stableId(), + variant.pieceKey(), + page + 1)); + window.setElement(1, 5, next); + } + window.setElement(4, 5, evaluationElement(state.evaluation())); + }); + } + + private UIElement ruleElement( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + RuleField field + ) { + int value = field.value(variant.rules()); + UIElement element = element( + "rule-" + field.name().toLowerCase(Locale.ROOT), + field.material(), + ChatColor.AQUA + field.displayName() + ": " + field.displayValue(value)); + boolean unlimitedMaximum = field == RuleField.MAXIMUM_PLACEMENTS && value == 0; + if (unlimitedMaximum) { + element.addLore(ChatColor.GRAY + "Increase is already unlimited."); + } else { + element.addLore(ChatColor.GREEN + "Left-click: +1"); + element.addLore(ChatColor.GREEN + "Shift-left: +" + field.shiftStep()); + element.onLeftClick(clicked -> adjustVariantRule( + window.getViewer(), state.requestId(), workcell, variant, field, 1)); + element.onShiftLeftClick(clicked -> adjustVariantRule( + window.getViewer(), state.requestId(), workcell, variant, field, field.shiftStep())); + } + element.addLore(unlimitedMaximum + ? ChatColor.YELLOW + "Right-click: set 512" + : ChatColor.YELLOW + "Right-click: -1"); + element.addLore(unlimitedMaximum + ? ChatColor.YELLOW + "Shift-right: set 497" + : ChatColor.YELLOW + "Shift-right: -" + field.shiftStep()); + element.onRightClick(clicked -> adjustVariantRule( + window.getViewer(), state.requestId(), workcell, variant, field, -1)); + element.onShiftRightClick(clicked -> adjustVariantRule( + window.getViewer(), state.requestId(), workcell, variant, field, -field.shiftStep())); + return element; + } + + private void addVariantThemeMemberships( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + int page + ) { + List themeSets = page( + state.themeSets(), page, THEME_SETS_PER_PAGE); + if (themeSets.isEmpty()) { + UIElement empty = element( + "variant-no-themes", + Material.GRAY_DYE, + ChatColor.GRAY + "Available to Every Theme"); + empty.addLore(ChatColor.GRAY + "Create a coherent theme set from Structure Rules first."); + window.setElement(0, 3, empty); + return; + } + for (int index = 0; index < themeSets.size(); index++) { + JigsawStudioMenuState.ThemeSet themeSet = themeSets.get(index); + boolean member = variant.themes().contains(themeSet.key()); + int position = GRID_POSITIONS[index % GRID_POSITIONS.length]; + int row = 2 + index / GRID_POSITIONS.length; + UIElement element = element( + "variant-theme-" + index, + member ? Material.LIME_DYE : Material.GRAY_DYE, + (member ? ChatColor.GREEN : ChatColor.GRAY) + safe(themeSet.key())) + .setEnchanted(member); + element.addLore(member + ? ChatColor.GREEN + "This variant belongs to the theme." + : ChatColor.GRAY + "This variant does not belong to the theme."); + element.addLore(ChatColor.YELLOW + "Left-click to toggle membership"); + element.onLeftClick(clicked -> toggleVariantTheme( + window.getViewer(), state.requestId(), workcell, variant, themeSet.key())); + window.setElement(position, row, element); + } + } + + private void renderToolbox( + UIWindow window, + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell, + int requestedPage + ) { + List allTools = toolboxTools(state, workcell); + int page = clampPage(requestedPage, allTools.size(), TOOLS_PER_PAGE); + List tools = page(allTools, page, TOOLS_PER_PAGE); + window.batch(() -> { + window.clearElements(); + + UIElement back = element("toolbox-back", Material.ARROW, ChatColor.YELLOW + "Back to Variants"); + back.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(-4, 0, back); + + UIElement heading = element("toolbox-heading", Material.CHEST, ChatColor.AQUA + "Bound Tool Sticks"); + heading.addLore(ChatColor.GRAY + "Every stick is bound to request " + state.requestId()); + heading.addLore(ChatColor.GRAY + "Stale sticks stop working when this Studio closes."); + window.setElement(0, 0, heading); + window.setElement(4, 0, evaluationElement(state.evaluation())); + + for (int index = 0; index < tools.size(); index++) { + ToolboxTool tool = tools.get(index); + int position = GRID_POSITIONS[index % GRID_POSITIONS.length]; + int row = 1 + index / GRID_POSITIONS.length; + UIElement element = element( + "tool-" + index, + Material.STICK, + ChatColor.AQUA + safe(tool.displayName())); + element.addLore(ChatColor.GRAY + "Action: " + tool.payload().action().displayName()); + addToolBindingLore(element, tool.payload()); + element.addLore(tool.payload().action().destructive() + ? ChatColor.RED + "Bound tools require confirmation when used." + : ChatColor.YELLOW + "Left-click to receive this named stick."); + element.onLeftClick(clicked -> giveTool( + window.getViewer(), state.requestId(), tool.payload())); + window.setElement(position, row, element); + } + + UIElement footerBack = element("toolbox-footer-back", Material.ARROW, ChatColor.YELLOW + "Back"); + footerBack.onLeftClick(clicked -> refreshMain( + window.getViewer(), state.requestId(), workcell.stableId(), 0)); + window.setElement(-4, 5, footerBack); + + int pageCount = pageCount(allTools.size(), TOOLS_PER_PAGE); + if (page > 0) { + UIElement previous = element("toolbox-previous", Material.ARROW, ChatColor.YELLOW + "Previous Page"); + previous.onLeftClick(clicked -> refreshToolbox( + window.getViewer(), state.requestId(), workcell.stableId(), page - 1)); + window.setElement(-1, 5, previous); + } + UIElement indicator = element("toolbox-page", Material.BOOK, ChatColor.WHITE + "Page " + + (page + 1) + "/" + pageCount); + indicator.addLore(ChatColor.GRAY + "" + allTools.size() + " bound tools"); + window.setElement(0, 5, indicator); + if (page + 1 < pageCount) { + UIElement next = element("toolbox-next", Material.ARROW, ChatColor.YELLOW + "Next Page"); + next.onLeftClick(clicked -> refreshToolbox( + window.getViewer(), state.requestId(), workcell.stableId(), page + 1)); + window.setElement(1, 5, next); + } + }); + } + + private void selectWorkcell(Player player, UUID requestId, String workcellId) { + if (matchingState(player, requestId, true).isEmpty()) { + return; + } + if (actions.selectWorkcell(player, workcellId)) { + clearConfirmations(player.getUniqueId()); + refreshMain(player, requestId, workcellId, 0); + } + } + + private void switchVariant(Player player, UUID requestId, String workcellId, String pieceKey) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + if (variant == null) { + stale(player); + return; + } + if (variant.active()) { + player.sendMessage(ChatColor.YELLOW + "That variant is already loaded. Right-click it for details."); + return; + } + if (actions.switchVariant(player, workcellId, pieceKey, false)) { + closeAfterAction(player); + } + } + + private void openDetails( + Player player, + UUID requestId, + String workcellId, + String pieceKey, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + if (variant == null) { + stale(player); + return; + } + UIWindow window = windows.get(player.getUniqueId()); + if (window != null) { + renderDetails(window, current.get(), workcell, variant, page); + } + } + + private void openWorkcellSettings(Player player, UUID requestId, String workcellId) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + UIWindow window = windows.get(player.getUniqueId()); + if (workcell == null || window == null) { + stale(player); + return; + } + renderWorkcellSettings(window, current.get(), workcell); + } + + private void openStructureSettings( + Player player, + UUID requestId, + String selectedWorkcellId, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + UIWindow window = windows.get(player.getUniqueId()); + if (current.get().workcell(selectedWorkcellId) == null || window == null) { + stale(player); + return; + } + renderStructureSettings(window, current.get(), selectedWorkcellId, page); + } + + private void openVariantSettings( + Player player, + UUID requestId, + String workcellId, + String pieceKey, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + UIWindow window = windows.get(player.getUniqueId()); + if (workcell == null || variant == null || window == null) { + stale(player); + return; + } + if (!variant.active() || !variant.owned()) { + player.sendMessage(ChatColor.YELLOW + "Load an owned variant before editing its rules."); + return; + } + if (!current.get().irisExtended()) { + player.sendMessage(ChatColor.YELLOW + + "Vanilla-portable pieces cannot encode Iris theme or piece-rule metadata."); + return; + } + renderVariantSettings(window, current.get(), workcell, variant, page); + } + + private void openVariantSizeSettings( + Player player, + UUID requestId, + String workcellId, + String pieceKey + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + UIWindow window = windows.get(player.getUniqueId()); + if (workcell == null || variant == null || !variant.owned() + || variant.dimensions().isEmpty() || window == null) { + stale(player); + return; + } + renderVariantSizeSettings(window, current.get(), workcell, variant); + } + + private void openToolbox(Player player, UUID requestId, String workcellId, int page) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + UIWindow window = windows.get(player.getUniqueId()); + if (workcell == null || window == null) { + stale(player); + return; + } + renderToolbox(window, current.get(), workcell, page); + } + + private void createVariant( + Player player, + UUID requestId, + String workcellId, + boolean duplicateActive + ) { + if (matchingState(player, requestId, true).isEmpty()) { + return; + } + if (actions.createVariant(player, workcellId, duplicateActive)) { + closeAfterAction(player); + } + } + + private void setWorkcellEnabled( + Player player, + UUID requestId, + String workcellId, + boolean enabled + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + if (current.get().workcell(workcellId) == null) { + stale(player); + return; + } + if (actions.setWorkcellEnabled(player, workcellId, enabled)) { + closeAfterAction(player); + } + } + + private void resizeWorkcell( + Player player, + UUID requestId, + String workcellId, + DimensionAxis axis, + int delta + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + if (workcell == null) { + stale(player); + return; + } + Optional adjusted = adjustedDimensions( + workcell.capacity(), axis, delta); + if (adjusted.isEmpty()) { + player.sendMessage(ChatColor.RED + "That workcell size is outside Iris limits."); + return; + } + if (actions.updateWorkcellDimensions(player, workcellId, adjusted.get())) { + closeAfterAction(player); + } + } + + private void resizeVariantAxis( + Player player, + UUID requestId, + String workcellId, + String pieceKey, + DimensionAxis axis, + int delta + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + if (workcell == null || variant == null || !variant.owned() || variant.dimensions().isEmpty()) { + stale(player); + return; + } + Optional adjusted = adjustedDimensions( + variant.dimensions().orElseThrow(), axis, delta); + if (adjusted.isEmpty()) { + player.sendMessage(ChatColor.RED + "That variant size is outside Iris limits."); + return; + } + resizeVariant(player, requestId, workcellId, pieceKey, adjusted.get()); + } + + private void resizeVariant( + Player player, + UUID requestId, + String workcellId, + String pieceKey, + JigsawStudioCellDimensions dimensions + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + if (workcell == null || variant == null || !variant.owned()) { + stale(player); + return; + } + if (dimensions.width() > workcell.capacity().width() + || dimensions.height() > workcell.capacity().height() + || dimensions.depth() > workcell.capacity().depth()) { + player.sendMessage(ChatColor.RED + "Increase this workcell's capacity before making the variant larger."); + return; + } + if (actions.resizeVariant(player, workcellId, pieceKey, dimensions)) { + closeAfterAction(player); + } + } + + private void setRequireCaps(Player player, UUID requestId, boolean requireCaps) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + if (!current.get().irisExtended()) { + player.sendMessage(ChatColor.YELLOW + "Mandatory caps require Iris compatibility."); + return; + } + if (current.get().requireCaps() == requireCaps) { + player.sendMessage(ChatColor.YELLOW + "Mandatory caps are already " + + (requireCaps ? "enabled." : "disabled.")); + return; + } + if (actions.setRequireCaps(player, requireCaps)) { + clearConfirmations(player.getUniqueId()); + closeAfterAction(player); + } + } + + private void duplicateActiveFamily(Player player, UUID requestId, String themeKey) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + if (!current.get().irisExtended()) { + player.sendMessage(ChatColor.YELLOW + "Theme sets require Iris compatibility."); + return; + } + if (!nextThemeSetKey(current.get().themeSets()).equals(themeKey)) { + stale(player); + return; + } + if (actions.duplicateActiveFamily(player, themeKey)) { + clearConfirmations(player.getUniqueId()); + closeAfterAction(player); + } + } + + private void adjustThemeSetWeight( + Player player, + UUID requestId, + JigsawStudioMenuState.ThemeSet expected, + int delta + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + if (!current.get().irisExtended()) { + player.sendMessage(ChatColor.YELLOW + "Theme weights require Iris compatibility."); + return; + } + JigsawStudioMenuState.ThemeSet themeSet = current.get().themeSet(expected.key()); + if (!expected.equals(themeSet)) { + stale(player); + return; + } + Optional weight = adjustedPositiveValue(themeSet.weight(), delta); + if (weight.isEmpty()) { + player.sendMessage(ChatColor.RED + "Theme weights must remain positive."); + return; + } + if (actions.updateThemeSetWeight(player, themeSet.key(), weight.get())) { + clearConfirmations(player.getUniqueId()); + closeAfterAction(player); + } + } + + private void flushNow(Player player, UUID requestId, String workcellId) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + if (workcell == null) { + stale(player); + return; + } + if (!workcell.dirty()) { + player.sendMessage(ChatColor.YELLOW + "This workcell has no pending changes to flush."); + return; + } + if (actions.flushAutosave(player, workcellId)) { + closeAfterAction(player); + } + } + + private void goToPreview(Player player, UUID requestId) { + if (matchingState(player, requestId, true).isPresent() + && actions.goToPreview(player)) { + closeAfterAction(player); + } + } + + private void toggleRotation(Player player, UUID requestId, String workcellId) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty() || activeVariant(current.get(), workcellId) == null) { + return; + } + if (actions.toggleVariantRotatable(player, workcellId)) { + closeAfterAction(player); + } + } + + private void adjustVariantRule( + Player player, + UUID requestId, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant expected, + RuleField field, + int delta + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Variant variant = activeVariant(current.get(), workcell.stableId()); + if (variant == null || !variant.owned() || !variant.pieceKey().equals(expected.pieceKey())) { + stale(player); + return; + } + Optional rules = adjustedRules(variant.rules(), field, delta); + if (rules.isEmpty()) { + player.sendMessage(ChatColor.RED + "That piece rule value is outside Iris limits."); + return; + } + updateVariantRules( + player, + requestId, + workcell.stableId(), + variant.pieceKey(), + rules.get()); + } + + private void updateVariantRules( + Player player, + UUID requestId, + String workcellId, + String pieceKey, + JigsawStudioPieceRules rules + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + if (!current.get().irisExtended()) { + player.sendMessage(ChatColor.YELLOW + "Piece rules require Iris compatibility."); + return; + } + JigsawStudioMenuState.Variant variant = activeVariant(current.get(), workcellId); + if (variant == null || !variant.owned() || !variant.pieceKey().equals(pieceKey)) { + stale(player); + return; + } + if (actions.updateVariantRules(player, workcellId, pieceKey, rules)) { + clearConfirmations(player.getUniqueId()); + closeAfterAction(player); + } + } + + private void toggleVariantTheme( + Player player, + UUID requestId, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant expected, + String themeKey + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + if (!current.get().irisExtended()) { + player.sendMessage(ChatColor.YELLOW + "Theme membership requires Iris compatibility."); + return; + } + JigsawStudioMenuState.Variant variant = activeVariant(current.get(), workcell.stableId()); + if (variant == null + || !variant.owned() + || !variant.pieceKey().equals(expected.pieceKey()) + || current.get().themeSet(themeKey) == null) { + stale(player); + return; + } + List themes = new ArrayList<>(variant.themes()); + if (!themes.remove(themeKey)) { + themes.add(themeKey); + } + if (actions.updateVariantThemes( + player, + workcell.stableId(), + variant.pieceKey(), + List.copyOf(themes))) { + clearConfirmations(player.getUniqueId()); + closeAfterAction(player); + } + } + + private void adjustMembershipWeight( + Player player, + UUID requestId, + String workcellId, + JigsawStudioMenuState.Membership membership, + int delta + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty() || !hasMembership(current.get(), workcellId, membership)) { + stale(player); + return; + } + JigsawStudioMenuState.Variant active = activeVariant(current.get(), workcellId); + if (active == null) { + stale(player); + return; + } + if (actions.adjustVariantWeight( + player, + workcellId, + active.pieceKey(), + membership.poolKey(), + membership.entryIndex(), + delta)) { + clearConfirmations(player.getUniqueId()); + closeAfterAction(player); + } + } + + private void adjustMembershipChance( + Player player, + UUID requestId, + String workcellId, + JigsawStudioMenuState.Membership membership, + int deltaPercentagePoints + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty() || !hasMembership(current.get(), workcellId, membership)) { + stale(player); + return; + } + if (!current.get().irisExtended()) { + player.sendMessage(ChatColor.YELLOW + "Per-entry chance requires Iris compatibility."); + return; + } + JigsawStudioMenuState.Variant active = activeVariant(current.get(), workcellId); + if (active == null) { + stale(player); + return; + } + if (actions.adjustVariantChance( + player, + workcellId, + active.pieceKey(), + membership.poolKey(), + membership.entryIndex(), + deltaPercentagePoints)) { + clearConfirmations(player.getUniqueId()); + closeAfterAction(player); + } + } + + private void confirmOrUnlink( + Player player, + UUID requestId, + String workcellId, + JigsawStudioMenuState.Membership membership, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty() || !hasMembership(current.get(), workcellId, membership)) { + stale(player); + return; + } + + UUID playerId = player.getUniqueId(); + JigsawStudioMenuState.Variant active = activeVariant(current.get(), workcellId); + if (active == null || !active.owned()) { + stale(player); + return; + } + if (!isUnlinkConfirmed(playerId, requestId, workcellId, active.pieceKey(), membership)) { + pendingUnlinks.put(playerId, new PendingUnlink( + requestId, + workcellId, + active.pieceKey(), + membership.poolKey(), + membership.entryIndex(), + System.nanoTime() + DESTRUCTIVE_CONFIRM_NANOS)); + J.runEntity(player, () -> refreshDetails(player, requestId, workcellId, page), 1); + return; + } + + if (actions.unlinkVariantMembership( + player, + workcellId, + active.pieceKey(), + membership.poolKey(), + membership.entryIndex())) { + clearConfirmations(playerId); + closeAfterAction(player); + } + } + + private void confirmOrDeleteVariant( + Player player, + UUID requestId, + String workcellId, + String pieceKey, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant target = variant(workcell, pieceKey); + if (target == null || !target.owned() || target.active()) { + stale(player); + return; + } + + UUID playerId = player.getUniqueId(); + if (!isDeleteConfirmed(playerId, requestId, workcellId, pieceKey)) { + pendingDeletes.put(playerId, new PendingDelete( + requestId, + workcellId, + pieceKey, + System.nanoTime() + DESTRUCTIVE_CONFIRM_NANOS)); + J.runEntity(player, () -> refreshDetails( + player, requestId, workcellId, pieceKey, page), 1); + return; + } + + if (actions.deleteVariant(player, workcellId, pieceKey)) { + clearConfirmations(playerId); + closeAfterAction(player); + } + } + + private void confirmOrDeleteProject( + Player player, + UUID requestId, + String selectedWorkcellId, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + UUID playerId = player.getUniqueId(); + if (!isProjectDeleteConfirmed(playerId, requestId)) { + pendingProjectDeletes.put(playerId, new PendingProjectDelete( + requestId, + System.nanoTime() + DESTRUCTIVE_CONFIRM_NANOS)); + J.runEntity(player, () -> refreshStructureSettings( + player, requestId, selectedWorkcellId, page), 1); + return; + } + if (actions.deleteProject(player)) { + clearConfirmations(playerId); + closeAfterAction(player); + } + } + + private void giveTool(Player player, UUID requestId, JigsawStudioToolPayload payload) { + if (matchingState(player, requestId, true).isEmpty()) { + return; + } + if (!payload.requestId().equals(requestId)) { + stale(player); + return; + } + actions.giveTool(player, payload); + } + + private void refreshMain(Player player, UUID requestId, String workcellId, int page) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + UIWindow window = windows.get(player.getUniqueId()); + if (workcell == null || window == null) { + stale(player); + return; + } + renderMain(window, current.get(), workcell, page); + } + + private void refreshDetails(Player player, UUID requestId, String workcellId, int page) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Variant active = activeVariant(current.get(), workcellId); + if (active == null) { + stale(player); + return; + } + refreshDetails(player, requestId, workcellId, active.pieceKey(), page); + } + + private void refreshDetails( + Player player, + UUID requestId, + String workcellId, + String pieceKey, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + UIWindow window = windows.get(player.getUniqueId()); + if (workcell == null || variant == null || window == null) { + stale(player); + return; + } + renderDetails(window, current.get(), workcell, variant, page); + } + + private void refreshStructureSettings( + Player player, + UUID requestId, + String selectedWorkcellId, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + UIWindow window = windows.get(player.getUniqueId()); + if (current.get().workcell(selectedWorkcellId) == null || window == null) { + stale(player); + return; + } + renderStructureSettings(window, current.get(), selectedWorkcellId, page); + } + + private void refreshVariantSettings( + Player player, + UUID requestId, + String workcellId, + String pieceKey, + int page + ) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + JigsawStudioMenuState.Variant variant = variant(workcell, pieceKey); + UIWindow window = windows.get(player.getUniqueId()); + if (workcell == null || variant == null || window == null) { + stale(player); + return; + } + renderVariantSettings(window, current.get(), workcell, variant, page); + } + + private void refreshToolbox(Player player, UUID requestId, String workcellId, int page) { + Optional current = matchingState(player, requestId, true); + if (current.isEmpty()) { + return; + } + JigsawStudioMenuState.Workcell workcell = current.get().workcell(workcellId); + UIWindow window = windows.get(player.getUniqueId()); + if (workcell == null || window == null) { + stale(player); + return; + } + renderToolbox(window, current.get(), workcell, page); + } + + private Optional matchingState(Player player, UUID requestId, boolean closeStale) { + Optional current = actions.menuState(player); + if (current.isPresent() && current.get().requestId().equals(requestId)) { + return current; + } + if (closeStale) { + stale(player); + } + return Optional.empty(); + } + + private void stale(Player player) { + closeAfterAction(player); + player.sendMessage(ChatColor.RED + "This Jigsaw Studio menu is stale. Open the control chest again."); + } + + private void closeAfterAction(Player player) { + J.runEntity(player, () -> close(player), 1); + } + + private boolean isUnlinkConfirmed( + UUID playerId, + UUID requestId, + String workcellId, + String pieceKey, + JigsawStudioMenuState.Membership membership + ) { + PendingUnlink pending = pendingUnlinks.get(playerId); + if (pending == null) { + return false; + } + if (pending.expiresAtNanos() < System.nanoTime()) { + pendingUnlinks.remove(playerId, pending); + return false; + } + return pending.requestId().equals(requestId) + && pending.workcellId().equals(workcellId) + && pending.pieceKey().equals(pieceKey) + && pending.poolKey().equals(membership.poolKey()) + && pending.entryIndex() == membership.entryIndex(); + } + + private boolean isDeleteConfirmed( + UUID playerId, + UUID requestId, + String workcellId, + String pieceKey + ) { + PendingDelete pending = pendingDeletes.get(playerId); + if (pending == null) { + return false; + } + if (pending.expiresAtNanos() < System.nanoTime()) { + pendingDeletes.remove(playerId, pending); + return false; + } + return pending.requestId().equals(requestId) + && pending.workcellId().equals(workcellId) + && pending.pieceKey().equals(pieceKey); + } + + private boolean isProjectDeleteConfirmed(UUID playerId, UUID requestId) { + PendingProjectDelete pending = pendingProjectDeletes.get(playerId); + if (pending == null) { + return false; + } + if (pending.expiresAtNanos() < System.nanoTime()) { + pendingProjectDeletes.remove(playerId, pending); + return false; + } + return pending.requestId().equals(requestId); + } + + private void purgeExpiredConfirmations(UUID playerId) { + PendingUnlink unlink = pendingUnlinks.get(playerId); + if (unlink != null && unlink.expiresAtNanos() < System.nanoTime()) { + pendingUnlinks.remove(playerId, unlink); + } + PendingDelete delete = pendingDeletes.get(playerId); + if (delete != null && delete.expiresAtNanos() < System.nanoTime()) { + pendingDeletes.remove(playerId, delete); + } + PendingProjectDelete projectDelete = pendingProjectDeletes.get(playerId); + if (projectDelete != null && projectDelete.expiresAtNanos() < System.nanoTime()) { + pendingProjectDeletes.remove(playerId, projectDelete); + } + } + + private void clearConfirmations(UUID playerId) { + pendingUnlinks.remove(playerId); + pendingDeletes.remove(playerId); + pendingProjectDeletes.remove(playerId); + } + + private static boolean hasMembership( + JigsawStudioMenuState state, + String workcellId, + JigsawStudioMenuState.Membership expected + ) { + JigsawStudioMenuState.Variant active = activeVariant(state, workcellId); + if (active == null) { + return false; + } + for (JigsawStudioMenuState.Membership membership : active.memberships()) { + if (membership.poolKey().equals(expected.poolKey()) + && membership.entryIndex() == expected.entryIndex()) { + return true; + } + } + return false; + } + + private static JigsawStudioMenuState.Variant activeVariant( + JigsawStudioMenuState state, + String workcellId + ) { + JigsawStudioMenuState.Workcell workcell = state.workcell(workcellId); + return workcell == null ? null : workcell.activeVariant(); + } + + private static JigsawStudioMenuState.Variant variant( + JigsawStudioMenuState.Workcell workcell, + String pieceKey + ) { + if (workcell == null || pieceKey == null) { + return null; + } + for (JigsawStudioMenuState.Variant variant : workcell.variants()) { + if (variant.pieceKey().equals(pieceKey)) { + return variant; + } + } + return null; + } + + static String nextThemeSetKey(List themeSets) { + List values = List.copyOf(Objects.requireNonNull( + themeSets, + "Jigsaw Studio theme sets")); + int candidate = 1; + while (candidate > 0) { + String key = "variant-" + candidate; + boolean present = false; + for (JigsawStudioMenuState.ThemeSet themeSet : values) { + if (Objects.requireNonNull(themeSet, "Jigsaw Studio theme set").key().equals(key)) { + present = true; + break; + } + } + if (!present) { + return key; + } + candidate = Math.incrementExact(candidate); + } + throw new IllegalStateException("Jigsaw Studio cannot allocate another numbered theme set"); + } + + static Optional adjustedPositiveValue(int value, int delta) { + if (value < 1 || delta == 0) { + throw new IllegalArgumentException("Jigsaw Studio positive value adjustment is invalid"); + } + try { + int adjusted = Math.addExact(value, delta); + return adjusted < 1 ? Optional.empty() : Optional.of(adjusted); + } catch (ArithmeticException exception) { + return Optional.empty(); + } + } + + static Optional adjustedRules( + JigsawStudioPieceRules rules, + RuleField field, + int delta + ) { + JigsawStudioPieceRules current = Objects.requireNonNull(rules, "Jigsaw Studio piece rules"); + RuleField target = Objects.requireNonNull(field, "Jigsaw Studio piece rule field"); + if (delta == 0) { + throw new IllegalArgumentException("Jigsaw Studio piece rule delta cannot be zero"); + } + try { + int minimumDepth = current.minimumDepth(); + int maximumDepth = current.maximumDepth(); + int minimumPlacements = current.minimumPlacements(); + int maximumPlacements = current.maximumPlacements(); + switch (target) { + case MINIMUM_DEPTH -> minimumDepth = Math.addExact(minimumDepth, delta); + case MAXIMUM_DEPTH -> maximumDepth = Math.addExact(maximumDepth, delta); + case MINIMUM_PLACEMENTS -> minimumPlacements = Math.addExact(minimumPlacements, delta); + case MAXIMUM_PLACEMENTS -> { + Optional adjustedMaximum = adjustedMaximumPlacements(maximumPlacements, delta); + if (adjustedMaximum.isEmpty()) { + return Optional.empty(); + } + maximumPlacements = adjustedMaximum.get(); + } + } + return Optional.of(new JigsawStudioPieceRules( + minimumDepth, + maximumDepth, + minimumPlacements, + maximumPlacements, + current.terminal())); + } catch (ArithmeticException | IllegalArgumentException exception) { + return Optional.empty(); + } + } + + static JigsawStudioPieceRules withTerminal(JigsawStudioPieceRules rules, boolean terminal) { + JigsawStudioPieceRules current = Objects.requireNonNull(rules, "Jigsaw Studio piece rules"); + return new JigsawStudioPieceRules( + current.minimumDepth(), + current.maximumDepth(), + current.minimumPlacements(), + current.maximumPlacements(), + terminal); + } + + private static Optional adjustedMaximumPlacements(int value, int delta) { + if (value == 0) { + if (delta > 0) { + return Optional.empty(); + } + int adjusted = Math.addExact(513, delta); + return adjusted < 1 ? Optional.empty() : Optional.of(adjusted); + } + int adjusted = Math.addExact(value, delta); + if (adjusted > 512) { + return Optional.of(0); + } + return adjusted < 1 ? Optional.empty() : Optional.of(adjusted); + } + + static Optional adjustedDimensions( + JigsawStudioCellDimensions dimensions, + DimensionAxis axis, + int delta + ) { + JigsawStudioCellDimensions current = Objects.requireNonNull( + dimensions, + "Jigsaw Studio workcell dimensions"); + DimensionAxis target = Objects.requireNonNull(axis, "Jigsaw Studio workcell dimension axis"); + if (delta == 0) { + throw new IllegalArgumentException("Jigsaw Studio workcell dimension delta cannot be zero"); + } + try { + int width = target == DimensionAxis.WIDTH + ? Math.addExact(current.width(), delta) + : current.width(); + int height = target == DimensionAxis.HEIGHT + ? Math.addExact(current.height(), delta) + : current.height(); + int depth = target == DimensionAxis.DEPTH + ? Math.addExact(current.depth(), delta) + : current.depth(); + return Optional.of(new JigsawStudioCellDimensions(width, height, depth)); + } catch (ArithmeticException | IllegalArgumentException exception) { + return Optional.empty(); + } + } + + static List toolboxTools( + JigsawStudioMenuState state, + JigsawStudioMenuState.Workcell workcell + ) { + JigsawStudioMenuState menu = Objects.requireNonNull(state, "Jigsaw Studio toolbox state"); + JigsawStudioMenuState.Workcell selected = Objects.requireNonNull( + workcell, + "Jigsaw Studio toolbox workcell"); + UUID requestId = menu.requestId(); + List tools = new ArrayList<>(); + tools.add(new ToolboxTool( + "Open Control Menu", + JigsawStudioToolPayload.request(JigsawStudioToolAction.OPEN_MENU, requestId))); + tools.add(new ToolboxTool( + "Select " + selected.displayName(), + JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.SELECT_WORKCELL, + requestId, + selected.stableId()))); + if (menu.mode() == JigsawStudioMode.PLANAR_JIGSAW) { + tools.add(new ToolboxTool( + "Toggle " + selected.displayName(), + JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.TOGGLE_WORKCELL, + requestId, + selected.stableId()))); + } + tools.add(new ToolboxTool( + "Resize " + selected.displayName() + " Capacity", + JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.RESIZE_WORKCELL, + requestId, + selected.stableId()))); + tools.add(new ToolboxTool( + "Rename " + selected.displayName(), + JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.RENAME_WORKCELL, + requestId, + selected.stableId()))); + tools.add(new ToolboxTool( + "New Blank " + selected.displayName() + " Variant", + JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.CREATE_VARIANT, + requestId, + selected.stableId()))); + tools.add(new ToolboxTool( + "Go to Preview", + JigsawStudioToolPayload.request(JigsawStudioToolAction.PREVIEW_GRAPH, requestId))); + tools.add(new ToolboxTool( + "Flush " + selected.displayName() + " Autosave", + JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.FLUSH_AUTOSAVE, + requestId, + selected.stableId()))); + if (menu.irisExtended()) { + tools.add(new ToolboxTool( + "Duplicate All Enabled Cells as Family: " + nextThemeSetKey(menu.themeSets()), + JigsawStudioToolPayload.request(JigsawStudioToolAction.DUPLICATE_FAMILY, requestId))); + } + + JigsawStudioMenuState.Variant active = selected.activeVariant(); + if (active != null) { + if (active.owned()) { + tools.add(new ToolboxTool( + "Duplicate This Cell's Variant: " + active.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.DUPLICATE_VARIANT, + requestId, + selected.stableId(), + active.pieceKey()))); + } + if (active.owned()) { + tools.add(new ToolboxTool( + "Rename This Variant: " + active.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.RENAME_VARIANT, + requestId, + selected.stableId(), + active.pieceKey()))); + tools.add(new ToolboxTool( + "Resize This Variant: " + active.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.RESIZE_VARIANT, + requestId, + selected.stableId(), + active.pieceKey()))); + } + if (active.rotationEditable()) { + tools.add(new ToolboxTool( + "Toggle Rotation: " + active.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.TOGGLE_ROTATION, + requestId, + selected.stableId(), + active.pieceKey()))); + } + if (active.owned() && active.resizableToCapacity()) { + tools.add(new ToolboxTool( + "Resize " + active.displayName() + " to Capacity", + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.EXPAND_TO_CELL, + requestId, + selected.stableId(), + active.pieceKey()))); + } + if (active.owned() && menu.irisExtended()) { + tools.add(new ToolboxTool( + "Open Theme Memberships: " + active.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.SET_THEME, + requestId, + selected.stableId(), + active.pieceKey()))); + tools.add(new ToolboxTool( + "Open Piece Rules: " + active.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.SET_PIECE_RULES, + requestId, + selected.stableId(), + active.pieceKey()))); + } + if (active.owned()) { + for (JigsawStudioMenuState.Membership membership : active.memberships()) { + addMembershipTools( + tools, + requestId, + selected, + active, + membership, + menu.irisExtended()); + } + } + } + for (JigsawStudioMenuState.Variant variant : selected.variants()) { + if (variant.active()) { + continue; + } + tools.add(new ToolboxTool( + "Load " + variant.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.LOAD_VARIANT, + requestId, + selected.stableId(), + variant.pieceKey()))); + if (variant.owned()) { + tools.add(new ToolboxTool( + "Rename " + variant.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.RENAME_VARIANT, + requestId, + selected.stableId(), + variant.pieceKey()))); + tools.add(new ToolboxTool( + "Resize " + variant.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.RESIZE_VARIANT, + requestId, + selected.stableId(), + variant.pieceKey()))); + } + if (variant.owned() && selected.variants().size() > 1) { + tools.add(new ToolboxTool( + "Delete " + variant.displayName(), + JigsawStudioToolPayload.variant( + JigsawStudioToolAction.DELETE_VARIANT, + requestId, + selected.stableId(), + variant.pieceKey()))); + } + } + if (menu.irisExtended()) { + tools.add(new ToolboxTool( + menu.requireCaps() ? "Disable Mandatory Caps" : "Enable Mandatory Caps", + JigsawStudioToolPayload.request(JigsawStudioToolAction.TOGGLE_REQUIRE_CAPS, requestId))); + } + tools.add(new ToolboxTool( + "Delete Project", + JigsawStudioToolPayload.request(JigsawStudioToolAction.DELETE_PROJECT, requestId))); + return List.copyOf(tools); + } + + private static void addMembershipTools( + List tools, + UUID requestId, + JigsawStudioMenuState.Workcell workcell, + JigsawStudioMenuState.Variant variant, + JigsawStudioMenuState.Membership membership, + boolean chanceEditable + ) { + String membershipName = displayKey(membership.poolKey()) + " [" + membership.entryIndex() + "]"; + tools.add(new ToolboxTool( + "Weight +1: " + membershipName, + JigsawStudioToolPayload.membership( + JigsawStudioToolAction.ADJUST_VARIANT_WEIGHT, + requestId, + workcell.stableId(), + variant.pieceKey(), + membership.poolKey(), + membership.entryIndex(), + 1))); + tools.add(new ToolboxTool( + "Weight -1: " + membershipName, + JigsawStudioToolPayload.membership( + JigsawStudioToolAction.ADJUST_VARIANT_WEIGHT, + requestId, + workcell.stableId(), + variant.pieceKey(), + membership.poolKey(), + membership.entryIndex(), + -1))); + if (chanceEditable) { + tools.add(new ToolboxTool( + "Chance +" + CHANCE_STEP_PERCENTAGE_POINTS + "%: " + membershipName, + JigsawStudioToolPayload.membership( + JigsawStudioToolAction.ADJUST_VARIANT_CHANCE, + requestId, + workcell.stableId(), + variant.pieceKey(), + membership.poolKey(), + membership.entryIndex(), + CHANCE_STEP_PERCENTAGE_POINTS))); + tools.add(new ToolboxTool( + "Chance -" + CHANCE_STEP_PERCENTAGE_POINTS + "%: " + membershipName, + JigsawStudioToolPayload.membership( + JigsawStudioToolAction.ADJUST_VARIANT_CHANCE, + requestId, + workcell.stableId(), + variant.pieceKey(), + membership.poolKey(), + membership.entryIndex(), + -CHANCE_STEP_PERCENTAGE_POINTS))); + } + tools.add(new ToolboxTool( + "Unlink " + membershipName, + JigsawStudioToolPayload.membership( + JigsawStudioToolAction.UNLINK_MEMBERSHIP, + requestId, + workcell.stableId(), + variant.pieceKey(), + membership.poolKey(), + membership.entryIndex(), + 0))); + } + + private static void addToolBindingLore(UIElement element, JigsawStudioToolPayload payload) { + if (!payload.workcellId().isEmpty()) { + element.addLore(ChatColor.GRAY + "Workcell: " + safe(payload.workcellId())); + } + if (!payload.pieceKey().isEmpty()) { + element.addLore(ChatColor.GRAY + "Variant: " + safe(payload.pieceKey())); + } + if (!payload.poolKey().isEmpty()) { + element.addLore(ChatColor.GRAY + "Pool: " + safe(payload.poolKey()) + + (payload.entryIndex() < 0 ? "" : " [" + payload.entryIndex() + "]")); + } + if (payload.amount() != 0) { + element.addLore(ChatColor.GRAY + "Amount: " + payload.amount()); + } + } + + static int pageCount(int itemCount, int pageSize) { + if (itemCount < 0 || pageSize < 1) { + throw new IllegalArgumentException("Jigsaw Studio menu page bounds are invalid"); + } + return Math.max(1, (itemCount + pageSize - 1) / pageSize); + } + + static int clampPage(int requestedPage, int itemCount, int pageSize) { + int maximum = pageCount(itemCount, pageSize) - 1; + return Math.max(0, Math.min(requestedPage, maximum)); + } + + static List page(List items, int requestedPage, int pageSize) { + List values = List.copyOf(Objects.requireNonNull(items, "Jigsaw Studio menu page items")); + int page = clampPage(requestedPage, values.size(), pageSize); + int start = page * pageSize; + int end = Math.min(values.size(), start + pageSize); + return values.subList(start, end); + } + + private static UIElement evaluationElement(JigsawStudioMenuState.Evaluation evaluation) { + UIElement element = element( + "evaluation", + evaluationMaterial(evaluation.state()), + evaluationColor(evaluation.state()) + evaluationName(evaluation.state())); + element.addLore(ChatColor.GRAY + "Evaluation updates automatically."); + element.addLore(ChatColor.GRAY + "Seed: " + evaluation.seed()); + if (evaluation.generation() > 0L) { + element.addLore(ChatColor.GRAY + "Generation: " + evaluation.generation()); + } + if (!evaluation.selectedTheme().isEmpty()) { + element.addLore(ChatColor.GRAY + "Theme: " + safe(evaluation.selectedTheme())); + } + element.addLore(ChatColor.GRAY + "Pieces: " + evaluation.pieceCount()); + if (!evaluation.detail().isEmpty()) { + element.addLore(ChatColor.GRAY + safe(evaluation.detail())); + } + return element; + } + + static Material evaluationMaterial(JigsawStudioEvaluationState state) { + return switch (state) { + case PENDING -> Material.CLOCK; + case VALID -> Material.EMERALD; + case WARNING -> Material.YELLOW_DYE; + case INVALID -> Material.RED_DYE; + case STALE -> Material.GRAY_DYE; + }; + } + + private static ChatColor evaluationColor(JigsawStudioEvaluationState state) { + return switch (state) { + case PENDING -> ChatColor.AQUA; + case VALID -> ChatColor.GREEN; + case WARNING -> ChatColor.YELLOW; + case INVALID -> ChatColor.RED; + case STALE -> ChatColor.GRAY; + }; + } + + private static String evaluationName(JigsawStudioEvaluationState state) { + return switch (state) { + case PENDING -> "Evaluation Pending"; + case VALID -> "Ready to Assemble"; + case WARNING -> "Ready with Warnings"; + case INVALID -> "Assembly Blocked"; + case STALE -> "Evaluation Stale"; + }; + } + + private static Material workcellMaterial(JigsawStudioMenuState.Workcell workcell, boolean selected) { + if (!workcell.enabled()) { + return Material.GRAY_WOOL; + } + if (workcell.loading() || workcell.saving()) { + return Material.YELLOW_WOOL; + } + if (workcell.dirty()) { + return Material.ORANGE_WOOL; + } + return selected ? Material.LIME_WOOL : Material.LIGHT_GRAY_WOOL; + } + + static Material variantMaterial(JigsawStudioMenuState.Variant variant) { + if (variant.active()) { + return Material.JIGSAW; + } + return variant.owned() ? Material.PAPER : Material.GRAY_DYE; + } + + static String workcellStatus(JigsawStudioMenuState.Workcell workcell) { + if (workcell.loading()) { + return ChatColor.YELLOW + "Loading variant"; + } + if (workcell.saving()) { + return ChatColor.YELLOW + "Autosave in progress"; + } + if (workcell.dirty()) { + return ChatColor.GOLD + "Autosave pending"; + } + return ChatColor.GREEN + "Autosaved"; + } + + private static String dimensions(JigsawStudioCellDimensions dimensions) { + return dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth(); + } + + private static String themes(List themes) { + return themes.isEmpty() ? "None" : safe(String.join(", ", themes)); + } + + private static String maximumPlacements(int maximum) { + return maximum == 0 ? "Unlimited" : Integer.toString(maximum); + } + + private static String chance(double chance) { + return String.format(Locale.ROOT, "%.1f%%", chance * 100D); + } + + private static String yesNo(boolean value) { + return value ? "Yes" : "No"; + } + + private static String compatibilityName(JigsawStudioCompatibilityTarget target) { + return switch (target) { + case IRIS_EXTENDED -> "Iris Extended"; + case VANILLA_PORTABLE -> "Vanilla Portable"; + }; + } + + private static UIElement element(String id, Material material, String name) { + return new UIElement(id) + .setMaterial(new MaterialBlock(material)) + .setName(name); + } + + private static String title(String structureKey) { + String value = "Iris Jigsaw: " + safe(displayKey(structureKey)); + return value.length() > 32 ? value.substring(0, 32) : value; + } + + private static String displayKey(String resourceKey) { + int separator = resourceKey.lastIndexOf('/'); + return separator < 0 ? resourceKey : resourceKey.substring(separator + 1); + } + + private static String safe(String value) { + return BoardSVC.untrustedBoardValue(value); + } + + public interface Actions { + Optional menuState(Player player); + + boolean selectWorkcell(Player player, String workcellId); + + boolean switchVariant(Player player, String workcellId, String pieceKey, boolean discardDirty); + + boolean createVariant(Player player, String workcellId, boolean duplicateActive); + + boolean setWorkcellEnabled(Player player, String workcellId, boolean enabled); + + boolean updateWorkcellDimensions( + Player player, + String workcellId, + JigsawStudioCellDimensions dimensions + ); + + boolean updateWorkcellDisplayName(Player player, String workcellId, String displayName); + + boolean setRequireCaps(Player player, boolean requireCaps); + + boolean duplicateActiveFamily(Player player, String themeKey); + + boolean updateThemeSetWeight(Player player, String themeKey, int weight); + + boolean flushAutosave(Player player, String workcellId); + + boolean goToPreview(Player player); + + boolean toggleVariantRotatable(Player player, String workcellId); + + boolean expandVariantToCell(Player player, String workcellId); + + boolean resizeVariant( + Player player, + String workcellId, + String pieceKey, + JigsawStudioCellDimensions dimensions + ); + + boolean updateVariantDisplayName( + Player player, + String workcellId, + String pieceKey, + String displayName + ); + + boolean updateVariantThemes( + Player player, + String workcellId, + String pieceKey, + List themes + ); + + boolean updateVariantRules( + Player player, + String workcellId, + String pieceKey, + JigsawStudioPieceRules rules + ); + + boolean adjustVariantWeight( + Player player, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex, + int delta + ); + + boolean adjustVariantChance( + Player player, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex, + int deltaPercentagePoints + ); + + boolean unlinkVariantMembership( + Player player, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex + ); + + boolean deleteVariant(Player player, String workcellId, String pieceKey); + + boolean deleteProject(Player player); + + boolean giveTool(Player player, JigsawStudioToolPayload payload); + } + + enum RuleField { + MINIMUM_DEPTH("Minimum Depth", Material.LIGHT_BLUE_DYE, RULE_SHIFT_STEP) { + @Override + int value(JigsawStudioPieceRules rules) { + return rules.minimumDepth(); + } + }, + MAXIMUM_DEPTH("Maximum Depth", Material.BLUE_DYE, RULE_SHIFT_STEP) { + @Override + int value(JigsawStudioPieceRules rules) { + return rules.maximumDepth(); + } + }, + MINIMUM_PLACEMENTS("Minimum Placements", Material.TARGET, PLACEMENT_RULE_SHIFT_STEP) { + @Override + int value(JigsawStudioPieceRules rules) { + return rules.minimumPlacements(); + } + }, + MAXIMUM_PLACEMENTS("Maximum Placements", Material.GREEN_DYE, PLACEMENT_RULE_SHIFT_STEP) { + @Override + int value(JigsawStudioPieceRules rules) { + return rules.maximumPlacements(); + } + + @Override + String displayValue(int value) { + return maximumPlacements(value); + } + }; + + private final String displayName; + private final Material material; + private final int shiftStep; + + RuleField(String displayName, Material material, int shiftStep) { + this.displayName = displayName; + this.material = material; + this.shiftStep = shiftStep; + } + + String displayName() { + return displayName; + } + + Material material() { + return material; + } + + int shiftStep() { + return shiftStep; + } + + String displayValue(int value) { + return Integer.toString(value); + } + + abstract int value(JigsawStudioPieceRules rules); + } + + enum DimensionAxis { + WIDTH("Width") { + @Override + int value(JigsawStudioCellDimensions dimensions) { + return dimensions.width(); + } + }, + HEIGHT("Height") { + @Override + int value(JigsawStudioCellDimensions dimensions) { + return dimensions.height(); + } + }, + DEPTH("Depth") { + @Override + int value(JigsawStudioCellDimensions dimensions) { + return dimensions.depth(); + } + }; + + private final String displayName; + + DimensionAxis(String displayName) { + this.displayName = displayName; + } + + String displayName() { + return displayName; + } + + abstract int value(JigsawStudioCellDimensions dimensions); + } + + record ToolboxTool(String displayName, JigsawStudioToolPayload payload) { + ToolboxTool { + displayName = Objects.requireNonNull(displayName, "Jigsaw Studio toolbox display name").trim(); + if (displayName.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio toolbox display name cannot be blank"); + } + payload = Objects.requireNonNull(payload, "Jigsaw Studio toolbox payload"); + } + } + + private record PendingUnlink( + UUID requestId, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex, + long expiresAtNanos + ) { + } + + private record PendingDelete( + UUID requestId, + String workcellId, + String pieceKey, + long expiresAtNanos + ) { + } + + private record PendingProjectDelete(UUID requestId, long expiresAtNanos) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuState.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuState.java new file mode 100644 index 000000000..3ad910434 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioMenuState.java @@ -0,0 +1,289 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public record JigsawStudioMenuState( + UUID worldId, + UUID requestId, + String structureKey, + JigsawStudioMode mode, + JigsawStudioCompatibilityTarget compatibilityTarget, + boolean requireCaps, + List themeSets, + String selectedWorkcellId, + Evaluation evaluation, + List workcells +) { + public JigsawStudioMenuState { + worldId = Objects.requireNonNull(worldId, "Jigsaw Studio menu world ID"); + requestId = Objects.requireNonNull(requestId, "Jigsaw Studio menu request ID"); + structureKey = requireText(structureKey, "structure key"); + mode = Objects.requireNonNull(mode, "Jigsaw Studio menu mode"); + compatibilityTarget = Objects.requireNonNull( + compatibilityTarget, + "Jigsaw Studio menu compatibility target"); + themeSets = List.copyOf(Objects.requireNonNull(themeSets, "Jigsaw Studio menu theme sets")); + selectedWorkcellId = optionalText(selectedWorkcellId); + evaluation = Objects.requireNonNull(evaluation, "Jigsaw Studio menu evaluation"); + workcells = List.copyOf(Objects.requireNonNull(workcells, "Jigsaw Studio menu workcells")); + + Set workcellIds = new HashSet<>(); + for (Workcell workcell : workcells) { + Workcell resolved = Objects.requireNonNull(workcell, "Jigsaw Studio menu workcell"); + if (!workcellIds.add(resolved.stableId())) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio menu workcell " + resolved.stableId()); + } + } + if (!selectedWorkcellId.isEmpty() && !workcellIds.contains(selectedWorkcellId)) { + throw new IllegalArgumentException("Selected Jigsaw Studio menu workcell is not present"); + } + + Set themeKeys = new HashSet<>(); + for (ThemeSet themeSet : themeSets) { + ThemeSet resolved = Objects.requireNonNull(themeSet, "Jigsaw Studio menu theme set"); + if (!themeKeys.add(resolved.key())) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio menu theme " + resolved.key()); + } + } + } + + public Workcell selectedWorkcell() { + if (selectedWorkcellId.isEmpty()) { + return null; + } + return workcell(selectedWorkcellId); + } + + public boolean irisExtended() { + return compatibilityTarget == JigsawStudioCompatibilityTarget.IRIS_EXTENDED; + } + + public Workcell workcell(String stableId) { + if (stableId == null) { + return null; + } + for (Workcell workcell : workcells) { + if (workcell.stableId().equals(stableId)) { + return workcell; + } + } + return null; + } + + public ThemeSet themeSet(String key) { + if (key == null) { + return null; + } + for (ThemeSet themeSet : themeSets) { + if (themeSet.key().equals(key)) { + return themeSet; + } + } + return null; + } + + public record ThemeSet(String key, int weight) { + public ThemeSet { + key = requireText(key, "theme key"); + if (weight < 1) { + throw new IllegalArgumentException("Jigsaw Studio menu theme weight must be positive"); + } + } + } + + public record Evaluation( + JigsawStudioEvaluationState state, + long generation, + long seed, + String selectedTheme, + int pieceCount, + String detail + ) { + public Evaluation { + state = Objects.requireNonNull(state, "Jigsaw Studio evaluation state"); + if (generation < 0L) { + throw new IllegalArgumentException("Jigsaw Studio evaluation generation cannot be negative"); + } + selectedTheme = optionalText(selectedTheme); + if (pieceCount < 0) { + throw new IllegalArgumentException("Jigsaw Studio evaluation piece count cannot be negative"); + } + detail = optionalText(detail); + } + + public static Evaluation pending() { + return new Evaluation( + JigsawStudioEvaluationState.PENDING, + 0L, + 1337L, + "", + 0, + "Iris evaluates the graph automatically as authoring state changes."); + } + + public static Evaluation from(JigsawStudioGraphEvaluation evaluation) { + JigsawStudioGraphEvaluation source = Objects.requireNonNull( + evaluation, + "Jigsaw Studio graph evaluation"); + return new Evaluation( + source.state(), + source.generation(), + source.seed(), + source.selectedTheme(), + source.pieceCount(), + source.detail()); + } + } + + public record Workcell( + String stableId, + String canonicalName, + String displayName, + JigsawStudioCellDimensions capacity, + boolean enabled, + String activeVariantKey, + boolean dirty, + boolean saving, + boolean loading, + List variants + ) { + public Workcell { + stableId = requireText(stableId, "workcell ID"); + canonicalName = requireText(canonicalName, "workcell canonical name"); + displayName = requireText(displayName, "workcell display name"); + capacity = Objects.requireNonNull(capacity, "Jigsaw Studio menu workcell capacity"); + activeVariantKey = optionalText(activeVariantKey); + variants = List.copyOf(Objects.requireNonNull(variants, "Jigsaw Studio menu variants")); + + Set variantKeys = new HashSet<>(); + int activeVariants = 0; + for (Variant variant : variants) { + Variant resolved = Objects.requireNonNull(variant, "Jigsaw Studio menu variant"); + if (!variantKeys.add(resolved.pieceKey())) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio menu variant " + resolved.pieceKey()); + } + if (resolved.active()) { + activeVariants++; + if (!resolved.pieceKey().equals(activeVariantKey)) { + throw new IllegalArgumentException("Active Jigsaw Studio menu variant key does not match"); + } + } + } + if ((activeVariantKey.isEmpty() && activeVariants != 0) + || (!activeVariantKey.isEmpty() && activeVariants != 1)) { + throw new IllegalArgumentException("Jigsaw Studio menu workcell active variant is inconsistent"); + } + } + + public Variant activeVariant() { + if (activeVariantKey.isEmpty()) { + return null; + } + for (Variant variant : variants) { + if (variant.pieceKey().equals(activeVariantKey)) { + return variant; + } + } + return null; + } + + public boolean busy() { + return saving || loading; + } + } + + public record Variant( + String pieceKey, + String displayName, + Optional dimensions, + boolean active, + boolean owned, + boolean rotatable, + boolean rotationEditable, + boolean resizableToCapacity, + List themes, + JigsawStudioPieceRules rules, + List memberships + ) { + public Variant { + pieceKey = requireText(pieceKey, "variant piece key"); + displayName = requireText(displayName, "variant display name"); + dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio menu variant dimensions"); + List resolvedThemes = new ArrayList<>(); + Set uniqueThemes = new HashSet<>(); + for (String theme : Objects.requireNonNull(themes, "Jigsaw Studio menu variant themes")) { + String resolvedTheme = requireText(theme, "variant theme"); + if (!uniqueThemes.add(resolvedTheme)) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio menu variant theme " + + resolvedTheme); + } + resolvedThemes.add(resolvedTheme); + } + themes = List.copyOf(resolvedThemes); + rules = Objects.requireNonNull(rules, "Jigsaw Studio menu variant rules"); + memberships = List.copyOf(Objects.requireNonNull( + memberships, + "Jigsaw Studio menu variant memberships")); + if (rotationEditable && !owned) { + throw new IllegalArgumentException("Read-only Jigsaw Studio variants cannot edit rotation"); + } + if (resizableToCapacity && (!owned || !active)) { + throw new IllegalArgumentException( + "Only the active owned Jigsaw Studio variant can resize to its workcell capacity"); + } + + Set identities = new HashSet<>(); + for (Membership membership : memberships) { + Membership resolved = Objects.requireNonNull( + membership, + "Jigsaw Studio menu variant membership"); + MembershipIdentity identity = new MembershipIdentity(resolved.poolKey(), resolved.entryIndex()); + if (!identities.add(identity)) { + throw new IllegalArgumentException("Duplicate Jigsaw Studio menu membership " + + resolved.poolKey() + "[" + resolved.entryIndex() + "]"); + } + } + } + } + + public record Membership(String poolKey, int entryIndex, int weight, double chance) { + public Membership { + poolKey = requireText(poolKey, "membership pool key"); + if (entryIndex < 0) { + throw new IllegalArgumentException("Jigsaw Studio menu membership index cannot be negative"); + } + if (weight < 1) { + throw new IllegalArgumentException("Jigsaw Studio menu membership weight must be positive"); + } + if (!Double.isFinite(chance) || chance < 0D || chance > 1D) { + throw new IllegalArgumentException("Jigsaw Studio menu membership chance must be within 0 and 1"); + } + } + } + + private record MembershipIdentity(String poolKey, int entryIndex) { + } + + private static String requireText(String value, String name) { + String normalized = optionalText(value); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio menu " + name + " cannot be blank"); + } + return normalized; + } + + private static String optionalText(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java new file mode 100644 index 000000000..b512d123f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioPreviewRenderer.java @@ -0,0 +1,455 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +public final class JigsawStudioPreviewRenderer { + private static final int MAX_BLOCKS = 250_000; + private static final String AIR = "minecraft:air"; + private static final String STRUCTURE_VOID = "minecraft:structure_void"; + + private final Map requests = new HashMap<>(); + + public static PreviewPlan plan(List pieces) throws IOException { + List source = List.copyOf(Objects.requireNonNull( + pieces, + "Jigsaw Studio preview pieces")); + if (source.isEmpty()) { + return PreviewPlan.empty(); + } + Map blocks = new LinkedHashMap<>(); + int minimumX = Integer.MAX_VALUE; + int minimumY = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumY = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (PlacedStructurePiece piece : source) { + if (piece == null || piece.getObject() == null || piece.getPiece() == null + || piece.getRotation() == null) { + throw new IOException("Jigsaw Studio preview contains an incomplete placed piece"); + } + minimumX = Math.min(minimumX, piece.getMinX()); + minimumY = Math.min(minimumY, piece.getMinY()); + minimumZ = Math.min(minimumZ, piece.getMinZ()); + maximumX = Math.max(maximumX, piece.getMaxX()); + maximumY = Math.max(maximumY, piece.getMaxY()); + maximumZ = Math.max(maximumZ, piece.getMaxZ()); + appendObject(blocks, piece); + appendFinalStates(blocks, piece); + if (blocks.size() > MAX_BLOCKS) { + throw new IOException("The seed-1337 preview exceeds the Studio render limit of " + + MAX_BLOCKS + " explicit blocks"); + } + } + return new PreviewPlan( + Map.copyOf(blocks), + new PreviewBounds(minimumX, minimumY, minimumZ, maximumX, maximumY, maximumZ)); + } + + public void render( + World world, + UUID requestId, + long generation, + PreviewPlan plan, + Consumer completion + ) { + World activeWorld = Objects.requireNonNull(world, "Jigsaw Studio preview world"); + UUID activeRequestId = Objects.requireNonNull(requestId, "Jigsaw Studio preview request ID"); + PreviewPlan activePlan = Objects.requireNonNull(plan, "Jigsaw Studio preview plan"); + Consumer callback = Objects.requireNonNull(completion, "Jigsaw Studio preview callback"); + Map previous; + Set uncertain; + Map> updates; + synchronized (this) { + RenderState old = requests.get(activeRequestId); + previous = old == null || !old.worldId().equals(activeWorld.getUID()) + ? Map.of() + : old.blocks(); + uncertain = old == null || !old.worldId().equals(activeWorld.getUID()) + ? Set.of() + : Set.copyOf(old.pending()); + updates = updates(previous, activePlan.blocks(), uncertain); + Set pending = new HashSet<>(); + for (List chunkUpdates : updates.values()) { + for (BlockUpdate update : chunkUpdates) { + pending.add(update.position()); + } + } + requests.put(activeRequestId, new RenderState( + activeWorld.getUID(), generation, activePlan.blocks(), activePlan.bounds(), pending)); + } + if (updates.isEmpty()) { + callback.accept(new RenderResult(true, 0, "")); + return; + } + AtomicInteger remaining = new AtomicInteger(updates.size()); + AtomicBoolean failed = new AtomicBoolean(); + for (Map.Entry> chunk : updates.entrySet()) { + int chunkX = chunkX(chunk.getKey()); + int chunkZ = chunkZ(chunk.getKey()); + boolean scheduled = J.runRegion( + activeWorld, + chunkX, + chunkZ, + () -> applyChunk( + activeWorld, + activeRequestId, + generation, + chunk.getValue(), + remaining, + failed, + callback)); + if (!scheduled) { + failed.set(true); + if (remaining.decrementAndGet() == 0) { + callback.accept(new RenderResult( + false, + activePlan.blocks().size(), + "One or more preview chunks could not be scheduled")); + } + } + } + } + + public synchronized PreviewBounds bounds(UUID requestId) { + RenderState state = requests.get(requestId); + return state == null ? null : state.bounds(); + } + + public synchronized boolean contains(UUID requestId, int x, int y, int z) { + RenderState state = requests.get(requestId); + return state != null && state.bounds().contains(x, y, z); + } + + public void removeRequest(UUID requestId) { + if (requestId == null) { + return; + } + RenderState removed; + synchronized (this) { + removed = requests.remove(requestId); + } + if (removed != null) { + World world = Bukkit.getWorld(removed.worldId()); + if (world != null) { + clear(world, removalPositions(removed)); + } + } + } + + void forgetRequest(UUID requestId) { + if (requestId == null) { + return; + } + synchronized (this) { + requests.remove(requestId); + } + } + + public void removeAll() { + Map removed; + synchronized (this) { + removed = Map.copyOf(requests); + requests.clear(); + } + for (RenderState state : removed.values()) { + World world = Bukkit.getWorld(state.worldId()); + if (world != null) { + clear(world, removalPositions(state)); + } + } + } + + private static void appendObject( + Map blocks, + PlacedStructurePiece piece + ) throws IOException { + IrisObject object = piece.getObject(); + IrisObjectRotation rotation = piece.getRotation(); + for (Map.Entry entry : object.getBlocks()) { + IrisBlockVector rotated = rotation.rotate(entry.getKey()); + PlatformBlockState state = rotation.rotate(entry.getValue(), 0, 0, 0); + putState( + blocks, + new BlockPosition( + piece.getX() + rotated.getBlockX(), + piece.getY() + rotated.getBlockY(), + piece.getZ() + rotated.getBlockZ()), + state, + "object block"); + } + } + + private static void appendFinalStates( + Map blocks, + PlacedStructurePiece piece + ) throws IOException { + IrisObject object = piece.getObject(); + IrisObjectRotation rotation = piece.getRotation(); + if (piece.getPiece().getConnectors() == null) { + return; + } + for (IrisJigsawConnector connector : piece.getPiece().getConnectors()) { + if (connector == null || connector.getPosition() == null) { + throw new IOException("Jigsaw Studio preview contains an incomplete connector"); + } + IrisBlockVector signed = new IrisBlockVector( + connector.getPosition().getX() - object.getCenter().getBlockX(), + connector.getPosition().getY() - object.getCenter().getBlockY(), + connector.getPosition().getZ() - object.getCenter().getBlockZ()); + IrisBlockVector rotated = rotation.rotate(signed); + PlatformBlockState source = B.getStateOrNull(connector.getFinalState(), false); + PlatformBlockState state = source == null ? null : rotation.rotate(source, 0, 0, 0); + putState( + blocks, + new BlockPosition( + piece.getX() + rotated.getBlockX(), + piece.getY() + rotated.getBlockY(), + piece.getZ() + rotated.getBlockZ()), + state, + "connector final state"); + } + } + + private static void putState( + Map blocks, + BlockPosition position, + PlatformBlockState state, + String source + ) throws IOException { + if (state == null || state.key() == null || state.key().isBlank()) { + throw new IOException("Jigsaw Studio preview could not resolve a " + source); + } + String key = state.key(); + if (AIR.equals(key) || STRUCTURE_VOID.equals(key)) { + blocks.remove(position); + return; + } + blocks.put(position, key); + } + + private void applyChunk( + World world, + UUID requestId, + long generation, + List updates, + AtomicInteger remaining, + AtomicBoolean failed, + Consumer completion + ) { + int changed = 0; + List applied = new ArrayList<>(updates.size()); + try { + if (!isCurrent(world.getUID(), requestId, generation)) { + return; + } + for (BlockUpdate update : updates) { + Block block = world.getBlockAt(update.position().x(), update.position().y(), update.position().z()); + BlockData data = Bukkit.createBlockData(update.stateKey()); + block.setBlockData(data, false); + applied.add(update.position()); + changed++; + } + } catch (RuntimeException exception) { + failed.set(true); + IrisLogging.reportError(exception); + } finally { + synchronized (this) { + RenderState active = requests.get(requestId); + if (active != null + && active.worldId().equals(world.getUID()) + && active.generation() == generation) { + active.pending().removeAll(applied); + } + } + if (remaining.decrementAndGet() == 0) { + RenderState state; + boolean pendingEmpty; + synchronized (this) { + state = requests.get(requestId); + pendingEmpty = state != null && state.pending().isEmpty(); + } + boolean current = state != null + && state.worldId().equals(world.getUID()) + && state.generation() == generation; + completion.accept(new RenderResult( + current && pendingEmpty && !failed.get(), + current ? state.blocks().size() : changed, + failed.get() || current && !pendingEmpty + ? "One or more preview blocks could not be rendered" + : "")); + } + } + } + + private synchronized boolean isCurrent(UUID worldId, UUID requestId, long generation) { + RenderState state = requests.get(requestId); + return state != null && state.worldId().equals(worldId) && state.generation() == generation; + } + + private static Map> updates( + Map previous, + Map next, + Set uncertain + ) { + Set positions = new HashSet<>(previous.keySet()); + positions.addAll(next.keySet()); + positions.addAll(uncertain); + Map> updates = new HashMap<>(); + for (BlockPosition position : positions) { + String nextState = next.getOrDefault(position, AIR); + if (!requiresUpdate(position, previous, next, uncertain)) { + continue; + } + updates.computeIfAbsent( + chunkKey(position.x() >> 4, position.z() >> 4), + ignored -> new ArrayList<>()).add(new BlockUpdate(position, nextState)); + } + return updates; + } + + static boolean requiresUpdate( + BlockPosition position, + Map previous, + Map next, + Set uncertain + ) { + return uncertain.contains(position) + || !next.getOrDefault(position, AIR).equals(previous.get(position)); + } + + private static void clear(World world, Set positions) { + Map> updates = new HashMap<>(); + for (BlockPosition position : positions) { + updates.computeIfAbsent( + chunkKey(position.x() >> 4, position.z() >> 4), + ignored -> new ArrayList<>()).add(new BlockUpdate(position, AIR)); + } + for (Map.Entry> chunk : updates.entrySet()) { + J.runRegion(world, chunkX(chunk.getKey()), chunkZ(chunk.getKey()), () -> { + for (BlockUpdate update : chunk.getValue()) { + world.getBlockAt( + update.position().x(), + update.position().y(), + update.position().z()) + .setType(Material.AIR, false); + } + }); + } + } + + private static Set removalPositions(RenderState state) { + Set positions = new HashSet<>(state.blocks().keySet()); + positions.addAll(state.pending()); + return Set.copyOf(positions); + } + + private static long chunkKey(int chunkX, int chunkZ) { + return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL); + } + + private static int chunkX(long chunkKey) { + return (int) (chunkKey >> 32); + } + + private static int chunkZ(long chunkKey) { + return (int) chunkKey; + } + + public record PreviewPlan(Map blocks, PreviewBounds bounds) { + public PreviewPlan { + blocks = Map.copyOf(Objects.requireNonNull(blocks, "Jigsaw Studio preview blocks")); + bounds = Objects.requireNonNull(bounds, "Jigsaw Studio preview bounds"); + } + + static PreviewPlan empty() { + return new PreviewPlan(Map.of(), PreviewBounds.empty()); + } + } + + public record PreviewBounds( + int minimumX, + int minimumY, + int minimumZ, + int maximumX, + int maximumY, + int maximumZ + ) { + static PreviewBounds empty() { + return new PreviewBounds(0, 0, 0, -1, -1, -1); + } + + public boolean isEmpty() { + return maximumX < minimumX || maximumY < minimumY || maximumZ < minimumZ; + } + + public int centerX() { + return isEmpty() ? 0 : minimumX + (maximumX - minimumX) / 2; + } + + public int centerZ() { + return isEmpty() ? 0 : minimumZ + (maximumZ - minimumZ) / 2; + } + + public boolean contains(int x, int y, int z) { + return !isEmpty() + && x >= minimumX && x <= maximumX + && y >= minimumY && y <= maximumY + && z >= minimumZ && z <= maximumZ; + } + } + + public record BlockPosition(int x, int y, int z) { + } + + public record RenderResult(boolean successful, int blockCount, String failure) { + public RenderResult { + failure = failure == null ? "" : failure; + } + } + + private record RenderState( + UUID worldId, + long generation, + Map blocks, + PreviewBounds bounds, + Set pending + ) { + private RenderState { + Objects.requireNonNull(worldId, "Jigsaw Studio preview world ID"); + blocks = Map.copyOf(Objects.requireNonNull(blocks, "Jigsaw Studio preview state blocks")); + bounds = Objects.requireNonNull(bounds, "Jigsaw Studio preview state bounds"); + pending = Objects.requireNonNull(pending, "Jigsaw Studio preview pending blocks"); + } + } + + private record BlockUpdate(BlockPosition position, String stateKey) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssembler.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssembler.java new file mode 100644 index 000000000..8bf127797 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssembler.java @@ -0,0 +1,185 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.pack.StructurePackageClosure; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioAuthoringAccess; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +final class JigsawStudioResourceBundleAssembler { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private JigsawStudioResourceBundleAssembler() { + } + + static Assembly assemble( + Path packRoot, + String structureKey, + String pieceKey, + byte[] objectContent, + List connectors, + boolean hasBlockEntities + ) throws IOException { + Path root = Objects.requireNonNull(packRoot, "Jigsaw Studio pack root").toAbsolutePath().normalize(); + String rootStructure = requireResourceKey(structureKey, "structure"); + String editedPiece = requireResourceKey(pieceKey, "piece"); + StructureKey ownershipKey = new StructureKey("iris", rootStructure); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + Path manifestPath = writer.ownershipManifestPath(ownershipKey); + if (!Files.isRegularFile(manifestPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Jigsaw Studio cannot save '" + rootStructure + + "' because it is not Studio-owned. Existing unowned graphs are read-only; create a new Jigsaw Studio project to author in-game."); + } + byte[] manifestContent = Files.readAllBytes(manifestPath); + StructureOwnershipManifest manifest; + try { + manifest = StructureOwnershipManifest.fromJson(manifestContent); + } catch (RuntimeException exception) { + throw new IOException("Jigsaw Studio cannot read the ownership manifest for '" + rootStructure + "'", exception); + } + JigsawStudioAuthoringAccess.requireEditable(manifest); + if (!manifest.structure().equals(ownershipKey)) { + throw new IOException("Jigsaw Studio ownership manifest belongs to " + manifest.structure() + + ", not " + ownershipKey); + } + + StructurePackageClosure closure = StructurePackageClosure.collect(root.toFile(), List.of(rootStructure)); + if (!closure.isValid()) { + throw new IOException("Jigsaw Studio cannot save an invalid structure graph: " + + String.join("; ", closure.errors())); + } + Set reachablePaths = resourcePaths(closure); + for (String relativePath : reachablePaths) { + if (!manifest.resourceHashes().containsKey(relativePath)) { + throw new IOException("Jigsaw Studio ownership conflict: reachable resource '" + relativePath + + "' is not owned by structure '" + rootStructure + "'."); + } + } + + String piecePath = "jigsaw-pieces/" + editedPiece + ".json"; + if (!manifest.resourceHashes().containsKey(piecePath)) { + throw new IOException("Jigsaw Studio ownership conflict: piece '" + editedPiece + + "' is not owned by structure '" + rootStructure + "'."); + } + Path absolutePiecePath = resolveOwnedResource(root, piecePath); + IrisJigsawPiece piece; + JsonObject pieceJson; + try { + JsonElement parsed = GSON.fromJson( + Files.readString(absolutePiecePath, StandardCharsets.UTF_8), + JsonElement.class); + if (parsed == null || !parsed.isJsonObject()) { + throw new IllegalArgumentException("Jigsaw piece is not a JSON object"); + } + pieceJson = parsed.getAsJsonObject(); + piece = GSON.fromJson(pieceJson, IrisJigsawPiece.class); + } catch (RuntimeException exception) { + throw new IOException("Jigsaw Studio cannot parse piece '" + editedPiece + "'", exception); + } + if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) { + throw new IOException("Jigsaw Studio piece '" + editedPiece + "' does not declare an object"); + } + String objectKey = requireResourceKey(piece.getObject(), "object"); + String objectPath = "objects/" + objectKey + ".iob"; + if (!manifest.resourceHashes().containsKey(objectPath)) { + throw new IOException("Jigsaw Studio ownership conflict: object '" + objectKey + + "' is not owned by structure '" + rootStructure + "'."); + } + piece.setConnectors(new KList<>(List.copyOf(connectors))); + pieceJson.add("connectors", GSON.toJsonTree(piece.getConnectors())); + + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(ownershipKey) + .source(manifest.source()) + .backend(manifest.backend()) + .capabilities(manifest.capabilities()) + .losses(manifest.losses()) + .capability(StructureCapability.CONNECTORS); + if (hasBlockEntities) { + bundle.capability(StructureCapability.BLOCK_ENTITIES); + } + for (String relativePath : manifest.resourceHashes().keySet()) { + if (relativePath.equals(piecePath)) { + bundle.textResource(relativePath, GSON.toJson(pieceJson) + "\n"); + } else if (relativePath.equals(objectPath)) { + bundle.resource(relativePath, objectContent); + } else { + Path resource = resolveOwnedResource(root, relativePath); + bundle.resource(relativePath, Files.readAllBytes(resource)); + } + } + return new Assembly(bundle.build(), objectKey, piece, StructureHash.sha256(manifestContent)); + } + + private static Set resourcePaths(StructurePackageClosure closure) { + Set resources = new LinkedHashSet<>(); + addPaths(resources, "structures", closure.structures(), ".json"); + addPaths(resources, "jigsaw-pools", closure.pools(), ".json"); + addPaths(resources, "jigsaw-pieces", closure.pieces(), ".json"); + addPaths(resources, "objects", closure.objects(), ".iob"); + addPaths(resources, "loot", closure.loot(), ".json"); + return resources; + } + + private static void addPaths(Set resources, String folder, Set keys, String extension) { + for (String key : keys) { + resources.add(folder + "/" + key + extension); + } + } + + private static Path resolveOwnedResource(Path root, String relativePath) throws IOException { + StructureResourceBundle.validateRelativePath(relativePath); + Path resource = root.resolve(relativePath).normalize(); + if (!resource.startsWith(root)) { + throw new IOException("Jigsaw Studio resource escapes its pack root: " + relativePath); + } + if (!Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Jigsaw Studio owned resource is missing or not a regular file: " + relativePath); + } + Path realRoot = root.toRealPath(); + Path realResource = resource.toRealPath(); + if (!realResource.startsWith(realRoot)) { + throw new IOException("Jigsaw Studio owned resource escapes through a symbolic link: " + relativePath); + } + return resource; + } + + private static String requireResourceKey(String key, String kind) { + String normalized = Objects.requireNonNull(key, "Jigsaw Studio " + kind + " key").trim(); + StructureResourceBundle.validateRelativePath(normalized); + return normalized; + } + + record Assembly( + StructureResourceBundle bundle, + String objectKey, + IrisJigsawPiece piece, + String expectedManifestHash + ) { + Assembly { + Objects.requireNonNull(bundle, "Jigsaw Studio resource bundle"); + Objects.requireNonNull(objectKey, "Jigsaw Studio object key"); + Objects.requireNonNull(piece, "Jigsaw Studio piece"); + Objects.requireNonNull(expectedManifestHash, "Jigsaw Studio expected manifest hash"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java new file mode 100644 index 000000000..4c0954a5d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioService.java @@ -0,0 +1,7976 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarDirection; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioControlPosition; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphMapper; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioGraphEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMarkerKeyCodec; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolMembership; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioProjectDeletionService; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioStructureEditor; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioTripleSneakTracker; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteOptions; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.framework.StructureAssembler; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; +import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; +import art.arcane.iris.engine.framework.structure.StructureGraphCompiler; +import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic; +import art.arcane.iris.engine.framework.structure.StructureGraphResolver; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator; +import art.arcane.iris.platform.bukkit.BukkitBlockState; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.plugin.IrisService; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.RNG; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Particle; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; +import org.bukkit.block.DoubleChest; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.Jigsaw; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockCookEvent; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFormEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockGrowEvent; +import org.bukkit.event.block.BlockMultiPlaceEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.block.BlockRedstoneEvent; +import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.event.block.BrewingStartEvent; +import org.bukkit.event.block.CrafterCraftEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.inventory.BrewEvent; +import org.bukkit.event.inventory.BrewingStandFuelEvent; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; +import org.bukkit.event.inventory.InventoryPickupItemEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerPortalEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.player.PlayerToggleSneakEvent; +import org.bukkit.event.server.ServerCommandEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; +import org.bukkit.event.world.StructureGrowEvent; +import org.bukkit.event.world.WorldUnloadEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.BlockInventoryHolder; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +public final class JigsawStudioService implements IrisService, JigsawStudioMenuController.Actions { + private static final int AUTOSAVE_DEBOUNCE_TICKS = 40; + private static final int AUTOSAVE_RETRY_TICKS = 5; + private static final List AUTOSAVE_PERSISTENT_RETRY_DELAYS = + List.of(40, 80, 160, 320, 600); + private static final long PREVIEW_SEED = 1337L; + private static final JigsawStudioPieceRules DEFAULT_PIECE_RULES = + new JigsawStudioPieceRules(0, 30, 0, 0, false); + private static final long TOOL_CONFIRM_NANOS = 10_000_000_000L; + private static final int VISUAL_INTERVAL_TICKS = 8; + private static final int PARTICLE_BUDGET = 384; + private static final int HYDRATION_RETRY_TICKS = 2; + private static final int MAX_HYDRATION_ATTEMPTS = 40; + private static final int JIGSAW_TILE_WATCH_INTERVAL_TICKS = 5; + private static final double VISUAL_RANGE = 96.0D; + private static final double VISUAL_RANGE_SQUARED = VISUAL_RANGE * VISUAL_RANGE; + private static final Color SELECTED_COLOR = Color.AQUA; + private static final Color NEARBY_COLOR = Color.fromRGB(92, 102, 118); + private static final Color INVALID_BAY_COLOR = Color.RED; + private static final Color INVALID_CONNECTOR_COLOR = Color.RED; + private static final Color VALID_CONNECTOR_COLOR = Color.LIME; + private static final Color ASSEMBLY_PREVIEW_COLOR = Color.fromRGB(180, 90, 255); + private static final Color LIVE_PREVIEW_WARNING_COLOR = Color.fromRGB(255, 180, 55); + private static final long ASSEMBLY_PREVIEW_MILLIS = 10_000L; + private static final Set MUTATING_COMMANDS = Set.of( + "biome", "brush", "chunk", "clone", "copy", "cut", "data", "deform", "drain", + "execute", "faces", "fill", "fillbiome", "fixlava", "fixwater", "flip", "flora", + "forest", "function", "gmask", "green", "hollow", "item", "load", "mask", "move", + "naturalize", "overlay", "paste", "place", "redo", "regen", "replace", "replacenear", + "restore", "rotate", "schedule", "schem", "schematic", "set", "setblock", "smooth", + "snow", "sphere", "stack", "thaw", "undo", "walls"); + private static final Set SAFE_NON_OWNER_COMMANDS = Set.of( + "help", "list", "me", "msg", "ping", "pl", "plugins", "r", "reply", "rules", + "say", "tell", "ver", "version", "w", "whisper"); + private static final Comparator NEW_CONNECTOR_SOURCE_POSITION_ORDER = Comparator + .comparingInt((IrisJigsawConnector connector) -> connector.getPosition().getX()) + .thenComparingInt(connector -> connector.getPosition().getY()) + .thenComparingInt(connector -> connector.getPosition().getZ()); + private static JigsawStudioService INSTANCE; + + private final Map studios = new ConcurrentHashMap<>(); + private final Set particlesDisabled = ConcurrentHashMap.newKeySet(); + private final Set visualizationLoops = ConcurrentHashMap.newKeySet(); + private final Map assemblyPreviews = new ConcurrentHashMap<>(); + private final Map playerWorkcells = new ConcurrentHashMap<>(); + private final Map autosaves = new ConcurrentHashMap<>(); + private final Map deferredDuplications = new ConcurrentHashMap<>(); + private final Map jigsawTileWatches = new ConcurrentHashMap<>(); + private final Map toolConfirmations = new ConcurrentHashMap<>(); + private final Map evaluations = new ConcurrentHashMap<>(); + private final JigsawStudioTripleSneakTracker tripleSneakTracker = new JigsawStudioTripleSneakTracker(); + private final JigsawStudioToolCodec toolCodec = new JigsawStudioToolCodec(); + private final JigsawStudioDisabledWorkcellRenderer disabledWorkcellRenderer = + new JigsawStudioDisabledWorkcellRenderer(); + private final JigsawStudioPreviewRenderer previewRenderer = new JigsawStudioPreviewRenderer(); + private final Object saveLifecycleLock = new Object(); + private final Set savesInProgress = new HashSet<>(); + private final Set graphMutationsInProgress = new HashSet<>(); + private final Set materializationsInProgress = new HashSet<>(); + private final Set exportsInProgress = new HashSet<>(); + private final Set closingRequests = new HashSet<>(); + private final Set discardingRequests = new HashSet<>(); + private final Set reopenRequiredRequests = ConcurrentHashMap.newKeySet(); + private final Set unregisterRetries = ConcurrentHashMap.newKeySet(); + private final Set unregisterDrainWarnings = ConcurrentHashMap.newKeySet(); + private volatile JigsawStudioMenuController menuController; + private volatile boolean enabled; + + public static JigsawStudioService get() { + JigsawStudioService service = INSTANCE; + return service == null ? IrisServices.get(JigsawStudioService.class) : service; + } + + @Override + public void onEnable() { + enabled = true; + menuController = new JigsawStudioMenuController(BukkitPlatform.volmitPlugin(), this); + INSTANCE = this; + } + + @Override + public void onDisable() { + finalizeAllJigsawTileWatches(); + drainAutosavesBeforeDisable(); + enabled = false; + JigsawStudioMenuController activeMenuController = menuController; + menuController = null; + if (activeMenuController != null) { + activeMenuController.closeAll(); + } + particlesDisabled.clear(); + visualizationLoops.clear(); + assemblyPreviews.clear(); + playerWorkcells.clear(); + autosaves.clear(); + deferredDuplications.clear(); + jigsawTileWatches.clear(); + toolConfirmations.clear(); + tripleSneakTracker.clearAll(); + disabledWorkcellRenderer.removeAll(); + evaluations.clear(); + previewRenderer.removeAll(); + reopenRequiredRequests.clear(); + unregisterRetries.clear(); + unregisterDrainWarnings.clear(); + synchronized (saveLifecycleLock) { + studios.clear(); + savesInProgress.clear(); + graphMutationsInProgress.clear(); + materializationsInProgress.clear(); + exportsInProgress.clear(); + closingRequests.clear(); + discardingRequests.clear(); + } + INSTANCE = null; + } + + public void register(Engine engine, JigsawStudioGenerator generator) { + Engine activeEngine = Objects.requireNonNull(engine, "Jigsaw Studio engine"); + JigsawStudioGenerator activeGenerator = Objects.requireNonNull(generator, "Jigsaw Studio generator"); + World world = BukkitWorldBinding.world(activeEngine.getTarget().getWorld()); + if (world == null) { + return; + } + ActiveStudio existing = studios.get(world.getUID()); + if (existing != null && existing.generator() == activeGenerator) { + return; + } + if (existing != null) { + UUID existingRequestId = existing.generator().getRequest().requestId(); + finalizeJigsawTileWatches(existingRequestId); + drainAutosavesBeforeRemoval(existing); + if (requiresLifecycleDrain(existing) && !discardingRequest(existingRequestId)) { + IrisLogging.warn("Jigsaw Studio registration deferred while request %s finishes its final autosave drain", + existingRequestId); + return; + } + } + ActiveStudio next = new ActiveStudio( + world.getUID(), + world, + activeEngine, + activeGenerator, + new ConcurrentHashMap<>(), + ConcurrentHashMap.newKeySet(), + new AtomicLong()); + UUID displacedRequestId = null; + synchronized (saveLifecycleLock) { + ActiveStudio previous = studios.get(world.getUID()); + if (previous != null && previous.generator() == activeGenerator) { + return; + } + ActiveStudio displaced = studios.put(world.getUID(), next); + if (displaced != null) { + displacedRequestId = displaced.generator().getRequest().requestId(); + savesInProgress.remove(displacedRequestId); + graphMutationsInProgress.remove(displacedRequestId); + materializationsInProgress.remove(displacedRequestId); + exportsInProgress.remove(displacedRequestId); + closingRequests.remove(displacedRequestId); + discardingRequests.remove(displacedRequestId); + reopenRequiredRequests.remove(displacedRequestId); + } + } + if (displacedRequestId != null) { + clearAutosaves(displacedRequestId); + clearJigsawTileWatches(displacedRequestId); + unregisterRetries.remove(displacedRequestId); + unregisterDrainWarnings.remove(displacedRequestId); + tripleSneakTracker.clearRequest(displacedRequestId); + disabledWorkcellRenderer.removeRequest(displacedRequestId); + evaluations.remove(displacedRequestId); + previewRenderer.removeRequest(displacedRequestId); + } + IrisLogging.info("Jigsaw Studio authoring registered: world=%s structure=%s bays=%d", + world.getName(), activeGenerator.getSession().structureKey(), activeGenerator.getLayout().bays().size()); + disabledWorkcellRenderer.reconcile( + world, + activeGenerator.getRequest().requestId(), + activeGenerator.getLayout()); + scheduleInitialEvaluation(next); + scheduleOnlinePlayers(world.getUID()); + } + + public void activationCommitted(World world, UUID requestId) { + if (world == null || requestId == null) { + return; + } + ActiveStudio studio = studios.get(world.getUID()); + if (studio == null || !requestId.equals(studio.generator().getRequest().requestId())) { + return; + } + scheduleInitialEvaluation(studio); + } + + public void markChunkGenerated( + Engine engine, + JigsawStudioGenerator generator, + int chunkX, + int chunkZ + ) { + if (engine == null || generator == null) { + return; + } + World world = BukkitWorldBinding.world(engine.getTarget().getWorld()); + if (world == null) { + return; + } + ActiveStudio studio = studios.get(world.getUID()); + if (studio == null || studio.engine() != engine || studio.generator() != generator) { + return; + } + markChunkAvailable(studio, chunkX, chunkZ); + } + + private void markChunkAvailable(ActiveStudio studio, int chunkX, int chunkZ) { + long chunkKey = chunkKey(chunkX, chunkZ); + boolean relevant = false; + for (JigsawStudioBay bay : studio.generator().getLayout().bays()) { + BayPopulation population = studio.population(bay); + if (population.markGenerated(chunkKey)) { + relevant = true; + } + } + if (relevant) { + scheduleHydration(studio, studio.world(), chunkX, chunkZ, 0); + } + } + + public void unregister(World world) { + if (world == null) { + return; + } + ActiveStudio active = studios.get(world.getUID()); + if (active != null) { + UUID activeRequestId = active.generator().getRequest().requestId(); + finalizeJigsawTileWatches(activeRequestId); + drainAutosavesBeforeRemoval(active); + if (requiresLifecycleDrain(active) && !discardingRequest(activeRequestId)) { + if (unregisterDrainWarnings.add(activeRequestId)) { + IrisLogging.warn("Jigsaw Studio unregister deferred while request %s finishes its final autosave drain", + activeRequestId); + } + scheduleUnregisterRetry(world, activeRequestId); + return; + } + } + ActiveStudio removed; + JigsawStudioActivation.Request request; + synchronized (saveLifecycleLock) { + removed = studios.remove(world.getUID()); + if (removed == null) { + return; + } + request = removed.generator().getRequest(); + savesInProgress.remove(request.requestId()); + graphMutationsInProgress.remove(request.requestId()); + materializationsInProgress.remove(request.requestId()); + exportsInProgress.remove(request.requestId()); + closingRequests.remove(request.requestId()); + discardingRequests.remove(request.requestId()); + reopenRequiredRequests.remove(request.requestId()); + } + clearAutosaves(request.requestId()); + deferredDuplications.remove(request.requestId()); + clearJigsawTileWatches(request.requestId()); + unregisterRetries.remove(request.requestId()); + unregisterDrainWarnings.remove(request.requestId()); + toolConfirmations.entrySet().removeIf(entry -> entry.getValue().payload().requestId().equals(request.requestId())); + tripleSneakTracker.clearRequest(request.requestId()); + disabledWorkcellRenderer.removeRequest(request.requestId()); + evaluations.remove(request.requestId()); + previewRenderer.forgetRequest(request.requestId()); + JigsawStudioActivation.deactivate(request.packKey(), request.requestId()); + clearWorldPlayerContexts(world.getUID()); + IrisLogging.info("Jigsaw Studio authoring unregistered: world=%s", world.getName()); + } + + private void scheduleUnregisterRetry(World world, UUID requestId) { + if (!enabled || !unregisterRetries.add(requestId)) { + return; + } + try { + J.s(() -> { + unregisterRetries.remove(requestId); + ActiveStudio current = studios.get(world.getUID()); + if (current != null + && current.generator().getRequest().requestId().equals(requestId)) { + unregister(world); + } + }, AUTOSAVE_RETRY_TICKS); + } catch (Throwable exception) { + unregisterRetries.remove(requestId); + IrisLogging.reportError(exception); + } + } + + public CloseStart tryBeginClose(UUID requestId, UUID ownerId, boolean discard) { + if (requestId == null) { + return CloseStart.NOT_ACTIVE; + } + finalizeJigsawTileWatches(requestId); + synchronized (saveLifecycleLock) { + JigsawStudioActivation.Request request = JigsawStudioActivation.getRequest(requestId); + JigsawStudioSession session = JigsawStudioActivation.getSession(requestId); + if (request == null || session == null) { + return CloseStart.NOT_ACTIVE; + } + if (request.ownerId() != null && !request.ownerId().equals(ownerId)) { + return CloseStart.NOT_OWNER; + } + if (savesInProgress.contains(requestId)) { + return CloseStart.SAVE_IN_PROGRESS; + } + if (graphMutationsInProgress.contains(requestId)) { + return CloseStart.OPERATION_IN_PROGRESS; + } + if (exportsInProgress.contains(requestId)) { + return CloseStart.OPERATION_IN_PROGRESS; + } + if (materializationsInProgress.contains(requestId)) { + return CloseStart.OPERATION_IN_PROGRESS; + } + if (session.operationInProgress()) { + return CloseStart.OPERATION_IN_PROGRESS; + } + if (hasJigsawTileWatch(requestId)) { + return CloseStart.OPERATION_IN_PROGRESS; + } + if (session.isDirty() && !discard) { + return CloseStart.DIRTY; + } + closingRequests.add(requestId); + if (discard) { + discardingRequests.add(requestId); + } else { + discardingRequests.remove(requestId); + } + return CloseStart.STARTED; + } + } + + public String closeProtectionFailure(UUID requestId) { + if (requestId == null) { + return null; + } + synchronized (saveLifecycleLock) { + if (closingRequests.contains(requestId) && !savesInProgress.contains(requestId)) { + return null; + } + if (savesInProgress.contains(requestId)) { + return "The active Jigsaw Studio is saving and cannot be closed or replaced yet."; + } + if (graphMutationsInProgress.contains(requestId)) { + return "The active Jigsaw Studio is updating its graph and cannot be closed or replaced yet."; + } + if (exportsInProgress.contains(requestId)) { + return "The active Jigsaw Studio is exporting and cannot be closed or replaced yet."; + } + if (materializationsInProgress.contains(requestId)) { + return "The active Jigsaw Studio is loading or restoring a variant and cannot be closed yet."; + } + JigsawStudioSession session = JigsawStudioActivation.getSession(requestId); + if (session != null && session.operationInProgress()) { + return "The active Jigsaw Studio is loading a variant and cannot be closed or replaced yet."; + } + if (hasJigsawTileWatch(requestId)) { + return "The active Jigsaw Studio is finalizing an open vanilla jigsaw-block editor."; + } + if (session != null && session.isDirty()) { + return "The active Jigsaw Studio is waiting for autosave. Let it finish before closing."; + } + return "The active Jigsaw Studio is owner-controlled. Close it with /iris jigsaw close."; + } + } + + public void cancelClose(UUID requestId) { + if (requestId == null) { + return; + } + synchronized (saveLifecycleLock) { + closingRequests.remove(requestId); + discardingRequests.remove(requestId); + } + } + + SaveStart tryBeginSave(UUID requestId) { + Objects.requireNonNull(requestId, "Jigsaw Studio save request ID"); + synchronized (saveLifecycleLock) { + if (closingRequests.contains(requestId)) { + return SaveStart.CLOSING; + } + if (graphMutationsInProgress.contains(requestId)) { + return SaveStart.GRAPH_OPERATION; + } + if (exportsInProgress.contains(requestId)) { + return SaveStart.EXPORT_OPERATION; + } + if (materializationsInProgress.contains(requestId)) { + return SaveStart.VARIANT_OPERATION; + } + if (!savesInProgress.add(requestId)) { + return SaveStart.IN_PROGRESS; + } + return SaveStart.STARTED; + } + } + + void finishSave(UUID requestId) { + if (requestId == null) { + return; + } + synchronized (saveLifecycleLock) { + savesInProgress.remove(requestId); + } + } + + public ExportStart tryBeginExport(UUID requestId, UUID ownerId) { + Objects.requireNonNull(requestId, "Jigsaw Studio export request ID"); + Objects.requireNonNull(ownerId, "Jigsaw Studio export owner ID"); + finalizeJigsawTileWatches(requestId); + synchronized (saveLifecycleLock) { + JigsawStudioActivation.Request request = JigsawStudioActivation.getRequest(requestId); + JigsawStudioSession session = JigsawStudioActivation.getSession(requestId); + if (request == null || session == null) { + return ExportStart.NOT_ACTIVE; + } + if (request.ownerId() != null && !request.ownerId().equals(ownerId)) { + return ExportStart.NOT_OWNER; + } + if (closingRequests.contains(requestId)) { + return ExportStart.CLOSING; + } + if (savesInProgress.contains(requestId)) { + return ExportStart.SAVE_IN_PROGRESS; + } + if (graphMutationsInProgress.contains(requestId)) { + return ExportStart.OPERATION_IN_PROGRESS; + } + if (materializationsInProgress.contains(requestId) || session.operationInProgress()) { + return ExportStart.OPERATION_IN_PROGRESS; + } + if (hasJigsawTileWatch(requestId)) { + return ExportStart.OPERATION_IN_PROGRESS; + } + if (session.isDirty()) { + return ExportStart.DIRTY; + } + if (!exportsInProgress.add(requestId)) { + return ExportStart.IN_PROGRESS; + } + return ExportStart.STARTED; + } + } + + public void finishExport(UUID requestId) { + if (requestId == null) { + return; + } + synchronized (saveLifecycleLock) { + exportsInProgress.remove(requestId); + } + } + + public boolean saveSelected(Player player) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> saveSelected(player)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + if (reopenRequiredRequests.contains(studio.generator().getRequest().requestId())) { + message(player, "Close and reopen Jigsaw Studio before loading variants in the resized layout."); + return false; + } + if (graphMutationInProgress(studio.generator().getRequest().requestId())) { + message(player, "Wait for the current Jigsaw Studio graph update to finish."); + return false; + } + UUID requestId = studio.generator().getRequest().requestId(); + finalizeJigsawTileWatches(requestId); + if (hasJigsawTileWatch(requestId)) { + message(player, "Wait for the open vanilla jigsaw-block editor to finish its final snapshot."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String selected = session.selectedBayId().orElse(null); + if (selected == null) { + Location location = player.getLocation(); + JigsawStudioBay underPlayer = session.layout().findAt( + location.getBlockX(), location.getBlockY(), location.getBlockZ()); + if (underPlayer != null) { + selected = underPlayer.stableId(); + session.selectBay(selected); + } + } + if (selected == null) { + message(player, "Stand inside or select a Jigsaw Studio workcell before saving."); + return false; + } + return saveBay(player, selected); + } + + public boolean saveBay(Player player, String bayId) { + if (player == null || bayId == null || bayId.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> saveBay(player, bayId)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + JigsawStudioBay bay = findBay(studio.generator().getSession().layout(), bayId); + if (bay == null) { + message(player, "No Jigsaw Studio bay matches '" + bayId + "'."); + return false; + } + return startSave(studio, player.getWorld(), bay, player, true) == SaveAttempt.STARTED; + } + + private SaveAttempt startSave( + ActiveStudio studio, + World world, + JigsawStudioBay bay, + Player player, + boolean report + ) { + UUID requestId = studio.generator().getRequest().requestId(); + if (reopenRequiredRequests.contains(requestId)) { + report(player, report, "Close and reopen Jigsaw Studio before saving the resized layout."); + return SaveAttempt.DEFERRED; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null); + if (activeVariant == null) { + report(player, report, "Workcell '" + bay.stableId() + "' has no active variant to save."); + return SaveAttempt.DEFERRED; + } + if (!activeVariant.owned()) { + report(player, report, "Variant '" + activeVariant.pieceKey() + + "' is read-only. Adopt or clone its graph before editing it."); + return SaveAttempt.DEFERRED; + } + BayReadiness readiness = studio.population(bay).readiness(); + if (!readiness.ready()) { + report(player, report, readinessMessage(bay, readiness)); + return readiness.failure().isEmpty() ? SaveAttempt.RETRY : SaveAttempt.DEFERRED; + } + CaptureTarget captureTarget; + try { + captureTarget = resolveCaptureTarget(studio, bay, activeVariant); + } catch (IOException exception) { + String failure = "Jigsaw Studio cannot capture this workcell: " + failureMessage(exception); + report(player, report, failure); + return retainCurrentAutosaveFailure( + studio, + bay.stableId(), + new AutosavePersistentFailure(failure, exception)) + ? SaveAttempt.PERSISTENT_FAILURE + : SaveAttempt.DEFERRED; + } + JigsawStudioSession.SaveStart sessionSave = session.beginSave(bay.stableId()); + if (sessionSave.status() != JigsawStudioSession.SaveStatus.STARTED) { + report(player, report, switch (sessionSave.status()) { + case SWITCH_IN_PROGRESS -> "This workcell is loading another variant."; + case SAVE_IN_PROGRESS -> "This workcell is already saving."; + case NO_ACTIVE_VARIANT -> "This workcell has no active variant."; + case UNKNOWN_WORKCELL -> "This workcell is no longer active."; + case STARTED -> "The workcell save could not start."; + }); + return sessionSave.status() == JigsawStudioSession.SaveStatus.SAVE_IN_PROGRESS + || sessionSave.status() == JigsawStudioSession.SaveStatus.SWITCH_IN_PROGRESS + ? SaveAttempt.RETRY + : SaveAttempt.DEFERRED; + } + JigsawStudioSession.SaveIdentity saveIdentity = sessionSave.identity().orElseThrow(); + SaveStart saveStart = tryBeginSave(requestId); + if (saveStart == SaveStart.CLOSING) { + session.abortSave(saveIdentity); + report(player, report, "This Jigsaw Studio is closing and cannot start another save."); + return SaveAttempt.DEFERRED; + } + if (saveStart == SaveStart.IN_PROGRESS) { + session.abortSave(saveIdentity); + report(player, report, "A save is already running for this Jigsaw Studio."); + return SaveAttempt.RETRY; + } + if (saveStart == SaveStart.GRAPH_OPERATION) { + session.abortSave(saveIdentity); + report(player, report, "Wait for the current Jigsaw Studio graph update to finish."); + return SaveAttempt.RETRY; + } + if (saveStart == SaveStart.EXPORT_OPERATION) { + session.abortSave(saveIdentity); + report(player, report, "Wait for the current Jigsaw Studio export to finish."); + return SaveAttempt.RETRY; + } + if (saveStart == SaveStart.VARIANT_OPERATION) { + session.abortSave(saveIdentity); + report(player, report, "Wait for the current Jigsaw Studio variant load or rollback to finish."); + return SaveAttempt.RETRY; + } + if (report) { + session.selectBay(bay.stableId()); + } + refreshWorkcellContext(studio.worldId(), bay.stableId()); + report(player, report, "Capturing variant '" + activeVariant.pieceKey() + "' from " + + bay.stableId() + "..."); + try { + boolean scheduled = scheduleCapture( + studio, + world, + bay, + captureTarget, + player, + requestId, + saveIdentity, + autosaveFailureState(studio, saveIdentity)); + return scheduled ? SaveAttempt.STARTED : SaveAttempt.DEFERRED; + } catch (Throwable exception) { + session.abortSave(saveIdentity); + finishSave(requestId); + refreshWorkcellContext(studio.worldId(), bay.stableId()); + IrisLogging.reportError(exception); + report(player, report, "Jigsaw Studio could not schedule the workcell capture: " + + failureMessage(exception)); + return SaveAttempt.DEFERRED; + } + } + + public boolean teleportTo(Player player, String bayId) { + if (player == null || bayId == null || bayId.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> teleportTo(player, bayId)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + if (reopenRequiredRequests.contains(studio.generator().getRequest().requestId())) { + message(player, "Close and reopen Jigsaw Studio before loading variants in the resized layout."); + return false; + } + JigsawStudioBay bay = findBay(studio.generator().getSession().layout(), bayId); + if (bay == null) { + message(player, "No Jigsaw Studio bay matches '" + bayId + "'."); + return false; + } + JigsawStudioBounds bounds = bay.bounds(); + Location destination = new Location( + player.getWorld(), + bounds.originX() + bounds.dimensions().width() / 2.0D, + bounds.maxY() + 2.0D, + bounds.originZ() + bounds.dimensions().depth() / 2.0D, + player.getLocation().getYaw(), + 45.0F + ); + studio.generator().getSession().selectBay(bay.stableId()); + BukkitPlatform.teleportAsync(player, destination).thenRun(() -> J.runEntity( + player, + () -> reconcilePlayerContext(player, player.getLocation()))); + message(player, "Selected Jigsaw Studio workcell '" + bay.stableId() + "'."); + ensureVisualizationLoop(player); + return true; + } + + public boolean setParticles(Player player, boolean visible) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> setParticles(player, visible)); + } + if (!studios.containsKey(player.getWorld().getUID())) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + if (visible) { + particlesDisabled.remove(player.getUniqueId()); + ensureVisualizationLoop(player); + } else { + particlesDisabled.add(player.getUniqueId()); + } + message(player, "Jigsaw Studio particles " + (visible ? "enabled." : "disabled.")); + return true; + } + + public boolean switchVariant( + Player player, + String workcellId, + String targetPieceKey, + boolean discardDirty + ) { + if (player == null || workcellId == null || targetPieceKey == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> switchVariant( + player, workcellId, targetPieceKey, discardDirty)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + if (graphMutationInProgress(studio.generator().getRequest().requestId())) { + message(player, "Wait for the current Jigsaw Studio graph update to finish."); + return false; + } + UUID requestId = studio.generator().getRequest().requestId(); + finalizeJigsawTileWatches(requestId); + if (hasJigsawTileWatch(requestId)) { + message(player, "Wait for the open vanilla jigsaw-block editor to finish its final snapshot."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(workcellId); + if (workcell == null) { + message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'."); + return false; + } + JigsawStudioSession.SwitchStart start = session.beginVariantSwitch( + workcell.stableId(), targetPieceKey, discardDirty); + if (start.status() != JigsawStudioSession.SwitchStatus.STARTED) { + message(player, switch (start.status()) { + case DIRTY -> "Wait for the current variant to finish autosaving before switching."; + case SAVE_IN_PROGRESS -> "Wait for the current variant save to finish."; + case SWITCH_IN_PROGRESS -> "This workcell is already loading another variant."; + case ALREADY_ACTIVE -> "Variant '" + targetPieceKey + "' is already active."; + case WRONG_WORKCELL -> "Variant '" + targetPieceKey + "' belongs to another workcell."; + case UNKNOWN_VARIANT -> "No variant '" + targetPieceKey + "' exists in this Studio catalog."; + case UNKNOWN_WORKCELL -> "The selected workcell no longer exists."; + case STARTED -> "The variant switch could not start."; + }); + return false; + } + return materializeVariant(player, studio, workcell, start, null); + } + + private void reloadActiveVariant( + Player player, + ActiveStudio studio, + VariantReloadRequest reload + ) { + if (!isCurrentRequest(studio, studio.generator().getRequest().requestId())) { + return; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(reload.workcellId()); + if (workcell == null) { + message(player, "The resized variant was saved, but its workcell is no longer active. Reopen Studio."); + return; + } + JigsawStudioSession.SwitchStart start = session.beginVariantReload(workcell.stableId()); + if (start.status() != JigsawStudioSession.SwitchStatus.STARTED) { + reopenRequiredRequests.add(studio.generator().getRequest().requestId()); + message(player, "The resized variant was saved, but Studio could not reload it: " + + start.status() + ". Close and reopen this project before editing that workcell."); + return; + } + materializeVariant(player, studio, workcell, start, reload.previous()); + } + + private boolean materializeVariant( + Player player, + ActiveStudio studio, + JigsawStudioBay workcell, + JigsawStudioSession.SwitchStart start, + JigsawStudioGenerator.RenderedBay previousOverride + ) { + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioSession.VariantSwitchToken token = start.token().orElseThrow(); + String targetPieceKey = token.targetVariant().pieceKey(); + JigsawStudioGenerator.RenderedBay target = studio.generator().renderVariant( + workcell, + token.targetVariant()); + if (!target.valid()) { + session.abortVariantSwitch(token); + refreshWorkcellContext(studio.worldId(), workcell.stableId()); + message(player, "Variant '" + targetPieceKey + "' cannot load: " + target.failure()); + return false; + } + JigsawStudioGenerator.RenderedBay previous = previousOverride == null + ? token.previousVariant() + .map(variant -> studio.generator().renderVariant(workcell, variant)) + .orElseGet(() -> JigsawStudioGenerator.RenderedBay.empty(workcell.bounds().dimensions())) + : previousOverride; + if (!previous.valid()) { + session.abortVariantSwitch(token); + refreshWorkcellContext(studio.worldId(), workcell.stableId()); + message(player, "The current variant cannot be retained for rollback: " + previous.failure()); + return false; + } + JigsawStudioVariant previousVariant = token.previousVariant().orElse(null); + if (previousVariant != null) { + String rollbackFailure = validateMaterialization(previous); + if (!rollbackFailure.isEmpty()) { + session.abortVariantSwitch(token); + refreshWorkcellContext(studio.worldId(), workcell.stableId()); + message(player, "The current variant cannot be retained for rollback: " + rollbackFailure); + return false; + } + } + session.selectBay(workcell.stableId()); + refreshWorkcellContext(studio.worldId(), workcell.stableId()); + message(player, "Loading variant '" + targetPieceKey + "' into " + workcell.stableId() + "..."); + return scheduleMaterialization(new MaterializationWork( + studio, + player.getWorld(), + player, + workcell, + token, + previous, + target)); + } + + public boolean createVariant( + Player player, + String workcellId, + boolean duplicateActive + ) { + if (player == null || workcellId == null || workcellId.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> createVariant(player, workcellId, duplicateActive)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(workcellId); + if (workcell == null) { + message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'."); + return false; + } + if (!canCreateVariants(session.layout())) { + message(player, "This Jigsaw Studio graph is read-only. " + + "Adopt or clone it before creating variants."); + return false; + } + JigsawStudioVariant activeVariant = session.activeVariant(workcellId).orElse(null); + String sourceFailure = variantCreationSourceFailure(activeVariant, duplicateActive); + if (!sourceFailure.isEmpty()) { + message(player, sourceFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + if (duplicateActive && deferDuplicationUntilAutosaved( + player, + studio, + DeferredDuplication.single( + request.requestId(), + session.sessionId(), + player, + studio.worldId(), + workcell.stableId(), + activeVariant.pieceKey()))) { + return true; + } + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + String archetypeKey = workcell.archetype() + .map(archetype -> archetype.name().toLowerCase(Locale.ROOT)) + .orElse("spatial"); + String sourcePieceKey = activeVariant.pieceKey(); + message(player, duplicateActive ? "Duplicating the active variant..." : "Creating a new variant..."); + return scheduleGraphMutation( + player, + studio, + () -> { + Path packRoot = request.source().getDataFolder().toPath(); + String pieceKey = JigsawStudioGraphEditor.nextVariantKey( + packRoot, request.structureKey(), archetypeKey); + if (duplicateActive) { + JigsawStudioGraphEditor.duplicatePiece( + packRoot, + request.structureKey(), + sourcePieceKey, + pieceKey); + } else { + JigsawStudioGraphEditor.createBlankVariant( + packRoot, + request.structureKey(), + sourcePieceKey, + pieceKey); + } + return new CommandGraphMutationResult( + loadMappedLayout(studio), + workcell.stableId(), + pieceKey, + (duplicateActive ? "Duplicated" : "Created") + " variant '" + pieceKey + "'."); + }); + } + + @Override + public boolean setWorkcellEnabled(Player player, String workcellId, boolean enabled) { + if (player == null || workcellId == null || workcellId.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> setWorkcellEnabled(player, workcellId, enabled)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(workcellId); + if (workcell == null || workcell.archetype().isEmpty()) { + message(player, "Only planar Jigsaw Studio workcells can be enabled or disabled."); + return false; + } + if (workcell.enabled() == enabled) { + message(player, "Workcell '" + workcellId + "' is already " + + (enabled ? "enabled." : "disabled.")); + return false; + } + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + JigsawPlanarArchetype archetype = workcell.archetype().orElseThrow(); + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioStructureEditor.updateWorkcellEnabled( + request.source().getDataFolder().toPath(), + request.structureKey(), + archetype, + enabled); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Workcell '" + workcellId + "' is now " + + (enabled ? "enabled." : "disabled for assembly and export.")); + }); + } + + @Override + public boolean updateWorkcellDimensions( + Player player, + String workcellId, + JigsawStudioCellDimensions dimensions + ) { + if (player == null || workcellId == null || workcellId.isBlank() || dimensions == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> updateWorkcellDimensions(player, workcellId, dimensions)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(workcellId); + if (workcell == null) { + message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'."); + return false; + } + if (workcell.capacity().equals(dimensions)) { + message(player, "Workcell '" + workcellId + "' already has capacity " + + dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + "."); + return false; + } + if (workcell.archetype().isPresent() + && (dimensions.width() < 3 || dimensions.depth() < 3)) { + message(player, "Planar workcell width and depth must each be at least 3 blocks."); + return false; + } + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + UUID requestId = request.requestId(); + JigsawPlanarArchetype archetype = workcell.archetype().orElse(null); + JigsawStudioLayout liveLayout = session.layout(); + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioGraphEditor.WorkcellCapacityResult capacityResult = null; + if (archetype == null) { + JigsawStudioStructureEditor.updateCellSize( + request.source().getDataFolder().toPath(), + request.structureKey(), + dimensions); + } else { + capacityResult = JigsawStudioGraphEditor.updatePlanarWorkcellCapacity( + request.source().getDataFolder().toPath(), + request.structureKey(), + archetype, + dimensions); + } + request.source().invalidateStructureResources(); + reopenRequiredRequests.add(requestId); + String resizeSummary = capacityResult == null + ? "" + : " Verified " + capacityResult.checkedVariants() + " existing variant" + + (capacityResult.checkedVariants() == 1 ? "" : "s") + "; no variant object was resized."; + return new CommandGraphMutationResult( + liveLayout, + "", + "", + "Updated '" + workcellId + "' capacity to " + + dimensions.width() + "x" + dimensions.height() + "x" + + dimensions.depth() + "." + resizeSummary + + " Close and reopen Jigsaw Studio to regenerate " + + "the compact workcell layout before editing further."); + }); + } + + @Override + public boolean setRequireCaps(Player player, boolean requireCaps) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> setRequireCaps(player, requireCaps)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + && requireCaps) { + message(player, "Mandatory caps are Iris-only and cannot be enabled for a vanilla-portable graph."); + return false; + } + IrisStructure structure = loadStudioStructure(studio); + if (structure == null) { + message(player, "The active jigsaw structure could not be loaded."); + return false; + } + if (structure.isRequireCaps() == requireCaps) { + message(player, "Mandatory caps are already " + (requireCaps ? "enabled." : "disabled.")); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioStructureEditor.updateRequireCaps( + request.source().getDataFolder().toPath(), + request.structureKey(), + requireCaps); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Mandatory physical caps are now " + + (requireCaps ? "enabled." : "disabled.")); + }); + } + + @Override + public boolean duplicateActiveFamily(Player player, String themeKey) { + if (player == null || themeKey == null || themeKey.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> duplicateActiveFamily(player, themeKey)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) { + message(player, "Coherent variant families are Iris-only and cannot be added to a vanilla-portable graph."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + Map sourcePieces = new LinkedHashMap<>(); + for (JigsawStudioBay workcell : session.layout().bays()) { + if (!workcell.enabled()) { + continue; + } + JigsawStudioVariant active = session.activeVariant(workcell.stableId()).orElse(null); + if (active == null || !active.owned()) { + message(player, "Load an owned source variant in every enabled workcell before duplicating a family."); + return false; + } + sourcePieces.put(workcell.stableId(), active.pieceKey()); + } + if (deferDuplicationUntilAutosaved( + player, + studio, + DeferredDuplication.family( + request.requestId(), + session.sessionId(), + player, + studio.worldId(), + sourcePieces, + themeKey))) { + return true; + } + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioGraphEditor.VariantFamilyCreation creation = + JigsawStudioGraphEditor.duplicateActiveFamily( + request.source().getDataFolder().toPath(), + request.structureKey(), + sourcePieces, + themeKey); + if (!creation.writeResult().successful()) { + throw new IOException("Variant-family transaction failed with " + + creation.writeResult().status()); + } + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + creation.pieceKeysByWorkcell(), + Optional.empty(), + "Duplicated every enabled workcell as coherent family '" + themeKey + "' with " + + creation.pieceKeysByWorkcell().size() + " variant(s)."); + }); + } + + @Override + public boolean updateThemeSetWeight(Player player, String themeKey, int weight) { + if (player == null || themeKey == null || themeKey.isBlank() || weight < 1) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> updateThemeSetWeight(player, themeKey, weight)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE) { + message(player, "Coherent theme sets are Iris-only."); + return false; + } + IrisStructure structure = loadStudioStructure(studio); + if (structure == null || structure.getThemeSets() == null) { + message(player, "The active jigsaw structure has no theme sets."); + return false; + } + List updated = new ArrayList<>(structure.getThemeSets().size()); + boolean found = false; + for (IrisJigsawThemeSet theme : structure.getThemeSets()) { + if (theme != null && themeKey.equals(theme.getKey())) { + if (theme.getWeight() == weight) { + message(player, "Theme '" + themeKey + "' already has weight " + weight + "."); + return false; + } + updated.add(new IrisJigsawThemeSet(themeKey, weight)); + found = true; + } else if (theme != null) { + updated.add(new IrisJigsawThemeSet(theme.getKey(), theme.getWeight())); + } + } + if (!found) { + message(player, "Theme set '" + themeKey + "' no longer exists."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioStructureEditor.updateThemeSets( + request.source().getDataFolder().toPath(), + request.structureKey(), + updated); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Set theme '" + themeKey + "' weight to " + weight + "."); + }); + } + + @Override + public boolean updateVariantThemes( + Player player, + String workcellId, + String pieceKey, + List themes + ) { + if (player == null || pieceKey == null || themes == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> updateVariantThemes( + player, workcellId, pieceKey, themes)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId, pieceKey); + if (variant == null) { + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + List targetThemes = List.copyOf(themes); + if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + && !targetThemes.isEmpty()) { + message(player, "Piece themes are Iris-only and cannot be added to a vanilla-portable graph."); + return false; + } + IrisStructure structure = loadStudioStructure(studio); + Set declaredThemes = new HashSet<>(); + if (structure != null && structure.getThemeSets() != null) { + for (IrisJigsawThemeSet theme : structure.getThemeSets()) { + if (theme != null) { + declaredThemes.add(theme.getKey()); + } + } + } + if (!declaredThemes.containsAll(targetThemes)) { + message(player, "One or more selected theme sets no longer exist."); + return false; + } + if (variant.themes().equals(targetThemes)) { + message(player, "Variant theme membership is unchanged."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioGraphEditor.updatePieceThemes( + request.source().getDataFolder().toPath(), + request.structureKey(), + variant.pieceKey(), + targetThemes); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Updated theme membership for '" + variant.pieceKey() + "'."); + }); + } + + @Override + public boolean updateVariantRules( + Player player, + String workcellId, + String pieceKey, + JigsawStudioPieceRules rules + ) { + if (player == null || pieceKey == null || rules == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> updateVariantRules( + player, workcellId, pieceKey, rules)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId, pieceKey); + if (variant == null) { + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + && !DEFAULT_PIECE_RULES.equals(rules)) { + message(player, "Piece placement rules are Iris-only for a vanilla-portable graph."); + return false; + } + if (variant.rules().equals(rules)) { + message(player, "Variant piece rules are unchanged."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioGraphEditor.updatePieceRules( + request.source().getDataFolder().toPath(), + request.structureKey(), + variant.pieceKey(), + rules); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Updated placement rules for '" + variant.pieceKey() + "'."); + }); + } + + public boolean toggleVariantRotatable(Player player, String workcellId) { + if (player == null || workcellId == null || workcellId.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> toggleVariantRotatable(player, workcellId)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio != null && !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId); + if (variant == null) { + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + if (!canToggleVariantRotation(request.compatibilityTarget(), variant)) { + message(player, "Vanilla-portable variants must remain rotatable."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + boolean rotatable = !variant.rotatable(); + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioGraphEditor.updateRotatable( + request.source().getDataFolder().toPath(), + request.structureKey(), + variant.pieceKey(), + rotatable); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Variant rotation is now " + (rotatable ? "enabled." : "disabled.")); + }); + } + + public boolean expandVariantToCell(Player player, String workcellId) { + if (player == null || workcellId == null || workcellId.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> expandVariantToCell(player, workcellId)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio != null && !authorizeOwner(player, studio)) { + return false; + } + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId); + JigsawStudioBay workcell = session.layout().get(workcellId); + if (variant == null || workcell == null) { + return false; + } + return resizeVariant(player, workcellId, variant.pieceKey(), workcell.capacity()); + } + + @Override + public boolean resizeVariant( + Player player, + String workcellId, + String pieceKey, + JigsawStudioCellDimensions dimensions + ) { + if (player == null || workcellId == null || workcellId.isBlank() + || pieceKey == null || pieceKey.isBlank() || dimensions == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> resizeVariant(player, workcellId, pieceKey, dimensions)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(workcellId); + JigsawStudioVariant variant = session.layout().variantCatalog().find(pieceKey).orElse(null); + if (workcell == null || variant == null || !session.layout().accepts(workcell, variant)) { + message(player, "Variant '" + pieceKey + "' does not belong to workcell '" + workcellId + "'."); + return false; + } + if (!variant.owned()) { + message(player, "Variant '" + pieceKey + "' is read-only. Duplicate it before resizing."); + return false; + } + if (dimensions.width() > workcell.capacity().width() + || dimensions.height() > workcell.capacity().height() + || dimensions.depth() > workcell.capacity().depth()) { + message(player, "Requested variant size " + dimensions.width() + "x" + dimensions.height() + "x" + + dimensions.depth() + " exceeds workcell capacity " + workcell.capacity().width() + "x" + + workcell.capacity().height() + "x" + workcell.capacity().depth() + + ". Increase the workcell capacity first."); + return false; + } + if (variant.mode() == JigsawStudioMode.PLANAR_JIGSAW + && (dimensions.width() < 3 || dimensions.depth() < 3)) { + message(player, "Planar variant width and depth must each be at least 3 blocks."); + return false; + } + if (variant.dimensions().filter(dimensions::equals).isPresent()) { + message(player, "Variant '" + variant.resolvedDisplayName() + "' already uses " + + dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + "."); + return false; + } + boolean active = session.activeVariant(workcellId) + .map(activeVariant -> activeVariant.pieceKey().equals(pieceKey)) + .orElse(false); + JigsawStudioGenerator.RenderedBay previous = active + ? studio.generator().renderVariant(workcell, variant) + : null; + if (previous != null && !previous.valid()) { + message(player, "The active variant cannot be retained for live resize rollback: " + previous.failure()); + return false; + } + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + message(player, "Resizing variant '" + variant.resolvedDisplayName() + "' to " + + dimensions.width() + "x" + dimensions.height() + "x" + dimensions.depth() + "..."); + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioGraphEditor.VariantResizeResult resize = + JigsawStudioGraphEditor.resizePieceObject( + request.source().getDataFolder().toPath(), + request.structureKey(), + variant.pieceKey(), + dimensions); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + Map.of(), + active ? Optional.of(new VariantReloadRequest(workcellId, previous)) : Optional.empty(), + "Resized variant '" + variant.resolvedDisplayName() + "' from " + + resize.previousDimensions().width() + "x" + + resize.previousDimensions().height() + "x" + + resize.previousDimensions().depth() + " to " + + dimensions.width() + "x" + dimensions.height() + "x" + + dimensions.depth() + ". " + resize.relocatedConnectors() + + " connector(s) moved with the new bounds."); + }); + } + + @Override + public boolean updateVariantDisplayName( + Player player, + String workcellId, + String pieceKey, + String displayName + ) { + if (player == null || workcellId == null || pieceKey == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> updateVariantDisplayName( + player, workcellId, pieceKey, displayName)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(workcellId); + JigsawStudioVariant variant = session.layout().variantCatalog().find(pieceKey).orElse(null); + if (workcell == null || variant == null || !session.layout().accepts(workcell, variant)) { + message(player, "Variant '" + pieceKey + "' does not belong to workcell '" + workcellId + "'."); + return false; + } + if (!variant.owned()) { + message(player, "Variant '" + pieceKey + "' is read-only. Duplicate it before renaming."); + return false; + } + String normalizedName; + try { + normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName); + } catch (IllegalArgumentException exception) { + message(player, exception.getMessage()); + return false; + } + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioGraphEditor.updatePieceDisplayName( + request.source().getDataFolder().toPath(), + request.structureKey(), + pieceKey, + normalizedName); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + normalizedName.isEmpty() + ? "Reset the variant label to its resource-key fallback." + : "Renamed the variant to '" + normalizedName + "'."); + }); + } + + @Override + public boolean updateWorkcellDisplayName( + Player player, + String workcellId, + String displayName + ) { + if (player == null || workcellId == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> updateWorkcellDisplayName(player, workcellId, displayName)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(workcellId); + if (workcell == null) { + message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'."); + return false; + } + String normalizedName; + try { + normalizedName = JigsawStudioGraphEditor.normalizeDisplayName(displayName); + } catch (IllegalArgumentException exception) { + message(player, exception.getMessage()); + return false; + } + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + JigsawPlanarArchetype archetype = workcell.archetype().orElse(null); + return scheduleGraphMutation( + player, + studio, + () -> { + if (archetype == null) { + JigsawStudioStructureEditor.updateSpatialWorkcellDisplayName( + request.source().getDataFolder().toPath(), + request.structureKey(), + normalizedName); + } else { + JigsawStudioStructureEditor.updateWorkcellDisplayName( + request.source().getDataFolder().toPath(), + request.structureKey(), + archetype, + normalizedName); + } + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + normalizedName.isEmpty() + ? "Reset the workcell label to '" + workcell.canonicalDisplayName() + "'." + : "Renamed the workcell to '" + normalizedName + "'."); + }); + } + + @Override + public boolean adjustVariantWeight( + Player player, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex, + int delta + ) { + if (delta == 0) { + return false; + } + if (player == null || workcellId == null || pieceKey == null || poolKey == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> adjustVariantWeight( + player, workcellId, pieceKey, poolKey, entryIndex, delta)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio != null && !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId, pieceKey); + if (variant == null) { + return false; + } + JigsawStudioPoolMembership membership = findMembership( + variant, poolKey, entryIndex); + if (membership == null) { + message(player, "That exact pool membership is no longer present."); + return false; + } + int weight; + try { + weight = Math.addExact(membership.weight(), delta); + } catch (ArithmeticException exception) { + message(player, "The requested variant weight is outside the supported integer range."); + return false; + } + if (weight < 1) { + message(player, "Variant weights cannot be lower than 1; unlink the membership instead."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioPoolEditor.updateWeightAtIndex( + request.source().getDataFolder().toPath(), + request.structureKey(), + membership.poolKey(), + membership.entryIndex(), + variant.pieceKey(), + weight); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Set '" + variant.pieceKey() + "' weight to " + weight + + " in pool '" + membership.poolKey() + "'."); + }); + } + + @Override + public boolean adjustVariantChance( + Player player, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex, + int deltaPercentagePoints + ) { + if (deltaPercentagePoints == 0 || player == null || workcellId == null + || pieceKey == null || poolKey == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> adjustVariantChance( + player, + workcellId, + pieceKey, + poolKey, + entryIndex, + deltaPercentagePoints)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio != null && !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId, pieceKey); + if (variant == null) { + return false; + } + JigsawStudioPoolMembership membership = findMembership(variant, poolKey, entryIndex); + if (membership == null) { + message(player, "That exact pool membership is no longer present."); + return false; + } + double chance = membership.chance() + deltaPercentagePoints / 100.0D; + if (!Double.isFinite(chance) || chance < 0.0D || chance > 1.0D) { + message(player, "Variant chance must stay between 0% and 100%."); + return false; + } + chance = Math.round(chance * 1_000_000.0D) / 1_000_000.0D; + JigsawStudioActivation.Request request = studio.generator().getRequest(); + if (request.compatibilityTarget() == JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + && chance != 1.0D) { + message(player, "Chance gates are Iris-only and cannot be added to a vanilla-portable graph."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + double targetChance = chance; + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioPoolEditor.updateChanceAtIndex( + request.source().getDataFolder().toPath(), + request.structureKey(), + membership.poolKey(), + membership.entryIndex(), + variant.pieceKey(), + targetChance); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Set '" + variant.pieceKey() + "' chance to " + + Math.round(targetChance * 100.0D) + "% in pool '" + + membership.poolKey() + "'."); + }); + } + + @Override + public boolean deleteVariant(Player player, String workcellId, String pieceKey) { + if (player == null || workcellId == null || workcellId.isBlank() + || pieceKey == null || pieceKey.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> deleteVariant(player, workcellId, pieceKey)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().get(workcellId); + JigsawStudioVariant variant = session.layout().variantCatalog().find(pieceKey).orElse(null); + if (workcell == null || variant == null || !session.layout().accepts(workcell, variant)) { + message(player, "Variant '" + pieceKey + "' no longer belongs to workcell '" + workcellId + "'."); + return false; + } + if (!variant.owned()) { + message(player, "Variant '" + pieceKey + "' is read-only. Adopt or clone it before deletion."); + return false; + } + String activePieceKey = session.activeVariant(workcellId) + .map(JigsawStudioVariant::pieceKey) + .orElse(""); + if (activePieceKey.equals(pieceKey)) { + message(player, "Load another variant in this workcell before deleting '" + pieceKey + "'."); + return false; + } + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioGraphEditor.PieceDeletionResult deletion = + JigsawStudioGraphEditor.deletePieceVariant( + request.source().getDataFolder().toPath(), + request.structureKey(), + pieceKey); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Deleted variant '" + pieceKey + "', removed " + + deletion.removedPoolMemberships() + " pool membership(s), and removed " + + deletion.removedObjectResources() + " unshared object(s)."); + }); + } + + public boolean deleteProject(Player player) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> deleteProject(player)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + CloseStart closeStart = tryBeginClose(request.requestId(), player.getUniqueId(), false); + if (closeStart != CloseStart.STARTED) { + message(player, switch (closeStart) { + case DIRTY -> "Wait for autosave to finish before deleting this project."; + case SAVE_IN_PROGRESS -> "Wait for the active autosave to finish before deleting this project."; + case OPERATION_IN_PROGRESS -> "Wait for the active Jigsaw Studio operation to finish."; + case NOT_OWNER -> "This Jigsaw Studio is owned by another player session."; + case NOT_ACTIVE -> "This Jigsaw Studio session is no longer active."; + case STARTED -> "Project deletion could not start."; + }); + return false; + } + Path packRoot = request.source().getDataFolder().toPath(); + message(player, "Inspecting owned resources and reverse references before project deletion..."); + J.a(() -> inspectProjectDeletion(player, studio, request, packRoot)); + return true; + } + + private void inspectProjectDeletion( + Player player, + ActiveStudio studio, + JigsawStudioActivation.Request request, + Path packRoot + ) { + JigsawStudioProjectDeletionService.DeletionPlan plan; + try { + plan = JigsawStudioProjectDeletionService.inspect(packRoot, request.structureKey()); + } catch (Throwable exception) { + cancelClose(request.requestId()); + IrisLogging.reportError(exception); + message(player, "Project deletion inspection failed: " + failureMessage(exception)); + return; + } + if (!plan.deletable()) { + cancelClose(request.requestId()); + message(player, "Project deletion is blocked by " + plan.blockers().size() + + " external reference(s). The first is " + + plan.blockers().getFirst().ownerPath() + " at " + + plan.blockers().getFirst().location() + "."); + return; + } + boolean scheduled = J.runEntity( + player, + () -> closeAndDeleteProject(player, studio, request, plan), + 0, + () -> cancelClose(request.requestId())); + if (!scheduled) { + cancelClose(request.requestId()); + message(player, "Project deletion stopped because the owner session ended."); + } + } + + private void closeAndDeleteProject( + Player player, + ActiveStudio studio, + JigsawStudioActivation.Request request, + JigsawStudioProjectDeletionService.DeletionPlan plan + ) { + if (!isCurrentRequest(studio, request.requestId())) { + cancelClose(request.requestId()); + message(player, "Project deletion stopped because the Jigsaw Studio session changed."); + return; + } + IrisServices.get(StudioSVC.class).close().whenComplete((result, throwable) -> { + Throwable failure = throwable != null + ? throwable + : result == null ? new IllegalStateException("Studio close completed without a result") + : result.failureCause(); + if (failure != null) { + cancelClose(request.requestId()); + IrisLogging.reportError(failure); + message(player, "Project deletion stopped because Studio could not close: " + + failureMessage(failure)); + return; + } + J.a(() -> applyProjectDeletion(player, request, plan)); + }); + } + + private void applyProjectDeletion( + Player player, + JigsawStudioActivation.Request request, + JigsawStudioProjectDeletionService.DeletionPlan plan + ) { + try { + JigsawStudioProjectDeletionService.ProjectDeletionResult result = + JigsawStudioProjectDeletionService.delete(plan); + request.source().invalidateStructureResources(); + message(player, "Deleted Jigsaw project '" + request.structureKey() + "' and " + + result.removedResourceCount() + " owned resource(s)."); + IrisLogging.info("Jigsaw Studio project deleted: structure=%s resources=%d", + request.structureKey(), result.removedResourceCount()); + } catch (Throwable exception) { + IrisLogging.reportError(exception); + message(player, "Studio closed, but the project was not deleted: " + + failureMessage(exception) + ". Its files remain recoverable on disk."); + } + } + + @Override + public boolean unlinkVariantMembership( + Player player, + String workcellId, + String pieceKey, + String poolKey, + int entryIndex + ) { + if (player == null || workcellId == null || pieceKey == null || poolKey == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> unlinkVariantMembership( + player, workcellId, pieceKey, poolKey, entryIndex)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio != null && !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId, pieceKey); + if (variant == null) { + return false; + } + JigsawStudioPoolMembership membership = findMembership(variant, poolKey, entryIndex); + if (membership == null) { + message(player, "That exact pool membership is no longer present."); + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String reservationFailure = beginGraphMutation(studio, session); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + return scheduleGraphMutation( + player, + studio, + () -> { + JigsawStudioPoolEditor.removeEntry( + request.source().getDataFolder().toPath(), + request.structureKey(), + membership.poolKey(), + membership.entryIndex(), + variant.pieceKey()); + return new CommandGraphMutationResult( + loadMappedLayout(studio), + "", + "", + "Unlinked '" + variant.pieceKey() + "' from pool '" + + membership.poolKey() + "'."); + }); + } + + public boolean previewStudio(Player player, long seed) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> previewStudio(player, seed)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + Location location = player.getLocation(); + IrisPosition origin = new IrisPosition( + location.getBlockX(), location.getBlockY(), location.getBlockZ()); + JigsawStudioActivation.Request request = studio.generator().getRequest(); + J.a(() -> { + try { + IrisStructure structure = request.source().load( + IrisStructure.class, request.structureKey(), false); + if (structure == null) { + throw new IOException("The active structure resource no longer exists"); + } + StructureAssembler assembler = StructureAssembler.forData( + request.source(), structure, origin); + StructureAssemblyResult assembly = assembler.assemble(new RNG(seed)); + if (!assembly.hasOutput()) { + message(player, "The active graph assembled zero preview pieces."); + return; + } + List pieces = assembly.pieces(); + if (!J.runEntity(player, () -> { + if (isCurrentRequest(studio, request.requestId())) { + showAssemblyPreview(player, pieces); + message(player, "Showing a " + pieces.size() + + "-piece particle preview for 10 seconds."); + } + })) { + message(player, "The preview completed after the player session ended."); + } + } catch (Throwable exception) { + IrisLogging.reportError(exception); + message(player, "Preview assembly failed: " + failureMessage(exception)); + } + }); + return true; + } + + public Optional evaluation(Player player) { + if (player == null || !J.isOwnedByCurrentRegion(player)) { + return Optional.empty(); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !ownerMatches(player, studio)) { + return Optional.empty(); + } + return Optional.ofNullable(evaluations.get(studio.generator().getRequest().requestId())); + } + + public boolean goToPreview(Player player) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> goToPreview(player)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + UUID requestId = studio.generator().getRequest().requestId(); + JigsawStudioPreviewRenderer.PreviewBounds bounds = previewRenderer.bounds(requestId); + if (bounds == null || bounds.isEmpty()) { + JigsawStudioGraphEvaluation evaluation = evaluations.get(requestId); + message(player, evaluation == null + ? "The seed-1337 preview is still being evaluated." + : "The seed-1337 preview has no generated blocks: " + evaluation.detail()); + return false; + } + Location destination = new Location( + studio.world(), + bounds.centerX() + 0.5D, + bounds.maximumY() + 2.0D, + bounds.centerZ() + 0.5D, + player.getLocation().getYaw(), + player.getLocation().getPitch()); + BukkitPlatform.teleportAsync(player, destination).thenRun(() -> J.runEntity( + player, + () -> message(player, "Teleported to the live seed-1337 preview."))); + return true; + } + + private void scheduleInitialEvaluation(ActiveStudio studio) { + if (studio == null) { + return; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + UUID requestId = request.requestId(); + if (!claimInitialEvaluation( + studio.evaluationGeneration(), + isCurrentRequest(studio, requestId))) { + return; + } + publishEvaluation(studio, requestId, 1L); + } + + static boolean claimInitialEvaluation(AtomicLong generation, boolean currentRequest) { + return currentRequest + && Objects.requireNonNull(generation, "Jigsaw Studio evaluation generation") + .compareAndSet(0L, 1L); + } + + private void scheduleEvaluation(ActiveStudio studio) { + if (studio == null) { + return; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + UUID requestId = request.requestId(); + if (!isCurrentRequest(studio, requestId)) { + return; + } + publishEvaluation(studio, requestId, studio.evaluationGeneration().incrementAndGet()); + } + + private void publishEvaluation(ActiveStudio studio, UUID requestId, long generation) { + JigsawStudioGraphEvaluation previous = evaluations.get(requestId); + JigsawStudioPreviewRenderer.PreviewBounds previousBounds = previous == null + ? JigsawStudioPreviewRenderer.PreviewBounds.empty() + : previous.previewBounds(); + evaluations.put(requestId, new JigsawStudioGraphEvaluation( + requestId, + generation, + PREVIEW_SEED, + JigsawStudioEvaluationState.PENDING, + previous == null ? "" : previous.selectedTheme(), + previous == null ? 0 : previous.pieceCount(), + "Compiling the committed graph and assembling seed 1337", + previousBounds)); + refreshAllWorkcellContexts(studio); + J.a(() -> evaluateGraph(studio, requestId, generation)); + } + + private void evaluateGraph(ActiveStudio studio, UUID requestId, long generation) { + EvaluationComputation computation; + try { + JigsawStudioActivation.Request request = studio.generator().getRequest(); + IrisStructure structure = request.source().load( + IrisStructure.class, + request.structureKey(), + false); + if (structure == null) { + throw new IOException("The active structure resource no longer exists"); + } + StructureGraphCompilation compilation = StructureGraphCompiler.compile( + structure, + StructureGraphResolver.forData(request.source())); + StructureGraphDiagnostic firstError = firstDiagnostic( + compilation, + StructureGraphDiagnostic.Severity.ERROR); + if (firstError != null) { + computation = invalidEvaluation( + requestId, + generation, + firstError.code() + ": " + firstError.message()); + } else { + IrisPosition origin = previewOrigin(studio.generator().getLayout(), structure); + StructureAssembler assembler = StructureAssembler.forCompilation(compilation, origin); + StructureAssemblyResult assembly = assembler.assemble(new RNG(PREVIEW_SEED)); + computation = evaluationForAssembly(requestId, generation, compilation, assembly); + } + } catch (Throwable exception) { + IrisLogging.reportError(exception); + computation = invalidEvaluation(requestId, generation, failureMessage(exception)); + } + completeEvaluation(studio, computation); + } + + private EvaluationComputation evaluationForAssembly( + UUID requestId, + long generation, + StructureGraphCompilation compilation, + StructureAssemblyResult assembly + ) throws IOException { + if (assembly.status().isFailure()) { + return invalidEvaluation( + requestId, + generation, + assembly.status().name().toLowerCase(Locale.ROOT) + ": " + assembly.detail()); + } + if (!assembly.hasOutput()) { + return new EvaluationComputation( + new JigsawStudioGraphEvaluation( + requestId, + generation, + PREVIEW_SEED, + JigsawStudioEvaluationState.WARNING, + assembly.selectedTheme(), + 0, + assembly.detail().isEmpty() + ? "Seed 1337 intentionally generated no structure" + : assembly.detail(), + JigsawStudioPreviewRenderer.PreviewBounds.empty()), + JigsawStudioPreviewRenderer.PreviewPlan.empty()); + } + List aligned = alignPreviewPieces(assembly.pieces()); + JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(aligned); + StructureGraphDiagnostic firstWarning = firstDiagnostic( + compilation, + StructureGraphDiagnostic.Severity.WARNING); + JigsawStudioEvaluationState state = firstWarning == null + ? JigsawStudioEvaluationState.VALID + : JigsawStudioEvaluationState.WARNING; + String detail = firstWarning == null + ? "Seed 1337 assembled " + aligned.size() + " piece(s)" + : firstWarning.code() + ": " + firstWarning.message(); + return new EvaluationComputation( + new JigsawStudioGraphEvaluation( + requestId, + generation, + PREVIEW_SEED, + state, + assembly.selectedTheme(), + aligned.size(), + detail, + plan.bounds()), + plan); + } + + private void completeEvaluation(ActiveStudio studio, EvaluationComputation computation) { + JigsawStudioGraphEvaluation evaluated = computation.evaluation(); + UUID requestId = evaluated.requestId(); + if (!isCurrentEvaluation(studio, requestId, evaluated.generation())) { + return; + } + previewRenderer.render( + studio.world(), + requestId, + evaluated.generation(), + computation.plan(), + result -> { + if (!isCurrentEvaluation(studio, requestId, evaluated.generation())) { + return; + } + JigsawStudioGraphEvaluation completed = result.successful() + ? evaluated + : new JigsawStudioGraphEvaluation( + requestId, + evaluated.generation(), + PREVIEW_SEED, + JigsawStudioEvaluationState.WARNING, + evaluated.selectedTheme(), + evaluated.pieceCount(), + evaluated.detail() + "; " + result.failure(), + evaluated.previewBounds()); + evaluations.put(requestId, completed); + refreshAllWorkcellContexts(studio); + }); + } + + private boolean isCurrentEvaluation(ActiveStudio studio, UUID requestId, long generation) { + if (!isCurrentRequest(studio, requestId) + || studio.evaluationGeneration().get() != generation) { + return false; + } + JigsawStudioGraphEvaluation current = evaluations.get(requestId); + return current != null && current.generation() == generation; + } + + private static EvaluationComputation invalidEvaluation( + UUID requestId, + long generation, + String detail + ) { + return new EvaluationComputation( + new JigsawStudioGraphEvaluation( + requestId, + generation, + PREVIEW_SEED, + JigsawStudioEvaluationState.INVALID, + "", + 0, + detail, + JigsawStudioPreviewRenderer.PreviewBounds.empty()), + JigsawStudioPreviewRenderer.PreviewPlan.empty()); + } + + private static StructureGraphDiagnostic firstDiagnostic( + StructureGraphCompilation compilation, + StructureGraphDiagnostic.Severity severity + ) { + for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) { + if (diagnostic.severity() == severity) { + return diagnostic; + } + } + return null; + } + + private static IrisPosition previewOrigin(JigsawStudioLayout layout, IrisStructure structure) { + int radius = Math.max(1, structure.getMaxSizeChunks()) * 16; + return new IrisPosition( + -radius - 32, + 0, + Math.max(16, layout.extentZ() / 2)); + } + + private static List alignPreviewPieces(List pieces) { + int minimumY = Integer.MAX_VALUE; + for (PlacedStructurePiece piece : pieces) { + minimumY = Math.min(minimumY, piece.getMinY()); + } + int shiftY = JigsawStudioLayout.FLOOR_Y + 1 - minimumY; + List aligned = new ArrayList<>(pieces.size()); + for (PlacedStructurePiece piece : pieces) { + aligned.add(new PlacedStructurePiece( + piece.getPiece(), + piece.getObject(), + piece.getX(), + piece.getY() + shiftY, + piece.getZ(), + piece.getRotation(), + piece.getMinX(), + piece.getMinY() + shiftY, + piece.getMinZ(), + piece.getMaxX(), + piece.getMaxY() + shiftY, + piece.getMaxZ())); + } + return List.copyOf(aligned); + } + + private void refreshAllWorkcellContexts(ActiveStudio studio) { + scheduleOnlinePlayers(studio.worldId()); + } + + private static IrisStructure loadStudioStructure(ActiveStudio studio) { + JigsawStudioActivation.Request request = studio.generator().getRequest(); + return request.source().load(IrisStructure.class, request.structureKey(), false); + } + + private static boolean canResizeVariantToCapacity( + JigsawStudioBay workcell, + JigsawStudioVariant variant, + boolean active + ) { + if (!active || !variant.owned()) { + return false; + } + JigsawStudioCellDimensions canonical = variant.dimensions().orElse(null); + if (canonical == null) { + return false; + } + JigsawStudioCellDimensions target = workcell.capacity(); + return canonical.width() <= target.width() + && canonical.height() <= target.height() + && canonical.depth() <= target.depth() + && (canonical.width() < target.width() + || canonical.height() < target.height() + || canonical.depth() < target.depth()); + } + + public Optional menuState(Player player) { + if (player == null || !J.isOwnedByCurrentRegion(player)) { + return Optional.empty(); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + return Optional.empty(); + } + if (!ownerMatches(player, studio)) { + return Optional.empty(); + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioLayout layout = session.layout(); + IrisStructure structure = loadStudioStructure(studio); + List themeSets = new ArrayList<>(); + if (structure != null && structure.getThemeSets() != null) { + for (IrisJigsawThemeSet themeSet : structure.getThemeSets()) { + if (themeSet != null) { + themeSets.add(new JigsawStudioMenuState.ThemeSet( + themeSet.getKey(), + themeSet.getWeight())); + } + } + } + List workcells = new ArrayList<>(layout.bays().size()); + for (JigsawStudioBay workcell : layout.bays()) { + JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(workcell.stableId()); + List variants = new ArrayList<>(); + for (JigsawStudioVariant variant : layout.variants(workcell)) { + boolean active = variant.pieceKey().equals(snapshot.activeVariantKey()); + List memberships = new ArrayList<>( + variant.memberships().size()); + for (JigsawStudioPoolMembership membership : variant.memberships()) { + memberships.add(new JigsawStudioMenuState.Membership( + membership.poolKey(), + membership.entryIndex(), + membership.weight(), + membership.chance())); + } + variants.add(new JigsawStudioMenuState.Variant( + variant.pieceKey(), + variant.resolvedDisplayName(), + variant.dimensions(), + active, + variant.owned(), + variant.rotatable(), + canToggleVariantRotation(request.compatibilityTarget(), variant), + canResizeVariantToCapacity(workcell, variant, active), + variant.themes(), + variant.rules(), + memberships)); + } + workcells.add(new JigsawStudioMenuState.Workcell( + workcell.stableId(), + workcell.canonicalDisplayName(), + workcell.displayName(), + workcell.capacity(), + workcell.enabled(), + snapshot.activeVariantKey(), + snapshot.dirty(), + snapshot.saveInProgress(), + snapshot.switchInProgress(), + variants)); + } + + return Optional.of(new JigsawStudioMenuState( + studio.worldId(), + request.requestId(), + request.structureKey(), + layout.mode(), + request.compatibilityTarget(), + structure != null && structure.isRequireCaps(), + themeSets, + session.selectedBayId().orElse(""), + Optional.ofNullable(evaluations.get(request.requestId())) + .map(JigsawStudioMenuState.Evaluation::from) + .orElseGet(JigsawStudioMenuState.Evaluation::pending), + workcells)); + } + + public boolean selectWorkcell(Player player, String workcellId) { + if (player == null || workcellId == null || workcellId.isBlank()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> selectWorkcell(player, workcellId)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + if (!session.selectBay(workcellId)) { + message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'."); + return false; + } + ensureVisualizationLoop(player); + return true; + } + + public boolean openControlMenu(Player player) { + if (player != null && J.isOwnedByCurrentRegion(player)) { + finalizeJigsawTileWatchesForPlayer(player.getUniqueId()); + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio != null + && reopenRequiredRequests.contains(studio.generator().getRequest().requestId())) { + message(player, "Close and reopen Jigsaw Studio to apply the resized workcell layout."); + return false; + } + } + JigsawStudioMenuController activeMenuController = menuController; + return activeMenuController != null && activeMenuController.open(player); + } + + public boolean giveTool(Player player, JigsawStudioToolPayload payload) { + if (player == null || payload == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> giveTool(player, payload)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null + || !studio.generator().getRequest().requestId().equals(payload.requestId()) + || !authorizeOwner(player, studio)) { + message(player, "This tool belongs to a different or closed Jigsaw Studio session."); + return false; + } + ItemStack tool = toolCodec.create(payload); + Map rejected = player.getInventory().addItem(tool); + if (!rejected.isEmpty()) { + message(player, "Your inventory is full; no Jigsaw Studio tool was added."); + return false; + } + message(player, "Added " + payload.action().displayName() + " tool."); + return true; + } + + public boolean flushAutosave(Player player, String workcellId) { + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> flushAutosave(player, workcellId)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !authorizeOwner(player, studio)) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + String targetId = workcellId == null || workcellId.isBlank() + ? session.selectedBayId().orElse("") + : workcellId; + JigsawStudioBay bay = session.layout().get(targetId); + if (bay == null) { + message(player, "Select a Jigsaw Studio workcell before saving now."); + return false; + } + UUID requestId = studio.generator().getRequest().requestId(); + finalizeJigsawTileWatches(requestId); + if (hasJigsawTileWatch(requestId)) { + message(player, "Wait for the open vanilla jigsaw-block editor to finish its final snapshot."); + return false; + } + SaveAttempt attempt = startSave(studio, studio.world(), bay, player, true); + if (attempt == SaveAttempt.STARTED) { + return true; + } + if (attempt == SaveAttempt.PERSISTENT_FAILURE) { + return false; + } + AutosaveTicket ticket = autosaves.get(new AutosaveKey(requestId, bay.stableId())); + if (ticket != null) { + expediteAutosave( + ticket, + attempt == SaveAttempt.RETRY + ? AUTOSAVE_RETRY_TICKS + : AUTOSAVE_DEBOUNCE_TICKS); + } + return false; + } + + public void closeMenu(Player player) { + JigsawStudioMenuController activeMenuController = menuController; + if (activeMenuController != null) { + activeMenuController.close(player); + } + } + + private boolean useTool( + Player player, + JigsawStudioToolPayload payload, + ItemStack tool, + boolean resetLabel + ) { + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> useTool(player, payload, tool, resetLabel)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null + || !studio.generator().getRequest().requestId().equals(payload.requestId()) + || !authorizeOwner(player, studio)) { + message(player, "This Jigsaw Studio tool is stale or belongs to another session."); + return false; + } + if (payload.action().destructive() && !confirmTool(player, payload)) { + message(player, "Right-click the same tool again within 10 seconds to confirm " + + payload.action().displayName() + "."); + return true; + } + return switch (payload.action()) { + case OPEN_MENU -> openControlMenu(player); + case SELECT_WORKCELL -> selectWorkcell(player, payload.workcellId()); + case LOAD_VARIANT -> switchVariant( + player, + payload.workcellId(), + payload.pieceKey(), + false); + case CREATE_VARIANT -> createVariant(player, payload.workcellId(), false); + case DUPLICATE_VARIANT -> createVariant(player, payload.workcellId(), true); + case DUPLICATE_FAMILY -> duplicateFamilyFromTool(player); + case PREVIEW_GRAPH -> goToPreview(player); + case FLUSH_AUTOSAVE -> flushAutosave(player, payload.workcellId()); + case TOGGLE_ROTATION -> toggleVariantRotatable(player, payload.workcellId()); + case EXPAND_TO_CELL -> expandVariantToCell(player, payload.workcellId()); + case RESIZE_VARIANT -> openVariantSizeSettingsFromTool(player, payload); + case RENAME_VARIANT -> renameVariantFromTool(player, payload, tool, resetLabel); + case ADJUST_VARIANT_WEIGHT -> adjustVariantWeight( + player, + payload.workcellId(), + payload.pieceKey(), + payload.poolKey(), + payload.entryIndex(), + payload.amount()); + case ADJUST_VARIANT_CHANCE -> adjustVariantChance( + player, + payload.workcellId(), + payload.pieceKey(), + payload.poolKey(), + payload.entryIndex(), + payload.amount()); + case UNLINK_MEMBERSHIP -> unlinkVariantMembership( + player, + payload.workcellId(), + payload.pieceKey(), + payload.poolKey(), + payload.entryIndex()); + case TOGGLE_WORKCELL -> toggleWorkcellFromTool(player, payload.workcellId()); + case RESIZE_WORKCELL -> openWorkcellSettingsFromTool(player, payload.workcellId()); + case RENAME_WORKCELL -> renameWorkcellFromTool(player, payload, tool, resetLabel); + case SET_THEME -> useThemeTool(player, payload); + case SET_PIECE_RULES -> openVariantSettingsFromTool(player, payload); + case DELETE_VARIANT -> deleteVariant(player, payload.workcellId(), payload.pieceKey()); + case DELETE_PROJECT -> deleteProject(player); + case TOGGLE_REQUIRE_CAPS -> toggleRequireCapsFromTool(player); + }; + } + + private boolean toggleWorkcellFromTool(Player player, String workcellId) { + Optional state = menuState(player); + JigsawStudioMenuState.Workcell workcell = state + .map(menu -> menu.workcell(workcellId)) + .orElse(null); + if (workcell == null) { + message(player, "This bound workcell no longer exists."); + return false; + } + return setWorkcellEnabled(player, workcellId, !workcell.enabled()); + } + + private boolean openWorkcellSettingsFromTool(Player player, String workcellId) { + JigsawStudioMenuController activeMenuController = menuController; + return activeMenuController != null + && activeMenuController.openWorkcellSettings(player, workcellId); + } + + private boolean openVariantSettingsFromTool(Player player, JigsawStudioToolPayload payload) { + JigsawStudioMenuController activeMenuController = menuController; + return activeMenuController != null + && activeMenuController.openVariantSettings( + player, + payload.workcellId(), + payload.pieceKey()); + } + + private boolean openVariantSizeSettingsFromTool(Player player, JigsawStudioToolPayload payload) { + JigsawStudioMenuController activeMenuController = menuController; + return activeMenuController != null + && activeMenuController.openVariantSizeSettings( + player, + payload.workcellId(), + payload.pieceKey()); + } + + private boolean renameVariantFromTool( + Player player, + JigsawStudioToolPayload payload, + ItemStack tool, + boolean resetLabel + ) { + Optional displayName = toolDisplayName(player, payload, tool, resetLabel); + return displayName.isPresent() && updateVariantDisplayName( + player, + payload.workcellId(), + payload.pieceKey(), + displayName.get()); + } + + private boolean renameWorkcellFromTool( + Player player, + JigsawStudioToolPayload payload, + ItemStack tool, + boolean resetLabel + ) { + Optional displayName = toolDisplayName(player, payload, tool, resetLabel); + return displayName.isPresent() && updateWorkcellDisplayName( + player, + payload.workcellId(), + displayName.get()); + } + + private static Optional toolDisplayName( + Player player, + JigsawStudioToolPayload payload, + ItemStack tool, + boolean resetLabel + ) { + if (resetLabel) { + return Optional.of(""); + } + String displayName = tool == null || !tool.hasItemMeta() || tool.getItemMeta() == null + ? "" + : tool.getItemMeta().getDisplayName(); + String normalized = ChatColor.stripColor(displayName); + normalized = normalized == null ? "" : normalized.trim(); + String defaultName = "Jigsaw Studio: " + payload.action().displayName(); + if (normalized.isEmpty() || normalized.equals(defaultName)) { + message(player, "Rename this bound stick in an anvil to the desired label, then right-click it. " + + "Sneak-right-click resets the label."); + return Optional.empty(); + } + return Optional.of(normalized); + } + + private boolean useThemeTool(Player player, JigsawStudioToolPayload payload) { + if (!payload.pieceKey().isBlank()) { + return openVariantSettingsFromTool(player, payload); + } + Optional state = menuState(player); + if (state.isEmpty()) { + return false; + } + return duplicateActiveFamily( + player, + JigsawStudioMenuController.nextThemeSetKey(state.get().themeSets())); + } + + private boolean duplicateFamilyFromTool(Player player) { + Optional state = menuState(player); + return state.isPresent() && duplicateActiveFamily( + player, + JigsawStudioMenuController.nextThemeSetKey(state.get().themeSets())); + } + + private boolean toggleRequireCapsFromTool(Player player) { + Optional state = menuState(player); + return state.isPresent() && setRequireCaps(player, !state.get().requireCaps()); + } + + private boolean confirmTool(Player player, JigsawStudioToolPayload payload) { + long now = System.nanoTime(); + ToolConfirmation previous = toolConfirmations.get(player.getUniqueId()); + if (previous != null + && previous.expiresAtNanos() >= now + && previous.payload().equals(payload)) { + toolConfirmations.remove(player.getUniqueId(), previous); + return true; + } + toolConfirmations.put( + player.getUniqueId(), + new ToolConfirmation(payload, now + TOOL_CONFIRM_NANOS)); + return false; + } + + private JigsawStudioVariant activeOwnedVariant( + Player player, + ActiveStudio studio, + String workcellId + ) { + if (studio == null) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return null; + } + JigsawStudioSession session = studio.generator().getSession(); + if (session.layout().get(workcellId) == null) { + message(player, "Unknown Jigsaw Studio workcell '" + workcellId + "'."); + return null; + } + JigsawStudioVariant variant = session.activeVariant(workcellId).orElse(null); + if (variant == null) { + message(player, "This workcell has no active variant."); + return null; + } + if (!variant.owned()) { + message(player, "Variant '" + variant.pieceKey() + + "' is read-only. Adopt or clone its graph before editing it."); + return null; + } + return variant; + } + + private JigsawStudioVariant activeOwnedVariant( + Player player, + ActiveStudio studio, + String workcellId, + String expectedPieceKey + ) { + JigsawStudioVariant variant = activeOwnedVariant(player, studio, workcellId); + if (variant == null) { + return null; + } + if (!variant.pieceKey().equals(expectedPieceKey)) { + message(player, "The bound variant changed. Open the control menu or request a fresh tool."); + return null; + } + return variant; + } + + private static JigsawStudioPoolMembership findMembership( + JigsawStudioVariant variant, + String poolKey, + int entryIndex + ) { + for (JigsawStudioPoolMembership membership : variant.memberships()) { + if (membership.poolKey().equals(poolKey) && membership.entryIndex() == entryIndex) { + return membership; + } + } + return null; + } + + private boolean deferDuplicationUntilAutosaved( + Player player, + ActiveStudio studio, + DeferredDuplication requested + ) { + JigsawStudioSession session = studio.generator().getSession(); + UUID requestId = studio.generator().getRequest().requestId(); + boolean autosavePending; + synchronized (saveLifecycleLock) { + autosavePending = session.isDirty() || savesInProgress.contains(requestId); + } + if (!autosavePending) { + return false; + } + DeferredDuplication existing = deferredDuplications.putIfAbsent(requestId, requested); + if (existing == null) { + message(player, requested.kind() == DeferredDuplicationKind.SINGLE + ? "Autosave is finishing first; this cell's variant will duplicate automatically." + : "Autosave is finishing every edited cell first; the coherent family will duplicate automatically."); + expediteAutosaves(requestId); + scheduleDeferredDuplication(requested); + return true; + } + message(player, existing.sameIntent(requested) + ? "That duplicate action is already queued behind autosave." + : "Another duplicate action is already queued behind autosave for this Studio."); + return true; + } + + private void scheduleDeferredDuplication(DeferredDuplication deferred) { + if (deferredDuplications.get(deferred.requestId()) != deferred + || !deferred.scheduled().compareAndSet(false, true)) { + return; + } + try { + J.s(() -> { + deferred.scheduled().set(false); + if (deferredDuplications.get(deferred.requestId()) != deferred) { + return; + } + Player player = Bukkit.getPlayer(deferred.playerId()); + if (player == null) { + deferredDuplications.remove(deferred.requestId(), deferred); + return; + } + boolean scheduled = J.runEntity( + player, + () -> resumeDeferredDuplication(player, deferred), + 0, + () -> deferredDuplications.remove(deferred.requestId(), deferred)); + if (!scheduled) { + deferredDuplications.remove(deferred.requestId(), deferred); + } + }, AUTOSAVE_RETRY_TICKS); + } catch (Throwable exception) { + deferred.scheduled().set(false); + deferredDuplications.remove(deferred.requestId(), deferred); + IrisLogging.reportError(exception); + } + } + + private void resumeDeferredDuplication(Player player, DeferredDuplication deferred) { + if (deferredDuplications.get(deferred.requestId()) != deferred) { + return; + } + ActiveStudio studio = studios.get(deferred.worldId()); + JigsawStudioSession session = studio == null ? null : studio.generator().getSession(); + boolean currentRequest = studio != null + && studio.generator().getRequest().requestId().equals(deferred.requestId()) + && session.sessionId().equals(deferred.sessionId()) + && player.getWorld().getUID().equals(deferred.worldId()); + boolean sourcesCurrent = currentRequest && sourceBindingsCurrent(session, deferred.sourcePieces()); + boolean autosavePending; + boolean operationPending; + boolean terminal; + synchronized (saveLifecycleLock) { + autosavePending = currentRequest + && (session.isDirty() || savesInProgress.contains(deferred.requestId())); + operationPending = currentRequest + && (graphMutationsInProgress.contains(deferred.requestId()) + || materializationsInProgress.contains(deferred.requestId()) + || exportsInProgress.contains(deferred.requestId()) + || session.operationInProgress() + || hasJigsawTileWatch(deferred.requestId())); + terminal = !currentRequest + || closingRequests.contains(deferred.requestId()) + || reopenRequiredRequests.contains(deferred.requestId()); + } + DeferredDuplicationReadiness readiness = deferredDuplicationReadiness( + currentRequest, + sourcesCurrent, + autosavePending, + operationPending, + terminal); + if (readiness == DeferredDuplicationReadiness.STALE) { + deferredDuplications.remove(deferred.requestId(), deferred); + message(player, "The queued duplicate was cancelled because its Studio or source variant changed."); + return; + } + if (readiness == DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE) { + expediteAutosaves(deferred.requestId()); + scheduleDeferredDuplication(deferred); + return; + } + if (readiness == DeferredDuplicationReadiness.WAITING_FOR_OPERATION) { + scheduleDeferredDuplication(deferred); + return; + } + boolean started = deferred.kind() == DeferredDuplicationKind.SINGLE + ? createVariant(player, deferred.workcellId(), true) + : duplicateActiveFamily(player, deferred.themeKey()); + if (started) { + deferredDuplications.remove(deferred.requestId(), deferred); + return; + } + if (isCurrentRequest(studio, deferred.requestId()) + && sourceBindingsCurrent(session, deferred.sourcePieces())) { + scheduleDeferredDuplication(deferred); + } else { + deferredDuplications.remove(deferred.requestId(), deferred); + } + } + + static DeferredDuplicationReadiness deferredDuplicationReadiness( + boolean currentRequest, + boolean sourcesCurrent, + boolean autosavePending, + boolean operationPending, + boolean terminal + ) { + if (!currentRequest || !sourcesCurrent || terminal) { + return DeferredDuplicationReadiness.STALE; + } + if (autosavePending) { + return DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE; + } + if (operationPending) { + return DeferredDuplicationReadiness.WAITING_FOR_OPERATION; + } + return DeferredDuplicationReadiness.READY; + } + + private static boolean sourceBindingsCurrent( + JigsawStudioSession session, + Map expectedSources + ) { + if (session == null) { + return false; + } + for (Map.Entry source : expectedSources.entrySet()) { + if (session.activeVariant(source.getKey()) + .map(JigsawStudioVariant::pieceKey) + .filter(source.getValue()::equals) + .isEmpty()) { + return false; + } + } + return true; + } + + private String beginGraphMutation( + ActiveStudio studio, + JigsawStudioSession session + ) { + UUID requestId = studio.generator().getRequest().requestId(); + finalizeJigsawTileWatches(requestId); + synchronized (saveLifecycleLock) { + if (closingRequests.contains(requestId)) { + return "This Jigsaw Studio is closing and cannot update its graph."; + } + if (savesInProgress.contains(requestId)) { + return "Wait for the current Jigsaw Studio save to finish."; + } + if (graphMutationsInProgress.contains(requestId)) { + return "A Jigsaw Studio graph update is already running."; + } + if (exportsInProgress.contains(requestId)) { + return "Wait for the current Jigsaw Studio export to finish."; + } + if (materializationsInProgress.contains(requestId)) { + return "Wait for the current Jigsaw Studio variant load or rollback to finish."; + } + if (session.operationInProgress()) { + return "Wait for the current workcell operation to finish."; + } + if (reopenRequiredRequests.contains(requestId)) { + return "Close and reopen Jigsaw Studio before making another graph change."; + } + if (hasJigsawTileWatch(requestId)) { + return "Finish or close the open vanilla jigsaw-block editor before changing graph metadata."; + } + if (session.isDirty()) { + return "Wait for every dirty workcell to finish autosaving before changing graph metadata."; + } + graphMutationsInProgress.add(requestId); + return ""; + } + } + + public boolean runCommandGraphMutation( + Player player, + UUID expectedRequestId, + CommandGraphMutation task + ) { + Objects.requireNonNull(expectedRequestId, "Jigsaw Studio command graph request ID"); + Objects.requireNonNull(task, "Jigsaw Studio command graph mutation"); + if (player == null) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> runCommandGraphMutation(player, expectedRequestId, task)); + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null + || !studio.generator().getRequest().requestId().equals(expectedRequestId)) { + message(player, "This Jigsaw Studio session is no longer active."); + return false; + } + if (!authorizeOwner(player, studio)) { + return false; + } + String reservationFailure = beginGraphMutation(studio, studio.generator().getSession()); + if (!reservationFailure.isEmpty()) { + message(player, reservationFailure); + return false; + } + return scheduleGraphMutation(player, studio, task); + } + + private boolean scheduleGraphMutation( + Player player, + ActiveStudio studio, + CommandGraphMutation task + ) { + UUID requestId = studio.generator().getRequest().requestId(); + J.a(() -> { + CommandGraphMutationResult result = null; + Throwable failure = null; + try { + result = task.run(); + } catch (Throwable exception) { + failure = exception; + } + CommandGraphMutationResult completedResult = result; + Throwable completedFailure = failure; + boolean scheduled = J.runEntity( + player, + () -> completeGraphMutation( + player, + studio, + requestId, + completedResult, + completedFailure), + 0, + () -> finishGraphMutation(requestId)); + if (!scheduled) { + finishGraphMutation(requestId); + if (completedFailure != null) { + IrisLogging.reportError(completedFailure); + } + } + }); + return true; + } + + private void completeGraphMutation( + Player player, + ActiveStudio studio, + UUID requestId, + CommandGraphMutationResult result, + Throwable failure + ) { + try { + if (failure != null) { + IrisLogging.reportError(failure); + message(player, "Graph update failed: " + failureMessage(failure)); + return; + } + if (result == null) { + message(player, "Graph update completed without a result; no Studio state changed."); + return; + } + if (!isCurrentRequest(studio, requestId)) { + message(player, "The graph updated on disk after this Studio session changed; reopen it to continue."); + return; + } + JigsawStudioSession session = studio.generator().getSession(); + if (result.rebindActiveVariants().isEmpty()) { + session.replaceLayout(result.layout()); + } else { + session.replaceLayoutAndRebind(result.layout(), result.rebindActiveVariants()); + } + disabledWorkcellRenderer.reconcile(studio.world(), requestId, result.layout()); + for (JigsawStudioBay workcell : result.layout().bays()) { + studio.generator().invalidateRender(workcell.stableId()); + refreshWorkcellContext(studio.worldId(), workcell.stableId()); + } + scheduleEvaluation(studio); + message(player, result.message()); + } catch (Throwable exception) { + IrisLogging.reportError(exception); + message(player, "The graph updated on disk, but Studio could not refresh: " + + failureMessage(exception) + ". Reopen this project before editing further."); + return; + } finally { + finishGraphMutation(requestId); + } + if (!result.activatePieceKey().isEmpty()) { + switchVariant( + player, + result.activateWorkcellId(), + result.activatePieceKey(), + false); + } else if (result.reload().isPresent()) { + reloadActiveVariant(player, studio, result.reload().orElseThrow()); + } + } + + private boolean graphMutationInProgress(UUID requestId) { + synchronized (saveLifecycleLock) { + return graphMutationsInProgress.contains(requestId); + } + } + + private void finishGraphMutation(UUID requestId) { + synchronized (saveLifecycleLock) { + graphMutationsInProgress.remove(requestId); + } + } + + private static JigsawStudioLayout loadMappedLayout(ActiveStudio studio) throws IOException { + JigsawStudioActivation.Request request = studio.generator().getRequest(); + IrisData source = request.source(); + source.invalidateStructureResources(); + IrisStructure structure = source.load(IrisStructure.class, request.structureKey(), false); + if (structure == null) { + throw new IOException("The structure could not be reloaded after the graph transaction"); + } + return JigsawStudioGraphMapper.map(source, structure); + } + + public boolean showAssemblyPreview(Player player, List pieces) { + if (player == null || pieces == null || pieces.isEmpty()) { + return false; + } + if (!J.isOwnedByCurrentRegion(player)) { + return J.runEntity(player, () -> showAssemblyPreview(player, pieces)); + } + if (!studios.containsKey(player.getWorld().getUID())) { + message(player, "Iris Jigsaw Studio is not active in this world."); + return false; + } + List bounds = new ArrayList<>(pieces.size()); + for (PlacedStructurePiece piece : pieces) { + int width = Math.addExact(Math.subtractExact(piece.getMaxX(), piece.getMinX()), 1); + int height = Math.addExact(Math.subtractExact(piece.getMaxY(), piece.getMinY()), 1); + int depth = Math.addExact(Math.subtractExact(piece.getMaxZ(), piece.getMinZ()), 1); + bounds.add(new JigsawStudioBounds( + piece.getMinX(), + piece.getMinY(), + piece.getMinZ(), + new JigsawStudioCellDimensions(width, height, depth))); + } + assemblyPreviews.put(player.getUniqueId(), new AssemblyPreview( + player.getWorld().getUID(), + System.currentTimeMillis() + ASSEMBLY_PREVIEW_MILLIS, + List.copyOf(bounds))); + particlesDisabled.remove(player.getUniqueId()); + ensureVisualizationLoop(player); + return true; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onWorldUnload(WorldUnloadEvent event) { + ActiveStudio studio = studios.get(event.getWorld().getUID()); + if (studio != null) { + UUID requestId = studio.generator().getRequest().requestId(); + finalizeJigsawTileWatches(requestId); + if (requiresLifecycleDrain(studio) && !discardingRequest(requestId)) { + event.setCancelled(true); + expediteAutosaves(requestId); + IrisLogging.warn("Jigsaw Studio kept world %s loaded while pending edits finish autosaving", + event.getWorld().getName()); + return; + } + } + unregister(event.getWorld()); + } + + @EventHandler + public void onChunkLoad(ChunkLoadEvent event) { + ActiveStudio studio = studios.get(event.getWorld().getUID()); + if (studio != null) { + markChunkAvailable(studio, event.getChunk().getX(), event.getChunk().getZ()); + for (JigsawStudioBay bay : studio.generator().getLayout().bays()) { + if (!bay.enabled() + && bay.bounds().originX() >> 4 == event.getChunk().getX() + && bay.bounds().originZ() >> 4 == event.getChunk().getZ()) { + disabledWorkcellRenderer.reconcile( + studio.world(), + studio.generator().getRequest().requestId(), + studio.generator().getLayout()); + break; + } + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkUnload(ChunkUnloadEvent event) { + ActiveStudio studio = studios.get(event.getWorld().getUID()); + if (studio != null) { + disabledWorkcellRenderer.unloadChunk( + studio.generator().getRequest().requestId(), + event.getChunk().getX(), + event.getChunk().getZ()); + } + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + ensureVisualizationLoop(event.getPlayer()); + reconcilePlayerContext(event.getPlayer(), event.getPlayer().getLocation()); + } + + @EventHandler + public void onPlayerChangedWorld(PlayerChangedWorldEvent event) { + closeMenu(event.getPlayer()); + finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); + tripleSneakTracker.clearPlayer(event.getPlayer().getUniqueId()); + visualizationLoops.remove(event.getPlayer().getUniqueId()); + assemblyPreviews.remove(event.getPlayer().getUniqueId()); + playerWorkcells.remove(event.getPlayer().getUniqueId()); + ensureVisualizationLoop(event.getPlayer()); + reconcilePlayerContext(event.getPlayer(), event.getPlayer().getLocation()); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + UUID playerId = event.getPlayer().getUniqueId(); + closeMenu(event.getPlayer()); + finalizeJigsawTileWatchesForPlayer(playerId); + tripleSneakTracker.clearPlayer(playerId); + toolConfirmations.remove(playerId); + visualizationLoops.remove(playerId); + particlesDisabled.remove(playerId); + assemblyPreviews.remove(playerId); + playerWorkcells.remove(playerId); + deferredDuplications.entrySet().removeIf(entry -> entry.getValue().playerId().equals(playerId)); + boardService().clearJigsawContext(event.getPlayer()); + } + + @EventHandler + public void onTripleSneak(PlayerToggleSneakEvent event) { + if (!event.isSneaking()) { + return; + } + Player player = event.getPlayer(); + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null || !ownerMatches(player, studio)) { + tripleSneakTracker.clearPlayer(player.getUniqueId()); + return; + } + JigsawStudioTripleSneakTracker.Progress progress = tripleSneakTracker.recordSneak( + player.getUniqueId(), + studio.worldId(), + studio.generator().getRequest().requestId(), + System.nanoTime()); + if (progress == JigsawStudioTripleSneakTracker.Progress.TRIGGERED) { + openControlMenu(player); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onUseTool(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND + || (event.getAction() != Action.RIGHT_CLICK_AIR + && event.getAction() != Action.RIGHT_CLICK_BLOCK)) { + return; + } + Optional decoded = toolCodec.decode(event.getItem()); + if (decoded.isEmpty()) { + return; + } + finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); + event.setCancelled(true); + useTool(event.getPlayer(), decoded.get(), event.getItem(), event.getPlayer().isSneaking()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerMove(PlayerMoveEvent event) { + if (sameBlockPosition(event.getFrom(), event.getTo())) { + return; + } + reconcilePlayerContext(event.getPlayer(), event.getTo()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerTeleport(PlayerTeleportEvent event) { + finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); + reconcilePlayerContext(event.getPlayer(), event.getTo()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerPortal(PlayerPortalEvent event) { + finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); + reconcilePlayerContext(event.getPlayer(), event.getTo()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerRespawn(PlayerRespawnEvent event) { + finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); + reconcilePlayerContext(event.getPlayer(), event.getRespawnLocation()); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onUnauthorizedBlockPlace(BlockPlaceEvent event) { + if (isUnauthorizedStudioEdit(event.getPlayer(), event.getBlockPlaced())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onUnauthorizedBlockMultiPlace(BlockMultiPlaceEvent event) { + for (BlockState state : event.getReplacedBlockStates()) { + if (isUnauthorizedStudioEdit(event.getPlayer(), state.getBlock())) { + event.setCancelled(true); + return; + } + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockBreak(BlockBreakEvent event) { + if (isControlChest(event.getBlock())) { + event.setCancelled(true); + message(event.getPlayer(), "The Jigsaw Studio control chest cannot be removed."); + return; + } + if (isUnauthorizedStudioEdit(event.getPlayer(), event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onUnauthorizedBucketEmpty(PlayerBucketEmptyEvent event) { + Block target = event.getBlockClicked().getRelative(event.getBlockFace()); + if (isUnauthorizedStudioEdit(event.getPlayer(), target)) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onUnauthorizedBucketFill(PlayerBucketFillEvent event) { + if (isUnauthorizedStudioEdit(event.getPlayer(), event.getBlockClicked())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onUnauthorizedInteract(PlayerInteractEvent event) { + if (event.getAction() == Action.RIGHT_CLICK_BLOCK + && !isControlChest(event.getClickedBlock()) + && isUnauthorizedStudioEdit(event.getPlayer(), event.getClickedBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockPlace(BlockPlaceEvent event) { + markDirty(event.getBlockPlaced()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockMultiPlace(BlockMultiPlaceEvent event) { + for (BlockState state : event.getReplacedBlockStates()) { + markDirty(state.getBlock()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockBreak(BlockBreakEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBucketEmpty(PlayerBucketEmptyEvent event) { + markDirty(event.getBlockClicked().getRelative(event.getBlockFace())); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBucketFill(PlayerBucketFillEvent event) { + markDirty(event.getBlockClicked()); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onControlChest(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK + || event.getHand() != EquipmentSlot.HAND + || !isControlChest(event.getClickedBlock())) { + return; + } + event.setCancelled(true); + ActiveStudio studio = studios.get(event.getPlayer().getWorld().getUID()); + if (!authorizeOwner(event.getPlayer(), studio)) { + return; + } + openControlMenu(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockExplode(BlockExplodeEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID()))) { + event.blockList().clear(); + return; + } + event.blockList().removeIf(this::isImmutableStudioBlock); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedEntityExplode(EntityExplodeEvent event) { + if (materializationInProgress(studios.get(event.getEntity().getWorld().getUID()))) { + event.blockList().clear(); + return; + } + event.blockList().removeIf(this::isImmutableStudioBlock); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedPistonExtend(BlockPistonExtendEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || movesProtectedBlocks(event.getBlocks(), event.getDirection())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedPistonRetract(BlockPistonRetractEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || movesProtectedBlocks(event.getBlocks(), event.getDirection())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedEntityChangeBlock(EntityChangeBlockEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || isImmutableStudioBlock(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockFromTo(BlockFromToEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || isImmutableStudioBlock(event.getBlock()) + || isImmutableStudioBlock(event.getToBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockForm(BlockFormEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || isImmutableStudioBlock(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockGrow(BlockGrowEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || isImmutableStudioBlock(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockSpread(BlockSpreadEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || isImmutableStudioBlock(event.getBlock()) + || isImmutableStudioBlock(event.getSource())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockFade(BlockFadeEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || isImmutableStudioBlock(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockBurn(BlockBurnEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || isImmutableStudioBlock(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onProtectedBlockRedstone(BlockRedstoneEvent event) { + if (materializationInProgress(studios.get(event.getBlock().getWorld().getUID())) + || isImmutableStudioBlock(event.getBlock())) { + event.setNewCurrent(event.getOldCurrent()); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedStructureGrow(StructureGrowEvent event) { + if (event.getLocation().getWorld() != null + && (materializationInProgress(studios.get(event.getLocation().getWorld().getUID())) + || containsImmutableStudioBlock(event.getBlocks()))) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedInventoryClick(InventoryClickEvent event) { + if (!blocksInventoryMutation( + event.getView().getTopInventory(), + event.getWhoClicked().getUniqueId())) { + return; + } + event.setCancelled(true); + if (event.getWhoClicked() instanceof Player player) { + message(player, "This Jigsaw Studio container is read-only during the current operation or owner session."); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedInventoryDrag(InventoryDragEvent event) { + if (!blocksInventoryMutation( + event.getView().getTopInventory(), + event.getWhoClicked().getUniqueId())) { + return; + } + event.setCancelled(true); + if (event.getWhoClicked() instanceof Player player) { + message(player, "This Jigsaw Studio container is read-only during the current operation or owner session."); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedInventoryMove(InventoryMoveItemEvent event) { + if (blocksInventoryMutation(event.getSource(), null) + || blocksInventoryMutation(event.getDestination(), null)) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedInventoryPickup(InventoryPickupItemEvent event) { + if (blocksInventoryMutation(event.getInventory(), null)) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockCook(BlockCookEvent event) { + if (blocksMachineMutation(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedFurnaceBurn(FurnaceBurnEvent event) { + if (blocksMachineMutation(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBrew(BrewEvent event) { + if (blocksMachineMutation(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBrewingStandFuel(BrewingStandFuelEvent event) { + if (blocksMachineMutation(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedBlockDispense(BlockDispenseEvent event) { + if (blocksMachineMutation(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onProtectedCrafterCraft(CrafterCraftEvent event) { + if (blocksMachineMutation(event.getBlock())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerInteract(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK && event.getAction() != Action.PHYSICAL) { + return; + } + Block clicked = event.getClickedBlock(); + if (clicked != null) { + if (event.getAction() == Action.RIGHT_CLICK_BLOCK) { + finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); + if (clicked.getType() == Material.JIGSAW) { + startJigsawTileWatch(event.getPlayer(), clicked); + } + } + markDirty(clicked); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + markDirty(event.getView().getTopInventory()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryDrag(InventoryDragEvent event) { + markDirty(event.getView().getTopInventory()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onInventoryClose(InventoryCloseEvent event) { + markDirty(event.getView().getTopInventory()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryMove(InventoryMoveItemEvent event) { + markDirty(event.getSource()); + markDirty(event.getDestination()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryPickup(InventoryPickupItemEvent event) { + markDirty(event.getInventory()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockCook(BlockCookEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onFurnaceBurn(FurnaceBurnEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onFurnaceStartSmelt(FurnaceStartSmeltEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBrew(BrewEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onBrewingStart(BrewingStartEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBrewingStandFuel(BrewingStandFuelEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockDispense(BlockDispenseEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCrafterCraft(CrafterCraftEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockExplode(BlockExplodeEvent event) { + markDirty(event.blockList()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityExplode(EntityExplodeEvent event) { + markDirty(event.blockList()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityChangeBlock(EntityChangeBlockEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockFromTo(BlockFromToEvent event) { + markDirty(event.getToBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockForm(BlockFormEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockGrow(BlockGrowEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockSpread(BlockSpreadEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockFade(BlockFadeEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockBurn(BlockBurnEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockRedstone(BlockRedstoneEvent event) { + markDirty(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPistonExtend(BlockPistonExtendEvent event) { + markDirty(event.getBlock()); + markDirty(event.getBlock().getRelative(event.getDirection())); + for (Block block : event.getBlocks()) { + markDirty(block); + markDirty(block.getRelative(event.getDirection())); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPistonRetract(BlockPistonRetractEvent event) { + markDirty(event.getBlock()); + markDirty(event.getBlock().getRelative(event.getDirection())); + for (Block block : event.getBlocks()) { + markDirty(block); + markDirty(block.getRelative(event.getDirection())); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onStructureGrow(StructureGrowEvent event) { + for (BlockState state : event.getBlocks()) { + markDirty(state.getBlock()); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onUnauthorizedPlayerCommand(PlayerCommandPreprocessEvent event) { + finalizeJigsawTileWatchesForPlayer(event.getPlayer().getUniqueId()); + ActiveStudio studio = studios.get(event.getPlayer().getWorld().getUID()); + if (studio != null + && materializationInProgress(studio) + && !isSafeNonOwnerCommand(event.getMessage())) { + event.setCancelled(true); + message(event.getPlayer(), "Wait for the current Jigsaw Studio variant load or rollback to finish."); + return; + } + if (studio != null && blocksNonEditableWorkcellMutation( + hasNonEditableWorkcell(studio), event.getMessage())) { + event.setCancelled(true); + message(event.getPlayer(), "Mutating commands are disabled while this Studio has an empty or read-only workcell."); + return; + } + UUID ownerId = studio == null ? null : studio.generator().getRequest().ownerId(); + if (!blocksMutatingCommand(ownerId, event.getPlayer().getUniqueId(), event.getMessage())) { + return; + } + event.setCancelled(true); + message(event.getPlayer(), "This Jigsaw Studio is owned by another player session."); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerCommand(PlayerCommandPreprocessEvent event) { + if (isMutatingCommand(event.getMessage())) { + markAllDirty(event.getPlayer().getWorld()); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onServerCommand(ServerCommandEvent event) { + if (!isSafeNonOwnerCommand(event.getCommand()) && anyMaterializationInProgress()) { + event.setCancelled(true); + return; + } + if (isMutatingCommand(event.getCommand()) && anyNonEditableWorkcell()) { + event.setCancelled(true); + return; + } + if (isMutatingCommand(event.getCommand())) { + markAllDirty(); + } + } + + public boolean markDirty(World world, int worldX, int worldY, int worldZ) { + if (world == null) { + return false; + } + ActiveStudio studio = studios.get(world.getUID()); + if (studio == null) { + return false; + } + if (reopenRequiredRequests.contains(studio.generator().getRequest().requestId())) { + return false; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay bay = session.layout().findAt(worldX, worldY, worldZ); + if (bay == null) { + return false; + } + JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null); + if (activeVariant == null || !activeVariant.owned()) { + return false; + } + JigsawStudioSession.DirtyMark dirtyMark = session.markWorkcellDirty(bay.stableId()); + if (dirtyMark.status() != JigsawStudioSession.DirtyStatus.MARKED) { + return false; + } + scheduleAutosave(studio, bay, dirtyMark.identity().orElseThrow(), AUTOSAVE_DEBOUNCE_TICKS); + markEvaluationStale(studio); + if (dirtyMark.newlyDirty()) { + refreshWorkcellContext(studio.worldId(), bay.stableId()); + } + return true; + } + + public int markAllDirty(World world) { + if (world == null) { + return 0; + } + ActiveStudio studio = studios.get(world.getUID()); + return studio == null ? 0 : markAllDirty(studio); + } + + private boolean scheduleCapture( + ActiveStudio studio, + World world, + JigsawStudioBay bay, + CaptureTarget captureTarget, + Player player, + UUID requestId, + JigsawStudioSession.SaveIdentity saveIdentity, + AutosaveFailureState autosaveFailureState + ) { + List areas = chunkIntersections(captureTarget.bounds()); + CaptureWork work = new CaptureWork( + studio, + world, + bay, + captureTarget, + player, + requestId, + saveIdentity, + autosaveFailureState); + CaptureCoordinator coordinator = new CaptureCoordinator( + work, + areas); + for (ChunkCaptureArea area : areas) { + if (coordinator.stopped()) { + return false; + } + boolean scheduled = J.runRegion( + world, + area.chunkX(), + area.chunkZ(), + () -> captureChunk(coordinator, area)); + if (!scheduled) { + coordinator.fail("Iris could not schedule bay chunk " + + area.chunkX() + "," + area.chunkZ() + " on its owning region.", null); + return false; + } + } + return !coordinator.stopped(); + } + + private boolean scheduleMaterialization(MaterializationWork work) { + String validationFailure = validateMaterialization(work.target()); + if (!validationFailure.isEmpty()) { + work.studio().generator().getSession().abortVariantSwitch(work.token()); + refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId()); + message(work.player(), "Variant '" + work.token().targetVariant().pieceKey() + + "' cannot load: " + validationFailure); + return false; + } + String reservationFailure = beginMaterialization(work); + if (!reservationFailure.isEmpty()) { + work.studio().generator().getSession().abortVariantSwitch(work.token()); + refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId()); + message(work.player(), reservationFailure); + return false; + } + List areas = chunkIntersections(work.workcell().bounds()); + MaterializationCoordinator coordinator = new MaterializationCoordinator(work, areas); + for (ChunkCaptureArea area : areas) { + boolean scheduled = J.runRegion( + work.world(), + area.chunkX(), + area.chunkZ(), + () -> materializeCandidateChunk(coordinator, area)); + if (!scheduled) { + coordinator.candidateComplete( + area, + "Iris could not schedule workcell chunk " + + area.chunkX() + "," + area.chunkZ() + " on its owning region.", + null); + } else { + coordinator.markScheduled(); + } + } + return coordinator.scheduledAny(); + } + + private String beginMaterialization(MaterializationWork work) { + UUID requestId = work.studio().generator().getRequest().requestId(); + synchronized (saveLifecycleLock) { + if (!isCurrentVariantSwitch(work)) { + return "The Jigsaw Studio changed before the variant load could start."; + } + if (closingRequests.contains(requestId)) { + return "This Jigsaw Studio is closing and cannot load another variant."; + } + if (savesInProgress.contains(requestId)) { + return "Wait for the current Jigsaw Studio save to finish."; + } + if (graphMutationsInProgress.contains(requestId)) { + return "Wait for the current Jigsaw Studio graph update to finish."; + } + if (exportsInProgress.contains(requestId)) { + return "Wait for the current Jigsaw Studio export to finish."; + } + if (hasJigsawTileWatch(requestId)) { + return "Finish or close the open vanilla jigsaw-block editor before loading another variant."; + } + if (!materializationsInProgress.add(requestId)) { + return "Another Jigsaw Studio variant load or rollback is already running."; + } + return ""; + } + } + + private void finishMaterialization(UUID requestId) { + synchronized (saveLifecycleLock) { + materializationsInProgress.remove(requestId); + } + } + + private void materializeCandidateChunk( + MaterializationCoordinator coordinator, + ChunkCaptureArea area + ) { + MaterializationWork work = coordinator.work(); + if (!isCurrentVariantSwitch(work)) { + coordinator.candidateComplete( + area, + "Jigsaw Studio changed before the variant could finish loading.", + null); + return; + } + if (!work.world().isChunkLoaded(area.chunkX(), area.chunkZ())) { + coordinator.candidateComplete( + area, + "Workcell chunk " + area.chunkX() + "," + area.chunkZ() + + " is not loaded. Visit the whole workcell and try again.", + null); + return; + } + try { + coordinator.markCandidateTouched(area); + writeMaterializedChunk( + work.world(), + work.workcell(), + work.target(), + area); + coordinator.candidateComplete(area, "", null); + } catch (Throwable exception) { + coordinator.candidateComplete( + area, + "Variant materialization failed in chunk " + + area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception), + exception); + } + } + + private void materializeRollbackChunk( + MaterializationCoordinator coordinator, + ChunkCaptureArea area + ) { + MaterializationWork work = coordinator.work(); + if (!isRollbackVariantSwitch(work)) { + coordinator.rollbackComplete( + area, + "Jigsaw Studio changed before the previous variant could be restored.", + null); + return; + } + if (!work.world().isChunkLoaded(area.chunkX(), area.chunkZ())) { + coordinator.rollbackComplete( + area, + "Rollback chunk " + area.chunkX() + "," + area.chunkZ() + " is not loaded.", + null); + return; + } + try { + writeMaterializedChunk( + work.world(), + work.workcell(), + work.previous(), + area); + coordinator.rollbackComplete(area, "", null); + } catch (Throwable exception) { + coordinator.rollbackComplete( + area, + "Rollback failed in chunk " + area.chunkX() + "," + area.chunkZ() + + ": " + failureMessage(exception), + exception); + } + } + + static String validateMaterialization(JigsawStudioGenerator.RenderedBay rendered) { + if (!rendered.valid()) { + return rendered.failure(); + } + for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) { + PlatformBlockState state = block.state(); + if (state.isCustom()) { + return "custom block '" + state.key() + + "' requires provider-owned placement and cannot be swapped live in Studio"; + } + if (!(state.nativeHandle() instanceof BlockData)) { + return "block '" + state.key() + "' has no Bukkit block-data representation"; + } + } + for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) { + try { + BlockData data = Bukkit.createBlockData( + "minecraft:jigsaw[orientation=" + connector.orientation() + "]"); + if (!(data instanceof Jigsaw)) { + return "connector orientation '" + connector.orientation() + + "' did not resolve to a jigsaw marker"; + } + } catch (IllegalArgumentException exception) { + return "connector orientation '" + connector.orientation() + "' is invalid"; + } + } + return ""; + } + + private static void writeMaterializedChunk( + World world, + JigsawStudioBay workcell, + JigsawStudioGenerator.RenderedBay rendered, + ChunkCaptureArea area + ) throws IOException { + JigsawStudioBounds bounds = workcell.bounds(); + Map expected = materializedBlockData(rendered); + BlockData air = Material.AIR.createBlockData(); + for (int x = area.minimumX(); x < area.maximumX(); x++) { + for (int y = 0; y < bounds.dimensions().height(); y++) { + for (int z = area.minimumZ(); z < area.maximumZ(); z++) { + Block target = world.getBlockAt( + bounds.originX() + x, + bounds.originY() + y, + bounds.originZ() + z); + BlockData blockData = expected.getOrDefault(new LocalPosition(x, y, z), air); + target.setBlockData(blockData, false); + } + } + } + applyRenderedBayChunk(world, workcell, rendered, area.chunkX(), area.chunkZ()); + verifyMaterializedChunk(world, workcell, rendered, area, expected); + } + + private static Map materializedBlockData( + JigsawStudioGenerator.RenderedBay rendered + ) throws IOException { + Map expected = new HashMap<>(); + for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) { + if (block.state().isCustom() || !(block.state().nativeHandle() instanceof BlockData blockData)) { + throw new IOException("Rendered block '" + block.state().key() + + "' cannot be placed directly in Bukkit Studio"); + } + expected.put(new LocalPosition(block.x(), block.y(), block.z()), blockData); + } + for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) { + BlockData marker; + try { + marker = Bukkit.createBlockData( + "minecraft:jigsaw[orientation=" + connector.orientation() + "]"); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid rendered connector orientation '" + + connector.orientation() + "'", exception); + } + expected.put(new LocalPosition(connector.x(), connector.y(), connector.z()), marker); + } + return expected; + } + + private static void verifyMaterializedChunk( + World world, + JigsawStudioBay workcell, + JigsawStudioGenerator.RenderedBay rendered, + ChunkCaptureArea area, + Map expected + ) throws IOException { + JigsawStudioBounds bounds = workcell.bounds(); + BlockData air = Material.AIR.createBlockData(); + for (int x = area.minimumX(); x < area.maximumX(); x++) { + for (int y = 0; y < bounds.dimensions().height(); y++) { + for (int z = area.minimumZ(); z < area.maximumZ(); z++) { + Block target = world.getBlockAt( + bounds.originX() + x, + bounds.originY() + y, + bounds.originZ() + z); + BlockData expectedData = expected.getOrDefault(new LocalPosition(x, y, z), air); + if (!target.getBlockData().equals(expectedData)) { + throw new IOException("Workcell block did not materialize at " + + target.getX() + "," + target.getY() + "," + target.getZ()); + } + } + } + } + verifyRenderedBayChunk(world, workcell, rendered, area.chunkX(), area.chunkZ()); + } + + private boolean isCurrentVariantSwitch(MaterializationWork work) { + return isCurrentRequest( + work.studio(), + work.studio().generator().getRequest().requestId()) + && work.studio().generator().getSession().isVariantSwitchCurrent(work.token()); + } + + private boolean isRollbackVariantSwitch(MaterializationWork work) { + return studios.get(work.studio().worldId()) == work.studio() + && materializationInProgress(work.studio()); + } + + private void markDirty(Block block) { + if (block != null) { + markDirty(block.getWorld(), block.getX(), block.getY(), block.getZ()); + } + } + + private void markDirty(List blocks) { + for (Block block : blocks) { + markDirty(block); + } + } + + private void markDirty(Inventory inventory) { + for (Block block : inventoryBlocks(inventory)) { + markDirty(block); + } + } + + private boolean blocksInventoryMutation(Inventory inventory, UUID actorId) { + for (Block block : inventoryBlocks(inventory)) { + StudioBlockMutationContext context = studioBlockMutationContext(block); + if (context != null && blocksStudioInventoryMutation( + true, + context.immutable(), + materializationInProgress(context.studio()), + reopenRequiredRequests.contains(context.requestId()), + context.ownerId(), + actorId)) { + return true; + } + } + return false; + } + + private boolean blocksMachineMutation(Block block) { + StudioBlockMutationContext context = studioBlockMutationContext(block); + return context != null && blocksStudioInventoryMutation( + true, + context.immutable(), + materializationInProgress(context.studio()), + reopenRequiredRequests.contains(context.requestId()), + context.ownerId(), + null); + } + + private StudioBlockMutationContext studioBlockMutationContext(Block block) { + if (block == null) { + return null; + } + ActiveStudio studio = studios.get(block.getWorld().getUID()); + if (studio == null) { + return null; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + boolean preview = previewRenderer.contains( + request.requestId(), block.getX(), block.getY(), block.getZ()); + JigsawStudioControlPosition control = studio.generator().getLayout().controlPosition(); + boolean controlChest = block.getType() == Material.CHEST + && block.getX() == control.worldX() + && block.getY() == control.worldY() + && block.getZ() == control.worldZ(); + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay bay = session.layout().findAt( + block.getX(), block.getY(), block.getZ()); + boolean workcell = bay != null; + if (!preview && !controlChest && !workcell) { + return null; + } + JigsawStudioVariant variant = bay == null + ? null + : session.activeVariant(bay.stableId()).orElse(null); + boolean nonEditableWorkcell = bay != null && (variant == null || !variant.owned()); + return new StudioBlockMutationContext( + studio, + request.requestId(), + request.ownerId(), + preview || controlChest || nonEditableWorkcell); + } + + private static List inventoryBlocks(Inventory inventory) { + if (inventory == null) { + return List.of(); + } + List blocks = new ArrayList<>(2); + InventoryHolder holder = inventory.getHolder(); + if (holder instanceof BlockInventoryHolder blockInventoryHolder) { + blocks.add(blockInventoryHolder.getBlock()); + } else if (holder instanceof DoubleChest doubleChest) { + addInventoryBlock(blocks, doubleChest.getLeftSide()); + addInventoryBlock(blocks, doubleChest.getRightSide()); + } + return blocks; + } + + private static void addInventoryBlock(List blocks, InventoryHolder holder) { + if (holder instanceof BlockInventoryHolder blockInventoryHolder + && !blocks.contains(blockInventoryHolder.getBlock())) { + blocks.add(blockInventoryHolder.getBlock()); + } + } + + private void startJigsawTileWatch(Player player, Block block) { + if (player == null || block == null || block.getType() != Material.JIGSAW) { + return; + } + World world = block.getWorld(); + int worldX = block.getX(); + int worldY = block.getY(); + int worldZ = block.getZ(); + UUID playerId = player.getUniqueId(); + if (J.isOwnedByCurrentRegion(world, worldX >> 4, worldZ >> 4)) { + initializeJigsawTileWatch(playerId, world, worldX, worldY, worldZ); + return; + } + boolean scheduled = J.runRegion( + world, + worldX >> 4, + worldZ >> 4, + () -> initializeJigsawTileWatch(playerId, world, worldX, worldY, worldZ)); + if (!scheduled) { + IrisLogging.warn("Jigsaw Studio could not watch jigsaw marker NBT at %d,%d,%d in %s", + worldX, worldY, worldZ, world.getName()); + } + } + + private void initializeJigsawTileWatch( + UUID playerId, + World world, + int worldX, + int worldY, + int worldZ + ) { + ActiveStudio studio = studios.get(world.getUID()); + if (!enabled || studio == null) { + return; + } + Block block = world.getBlockAt(worldX, worldY, worldZ); + if (block.getType() != Material.JIGSAW) { + return; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay bay = session.layout().findAt(worldX, worldY, worldZ); + JigsawStudioVariant variant = bay == null + ? null + : session.activeVariant(bay.stableId()).orElse(null); + if (bay == null || variant == null || !variant.owned()) { + return; + } + KMap snapshot; + try { + snapshot = BukkitPlatform.serializeTile(block.getLocation()); + } catch (Throwable exception) { + IrisLogging.reportError(exception); + return; + } + if (snapshot == null) { + IrisLogging.warn("Jigsaw Studio could not read jigsaw marker NBT at %d,%d,%d in %s", + worldX, worldY, worldZ, world.getName()); + return; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + JigsawTileWatchKey key = new JigsawTileWatchKey( + request.requestId(), world.getUID(), worldX, worldY, worldZ); + JigsawTileWatch watch = new JigsawTileWatch( + key, + studio, + playerId, + bay.stableId(), + snapshot, + new AtomicBoolean(false), + new AtomicBoolean(false)); + jigsawTileWatches.put(key, watch); + scheduleJigsawTileWatch(watch); + } + + private void scheduleJigsawTileWatch(JigsawTileWatch watch) { + boolean scheduled = J.runRegion( + watch.studio().world(), + watch.key().worldX() >> 4, + watch.key().worldZ() >> 4, + () -> runJigsawTileWatch(watch, false), + JIGSAW_TILE_WATCH_INTERVAL_TICKS); + if (!scheduled && jigsawTileWatches.get(watch.key()) == watch) { + warnJigsawTileWatchScheduleFailure(watch); + scheduleJigsawTileWatchReconciliation(watch, false); + } + } + + private void runJigsawTileWatch(JigsawTileWatch watch, boolean release) { + if (jigsawTileWatches.get(watch.key()) != watch) { + return; + } + ActiveStudio studio = studios.get(watch.key().worldId()); + if (!enabled || studio != watch.studio() + || !isCurrentRequest(studio, watch.key().requestId())) { + jigsawTileWatches.remove(watch.key(), watch); + return; + } + Block block = studio.world().getBlockAt( + watch.key().worldX(), watch.key().worldY(), watch.key().worldZ()); + JigsawStudioBay bay = studio.generator().getSession().layout().findAt( + watch.key().worldX(), watch.key().worldY(), watch.key().worldZ()); + if (block.getType() != Material.JIGSAW + || bay == null + || !bay.stableId().equals(watch.workcellId())) { + jigsawTileWatches.remove(watch.key(), watch); + return; + } + KMap snapshot; + try { + snapshot = BukkitPlatform.serializeTile(block.getLocation()); + } catch (Throwable exception) { + jigsawTileWatches.remove(watch.key(), watch); + IrisLogging.reportError(exception); + return; + } + if (snapshot == null) { + jigsawTileWatches.remove(watch.key(), watch); + IrisLogging.warn("Jigsaw Studio stopped watching unreadable jigsaw marker NBT at %d,%d,%d in %s", + watch.key().worldX(), + watch.key().worldY(), + watch.key().worldZ(), + studio.world().getName()); + return; + } + JigsawTilePollDecision decision = jigsawTilePollDecision( + watch.snapshot(), snapshot, release); + if (decision.changed()) { + markDirty( + studio.world(), + watch.key().worldX(), + watch.key().worldY(), + watch.key().worldZ()); + } + if (!decision.continueWatching()) { + jigsawTileWatches.remove(watch.key(), watch); + return; + } + JigsawTileWatch next = new JigsawTileWatch( + watch.key(), + watch.studio(), + watch.playerId(), + watch.workcellId(), + snapshot, + watch.scheduleFailureLogged(), + new AtomicBoolean(false)); + if (jigsawTileWatches.replace(watch.key(), watch, next)) { + scheduleJigsawTileWatch(next); + } + } + + private boolean hasJigsawTileWatch(UUID requestId) { + if (requestId == null) { + return false; + } + for (JigsawTileWatchKey key : jigsawTileWatches.keySet()) { + if (requestId.equals(key.requestId())) { + return true; + } + } + return false; + } + + private void clearJigsawTileWatches(UUID requestId) { + if (requestId != null) { + jigsawTileWatches.keySet().removeIf(key -> requestId.equals(key.requestId())); + } + } + + private void finalizeJigsawTileWatchesForPlayer(UUID playerId) { + if (playerId == null) { + return; + } + for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) { + if (playerId.equals(watch.playerId())) { + finalizeJigsawTileWatch(watch); + } + } + } + + private void finalizeJigsawTileWatches(UUID requestId) { + if (requestId == null) { + return; + } + for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) { + if (requestId.equals(watch.key().requestId())) { + finalizeJigsawTileWatch(watch); + } + } + } + + private void finalizeAllJigsawTileWatches() { + for (JigsawTileWatch watch : List.copyOf(jigsawTileWatches.values())) { + finalizeJigsawTileWatch(watch); + } + } + + private void finalizeJigsawTileWatch(JigsawTileWatch watch) { + if (jigsawTileWatches.get(watch.key()) != watch) { + return; + } + World world = watch.studio().world(); + int chunkX = watch.key().worldX() >> 4; + int chunkZ = watch.key().worldZ() >> 4; + if (J.isOwnedByCurrentRegion(world, chunkX, chunkZ)) { + runJigsawTileWatch(watch, true); + return; + } + boolean scheduled = J.runRegion( + world, + chunkX, + chunkZ, + () -> runJigsawTileWatch(watch, true)); + if (!scheduled) { + warnJigsawTileWatchScheduleFailure(watch); + scheduleJigsawTileWatchReconciliation(watch, true); + } + } + + private void warnJigsawTileWatchScheduleFailure(JigsawTileWatch watch) { + if (watch.scheduleFailureLogged().compareAndSet(false, true)) { + IrisLogging.warn("Jigsaw Studio could not schedule jigsaw marker NBT at %d,%d,%d in %s; Iris will keep retrying", + watch.key().worldX(), + watch.key().worldY(), + watch.key().worldZ(), + watch.studio().world().getName()); + } + } + + private void scheduleJigsawTileWatchReconciliation(JigsawTileWatch watch, boolean release) { + if (!watch.reconciliationScheduled().compareAndSet(false, true)) { + return; + } + try { + J.s(() -> { + watch.reconciliationScheduled().set(false); + if (jigsawTileWatches.get(watch.key()) != watch) { + return; + } + if (release) { + finalizeJigsawTileWatch(watch); + } else { + scheduleJigsawTileWatch(watch); + } + }, AUTOSAVE_RETRY_TICKS); + } catch (Throwable exception) { + watch.reconciliationScheduled().set(false); + IrisLogging.reportError(exception); + } + } + + static boolean tileSnapshotChanged( + KMap previous, + KMap current + ) { + return !Objects.equals(previous, current); + } + + static JigsawTilePollDecision jigsawTilePollDecision( + KMap previous, + KMap current, + boolean release + ) { + return new JigsawTilePollDecision(tileSnapshotChanged(previous, current), !release); + } + + private void markAllDirty() { + for (ActiveStudio studio : studios.values()) { + markAllDirty(studio); + } + } + + private int markAllDirty(ActiveStudio studio) { + int changed = 0; + JigsawStudioSession session = studio.generator().getSession(); + for (JigsawStudioBay bay : session.layout().bays()) { + JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null); + if (activeVariant == null || !activeVariant.owned()) { + continue; + } + JigsawStudioSession.DirtyMark dirtyMark = session.markWorkcellDirty(bay.stableId()); + if (dirtyMark.status() == JigsawStudioSession.DirtyStatus.MARKED) { + scheduleAutosave( + studio, + bay, + dirtyMark.identity().orElseThrow(), + AUTOSAVE_DEBOUNCE_TICKS); + changed++; + } + } + if (changed > 0) { + markEvaluationStale(studio); + for (JigsawStudioBay bay : session.layout().bays()) { + refreshWorkcellContext(studio.worldId(), bay.stableId()); + } + } + return changed; + } + + private void scheduleAutosave( + ActiveStudio studio, + JigsawStudioBay bay, + JigsawStudioSession.DirtyIdentity identity, + int delayTicks + ) { + UUID requestId = studio.generator().getRequest().requestId(); + AutosaveKey key = new AutosaveKey(requestId, bay.stableId()); + AutosaveTicket ticket = new AutosaveTicket( + key, + studio, + identity, + new AtomicBoolean(false), + new AtomicBoolean(false)); + autosaves.put(key, ticket); + scheduleAutosave(ticket, delayTicks); + } + + private void scheduleAutosave(AutosaveTicket ticket, int delayTicks) { + if (autosaves.get(ticket.key()) != ticket + || !ticket.scheduled().compareAndSet(false, true)) { + return; + } + JigsawStudioBay bay = resolveAutosaveBay(ticket); + if (bay == null) { + ticket.scheduled().set(false); + return; + } + JigsawStudioBounds bounds = bay.bounds(); + boolean scheduled = J.runRegion( + ticket.studio().world(), + bounds.originX() >> 4, + bounds.originZ() >> 4, + () -> runAutosave(ticket), + delayTicks); + if (!scheduled) { + ticket.scheduled().set(false); + if (ticket.scheduleFailureLogged().compareAndSet(false, true)) { + IrisLogging.warn("Jigsaw Studio autosave could not schedule workcell %s for request %s; Iris will keep retrying", + ticket.key().workcellId(), ticket.key().requestId()); + } + scheduleAutosaveReconciliation(ticket); + } + } + + private JigsawStudioBay resolveAutosaveBay(AutosaveTicket ticket) { + if (autosaves.get(ticket.key()) != ticket) { + return null; + } + JigsawStudioBay bay = resolveCurrentAutosaveBay( + ticket.studio().generator().getSession(), + ticket.key().workcellId()); + if (bay != null) { + return bay; + } + if (autosaves.remove(ticket.key(), ticket)) { + reportAutosaveFailure( + ticket, + new AutosavePersistentFailure( + "workcell is absent from the current Studio layout; pending ticket retired", + null)); + } + return null; + } + + static JigsawStudioBay resolveCurrentAutosaveBay( + JigsawStudioSession session, + String workcellId + ) { + JigsawStudioSession activeSession = Objects.requireNonNull( + session, + "Jigsaw Studio autosave session"); + return activeSession.layout().get(Objects.requireNonNull( + workcellId, + "Jigsaw Studio autosave workcell ID")); + } + + private boolean retainCurrentAutosaveFailure( + ActiveStudio studio, + String workcellId, + AutosavePersistentFailure failure + ) { + AutosaveKey key = new AutosaveKey( + studio.generator().getRequest().requestId(), + workcellId); + AutosaveTicket ticket = autosaves.get(key); + return ticket != null && retainPersistentAutosaveFailure(ticket, failure); + } + + private AutosaveFailureState autosaveFailureState( + ActiveStudio studio, + JigsawStudioSession.SaveIdentity saveIdentity + ) { + AutosaveKey key = new AutosaveKey( + studio.generator().getRequest().requestId(), + saveIdentity.workcellId()); + AutosaveTicket ticket = autosaves.get(key); + return ticket != null && matchesSaveIdentity(ticket.identity(), saveIdentity) + ? ticket.failureState() + : new AutosaveFailureState(); + } + + private boolean retainPersistentAutosaveFailure( + AutosaveTicket ticket, + AutosavePersistentFailure failure + ) { + if (autosaves.get(ticket.key()) != ticket + || !isCurrentRequest(ticket.studio(), ticket.key().requestId()) + || !ticket.studio().generator().getSession().isDirtyCurrent(ticket.identity())) { + return false; + } + AutosaveFailureDecision decision = reportAutosaveFailure(ticket, failure); + expediteAutosave(ticket, decision.retryTicks()); + return true; + } + + private static AutosaveFailureDecision reportAutosaveFailure( + AutosaveTicket ticket, + AutosavePersistentFailure failure + ) { + JigsawStudioActivation.Request request = ticket.studio().generator().getRequest(); + return recordPersistentAutosaveFailure( + ticket.failureState(), + ticket.key().requestId(), + request.structureKey(), + ticket.key().workcellId(), + ticket.identity().variantKey(), + failure.detail(), + failure.cause()); + } + + private static void reportPersistentSaveFailure( + ActiveStudio studio, + JigsawStudioSession.SaveIdentity saveIdentity, + AutosaveFailureState failureState, + AutosavePersistentFailure failure + ) { + JigsawStudioActivation.Request request = studio.generator().getRequest(); + recordPersistentAutosaveFailure( + failureState, + request.requestId(), + request.structureKey(), + saveIdentity.workcellId(), + saveIdentity.variantKey(), + failure.detail(), + failure.cause()); + } + + static AutosaveFailureDecision recordPersistentAutosaveFailure( + AutosaveFailureState state, + UUID requestId, + String structureKey, + String workcellId, + String pieceKey, + String detail, + Throwable cause + ) { + AutosaveFailureState activeState = Objects.requireNonNull( + state, + "Jigsaw Studio autosave failure state"); + AutosaveFailureDecision decision = activeState.recordPersistentFailure(); + if (!decision.logFailure()) { + return decision; + } + String context = autosaveFailureContext( + requestId, + structureKey, + workcellId, + pieceKey, + detail); + if (cause == null) { + IrisLogging.warn("%s", context); + } else { + IrisLogging.reportError(context, cause); + } + return decision; + } + + static String autosaveFailureContext( + UUID requestId, + String structureKey, + String workcellId, + String pieceKey, + String detail + ) { + return "Jigsaw Studio save failure: request=" + + Objects.requireNonNull(requestId, "Jigsaw Studio autosave request ID") + + " structure=" + + Objects.requireNonNull(structureKey, "Jigsaw Studio autosave structure key") + + " workcell=" + + Objects.requireNonNull(workcellId, "Jigsaw Studio autosave workcell ID") + + " piece=" + + Objects.requireNonNull(pieceKey, "Jigsaw Studio autosave piece key") + + " failure=" + + Objects.requireNonNull(detail, "Jigsaw Studio autosave failure detail"); + } + + static int persistentAutosaveRetryTicks(int failureCount) { + int index = Math.max(0, Math.min( + failureCount - 1, + AUTOSAVE_PERSISTENT_RETRY_DELAYS.size() - 1)); + return AUTOSAVE_PERSISTENT_RETRY_DELAYS.get(index); + } + + private void scheduleAutosaveReconciliation(AutosaveTicket ticket) { + try { + J.s(() -> { + if (autosaves.get(ticket.key()) == ticket + && !ticket.scheduled().get()) { + scheduleAutosave(ticket, AUTOSAVE_RETRY_TICKS); + } + }, AUTOSAVE_RETRY_TICKS); + } catch (Throwable exception) { + IrisLogging.reportError(exception); + } + } + + private void runAutosave(AutosaveTicket ticket) { + if (autosaves.get(ticket.key()) != ticket) { + return; + } + ticket.scheduled().set(false); + ActiveStudio studio = ticket.studio(); + JigsawStudioSession session = studio.generator().getSession(); + if (!isCurrentRequest(studio, ticket.key().requestId())) { + autosaves.remove(ticket.key(), ticket); + return; + } + JigsawStudioBay bay = resolveAutosaveBay(ticket); + if (bay == null) { + return; + } + if (!session.isDirtyCurrent(ticket.identity())) { + autosaves.remove(ticket.key(), ticket); + return; + } + SaveAttempt attempt = startSave(studio, studio.world(), bay, null, false); + if (attempt == SaveAttempt.STARTED || attempt == SaveAttempt.PERSISTENT_FAILURE) { + return; + } + scheduleAutosave( + ticket, + attempt == SaveAttempt.RETRY + ? AUTOSAVE_RETRY_TICKS + : AUTOSAVE_DEBOUNCE_TICKS); + } + + private void completeAutosaveAttempt( + ActiveStudio studio, + JigsawStudioSession.SaveIdentity saveIdentity, + AutosaveFailureState failureState, + AutosavePersistentFailure persistentFailure + ) { + AutosaveKey key = new AutosaveKey( + studio.generator().getRequest().requestId(), + saveIdentity.workcellId()); + AutosaveTicket ticket = autosaves.get(key); + if (ticket == null || !matchesSaveIdentity(ticket.identity(), saveIdentity)) { + if (persistentFailure != null) { + reportPersistentSaveFailure(studio, saveIdentity, failureState, persistentFailure); + } + return; + } + if (resolveAutosaveBay(ticket) == null) { + return; + } + if (!studio.generator().getSession().isDirtyCurrent(ticket.identity())) { + autosaves.remove(key, ticket); + return; + } + if (persistentFailure != null) { + retainPersistentAutosaveFailure(ticket, persistentFailure); + return; + } + scheduleAutosave(ticket, AUTOSAVE_RETRY_TICKS); + } + + private static boolean matchesSaveIdentity( + JigsawStudioSession.DirtyIdentity dirtyIdentity, + JigsawStudioSession.SaveIdentity saveIdentity + ) { + return dirtyIdentity.sessionId().equals(saveIdentity.sessionId()) + && dirtyIdentity.workcellId().equals(saveIdentity.workcellId()) + && dirtyIdentity.variantKey().equals(saveIdentity.variantKey()) + && dirtyIdentity.loadGeneration() == saveIdentity.loadGeneration() + && dirtyIdentity.mutationGeneration() == saveIdentity.mutationGeneration(); + } + + private void drainAutosavesBeforeDisable() { + for (ActiveStudio studio : List.copyOf(studios.values())) { + drainAutosavesBeforeRemoval(studio); + } + if (!autosaves.isEmpty()) { + IrisLogging.warn("Jigsaw Studio disabled with %d autosave operation(s) still pending after the final drain attempt", + autosaves.size()); + } + } + + private void drainAutosavesBeforeRemoval(ActiveStudio studio) { + if (studio == null) { + return; + } + UUID requestId = studio.generator().getRequest().requestId(); + if (discardingRequest(requestId)) { + return; + } + if (J.isFolia() || !J.isPrimaryThread()) { + expediteAutosaves(requestId); + return; + } + for (AutosaveTicket ticket : List.copyOf(autosaves.values())) { + if (requestId.equals(ticket.key().requestId())) { + drainAutosaveSynchronously(ticket); + } + } + } + + private boolean drainAutosaveSynchronously(AutosaveTicket ticket) { + if (autosaves.get(ticket.key()) != ticket) { + return true; + } + ActiveStudio studio = ticket.studio(); + JigsawStudioSession session = studio.generator().getSession(); + if (!isCurrentRequest(studio, ticket.key().requestId())) { + autosaves.remove(ticket.key(), ticket); + return true; + } + JigsawStudioBay bay = resolveAutosaveBay(ticket); + if (bay == null) { + return false; + } + if (!session.isDirtyCurrent(ticket.identity())) { + autosaves.remove(ticket.key(), ticket); + return true; + } + JigsawStudioVariant activeVariant = session.activeVariant(bay.stableId()).orElse(null); + if (activeVariant == null || !activeVariant.owned()) { + return false; + } + BayReadiness readiness = studio.population(bay).readiness(); + if (!readiness.ready()) { + return false; + } + CaptureTarget captureTarget; + try { + captureTarget = resolveCaptureTarget(studio, bay, activeVariant); + } catch (IOException exception) { + retainPersistentAutosaveFailure( + ticket, + new AutosavePersistentFailure( + "Jigsaw Studio cannot capture this workcell: " + failureMessage(exception), + exception)); + return false; + } + JigsawStudioSession.SaveStart sessionSave = session.beginSave(bay.stableId()); + if (sessionSave.status() != JigsawStudioSession.SaveStatus.STARTED) { + return false; + } + JigsawStudioSession.SaveIdentity saveIdentity = sessionSave.identity().orElseThrow(); + if (tryBeginSave(ticket.key().requestId()) != SaveStart.STARTED) { + session.abortSave(saveIdentity); + return false; + } + List areas = chunkIntersections(captureTarget.bounds()); + CaptureWork work = new CaptureWork( + studio, + studio.world(), + bay, + captureTarget, + null, + ticket.key().requestId(), + saveIdentity, + ticket.failureState()); + CaptureCoordinator coordinator = new CaptureCoordinator(work, areas); + List snapshots = new ArrayList<>(areas.size()); + try { + for (ChunkCaptureArea area : areas) { + if (!studio.world().isChunkLoaded(area.chunkX(), area.chunkZ())) { + throw new IOException("Workcell chunk " + area.chunkX() + "," + area.chunkZ() + + " is not loaded during the final autosave drain"); + } + snapshots.add(captureChunkIntersection( + studio.world(), + captureTarget.bounds(), + captureTarget.piece(), + captureTarget.object(), + area, + captureTarget.displayRotationQuarterTurns())); + } + assembleAndPersist(coordinator, snapshots); + } catch (Throwable exception) { + coordinator.fail("Jigsaw Studio final autosave drain failed: " + + failureMessage(exception), exception); + } + return !session.isDirtyCurrent(ticket.identity()); + } + + private void expediteAutosaves(UUID requestId) { + if (requestId == null) { + return; + } + for (AutosaveTicket current : List.copyOf(autosaves.values())) { + if (!requestId.equals(current.key().requestId())) { + continue; + } + expediteAutosave(current, 0); + } + } + + private void expediteAutosave(AutosaveTicket current, int delayTicks) { + AutosaveTicket expedited = new AutosaveTicket( + current.key(), + current.studio(), + current.identity(), + new AtomicBoolean(false), + current.scheduleFailureLogged(), + current.failureState()); + if (autosaves.replace(current.key(), current, expedited)) { + scheduleAutosave(expedited, delayTicks); + } + } + + private boolean requiresLifecycleDrain(ActiveStudio studio) { + UUID requestId = studio.generator().getRequest().requestId(); + if (hasJigsawTileWatch(requestId) + || studio.generator().getSession().isDirty() + || studio.generator().getSession().operationInProgress()) { + return true; + } + for (AutosaveKey key : autosaves.keySet()) { + if (requestId.equals(key.requestId())) { + return true; + } + } + synchronized (saveLifecycleLock) { + return savesInProgress.contains(requestId) + || graphMutationsInProgress.contains(requestId) + || materializationsInProgress.contains(requestId) + || exportsInProgress.contains(requestId); + } + } + + private boolean discardingRequest(UUID requestId) { + synchronized (saveLifecycleLock) { + return discardingRequests.contains(requestId); + } + } + + private void clearAutosaves(UUID requestId) { + if (requestId != null) { + autosaves.keySet().removeIf(key -> key.requestId().equals(requestId)); + } + } + + private void markEvaluationStale(ActiveStudio studio) { + UUID requestId = studio.generator().getRequest().requestId(); + evaluations.computeIfPresent( + requestId, + (ignored, current) -> current.state() == JigsawStudioEvaluationState.STALE + ? current + : current.stale("Autosave is pending for one or more edited workcells")); + } + + static boolean isMutatingCommand(String commandLine) { + return MUTATING_COMMANDS.contains(commandLabel(commandLine)); + } + + static boolean blocksMutatingCommand(UUID ownerId, UUID actorId, String commandLine) { + return !ownerMatches(ownerId, actorId) + && !isSafeNonOwnerCommand(commandLine); + } + + static boolean blocksNonEditableWorkcellMutation( + boolean hasNonEditableWorkcell, + String commandLine + ) { + return hasNonEditableWorkcell && isMutatingCommand(commandLine); + } + + static boolean canToggleVariantRotation( + JigsawStudioCompatibilityTarget compatibilityTarget, + JigsawStudioVariant variant + ) { + JigsawStudioCompatibilityTarget target = Objects.requireNonNull( + compatibilityTarget, + "Jigsaw Studio compatibility target"); + JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio variant"); + return activeVariant.owned() + && (target != JigsawStudioCompatibilityTarget.VANILLA_PORTABLE + || !activeVariant.rotatable()); + } + + static boolean isSafeNonOwnerCommand(String commandLine) { + String normalized = normalizeCommandLine(commandLine); + String label = commandLabel(normalized); + if (SAFE_NON_OWNER_COMMANDS.contains(label)) { + return true; + } + int separator = normalized.indexOf(' '); + if (!label.equals("iris") || separator < 0) { + return false; + } + String arguments = normalized.substring(separator + 1).trim(); + return arguments.equals("jigsaw status") || arguments.startsWith("jigsaw status "); + } + + private static String commandLabel(String commandLine) { + String normalized = normalizeCommandLine(commandLine); + int separator = normalized.indexOf(' '); + String label = separator < 0 ? normalized : normalized.substring(0, separator); + int namespace = label.lastIndexOf(':'); + return namespace < 0 ? label : label.substring(namespace + 1); + } + + private static String normalizeCommandLine(String commandLine) { + if (commandLine == null) { + return ""; + } + String normalized = commandLine.trim().toLowerCase(Locale.ROOT); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + return normalized; + } + + private void captureChunk(CaptureCoordinator coordinator, ChunkCaptureArea area) { + if (coordinator.stopped()) { + return; + } + ActiveStudio studio = coordinator.studio(); + World world = coordinator.world(); + if (!isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) { + coordinator.fail("Jigsaw Studio changed before every bay chunk could be captured; save cancelled.", null); + return; + } + if (!world.isChunkLoaded(area.chunkX(), area.chunkZ())) { + coordinator.fail("Bay chunk " + area.chunkX() + "," + area.chunkZ() + + " is not loaded. Visit the whole bay and try again.", null); + return; + } + try { + ChunkSnapshot snapshot = captureChunkIntersection( + world, + coordinator.captureTarget().bounds(), + coordinator.captureTarget().piece(), + coordinator.captureTarget().object(), + area, + coordinator.captureTarget().displayRotationQuarterTurns()); + if (!isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) { + coordinator.fail("Jigsaw Studio changed while bay chunks were being captured; save cancelled.", null); + return; + } + coordinator.accept(snapshot); + } catch (Throwable exception) { + coordinator.fail("Jigsaw Studio capture failed in chunk " + + area.chunkX() + "," + area.chunkZ() + ": " + failureMessage(exception), exception); + } + } + + private void assembleAndPersist(CaptureCoordinator coordinator, List snapshots) { + if (coordinator.failed()) { + return; + } + ActiveStudio studio = coordinator.studio(); + if (!isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) { + coordinator.fail("Jigsaw Studio changed before captured chunks could be assembled; save cancelled.", null); + return; + } + try { + Capture capture = aggregateSnapshots( + coordinator.captureTarget().bounds(), + coordinator.areas(), + snapshots, + coordinator.captureTarget().displayRotationQuarterTurns()); + List connectors = preserveCapturedConnectorOrder( + coordinator.captureTarget().piece(), + capture.connectors()); + requireWorkcellTopology( + coordinator.bay(), + connectors, + coordinator.captureTarget().displayRotationQuarterTurns()); + if (!isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) { + coordinator.fail("Jigsaw Studio changed before the captured bay could be written; save cancelled.", null); + return; + } + message(coordinator.player(), "Captured " + connectors.size() + + " connector(s) from " + snapshots.size() + + " chunk(s); validating and writing the owned structure graph..."); + persistCapture(coordinator, capture, connectors); + } catch (Throwable exception) { + coordinator.failPersistent( + "Jigsaw Studio capture assembly failed: " + failureMessage(exception), + exception); + } + } + + private void persistCapture( + CaptureCoordinator coordinator, + Capture capture, + List connectors + ) { + ActiveStudio studio = coordinator.studio(); + JigsawStudioBay bay = coordinator.bay(); + Player player = coordinator.player(); + try { + if (!isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) { + message(player, "Jigsaw Studio changed before the captured bay could be written; save cancelled."); + return; + } + JigsawStudioActivation.Request request = studio.generator().getRequest(); + IrisData source = request.source(); + Path packRoot = source.getDataFolder().toPath(); + JigsawStudioResourceBundleAssembler.Assembly assembly = + JigsawStudioResourceBundleAssembler.assemble( + packRoot, + request.structureKey(), + coordinator.saveIdentity().variantKey(), + capture.objectContent(), + connectors, + capture.hasBlockEntities()); + StructureResourceBundleGraphCompiler.requireViable(assembly.bundle()); + if (!isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) { + message(player, "Jigsaw Studio changed during validation; save cancelled before writing."); + return; + } + StructureWriteResult result; + synchronized (saveLifecycleLock) { + if (!isCurrentSave(studio, coordinator.requestId(), coordinator.saveIdentity())) { + message(player, "Jigsaw Studio changed before the validated save entered the writer; save cancelled."); + return; + } + result = new StructureTransactionWriter(packRoot) + .write(assembly.bundle(), StructureWriteOptions.overwriteExpected( + assembly.expectedManifestHash())); + } + if (!result.successful()) { + String failure = writeFailure(result); + coordinator.recordPersistentFailure(failure, result.failure().orElse(null)); + message(player, failure); + return; + } + source.invalidateStructureResources(); + studio.generator().invalidateRender(bay.stableId(), coordinator.saveIdentity().variantKey()); + JigsawStudioSession session = studio.generator().getSession(); + boolean unchanged = session.markWorkcellSaved(coordinator.saveIdentity()); + try { + reloadSessionLayout(studio); + } catch (IOException | RuntimeException refreshFailure) { + IrisLogging.reportError(refreshFailure); + message(player, "The graph saved, but Studio could not refresh its variant catalog. " + + "Close and reopen this project before continuing: " + + failureMessage(refreshFailure)); + } + String cleanup = result.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED + ? " The graph committed, but transaction cleanup requires operator attention in the console." + : ""; + String mutationNotice = unchanged ? "" : " Newer edits remain unsaved."; + message(player, "Saved piece '" + coordinator.saveIdentity().variantKey() + "' and object '" + + assembly.objectKey() + "' atomically." + mutationNotice + cleanup); + if (unchanged) { + scheduleEvaluation(studio); + } + IrisLogging.info("Jigsaw Studio saved: structure=%s piece=%s object=%s connectors=%d status=%s", + request.structureKey(), coordinator.saveIdentity().variantKey(), assembly.objectKey(), + connectors.size(), result.status()); + } catch (Throwable exception) { + String failure = "Jigsaw Studio save failed: " + failureMessage(exception); + coordinator.recordPersistentFailure(failure, exception); + message(player, failure); + } finally { + coordinator.complete(); + } + } + + static List chunkIntersections(JigsawStudioBounds bounds) { + JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds"); + List areas = new ArrayList<>(); + int minimumChunkX = captureBounds.originX() >> 4; + int maximumChunkX = captureBounds.maxX() >> 4; + int minimumChunkZ = captureBounds.originZ() >> 4; + int maximumChunkZ = captureBounds.maxZ() >> 4; + for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { + int chunkOriginX = chunkX << 4; + int minimumX = Math.max(0, chunkOriginX - captureBounds.originX()); + int maximumX = Math.min( + captureBounds.dimensions().width(), + chunkOriginX + 16 - captureBounds.originX()); + for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { + int chunkOriginZ = chunkZ << 4; + int minimumZ = Math.max(0, chunkOriginZ - captureBounds.originZ()); + int maximumZ = Math.min( + captureBounds.dimensions().depth(), + chunkOriginZ + 16 - captureBounds.originZ()); + areas.add(new ChunkCaptureArea( + chunkX, + chunkZ, + minimumX, + maximumX, + minimumZ, + maximumZ)); + } + } + return List.copyOf(areas); + } + + static ChunkSnapshot captureChunkIntersection( + World world, + JigsawStudioBounds bounds, + IrisJigsawPiece sourcePiece, + IrisObject sourceObject, + ChunkCaptureArea area + ) throws IOException { + return captureChunkIntersection(world, bounds, sourcePiece, sourceObject, area, 0); + } + + static ChunkSnapshot captureChunkIntersection( + World world, + JigsawStudioBounds bounds, + IrisJigsawPiece sourcePiece, + IrisObject sourceObject, + ChunkCaptureArea area, + int displayRotationQuarterTurns + ) throws IOException { + World captureWorld = Objects.requireNonNull(world, "Jigsaw Studio capture world"); + JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds"); + IrisObject captureSource = Objects.requireNonNull(sourceObject, "Jigsaw Studio source object"); + ChunkCaptureArea captureArea = Objects.requireNonNull(area, "Jigsaw Studio chunk capture area"); + int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4); + int sourceWidth = (quarterTurns & 1) == 0 + ? captureBounds.dimensions().width() + : captureBounds.dimensions().depth(); + int sourceDepth = (quarterTurns & 1) == 0 + ? captureBounds.dimensions().depth() + : captureBounds.dimensions().width(); + List blocks = new ArrayList<>(); + List connectors = new ArrayList<>(); + for (int x = captureArea.minimumX(); x < captureArea.maximumX(); x++) { + for (int y = 0; y < bounds.dimensions().height(); y++) { + for (int z = captureArea.minimumZ(); z < captureArea.maximumZ(); z++) { + Block block = captureWorld.getBlockAt( + captureBounds.originX() + x, + captureBounds.originY() + y, + captureBounds.originZ() + z); + BlockData blockData = block.getBlockData(); + if (blockData instanceof Jigsaw jigsaw) { + KMap nbt = BukkitPlatform.serializeTile(block.getLocation()); + if (nbt == null) { + throw new IOException("Cannot read jigsaw marker NBT at " + + block.getX() + "," + block.getY() + "," + block.getZ() + + ". The active NMS binding must support tile serialization."); + } + IrisJigsawConnector connector; + try { + connector = JigsawStudioMarkerParser.parse(nbt, jigsaw.getOrientation(), x, y, z); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid jigsaw marker at " + + block.getX() + "," + block.getY() + "," + block.getZ() + + ": " + failureMessage(exception), exception); + } + restoreCapturedMetadataForDisplay( + connector, + sourcePiece, + captureBounds.dimensions(), + quarterTurns); + BlockData finalData; + try { + finalData = Bukkit.createBlockData(connector.getFinalState()); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid final_state '" + connector.getFinalState() + + "' at " + block.getX() + "," + block.getY() + "," + block.getZ(), exception); + } + connector.setFinalState(finalData.getAsString()); + connectors.add(CapturedConnector.from(connector)); + if (finalData.getMaterial() != Material.STRUCTURE_VOID) { + blocks.add(new CapturedBlock( + x, + y, + z, + BukkitBlockState.of(finalData), + null)); + } + continue; + } + if (isAir(blockData.getMaterial())) { + LocalPosition sourcePosition = inversePosition( + x, + y, + z, + sourceWidth, + sourceDepth, + quarterTurns); + PlatformBlockState retainedAir = retainedSourceAir( + captureSource, + sourcePosition.x(), + sourcePosition.y(), + sourcePosition.z(), + blockData); + if (retainedAir != null) { + blocks.add(new CapturedBlock(x, y, z, retainedAir, null)); + } + continue; + } + if (blockData.getMaterial() == Material.STRUCTURE_VOID) { + continue; + } + blocks.add(new CapturedBlock( + x, + y, + z, + BukkitBlockState.of(blockData), + TileData.getTileState(block, false))); + } + } + } + return new ChunkSnapshot(captureArea, blocks, connectors); + } + + static PlatformBlockState retainedSourceAir( + IrisObject sourceObject, + int x, + int y, + int z, + BlockData current + ) { + IrisObject source = Objects.requireNonNull(sourceObject, "Jigsaw Studio source object"); + BlockData currentState = Objects.requireNonNull(current, "Jigsaw Studio current block state"); + if (!isAir(currentState.getMaterial()) + || x < 0 + || y < 0 + || z < 0 + || x >= source.getW() + || y >= source.getH() + || z >= source.getD()) { + return null; + } + PlatformBlockState original = source.getBlocks().get(source.getSigned(x, y, z)); + return original != null && original.isAir() ? original : null; + } + + private static boolean isAir(Material material) { + return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR; + } + + static Capture aggregateSnapshots( + JigsawStudioBounds bounds, + List expectedAreas, + List snapshots + ) throws IOException { + return aggregateSnapshots(bounds, expectedAreas, snapshots, 0); + } + + static Capture aggregateSnapshots( + JigsawStudioBounds bounds, + List expectedAreas, + List snapshots, + int displayRotationQuarterTurns + ) throws IOException { + JigsawStudioBounds captureBounds = Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds"); + int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4); + List requiredAreas = List.copyOf(expectedAreas); + List capturedSnapshots = List.copyOf(snapshots); + Map requiredByChunk = new HashMap<>(); + for (ChunkCaptureArea area : requiredAreas) { + if (requiredByChunk.put(chunkKey(area.chunkX(), area.chunkZ()), area) != null) { + throw new IOException("Duplicate required bay chunk " + area.chunkX() + "," + area.chunkZ()); + } + } + if (capturedSnapshots.size() != requiredByChunk.size()) { + throw new IOException("Expected " + requiredByChunk.size() + " bay chunk snapshot(s), received " + + capturedSnapshots.size()); + } + Map capturedByChunk = new HashMap<>(); + for (ChunkSnapshot snapshot : capturedSnapshots) { + ChunkCaptureArea area = snapshot.area(); + long key = chunkKey(area.chunkX(), area.chunkZ()); + ChunkCaptureArea required = requiredByChunk.get(key); + if (!area.equals(required)) { + throw new IOException("Unexpected bay chunk intersection for " + + area.chunkX() + "," + area.chunkZ()); + } + if (capturedByChunk.put(key, snapshot) != null) { + throw new IOException("Duplicate bay chunk snapshot " + area.chunkX() + "," + area.chunkZ()); + } + } + if (!capturedByChunk.keySet().containsAll(requiredByChunk.keySet())) { + throw new IOException("One or more required bay chunk snapshots are missing"); + } + + List ordered = new ArrayList<>(capturedSnapshots); + ordered.sort(Comparator + .comparingInt((ChunkSnapshot snapshot) -> snapshot.area().chunkX()) + .thenComparingInt(snapshot -> snapshot.area().chunkZ())); + int sourceWidth = (quarterTurns & 1) == 0 + ? captureBounds.dimensions().width() + : captureBounds.dimensions().depth(); + int sourceDepth = (quarterTurns & 1) == 0 + ? captureBounds.dimensions().depth() + : captureBounds.dimensions().width(); + IrisObject object = new IrisObject( + sourceWidth, + captureBounds.dimensions().height(), + sourceDepth); + IrisObjectRotation inverseRotation = IrisObjectRotation.of(0, 90.0D * quarterTurns, 0); + List connectors = new ArrayList<>(); + Set capturedBlocks = new HashSet<>(); + Set capturedConnectors = new HashSet<>(); + boolean hasBlockEntities = false; + for (ChunkSnapshot snapshot : ordered) { + ChunkCaptureArea area = snapshot.area(); + for (CapturedBlock block : snapshot.blocks()) { + if (!area.contains(block.x(), block.z()) + || block.y() < 0 + || block.y() >= captureBounds.dimensions().height()) { + throw new IOException("Captured block falls outside bay chunk " + + area.chunkX() + "," + area.chunkZ()); + } + LocalPosition position = inversePosition( + block.x(), block.y(), block.z(), sourceWidth, sourceDepth, quarterTurns); + if (!capturedBlocks.add(position)) { + throw new IOException("Duplicate captured block at " + + block.x() + "," + block.y() + "," + block.z()); + } + PlatformBlockState sourceState = quarterTurns == 0 + ? block.state() + : inverseRotation.rotate(block.state(), 0, 0, 0); + if (sourceState == null) { + throw new IOException("Captured block state cannot be inverse-rotated at " + + block.x() + "," + block.y() + "," + block.z()); + } + object.setUnsigned(position.x(), position.y(), position.z(), sourceState); + TileData tileData = block.tileData(); + if (tileData != null) { + object.setUnsignedTile(position.x(), position.y(), position.z(), tileData.clone()); + hasBlockEntities = true; + } + } + for (CapturedConnector capturedConnector : snapshot.connectors()) { + if (!area.contains(capturedConnector.x(), capturedConnector.z()) + || capturedConnector.y() < 0 + || capturedConnector.y() >= captureBounds.dimensions().height()) { + throw new IOException("Captured connector falls outside bay chunk " + + area.chunkX() + "," + area.chunkZ()); + } + LocalPosition position = inversePosition( + capturedConnector.x(), + capturedConnector.y(), + capturedConnector.z(), + sourceWidth, + sourceDepth, + quarterTurns); + if (!capturedConnectors.add(position)) { + throw new IOException("Duplicate captured connector at " + + capturedConnector.x() + "," + capturedConnector.y() + "," + + capturedConnector.z()); + } + IrisJigsawConnector connector = capturedConnector.toConnector(); + if (quarterTurns != 0) { + connector.setPosition(new IrisPosition(position.x(), position.y(), position.z())); + connector.setDirection(inverseRotation.rotate(connector.getDirection())); + connector.setTop(inverseRotation.rotate(connector.getTop())); + PlatformBlockState finalState = B.getStateOrNull(connector.getFinalState(), false); + if (finalState == null) { + throw new IOException("Captured connector final state cannot be parsed at " + + capturedConnector.x() + "," + capturedConnector.y() + "," + + capturedConnector.z()); + } + PlatformBlockState sourceFinalState = inverseRotation.rotate(finalState, 0, 0, 0); + if (sourceFinalState == null) { + throw new IOException("Captured connector final state cannot be inverse-rotated at " + + capturedConnector.x() + "," + capturedConnector.y() + "," + + capturedConnector.z()); + } + connector.setFinalState(sourceFinalState.key()); + } + connectors.add(connector); + } + } + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + object.write(output); + return new Capture(output.toByteArray(), connectors, hasBlockEntities); + } + } + + static List preserveCapturedConnectorOrder( + IrisJigsawPiece sourcePiece, + List capturedConnectors + ) throws IOException { + IrisJigsawPiece source = Objects.requireNonNull(sourcePiece, "Jigsaw Studio source piece"); + List sourceConnectors = source.getConnectors(); + if (sourceConnectors == null) { + throw new IOException("Jigsaw Studio source piece has no connector list"); + } + List captured = new ArrayList<>(Objects.requireNonNull( + capturedConnectors, + "Jigsaw Studio captured connectors")); + Map capturedByPosition = new HashMap<>(captured.size()); + for (IrisJigsawConnector connector : captured) { + LocalPosition position = connectorPosition(connector, "captured"); + if (capturedByPosition.put(position, connector) != null) { + throw new IOException("Jigsaw Studio capture has duplicate connector position " + + connectorLocation(position)); + } + } + + List ordered = new ArrayList<>(captured.size()); + Set sourcePositions = new HashSet<>(sourceConnectors.size()); + for (IrisJigsawConnector connector : sourceConnectors) { + LocalPosition position = connectorPosition(connector, "source piece"); + if (!sourcePositions.add(position)) { + throw new IOException("Jigsaw Studio source piece has duplicate connector position " + + connectorLocation(position)); + } + IrisJigsawConnector matched = capturedByPosition.remove(position); + if (matched != null) { + ordered.add(matched); + } + } + + List appended = new ArrayList<>(capturedByPosition.values()); + appended.sort(NEW_CONNECTOR_SOURCE_POSITION_ORDER); + ordered.addAll(appended); + return List.copyOf(ordered); + } + + private static LocalPosition connectorPosition(IrisJigsawConnector connector, String owner) throws IOException { + if (connector == null || connector.getPosition() == null) { + throw new IOException("Jigsaw Studio " + owner + " contains a connector without a position"); + } + IrisPosition position = connector.getPosition(); + return new LocalPosition(position.getX(), position.getY(), position.getZ()); + } + + private static String connectorLocation(LocalPosition position) { + return position.x() + "," + position.y() + "," + position.z(); + } + + private static LocalPosition inversePosition( + int x, + int y, + int z, + int sourceWidth, + int sourceDepth, + int displayRotationQuarterTurns + ) { + return switch (Math.floorMod(displayRotationQuarterTurns, 4)) { + case 0 -> new LocalPosition(x, y, z); + case 1 -> new LocalPosition(z, y, sourceDepth - 1 - x); + case 2 -> new LocalPosition(sourceWidth - 1 - x, y, sourceDepth - 1 - z); + case 3 -> new LocalPosition(sourceWidth - 1 - z, y, x); + default -> throw new IllegalStateException("Unreachable Jigsaw Studio inverse rotation"); + }; + } + + static void requireWorkcellTopology( + JigsawStudioBay workcell, + List connectors, + int displayRotationQuarterTurns + ) throws IOException { + JigsawPlanarArchetype expected = workcell.archetype().orElse(null); + if (expected == null) { + return; + } + int mask = 0; + for (IrisJigsawConnector connector : connectors) { + mask |= switch (connector.getDirection()) { + case NORTH_NEGATIVE_Z -> JigsawPlanarDirection.NORTH.bit(); + case EAST_POSITIVE_X -> JigsawPlanarDirection.EAST.bit(); + case SOUTH_POSITIVE_Z -> JigsawPlanarDirection.SOUTH.bit(); + case WEST_NEGATIVE_X -> JigsawPlanarDirection.WEST.bit(); + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new IOException( + "Planar workcells cannot save vertical connectors"); + }; + } + JigsawPlanarTopology sourceTopology = JigsawPlanarTopology.fromMask(mask); + JigsawPlanarTopology displayedTopology = sourceTopology.rotateClockwise(displayRotationQuarterTurns); + if (displayedTopology != expected.canonicalTopology()) { + throw new IOException("Workcell '" + workcell.stableId() + "' requires " + + expected.canonicalTopology().name().toLowerCase(Locale.ROOT) + + " connector orientation, but the edited markers form " + + displayedTopology.name().toLowerCase(Locale.ROOT) + + ". Keep the workcell's red floor glyph orientation; Iris rotates the saved variant automatically."); + } + } + + static void storeConnectorFinalState( + IrisObject object, + int x, + int y, + int z, + BlockData finalData + ) { + Objects.requireNonNull(object, "Jigsaw Studio captured object"); + BlockData activeFinalData = Objects.requireNonNull(finalData, "Jigsaw connector final state"); + if (activeFinalData.getMaterial() != Material.STRUCTURE_VOID) { + object.setUnsigned(x, y, z, BukkitBlockState.of(activeFinalData)); + } + } + + private void scheduleHydration( + ActiveStudio studio, + World world, + int chunkX, + int chunkZ, + int attempt + ) { + if (!enabled || studios.get(world.getUID()) != studio) { + return; + } + long key = chunkKey(chunkX, chunkZ); + if (!studio.hydrationsInProgress().add(key)) { + return; + } + boolean scheduled = J.runRegion( + world, + chunkX, + chunkZ, + () -> runHydrationAttempt(studio, world, chunkX, chunkZ, attempt, key), + HYDRATION_RETRY_TICKS); + if (!scheduled) { + studio.hydrationsInProgress().remove(key); + } + } + + private void runHydrationAttempt( + ActiveStudio studio, + World world, + int chunkX, + int chunkZ, + int attempt, + long key + ) { + studio.hydrationsInProgress().remove(key); + if (!enabled || studios.get(world.getUID()) != studio) { + return; + } + if (!world.isChunkLoaded(chunkX, chunkZ)) { + if (attempt < MAX_HYDRATION_ATTEMPTS) { + scheduleHydration(studio, world, chunkX, chunkZ, attempt + 1); + } + return; + } + if (hydrateChunk(studio, world, chunkX, chunkZ)) { + scheduleHydration(studio, world, chunkX, chunkZ, 0); + } + } + + private static boolean hydrateChunk( + ActiveStudio studio, + World world, + int chunkX, + int chunkZ + ) { + long key = chunkKey(chunkX, chunkZ); + boolean verificationRequired = false; + for (JigsawStudioBay bay : studio.generator().getLayout().bays()) { + BayPopulation population = studio.population(bay); + if (!population.needsApplication(key) && !population.needsVerification(key)) { + continue; + } + JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(bay); + if (!rendered.valid()) { + population.fail(rendered.failure()); + continue; + } + try { + if (population.needsApplication(key)) { + applyRenderedBayChunk(world, bay, rendered, chunkX, chunkZ); + population.markApplied(key); + verificationRequired = true; + } else { + verifyRenderedBayChunk(world, bay, rendered, chunkX, chunkZ); + population.markHydrated(key); + } + } catch (Throwable exception) { + if (population.fail("chunk " + chunkX + "," + chunkZ + + " could not hydrate: " + failureMessage(exception))) { + IrisLogging.reportError(exception); + } + } + } + return verificationRequired; + } + + private static void applyRenderedBayChunk( + World world, + JigsawStudioBay bay, + JigsawStudioGenerator.RenderedBay rendered, + int chunkX, + int chunkZ + ) throws IOException { + JigsawStudioBounds bounds = bay.bounds(); + Set connectorPositions = new HashSet<>(rendered.connectors().size()); + for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) { + connectorPositions.add(new LocalPosition(connector.x(), connector.y(), connector.z())); + } + for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) { + TileData tileData = block.tileData(); + if (tileData == null + || connectorPositions.contains(new LocalPosition(block.x(), block.y(), block.z()))) { + continue; + } + int worldX = bounds.originX() + block.x(); + int worldZ = bounds.originZ() + block.z(); + if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) { + continue; + } + Block target = world.getBlockAt(worldX, bounds.originY() + block.y(), worldZ); + if (!tileData.isApplicable(target.getBlockData())) { + throw new IOException("source tile state does not match rendered block at " + + target.getX() + "," + target.getY() + "," + target.getZ()); + } + if (!tileData.toBukkitTry(target)) { + throw new IOException("source tile state could not hydrate at " + + target.getX() + "," + target.getY() + "," + target.getZ()); + } + } + for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) { + int worldX = bounds.originX() + renderedConnector.x(); + int worldZ = bounds.originZ() + renderedConnector.z(); + if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) { + continue; + } + Block target = world.getBlockAt( + worldX, + bounds.originY() + renderedConnector.y(), + worldZ); + BlockData blockData = target.getBlockData(); + if (!(blockData instanceof Jigsaw jigsaw)) { + throw new IOException("expected a jigsaw marker at " + + target.getX() + "," + target.getY() + "," + target.getZ()); + } + String actualOrientation = jigsaw.getOrientation().name().toLowerCase(Locale.ROOT); + if (!actualOrientation.equals(renderedConnector.orientation())) { + throw new IOException("jigsaw marker orientation at " + + target.getX() + "," + target.getY() + "," + target.getZ() + + " is " + actualOrientation + " instead of " + renderedConnector.orientation()); + } + KMap expected = markerNbt(renderedConnector.connector()); + BukkitPlatform.deserializeTile(expected, target.getLocation()); + } + } + + private static void verifyRenderedBayChunk( + World world, + JigsawStudioBay bay, + JigsawStudioGenerator.RenderedBay rendered, + int chunkX, + int chunkZ + ) throws IOException { + JigsawStudioBounds bounds = bay.bounds(); + Set connectorPositions = new HashSet<>(rendered.connectors().size()); + for (JigsawStudioGenerator.RenderedConnector connector : rendered.connectors()) { + connectorPositions.add(new LocalPosition(connector.x(), connector.y(), connector.z())); + } + for (JigsawStudioGenerator.RenderedBlock block : rendered.blocks()) { + TileData tileData = block.tileData(); + if (tileData == null + || connectorPositions.contains(new LocalPosition(block.x(), block.y(), block.z()))) { + continue; + } + int worldX = bounds.originX() + block.x(); + int worldZ = bounds.originZ() + block.z(); + if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) { + continue; + } + Block target = world.getBlockAt(worldX, bounds.originY() + block.y(), worldZ); + if (!tileData.isApplicable(target.getBlockData())) { + throw new IOException("hydrated tile state no longer matches its block at " + + target.getX() + "," + target.getY() + "," + target.getZ()); + } + KMap hydrated = BukkitPlatform.serializeTile(target.getLocation()); + if (hydrated == null + || tileData.getProperties() != null + && !nbtContains(tileData.getProperties(), hydrated)) { + throw new IOException("the active NMS binding did not preserve source tile NBT at " + + target.getX() + "," + target.getY() + "," + target.getZ()); + } + } + for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) { + int worldX = bounds.originX() + renderedConnector.x(); + int worldZ = bounds.originZ() + renderedConnector.z(); + if ((worldX >> 4) != chunkX || (worldZ >> 4) != chunkZ) { + continue; + } + Block target = world.getBlockAt( + worldX, + bounds.originY() + renderedConnector.y(), + worldZ); + BlockData blockData = target.getBlockData(); + if (!(blockData instanceof Jigsaw jigsaw) + || !jigsaw.getOrientation().name().toLowerCase(Locale.ROOT) + .equals(renderedConnector.orientation())) { + throw new IOException("jigsaw marker changed before hydration completed at " + + target.getX() + "," + target.getY() + "," + target.getZ()); + } + KMap expected = markerNbt(renderedConnector.connector()); + KMap hydrated = BukkitPlatform.serializeTile(target.getLocation()); + if (!markerNbtMatches(expected, hydrated)) { + throw new IOException("the active NMS binding did not preserve jigsaw marker NBT at " + + target.getX() + "," + target.getY() + "," + target.getZ()); + } + } + } + + static KMap markerNbt(IrisJigsawConnector connector) { + IrisJigsawConnector activeConnector = Objects.requireNonNull(connector, "Jigsaw Studio connector"); + KMap nbt = new KMap<>(); + nbt.put("name", studioIdentifier(activeConnector.getName())); + nbt.put("target", studioIdentifier(activeConnector.getTargetName())); + nbt.put("pool", JigsawStudioMarkerKeyCodec.encodePool(activeConnector.getPool())); + nbt.put("final_state", activeConnector.getFinalState()); + nbt.put("joint", activeConnector.getJoint().name().toLowerCase(Locale.ROOT)); + nbt.put("selection_priority", activeConnector.getSelectionPriority()); + nbt.put("placement_priority", activeConnector.getPlacementPriority()); + return nbt; + } + + private static boolean markerNbtMatches( + Map expected, + Map hydrated + ) { + if (hydrated == null) { + return false; + } + for (Map.Entry entry : expected.entrySet()) { + Object actual = hydrated.get(entry.getKey()); + if (entry.getValue() instanceof Number expectedNumber) { + if (!(actual instanceof Number actualNumber) + || expectedNumber.longValue() != actualNumber.longValue()) { + return false; + } + } else if (!Objects.equals(entry.getValue(), actual)) { + return false; + } + } + return true; + } + + private static boolean nbtContains(Object expected, Object actual) { + if (expected instanceof Number expectedNumber) { + if (!(actual instanceof Number actualNumber)) { + return false; + } + if (isIntegral(expectedNumber) && isIntegral(actualNumber)) { + return expectedNumber.longValue() == actualNumber.longValue(); + } + return Double.compare(expectedNumber.doubleValue(), actualNumber.doubleValue()) == 0; + } + if (expected instanceof Map expectedMap) { + if (!(actual instanceof Map actualMap)) { + return false; + } + for (Map.Entry entry : expectedMap.entrySet()) { + if (!actualMap.containsKey(entry.getKey()) + || !nbtContains(entry.getValue(), actualMap.get(entry.getKey()))) { + return false; + } + } + return true; + } + if (expected instanceof List expectedList) { + if (!(actual instanceof List actualList) || expectedList.size() != actualList.size()) { + return false; + } + for (int index = 0; index < expectedList.size(); index++) { + if (!nbtContains(expectedList.get(index), actualList.get(index))) { + return false; + } + } + return true; + } + return Objects.equals(expected, actual); + } + + private static boolean isIntegral(Number number) { + return number instanceof Byte + || number instanceof Short + || number instanceof Integer + || number instanceof Long; + } + + static void restoreCapturedMetadata( + IrisJigsawConnector captured, + IrisJigsawPiece sourcePiece + ) { + IrisJigsawConnector activeCaptured = Objects.requireNonNull(captured, "Captured jigsaw connector"); + IrisJigsawConnector original = connectorAt(sourcePiece, activeCaptured.getPosition()); + if (original == null) { + return; + } + activeCaptured.setName(restoreIdentifier(activeCaptured.getName(), original.getName())); + activeCaptured.setTargetName(restoreIdentifier( + activeCaptured.getTargetName(), + original.getTargetName())); + activeCaptured.setChannel(original.getChannel() == null ? "" : original.getChannel()); + } + + private static void restoreCapturedMetadataForDisplay( + IrisJigsawConnector captured, + IrisJigsawPiece sourcePiece, + JigsawStudioCellDimensions displayDimensions, + int displayRotationQuarterTurns + ) { + if (Math.floorMod(displayRotationQuarterTurns, 4) == 0) { + restoreCapturedMetadata(captured, sourcePiece); + return; + } + int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4); + int sourceWidth = (quarterTurns & 1) == 0 + ? displayDimensions.width() + : displayDimensions.depth(); + int sourceDepth = (quarterTurns & 1) == 0 + ? displayDimensions.depth() + : displayDimensions.width(); + LocalPosition position = inversePosition( + captured.getPosition().getX(), + captured.getPosition().getY(), + captured.getPosition().getZ(), + sourceWidth, + sourceDepth, + quarterTurns); + IrisObjectRotation inverseRotation = IrisObjectRotation.of(0, 90.0D * quarterTurns, 0); + IrisJigsawConnector sourceOriented = CapturedConnector.from(captured).toConnector() + .setPosition(new IrisPosition(position.x(), position.y(), position.z())) + .setDirection(inverseRotation.rotate(captured.getDirection())) + .setTop(inverseRotation.rotate(captured.getTop())); + restoreCapturedMetadata(sourceOriented, sourcePiece); + captured.setName(sourceOriented.getName()); + captured.setTargetName(sourceOriented.getTargetName()); + captured.setChannel(sourceOriented.getChannel()); + } + + private static IrisJigsawConnector connectorAt(IrisJigsawPiece piece, IrisPosition position) { + if (piece == null || piece.getConnectors() == null || position == null) { + return null; + } + for (IrisJigsawConnector connector : piece.getConnectors()) { + if (connector != null && position.equals(connector.getPosition())) { + return connector; + } + } + return null; + } + + private static String restoreIdentifier(String captured, String original) { + if (original != null && studioIdentifier(original).equals(captured)) { + return original; + } + return captured; + } + + private static String studioIdentifier(String value) { + String normalized = Objects.requireNonNull(value, "Jigsaw Studio marker identifier").trim(); + return normalized.indexOf(':') < 0 ? "iris:" + normalized : normalized; + } + + private boolean isCurrentRequest(ActiveStudio studio, UUID requestId) { + if (studio == null + || requestId == null + || studios.get(studio.worldId()) != studio + || !studio.generator().getRequest().requestId().equals(requestId)) { + return false; + } + JigsawStudioActivation.Request active = JigsawStudioActivation.getRequest( + studio.generator().getRequest().packKey()); + return active != null && active.requestId().equals(requestId); + } + + private boolean isCurrentSave( + ActiveStudio studio, + UUID requestId, + JigsawStudioSession.SaveIdentity identity + ) { + return isCurrentRequest(studio, requestId) + && studio.generator().getSession().isSaveCurrent(identity); + } + + private static String readinessMessage(JigsawStudioBay bay, BayReadiness readiness) { + if (!readiness.failure().isEmpty()) { + return "Bay '" + bay.stableId() + "' is invalid and cannot be saved: " + readiness.failure(); + } + return "Bay '" + bay.stableId() + "' is not ready to save: " + + readiness.generatedChunks() + "/" + readiness.requiredChunks() + " chunk(s) populated, " + + readiness.hydratedChunks() + "/" + readiness.requiredChunks() + + " chunk(s) hydrated. Visit the whole bay and wait for its markers to finish loading."; + } + + private static long chunkKey(int chunkX, int chunkZ) { + return ((long) chunkX << 32) ^ (chunkZ & 0xffffffffL); + } + + private static Set requiredChunks( + JigsawStudioBay bay, + JigsawStudioGenerator.RenderedBay rendered + ) { + Set chunks = new HashSet<>(); + if (!rendered.valid()) { + return chunks; + } + JigsawStudioBounds bounds = bay.bounds(); + int minimumChunkX = bounds.originX() >> 4; + int maximumChunkX = bounds.maxX() >> 4; + int minimumChunkZ = bounds.originZ() >> 4; + int maximumChunkZ = bounds.maxZ() >> 4; + for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { + for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { + chunks.add(chunkKey(chunkX, chunkZ)); + } + } + return Set.copyOf(chunks); + } + + private static CaptureTarget resolveCaptureTarget( + ActiveStudio studio, + JigsawStudioBay bay, + JigsawStudioVariant variant + ) + throws IOException { + IrisData source = studio.generator().getRequest().source(); + JigsawStudioLayout currentLayout = studio.generator().getSession().layout(); + JigsawStudioBay currentBay = currentLayout.get(bay.stableId()); + if (currentBay == null) { + throw new IOException("workcell '" + bay.stableId() + "' is absent from the current Studio layout"); + } + JigsawStudioVariant activeVariant = Objects.requireNonNull(variant, "Jigsaw Studio capture variant"); + if (!currentLayout.accepts(currentBay, activeVariant)) { + throw new IOException("variant '" + activeVariant.pieceKey() + + "' does not belong to workcell '" + currentBay.stableId() + "'"); + } + IrisJigsawPiece piece = source.getJigsawPieceLoader().load(activeVariant.pieceKey(), false); + if (piece == null || piece.getObject() == null || piece.getObject().isBlank()) { + throw new IOException("piece '" + activeVariant.pieceKey() + "' is missing or has no object"); + } + IrisObject object = source.getObjectLoader().load(piece.getObject(), false); + if (object == null || object.getW() < 1 || object.getH() < 1 || object.getD() < 1) { + throw new IOException("object '" + piece.getObject() + "' is missing or has invalid dimensions"); + } + JigsawStudioBounds bayBounds = currentBay.bounds(); + JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions( + object.getW(), object.getH(), object.getD()); + JigsawStudioCellDimensions canonicalDimensions = activeVariant.canonicalDimensions(sourceDimensions); + if (canonicalDimensions.width() > bayBounds.dimensions().width() + || canonicalDimensions.height() > bayBounds.dimensions().height() + || canonicalDimensions.depth() > bayBounds.dimensions().depth()) { + throw new IOException("object '" + piece.getObject() + "' is " + + canonicalDimensions.width() + "x" + canonicalDimensions.height() + "x" + + canonicalDimensions.depth() + " in its displayed orientation" + + " but bay '" + currentBay.stableId() + "' is only " + + bayBounds.dimensions().width() + "x" + bayBounds.dimensions().height() + "x" + + bayBounds.dimensions().depth()); + } + return new CaptureTarget( + new JigsawStudioBounds( + bayBounds.originX(), + bayBounds.originY(), + bayBounds.originZ(), + canonicalDimensions), + piece, + object, + activeVariant.sourceToCanonicalQuarterTurns()); + } + + private void reloadSessionLayout(ActiveStudio studio) throws IOException { + JigsawStudioLayout layout = loadMappedLayout(studio); + studio.generator().getSession().replaceLayout(layout); + disabledWorkcellRenderer.reconcile( + studio.world(), + studio.generator().getRequest().requestId(), + layout); + for (JigsawStudioBay workcell : layout.bays()) { + studio.generator().invalidateRender(workcell.stableId()); + } + } + + private void scheduleOnlinePlayers(UUID worldId) { + J.runGlobal(() -> { + for (Player player : Bukkit.getOnlinePlayers()) { + J.runEntity(player, () -> { + if (player.getWorld().getUID().equals(worldId)) { + ensureVisualizationLoop(player); + reconcilePlayerContext(player, player.getLocation()); + } + }); + } + }); + } + + public void reconcilePlayerContext(Player player, Location location) { + if (player == null) { + return; + } + Location target = location == null ? player.getLocation() : location; + if (!J.isOwnedByCurrentRegion(player) + || target.getWorld() == null + || !target.getWorld().getUID().equals(player.getWorld().getUID())) { + J.runEntity(player, () -> reconcilePlayerContext(player, player.getLocation()), 1); + return; + } + ActiveStudio studio = studios.get(target.getWorld().getUID()); + if (studio == null) { + clearPlayerContext(player); + return; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay workcell = session.layout().findAt( + target.getBlockX(), target.getBlockY(), target.getBlockZ()); + selectEnteredWorkcell(session, workcell, ownerMatches(player, studio)); + String workcellId = workcell == null ? "" : workcell.stableId(); + playerWorkcells.put(player.getUniqueId(), new PlayerWorkcellContext( + studio.worldId(), + studio.generator().getRequest().requestId(), + workcellId)); + + JigsawStudioVariant variant = workcell == null + ? null + : session.activeVariant(workcell.stableId()).orElse(null); + JigsawStudioBoardState state = boardState(studio, session, workcell, variant); + String workcellRole = workcell == null ? "" : workcell.canonicalDisplayName(); + String workcellName = workcell == null ? "" : workcell.displayName(); + String variantName = variant == null ? "" : variant.resolvedDisplayName(); + String hint = "Triple-sneak for controls"; + boardService().applyJigsawContext(player, new JigsawStudioBoardContext( + studio.worldId(), + studio.generator().getRequest().requestId(), + studio.generator().getRequest().structureKey(), + session.layout().mode(), + workcellRole, + workcellName, + variantName, + state, + hint)); + } + + static boolean selectEnteredWorkcell( + JigsawStudioSession session, + JigsawStudioBay workcell, + boolean owner + ) { + return owner && workcell != null && session.selectBay(workcell.stableId()); + } + + public void refreshWorkcellContext(UUID worldId, String workcellId) { + if (worldId == null || workcellId == null) { + return; + } + J.runGlobal(() -> { + for (Player player : Bukkit.getOnlinePlayers()) { + J.runEntity(player, () -> { + if (!player.getWorld().getUID().equals(worldId)) { + return; + } + PlayerWorkcellContext context = playerWorkcells.get(player.getUniqueId()); + if (context != null && workcellId.equals(context.workcellId())) { + reconcilePlayerContext(player, player.getLocation()); + } + }); + } + }); + } + + private void clearWorldPlayerContexts(UUID worldId) { + J.runGlobal(() -> { + for (Player player : Bukkit.getOnlinePlayers()) { + J.runEntity(player, () -> { + PlayerWorkcellContext context = playerWorkcells.get(player.getUniqueId()); + if (context != null && context.worldId().equals(worldId)) { + closeMenu(player); + clearPlayerContext(player); + } + }); + } + }); + } + + private void clearPlayerContext(Player player) { + if (player == null) { + return; + } + playerWorkcells.remove(player.getUniqueId()); + boardService().clearJigsawContext(player); + } + + private static BoardSVC boardService() { + return IrisServices.get(BoardSVC.class); + } + + private static boolean ownerMatches(Player player, ActiveStudio studio) { + if (player == null || studio == null) { + return false; + } + UUID ownerId = studio.generator().getRequest().ownerId(); + return ownerMatches(ownerId, player.getUniqueId()); + } + + static boolean canCreateVariants(JigsawStudioLayout layout) { + return Objects.requireNonNull(layout, "Jigsaw Studio layout") + .variantCatalog() + .editableGraph(); + } + + static String variantCreationSourceFailure( + JigsawStudioVariant activeVariant, + boolean duplicateActive + ) { + if (activeVariant == null) { + return duplicateActive + ? "This workcell has no active variant to duplicate." + : "This workcell has no active variant whose pool role can be copied. Use " + + "/iris jigsaw piece create to choose an owned pool explicitly."; + } + if (!activeVariant.owned()) { + return "Adopt or clone this graph before creating from its read-only variant."; + } + if (activeVariant.memberships().isEmpty()) { + return "The active variant has no owned pool membership to copy. Use " + + "/iris jigsaw piece create to choose an owned pool explicitly."; + } + return ""; + } + + static boolean ownerMatches(UUID ownerId, UUID actorId) { + return ownerId == null || ownerId.equals(actorId); + } + + private static boolean authorizeOwner(Player player, ActiveStudio studio) { + if (ownerMatches(player, studio)) { + return true; + } + message(player, "This Jigsaw Studio is owned by another player session."); + return false; + } + + private boolean isUnauthorizedStudioEdit(Player player, Block block) { + if (player == null || block == null) { + return false; + } + ActiveStudio studio = studios.get(block.getWorld().getUID()); + if (studio == null) { + return false; + } + UUID requestId = studio.generator().getRequest().requestId(); + if (previewRenderer.contains(requestId, block.getX(), block.getY(), block.getZ())) { + message(player, "The seed-1337 Jigsaw preview is read-only and refreshes automatically."); + return true; + } + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioBay bay = session.layout().findAt(block.getX(), block.getY(), block.getZ()); + JigsawStudioVariant variant = bay == null + ? null + : session.activeVariant(bay.stableId()).orElse(null); + if (bay != null && (variant == null || !variant.owned())) { + message(player, variant == null + ? "This empty Jigsaw Studio workcell has no loaded editable variant. Load one before editing it." + : "This Jigsaw Studio variant is read-only. Adopt or clone its graph before editing it."); + return true; + } + if (reopenRequiredRequests.contains(requestId)) { + message(player, "Close and reopen Jigsaw Studio before editing the resized layout."); + return true; + } + if (materializationInProgress(studio)) { + message(player, "Wait for the current Jigsaw Studio variant load or rollback to finish."); + return true; + } + if (!blocksStudioEdit(studio.generator().getRequest().ownerId(), player.getUniqueId())) { + return false; + } + message(player, "This Jigsaw Studio is owned by another player session."); + return true; + } + + private boolean materializationInProgress(ActiveStudio studio) { + if (studio == null) { + return false; + } + UUID requestId = studio.generator().getRequest().requestId(); + synchronized (saveLifecycleLock) { + return materializationsInProgress.contains(requestId); + } + } + + private boolean anyMaterializationInProgress() { + synchronized (saveLifecycleLock) { + return !materializationsInProgress.isEmpty(); + } + } + + private boolean anyNonEditableWorkcell() { + for (ActiveStudio studio : studios.values()) { + if (hasNonEditableWorkcell(studio)) { + return true; + } + } + return false; + } + + private static boolean hasNonEditableWorkcell(ActiveStudio studio) { + JigsawStudioSession session = studio.generator().getSession(); + for (JigsawStudioBay bay : session.layout().bays()) { + JigsawStudioVariant variant = session.activeVariant(bay.stableId()).orElse(null); + if (variant == null || !variant.owned()) { + return true; + } + } + return false; + } + + static boolean blocksStudioEdit(UUID ownerId, UUID actorId) { + return !ownerMatches(ownerId, actorId); + } + + static boolean blocksStudioInventoryMutation( + boolean studioBlockInventory, + boolean immutable, + boolean materializing, + boolean reopenRequired, + UUID ownerId, + UUID actorId + ) { + return studioBlockInventory + && (immutable + || materializing + || reopenRequired + || actorId != null && blocksStudioEdit(ownerId, actorId)); + } + + private boolean movesProtectedBlocks(List blocks, BlockFace direction) { + for (Block block : blocks) { + if (isImmutableStudioBlock(block) + || isImmutableStudioBlock(block.getRelative(direction)) + || isImmutableStudioBlock(block.getRelative(direction.getOppositeFace()))) { + return true; + } + } + return false; + } + + private boolean containsImmutableStudioBlock(List states) { + for (BlockState state : states) { + if (isImmutableStudioBlock(state.getBlock())) { + return true; + } + } + return false; + } + + private boolean isImmutableStudioBlock(Block block) { + StudioBlockMutationContext context = studioBlockMutationContext(block); + return context != null && context.immutable(); + } + + private boolean isPreviewBlock(Block block) { + if (block == null) { + return false; + } + ActiveStudio studio = studios.get(block.getWorld().getUID()); + if (studio == null) { + return false; + } + UUID requestId = studio.generator().getRequest().requestId(); + return previewRenderer.contains(requestId, block.getX(), block.getY(), block.getZ()); + } + + private boolean isControlChest(Block block) { + if (block == null || block.getType() != Material.CHEST) { + return false; + } + ActiveStudio studio = studios.get(block.getWorld().getUID()); + if (studio == null) { + return false; + } + JigsawStudioControlPosition control = studio.generator().getLayout().controlPosition(); + return block.getX() == control.worldX() + && block.getY() == control.worldY() + && block.getZ() == control.worldZ(); + } + + private static JigsawStudioBoardState boardState( + ActiveStudio studio, + JigsawStudioSession session, + JigsawStudioBay workcell, + JigsawStudioVariant variant + ) { + if (workcell == null) { + return JigsawStudioBoardState.SAVED; + } + JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(workcell.stableId()); + if (snapshot.switchInProgress()) { + return JigsawStudioBoardState.LOADING; + } + if (snapshot.saveInProgress()) { + return JigsawStudioBoardState.SAVING; + } + if (!workcell.enabled()) { + return JigsawStudioBoardState.DISABLED; + } + if (variant != null && !variant.owned()) { + return JigsawStudioBoardState.READ_ONLY; + } + BayReadiness readiness = studio.population(workcell).readiness(); + if (!readiness.failure().isEmpty()) { + return JigsawStudioBoardState.INVALID; + } + return snapshot.dirty() ? JigsawStudioBoardState.UNSAVED : JigsawStudioBoardState.SAVED; + } + + private static String displayKey(String resourceKey) { + int separator = resourceKey.lastIndexOf('/'); + return separator < 0 ? resourceKey : resourceKey.substring(separator + 1); + } + + private static boolean sameBlockPosition(Location first, Location second) { + if (first == null || second == null || first.getWorld() == null || second.getWorld() == null) { + return false; + } + return first.getWorld().getUID().equals(second.getWorld().getUID()) + && first.getBlockX() == second.getBlockX() + && first.getBlockY() == second.getBlockY() + && first.getBlockZ() == second.getBlockZ(); + } + + private void ensureVisualizationLoop(Player player) { + if (player == null) { + return; + } + if (!J.isOwnedByCurrentRegion(player)) { + J.runEntity(player, () -> ensureVisualizationLoop(player)); + return; + } + UUID playerId = player.getUniqueId(); + if (!enabled || particlesDisabled.contains(playerId) + || !studios.containsKey(player.getWorld().getUID()) + || !visualizationLoops.add(playerId)) { + return; + } + visualizationTick(player); + } + + private void visualizationTick(Player player) { + UUID playerId = player.getUniqueId(); + if (!enabled || particlesDisabled.contains(playerId)) { + visualizationLoops.remove(playerId); + return; + } + ActiveStudio studio = studios.get(player.getWorld().getUID()); + if (studio == null) { + visualizationLoops.remove(playerId); + return; + } + try { + renderVisualization(player, studio); + } catch (Throwable exception) { + visualizationLoops.remove(playerId); + IrisLogging.reportError(exception); + return; + } + boolean scheduled = J.runEntity( + player, + () -> visualizationTick(player), + VISUAL_INTERVAL_TICKS, + () -> visualizationLoops.remove(playerId)); + if (!scheduled) { + visualizationLoops.remove(playerId); + } + } + + private void renderVisualization(Player player, ActiveStudio studio) { + JigsawStudioSession session = studio.generator().getSession(); + JigsawStudioLayout layout = session.layout(); + Location playerLocation = player.getLocation(); + JigsawStudioBay focused = layout.findAt( + playerLocation.getBlockX(), + playerLocation.getBlockY(), + playerLocation.getBlockZ()); + ParticleBudget budget = new ParticleBudget(PARTICLE_BUDGET); + if (focused != null) { + Color focusedColor = bayValid(studio, focused) ? SELECTED_COLOR : INVALID_BAY_COLOR; + drawConnectors(player, playerLocation, studio, focused, budget); + drawBounds(player, playerLocation, focused.bounds(), focusedColor, 1.15F, 1.0D, budget); + } + drawLivePreview(player, playerLocation, studio, budget); + drawAssemblyPreview(player, playerLocation, budget); + for (JigsawStudioBay bay : layout.bays()) { + if (bay == focused || !isNearby(playerLocation, bay.bounds())) { + continue; + } + Color nearbyColor = bayValid(studio, bay) ? NEARBY_COLOR : INVALID_BAY_COLOR; + drawBounds(player, playerLocation, bay.bounds(), nearbyColor, 0.55F, 4.0D, budget); + if (budget.empty()) { + return; + } + } + } + + private void drawLivePreview( + Player player, + Location playerLocation, + ActiveStudio studio, + ParticleBudget budget + ) { + UUID requestId = studio.generator().getRequest().requestId(); + JigsawStudioGraphEvaluation evaluation = evaluations.get(requestId); + if (evaluation == null || evaluation.previewBounds().isEmpty()) { + return; + } + JigsawStudioPreviewRenderer.PreviewBounds preview = evaluation.previewBounds(); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions( + preview.maximumX() - preview.minimumX() + 1, + preview.maximumY() - preview.minimumY() + 1, + preview.maximumZ() - preview.minimumZ() + 1); + Color color = switch (evaluation.state()) { + case VALID -> ASSEMBLY_PREVIEW_COLOR; + case PENDING, WARNING, STALE -> LIVE_PREVIEW_WARNING_COLOR; + case INVALID -> INVALID_BAY_COLOR; + }; + drawBounds( + player, + playerLocation, + new JigsawStudioBounds( + preview.minimumX(), + preview.minimumY(), + preview.minimumZ(), + dimensions), + color, + 0.85F, + 2.0D, + budget); + } + + private void drawAssemblyPreview( + Player player, + Location playerLocation, + ParticleBudget budget + ) { + AssemblyPreview preview = assemblyPreviews.get(player.getUniqueId()); + if (preview == null) { + return; + } + if (!preview.worldId().equals(player.getWorld().getUID()) + || preview.expiresAtMillis() < System.currentTimeMillis()) { + assemblyPreviews.remove(player.getUniqueId(), preview); + return; + } + for (JigsawStudioBounds bounds : preview.bounds()) { + drawBounds(player, playerLocation, bounds, ASSEMBLY_PREVIEW_COLOR, 0.85F, 2.0D, budget); + if (budget.empty()) { + return; + } + } + } + + private static void drawConnectors( + Player player, + Location playerLocation, + ActiveStudio studio, + JigsawStudioBay bay, + ParticleBudget budget + ) { + JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(bay); + if (!rendered.valid()) { + return; + } + for (JigsawStudioGenerator.RenderedConnector renderedConnector : rendered.connectors()) { + IrisJigsawConnector connector = renderedConnector.connector(); + IrisDirection direction = connector.getDirection(); + drawConnectorLine( + player, + playerLocation, + bay.bounds().originX() + renderedConnector.x() + 0.5D, + bay.bounds().originY() + renderedConnector.y() + 0.5D, + bay.bounds().originZ() + renderedConnector.z() + 0.5D, + direction, + connectorColor(connector), + budget); + if (budget.empty()) { + return; + } + } + } + + private static void drawConnectorLine( + Player player, + Location playerLocation, + double startX, + double startY, + double startZ, + IrisDirection direction, + Color color, + ParticleBudget budget + ) { + drawLine( + player, + playerLocation, + startX, + startY, + startZ, + startX + direction.x() * 1.75D, + startY + direction.y() * 1.75D, + startZ + direction.z() * 1.75D, + color, + 1.05F, + 0.35D, + budget); + } + + private static boolean bayValid(ActiveStudio studio, JigsawStudioBay bay) { + JigsawStudioGenerator.RenderedBay rendered = studio.generator().renderBay(bay); + return rendered.valid() && studio.population(bay).readiness().failure().isEmpty(); + } + + private static Color connectorColor(IrisJigsawConnector connector) { + if (connector.getPool() == null || connector.getPool().isBlank() + || connector.getName() == null || connector.getName().isBlank() + || connector.getTargetName() == null || connector.getTargetName().isBlank()) { + return INVALID_CONNECTOR_COLOR; + } + String channel = connector.getChannel(); + if (channel == null || channel.isBlank()) { + return VALID_CONNECTOR_COLOR; + } + int hash = channel.toLowerCase(Locale.ROOT).hashCode(); + int red = 80 + (hash & 127); + int green = 80 + ((hash >>> 8) & 127); + int blue = 80 + ((hash >>> 16) & 127); + return Color.fromRGB(red, green, blue); + } + + private static void drawBounds( + Player player, + Location playerLocation, + JigsawStudioBounds bounds, + Color color, + float size, + double step, + ParticleBudget budget + ) { + double minX = bounds.originX(); + double minY = bounds.originY(); + double minZ = bounds.originZ(); + double maxX = bounds.maxX() + 1.0D; + double maxY = bounds.maxY() + 1.0D; + double maxZ = bounds.maxZ() + 1.0D; + drawLine(player, playerLocation, minX, minY, minZ, maxX, minY, minZ, color, size, step, budget); + drawLine(player, playerLocation, minX, minY, maxZ, maxX, minY, maxZ, color, size, step, budget); + drawLine(player, playerLocation, minX, maxY, minZ, maxX, maxY, minZ, color, size, step, budget); + drawLine(player, playerLocation, minX, maxY, maxZ, maxX, maxY, maxZ, color, size, step, budget); + drawLine(player, playerLocation, minX, minY, minZ, minX, maxY, minZ, color, size, step, budget); + drawLine(player, playerLocation, maxX, minY, minZ, maxX, maxY, minZ, color, size, step, budget); + drawLine(player, playerLocation, minX, minY, maxZ, minX, maxY, maxZ, color, size, step, budget); + drawLine(player, playerLocation, maxX, minY, maxZ, maxX, maxY, maxZ, color, size, step, budget); + drawLine(player, playerLocation, minX, minY, minZ, minX, minY, maxZ, color, size, step, budget); + drawLine(player, playerLocation, maxX, minY, minZ, maxX, minY, maxZ, color, size, step, budget); + drawLine(player, playerLocation, minX, maxY, minZ, minX, maxY, maxZ, color, size, step, budget); + drawLine(player, playerLocation, maxX, maxY, minZ, maxX, maxY, maxZ, color, size, step, budget); + } + + private static void drawLine( + Player player, + Location playerLocation, + double startX, + double startY, + double startZ, + double endX, + double endY, + double endZ, + Color color, + float size, + double step, + ParticleBudget budget + ) { + if (budget.empty()) { + return; + } + double deltaX = endX - startX; + double deltaY = endY - startY; + double deltaZ = endZ - startZ; + double length = Math.sqrt(deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ); + int samples = Math.max(1, (int) Math.ceil(length / step)); + Particle.DustOptions dust = new Particle.DustOptions(color, size); + for (int index = 0; index <= samples; index++) { + double progress = (double) index / samples; + double x = startX + deltaX * progress; + double y = startY + deltaY * progress; + double z = startZ + deltaZ * progress; + double distanceX = playerLocation.getX() - x; + double distanceY = playerLocation.getY() - y; + double distanceZ = playerLocation.getZ() - z; + if (distanceX * distanceX + distanceY * distanceY + distanceZ * distanceZ > VISUAL_RANGE_SQUARED) { + continue; + } + if (!budget.consume()) { + return; + } + player.spawnParticle(Particle.DUST, x, y, z, 1, 0.0D, 0.0D, 0.0D, 0.0D, dust); + } + } + + private static boolean isNearby(Location location, JigsawStudioBounds bounds) { + double centerX = bounds.originX() + bounds.dimensions().width() / 2.0D; + double centerY = bounds.originY() + bounds.dimensions().height() / 2.0D; + double centerZ = bounds.originZ() + bounds.dimensions().depth() / 2.0D; + double deltaX = location.getX() - centerX; + double deltaY = location.getY() - centerY; + double deltaZ = location.getZ() - centerZ; + return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ <= VISUAL_RANGE_SQUARED; + } + + private static JigsawStudioBay findBay(JigsawStudioLayout layout, String requested) { + String key = requested.trim(); + JigsawStudioBay exact = layout.get(key); + if (exact != null) { + return exact; + } + if (key.regionMatches(true, 0, "topology/", 0, "topology/".length())) { + String maskText = key.substring("topology/".length()); + try { + int mask = Integer.parseInt(maskText, 16); + JigsawPlanarArchetype archetype = JigsawPlanarArchetype.fromTopology( + JigsawPlanarTopology.fromMask(mask)); + return layout.get(archetype.stableId()); + } catch (IllegalArgumentException ignored) { + return null; + } + } + for (JigsawStudioBay bay : layout.bays()) { + if (bay.stableId().equalsIgnoreCase(key) + || bay.archetype().map(Enum::name).filter(name -> name.equalsIgnoreCase(key)).isPresent()) { + return bay; + } + } + for (JigsawStudioVariant variant : layout.variantCatalog().variants()) { + if (!variant.pieceKey().equalsIgnoreCase(key)) { + continue; + } + return variant.archetype() + .map(archetype -> layout.get(archetype.stableId())) + .orElse(layout.get(JigsawStudioLayout.SPATIAL_WORKCELL_ID)); + } + return null; + } + + static String writeFailure(StructureWriteResult result) { + if (!result.conflicts().isEmpty()) { + StructureWriteResult.Conflict conflict = result.conflicts().getFirst(); + return "Jigsaw Studio ownership conflict at '" + conflict.relativePath() + "': " + + conflict.reason().name().toLowerCase(Locale.ROOT) + + ". No authored files were changed."; + } + String detail = result.failure().map(JigsawStudioService::failureMessage) + .orElse(result.status().name().toLowerCase(Locale.ROOT)); + return "Jigsaw Studio atomic save failed: " + detail; + } + + private static String failureMessage(Throwable exception) { + String message = exception.getMessage(); + return message == null || message.isBlank() ? exception.getClass().getSimpleName() : message; + } + + private static void message(Player player, String text) { + if (player != null) { + J.runEntity(player, () -> player.sendMessage("[Iris Jigsaw Studio] " + text)); + } + } + + private static void report(Player player, boolean enabled, String text) { + if (enabled) { + message(player, text); + } + } + + private record StudioBlockMutationContext( + ActiveStudio studio, + UUID requestId, + UUID ownerId, + boolean immutable + ) { + private StudioBlockMutationContext { + Objects.requireNonNull(studio, "Jigsaw Studio block mutation studio"); + Objects.requireNonNull(requestId, "Jigsaw Studio block mutation request ID"); + } + } + + private record ActiveStudio( + UUID worldId, + World world, + Engine engine, + JigsawStudioGenerator generator, + ConcurrentHashMap populations, + Set hydrationsInProgress, + AtomicLong evaluationGeneration + ) { + ActiveStudio { + Objects.requireNonNull(worldId, "Jigsaw Studio world ID"); + Objects.requireNonNull(world, "Jigsaw Studio world"); + Objects.requireNonNull(engine, "Jigsaw Studio engine"); + Objects.requireNonNull(generator, "Jigsaw Studio generator"); + Objects.requireNonNull(populations, "Jigsaw Studio bay populations"); + Objects.requireNonNull(hydrationsInProgress, "Jigsaw Studio hydrations"); + Objects.requireNonNull(evaluationGeneration, "Jigsaw Studio evaluation generation"); + } + + BayPopulation population(JigsawStudioBay bay) { + return populations.computeIfAbsent(bay.stableId(), key -> { + JigsawStudioGenerator.RenderedBay rendered = generator.renderBay(bay); + return new BayPopulation( + requiredChunks(bay, rendered), + rendered.valid() ? "" : rendered.failure()); + }); + } + + BayPopulation replacePopulation( + JigsawStudioBay bay, + JigsawStudioGenerator.RenderedBay rendered, + String failure, + boolean ready + ) { + BayPopulation replacement = new BayPopulation( + requiredChunks(bay, rendered), + failure); + if (ready) { + replacement.markFullyReady(); + } + populations.put(bay.stableId(), replacement); + return replacement; + } + } + + private final class MaterializationCoordinator { + private final MaterializationWork work; + private final List areas; + private final Set candidateCompleted = new HashSet<>(); + private final Set candidateTouched = new HashSet<>(); + private final Set rollbackCompleted = new HashSet<>(); + private int candidateRemaining; + private int rollbackRemaining; + private String candidateFailure = ""; + private String rollbackFailure = ""; + private boolean rollbackStarted; + private boolean finished; + private boolean leaseReleased; + private boolean scheduledAny; + + private MaterializationCoordinator( + MaterializationWork work, + List areas + ) { + this.work = Objects.requireNonNull(work, "Jigsaw Studio materialization work"); + this.areas = List.copyOf(areas); + candidateRemaining = this.areas.size(); + } + + private MaterializationWork work() { + return work; + } + + private synchronized void markScheduled() { + scheduledAny = true; + } + + private synchronized boolean scheduledAny() { + return scheduledAny; + } + + private synchronized void markCandidateTouched(ChunkCaptureArea area) { + candidateTouched.add(chunkKey(area.chunkX(), area.chunkZ())); + } + + private void candidateComplete( + ChunkCaptureArea area, + String failure, + Throwable exception + ) { + boolean finishSuccess = false; + boolean startRollback = false; + synchronized (this) { + if (finished || rollbackStarted + || !candidateCompleted.add(chunkKey(area.chunkX(), area.chunkZ()))) { + return; + } + if (failure != null && !failure.isBlank() && candidateFailure.isEmpty()) { + candidateFailure = failure; + } + candidateRemaining--; + if (candidateRemaining == 0) { + if (candidateFailure.isEmpty()) { + finishSuccess = true; + } else { + rollbackStarted = true; + startRollback = true; + } + } + } + if (exception != null) { + IrisLogging.reportError(exception); + } + if (finishSuccess) { + finishSuccess(); + } else if (startRollback) { + scheduleRollback(); + } + } + + private void finishSuccess() { + JigsawStudioSession session = work.studio().generator().getSession(); + if (!isCurrentVariantSwitch(work) || !session.completeVariantSwitch(work.token())) { + beginLateRollback("Jigsaw Studio changed before the loaded variant could be activated."); + return; + } + synchronized (this) { + if (finished) { + return; + } + finished = true; + } + try { + work.studio().generator().invalidateRender(work.workcell().stableId()); + work.studio().replacePopulation(work.workcell(), work.target(), "", true); + refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId()); + message(work.player(), "Loaded variant '" + work.token().targetVariant().pieceKey() + + "' into " + work.workcell().stableId() + "."); + } finally { + releaseLease(); + } + } + + private void beginLateRollback(String failure) { + synchronized (this) { + if (finished || rollbackStarted) { + return; + } + candidateFailure = failure; + rollbackStarted = true; + } + scheduleRollback(); + } + + private void scheduleRollback() { + List rollbackAreas = new ArrayList<>(); + synchronized (this) { + for (ChunkCaptureArea area : areas) { + if (candidateTouched.contains(chunkKey(area.chunkX(), area.chunkZ()))) { + rollbackAreas.add(area); + } + } + rollbackRemaining = rollbackAreas.size(); + if (rollbackRemaining == 0) { + finished = true; + } + } + if (rollbackAreas.isEmpty()) { + finishRollback(); + return; + } + for (ChunkCaptureArea area : rollbackAreas) { + boolean scheduled = J.runRegion( + work.world(), + area.chunkX(), + area.chunkZ(), + () -> materializeRollbackChunk(this, area)); + if (!scheduled) { + rollbackComplete( + area, + "Iris could not schedule rollback for workcell chunk " + + area.chunkX() + "," + area.chunkZ() + ".", + null); + } + } + } + + private void rollbackComplete( + ChunkCaptureArea area, + String failure, + Throwable exception + ) { + boolean finalizeRollback = false; + synchronized (this) { + if (finished || !rollbackStarted + || !rollbackCompleted.add(chunkKey(area.chunkX(), area.chunkZ()))) { + return; + } + if (failure != null && !failure.isBlank() && rollbackFailure.isEmpty()) { + rollbackFailure = failure; + } + rollbackRemaining--; + if (rollbackRemaining == 0) { + finished = true; + finalizeRollback = true; + } + } + if (exception != null) { + IrisLogging.reportError(exception); + } + if (finalizeRollback) { + finishRollback(); + } + } + + private void finishRollback() { + JigsawStudioSession session = work.studio().generator().getSession(); + boolean released = session.abortVariantSwitch(work.token()); + try { + work.studio().generator().invalidateRender(work.workcell().stableId()); + if (rollbackFailure.isEmpty() && released) { + work.studio().replacePopulation(work.workcell(), work.previous(), "", true); + refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId()); + message(work.player(), candidateFailure + " The previous variant was restored."); + return; + } + String failure = rollbackFailure.isEmpty() + ? "The previous variant could not be restored because the Studio session changed." + : rollbackFailure; + work.studio().replacePopulation(work.workcell(), work.previous(), failure, false); + refreshWorkcellContext(work.studio().worldId(), work.workcell().stableId()); + message(work.player(), candidateFailure + " Automatic rollback also failed: " + failure + + " Do not save this workcell until it is reopened."); + } finally { + releaseLease(); + } + } + + private void releaseLease() { + synchronized (this) { + if (leaseReleased) { + return; + } + leaseReleased = true; + } + finishMaterialization(work.studio().generator().getRequest().requestId()); + } + } + + private final class CaptureCoordinator { + private final CaptureWork work; + private final List areas; + private final Map snapshots = new ConcurrentHashMap<>(); + private final AtomicInteger remaining; + private final AtomicBoolean failed = new AtomicBoolean(false); + private final AtomicBoolean assemblyStarted = new AtomicBoolean(false); + private final AtomicBoolean operationFinished = new AtomicBoolean(false); + private volatile AutosavePersistentFailure persistentFailure; + + private CaptureCoordinator(CaptureWork work, List areas) { + this.work = Objects.requireNonNull(work, "Jigsaw Studio capture work"); + this.areas = List.copyOf(areas); + this.remaining = new AtomicInteger(this.areas.size()); + } + + private ActiveStudio studio() { + return work.studio(); + } + + private World world() { + return work.world(); + } + + private JigsawStudioBay bay() { + return work.bay(); + } + + private CaptureTarget captureTarget() { + return work.captureTarget(); + } + + private Player player() { + return work.player(); + } + + private UUID requestId() { + return work.requestId(); + } + + private JigsawStudioSession.SaveIdentity saveIdentity() { + return work.saveIdentity(); + } + + private List areas() { + return areas; + } + + private boolean stopped() { + return failed.get(); + } + + private boolean failed() { + return failed.get(); + } + + private void accept(ChunkSnapshot snapshot) { + if (failed.get() || assemblyStarted.get()) { + return; + } + ChunkCaptureArea area = snapshot.area(); + long key = chunkKey(area.chunkX(), area.chunkZ()); + if (snapshots.putIfAbsent(key, snapshot) != null) { + fail("Jigsaw Studio received duplicate snapshot for bay chunk " + + area.chunkX() + "," + area.chunkZ() + ".", null); + return; + } + int incomplete = remaining.decrementAndGet(); + if (incomplete < 0) { + fail("Jigsaw Studio received more bay chunk snapshots than expected.", null); + return; + } + if (incomplete == 0 && assemblyStarted.compareAndSet(false, true)) { + List completed = List.copyOf(snapshots.values()); + J.a(() -> assembleAndPersist(this, completed)); + } + } + + private void fail(String failure, Throwable exception) { + if (!failed.compareAndSet(false, true)) { + return; + } + if (exception != null) { + IrisLogging.reportError(exception); + } + complete(); + message(player(), failure); + } + + private void failPersistent(String failure, Throwable exception) { + if (!failed.compareAndSet(false, true)) { + return; + } + recordPersistentFailure(failure, exception); + complete(); + message(player(), failure); + } + + private void recordPersistentFailure(String failure, Throwable exception) { + persistentFailure = new AutosavePersistentFailure(failure, exception); + } + + private void complete() { + if (operationFinished.compareAndSet(false, true)) { + studio().generator().getSession().abortSave(saveIdentity()); + finishSave(requestId()); + completeAutosaveAttempt( + studio(), + saveIdentity(), + work.autosaveFailureState(), + persistentFailure); + refreshWorkcellContext(studio().worldId(), saveIdentity().workcellId()); + } + } + } + + private record CaptureWork( + ActiveStudio studio, + World world, + JigsawStudioBay bay, + CaptureTarget captureTarget, + Player player, + UUID requestId, + JigsawStudioSession.SaveIdentity saveIdentity, + AutosaveFailureState autosaveFailureState + ) { + CaptureWork { + Objects.requireNonNull(studio, "Jigsaw Studio capture studio"); + Objects.requireNonNull(world, "Jigsaw Studio capture world"); + Objects.requireNonNull(bay, "Jigsaw Studio capture bay"); + Objects.requireNonNull(captureTarget, "Jigsaw Studio capture target"); + Objects.requireNonNull(requestId, "Jigsaw Studio capture request ID"); + Objects.requireNonNull(saveIdentity, "Jigsaw Studio capture save identity"); + Objects.requireNonNull(autosaveFailureState, "Jigsaw Studio capture autosave failure state"); + } + } + + private record EvaluationComputation( + JigsawStudioGraphEvaluation evaluation, + JigsawStudioPreviewRenderer.PreviewPlan plan + ) { + private EvaluationComputation { + Objects.requireNonNull(evaluation, "Jigsaw Studio graph evaluation"); + Objects.requireNonNull(plan, "Jigsaw Studio evaluation preview plan"); + } + } + + private record MaterializationWork( + ActiveStudio studio, + World world, + Player player, + JigsawStudioBay workcell, + JigsawStudioSession.VariantSwitchToken token, + JigsawStudioGenerator.RenderedBay previous, + JigsawStudioGenerator.RenderedBay target + ) { + MaterializationWork { + Objects.requireNonNull(studio, "Jigsaw Studio materialization studio"); + Objects.requireNonNull(world, "Jigsaw Studio materialization world"); + Objects.requireNonNull(player, "Jigsaw Studio materialization player"); + Objects.requireNonNull(workcell, "Jigsaw Studio materialization workcell"); + Objects.requireNonNull(token, "Jigsaw Studio materialization token"); + Objects.requireNonNull(previous, "Jigsaw Studio previous rendered variant"); + Objects.requireNonNull(target, "Jigsaw Studio target rendered variant"); + } + } + + @FunctionalInterface + public interface CommandGraphMutation { + CommandGraphMutationResult run() throws IOException; + } + + public record CommandGraphMutationResult( + JigsawStudioLayout layout, + String activateWorkcellId, + String activatePieceKey, + Map rebindActiveVariants, + Optional reload, + String message + ) { + public CommandGraphMutationResult { + Objects.requireNonNull(layout, "Jigsaw Studio graph-mutation layout"); + activateWorkcellId = activateWorkcellId == null ? "" : activateWorkcellId; + activatePieceKey = activatePieceKey == null ? "" : activatePieceKey; + rebindActiveVariants = Map.copyOf(Objects.requireNonNull( + rebindActiveVariants, + "Jigsaw Studio graph-mutation active variant bindings")); + reload = Objects.requireNonNull(reload, "Jigsaw Studio graph-mutation reload"); + message = message == null ? "Graph update completed." : message; + if (activatePieceKey.isEmpty() != activateWorkcellId.isEmpty()) { + throw new IllegalArgumentException( + "Jigsaw Studio graph activation requires both a workcell and piece key"); + } + int followUpActions = (activatePieceKey.isEmpty() ? 0 : 1) + + (rebindActiveVariants.isEmpty() ? 0 : 1) + + (reload.isEmpty() ? 0 : 1); + if (followUpActions > 1) { + throw new IllegalArgumentException( + "Jigsaw Studio graph mutations may activate, rebind, or reload, but not combine them"); + } + } + + public CommandGraphMutationResult( + JigsawStudioLayout layout, + String activateWorkcellId, + String activatePieceKey, + String message + ) { + this(layout, activateWorkcellId, activatePieceKey, Map.of(), Optional.empty(), message); + } + } + + public record VariantReloadRequest( + String workcellId, + JigsawStudioGenerator.RenderedBay previous + ) { + public VariantReloadRequest { + workcellId = workcellId == null ? "" : workcellId.trim(); + if (workcellId.isEmpty()) { + throw new IllegalArgumentException("Jigsaw Studio reload workcell ID cannot be blank"); + } + Objects.requireNonNull(previous, "Jigsaw Studio previous rendered variant"); + } + } + + static final class BayPopulation { + private final Set requiredChunks; + private final Set generatedChunks = ConcurrentHashMap.newKeySet(); + private final Set appliedChunks = ConcurrentHashMap.newKeySet(); + private final Set hydratedChunks = ConcurrentHashMap.newKeySet(); + private volatile String failure; + + BayPopulation(Set requiredChunks, String failure) { + this.requiredChunks = Set.copyOf(requiredChunks); + this.failure = failure == null ? "" : failure; + } + + boolean markGenerated(long chunk) { + if (!requiredChunks.contains(chunk)) { + return false; + } + generatedChunks.add(chunk); + return !hydratedChunks.contains(chunk) && failure.isEmpty(); + } + + boolean needsApplication(long chunk) { + return failure.isEmpty() + && requiredChunks.contains(chunk) + && generatedChunks.contains(chunk) + && !appliedChunks.contains(chunk); + } + + boolean needsVerification(long chunk) { + return failure.isEmpty() + && requiredChunks.contains(chunk) + && generatedChunks.contains(chunk) + && appliedChunks.contains(chunk) + && !hydratedChunks.contains(chunk); + } + + void markApplied(long chunk) { + if (requiredChunks.contains(chunk)) { + appliedChunks.add(chunk); + } + } + + void markHydrated(long chunk) { + if (requiredChunks.contains(chunk)) { + hydratedChunks.add(chunk); + } + } + + void markFullyReady() { + generatedChunks.addAll(requiredChunks); + appliedChunks.addAll(requiredChunks); + hydratedChunks.addAll(requiredChunks); + } + + synchronized boolean fail(String reason) { + if (!failure.isEmpty()) { + return false; + } + failure = reason == null || reason.isBlank() ? "unknown hydration failure" : reason; + return true; + } + + BayReadiness readiness() { + boolean ready = failure.isEmpty() + && !requiredChunks.isEmpty() + && generatedChunks.containsAll(requiredChunks) + && hydratedChunks.containsAll(requiredChunks); + return new BayReadiness( + ready, + failure, + requiredChunks.size(), + generatedChunks.size(), + hydratedChunks.size()); + } + } + + record BayReadiness( + boolean ready, + String failure, + int requiredChunks, + int generatedChunks, + int hydratedChunks + ) { + } + + enum SaveStart { + STARTED, + IN_PROGRESS, + CLOSING, + GRAPH_OPERATION, + EXPORT_OPERATION, + VARIANT_OPERATION + } + + public enum ExportStart { + STARTED, + NOT_ACTIVE, + NOT_OWNER, + DIRTY, + CLOSING, + SAVE_IN_PROGRESS, + OPERATION_IN_PROGRESS, + IN_PROGRESS + } + + private enum SaveAttempt { + STARTED, + RETRY, + DEFERRED, + PERSISTENT_FAILURE + } + + enum DeferredDuplicationReadiness { + READY, + WAITING_FOR_AUTOSAVE, + WAITING_FOR_OPERATION, + STALE + } + + private enum DeferredDuplicationKind { + SINGLE, + FAMILY + } + + public enum CloseStart { + STARTED, + NOT_ACTIVE, + NOT_OWNER, + DIRTY, + SAVE_IN_PROGRESS, + OPERATION_IN_PROGRESS + } + + private record CaptureTarget( + JigsawStudioBounds bounds, + IrisJigsawPiece piece, + IrisObject object, + int displayRotationQuarterTurns + ) { + CaptureTarget { + Objects.requireNonNull(bounds, "Jigsaw Studio capture bounds"); + Objects.requireNonNull(piece, "Jigsaw Studio capture piece"); + Objects.requireNonNull(object, "Jigsaw Studio capture object"); + displayRotationQuarterTurns = Math.floorMod(displayRotationQuarterTurns, 4); + } + } + + private record AutosaveKey(UUID requestId, String workcellId) { + private AutosaveKey { + Objects.requireNonNull(requestId, "Jigsaw Studio autosave request ID"); + Objects.requireNonNull(workcellId, "Jigsaw Studio autosave workcell ID"); + } + } + + private record JigsawTileWatchKey( + UUID requestId, + UUID worldId, + int worldX, + int worldY, + int worldZ + ) { + private JigsawTileWatchKey { + Objects.requireNonNull(requestId, "Jigsaw Studio tile watch request ID"); + Objects.requireNonNull(worldId, "Jigsaw Studio tile watch world ID"); + } + } + + record JigsawTilePollDecision(boolean changed, boolean continueWatching) { + } + + private record JigsawTileWatch( + JigsawTileWatchKey key, + ActiveStudio studio, + UUID playerId, + String workcellId, + KMap snapshot, + AtomicBoolean scheduleFailureLogged, + AtomicBoolean reconciliationScheduled + ) { + private JigsawTileWatch { + Objects.requireNonNull(key, "Jigsaw Studio tile watch key"); + Objects.requireNonNull(studio, "Jigsaw Studio tile watch studio"); + Objects.requireNonNull(playerId, "Jigsaw Studio tile watch player ID"); + Objects.requireNonNull(workcellId, "Jigsaw Studio tile watch workcell ID"); + Objects.requireNonNull(snapshot, "Jigsaw Studio tile watch snapshot"); + Objects.requireNonNull(scheduleFailureLogged, "Jigsaw Studio tile watch schedule warning state"); + Objects.requireNonNull(reconciliationScheduled, "Jigsaw Studio tile watch retry schedule state"); + } + } + + static final class AutosaveFailureState { + private final AtomicInteger persistentFailures = new AtomicInteger(); + private final AtomicBoolean failureLogged = new AtomicBoolean(false); + + AutosaveFailureDecision recordPersistentFailure() { + int failureCount = persistentFailures.incrementAndGet(); + return new AutosaveFailureDecision( + persistentAutosaveRetryTicks(failureCount), + failureLogged.compareAndSet(false, true)); + } + } + + record AutosaveFailureDecision(int retryTicks, boolean logFailure) { + } + + private record AutosavePersistentFailure(String detail, Throwable cause) { + private AutosavePersistentFailure { + Objects.requireNonNull(detail, "Jigsaw Studio persistent autosave failure detail"); + } + } + + private record AutosaveTicket( + AutosaveKey key, + ActiveStudio studio, + JigsawStudioSession.DirtyIdentity identity, + AtomicBoolean scheduled, + AtomicBoolean scheduleFailureLogged, + AutosaveFailureState failureState + ) { + private AutosaveTicket( + AutosaveKey key, + ActiveStudio studio, + JigsawStudioSession.DirtyIdentity identity, + AtomicBoolean scheduled, + AtomicBoolean scheduleFailureLogged + ) { + this( + key, + studio, + identity, + scheduled, + scheduleFailureLogged, + new AutosaveFailureState()); + } + + private AutosaveTicket { + Objects.requireNonNull(key, "Jigsaw Studio autosave key"); + Objects.requireNonNull(studio, "Jigsaw Studio autosave studio"); + Objects.requireNonNull(identity, "Jigsaw Studio autosave dirty identity"); + Objects.requireNonNull(scheduled, "Jigsaw Studio autosave schedule state"); + Objects.requireNonNull(scheduleFailureLogged, "Jigsaw Studio autosave schedule warning state"); + Objects.requireNonNull(failureState, "Jigsaw Studio autosave failure state"); + } + } + + private record DeferredDuplication( + UUID requestId, + UUID sessionId, + UUID playerId, + UUID worldId, + DeferredDuplicationKind kind, + String workcellId, + Map sourcePieces, + String themeKey, + AtomicBoolean scheduled + ) { + private DeferredDuplication { + Objects.requireNonNull(requestId, "Jigsaw Studio deferred duplicate request ID"); + Objects.requireNonNull(sessionId, "Jigsaw Studio deferred duplicate session ID"); + Objects.requireNonNull(playerId, "Jigsaw Studio deferred duplicate player ID"); + Objects.requireNonNull(worldId, "Jigsaw Studio deferred duplicate world ID"); + Objects.requireNonNull(kind, "Jigsaw Studio deferred duplicate kind"); + workcellId = workcellId == null ? "" : workcellId; + sourcePieces = Map.copyOf(Objects.requireNonNull( + sourcePieces, + "Jigsaw Studio deferred duplicate source pieces")); + themeKey = themeKey == null ? "" : themeKey; + Objects.requireNonNull(scheduled, "Jigsaw Studio deferred duplicate schedule state"); + if (sourcePieces.isEmpty()) { + throw new IllegalArgumentException("A deferred duplicate requires at least one source variant"); + } + if (kind == DeferredDuplicationKind.SINGLE && workcellId.isBlank()) { + throw new IllegalArgumentException("A deferred single duplicate requires a workcell"); + } + if (kind == DeferredDuplicationKind.FAMILY && themeKey.isBlank()) { + throw new IllegalArgumentException("A deferred family duplicate requires a theme key"); + } + } + + private static DeferredDuplication single( + UUID requestId, + UUID sessionId, + Player player, + UUID worldId, + String workcellId, + String sourcePiece + ) { + return new DeferredDuplication( + requestId, + sessionId, + player.getUniqueId(), + worldId, + DeferredDuplicationKind.SINGLE, + workcellId, + Map.of(workcellId, sourcePiece), + "", + new AtomicBoolean()); + } + + private static DeferredDuplication family( + UUID requestId, + UUID sessionId, + Player player, + UUID worldId, + Map sourcePieces, + String themeKey + ) { + return new DeferredDuplication( + requestId, + sessionId, + player.getUniqueId(), + worldId, + DeferredDuplicationKind.FAMILY, + "", + sourcePieces, + themeKey, + new AtomicBoolean()); + } + + private boolean sameIntent(DeferredDuplication other) { + return other != null + && playerId.equals(other.playerId) + && kind == other.kind + && workcellId.equals(other.workcellId) + && sourcePieces.equals(other.sourcePieces) + && themeKey.equals(other.themeKey); + } + } + + private record ToolConfirmation(JigsawStudioToolPayload payload, long expiresAtNanos) { + private ToolConfirmation { + Objects.requireNonNull(payload, "Jigsaw Studio tool confirmation payload"); + } + } + + private record AssemblyPreview( + UUID worldId, + long expiresAtMillis, + List bounds + ) { + AssemblyPreview { + Objects.requireNonNull(worldId, "Jigsaw Studio preview world"); + bounds = List.copyOf(bounds); + } + } + + private record PlayerWorkcellContext( + UUID worldId, + UUID requestId, + String workcellId + ) { + PlayerWorkcellContext { + Objects.requireNonNull(worldId, "Jigsaw Studio player-context world ID"); + Objects.requireNonNull(requestId, "Jigsaw Studio player-context request ID"); + workcellId = workcellId == null ? "" : workcellId; + } + } + + record ChunkCaptureArea( + int chunkX, + int chunkZ, + int minimumX, + int maximumX, + int minimumZ, + int maximumZ + ) { + ChunkCaptureArea { + if (minimumX < 0 || minimumZ < 0 || maximumX <= minimumX || maximumZ <= minimumZ) { + throw new IllegalArgumentException("Jigsaw Studio chunk capture intersection is invalid"); + } + } + + boolean contains(int x, int z) { + return x >= minimumX && x < maximumX && z >= minimumZ && z < maximumZ; + } + } + + record CapturedBlock( + int x, + int y, + int z, + PlatformBlockState state, + TileData tileData + ) { + CapturedBlock { + Objects.requireNonNull(state, "Jigsaw Studio captured block state"); + tileData = tileData == null ? null : tileData.clone(); + } + + @Override + public TileData tileData() { + return tileData == null ? null : tileData.clone(); + } + } + + record CapturedConnector( + int x, + int y, + int z, + IrisDirection direction, + IrisDirection top, + String pool, + String name, + String targetName, + String channel, + JigsawJoint joint, + String finalState, + int selectionPriority, + int placementPriority + ) { + CapturedConnector { + Objects.requireNonNull(direction, "Jigsaw Studio captured connector direction"); + Objects.requireNonNull(top, "Jigsaw Studio captured connector top"); + Objects.requireNonNull(pool, "Jigsaw Studio captured connector pool"); + Objects.requireNonNull(name, "Jigsaw Studio captured connector name"); + Objects.requireNonNull(targetName, "Jigsaw Studio captured connector target"); + channel = channel == null ? "" : channel; + Objects.requireNonNull(joint, "Jigsaw Studio captured connector joint"); + Objects.requireNonNull(finalState, "Jigsaw Studio captured connector final state"); + } + + static CapturedConnector from(IrisJigsawConnector connector) { + IrisJigsawConnector source = Objects.requireNonNull(connector, "Jigsaw Studio captured connector"); + IrisPosition position = source.getPosition(); + return new CapturedConnector( + position.getX(), + position.getY(), + position.getZ(), + source.getDirection(), + source.getTop(), + source.getPool(), + source.getName(), + source.getTargetName(), + source.getChannel(), + source.getJoint(), + source.getFinalState(), + source.getSelectionPriority(), + source.getPlacementPriority()); + } + + IrisJigsawConnector toConnector() { + return new IrisJigsawConnector() + .setPosition(new IrisPosition(x, y, z)) + .setDirection(direction) + .setTop(top) + .setPool(pool) + .setName(name) + .setTargetName(targetName) + .setChannel(channel) + .setJoint(joint) + .setFinalState(finalState) + .setSelectionPriority(selectionPriority) + .setPlacementPriority(placementPriority); + } + } + + record ChunkSnapshot( + ChunkCaptureArea area, + List blocks, + List connectors + ) { + ChunkSnapshot { + Objects.requireNonNull(area, "Jigsaw Studio captured chunk area"); + blocks = List.copyOf(blocks); + connectors = List.copyOf(connectors); + } + } + + private record LocalPosition(int x, int y, int z) { + } + + record Capture(byte[] objectContent, List connectors, boolean hasBlockEntities) { + Capture { + objectContent = Objects.requireNonNull(objectContent, "Jigsaw Studio object content").clone(); + connectors = List.copyOf(connectors); + } + + @Override + public byte[] objectContent() { + return objectContent.clone(); + } + } + + private static final class ParticleBudget { + private int remaining; + + private ParticleBudget(int remaining) { + this.remaining = remaining; + } + + private boolean consume() { + if (remaining < 1) { + return false; + } + remaining--; + return true; + } + + private boolean empty() { + return remaining < 1; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/service/JigsawStudioToolCodec.java b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioToolCodec.java new file mode 100644 index 000000000..48a208d84 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/service/JigsawStudioToolCodec.java @@ -0,0 +1,150 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload; + +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public final class JigsawStudioToolCodec { + static final NamespacedKey SCHEMA_KEY = new NamespacedKey("iris", "jigsaw_tool_schema"); + static final NamespacedKey ACTION_KEY = new NamespacedKey("iris", "jigsaw_tool_action"); + static final NamespacedKey REQUEST_KEY = new NamespacedKey("iris", "jigsaw_tool_request"); + static final NamespacedKey WORKCELL_KEY = new NamespacedKey("iris", "jigsaw_tool_workcell"); + static final NamespacedKey PIECE_KEY = new NamespacedKey("iris", "jigsaw_tool_piece"); + static final NamespacedKey POOL_KEY = new NamespacedKey("iris", "jigsaw_tool_pool"); + static final NamespacedKey ENTRY_INDEX_KEY = new NamespacedKey("iris", "jigsaw_tool_entry_index"); + static final NamespacedKey AMOUNT_KEY = new NamespacedKey("iris", "jigsaw_tool_amount"); + + public ItemStack create(JigsawStudioToolPayload payload) { + ItemStack tool = new ItemStack(Material.STICK); + bind(tool, payload); + return tool; + } + + public void bind(ItemStack tool, JigsawStudioToolPayload payload) { + ItemStack item = Objects.requireNonNull(tool, "Jigsaw Studio tool item"); + JigsawStudioToolPayload binding = requireCurrentSchema(payload); + if (item.getType() != Material.STICK) { + throw new IllegalArgumentException("Jigsaw Studio tools must use a stick"); + } + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + throw new IllegalStateException("Jigsaw Studio tool stick has no item metadata"); + } + write(meta.getPersistentDataContainer(), binding); + meta.setDisplayName(ChatColor.AQUA + "Jigsaw Studio: " + binding.action().displayName()); + meta.setLore(lore(binding)); + if (!item.setItemMeta(meta)) { + throw new IllegalStateException("Jigsaw Studio tool metadata was rejected"); + } + } + + public Optional decode(ItemStack tool) { + if (tool == null || tool.getType() != Material.STICK || !tool.hasItemMeta()) { + return Optional.empty(); + } + ItemMeta meta = tool.getItemMeta(); + return meta == null ? Optional.empty() : decode(meta.getPersistentDataContainer()); + } + + public boolean isTool(ItemStack tool) { + return decode(tool).isPresent(); + } + + void write(PersistentDataContainer container, JigsawStudioToolPayload payload) { + PersistentDataContainer data = Objects.requireNonNull( + container, + "Jigsaw Studio tool persistent data"); + JigsawStudioToolPayload binding = requireCurrentSchema(payload); + data.set(SCHEMA_KEY, PersistentDataType.INTEGER, binding.schemaVersion()); + data.set(ACTION_KEY, PersistentDataType.STRING, binding.action().name()); + data.set(REQUEST_KEY, PersistentDataType.STRING, binding.requestId().toString()); + data.set(WORKCELL_KEY, PersistentDataType.STRING, binding.workcellId()); + data.set(PIECE_KEY, PersistentDataType.STRING, binding.pieceKey()); + data.set(POOL_KEY, PersistentDataType.STRING, binding.poolKey()); + data.set(ENTRY_INDEX_KEY, PersistentDataType.INTEGER, binding.entryIndex()); + data.set(AMOUNT_KEY, PersistentDataType.INTEGER, binding.amount()); + } + + Optional decode(PersistentDataContainer container) { + if (container == null) { + return Optional.empty(); + } + Integer schemaVersion = container.get(SCHEMA_KEY, PersistentDataType.INTEGER); + String actionName = container.get(ACTION_KEY, PersistentDataType.STRING); + String requestValue = container.get(REQUEST_KEY, PersistentDataType.STRING); + if (schemaVersion == null + || schemaVersion != JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION + || actionName == null + || requestValue == null) { + return Optional.empty(); + } + try { + JigsawStudioToolAction action = JigsawStudioToolAction.valueOf(actionName); + UUID requestId = UUID.fromString(requestValue); + String workcellId = optionalString(container, WORKCELL_KEY); + String pieceKey = optionalString(container, PIECE_KEY); + String poolKey = optionalString(container, POOL_KEY); + Integer entryIndex = container.get(ENTRY_INDEX_KEY, PersistentDataType.INTEGER); + Integer amount = container.get(AMOUNT_KEY, PersistentDataType.INTEGER); + return Optional.of(new JigsawStudioToolPayload( + schemaVersion, + action, + requestId, + workcellId, + pieceKey, + poolKey, + entryIndex == null ? JigsawStudioToolPayload.NO_ENTRY_INDEX : entryIndex, + amount == null ? 0 : amount)); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } + + private static JigsawStudioToolPayload requireCurrentSchema(JigsawStudioToolPayload payload) { + JigsawStudioToolPayload binding = Objects.requireNonNull(payload, "Jigsaw Studio tool payload"); + if (binding.schemaVersion() != JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION) { + throw new IllegalArgumentException("Unsupported Jigsaw Studio tool schema " + + binding.schemaVersion()); + } + return binding; + } + + private static String optionalString(PersistentDataContainer container, NamespacedKey key) { + String value = container.get(key, PersistentDataType.STRING); + return value == null ? "" : value; + } + + private static List lore(JigsawStudioToolPayload payload) { + List lore = new ArrayList<>(); + if (!payload.workcellId().isEmpty()) { + lore.add(ChatColor.GRAY + "Workcell: " + payload.workcellId()); + } + if (!payload.pieceKey().isEmpty()) { + lore.add(ChatColor.GRAY + "Variant: " + payload.pieceKey()); + } + if (!payload.poolKey().isEmpty()) { + lore.add(ChatColor.GRAY + "Pool: " + payload.poolKey() + + (payload.entryIndex() < 0 ? "" : " [" + payload.entryIndex() + "]")); + } + if (payload.amount() != 0) { + lore.add(ChatColor.GRAY + "Amount: " + payload.amount()); + } + lore.add(payload.action().destructive() + ? ChatColor.RED + "Right-click twice to confirm" + : ChatColor.YELLOW + "Right-click to use"); + return List.copyOf(lore); + } +} 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 f242f0884..9ed1c00d4 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 @@ -40,6 +40,7 @@ import art.arcane.iris.core.project.IrisProjectCopier; import art.arcane.iris.core.runtime.StudioOpenCoordinator; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; import art.arcane.iris.core.runtime.WorldDeletionQueue; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.IrisDimension; @@ -497,6 +498,81 @@ public class StudioSVC implements IrisService { }); } + public CompletableFuture openTracked( + VolmitSender sender, + long seed, + String dimension, + StudioOpenCoordinator.StudioOpenKind openKind, + Runnable beforeOpen, + Consumer onDone + ) { + if (blockIfPackBroken(sender, dimension)) { + return CompletableFuture.failedFuture( + new IllegalStateException("Studio pack '" + dimension + "' has blocking validation errors.")); + } + return studioTransitions.submit(() -> replaceActiveProjectTracked( + sender, + seed, + dimension, + Objects.requireNonNull(openKind, "Studio open kind"), + Objects.requireNonNull(beforeOpen, "Studio before-open callback"), + Objects.requireNonNull(onDone, "Studio open completion callback"))); + } + + private CompletableFuture replaceActiveProjectTracked( + VolmitSender sender, + long seed, + String dimension, + StudioOpenCoordinator.StudioOpenKind openKind, + Runnable beforeOpen, + Consumer onDone + ) { + return closeActiveProject().thenCompose(closeResult -> { + if (closeResult == null) { + return CompletableFuture.failedFuture( + new IllegalStateException("Studio close completed without a result.")); + } + if (closeResult.failureCause() != null) { + return CompletableFuture.failedFuture(closeResult.failureCause()); + } + beforeOpen.run(); + return beginStudioOpenTracked(sender, seed, dimension, openKind, onDone); + }); + } + + private CompletableFuture beginStudioOpenTracked( + VolmitSender sender, + long seed, + String dimension, + StudioOpenCoordinator.StudioOpenKind openKind, + Consumer onDone + ) { + IrisProject project = new IrisProject(new File(getWorkspaceFolder(), dimension)); + activeProject = project; + CompletableFuture opening; + try { + opening = project.open(sender, seed, openKind, onDone); + } catch (IrisException exception) { + if (activeProject == project) { + activeProject = null; + } + return CompletableFuture.failedFuture(exception); + } + + activeOpen = opening; + return opening.thenApply(result -> Objects.requireNonNull( + result, + "Studio open completed without a result.")) + .whenComplete((result, throwable) -> { + if (activeOpen == opening) { + activeOpen = null; + } + if (throwable != null && activeProject == project && !project.isOpen()) { + activeProject = null; + } + }); + } + private CompletableFuture replaceActiveProject( VolmitSender sender, long seed, @@ -543,7 +619,11 @@ public class StudioSVC implements IrisService { activeProject = project; CompletableFuture opening; try { - opening = project.open(sender, seed, onDone); + opening = project.open( + sender, + seed, + StudioOpenCoordinator.StudioOpenKind.STANDARD, + onDone); } catch (IrisException e) { if (activeProject == project) { activeProject = null; @@ -594,6 +674,15 @@ public class StudioSVC implements IrisService { )); } + JigsawStudioActivation.Request jigsawRequest = JigsawStudioActivation.getRequest(project.getName()); + if (jigsawRequest != null) { + String protectionFailure = JigsawStudioService.get() + .closeProtectionFailure(jigsawRequest.requestId()); + if (protectionFailure != null) { + return CompletableFuture.failedFuture(new IllegalStateException(protectionFailure)); + } + } + IrisLogging.debug("Closing Active Project"); CompletableFuture closing = project.close(); return closing.whenComplete((result, throwable) -> { diff --git a/core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java index ed7ec3b52..53a1854d2 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/BulkStructureImporter.java @@ -41,13 +41,31 @@ import art.arcane.iris.core.localization.BukkitRuntimeMessages; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.volmlib.util.localization.MessageArgument; public final class BulkStructureImporter { - public record Report(int total, int imported, int skipped, int failed, Map successfulBundles) { + public record Report( + int total, + int imported, + int skipped, + int failed, + Map successfulBundles, + boolean retryRequired, + Set captureCandidates + ) { public Report(int total, int imported, int skipped, int failed) { - this(total, imported, skipped, failed, Map.of()); + this(total, imported, skipped, failed, Map.of(), false, Set.of()); + } + + public Report(int total, int imported, int skipped, int failed, Map successfulBundles) { + this(total, imported, skipped, failed, successfulBundles, false, Set.of()); + } + + public Report(int total, int imported, int skipped, int failed, + Map successfulBundles, boolean retryRequired) { + this(total, imported, skipped, failed, successfulBundles, retryRequired, Set.of()); } public Report { successfulBundles = Collections.unmodifiableMap(new TreeMap<>(successfulBundles)); + captureCandidates = Collections.unmodifiableSet(new TreeSet<>(captureCandidates)); } } @@ -68,6 +86,7 @@ public final class BulkStructureImporter { int imported = 0; int skipped = 0; int failed = 0; + TreeSet captureCandidates = new TreeSet<>(); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_IMPORTING_VANILLA_DATAPACK_STRUCTURES_MODE_INCLUDENONJIGSAW, MessageArgument.untrusted("total", String.valueOf(total)), MessageArgument.untrusted("mode", String.valueOf(mode)), MessageArgument.untrusted("includeNonJigsaw", String.valueOf(includeNonJigsaw)))); @@ -100,8 +119,9 @@ public final class BulkStructureImporter { sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_SINGLE, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("name", String.valueOf(name)))); } else if (single.message() != null && single.message().startsWith("Skipped")) { skipped++; - } else if (single.message() != null && single.message().contains("No loadable structure NBT")) { + } else if (isCaptureCandidate(message, single.message())) { skipped++; + captureCandidates.add(nk.toString()); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_SKIP_NO_SINGLE_TEMPLATE_NBT_VANILLA_BUILDS_THIS_CODE_FROM_SEPARATE_PIECE, MessageArgument.untrusted("keyString", String.valueOf(keyString)))); } else { failed++; @@ -126,7 +146,7 @@ public final class BulkStructureImporter { StructureIndexService.write(data); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_BULK_IMPORT_COMPLETE_IMPORTED_SKIPPED_FAILED_TOTAL, MessageArgument.untrusted("imported", String.valueOf(imported)), MessageArgument.untrusted("skipped", String.valueOf(skipped)), MessageArgument.untrusted("failed", String.valueOf(failed)), MessageArgument.untrusted("total", String.valueOf(total)))); - return new Report(total, imported, skipped, failed); + return new Report(total, imported, skipped, failed, Map.of(), false, captureCandidates); } public static Report importTemplateGroups(IrisData data, StructureImporter.Mode mode, VolmitSender sender) { @@ -231,15 +251,40 @@ public final class BulkStructureImporter { } public static Report importDatapackStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender) { - return importDatapackStructures(data, mode, sender, null, null); + return importDatapackStructures( + data, + mode, + sender, + null, + null, + StructureImporter.Ownership.EDITABLE + ); } - public static Report importDatapackStructures( + public static Report importManagedDatapackStructures( IrisData data, StructureImporter.Mode mode, VolmitSender sender, Set allowedStructureKeys, Set allowedTemplateKeys + ) { + return importDatapackStructures( + data, + mode, + sender, + allowedStructureKeys, + allowedTemplateKeys, + StructureImporter.Ownership.MANAGED_DATAPACK + ); + } + + private static Report importDatapackStructures( + IrisData data, + StructureImporter.Mode mode, + VolmitSender sender, + Set allowedStructureKeys, + Set allowedTemplateKeys, + StructureImporter.Ownership ownership ) { KList keys = INMS.get().getStructureKeys(); KeySelection structureSelection = selectDatapackKeys(keys, allowedStructureKeys); @@ -250,6 +295,7 @@ public final class BulkStructureImporter { int imported = 0; int skipped = 0; int failed = structureSelection.missing().size(); + boolean retryRequired = false; Map successfulBundles = new TreeMap<>(); if (structureAttempts == 0) { @@ -269,17 +315,31 @@ public final class BulkStructureImporter { String name = StructureImporter.deriveName(nk); try { - VillageImporter.Result jigsaw = VillageImporter.importVillage(data, nk, name, mode); + VillageImporter.Result jigsaw = VillageImporter.importVillage( + data, + nk, + name, + mode, + ownership + ); if (jigsaw.success()) { imported++; successfulBundles.put(bundleKey(name), keyString); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_JIGSAW_2, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("name", String.valueOf(name)))); continue; } + retryRequired |= jigsaw.retryableFailure(); String message = jigsaw.message() == null ? "" : jigsaw.message(); if (message.contains("is not a jigsaw structure")) { - StructureImporter.Result single = StructureImporter.importStructure(data, nk, name, mode); + StructureImporter.Result single = StructureImporter.importStructure( + data, + nk, + name, + mode, + false, + ownership + ); if (single.success()) { imported++; successfulBundles.put(bundleKey(name), keyString); @@ -290,6 +350,7 @@ public final class BulkStructureImporter { skipped++; } else { failed++; + retryRequired |= single.retryableFailure(); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_7, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(single.message())))); } continue; @@ -304,6 +365,7 @@ public final class BulkStructureImporter { sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_8, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(message)))); } catch (Throwable e) { failed++; + retryRequired = true; sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_9, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("error", String.valueOf(e.getMessage())))); } } @@ -318,6 +380,7 @@ public final class BulkStructureImporter { } catch (Throwable e) { templateAttempts++; failed++; + retryRequired = true; sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_COULD_NOT_ENUMERATE_DATAPACK_TEMPLATES_VIA_SERVER_RESOURCEMANAGER, MessageArgument.untrusted("error", String.valueOf(e.getMessage())))); } if (enumerationSucceeded) { @@ -339,7 +402,14 @@ public final class BulkStructureImporter { } String name = templateNameFor(keyString); try { - StructureImporter.Result result = StructureImporter.importStructure(data, nk, name, mode, true); + StructureImporter.Result result = StructureImporter.importStructure( + data, + nk, + name, + mode, + true, + ownership + ); if (result.success()) { imported++; successfulBundles.put(bundleKey(name), keyString); @@ -347,10 +417,12 @@ public final class BulkStructureImporter { skipped++; } else { failed++; + retryRequired |= result.retryableFailure(); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_10, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("message", String.valueOf(result.message())))); } } catch (Throwable e) { failed++; + retryRequired = true; sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_FAIL_11, MessageArgument.untrusted("keyString", String.valueOf(keyString)), MessageArgument.untrusted("error", String.valueOf(e.getMessage())))); } } @@ -360,13 +432,27 @@ public final class BulkStructureImporter { StructureIndexService.write(data); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.BULK_STRUCTURE_IMPORTER_DATAPACK_STRUCTURE_IMPORT_COMPLETE_IMPORTED_SKIPPED_FAILED, MessageArgument.untrusted("imported", String.valueOf(imported)), MessageArgument.untrusted("skipped", String.valueOf(skipped)), MessageArgument.untrusted("failed", String.valueOf(failed)))); - return datapackReport(structureAttempts, templateAttempts, imported, skipped, failed, successfulBundles); + return datapackReport( + structureAttempts, + templateAttempts, + imported, + skipped, + failed, + successfulBundles, + retryRequired); } static boolean isAllowedDatapackKey(String key, Set allowedKeys) { return allowedKeys == null ? !key.startsWith("minecraft:") : allowedKeys.contains(key); } + static boolean isCaptureCandidate(String jigsawMessage, String singleMessage) { + return jigsawMessage != null + && jigsawMessage.contains("is not a jigsaw structure") + && singleMessage != null + && singleMessage.contains("No loadable structure NBT"); + } + static KeySelection selectDatapackKeys(Iterable discoveredKeys, Set allowedKeys) { TreeSet discovered = normalizeKeys(discoveredKeys); TreeSet present = new TreeSet<>(); @@ -401,8 +487,26 @@ public final class BulkStructureImporter { return new Report(structureAttempts + templateAttempts, imported, skipped, failed, successfulBundles); } + static Report datapackReport( + int structureAttempts, + int templateAttempts, + int imported, + int skipped, + int failed, + Map successfulBundles, + boolean retryRequired + ) { + return new Report( + structureAttempts + templateAttempts, + imported, + skipped, + failed, + successfulBundles, + retryRequired); + } + static Report enumerationFailureReport() { - return new Report(1, 0, 0, 1); + return new Report(1, 0, 0, 1, Map.of(), true); } public static String templateNameFor(String key) { diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java index 13843bf05..c706bc814 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureCaptureImporter.java @@ -31,6 +31,11 @@ import org.bukkit.World; import org.bukkit.block.Block; import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -51,6 +56,11 @@ public final class StructureCaptureImporter { } public static Report importAllStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender) { + return importStructures(data, mode, sender, null); + } + + public static Report importStructures(IrisData data, StructureImporter.Mode mode, VolmitSender sender, + Set allowedStructureKeys) { StructureImporter.Mode activeMode = mode == null ? StructureImporter.Mode.ADD_ONLY : mode; if (!INMS.get().supportsStructureCapture()) { sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.STRUCTURE_CAPTURE_IMPORTER_STRUCTURE_CAPTURE_IS_NOT_SUPPORTED_BY_ACTIVE_NMS_BINDING_SKIPPING_CAPTURE_PASS)); @@ -62,11 +72,8 @@ public final class StructureCaptureImporter { return new Report(0, 0, 0, 0); } - KList targets = new KList<>(); - for (String key : keys) { - if (key == null || key.isEmpty()) { - continue; - } + ArrayList targets = new ArrayList<>(); + for (String key : selectCaptureKeys(keys, allowedStructureKeys)) { String name = StructureImporter.deriveName(key); File structureFile = new File(data.getDataFolder(), "structures/" + name + ".json"); if (structureFile.exists() && activeMode != StructureImporter.Mode.OVERWRITE) { @@ -133,6 +140,35 @@ public final class StructureCaptureImporter { return new Report(total, imported, skipped, failed); } + static List selectCaptureKeys(Iterable keys, Set allowedStructureKeys) { + TreeSet allowed = null; + if (allowedStructureKeys != null) { + allowed = normalizeKeys(allowedStructureKeys); + } + TreeSet selected = new TreeSet<>(); + for (String key : keys) { + if (key == null || key.isBlank()) { + continue; + } + String normalized = key.trim().toLowerCase(Locale.ROOT); + if (allowed == null || allowed.contains(normalized)) { + selected.add(normalized); + } + } + return new ArrayList<>(selected); + } + + private static TreeSet normalizeKeys(Iterable keys) { + TreeSet normalized = new TreeSet<>(); + for (String key : keys) { + if (key == null || key.isBlank()) { + continue; + } + normalized.add(key.trim().toLowerCase(Locale.ROOT)); + } + return normalized; + } + private static IrisObject captureOne(World world, String key, int cellIndex) { int originX = (cellIndex % CELL_COLUMNS) * CELL_STRIDE; int originZ = (cellIndex / CELL_COLUMNS) * CELL_STRIDE; diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java b/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java index 9ad88b9c1..613577974 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureImporter.java @@ -29,6 +29,7 @@ import art.arcane.iris.core.structure.authoring.StructureSource; import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.core.structure.authoring.StructureWriteMode; import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.StructureGraphValidationException; import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.LegacyTileData; @@ -66,7 +67,33 @@ public final class StructureImporter { ADD_ONLY } - public record Result(boolean success, String message, int blocks, List losses) { + enum Ownership { + EDITABLE, + MANAGED_DATAPACK; + + StructureWriteResult write( + IrisData data, + StructureResourceBundle bundle, + StructureWriteMode mode + ) { + StructureTransactionWriter writer = new StructureTransactionWriter(data.getDataFolder().toPath()); + return this == MANAGED_DATAPACK + ? writer.writeManagedDatapack(bundle, mode) + : writer.write(bundle, mode); + } + } + + public record Result( + boolean success, + String message, + int blocks, + List losses, + boolean retryableFailure + ) { + public Result(boolean success, String message, int blocks, List losses) { + this(success, message, blocks, losses, false); + } + public Result { losses = List.copyOf(losses); } @@ -75,6 +102,7 @@ public final class StructureImporter { record CapturedStructure( IrisObject object, int blocks, + int nonAirBlocks, int tiles, int width, int height, @@ -113,10 +141,21 @@ public final class StructureImporter { } public static Result importStructure(IrisData data, NamespacedKey key, String name, Mode mode) { - return importStructure(data, key, name, mode, false); + return importStructure(data, key, name, mode, false, Ownership.EDITABLE); } public static Result importStructure(IrisData data, NamespacedKey key, String name, Mode mode, boolean objectOnly) { + return importStructure(data, key, name, mode, objectOnly, Ownership.EDITABLE); + } + + static Result importStructure( + IrisData data, + NamespacedKey key, + String name, + Mode mode, + boolean objectOnly, + Ownership ownership + ) { Mode activeMode = mode == null ? Mode.ADD_ONLY : mode; Structure structure; try { @@ -124,7 +163,7 @@ public final class StructureImporter { } catch (Throwable e) { IrisLogging.reportError(e); e.printStackTrace(); - return new Result(false, "Failed to load structure " + key + ": " + e.getMessage(), 0, List.of()); + return new Result(false, "Failed to load structure " + key + ": " + e.getMessage(), 0, List.of(), true); } if (structure == null || structure.getPalettes().isEmpty()) { return new Result(false, "No loadable structure NBT for key " + key + " (jigsaw structures must be imported by their piece keys)", 0, List.of()); @@ -136,7 +175,7 @@ public final class StructureImporter { } catch (Throwable e) { IrisLogging.reportError(e); e.printStackTrace(); - return new Result(false, "Failed to capture structure " + key + ": " + e.getMessage(), 0, List.of()); + return new Result(false, "Failed to capture structure " + key + ": " + e.getMessage(), 0, List.of(), true); } IrisObject object = captured.object(); @@ -168,20 +207,23 @@ public final class StructureImporter { } StructureWriteMode writeMode = activeMode == Mode.ADD_ONLY ? StructureWriteMode.ADD_ONLY : StructureWriteMode.OVERWRITE; - StructureWriteResult writeResult = new StructureTransactionWriter(data.getDataFolder().toPath()) - .write(bundle, writeMode); + StructureWriteResult writeResult = ownership.write(data, bundle, writeMode); reportWriteFailure(writeResult); if (!writeResult.successful()) { - return new Result(false, writeFailureMessage(name, activeMode, writeResult), count, losses); + return new Result(false, writeFailureMessage(name, activeMode, writeResult), count, losses, + writeResult.failure().isPresent()); } if (writeResult.committed()) { data.invalidateStructureResources(); } writeNote = writeResultNote(writeResult); + } catch (StructureGraphValidationException e) { + return new Result(false, "Failed writing import for '" + name + "': " + e.getMessage(), count, losses); } catch (Throwable e) { IrisLogging.reportError(e); e.printStackTrace(); - return new Result(false, "Failed writing import for '" + name + "': " + e.getMessage(), count, losses); + return new Result(false, "Failed writing import for '" + name + "': " + e.getMessage(), count, losses, + true); } String lossSummary = losses.isEmpty() ? "" : ", " + losses.size() + " fidelity warning(s) recorded"; @@ -201,8 +243,10 @@ public final class StructureImporter { int d = Math.max(1, size.getBlockZ()); IrisObject object = new IrisObject(w, h, d); int count = 0; + int nonAirBlocks = 0; int tiles = 0; int structureMarkers = 0; + int invalidFinalStates = 0; Palette palette = activeStructure.getPalettes().get(0); for (BlockState block : palette.getBlocks()) { Location loc = block.getLocation(); @@ -222,7 +266,11 @@ public final class StructureImporter { structureMarkers++; } if (mat == Material.JIGSAW) { - BlockData resolved = readJigsawFinalState(block); + FinalStateResult finalState = readJigsawFinalState(block); + BlockData resolved = finalState.blockData(); + if (finalState.invalidSourceState()) { + invalidFinalStates++; + } if (resolved == null || isAir(resolved)) { continue; } @@ -232,6 +280,9 @@ public final class StructureImporter { } object.setUnsigned(x, y, z, art.arcane.iris.platform.bukkit.BukkitBlockState.of(blockData)); count++; + if (!isAir(blockData)) { + nonAirBlocks++; + } if (!structural) { LegacyTileData tile = captureTile(block); if (tile != null) { @@ -249,13 +300,14 @@ public final class StructureImporter { return new CapturedStructure( object, count, + nonAirBlocks, tiles, w, h, d, structureMarkers, capabilities, - importLosses(activeStructure, structureMarkers) + importLosses(activeStructure, structureMarkers, invalidFinalStates) ); } @@ -264,7 +316,11 @@ public final class StructureImporter { return m == Material.AIR || m == Material.CAVE_AIR || m == Material.VOID_AIR; } - private static List importLosses(Structure structure, int structureMarkers) { + private static List importLosses( + Structure structure, + int structureMarkers, + int invalidFinalStates + ) { List losses = new ArrayList<>(); if (structure.getPaletteCount() > 1) { losses.add(StructureLoss.warning( @@ -285,6 +341,12 @@ public final class StructureImporter { "structure_markers_resolved", structureMarkers + " jigsaw or structure marker(s) were resolved to final blocks or omitted from the Iris snapshot.")); } + if (invalidFinalStates > 0) { + losses.add(StructureLoss.warning( + StructureCapability.BLOCKS, + "invalid_jigsaw_final_state", + invalidFinalStates + " jigsaw final-state value(s) were invalid and omitted from the Iris snapshot.")); + } return losses; } @@ -313,28 +375,56 @@ public final class StructureImporter { return "Failed writing import for '" + name + "' in " + mode.name().toLowerCase() + " mode: " + failure; } - private static BlockData readJigsawFinalState(BlockState block) { + private static FinalStateResult readJigsawFinalState(BlockState block) { + String finalState; try { Object nbt = block.getClass().getMethod("getSnapshotNBT").invoke(block); if (nbt == null) { - return null; + return new FinalStateResult(null, false); } Object res = nbt.getClass().getMethod("getString", String.class).invoke(nbt, "final_state"); - String finalState = null; + finalState = null; if (res instanceof String s) { finalState = s; } else if (res instanceof Optional o && o.isPresent()) { finalState = String.valueOf(o.get()); } if (finalState == null || finalState.isBlank()) { - return null; + return new FinalStateResult(null, false); } - return Bukkit.createBlockData(finalState); } catch (Throwable e) { IrisLogging.reportError(e); - e.printStackTrace(); + if (VillageImporter.shouldPrintFullTrace(e)) { + e.printStackTrace(); + } + return new FinalStateResult(null, false); + } + try { + return new FinalStateResult(Bukkit.createBlockData(normalizeJigsawFinalState(finalState)), false); + } catch (IllegalArgumentException e) { + return new FinalStateResult(null, true); + } + } + + static String normalizeJigsawFinalState(String finalState) { + if (finalState == null) { return null; } + int propertiesStart = finalState.indexOf('['); + String blockId = propertiesStart < 0 ? finalState : finalState.substring(0, propertiesStart); + if (blockId.equals("minecraft:chisled_polished_blackstone")) { + finalState = "minecraft:chiseled_polished_blackstone" + + (propertiesStart < 0 ? "" : finalState.substring(propertiesStart)); + propertiesStart = finalState.indexOf('['); + } + if (propertiesStart < 0 || !finalState.substring(0, propertiesStart).endsWith("_slab") + || finalState.contains("type=")) { + return finalState; + } + return finalState.replaceAll("([\\[,])half=(top|bottom)(?=[,\\]])", "$1type=$2"); + } + + private record FinalStateResult(BlockData blockData, boolean invalidSourceState) { } private static LegacyTileData captureTile(BlockState block) { @@ -376,7 +466,8 @@ public final class StructureImporter { } catch (IOException e) { IrisLogging.reportError(e); e.printStackTrace(); - return new Result(false, "Cannot read imported object '" + rel + "': " + e.getMessage(), 0, List.of()); + return new Result(false, "Cannot read imported object '" + rel + "': " + e.getMessage(), 0, + List.of(), true); } } @@ -407,7 +498,8 @@ public final class StructureImporter { .write(bundle, writeMode); reportWriteFailure(writeResult); if (!writeResult.successful()) { - return new Result(false, writeFailureMessage(groupName, activeMode, writeResult), 0, List.of()); + return new Result(false, writeFailureMessage(groupName, activeMode, writeResult), 0, List.of(), + writeResult.failure().isPresent()); } if (writeResult.committed()) { data.invalidateStructureResources(); @@ -417,7 +509,8 @@ public final class StructureImporter { } catch (Throwable e) { IrisLogging.reportError(e); e.printStackTrace(); - return new Result(false, "Failed writing group structure '" + groupName + "': " + e.getMessage(), 0, List.of()); + return new Result(false, "Failed writing group structure '" + groupName + "': " + e.getMessage(), 0, + List.of(), true); } } @@ -493,17 +586,21 @@ public final class StructureImporter { .write(bundle, writeMode); reportWriteFailure(writeResult); if (!writeResult.successful()) { - return new Result(false, writeFailureMessage(name, activeMode, writeResult), 0, List.of()); + return new Result(false, writeFailureMessage(name, activeMode, writeResult), 0, List.of(), + writeResult.failure().isPresent()); } if (writeResult.committed()) { data.invalidateStructureResources(); } return new Result(true, "Captured '" + vanillaSource + "' as '" + name + "'" + writeResultNote(writeResult), object.getBlocks().size(), List.of()); + } catch (StructureGraphValidationException e) { + return new Result(false, "Failed writing capture for '" + name + "': " + e.getMessage(), 0, List.of()); } catch (Throwable e) { IrisLogging.reportError(e); e.printStackTrace(); - return new Result(false, "Failed writing capture for '" + name + "': " + e.getMessage(), 0, List.of()); + return new Result(false, "Failed writing capture for '" + name + "': " + e.getMessage(), 0, List.of(), + true); } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java b/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java index a4d4dfb40..e16149625 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/VillageImporter.java @@ -25,10 +25,11 @@ import art.arcane.iris.core.structure.authoring.StructureKey; import art.arcane.iris.core.structure.authoring.StructureLoss; import art.arcane.iris.core.structure.authoring.StructureResourceBundle; import art.arcane.iris.core.structure.authoring.StructureSource; -import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.core.structure.authoring.StructureWriteMode; import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.framework.structure.StructureGraphValidationException; import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.spi.IrisLogging; import com.google.gson.Gson; @@ -44,11 +45,13 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collections; import java.util.Deque; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -56,7 +59,18 @@ public final class VillageImporter { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Set PRINTED_FAILURE_SIGNATURES = ConcurrentHashMap.newKeySet(); - public record Result(boolean success, String message, int pools, int pieces, List losses) { + public record Result( + boolean success, + String message, + int pools, + int pieces, + List losses, + boolean retryableFailure + ) { + public Result(boolean success, String message, int pools, int pieces, List losses) { + this(success, message, pools, pieces, losses, false); + } + public Result { losses = List.copyOf(losses); } @@ -66,8 +80,19 @@ public final class VillageImporter { } public static Result importVillage(IrisData data, NamespacedKey structureKey, String name, StructureImporter.Mode mode) { + return importVillage(data, structureKey, name, mode, StructureImporter.Ownership.EDITABLE); + } + + static Result importVillage( + IrisData data, + NamespacedKey structureKey, + String name, + StructureImporter.Mode mode, + StructureImporter.Ownership ownership + ) { StructureImporter.Mode activeMode = mode == null ? StructureImporter.Mode.ADD_ONLY : mode; List losses = new ArrayList<>(); + boolean retryableFailure = false; Object server; Object registryAccess; Object structureManager; @@ -80,10 +105,10 @@ public final class VillageImporter { structureManager = invoke(server, "getStructureManager"); } catch (Throwable e) { reportFailure(e); - return failed("Failed to access server registries via reflection: " + e, losses); + return failed("Failed to access server registries via reflection: " + e, losses, true); } if (registryAccess == null) { - return failed("Could not resolve RegistryAccess from the server", losses); + return failed("Could not resolve RegistryAccess from the server", losses, true); } Object startPool; @@ -105,7 +130,7 @@ public final class VillageImporter { maxDistanceFromCenter = readIntMember(structure, "maxDistanceFromCenter"); } catch (Throwable e) { reportFailure(e); - return failed("Failed to read jigsaw structure graph: " + e, losses); + return failed("Failed to read jigsaw structure graph: " + e, losses, true); } Object templatePoolRegistry; @@ -115,7 +140,7 @@ public final class VillageImporter { random = new java.util.Random(structureKey.hashCode()); } catch (Throwable e) { reportFailure(e); - return failed("Failed to access TEMPLATE_POOL registry: " + e, losses); + return failed("Failed to access TEMPLATE_POOL registry: " + e, losses, true); } String startPoolKey; @@ -123,7 +148,7 @@ public final class VillageImporter { startPoolKey = registryKeyOf(templatePoolRegistry, startPool); } catch (Throwable e) { reportFailure(e); - return failed("Could not resolve the start pool key for " + structureKey + ": " + e, losses); + return failed("Could not resolve the start pool key for " + structureKey + ": " + e, losses, true); } if (startPoolKey == null) { return failed("Could not resolve the start pool key for " + structureKey, losses); @@ -136,6 +161,7 @@ public final class VillageImporter { Map> emittedPools = new LinkedHashMap<>(); Map> emittedPieces = new LinkedHashMap<>(); Map emittedObjects = new LinkedHashMap<>(); + Map importedTemplates = new LinkedHashMap<>(); Set capabilities = new HashSet<>(); capabilities.add(StructureCapability.BLOCKS); capabilities.add(StructureCapability.CONNECTORS); @@ -146,6 +172,7 @@ public final class VillageImporter { "native_placement_settings_not_imported", "Native jigsaw placement settings other than the start pool, maximum depth, and maximum distance are not represented by the Iris assembly.")); int pieceBlocks = 0; + int emittedPoolMembers = 0; while (!poolQueue.isEmpty()) { String poolKey = poolQueue.poll(); @@ -157,6 +184,7 @@ public final class VillageImporter { pool = registryGetByKey(templatePoolRegistry, poolKey); } catch (Throwable e) { reportFailure(e); + retryableFailure = true; fatalErrors.add("pool " + poolKey + ": " + e.getMessage()); continue; } @@ -175,6 +203,7 @@ public final class VillageImporter { fallbackKey = registryKeyOf(templatePoolRegistry, fallbackPool); } catch (Throwable e) { reportFailure(e); + retryableFailure = true; losses.add(StructureLoss.warning( StructureCapability.CONNECTORS, "fallback_pool_not_imported", @@ -190,6 +219,7 @@ public final class VillageImporter { templates = (List) invoke(pool, "getTemplates"); } catch (Throwable e) { reportFailure(e); + retryableFailure = true; fatalErrors.add("templates " + poolKey + ": " + e.getMessage()); templates = List.of(); } @@ -203,6 +233,7 @@ public final class VillageImporter { weight = second instanceof Number ? Math.max(1, ((Number) second).intValue()) : 1; } catch (Throwable e) { reportFailure(e); + retryableFailure = true; losses.add(StructureLoss.warning( StructureCapability.LIST_ELEMENTS, "pool_entry_not_imported", @@ -213,11 +244,12 @@ public final class VillageImporter { if (element == null) { continue; } - String templateLocation; + PoolElementResolution elementResolution; try { - templateLocation = templateLocationOf(element); + elementResolution = resolvePoolElement(element); } catch (Throwable e) { reportFailure(e); + retryableFailure = true; losses.add(StructureLoss.warning( StructureCapability.BLOCKS, "template_location_not_imported", @@ -225,13 +257,19 @@ public final class VillageImporter { .affecting("jigsaw-pools/" + irisPoolName + ".json")); continue; } + if (elementResolution.omittedElements() > 0) { + losses.add(listElementFallbackLoss(elementResolution, poolKey) + .affecting("jigsaw-pools/" + irisPoolName + ".json")); + } + Object physicalElement = elementResolution.physicalElement(); + String templateLocation = elementResolution.templateLocation(); if (templateLocation == null) { - String elementType = element.getClass().getSimpleName(); + String elementType = physicalElement == null + ? element.getClass().getSimpleName() + : physicalElement.getClass().getSimpleName(); if (elementType.endsWith("EmptyPoolElement")) { - Map emptyEntry = new LinkedHashMap<>(); - emptyEntry.put("empty", true); - emptyEntry.put("weight", weight); - pieceEntries.add(emptyEntry); + pieceEntries.add(emptyPoolEntry(weight)); + emittedPoolMembers++; } else { StructureCapability unsupportedCapability = unsupportedCapability(elementType); losses.add(StructureLoss.warning( @@ -249,12 +287,14 @@ public final class VillageImporter { } String irisPieceName = pieceName(name, templateLocation); - if (!emittedPieces.containsKey(irisPieceName)) { + ImportedTemplate importedTemplate = importedTemplates.get(irisPieceName); + if (importedTemplate == null) { Structure sourceTemplate; try { sourceTemplate = Bukkit.getStructureManager().loadStructure(pieceNbtKey); } catch (Throwable e) { reportFailure(e); + retryableFailure = true; fatalErrors.add(templateLocation + ": failed to load structure template: " + failureDetail(e)); continue; } @@ -268,27 +308,53 @@ public final class VillageImporter { captured = StructureImporter.captureStructure(sourceTemplate); } catch (Throwable e) { reportFailure(e); + retryableFailure = true; fatalErrors.add(templateLocation + ": failed to capture structure template: " + failureDetail(e)); continue; } - pieceBlocks += captured.blocks(); - capabilities.addAll(captured.capabilities()); for (StructureLoss loss : captured.losses()) { losses.add(loss.affecting("objects/" + irisPieceName + ".iob")); } - emittedObjects.put(irisPieceName, captured.object()); Connectors result = readConnectors(element, structureManager, random, name, irisPieceName); - emittedPieces.put(irisPieceName, pieceJson(irisPieceName, result.json())); + retryableFailure |= result.retryableFailure(); + importedTemplate = new ImportedTemplate( + captured.object(), + captured.blocks(), + captured.nonAirBlocks(), + result.json(), + captured.capabilities()); + importedTemplates.put(irisPieceName, importedTemplate); poolQueue.addAll(result.targetPoolKeys()); losses.addAll(result.losses()); } - if (emittedPieces.containsKey(irisPieceName)) { - Map entry = new LinkedHashMap<>(); - entry.put("piece", irisPieceName); - entry.put("weight", weight); - pieceEntries.add(entry); + PoolMemberNormalization normalization = normalizePoolMember( + poolKey, + irisPoolName, + poolKey.equals(startPoolKey), + templates.size(), + fallbackKey, + templateLocation, + irisPieceName, + weight, + importedTemplate.nonAirBlocks(), + importedTemplate.connectors()); + losses.addAll(normalization.losses()); + if (!emittedPieces.containsKey(irisPieceName)) { + pieceBlocks += importedTemplate.emittedBlocks(normalization); + capabilities.addAll(importedTemplate.emittedCapabilities(normalization)); + if (normalization.disposition() == PoolMemberDisposition.PHYSICAL) { + emittedObjects.put(irisPieceName, importedTemplate.object()); + emittedPieces.put(irisPieceName, pieceJson( + irisPieceName, + importedTemplate.connectors(), + importedTemplate.nonAirBlocks())); + } + } + if (!normalization.poolEntry().isEmpty()) { + pieceEntries.add(normalization.poolEntry()); + emittedPoolMembers++; } } @@ -302,10 +368,11 @@ public final class VillageImporter { if (!fatalErrors.isEmpty()) { return failed("Failed to capture the complete graph for " + structureKey + ": " + fatalErrors.getFirst() - + (fatalErrors.size() == 1 ? "" : " (" + (fatalErrors.size() - 1) + " more)"), losses); + + (fatalErrors.size() == 1 ? "" : " (" + (fatalErrors.size() - 1) + " more)"), losses, + retryableFailure); } - if (emittedPieces.isEmpty()) { - return failed("Imported 0 pieces for " + structureKey, losses); + if (emittedPoolMembers == 0) { + return failed("Imported 0 attachable or empty pool members for " + structureKey, losses); } for (StructureLoss loss : losses) { if (loss.capability() == StructureCapability.CONNECTORS) { @@ -340,21 +407,23 @@ public final class VillageImporter { StructureResourceBundleGraphCompiler.requireViable(bundle); StructureWriteMode writeMode = activeMode == StructureImporter.Mode.OVERWRITE ? StructureWriteMode.OVERWRITE : StructureWriteMode.ADD_ONLY; - StructureWriteResult writeResult = new StructureTransactionWriter(data.getDataFolder().toPath()) - .write(bundle, writeMode); + StructureWriteResult writeResult = ownership.write(data, bundle, writeMode); reportWriteFailure(writeResult); if (!writeResult.successful()) { return new Result(false, writeFailureMessage(name, writeResult), emittedPools.size(), - emittedPieces.size(), losses); + emittedPieces.size(), losses, writeResult.failure().isPresent()); } if (writeResult.committed()) { data.invalidateStructureResources(); } writeNote = writeResultNote(writeResult); + } catch (StructureGraphValidationException e) { + return new Result(false, "Failed writing jigsaw resources for '" + name + "': " + e.getMessage(), + emittedPools.size(), emittedPieces.size(), losses, retryableFailure); } catch (Throwable e) { reportFailure(e); return new Result(false, "Failed writing jigsaw resources for '" + name + "': " + e, - emittedPools.size(), emittedPieces.size(), losses); + emittedPools.size(), emittedPieces.size(), losses, true); } String msg = "Imported village " + structureKey + " as '" + name + "': " + emittedPieces.size() + " pieces, " + emittedPools.size() + " pools, " + pieceBlocks + " blocks"; @@ -395,8 +464,47 @@ public final class VillageImporter { private record Connectors( List> json, Set targetPoolKeys, + List losses, + boolean retryableFailure + ) { + } + + record ImportedTemplate( + IrisObject object, + int blocks, + int nonAirBlocks, + List> connectors, + List capabilities + ) { + ImportedTemplate { + connectors = List.copyOf(connectors); + capabilities = List.copyOf(capabilities); + } + + int emittedBlocks(PoolMemberNormalization normalization) { + return normalization.disposition() == PoolMemberDisposition.PHYSICAL ? blocks : 0; + } + + List emittedCapabilities(PoolMemberNormalization normalization) { + return normalization.disposition() == PoolMemberDisposition.PHYSICAL ? capabilities : List.of(); + } + } + + enum PoolMemberDisposition { + PHYSICAL, + EMPTY, + OMITTED + } + + record PoolMemberNormalization( + PoolMemberDisposition disposition, + Map poolEntry, List losses ) { + PoolMemberNormalization { + poolEntry = Collections.unmodifiableMap(new LinkedHashMap<>(poolEntry)); + losses = List.copyOf(losses); + } } private static Connectors readConnectors( @@ -409,6 +517,7 @@ public final class VillageImporter { List> connectors = new ArrayList<>(); Set targets = new HashSet<>(); List losses = new ArrayList<>(); + boolean retryableFailure = false; String affectedResource = "jigsaw-pieces/" + pieceName + ".json"; try { Object zero = staticField("net.minecraft.core.BlockPos", "ZERO"); @@ -420,7 +529,7 @@ public final class VillageImporter { "connector_extraction_unavailable", "The source pool element does not expose jigsaw connector extraction on this server version.") .affecting(affectedResource)); - return new Connectors(connectors, targets, losses); + return new Connectors(connectors, targets, losses, false); } m.setAccessible(true); Object random0 = freshRandomSource(random); @@ -431,7 +540,7 @@ public final class VillageImporter { "connector_extraction_returned_null", "The source pool element returned no connector collection.") .affecting(affectedResource)); - return new Connectors(connectors, targets, losses); + return new Connectors(connectors, targets, losses, false); } for (Object jigsaw : blocks) { String[] rawPoolKey = new String[1]; @@ -443,6 +552,7 @@ public final class VillageImporter { } } catch (Throwable e) { reportFailure(e); + retryableFailure = true; losses.add(StructureLoss.warning( StructureCapability.CONNECTORS, "connector_not_imported", @@ -452,13 +562,14 @@ public final class VillageImporter { } } catch (Throwable e) { reportFailure(e); + retryableFailure = true; losses.add(StructureLoss.warning( StructureCapability.CONNECTORS, "connector_extraction_failed", "Source jigsaw connectors could not be extracted: " + failureDetail(e)) .affecting(affectedResource)); } - return new Connectors(connectors, targets, losses); + return new Connectors(connectors, targets, losses, retryableFailure); } private static Map connectorFrom(Object jigsaw, String baseName, String[] rawPoolKeyOut) throws Exception { @@ -479,6 +590,35 @@ public final class VillageImporter { String top = topFacing(blockState); rawPoolKeyOut[0] = poolId; + ConnectorMetadata metadata = readConnectorMetadata(jigsaw, info); + return connectorJson( + x, + y, + z, + front, + top, + poolId, + baseName, + identifierString(nameId), + identifierString(targetId), + jointType, + metadata + ); + } + + static Map connectorJson( + int x, + int y, + int z, + String front, + String top, + String poolId, + String baseName, + String nameId, + String targetId, + Object jointType, + ConnectorMetadata metadata + ) { Map connector = new LinkedHashMap<>(); Map position = new LinkedHashMap<>(); position.put("x", x); @@ -488,12 +628,60 @@ public final class VillageImporter { connector.put("direction", irisDirection(front)); connector.put("top", irisDirection(top)); connector.put("pool", poolId == null ? "" : poolName(baseName, poolId)); - connector.put("name", identifierString(nameId)); - connector.put("targetName", identifierString(targetId)); + connector.put("name", nameId); + connector.put("targetName", targetId); connector.put("joint", jointType != null && jointType.toString().toUpperCase().contains("ALIGN") ? "ALIGNED" : "ROLLABLE"); + connector.put("finalState", metadata.finalState()); + connector.put("selectionPriority", metadata.selectionPriority()); + connector.put("placementPriority", metadata.placementPriority()); return connector; } + static ConnectorMetadata readConnectorMetadata(Object jigsaw, Object info) throws Exception { + Object nbt = invoke(info, "nbt"); + String finalState = readNbtString(nbt, "final_state"); + String normalizedFinalState = finalState == null || finalState.isBlank() + ? "minecraft:air" + : StructureImporter.normalizeJigsawFinalState(finalState); + return new ConnectorMetadata( + normalizedFinalState, + readIntAccessor(jigsaw, "selectionPriority"), + readIntAccessor(jigsaw, "placementPriority") + ); + } + + private static String readNbtString(Object nbt, String key) throws Exception { + if (nbt == null) { + return null; + } + Method method = findMethod(nbt.getClass(), "getString", 1); + if (method == null) { + throw new NoSuchMethodException("getString(String) on " + nbt.getClass().getName()); + } + method.setAccessible(true); + Object value = method.invoke(nbt, key); + if (value instanceof String string) { + return string; + } + if (value instanceof Optional optional) { + return optional.isPresent() ? String.valueOf(optional.get()) : null; + } + return value == null ? null : String.valueOf(value); + } + + private static int readIntAccessor(Object value, String accessor) throws Exception { + Method method = findMethod(value.getClass(), accessor); + if (method == null) { + throw new NoSuchMethodException(accessor + "() on " + value.getClass().getName()); + } + method.setAccessible(true); + Object result = method.invoke(value); + if (result instanceof Number number) { + return number.intValue(); + } + throw new IllegalStateException(accessor + " on " + value.getClass().getName() + " is not numeric"); + } + private static String frontFacing(Object blockState) throws Exception { Class jigsawBlock = Class.forName("net.minecraft.world.level.block.JigsawBlock"); Method getFront = jigsawBlock.getMethod("getFrontFacing", Class.forName("net.minecraft.world.level.block.state.BlockState")); @@ -539,6 +727,42 @@ public final class VillageImporter { return identifierString(id); } + static PoolElementResolution resolvePoolElement(Object element) throws Exception { + if (element == null) { + return new PoolElementResolution(null, null, 0, 0); + } + if (!element.getClass().getSimpleName().endsWith("ListPoolElement")) { + return new PoolElementResolution(element, templateLocationOf(element), 0, 0); + } + Object rawElements = invoke(element, "getElements"); + if (!(rawElements instanceof List elements)) { + throw new IllegalStateException("getElements on " + element.getClass().getName() + " is not a list"); + } + if (elements.isEmpty()) { + return new PoolElementResolution(null, null, 1, 0); + } + PoolElementResolution primary = resolvePoolElement(elements.getFirst()); + return new PoolElementResolution( + primary.physicalElement(), + primary.templateLocation(), + primary.listLevels() + 1, + primary.omittedElements() + elements.size() - 1 + ); + } + + static StructureLoss listElementFallbackLoss(PoolElementResolution resolution, String poolKey) { + int omitted = resolution.omittedElements(); + String elementLabel = omitted == 1 ? "element" : "elements"; + String levelLabel = resolution.listLevels() == 1 ? "list level" : "nested list levels"; + return StructureLoss.warning( + StructureCapability.LIST_ELEMENTS, + "list_pool_overlays_not_imported", + "Converted the first physical template from a ListPoolElement in source pool " + poolKey + + " and omitted " + omitted + " colocated " + elementLabel + + ", including their processors, across " + resolution.listLevels() + " " + levelLabel + "." + ); + } + private static Object resolveRegistryAccess(Object server) { try { Class frozen = Class.forName("net.minecraft.core.RegistryAccess$Frozen"); @@ -789,17 +1013,21 @@ public final class VillageImporter { } private static Method findMethod4(Class type, String name) { + return findMethod(type, name, 4); + } + + private static Method findMethod(Class type, String name, int parameterCount) { Class c = type; while (c != null) { for (Method m : c.getDeclaredMethods()) { - if (m.getName().equals(name) && m.getParameterCount() == 4) { + if (m.getName().equals(name) && m.getParameterCount() == parameterCount) { return m; } } c = c.getSuperclass(); } for (Method m : type.getMethods()) { - if (m.getName().equals(name) && m.getParameterCount() == 4) { + if (m.getName().equals(name) && m.getParameterCount() == parameterCount) { return m; } } @@ -822,14 +1050,116 @@ public final class VillageImporter { return base + "/piece/" + key.namespace() + "/" + key.path(); } - private static Map pieceJson(String pieceName, List> connectors) { + static Map pieceJson( + String pieceName, + List> connectors, + int nonAirBlocks + ) { Map piece = new LinkedHashMap<>(); piece.put("object", pieceName); piece.put("connectors", connectors); piece.put("rotatable", true); + if (nonAirBlocks == 0) { + piece.put("collidable", false); + } return piece; } + static PoolMemberNormalization normalizePoolMember( + String sourcePoolKey, + String irisPoolName, + boolean startPoolMember, + int sourcePoolMembershipCount, + String sourceFallbackKey, + String templateLocation, + String irisPieceName, + int weight, + int nonAirBlocks, + List> connectors + ) { + if (!connectors.isEmpty() || startPoolMember) { + return new PoolMemberNormalization( + PoolMemberDisposition.PHYSICAL, + piecePoolEntry(irisPieceName, weight), + List.of()); + } + if (sourceFallbackKey != null + && !sourceFallbackKey.isBlank() + && !sourceFallbackKey.equals(sourcePoolKey)) { + return new PoolMemberNormalization( + PoolMemberDisposition.PHYSICAL, + piecePoolEntry(irisPieceName, weight), + List.of()); + } + String affectedResource = "jigsaw-pools/" + irisPoolName + ".json"; + if (nonAirBlocks == 0 && sourcePoolMembershipCount == 1) { + StructureLoss loss = StructureLoss.warning( + StructureCapability.IRIS_PLACEMENT, + "connectorless_all_air_member_normalized_empty", + "Source pool member " + templateLocation + " in " + sourcePoolKey + + " captured no non-air blocks and exposed no jigsaw connectors;" + + " its singleton membership was normalized to an explicit empty Iris pool entry" + + fallbackContext(sourcePoolKey, sourceFallbackKey) + ".") + .affecting(affectedResource); + return new PoolMemberNormalization( + PoolMemberDisposition.EMPTY, + emptyPoolEntry(weight), + List.of(loss)); + } + if (nonAirBlocks == 0) { + StructureLoss loss = StructureLoss.warning( + StructureCapability.IRIS_PLACEMENT, + "connectorless_all_air_mixed_member_omitted", + "Source pool member " + templateLocation + " in " + sourcePoolKey + + " captured no non-air blocks and exposed no jigsaw connectors;" + + " it was omitted from the mixed " + sourcePoolMembershipCount + "-member pool" + + fallbackContext(sourcePoolKey, sourceFallbackKey) + + ", changing source selection weights and RNG consumption.") + .affecting(affectedResource); + return new PoolMemberNormalization( + PoolMemberDisposition.OMITTED, + Map.of(), + List.of(loss)); + } + StructureLoss loss = StructureLoss.warning( + StructureCapability.BLOCKS, + "connectorless_non_air_member_omitted", + "Source pool member " + templateLocation + " in " + sourcePoolKey + + " captured " + nonAirBlocks + " non-air block(s) but exposed no jigsaw connectors;" + + " it was omitted because it cannot attach to the Iris assembly graph" + + fallbackContext(sourcePoolKey, sourceFallbackKey) + + ", changing source selection weights and RNG consumption while dropping those blocks.") + .affecting(affectedResource); + return new PoolMemberNormalization( + PoolMemberDisposition.OMITTED, + Map.of(), + List.of(loss)); + } + + private static String fallbackContext(String sourcePoolKey, String sourceFallbackKey) { + if (sourceFallbackKey == null || sourceFallbackKey.isBlank()) { + return " with no source fallback"; + } + if (sourceFallbackKey.equals(sourcePoolKey)) { + return " with its source self-fallback"; + } + return " before source fallback " + sourceFallbackKey; + } + + static Map emptyPoolEntry(int weight) { + Map entry = new LinkedHashMap<>(); + entry.put("empty", true); + entry.put("weight", weight); + return entry; + } + + static Map piecePoolEntry(String pieceName, int weight) { + Map entry = new LinkedHashMap<>(); + entry.put("piece", pieceName); + entry.put("weight", weight); + return entry; + } + static Map structureJson( String source, String startPool, @@ -842,6 +1172,7 @@ public final class VillageImporter { int maxSizeChunks = Math.max(1, Math.min(32, (Math.max(1, maxDistanceFromCenter) + 15) / 16)); root.put("maxSizeChunks", maxSizeChunks); root.put("placeMode", "STRUCTURE_PIECE"); + root.put("branchFailurePolicy", IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH.name()); root.put("vanillaSource", source); return root; } @@ -866,6 +1197,10 @@ public final class VillageImporter { return new Result(false, message, 0, 0, losses); } + private static Result failed(String message, List losses, boolean retryableFailure) { + return new Result(false, message, 0, 0, losses, retryableFailure); + } + private static void reportFailure(Throwable failure) { IrisLogging.reportError(failure); if (shouldPrintFullTrace(failure)) { @@ -918,4 +1253,15 @@ public final class VillageImporter { String message = failure.getMessage(); return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message; } + + record ConnectorMetadata(String finalState, int selectionPriority, int placementPriority) { + } + + record PoolElementResolution( + Object physicalElement, + String templateLocation, + int listLevels, + int omittedElements + ) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureOwnershipManifest.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureOwnershipManifest.java index aaa62462d..a4b452826 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureOwnershipManifest.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureOwnershipManifest.java @@ -30,6 +30,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.TreeMap; +import java.util.UUID; public record StructureOwnershipManifest( int schemaVersion, @@ -38,7 +39,8 @@ public record StructureOwnershipManifest( StructureBackend backend, List capabilities, List losses, - Map resourceHashes + Map resourceHashes, + Provenance provenance ) { public static final int CURRENT_SCHEMA_VERSION = 1; private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -53,6 +55,7 @@ public record StructureOwnershipManifest( Objects.requireNonNull(capabilities, "capabilities"); Objects.requireNonNull(losses, "losses"); Objects.requireNonNull(resourceHashes, "resourceHashes"); + provenance = provenance == null ? Provenance.created() : provenance; ArrayList orderedCapabilities = new ArrayList<>(capabilities); orderedCapabilities.sort(Comparator.naturalOrder()); capabilities = List.copyOf(orderedCapabilities); @@ -77,8 +80,25 @@ public record StructureOwnershipManifest( resourceHashes = Collections.unmodifiableMap(orderedHashes); } + public StructureOwnershipManifest( + int schemaVersion, + StructureKey structure, + StructureSource source, + StructureBackend backend, + List capabilities, + List losses, + Map resourceHashes + ) { + this(schemaVersion, structure, source, backend, capabilities, losses, resourceHashes, Provenance.created()); + } + public static StructureOwnershipManifest from(StructureResourceBundle bundle) { + return from(bundle, Provenance.created()); + } + + public static StructureOwnershipManifest from(StructureResourceBundle bundle, Provenance provenance) { Objects.requireNonNull(bundle, "bundle"); + Objects.requireNonNull(provenance, "provenance"); TreeMap hashes = new TreeMap<>(); for (StructureResourceBundle.Resource resource : bundle.resources().values()) { hashes.put(resource.relativePath(), resource.contentHash()); @@ -90,7 +110,8 @@ public record StructureOwnershipManifest( bundle.backend(), new ArrayList<>(bundle.capabilities()), bundle.losses(), - hashes + hashes, + provenance ); } @@ -119,4 +140,121 @@ public record StructureOwnershipManifest( String identityHash = StructureHash.sha256(structure.value().getBytes(StandardCharsets.UTF_8)); return ".iris/structure-manifests/key-" + identityHash + ".json"; } + + public record Provenance( + Origin origin, + String receiptId, + String planHash, + String sourceClosureHash, + long appliedAtEpochMilli, + Map sourceResourceHashes, + Map sourceToTargetPaths, + RollbackDisposition rollbackDisposition + ) { + public Provenance { + origin = origin == null ? Origin.CREATED : origin; + receiptId = normalize(receiptId); + planHash = normalize(planHash); + sourceClosureHash = normalize(sourceClosureHash); + sourceResourceHashes = immutableHashes(sourceResourceHashes); + sourceToTargetPaths = immutableMappings(sourceToTargetPaths); + rollbackDisposition = rollbackDisposition == null + ? RollbackDisposition.NONE + : rollbackDisposition; + if (origin == Origin.CREATED) { + if (!receiptId.isEmpty() || !planHash.isEmpty() || !sourceClosureHash.isEmpty() + || appliedAtEpochMilli != 0L || !sourceResourceHashes.isEmpty() + || !sourceToTargetPaths.isEmpty() || rollbackDisposition != RollbackDisposition.NONE) { + throw new IllegalArgumentException("Created structure provenance cannot declare adoption metadata"); + } + } else { + requireUuid(receiptId); + requireHash(planHash, "plan"); + requireHash(sourceClosureHash, "source closure"); + if (appliedAtEpochMilli <= 0L) { + throw new IllegalArgumentException("Adoption provenance requires a positive application time"); + } + if (sourceResourceHashes.isEmpty() || sourceToTargetPaths.isEmpty()) { + throw new IllegalArgumentException("Adoption provenance requires source hashes and path mappings"); + } + } + } + + public static Provenance created() { + return new Provenance( + Origin.CREATED, + "", + "", + "", + 0L, + Map.of(), + Map.of(), + RollbackDisposition.NONE + ); + } + + public boolean adopted() { + return origin != Origin.CREATED; + } + + private static Map immutableHashes(Map hashes) { + if (hashes == null || hashes.isEmpty()) { + return Map.of(); + } + TreeMap ordered = new TreeMap<>(); + for (Map.Entry entry : hashes.entrySet()) { + String relativePath = StructureResourceBundle.validateRelativePath(entry.getKey()); + String hash = Objects.requireNonNull(entry.getValue(), "source resource hash"); + requireHash(hash, "source resource"); + ordered.put(relativePath, hash); + } + return Collections.unmodifiableMap(ordered); + } + + private static Map immutableMappings(Map mappings) { + if (mappings == null || mappings.isEmpty()) { + return Map.of(); + } + TreeMap ordered = new TreeMap<>(); + for (Map.Entry entry : mappings.entrySet()) { + String sourcePath = StructureResourceBundle.validateRelativePath(entry.getKey()); + String targetPath = StructureResourceBundle.validateRelativePath( + Objects.requireNonNull(entry.getValue(), "target resource path")); + ordered.put(sourcePath, targetPath); + } + return Collections.unmodifiableMap(ordered); + } + + private static void requireUuid(String value) { + try { + UUID.fromString(value); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Adoption receipt ID must be a UUID", exception); + } + } + + private static void requireHash(String value, String kind) { + if (!StructureHash.isSha256(value)) { + throw new IllegalArgumentException("Adoption " + kind + " hash must be SHA-256"); + } + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } + } + + public enum Origin { + CREATED, + ADOPTED_EXISTING, + ADOPTED_CLONE, + ADOPTED_MANAGED_CLONE, + CONVERTED, + MANAGED_DATAPACK + } + + public enum RollbackDisposition { + NONE, + DELETE_CREATED_IF_UNCHANGED + } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionReadSet.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionReadSet.java new file mode 100644 index 000000000..fdb9493b0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionReadSet.java @@ -0,0 +1,147 @@ +package art.arcane.iris.core.structure.authoring; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +public record StructureTransactionReadSet( + Map fileHashes, + Set absentPaths, + Map> directoryEntries +) { + public static final int MAX_ENTRIES = 100_000; + + public StructureTransactionReadSet { + Objects.requireNonNull(fileHashes, "fileHashes"); + Objects.requireNonNull(absentPaths, "absentPaths"); + Objects.requireNonNull(directoryEntries, "directoryEntries"); + TreeMap orderedHashes = new TreeMap<>(); + for (Map.Entry entry : fileHashes.entrySet()) { + String path = validatePath(entry.getKey(), false); + String hash = Objects.requireNonNull(entry.getValue(), "read-set file hash"); + if (!StructureHash.isSha256(hash)) { + throw new IllegalArgumentException("Read-set file hash must be SHA-256 for " + path); + } + orderedHashes.put(path, hash); + } + TreeSet orderedAbsent = new TreeSet<>(); + for (String path : absentPaths) { + orderedAbsent.add(validatePath(path, false)); + } + for (String path : orderedHashes.keySet()) { + if (orderedAbsent.contains(path)) { + throw new IllegalArgumentException("Read-set path cannot be both present and absent: " + path); + } + } + TreeMap> orderedDirectories = new TreeMap<>(); + TreeSet uniqueEntries = new TreeSet<>(orderedHashes.keySet()); + uniqueEntries.addAll(orderedAbsent); + for (Map.Entry> entry : directoryEntries.entrySet()) { + String directory = validatePath(entry.getKey(), true); + uniqueEntries.add(directory); + Objects.requireNonNull(entry.getValue(), "read-set directory entries"); + TreeSet entries = new TreeSet<>(); + for (String child : entry.getValue()) { + String childPath = validatePath(child, false); + if (!childPath.startsWith(directory + "/")) { + throw new IllegalArgumentException( + "Read-set directory entry is outside " + directory + ": " + childPath); + } + entries.add(childPath); + } + uniqueEntries.addAll(entries); + orderedDirectories.put(directory, List.copyOf(entries)); + } + if (uniqueEntries.size() > MAX_ENTRIES) { + throw new IllegalArgumentException("Read set exceeds " + MAX_ENTRIES + " entries"); + } + fileHashes = Collections.unmodifiableMap(orderedHashes); + absentPaths = Collections.unmodifiableSet(orderedAbsent); + directoryEntries = Collections.unmodifiableMap(orderedDirectories); + } + + public static StructureTransactionReadSet empty() { + return new StructureTransactionReadSet(Map.of(), Set.of(), Map.of()); + } + + public boolean isEmpty() { + return fileHashes.isEmpty() && absentPaths.isEmpty() && directoryEntries.isEmpty(); + } + + public List paths() { + TreeSet paths = new TreeSet<>(fileHashes.keySet()); + paths.addAll(absentPaths); + for (List entries : directoryEntries.values()) { + paths.addAll(entries); + } + return List.copyOf(paths); + } + + public static Builder builder() { + return new Builder(); + } + + private static String validatePath(String value, boolean directory) { + Objects.requireNonNull(value, "read-set path"); + String normalized = value.trim(); + if (normalized.isEmpty() || !normalized.equals(value) || normalized.startsWith("/") + || normalized.endsWith("/") || normalized.indexOf('\\') >= 0 || normalized.contains(":")) { + throw new IllegalArgumentException("Invalid read-set path: " + value); + } + Path path = Path.of(normalized).normalize(); + if (path.isAbsolute() || path.getNameCount() == 0 || path.startsWith("..") + || path.toString().equals(".")) { + throw new IllegalArgumentException("Invalid read-set path: " + value); + } + String portable = path.toString().replace('\\', '/'); + if (!portable.equals(normalized)) { + throw new IllegalArgumentException("Read-set path is not normalized: " + value); + } + if (!directory && normalized.endsWith("/.")) { + throw new IllegalArgumentException("Invalid read-set file path: " + value); + } + return normalized; + } + + public static final class Builder { + private final Map fileHashes = new TreeMap<>(); + private final Set absentPaths = new TreeSet<>(); + private final Map> directoryEntries = new TreeMap<>(); + + public Builder file(String relativePath, String contentHash) { + fileHashes.put(relativePath, contentHash); + return this; + } + + public Builder files(Map hashes) { + fileHashes.putAll(Objects.requireNonNull(hashes, "read-set files")); + return this; + } + + public Builder absent(String relativePath) { + absentPaths.add(relativePath); + return this; + } + + public Builder absent(Collection relativePaths) { + absentPaths.addAll(Objects.requireNonNull(relativePaths, "absent read-set paths")); + return this; + } + + public Builder directory(String relativePath, Collection entries) { + directoryEntries.put(relativePath, new ArrayList<>(Objects.requireNonNull(entries, "directory entries"))); + return this; + } + + public StructureTransactionReadSet build() { + return new StructureTransactionReadSet(fileHashes, absentPaths, directoryEntries); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java index 302c42a1c..a8bba28d5 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriter.java @@ -36,6 +36,7 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.Collections; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -57,7 +58,10 @@ public final class StructureTransactionWriter { private static final int MAX_RECOVERY_CLAIM_BYTES = 64 * 1024; private static final int MAX_COORDINATOR_JOURNAL_BYTES = 4 * 1024 * 1024; private static final int MAX_STRUCTURE_STATE_BYTES = 64 * 1024 * 1024; + private static final long MAX_VERIFIED_READ_FILE_BYTES = 256L * 1024L * 1024L; private static final int MAX_RECOVERY_TRANSACTIONS = 1_024; + private static final LockedRemovalValidator NO_REMOVAL_VALIDATION = () -> { + }; private static final ConcurrentMap ROOT_LOCKS = new ConcurrentHashMap<>(); private static final Gson GSON = new Gson(); @@ -139,7 +143,23 @@ public final class StructureTransactionWriter { } public boolean removeOwned(StructureKey key, StructureSource.Kind sourceKind, StructureKey sourceKey) throws IOException { - OwnedRemoval request = new OwnedRemoval(key, sourceKind, sourceKey); + return removeOwned(new OwnedRemoval( + key, + sourceKind, + sourceKey, + Optional.empty(), + Optional.empty())); + } + + public boolean removeManagedDatapackOwned( + StructureKey key, + StructureSource.Kind sourceKind, + StructureKey sourceKey + ) throws IOException { + return removeOwned(OwnedRemoval.managedDatapack(key, sourceKind, sourceKey)); + } + + private boolean removeOwned(OwnedRemoval request) throws IOException { try (PreparedRemoval removal = prepareOwnedRemovals(List.of(request))) { boolean changed = removal.changed(); removal.markCommitted(); @@ -149,19 +169,30 @@ public final class StructureTransactionWriter { } public PreparedRemoval prepareOwnedRemovals(List removals) throws IOException { - return prepareOwnedRemovals(removals, false); + return prepareOwnedRemovals(removals, NO_REMOVAL_VALIDATION); + } + + public PreparedRemoval prepareOwnedRemovals( + List removals, + LockedRemovalValidator validator + ) throws IOException { + return prepareOwnedRemovals(removals, false, validator); } public PreparedRemoval prepareMatchingOwnedRemovals(List removals) throws IOException { - return prepareOwnedRemovals(removals, true); + return prepareOwnedRemovals(removals, true, NO_REMOVAL_VALIDATION); } private PreparedRemoval prepareOwnedRemovals( List removals, - boolean skipOwnershipMismatches + boolean skipOwnershipMismatches, + LockedRemovalValidator validator ) throws IOException { Objects.requireNonNull(removals, "removals"); List requests = List.copyOf(removals); + LockedRemovalValidator requiredValidator = Objects.requireNonNull( + validator, + "locked removal validator"); rootLock.lock(); ProcessLock processLock = null; Path transactionRoot = null; @@ -172,6 +203,7 @@ public final class StructureTransactionWriter { if (!recovery.successful()) { throw recoveryFailure(recovery); } + requiredValidator.validate(); RemovalPlan plan = buildRemovalPlan(requests, skipOwnershipMismatches); if (plan.targets().isEmpty()) { return new PreparedRemoval(null, null, backups, processLock, false); @@ -216,13 +248,40 @@ public final class StructureTransactionWriter { public record OwnedRemoval( StructureKey key, StructureSource.Kind sourceKind, - StructureKey sourceKey + StructureKey sourceKey, + Optional requiredOrigin, + Optional expectedManifestHash ) { public OwnedRemoval { Objects.requireNonNull(key, "key"); Objects.requireNonNull(sourceKind, "sourceKind"); Objects.requireNonNull(sourceKey, "sourceKey"); + requiredOrigin = Objects.requireNonNull(requiredOrigin, "requiredOrigin"); + expectedManifestHash = Objects.requireNonNull(expectedManifestHash, "expectedManifestHash"); + if (expectedManifestHash.isPresent() + && !StructureHash.isSha256(expectedManifestHash.get())) { + throw new IllegalArgumentException("Expected ownership manifest hash must be SHA-256"); + } } + + public static OwnedRemoval managedDatapack( + StructureKey key, + StructureSource.Kind sourceKind, + StructureKey sourceKey + ) { + return new OwnedRemoval( + key, + sourceKind, + sourceKey, + Optional.of(StructureOwnershipManifest.Origin.MANAGED_DATAPACK), + Optional.empty() + ); + } + } + + @FunctionalInterface + public interface LockedRemovalValidator { + void validate() throws IOException; } public record PreparedRemovalToken(Path packRoot, UUID transactionId) { @@ -491,16 +550,121 @@ public final class StructureTransactionWriter { return write(bundle, StructureWriteOptions.preview(mode)); } + public StructureWriteResult claimExisting( + StructureOwnershipManifest manifest, + StructureTransactionReadSet readSet + ) { + Objects.requireNonNull(manifest, "manifest"); + Objects.requireNonNull(readSet, "readSet"); + for (Map.Entry resource : manifest.resourceHashes().entrySet()) { + String expectedHash = readSet.fileHashes().get(resource.getKey()); + if (!resource.getValue().equals(expectedHash)) { + throw new IllegalArgumentException( + "Claim read set does not pin owned resource " + resource.getKey()); + } + } + rootLock.lock(); + try (ProcessLock ignored = acquireProcessLock()) { + StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked(); + if (!recovery.successful()) { + return claimResult( + StructureWriteResult.Status.FAILED, + manifest, + List.of(), + Optional.of(recoveryFailure(recovery)) + ); + } + List conflicts = verifyReadSet(readSet); + Path manifestPath = ownershipManifestPath(manifest.structure()); + if (files.exists(manifestPath)) { + ArrayList updated = new ArrayList<>(conflicts); + updated.add(StructureWriteResult.Conflict.at( + manifest.relativePath(), + StructureWriteResult.ConflictReason.MANIFEST_EXISTS)); + conflicts = orderedConflicts(updated); + } + if (!conflicts.isEmpty()) { + StructureWriteResult.Status status = conflicts.stream().anyMatch(conflict -> + conflict.reason() == StructureWriteResult.ConflictReason.MANIFEST_EXISTS) + ? StructureWriteResult.Status.ADD_ONLY_CONFLICT + : StructureWriteResult.Status.OWNERSHIP_CONFLICT; + return claimResult(status, manifest, conflicts, Optional.empty()); + } + return commitClaim(manifest, readSet); + } catch (IOException | RuntimeException exception) { + return claimResult( + StructureWriteResult.Status.FAILED, + manifest, + List.of(), + Optional.of(exception) + ); + } finally { + rootLock.unlock(); + } + } + public StructureWriteResult write(StructureResourceBundle bundle, StructureWriteOptions options) { + return writeVerified( + bundle, + options, + StructureTransactionReadSet.empty(), + StructureOwnershipManifest.Provenance.created() + ); + } + + public StructureWriteResult writeManagedDatapack( + StructureResourceBundle bundle, + StructureWriteMode mode + ) { + Objects.requireNonNull(bundle, "bundle"); + Objects.requireNonNull(mode, "mode"); + if (bundle.source().kind() != StructureSource.Kind.DATAPACK + && bundle.source().kind() != StructureSource.Kind.VANILLA) { + throw new IllegalArgumentException("Managed datapack writes require a datapack or vanilla source"); + } + return writeVerified( + bundle, + new StructureWriteOptions(mode, false), + StructureTransactionReadSet.empty(), + managedDatapackProvenance(bundle), + ExistingProvenancePolicy.INSTALL_MANAGED_DATAPACK + ); + } + + public StructureWriteResult writeVerified( + StructureResourceBundle bundle, + StructureWriteOptions options, + StructureTransactionReadSet readSet, + StructureOwnershipManifest.Provenance provenance + ) { + return writeVerified( + bundle, + options, + readSet, + provenance, + ExistingProvenancePolicy.PRESERVE + ); + } + + private StructureWriteResult writeVerified( + StructureResourceBundle bundle, + StructureWriteOptions options, + StructureTransactionReadSet readSet, + StructureOwnershipManifest.Provenance provenance, + ExistingProvenancePolicy provenancePolicy + ) { Objects.requireNonNull(bundle, "bundle"); Objects.requireNonNull(options, "options"); + Objects.requireNonNull(readSet, "readSet"); + Objects.requireNonNull(provenance, "provenance"); + Objects.requireNonNull(provenancePolicy, "provenancePolicy"); rootLock.lock(); try { if (options.dryRun()) { - return writeLocked(bundle, options); + return writeLocked(bundle, options, readSet, provenance, provenancePolicy); } try (ProcessLock ignored = acquireProcessLock()) { - return writeLocked(bundle, options); + return writeLocked(bundle, options, readSet, provenance, provenancePolicy); } } catch (IOException | RuntimeException e) { return failedResult(bundle, e); @@ -509,7 +673,13 @@ public final class StructureTransactionWriter { } } - private StructureWriteResult writeLocked(StructureResourceBundle bundle, StructureWriteOptions options) + private StructureWriteResult writeLocked( + StructureResourceBundle bundle, + StructureWriteOptions options, + StructureTransactionReadSet readSet, + StructureOwnershipManifest.Provenance provenance, + ExistingProvenancePolicy provenancePolicy + ) throws IOException { if (!options.dryRun()) { StructureRecoveryResult recovery = recoverIncompleteTransactionsLocked(); @@ -517,7 +687,11 @@ public final class StructureTransactionWriter { return failedResult(bundle, recoveryFailure(recovery)); } } - WritePlan plan = buildPlan(bundle, options.mode()); + List readSetConflicts = verifyReadSet(readSet); + if (!readSetConflicts.isEmpty()) { + return readSetConflictResult(bundle, readSetConflicts); + } + WritePlan plan = buildPlan(bundle, options, provenance, provenancePolicy); if (!plan.conflicts().isEmpty()) { StructureWriteResult.Status status = options.mode() == StructureWriteMode.ADD_ONLY ? StructureWriteResult.Status.ADD_ONLY_CONFLICT @@ -530,7 +704,7 @@ public final class StructureTransactionWriter { if (plan.action() == StructureWriteResult.Action.NONE) { return result(StructureWriteResult.Status.UNCHANGED, plan, Optional.empty()); } - return commit(plan); + return commit(plan, readSet); } private RemovalPlan buildRemovalPlan( @@ -551,6 +725,12 @@ public final class StructureTransactionWriter { MAX_STRUCTURE_STATE_BYTES, "Structure ownership manifest" ); + String manifestHash = StructureHash.sha256(manifestContent); + if (removal.expectedManifestHash().isPresent() + && !removal.expectedManifestHash().get().equals(manifestHash)) { + throw new IOException("Structure ownership manifest changed after removal was planned: " + + removal.key()); + } StructureOwnershipManifest manifest; try { manifest = StructureOwnershipManifest.fromJson(manifestContent); @@ -560,11 +740,18 @@ public final class StructureTransactionWriter { if (!manifest.structure().equals(removal.key())) { throw new IOException("Structure ownership manifest belongs to " + manifest.structure()); } - if (manifest.source().kind() != removal.sourceKind() - || !manifest.source().key().equals(removal.sourceKey())) { + boolean sourceMismatch = manifest.source().kind() != removal.sourceKind() + || !manifest.source().key().equals(removal.sourceKey()); + boolean originMismatch = removal.requiredOrigin().isPresent() + && manifest.provenance().origin() != removal.requiredOrigin().get(); + if (sourceMismatch || originMismatch) { if (skipOwnershipMismatches) { continue; } + if (originMismatch) { + throw new IOException("Structure '" + removal.key() + + "' is not owned by managed datapack ingest"); + } throw new IOException("Structure '" + removal.key() + "' is owned by source " + manifest.source().key() + " (" + manifest.source().kind() + "), not " + removal.sourceKey() + " (" + removal.sourceKind() + ")"); @@ -1036,8 +1223,61 @@ public final class StructureTransactionWriter { return failure; } - private WritePlan buildPlan(StructureResourceBundle bundle, StructureWriteMode mode) throws IOException { - StructureOwnershipManifest nextManifest = StructureOwnershipManifest.from(bundle); + private StructureOwnershipManifest.Provenance managedDatapackProvenance( + StructureResourceBundle bundle + ) { + TreeMap hashes = new TreeMap<>(); + TreeMap mappings = new TreeMap<>(); + StringBuilder closure = new StringBuilder(); + for (StructureResourceBundle.Resource resource : bundle.resources().values()) { + hashes.put(resource.relativePath(), resource.contentHash()); + mappings.put(resource.relativePath(), resource.relativePath()); + closure.append(resource.relativePath()) + .append('=') + .append(resource.contentHash()) + .append('\n'); + } + String closureHash = StructureHash.sha256(closure.toString().getBytes(StandardCharsets.UTF_8)); + StructureSource source = bundle.source(); + String planInput = "managed-datapack\n" + + source.kind() + '\n' + + source.key().value() + '\n' + + source.version() + '\n' + + source.contentHash() + '\n' + + closureHash; + return new StructureOwnershipManifest.Provenance( + StructureOwnershipManifest.Origin.MANAGED_DATAPACK, + UUID.randomUUID().toString(), + StructureHash.sha256(planInput.getBytes(StandardCharsets.UTF_8)), + closureHash, + Math.max(1L, System.currentTimeMillis()), + hashes, + mappings, + StructureOwnershipManifest.RollbackDisposition.NONE + ); + } + + private boolean managedDatapackUpgradeAllowed( + StructureOwnershipManifest previousManifest, + StructureSource nextSource + ) { + StructureOwnershipManifest.Origin origin = previousManifest.provenance().origin(); + if (origin != StructureOwnershipManifest.Origin.CREATED + && origin != StructureOwnershipManifest.Origin.MANAGED_DATAPACK) { + return false; + } + return previousManifest.source().kind() == nextSource.kind() + && previousManifest.source().key().equals(nextSource.key()); + } + + private WritePlan buildPlan( + StructureResourceBundle bundle, + StructureWriteOptions options, + StructureOwnershipManifest.Provenance provenance, + ExistingProvenancePolicy provenancePolicy + ) throws IOException { + StructureWriteMode mode = options.mode(); + StructureOwnershipManifest nextManifest = StructureOwnershipManifest.from(bundle, provenance); String manifestRelativePath = nextManifest.relativePath(); Path manifestPath = resolveTarget(manifestRelativePath); ArrayList conflicts = new ArrayList<>(); @@ -1061,6 +1301,12 @@ public final class StructureTransactionWriter { } if (!files.exists(manifestPath)) { + if (!options.expectedManifestHash().isEmpty()) { + conflicts.add(StructureWriteResult.Conflict.staleManifest( + manifestRelativePath, + options.expectedManifestHash(), + "")); + } findUnownedResources(bundle, previousResourceHashes, conflicts); return createPlan( bundle, @@ -1085,13 +1331,45 @@ public final class StructureTransactionWriter { ); } - StructureOwnershipManifest previousManifest; + byte[] manifestContent; try { - previousManifest = StructureOwnershipManifest.fromJson(readBoundedBytes( + manifestContent = readBoundedBytes( manifestPath, MAX_STRUCTURE_STATE_BYTES, "Structure ownership manifest" - )); + ); + } catch (IOException exception) { + conflicts.add(StructureWriteResult.Conflict.invalidManifest( + manifestRelativePath, + exception.toString())); + return createPlan( + bundle, + nextManifest, + previousResourceHashes, + StructureWriteResult.Action.OVERWRITE, + conflicts + ); + } + if (!options.expectedManifestHash().isEmpty()) { + String actualManifestHash = StructureHash.sha256(manifestContent); + if (!options.expectedManifestHash().equals(actualManifestHash)) { + conflicts.add(StructureWriteResult.Conflict.staleManifest( + manifestRelativePath, + options.expectedManifestHash(), + actualManifestHash)); + return createPlan( + bundle, + nextManifest, + previousResourceHashes, + StructureWriteResult.Action.OVERWRITE, + conflicts + ); + } + } + + StructureOwnershipManifest previousManifest; + try { + previousManifest = StructureOwnershipManifest.fromJson(manifestContent); } catch (RuntimeException e) { conflicts.add(StructureWriteResult.Conflict.invalidManifest(manifestRelativePath, e.toString())); return createPlan( @@ -1117,6 +1395,24 @@ public final class StructureTransactionWriter { ); } + if (provenancePolicy == ExistingProvenancePolicy.PRESERVE) { + nextManifest = StructureOwnershipManifest.from(bundle, previousManifest.provenance()); + } else if (!managedDatapackUpgradeAllowed(previousManifest, bundle.source())) { + conflicts.add(new StructureWriteResult.Conflict( + manifestRelativePath, + StructureWriteResult.ConflictReason.PROVENANCE_MISMATCH, + "", + "", + "Managed datapack import cannot replace non-managed structure provenance" + )); + return createPlan( + bundle, + nextManifest, + previousResourceHashes, + StructureWriteResult.Action.OVERWRITE, + conflicts + ); + } previousResourceHashes.putAll(previousManifest.resourceHashes()); verifyOwnedResources(previousResourceHashes, conflicts); findUnownedResources(bundle, previousResourceHashes, conflicts); @@ -1205,7 +1501,296 @@ public final class StructureTransactionWriter { ); } - private StructureWriteResult commit(WritePlan plan) { + private List verifyReadSet(StructureTransactionReadSet readSet) { + if (readSet.isEmpty()) { + return List.of(); + } + ArrayList conflicts = new ArrayList<>(); + for (Map.Entry entry : readSet.fileHashes().entrySet()) { + String relativePath = entry.getKey(); + String expectedHash = entry.getValue(); + try { + Path target = resolveTarget(relativePath); + if (!files.exists(target)) { + conflicts.add(StructureWriteResult.Conflict.staleReadSet( + relativePath, + expectedHash, + "", + "Read-set file is missing")); + } else if (!files.isRegularFile(target)) { + conflicts.add(StructureWriteResult.Conflict.staleReadSet( + relativePath, + expectedHash, + "", + "Read-set path is not a regular file")); + } else { + String actualHash = sha256ReadSetTarget(target); + if (!expectedHash.equals(actualHash)) { + conflicts.add(StructureWriteResult.Conflict.staleReadSet( + relativePath, + expectedHash, + actualHash, + "Read-set file content changed")); + } + } + } catch (IOException | RuntimeException exception) { + conflicts.add(StructureWriteResult.Conflict.staleReadSet( + relativePath, + expectedHash, + "", + "Cannot verify read-set file: " + describe(exception))); + } + } + for (String relativePath : readSet.absentPaths()) { + try { + Path target = resolveTarget(relativePath); + if (files.exists(target)) { + conflicts.add(StructureWriteResult.Conflict.staleReadSet( + relativePath, + "", + "", + "Read-set path was expected to remain absent")); + } + } catch (RuntimeException exception) { + conflicts.add(StructureWriteResult.Conflict.staleReadSet( + relativePath, + "", + "", + "Cannot verify absent read-set path: " + describe(exception))); + } + } + for (Map.Entry> entry : readSet.directoryEntries().entrySet()) { + String relativePath = entry.getKey(); + List expectedEntries = entry.getValue(); + try { + List actualEntries = actualDirectoryEntries(relativePath); + if (!expectedEntries.equals(actualEntries)) { + String expectedHash = StructureHash.sha256( + String.join("\n", expectedEntries).getBytes(StandardCharsets.UTF_8)); + String actualHash = StructureHash.sha256( + String.join("\n", actualEntries).getBytes(StandardCharsets.UTF_8)); + conflicts.add(StructureWriteResult.Conflict.staleReadSet( + relativePath, + expectedHash, + actualHash, + "Read-set directory membership changed")); + } + } catch (IOException | RuntimeException exception) { + conflicts.add(StructureWriteResult.Conflict.staleReadSet( + relativePath, + "", + "", + "Cannot verify read-set directory: " + describe(exception))); + } + } + return orderedConflicts(conflicts); + } + + private List actualDirectoryEntries(String relativePath) throws IOException { + Path directory = resolveTarget(relativePath); + if (!files.exists(directory)) { + return List.of(); + } + if (!files.isDirectory(directory)) { + throw new IOException("Read-set directory is not a directory: " + relativePath); + } + ArrayList entries = new ArrayList<>(); + try (Stream paths = Files.walk(directory)) { + Iterator iterator = paths.iterator(); + while (iterator.hasNext()) { + Path path = iterator.next(); + if (path.equals(directory)) { + continue; + } + if (Files.isSymbolicLink(path)) { + throw new IOException("Read-set directory contains a symbolic link: " + path); + } + if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Read-set directory contains a non-file entry: " + path); + } + entries.add(packRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/')); + if (entries.size() > StructureTransactionReadSet.MAX_ENTRIES) { + throw new IOException("Read-set directory exceeds " + + StructureTransactionReadSet.MAX_ENTRIES + " entries"); + } + } + } + Collections.sort(entries); + return List.copyOf(entries); + } + + private String sha256ReadSetTarget(Path target) throws IOException { + try (InputStream input = Files.newInputStream( + target, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS)) { + return StructureHash.sha256(new BoundedReadSetInputStream(input)); + } + } + + private void requireReadSetUnchanged(StructureTransactionReadSet readSet) throws IOException { + List conflicts = verifyReadSet(readSet); + if (conflicts.isEmpty()) { + return; + } + StructureWriteResult.Conflict conflict = conflicts.getFirst(); + throw new ReadSetChangedException(conflict); + } + + private StructureWriteResult readSetConflictResult( + StructureResourceBundle bundle, + List conflicts + ) { + StructureOwnershipManifest manifest = StructureOwnershipManifest.from(bundle); + TreeSet affectedResources = new TreeSet<>(bundle.resources().keySet()); + affectedResources.addAll(conflicts.stream().map(StructureWriteResult.Conflict::relativePath).toList()); + affectedResources.add(manifest.relativePath()); + return new StructureWriteResult( + StructureWriteResult.Status.OWNERSHIP_CONFLICT, + StructureWriteResult.Action.NONE, + orderedConflicts(conflicts), + List.copyOf(affectedResources), + manifest.relativePath(), + Optional.empty() + ); + } + + private List orderedConflicts( + List conflicts + ) { + ArrayList ordered = new ArrayList<>(conflicts); + ordered.sort((left, right) -> left.relativePath().compareTo(right.relativePath())); + return List.copyOf(ordered); + } + + private StructureWriteResult commitClaim( + StructureOwnershipManifest manifest, + StructureTransactionReadSet readSet + ) { + UUID transactionId = UUID.randomUUID(); + Path transactionRoot = stagingRoot().resolve(transactionId.toString()).normalize(); + Path stagedRoot = transactionRoot.resolve("staged"); + Path backupRoot = transactionRoot.resolve("backup"); + Path stagedManifest = stagedRoot.resolve("ownership-manifest.json"); + byte[] manifestContent = manifest.toJson(); + ArrayList installedTargets = new ArrayList<>(); + StructureTransactionJournal journal; + try { + if (manifestContent.length > MAX_STRUCTURE_STATE_BYTES) { + throw new IOException("Structure ownership manifest exceeds " + + MAX_STRUCTURE_STATE_BYTES + " bytes"); + } + files.createDirectories(stagedRoot); + files.createDirectories(backupRoot); + files.writeNew(stagedManifest, manifestContent); + files.forceFile(stagedManifest); + StructureTransactionJournal.Target target = new StructureTransactionJournal.Target( + manifest.relativePath(), + false, + "", + StructureHash.sha256(manifestContent)); + journal = StructureTransactionJournal.prepared(transactionId, List.of(target)); + writeJournal(transactionRoot, journal); + files.forceDirectory(transactionRoot); + files.forceDirectory(stagingRoot()); + requireReadSetUnchanged(readSet); + verifyTargetSnapshot(journal); + Path manifestTarget = resolveTarget(manifest.relativePath()); + files.createDirectories(Objects.requireNonNull(manifestTarget.getParent(), "manifest target parent")); + files.moveNew(stagedManifest, manifestTarget); + installedTargets.add(new InstalledTarget(manifestTarget, StructureHash.sha256(manifestContent))); + files.forceFile(manifestTarget); + files.forceDirectory(Objects.requireNonNull(manifestTarget.getParent(), "manifest target parent")); + } catch (IOException | RuntimeException commitFailure) { + return rollbackClaimResult(manifest, transactionRoot, installedTargets, commitFailure); + } + + boolean committedJournalWritten = false; + try { + writeJournal(transactionRoot, journal.committed()); + committedJournalWritten = true; + files.forceDirectory(transactionRoot); + } catch (IOException | RuntimeException commitPhaseFailure) { + if (committedJournalWritten || isCommittedJournal(transactionRoot, commitPhaseFailure)) { + return claimResult( + StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED, + manifest, + List.of(), + Optional.of(commitPhaseFailure)); + } + return rollbackClaimResult(manifest, transactionRoot, installedTargets, commitPhaseFailure); + } + + try { + cleanupTransaction(transactionRoot); + } catch (IOException | RuntimeException cleanupFailure) { + return claimResult( + StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED, + manifest, + List.of(), + Optional.of(cleanupFailure)); + } + return claimResult( + StructureWriteResult.Status.ADDED, + manifest, + List.of(), + Optional.empty()); + } + + private StructureWriteResult rollbackClaimResult( + StructureOwnershipManifest manifest, + Path transactionRoot, + List installedTargets, + Throwable failure + ) { + Optional rollbackFailure = rollback(Map.of(), installedTargets); + if (rollbackFailure.isPresent()) { + failure.addSuppressed(new IOException( + "Transaction recovery data retained at " + transactionRoot, + rollbackFailure.get())); + return claimResult( + StructureWriteResult.Status.FAILED, + manifest, + List.of(), + Optional.of(failure)); + } + cleanupAfterFailure(transactionRoot, failure); + if (failure instanceof ReadSetChangedException readSetChanged) { + return claimResult( + StructureWriteResult.Status.OWNERSHIP_CONFLICT, + manifest, + List.of(readSetChanged.conflict()), + Optional.empty()); + } + return claimResult( + StructureWriteResult.Status.ROLLED_BACK, + manifest, + List.of(), + Optional.of(failure)); + } + + private StructureWriteResult claimResult( + StructureWriteResult.Status status, + StructureOwnershipManifest manifest, + List conflicts, + Optional failure + ) { + TreeSet affectedResources = new TreeSet<>(manifest.resourceHashes().keySet()); + affectedResources.add(manifest.relativePath()); + return new StructureWriteResult( + status, + StructureWriteResult.Action.ADD, + orderedConflicts(conflicts), + List.copyOf(affectedResources), + manifest.relativePath(), + failure + ); + } + + private StructureWriteResult commit(WritePlan plan, StructureTransactionReadSet readSet) { UUID transactionId = UUID.randomUUID(); Path transactionRoot = stagingRoot().resolve(transactionId.toString()).normalize(); Path stagedRoot = transactionRoot.resolve("staged"); @@ -1223,6 +1808,7 @@ public final class StructureTransactionWriter { writeJournal(transactionRoot, journal); files.forceDirectory(transactionRoot); files.forceDirectory(stagingRoot()); + requireReadSetUnchanged(readSet); verifyTargetSnapshot(journal); backupTargets(journal, backupRoot, backups); installResources(plan, stagedRoot, stagedManifest, installedTargets); @@ -1401,6 +1987,9 @@ public final class StructureTransactionWriter { return result(StructureWriteResult.Status.FAILED, plan, Optional.of(commitFailure)); } cleanupAfterFailure(transactionRoot, commitFailure); + if (commitFailure instanceof ReadSetChangedException readSetChanged) { + return readSetConflictResult(plan.bundle(), List.of(readSetChanged.conflict())); + } return result(StructureWriteResult.Status.ROLLED_BACK, plan, Optional.of(commitFailure)); } @@ -1466,6 +2055,11 @@ public final class StructureTransactionWriter { return existing; } + private String describe(Throwable throwable) { + String message = throwable.getMessage(); + return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message; + } + private void verifyOriginalTarget( Path target, StructureTransactionJournal.Target state @@ -1685,6 +2279,11 @@ public final class StructureTransactionWriter { CLEANED_ORPHAN } + private enum ExistingProvenancePolicy { + PRESERVE, + INSTALL_MANAGED_DATAPACK + } + private record InstalledTarget(Path path, String contentHash) { private InstalledTarget { Objects.requireNonNull(path, "path"); @@ -1694,6 +2293,55 @@ public final class StructureTransactionWriter { } } + private static final class ReadSetChangedException extends IOException { + private final StructureWriteResult.Conflict conflict; + + private ReadSetChangedException(StructureWriteResult.Conflict conflict) { + super("Structure transaction read set changed at " + conflict.relativePath() + + ": " + conflict.detail()); + this.conflict = conflict; + } + + private StructureWriteResult.Conflict conflict() { + return conflict; + } + } + + private static final class BoundedReadSetInputStream extends InputStream { + private final InputStream delegate; + private long consumed; + + private BoundedReadSetInputStream(InputStream delegate) { + this.delegate = delegate; + } + + @Override + public int read() throws IOException { + int value = delegate.read(); + if (value >= 0) { + recordBytes(1L); + } + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int read = delegate.read(buffer, offset, length); + if (read > 0) { + recordBytes(read); + } + return read; + } + + private void recordBytes(long bytes) throws IOException { + consumed = Math.addExact(consumed, bytes); + if (consumed > MAX_VERIFIED_READ_FILE_BYTES) { + throw new IOException("Verified read-set file exceeds " + + MAX_VERIFIED_READ_FILE_BYTES + " bytes"); + } + } + } + private record RemovalPlan(List targets) { } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteOptions.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteOptions.java index d850a82fa..976c03fa3 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteOptions.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteOptions.java @@ -20,9 +20,20 @@ package art.arcane.iris.core.structure.authoring; import java.util.Objects; -public record StructureWriteOptions(StructureWriteMode mode, boolean dryRun) { +public record StructureWriteOptions(StructureWriteMode mode, boolean dryRun, String expectedManifestHash) { public StructureWriteOptions { Objects.requireNonNull(mode, "mode"); + expectedManifestHash = expectedManifestHash == null ? "" : expectedManifestHash.trim(); + if (!expectedManifestHash.isEmpty() && !StructureHash.isSha256(expectedManifestHash)) { + throw new IllegalArgumentException("Expected structure manifest hash must be SHA-256"); + } + if (!expectedManifestHash.isEmpty() && mode != StructureWriteMode.OVERWRITE) { + throw new IllegalArgumentException("Expected structure manifest hashes require overwrite mode"); + } + } + + public StructureWriteOptions(StructureWriteMode mode, boolean dryRun) { + this(mode, dryRun, ""); } public static StructureWriteOptions addOnly() { @@ -36,4 +47,8 @@ public record StructureWriteOptions(StructureWriteMode mode, boolean dryRun) { public static StructureWriteOptions preview(StructureWriteMode mode) { return new StructureWriteOptions(mode, true); } + + public static StructureWriteOptions overwriteExpected(String manifestHash) { + return new StructureWriteOptions(StructureWriteMode.OVERWRITE, false, manifestHash); + } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteResult.java b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteResult.java index ea89238cb..3826f9593 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteResult.java +++ b/core/src/main/java/art/arcane/iris/core/structure/authoring/StructureWriteResult.java @@ -80,7 +80,10 @@ public record StructureWriteResult( MODIFIED_RESOURCE, MISSING_OWNED_RESOURCE, NON_FILE_RESOURCE, - INVALID_MANIFEST + INVALID_MANIFEST, + PROVENANCE_MISMATCH, + STALE_MANIFEST, + STALE_READ_SET } public record Conflict( @@ -115,5 +118,30 @@ public record StructureWriteResult( public static Conflict invalidManifest(String relativePath, String detail) { return new Conflict(relativePath, ConflictReason.INVALID_MANIFEST, "", "", detail); } + + public static Conflict staleManifest(String relativePath, String expectedHash, String actualHash) { + return new Conflict( + relativePath, + ConflictReason.STALE_MANIFEST, + expectedHash, + actualHash, + "Ownership manifest changed after the graph edit was loaded" + ); + } + + public static Conflict staleReadSet( + String relativePath, + String expectedHash, + String actualHash, + String detail + ) { + return new Conflict( + relativePath, + ConflictReason.STALE_READ_SET, + expectedHash, + actualHash, + detail + ); + } } } diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDiagnostic.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDiagnostic.java new file mode 100644 index 000000000..7ef7c495d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDiagnostic.java @@ -0,0 +1,88 @@ +package art.arcane.iris.core.structure.conversion; + +import java.util.Objects; + +public record IrisStructureAdoptionDiagnostic( + Severity severity, + Code code, + String resource, + String detail, + String recommendation +) implements Comparable { + public IrisStructureAdoptionDiagnostic { + Objects.requireNonNull(severity, "severity"); + Objects.requireNonNull(code, "code"); + resource = normalize(resource); + detail = requireText(detail, "detail"); + recommendation = normalize(recommendation); + } + + public String summary() { + String location = resource.isEmpty() ? "" : " [" + resource + "]"; + String action = recommendation.isEmpty() ? "" : " " + recommendation; + return severity + " " + code + location + ": " + detail + action; + } + + public boolean blocking() { + return severity == Severity.ERROR; + } + + @Override + public int compareTo(IrisStructureAdoptionDiagnostic other) { + int severityComparison = severity.compareTo(other.severity); + if (severityComparison != 0) { + return severityComparison; + } + int codeComparison = code.compareTo(other.code); + if (codeComparison != 0) { + return codeComparison; + } + int resourceComparison = resource.compareTo(other.resource); + if (resourceComparison != 0) { + return resourceComparison; + } + return detail.compareTo(other.detail); + } + + private static String requireText(String value, String name) { + String normalized = normalize(value); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("Adoption diagnostic " + name + " cannot be blank"); + } + return normalized; + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } + + public enum Severity { + ERROR, + WARNING, + INFO + } + + public enum Code { + SOURCE_GRAPH_INVALID, + SOURCE_ALREADY_OWNED, + SOURCE_RESOURCE_UNSAFE, + SOURCE_RESOURCE_LIMIT, + SOURCE_RESOURCE_CHANGED, + TARGET_NAMESPACE_UNSUPPORTED, + TARGET_RESOURCE_EXISTS, + TARGET_RESOURCE_UNSAFE, + TARGET_MAPPING_COLLISION, + TARGET_REQUIRED_FOR_CLONE, + MANAGED_INPUT_REQUIRES_CLONE, + SHARED_DEPENDENCY, + EXCLUSIVITY_UNPROVEN, + IN_PLACE_AVAILABLE, + CLONE_SELECTED, + PLAN_BLOCKED, + PLAN_EXPIRED, + PLAN_UNKNOWN, + PLAN_STALE, + TRANSACTION_FAILED, + APPLIED + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDisposition.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDisposition.java new file mode 100644 index 000000000..9d90e5a74 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionDisposition.java @@ -0,0 +1,7 @@ +package art.arcane.iris.core.structure.conversion; + +public enum IrisStructureAdoptionDisposition { + IN_PLACE, + CLONE_REQUIRED, + BLOCKED +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionInputKind.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionInputKind.java new file mode 100644 index 000000000..ee40b8d36 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionInputKind.java @@ -0,0 +1,6 @@ +package art.arcane.iris.core.structure.conversion; + +public enum IrisStructureAdoptionInputKind { + UNOWNED_IRIS, + MANAGED_DATAPACK +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionLimits.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionLimits.java new file mode 100644 index 000000000..1273b2ea4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionLimits.java @@ -0,0 +1,56 @@ +package art.arcane.iris.core.structure.conversion; + +import java.time.Duration; +import java.util.Objects; + +public record IrisStructureAdoptionLimits( + int maxResources, + int maxJsonBytes, + int maxBinaryBytes, + long maxTotalBytes, + int maxStructuresScanned, + int maxDiagnostics, + int maxActivePlans, + Duration planTtl +) { + public IrisStructureAdoptionLimits { + if (maxResources < 1 || maxResources > 100_000) { + throw new IllegalArgumentException("Adoption resource limit must be between 1 and 100000"); + } + if (maxJsonBytes < 1 || maxJsonBytes > 64 * 1024 * 1024) { + throw new IllegalArgumentException("Adoption JSON limit must be between 1 and 67108864 bytes"); + } + if (maxBinaryBytes < 1 || maxBinaryBytes > 256 * 1024 * 1024) { + throw new IllegalArgumentException("Adoption binary limit must be between 1 and 268435456 bytes"); + } + if (maxTotalBytes < 1L || maxTotalBytes > 1024L * 1024L * 1024L) { + throw new IllegalArgumentException("Adoption aggregate limit must be between 1 and 1073741824 bytes"); + } + if (maxStructuresScanned < 1 || maxStructuresScanned > 100_000) { + throw new IllegalArgumentException("Adoption structure scan limit must be between 1 and 100000"); + } + if (maxDiagnostics < 1 || maxDiagnostics > 10_000) { + throw new IllegalArgumentException("Adoption diagnostic limit must be between 1 and 10000"); + } + if (maxActivePlans < 1 || maxActivePlans > 10_000) { + throw new IllegalArgumentException("Adoption active-plan limit must be between 1 and 10000"); + } + planTtl = Objects.requireNonNull(planTtl, "planTtl"); + if (planTtl.isNegative() || planTtl.isZero() || planTtl.compareTo(Duration.ofHours(24L)) > 0) { + throw new IllegalArgumentException("Adoption plan TTL must be between one nanosecond and 24 hours"); + } + } + + public static IrisStructureAdoptionLimits defaults() { + return new IrisStructureAdoptionLimits( + 10_000, + 8 * 1024 * 1024, + 64 * 1024 * 1024, + 1024L * 1024L * 1024L, + 10_000, + 1_000, + 1_024, + Duration.ofMinutes(15L) + ); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionPlan.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionPlan.java new file mode 100644 index 000000000..e0d3a56f8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionPlan.java @@ -0,0 +1,107 @@ +package art.arcane.iris.core.structure.conversion; + +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureTransactionReadSet; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.UUID; + +public record IrisStructureAdoptionPlan( + UUID planId, + Instant createdAt, + Instant expiresAt, + IrisStructureAdoptionRequest request, + StructureKey targetStructure, + IrisStructureAdoptionDisposition disposition, + List diagnostics, + Map sourceResourceHashes, + Map sourceToTargetPaths, + StructureTransactionReadSet readSet, + long totalSourceBytes, + String sourceClosureHash, + String planHash +) { + public IrisStructureAdoptionPlan { + Objects.requireNonNull(planId, "planId"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(targetStructure, "targetStructure"); + Objects.requireNonNull(disposition, "disposition"); + Objects.requireNonNull(diagnostics, "diagnostics"); + diagnostics = sortedDiagnostics(diagnostics); + sourceResourceHashes = immutableMap(sourceResourceHashes); + sourceToTargetPaths = immutableMap(sourceToTargetPaths); + readSet = Objects.requireNonNull(readSet, "readSet"); + if (!expiresAt.isAfter(createdAt)) { + throw new IllegalArgumentException("Adoption plan expiry must be after creation"); + } + if (totalSourceBytes < 0L) { + throw new IllegalArgumentException("Adoption plan source bytes cannot be negative"); + } + requireHash(sourceClosureHash, "source closure"); + requireHash(planHash, "plan"); + if (sourceResourceHashes.isEmpty() && disposition != IrisStructureAdoptionDisposition.BLOCKED) { + throw new IllegalArgumentException("Applicable adoption plan requires source resources"); + } + } + + public boolean canApply() { + return disposition != IrisStructureAdoptionDisposition.BLOCKED + && diagnostics.stream().noneMatch(IrisStructureAdoptionDiagnostic::blocking); + } + + public boolean expiredAt(Instant instant) { + return !Objects.requireNonNull(instant, "instant").isBefore(expiresAt); + } + + public int resourceCount() { + return sourceResourceHashes.size(); + } + + public long errorCount() { + return diagnostics.stream().filter(IrisStructureAdoptionDiagnostic::blocking).count(); + } + + public long warningCount() { + return diagnostics.stream().filter(diagnostic -> + diagnostic.severity() == IrisStructureAdoptionDiagnostic.Severity.WARNING).count(); + } + + public List summaryLines() { + ArrayList lines = new ArrayList<>(); + lines.add("Plan " + planId + " -> " + disposition + " for " + targetStructure.value()); + lines.add(resourceCount() + " resources, " + totalSourceBytes + " bytes, " + + errorCount() + " errors, " + warningCount() + " warnings"); + for (IrisStructureAdoptionDiagnostic diagnostic : diagnostics) { + lines.add(diagnostic.summary()); + } + return List.copyOf(lines); + } + + private static List sortedDiagnostics( + List values + ) { + ArrayList ordered = new ArrayList<>(values); + ordered.sort(IrisStructureAdoptionDiagnostic::compareTo); + return List.copyOf(ordered); + } + + private static Map immutableMap(Map values) { + Objects.requireNonNull(values, "plan values"); + return Collections.unmodifiableMap(new TreeMap<>(values)); + } + + private static void requireHash(String value, String kind) { + if (!StructureHash.isSha256(value)) { + throw new IllegalArgumentException("Adoption " + kind + " hash must be SHA-256"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionReceipt.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionReceipt.java new file mode 100644 index 000000000..e6ef0af02 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionReceipt.java @@ -0,0 +1,82 @@ +package art.arcane.iris.core.structure.conversion; + +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; + +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.UUID; + +public record IrisStructureAdoptionReceipt( + UUID receiptId, + UUID planId, + StructureOwnershipManifest.Origin origin, + Instant appliedAt, + StructureKey sourceStructure, + StructureKey targetStructure, + String sourceClosureHash, + String planHash, + Map sourceResourceHashes, + Map targetResourceHashes, + Map sourceToTargetPaths, + StructureOwnershipManifest.RollbackDisposition rollbackDisposition +) { + public IrisStructureAdoptionReceipt { + Objects.requireNonNull(receiptId, "receiptId"); + Objects.requireNonNull(planId, "planId"); + Objects.requireNonNull(origin, "origin"); + Objects.requireNonNull(appliedAt, "appliedAt"); + Objects.requireNonNull(sourceStructure, "sourceStructure"); + Objects.requireNonNull(targetStructure, "targetStructure"); + requireHash(sourceClosureHash, "source closure"); + requireHash(planHash, "plan"); + sourceResourceHashes = immutableMap(sourceResourceHashes, true); + targetResourceHashes = immutableMap(targetResourceHashes, true); + sourceToTargetPaths = immutableMap(sourceToTargetPaths, false); + rollbackDisposition = Objects.requireNonNull(rollbackDisposition, "rollbackDisposition"); + if (sourceResourceHashes.isEmpty() || targetResourceHashes.isEmpty() || sourceToTargetPaths.isEmpty()) { + throw new IllegalArgumentException("Adoption receipt requires source, target, and mapping entries"); + } + } + + public StructureOwnershipManifest.Provenance provenance() { + return new StructureOwnershipManifest.Provenance( + origin, + receiptId.toString(), + planHash, + sourceClosureHash, + appliedAt.toEpochMilli(), + sourceResourceHashes, + sourceToTargetPaths, + rollbackDisposition + ); + } + + public boolean rollbackAvailable() { + return rollbackDisposition != StructureOwnershipManifest.RollbackDisposition.NONE; + } + + private static Map immutableMap(Map values, boolean hashes) { + Objects.requireNonNull(values, "receipt values"); + TreeMap ordered = new TreeMap<>(); + for (Map.Entry entry : values.entrySet()) { + String key = Objects.requireNonNull(entry.getKey(), "receipt map key"); + String value = Objects.requireNonNull(entry.getValue(), "receipt map value"); + if (hashes) { + requireHash(value, "resource"); + } + ordered.put(key, value); + } + return Collections.unmodifiableMap(ordered); + } + + private static void requireHash(String value, String kind) { + if (!StructureHash.isSha256(value)) { + throw new IllegalArgumentException("Adoption receipt " + kind + " hash must be SHA-256"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionRequest.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionRequest.java new file mode 100644 index 000000000..1279b54d5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionRequest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.core.structure.conversion; + +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; + +import java.util.Objects; +import java.util.Optional; + +public record IrisStructureAdoptionRequest( + String sourceStructure, + Optional requestedTarget, + IrisStructureAdoptionStrategy strategy, + IrisStructureAdoptionInputKind inputKind +) { + public IrisStructureAdoptionRequest { + sourceStructure = requireInternalKey(sourceStructure); + requestedTarget = Objects.requireNonNull(requestedTarget, "requestedTarget"); + strategy = Objects.requireNonNull(strategy, "strategy"); + inputKind = Objects.requireNonNull(inputKind, "inputKind"); + } + + public static IrisStructureAdoptionRequest unowned(String sourceStructure) { + return new IrisStructureAdoptionRequest( + sourceStructure, + Optional.empty(), + IrisStructureAdoptionStrategy.AUTO, + IrisStructureAdoptionInputKind.UNOWNED_IRIS + ); + } + + public static IrisStructureAdoptionRequest cloneTo(String sourceStructure, StructureKey target) { + return new IrisStructureAdoptionRequest( + sourceStructure, + Optional.of(Objects.requireNonNull(target, "target")), + IrisStructureAdoptionStrategy.CLONE, + IrisStructureAdoptionInputKind.UNOWNED_IRIS + ); + } + + public StructureKey sourceOwnershipKey() { + return new StructureKey("iris", sourceStructure); + } + + private static String requireInternalKey(String value) { + Objects.requireNonNull(value, "sourceStructure"); + String normalized = value.trim(); + if (!normalized.equals(value)) { + throw new IllegalArgumentException("Source structure key cannot contain surrounding whitespace"); + } + StructureResourceBundle.validateRelativePath("structures/" + normalized + ".json"); + new StructureKey("iris", normalized); + return normalized; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionResult.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionResult.java new file mode 100644 index 000000000..2ad166805 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionResult.java @@ -0,0 +1,54 @@ +package art.arcane.iris.core.structure.conversion; + +import art.arcane.iris.core.structure.authoring.StructureWriteResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public record IrisStructureAdoptionResult( + Status status, + UUID planId, + List diagnostics, + Optional receipt, + Optional writeResult +) { + public IrisStructureAdoptionResult { + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(planId, "planId"); + Objects.requireNonNull(diagnostics, "diagnostics"); + Objects.requireNonNull(receipt, "receipt"); + Objects.requireNonNull(writeResult, "writeResult"); + diagnostics = List.copyOf(diagnostics); + if (status == Status.APPLIED && receipt.isEmpty()) { + throw new IllegalArgumentException("Applied adoption result requires a receipt"); + } + if (status != Status.APPLIED && receipt.isPresent()) { + throw new IllegalArgumentException("Failed adoption result cannot expose a receipt"); + } + } + + public boolean successful() { + return status == Status.APPLIED; + } + + public List summaryLines() { + ArrayList lines = new ArrayList<>(); + lines.add("Adoption plan " + planId + ": " + status); + for (IrisStructureAdoptionDiagnostic diagnostic : diagnostics) { + lines.add(diagnostic.summary()); + } + return List.copyOf(lines); + } + + public enum Status { + APPLIED, + BLOCKED, + EXPIRED, + UNKNOWN_PLAN, + STALE, + FAILED + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionService.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionService.java new file mode 100644 index 000000000..db367ab36 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionService.java @@ -0,0 +1,1313 @@ +package art.arcane.iris.core.structure.conversion; + +import art.arcane.iris.core.pack.StructurePackageClosure; +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionReadSet; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteOptions; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Clock; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Stream; + +public final class IrisStructureAdoptionService { + private static final String STRUCTURES = "structures"; + private static final String POOLS = "jigsaw-pools"; + private static final String PIECES = "jigsaw-pieces"; + private static final String OBJECTS = "objects"; + private static final String LOOT = "loot"; + private static final String MANIFESTS = ".iris/structure-manifests"; + private static final String ADOPTION_VERSION = "iris-adoption-1"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private final Path packRoot; + private final IrisStructureAdoptionLimits limits; + private final Clock clock; + private final StructureTransactionWriter writer; + private final ConcurrentMap plans = new ConcurrentHashMap<>(); + + public IrisStructureAdoptionService(Path packRoot) { + this(packRoot, IrisStructureAdoptionLimits.defaults(), Clock.systemUTC()); + } + + public IrisStructureAdoptionService( + Path packRoot, + IrisStructureAdoptionLimits limits, + Clock clock + ) { + this.packRoot = canonicalRoot(Objects.requireNonNull(packRoot, "packRoot")); + this.limits = Objects.requireNonNull(limits, "limits"); + this.clock = Objects.requireNonNull(clock, "clock"); + writer = new StructureTransactionWriter(this.packRoot); + } + + public synchronized IrisStructureAdoptionPlan inspect(IrisStructureAdoptionRequest request) { + Objects.requireNonNull(request, "request"); + pruneExpiredPlans(); + Instant createdAt = clock.instant(); + Instant expiresAt = createdAt.plus(limits.planTtl()); + UUID planId = UUID.randomUUID(); + PlanDiagnostics diagnostics = new PlanDiagnostics(limits.maxDiagnostics()); + boolean capacityAvailable = plans.size() < limits.maxActivePlans(); + if (!capacityAvailable) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.PLAN_BLOCKED, + "", + "The active adoption plan limit of " + limits.maxActivePlans() + " was reached", + "Apply or wait for an existing plan to expire."); + } + StructureKey sourceKey = request.sourceOwnershipKey(); + StructureKey initialTarget = request.requestedTarget().orElse(sourceKey); + if (!initialTarget.namespace().equals("iris")) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.TARGET_NAMESPACE_UNSUPPORTED, + initialTarget.value(), + "Iris graph ownership targets must use the iris namespace", + "Choose an iris: target."); + } + + StructurePackageClosure closure = StructurePackageClosure.collect( + packRoot.toFile(), + List.of(request.sourceStructure()), + new StructurePackageClosure.Limits(limits.maxResources(), limits.maxJsonBytes())); + for (String error : closure.errors()) { + diagnostics.error( + error.contains("exceeds") + ? IrisStructureAdoptionDiagnostic.Code.SOURCE_RESOURCE_LIMIT + : IrisStructureAdoptionDiagnostic.Code.SOURCE_GRAPH_INVALID, + request.sourceStructure(), + error, + "Repair the source graph before adoption."); + } + + Map sourceHashes = new TreeMap<>(); + long sourceBytes = 0L; + if (!closure.structures().isEmpty()) { + for (String relativePath : resourcePaths(closure)) { + try { + HashedResource resource = hashResource(relativePath); + sourceHashes.put(relativePath, resource.contentHash()); + sourceBytes = Math.addExact(sourceBytes, resource.size()); + if (sourceBytes > limits.maxTotalBytes()) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.SOURCE_RESOURCE_LIMIT, + relativePath, + "Source closure exceeds " + limits.maxTotalBytes() + " bytes", + "Reduce the graph or raise a safe configured limit."); + break; + } + } catch (IOException | RuntimeException exception) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.SOURCE_RESOURCE_UNSAFE, + relativePath, + describe(exception), + "Repair or replace this resource before adoption."); + } + } + } + String closureHash = canonicalMapHash(sourceHashes); + + String sourceManifestPath = StructureOwnershipManifest.relativePath(sourceKey); + Map ownershipReadHashes = new TreeMap<>(); + Path sourceManifest = null; + try { + sourceManifest = resolveSafe(sourceManifestPath); + } catch (IllegalArgumentException exception) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.SOURCE_RESOURCE_UNSAFE, + sourceManifestPath, + describe(exception), + "Repair the structure authoring metadata path before adoption."); + } + if (sourceManifest != null && Files.exists(sourceManifest, LinkOption.NOFOLLOW_LINKS)) { + if (request.inputKind() == IrisStructureAdoptionInputKind.MANAGED_DATAPACK) { + try { + byte[] manifestContent = readResource(sourceManifestPath, limits.maxJsonBytes()); + StructureOwnershipManifest ownership = StructureOwnershipManifest.fromJson(manifestContent); + if (!ownership.structure().equals(sourceKey) + || ownership.backend() != StructureBackend.IRIS_ASSEMBLY) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.SOURCE_ALREADY_OWNED, + sourceManifestPath, + "The managed source manifest does not identify this Iris assembly graph", + "Refresh or repair the managed import before cloning it."); + } else if ((ownership.source().kind() != StructureSource.Kind.DATAPACK + && ownership.source().kind() != StructureSource.Kind.VANILLA) + || ownership.provenance().origin() + != StructureOwnershipManifest.Origin.MANAGED_DATAPACK) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.SOURCE_ALREADY_OWNED, + sourceManifestPath, + "The source manifest is not owned by managed datapack ingest", + "Clone from the correct managed source or open its existing owner."); + } else if (!manifestMatchesSource(ownership, sourceHashes)) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.SOURCE_RESOURCE_CHANGED, + sourceManifestPath, + "Managed source ownership resources do not match their manifest", + "Refresh or repair the managed import before cloning it."); + } else { + ownershipReadHashes.put(sourceManifestPath, StructureHash.sha256(manifestContent)); + } + } catch (IOException | RuntimeException exception) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.SOURCE_ALREADY_OWNED, + sourceManifestPath, + "Cannot verify managed source ownership: " + describe(exception), + "Repair the managed import before cloning it."); + } + } else { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.SOURCE_ALREADY_OWNED, + sourceManifestPath, + "The source graph already has an ownership manifest", + "Open the owned project or choose a different source."); + } + } + + boolean differentTarget = request.requestedTarget().filter(target -> !target.equals(sourceKey)).isPresent(); + if (request.strategy() == IrisStructureAdoptionStrategy.IN_PLACE && differentTarget) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.TARGET_REQUIRED_FOR_CLONE, + initialTarget.value(), + "In-place adoption cannot change the structure key", + "Use clone strategy for a different target."); + } + boolean clone = request.strategy() == IrisStructureAdoptionStrategy.CLONE + || request.strategy() != IrisStructureAdoptionStrategy.IN_PLACE && differentTarget; + if (request.inputKind() == IrisStructureAdoptionInputKind.MANAGED_DATAPACK) { + if (request.strategy() == IrisStructureAdoptionStrategy.IN_PLACE) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.MANAGED_INPUT_REQUIRES_CLONE, + request.sourceStructure(), + "Managed datapack graphs cannot be adopted in place", + "Inspect a clone target so managed cleanup remains isolated."); + } else { + diagnostics.warning( + IrisStructureAdoptionDiagnostic.Code.MANAGED_INPUT_REQUIRES_CLONE, + request.sourceStructure(), + "Managed datapack ownership requires a private clone", + "The source will remain managed and unchanged."); + clone = true; + } + } + + ExclusivitySnapshot exclusivity = ExclusivitySnapshot.empty(); + if (!clone && !diagnostics.blocked()) { + exclusivity = inspectExclusivity(request.sourceStructure(), sourceHashes); + for (ExclusivityIssue issue : exclusivity.issues()) { + if (request.strategy() == IrisStructureAdoptionStrategy.IN_PLACE) { + diagnostics.error(issue.code(), issue.resource(), issue.detail(), + "Choose clone adoption instead."); + } else { + diagnostics.warning(issue.code(), issue.resource(), issue.detail(), + "A private clone will avoid overlapping ownership."); + clone = true; + } + } + } + + StructureKey target = initialTarget; + if (clone && request.requestedTarget().isEmpty()) { + try { + target = firstAvailableCloneTarget(request.sourceStructure(), sourceHashes.keySet()); + } catch (IllegalArgumentException | IllegalStateException exception) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.TARGET_RESOURCE_UNSAFE, + request.sourceStructure(), + describe(exception), + "Choose an explicit safe target after repairing the pack path."); + target = new StructureKey("iris", request.sourceStructure() + "-studio-unavailable"); + } + } + if (clone && target.equals(sourceKey)) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.TARGET_REQUIRED_FOR_CLONE, + target.value(), + "Clone adoption cannot write over the source graph", + "Choose a different iris: target."); + } + if (!clone && !target.equals(sourceKey)) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.TARGET_REQUIRED_FOR_CLONE, + target.value(), + "In-place adoption must keep the source structure key", + "Use clone strategy for a different target."); + } + + Map mappings = sourceHashes.isEmpty() + ? Map.of() + : clone ? cloneMappings(request.sourceStructure(), target.path(), sourceHashes.keySet()) + : identityMappings(sourceHashes.keySet()); + StructureTransactionReadSet.Builder readSet = StructureTransactionReadSet.builder() + .files(sourceHashes) + .files(ownershipReadHashes); + if (clone) { + TreeSet targetPaths = new TreeSet<>(mappings.values()); + targetPaths.add(StructureOwnershipManifest.relativePath(target)); + TreeSet absentTargetPaths = new TreeSet<>(targetPaths); + absentTargetPaths.removeAll(sourceHashes.keySet()); + absentTargetPaths.removeAll(ownershipReadHashes.keySet()); + readSet.absent(absentTargetPaths); + for (String targetPath : targetPaths) { + Path resolvedTarget; + try { + resolvedTarget = resolveSafe(targetPath); + } catch (IllegalArgumentException exception) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.TARGET_RESOURCE_UNSAFE, + targetPath, + describe(exception), + "Choose a target whose resource paths stay inside the pack."); + continue; + } + if (Files.exists(resolvedTarget, LinkOption.NOFOLLOW_LINKS)) { + diagnostics.error( + IrisStructureAdoptionDiagnostic.Code.TARGET_RESOURCE_EXISTS, + targetPath, + "Clone target path already exists", + "Choose a different target key."); + } + } + if (!diagnostics.blocked()) { + diagnostics.info( + IrisStructureAdoptionDiagnostic.Code.CLONE_SELECTED, + target.value(), + "The graph will be copied into project-private resource paths", + "Apply the inspected plan ID to commit it."); + } + } else { + readSet.absent(sourceManifestPath); + readSet.files(exclusivity.fileHashes()); + for (Map.Entry> directory : exclusivity.directoryEntries().entrySet()) { + readSet.directory(directory.getKey(), directory.getValue()); + } + if (!diagnostics.blocked()) { + diagnostics.info( + IrisStructureAdoptionDiagnostic.Code.IN_PLACE_AVAILABLE, + target.value(), + "The exclusive source graph can be claimed without rewriting resource bytes", + "Apply the inspected plan ID to install ownership."); + } + } + + IrisStructureAdoptionDisposition disposition = diagnostics.blocked() + ? IrisStructureAdoptionDisposition.BLOCKED + : clone + ? IrisStructureAdoptionDisposition.CLONE_REQUIRED + : IrisStructureAdoptionDisposition.IN_PLACE; + StructureTransactionReadSet transactionReadSet = readSet.build(); + String planHash = planHash( + planId, + createdAt, + expiresAt, + request, + target, + disposition, + diagnostics.values(), + sourceHashes, + mappings, + transactionReadSet, + closureHash); + IrisStructureAdoptionPlan plan = new IrisStructureAdoptionPlan( + planId, + createdAt, + expiresAt, + request, + target, + disposition, + diagnostics.values(), + sourceHashes, + mappings, + transactionReadSet, + sourceBytes, + closureHash, + planHash + ); + if (capacityAvailable) { + plans.put(planId, plan); + } + return plan; + } + + public synchronized IrisStructureAdoptionResult apply(IrisStructureAdoptionPlan plan) { + Objects.requireNonNull(plan, "plan"); + IrisStructureAdoptionPlan stored = plans.remove(plan.planId()); + if (stored == null || !stored.planHash().equals(plan.planHash())) { + return result( + IrisStructureAdoptionResult.Status.UNKNOWN_PLAN, + plan.planId(), + diagnostic( + IrisStructureAdoptionDiagnostic.Severity.ERROR, + IrisStructureAdoptionDiagnostic.Code.PLAN_UNKNOWN, + "", + "The adoption plan is unknown or has already been consumed", + "Run inspect again."), + Optional.empty(), + Optional.empty()); + } + if (stored.expiredAt(clock.instant())) { + return result( + IrisStructureAdoptionResult.Status.EXPIRED, + stored.planId(), + diagnostic( + IrisStructureAdoptionDiagnostic.Severity.ERROR, + IrisStructureAdoptionDiagnostic.Code.PLAN_EXPIRED, + "", + "The adoption plan expired at " + stored.expiresAt(), + "Run inspect again."), + Optional.empty(), + Optional.empty()); + } + if (!stored.canApply()) { + return new IrisStructureAdoptionResult( + IrisStructureAdoptionResult.Status.BLOCKED, + stored.planId(), + stored.diagnostics(), + Optional.empty(), + Optional.empty()); + } + + try { + return stored.disposition() == IrisStructureAdoptionDisposition.IN_PLACE + ? applyInPlace(stored) + : applyClone(stored); + } catch (StalePlanException exception) { + return result( + IrisStructureAdoptionResult.Status.STALE, + stored.planId(), + diagnostic( + IrisStructureAdoptionDiagnostic.Severity.ERROR, + IrisStructureAdoptionDiagnostic.Code.PLAN_STALE, + exception.resource(), + exception.getMessage(), + "Run inspect again."), + Optional.empty(), + Optional.empty()); + } catch (IOException | RuntimeException exception) { + return result( + IrisStructureAdoptionResult.Status.FAILED, + stored.planId(), + diagnostic( + IrisStructureAdoptionDiagnostic.Severity.ERROR, + IrisStructureAdoptionDiagnostic.Code.TRANSACTION_FAILED, + "", + describe(exception), + "No adoption receipt was committed."), + Optional.empty(), + Optional.empty()); + } + } + + public synchronized Optional plan(UUID planId) { + Objects.requireNonNull(planId, "planId"); + pruneExpiredPlans(); + return Optional.ofNullable(plans.get(planId)); + } + + public synchronized List activePlanIds() { + pruneExpiredPlans(); + ArrayList ids = new ArrayList<>(plans.keySet()); + Collections.sort(ids); + return List.copyOf(ids); + } + + public synchronized int pruneExpiredPlans() { + Instant now = clock.instant(); + int removed = 0; + for (Map.Entry entry : plans.entrySet()) { + if (entry.getValue().expiredAt(now) && plans.remove(entry.getKey(), entry.getValue())) { + removed++; + } + } + return removed; + } + + private IrisStructureAdoptionResult applyInPlace(IrisStructureAdoptionPlan plan) { + IrisStructureAdoptionReceipt receipt = receipt( + plan, + StructureOwnershipManifest.Origin.ADOPTED_EXISTING, + plan.sourceResourceHashes(), + StructureOwnershipManifest.RollbackDisposition.NONE); + StructureOwnershipManifest manifest = new StructureOwnershipManifest( + StructureOwnershipManifest.CURRENT_SCHEMA_VERSION, + plan.targetStructure(), + source(plan), + StructureBackend.IRIS_ASSEMBLY, + defaultCapabilities(), + List.of(), + plan.sourceResourceHashes(), + receipt.provenance()); + StructureWriteResult writeResult = writer.claimExisting(manifest, plan.readSet()); + return completeApply(plan, receipt, writeResult); + } + + private IrisStructureAdoptionResult applyClone(IrisStructureAdoptionPlan plan) throws IOException { + StructureResourceBundle bundle = cloneBundle(plan); + Map targetHashes = new TreeMap<>(); + for (StructureResourceBundle.Resource resource : bundle.resources().values()) { + targetHashes.put(resource.relativePath(), resource.contentHash()); + } + IrisStructureAdoptionReceipt receipt = receipt( + plan, + plan.request().inputKind() == IrisStructureAdoptionInputKind.MANAGED_DATAPACK + ? StructureOwnershipManifest.Origin.ADOPTED_MANAGED_CLONE + : StructureOwnershipManifest.Origin.ADOPTED_CLONE, + targetHashes, + StructureOwnershipManifest.RollbackDisposition.DELETE_CREATED_IF_UNCHANGED); + StructureWriteResult writeResult = writer.writeVerified( + bundle, + StructureWriteOptions.addOnly(), + plan.readSet(), + receipt.provenance()); + return completeApply(plan, receipt, writeResult); + } + + private IrisStructureAdoptionResult completeApply( + IrisStructureAdoptionPlan plan, + IrisStructureAdoptionReceipt receipt, + StructureWriteResult writeResult + ) { + if (writeResult.committed()) { + ArrayList diagnostics = new ArrayList<>(); + diagnostics.add(diagnostic( + IrisStructureAdoptionDiagnostic.Severity.INFO, + IrisStructureAdoptionDiagnostic.Code.APPLIED, + plan.targetStructure().value(), + "Ownership and adoption provenance were committed", + "The graph is ready for owned authoring.")); + if (writeResult.status() == StructureWriteResult.Status.COMMITTED_CLEANUP_REQUIRED) { + diagnostics.add(diagnostic( + IrisStructureAdoptionDiagnostic.Severity.WARNING, + IrisStructureAdoptionDiagnostic.Code.TRANSACTION_FAILED, + writeResult.manifestPath(), + "The transaction committed but recovery cleanup remains", + "Run structure transaction recovery before another mutation.")); + } + return new IrisStructureAdoptionResult( + IrisStructureAdoptionResult.Status.APPLIED, + plan.planId(), + diagnostics, + Optional.of(receipt), + Optional.of(writeResult)); + } + boolean stale = writeResult.conflicts().stream().anyMatch(conflict -> + conflict.reason() == StructureWriteResult.ConflictReason.STALE_READ_SET + || conflict.reason() == StructureWriteResult.ConflictReason.RESOURCE_EXISTS + || conflict.reason() == StructureWriteResult.ConflictReason.MANIFEST_EXISTS); + IrisStructureAdoptionDiagnostic diagnostic = diagnostic( + IrisStructureAdoptionDiagnostic.Severity.ERROR, + stale + ? IrisStructureAdoptionDiagnostic.Code.PLAN_STALE + : IrisStructureAdoptionDiagnostic.Code.TRANSACTION_FAILED, + writeResult.conflicts().isEmpty() ? writeResult.manifestPath() + : writeResult.conflicts().getFirst().relativePath(), + failureDetail(writeResult), + stale ? "Run inspect again." : "No adoption receipt was committed."); + return result( + stale ? IrisStructureAdoptionResult.Status.STALE : IrisStructureAdoptionResult.Status.FAILED, + plan.planId(), + diagnostic, + Optional.empty(), + Optional.of(writeResult)); + } + + private StructureResourceBundle cloneBundle(IrisStructureAdoptionPlan plan) throws IOException { + StructureResourceBundle.Builder bundle = StructureResourceBundle.builder(plan.targetStructure()) + .source(source(plan)) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capabilities(defaultCapabilities()); + for (Map.Entry mapping : plan.sourceToTargetPaths().entrySet()) { + String sourcePath = mapping.getKey(); + byte[] content = readResource(sourcePath); + String observedHash = StructureHash.sha256(content); + String expectedHash = plan.sourceResourceHashes().get(sourcePath); + if (!observedHash.equals(expectedHash)) { + throw new StalePlanException(sourcePath, "Source resource changed after inspection"); + } + byte[] targetContent = rewriteResource(sourcePath, content, plan.sourceToTargetPaths()); + bundle.resource(mapping.getValue(), targetContent); + } + return bundle.build(); + } + + private byte[] rewriteResource( + String sourcePath, + byte[] content, + Map mappings + ) throws IOException { + if (sourcePath.startsWith(OBJECTS + "/") || sourcePath.startsWith(LOOT + "/")) { + return content; + } + JsonObject root; + try { + JsonElement parsed = JsonParser.parseString(new String(content, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("Graph resource is not a JSON object: " + sourcePath); + } + root = parsed.getAsJsonObject(); + } catch (RuntimeException exception) { + throw new IOException("Cannot parse graph resource " + sourcePath, exception); + } + if (sourcePath.startsWith(STRUCTURES + "/")) { + rewriteString(root, "startPool", POOLS, ".json", mappings, sourcePath); + rewriteArray(root, "loot", LOOT, ".json", mappings, sourcePath); + } else if (sourcePath.startsWith(POOLS + "/")) { + JsonArray pieces = root.getAsJsonArray("pieces"); + if (pieces != null) { + for (JsonElement element : pieces) { + if (element.isJsonObject() && element.getAsJsonObject().has("piece")) { + rewriteString(element.getAsJsonObject(), "piece", PIECES, ".json", mappings, sourcePath); + } + } + } + if (root.has("fallback")) { + rewriteString(root, "fallback", POOLS, ".json", mappings, sourcePath); + } + } else if (sourcePath.startsWith(PIECES + "/")) { + rewriteString(root, "object", OBJECTS, ".iob", mappings, sourcePath); + JsonArray connectors = root.getAsJsonArray("connectors"); + if (connectors != null) { + for (JsonElement element : connectors) { + if (element.isJsonObject()) { + rewriteString(element.getAsJsonObject(), "pool", POOLS, ".json", mappings, sourcePath); + } + } + } + } + return (GSON.toJson(root) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private void rewriteString( + JsonObject object, + String field, + String folder, + String extension, + Map mappings, + String owner + ) throws IOException { + JsonElement value = object.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IOException("Graph resource " + owner + " has invalid field " + field); + } + if (value.getAsString().isBlank()) { + return; + } + String sourcePath = folder + "/" + value.getAsString() + extension; + String targetPath = mappings.get(sourcePath); + if (targetPath == null) { + throw new IOException("Graph resource " + owner + " references unmapped resource " + sourcePath); + } + object.addProperty(field, internalKey(targetPath, folder, extension)); + } + + private void rewriteArray( + JsonObject object, + String field, + String folder, + String extension, + Map mappings, + String owner + ) throws IOException { + if (!object.has(field)) { + return; + } + JsonArray values = object.getAsJsonArray(field); + if (values == null) { + throw new IOException("Graph resource " + owner + " has invalid field " + field); + } + JsonArray rewritten = new JsonArray(); + for (JsonElement value : values) { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IOException("Graph resource " + owner + " has non-string " + field + " entry"); + } + String sourcePath = folder + "/" + value.getAsString() + extension; + String targetPath = mappings.get(sourcePath); + if (targetPath == null) { + throw new IOException("Graph resource " + owner + " references unmapped resource " + sourcePath); + } + rewritten.add(internalKey(targetPath, folder, extension)); + } + object.add(field, rewritten); + } + + private ExclusivitySnapshot inspectExclusivity( + String sourceStructure, + Map sourceHashes + ) { + TreeMap globalHashes = new TreeMap<>(); + TreeMap> directoryEntries = new TreeMap<>(); + ArrayList issues = new ArrayList<>(); + long inspectedBytes = 0L; + int inspectedResources = 0; + try { + for (String directory : List.of(STRUCTURES, POOLS, PIECES, MANIFESTS)) { + List entries = snapshotDirectory(directory); + inspectedResources = Math.addExact(inspectedResources, entries.size()); + if (inspectedResources > limits.maxResources()) { + throw new IOException("Exclusivity snapshot exceeds " + + limits.maxResources() + " resources"); + } + directoryEntries.put(directory, entries); + for (String relativePath : entries) { + int maximum = relativePath.startsWith(MANIFESTS + "/") + ? limits.maxJsonBytes() + : limits.maxJsonBytes(); + HashedResource resource = hashResource(relativePath, maximum); + globalHashes.put(relativePath, resource.contentHash()); + inspectedBytes = Math.addExact(inspectedBytes, resource.size()); + if (inspectedBytes > limits.maxTotalBytes()) { + throw new IOException("Exclusivity snapshot exceeds " + + limits.maxTotalBytes() + " bytes"); + } + } + } + List structurePaths = directoryEntries.getOrDefault(STRUCTURES, List.of()); + if (structurePaths.size() > limits.maxStructuresScanned()) { + issues.add(new ExclusivityIssue( + IrisStructureAdoptionDiagnostic.Code.EXCLUSIVITY_UNPROVEN, + STRUCTURES, + "Structure scan exceeds " + limits.maxStructuresScanned() + " roots")); + } else { + for (String structurePath : structurePaths) { + if (!structurePath.endsWith(".json")) { + continue; + } + String structureKey = internalKey(structurePath, STRUCTURES, ".json"); + if (structureKey.equals(sourceStructure)) { + continue; + } + JsonObject structure = readJsonObject(structurePath); + if (structure.has("startPool")) { + String startPool = requiredReference(structure, "startPool", structurePath); + String referencedPath = POOLS + "/" + startPool + ".json"; + if (sourceHashes.containsKey(referencedPath)) { + issues.add(new ExclusivityIssue( + IrisStructureAdoptionDiagnostic.Code.SHARED_DEPENDENCY, + referencedPath, + "Structure '" + structureKey + "' directly references the source graph")); + } + } + if (structure.has("loot")) { + JsonArray loot = structure.getAsJsonArray("loot"); + if (loot == null) { + throw new IOException("Structure has invalid loot references: " + structurePath); + } + for (JsonElement lootEntry : loot) { + if (!lootEntry.isJsonPrimitive() || !lootEntry.getAsJsonPrimitive().isString()) { + throw new IOException("Structure has a non-string loot reference: " + structurePath); + } + addSharedBoundary( + issues, + sourceHashes, + LOOT, + lootEntry.getAsString(), + ".json", + structurePath); + } + } + } + } + for (String poolPath : directoryEntries.getOrDefault(POOLS, List.of())) { + if (!poolPath.endsWith(".json") || sourceHashes.containsKey(poolPath)) { + continue; + } + JsonObject pool = readJsonObject(poolPath); + if (pool.has("fallback")) { + String fallback = requiredReference(pool, "fallback", poolPath); + if (!fallback.isBlank()) { + addSharedBoundary(issues, sourceHashes, POOLS, fallback, ".json", poolPath); + } + } + JsonArray entries = pool.getAsJsonArray("pieces"); + if (entries == null) { + throw new IOException("Jigsaw pool has no pieces array: " + poolPath); + } + for (JsonElement entry : entries) { + if (!entry.isJsonObject() || !entry.getAsJsonObject().has("piece")) { + continue; + } + String piece = requiredReference(entry.getAsJsonObject(), "piece", poolPath); + addSharedBoundary(issues, sourceHashes, PIECES, piece, ".json", poolPath); + } + } + for (String piecePath : directoryEntries.getOrDefault(PIECES, List.of())) { + if (!piecePath.endsWith(".json") || sourceHashes.containsKey(piecePath)) { + continue; + } + JsonObject piece = readJsonObject(piecePath); + String object = requiredReference(piece, "object", piecePath); + addSharedBoundary(issues, sourceHashes, OBJECTS, object, ".iob", piecePath); + if (!piece.has("connectors")) { + continue; + } + JsonArray connectors = piece.getAsJsonArray("connectors"); + if (connectors == null) { + throw new IOException("Jigsaw piece has invalid connectors: " + piecePath); + } + for (JsonElement connector : connectors) { + if (!connector.isJsonObject()) { + throw new IOException("Jigsaw piece has a non-object connector: " + piecePath); + } + String pool = requiredReference(connector.getAsJsonObject(), "pool", piecePath); + addSharedBoundary(issues, sourceHashes, POOLS, pool, ".json", piecePath); + } + } + for (String manifestPath : directoryEntries.getOrDefault(MANIFESTS, List.of())) { + StructureOwnershipManifest manifest; + try { + manifest = StructureOwnershipManifest.fromJson(readResource(manifestPath, limits.maxJsonBytes())); + } catch (IOException | RuntimeException exception) { + issues.add(new ExclusivityIssue( + IrisStructureAdoptionDiagnostic.Code.EXCLUSIVITY_UNPROVEN, + manifestPath, + "An ownership manifest is invalid: " + describe(exception))); + continue; + } + Set overlap = new TreeSet<>(manifest.resourceHashes().keySet()); + overlap.retainAll(sourceHashes.keySet()); + if (!overlap.isEmpty()) { + issues.add(new ExclusivityIssue( + IrisStructureAdoptionDiagnostic.Code.SHARED_DEPENDENCY, + overlap.iterator().next(), + "Owned structure '" + manifest.structure() + "' already claims " + + overlap.size() + " source resources")); + } + } + } catch (IOException | RuntimeException exception) { + issues.add(new ExclusivityIssue( + IrisStructureAdoptionDiagnostic.Code.EXCLUSIVITY_UNPROVEN, + "", + describe(exception))); + } + return new ExclusivitySnapshot(globalHashes, directoryEntries, issues); + } + + private void addSharedBoundary( + List issues, + Map sourceHashes, + String folder, + String key, + String extension, + String owner + ) { + String referencedPath = folder + "/" + key + extension; + if (sourceHashes.containsKey(referencedPath)) { + issues.add(new ExclusivityIssue( + IrisStructureAdoptionDiagnostic.Code.SHARED_DEPENDENCY, + referencedPath, + owner + " references a source graph resource")); + } + } + + private String requiredReference(JsonObject object, String field, String owner) throws IOException { + JsonElement value = object.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IOException("Graph resource " + owner + " has invalid field " + field); + } + return value.getAsString(); + } + + private List snapshotDirectory(String relativePath) throws IOException { + Path directory = resolveSafe(relativePath); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return List.of(); + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Snapshot path is not a directory: " + relativePath); + } + ArrayList entries = new ArrayList<>(); + try (Stream paths = Files.walk(directory)) { + Iterator iterator = paths.iterator(); + while (iterator.hasNext()) { + Path path = iterator.next(); + if (path.equals(directory)) { + continue; + } + if (Files.isSymbolicLink(path)) { + throw new IOException("Snapshot directory contains a symbolic link: " + path); + } + if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Snapshot directory contains a non-file entry: " + path); + } + entries.add(relative(path)); + if (entries.size() > limits.maxResources()) { + throw new IOException("Snapshot directory exceeds " + limits.maxResources() + " resources"); + } + } + } + Collections.sort(entries); + return List.copyOf(entries); + } + + private StructureKey firstAvailableCloneTarget(String sourceStructure, Set sourcePaths) { + for (int suffix = 0; suffix < 100; suffix++) { + String path = sourceStructure + "-studio" + (suffix == 0 ? "" : "-" + (suffix + 1)); + StructureKey candidate = new StructureKey("iris", path); + Map mappings = cloneMappings(sourceStructure, path, sourcePaths); + boolean occupied = Files.exists( + resolveSafe(StructureOwnershipManifest.relativePath(candidate)), + LinkOption.NOFOLLOW_LINKS); + if (!occupied) { + occupied = mappings.values().stream().anyMatch(target -> + Files.exists(resolveSafe(target), LinkOption.NOFOLLOW_LINKS)); + } + if (!occupied) { + return candidate; + } + } + throw new IllegalStateException("No available automatic clone target was found after 100 candidates"); + } + + private Map cloneMappings( + String sourceStructure, + String targetStructure, + Set sourcePaths + ) { + TreeMap mappings = new TreeMap<>(); + Set targetPaths = new LinkedHashSet<>(); + for (String sourcePath : sourcePaths) { + String targetPath; + if (sourcePath.equals(STRUCTURES + "/" + sourceStructure + ".json")) { + targetPath = STRUCTURES + "/" + targetStructure + ".json"; + } else if (sourcePath.startsWith(POOLS + "/")) { + targetPath = POOLS + "/" + targetStructure + "/pool/" + + internalKey(sourcePath, POOLS, ".json") + ".json"; + } else if (sourcePath.startsWith(PIECES + "/")) { + targetPath = PIECES + "/" + targetStructure + "/piece/" + + internalKey(sourcePath, PIECES, ".json") + ".json"; + } else if (sourcePath.startsWith(OBJECTS + "/")) { + targetPath = OBJECTS + "/" + targetStructure + "/object/" + + internalKey(sourcePath, OBJECTS, ".iob") + ".iob"; + } else if (sourcePath.startsWith(LOOT + "/")) { + targetPath = LOOT + "/" + targetStructure + "/loot/" + + internalKey(sourcePath, LOOT, ".json") + ".json"; + } else { + throw new IllegalArgumentException("Unsupported graph resource path " + sourcePath); + } + StructureResourceBundle.validateRelativePath(targetPath); + if (!targetPaths.add(targetPath)) { + throw new IllegalArgumentException("Clone resource mapping collision at " + targetPath); + } + mappings.put(sourcePath, targetPath); + } + return Collections.unmodifiableMap(mappings); + } + + private Map identityMappings(Set sourcePaths) { + TreeMap mappings = new TreeMap<>(); + for (String sourcePath : sourcePaths) { + mappings.put(sourcePath, sourcePath); + } + return Collections.unmodifiableMap(mappings); + } + + private Set resourcePaths(StructurePackageClosure closure) { + TreeSet resources = new TreeSet<>(); + addPaths(resources, STRUCTURES, closure.structures(), ".json"); + addPaths(resources, POOLS, closure.pools(), ".json"); + addPaths(resources, PIECES, closure.pieces(), ".json"); + addPaths(resources, OBJECTS, closure.objects(), ".iob"); + addPaths(resources, LOOT, closure.loot(), ".json"); + return Collections.unmodifiableSet(resources); + } + + private void addPaths(Set resources, String folder, Set keys, String extension) { + for (String key : keys) { + resources.add(folder + "/" + key + extension); + } + } + + private HashedResource hashResource(String relativePath) throws IOException { + int maximum = relativePath.endsWith(".iob") ? limits.maxBinaryBytes() : limits.maxJsonBytes(); + return hashResource(relativePath, maximum); + } + + private HashedResource hashResource(String relativePath, int maximumBytes) throws IOException { + byte[] content = readResource(relativePath, maximumBytes); + return new HashedResource(content.length, StructureHash.sha256(content)); + } + + private byte[] readResource(String relativePath) throws IOException { + int maximum = relativePath.endsWith(".iob") ? limits.maxBinaryBytes() : limits.maxJsonBytes(); + return readResource(relativePath, maximum); + } + + private byte[] readResource(String relativePath, int maximumBytes) throws IOException { + Path resource = resolveRegularFile(relativePath); + try (InputStream input = Files.newInputStream( + resource, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS)) { + byte[] content = input.readNBytes(maximumBytes + 1); + if (content.length > maximumBytes) { + throw new IOException("Resource exceeds " + maximumBytes + " bytes"); + } + return content; + } + } + + private JsonObject readJsonObject(String relativePath) throws IOException { + try { + JsonElement parsed = JsonParser.parseString( + new String(readResource(relativePath, limits.maxJsonBytes()), StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) { + throw new IOException("JSON resource is not an object: " + relativePath); + } + return parsed.getAsJsonObject(); + } catch (RuntimeException exception) { + throw new IOException("Cannot parse JSON resource " + relativePath, exception); + } + } + + private Path resolveRegularFile(String relativePath) throws IOException { + Path resource = resolveSafe(relativePath); + if (!Files.isRegularFile(resource, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Resource is missing or not a regular file: " + relativePath); + } + Path realResource = resource.toRealPath(); + Path realRoot = packRoot.toRealPath(); + if (!realResource.startsWith(realRoot)) { + throw new IOException("Resource escapes the pack through a symbolic link: " + relativePath); + } + return resource; + } + + private Path resolveSafe(String relativePath) { + Path target = packRoot.resolve(relativePath).normalize(); + if (!target.startsWith(packRoot) || target.equals(packRoot)) { + throw new IllegalArgumentException("Resource path escapes the pack root: " + relativePath); + } + Path current = packRoot; + for (Path segment : packRoot.relativize(target)) { + current = current.resolve(segment); + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + if (Files.isSymbolicLink(current)) { + throw new IllegalArgumentException("Resource path contains a symbolic link: " + current); + } + if (!current.equals(target) && !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalArgumentException("Resource path contains a non-directory parent: " + current); + } + } + return target; + } + + private String relative(Path path) { + return packRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'); + } + + private String internalKey(String relativePath, String folder, String extension) { + String prefix = folder + "/"; + if (!relativePath.startsWith(prefix) || !relativePath.endsWith(extension)) { + throw new IllegalArgumentException("Resource path is outside " + folder + ": " + relativePath); + } + return relativePath.substring(prefix.length(), relativePath.length() - extension.length()); + } + + private StructureSource source(IrisStructureAdoptionPlan plan) { + return new StructureSource( + StructureSource.Kind.IRIS, + plan.request().sourceOwnershipKey(), + ADOPTION_VERSION, + plan.sourceClosureHash()); + } + + private List defaultCapabilities() { + return List.copyOf(EnumSet.of( + StructureCapability.BLOCKS, + StructureCapability.CONNECTORS, + StructureCapability.IRIS_PLACEMENT)); + } + + private IrisStructureAdoptionReceipt receipt( + IrisStructureAdoptionPlan plan, + StructureOwnershipManifest.Origin origin, + Map targetHashes, + StructureOwnershipManifest.RollbackDisposition rollbackDisposition + ) { + return new IrisStructureAdoptionReceipt( + UUID.randomUUID(), + plan.planId(), + origin, + clock.instant(), + plan.request().sourceOwnershipKey(), + plan.targetStructure(), + plan.sourceClosureHash(), + plan.planHash(), + plan.sourceResourceHashes(), + targetHashes, + plan.sourceToTargetPaths(), + rollbackDisposition); + } + + private String planHash( + UUID planId, + Instant createdAt, + Instant expiresAt, + IrisStructureAdoptionRequest request, + StructureKey target, + IrisStructureAdoptionDisposition disposition, + List diagnostics, + Map sourceHashes, + Map mappings, + StructureTransactionReadSet readSet, + String closureHash + ) { + StringBuilder canonical = new StringBuilder(); + canonical.append(planId).append('\n') + .append(createdAt).append('\n') + .append(expiresAt).append('\n') + .append(request.sourceStructure()).append('\n') + .append(request.strategy()).append('\n') + .append(request.inputKind()).append('\n') + .append(target.value()).append('\n') + .append(disposition).append('\n') + .append(closureHash).append('\n'); + appendMap(canonical, sourceHashes); + appendMap(canonical, mappings); + appendMap(canonical, readSet.fileHashes()); + for (String absentPath : readSet.absentPaths()) { + canonical.append("absent=").append(absentPath).append('\n'); + } + for (Map.Entry> directory : readSet.directoryEntries().entrySet()) { + canonical.append("directory=").append(directory.getKey()).append('\n'); + for (String entry : directory.getValue()) { + canonical.append("entry=").append(entry).append('\n'); + } + } + ArrayList orderedDiagnostics = new ArrayList<>(diagnostics); + Collections.sort(orderedDiagnostics); + for (IrisStructureAdoptionDiagnostic diagnostic : orderedDiagnostics) { + canonical.append(diagnostic.summary()).append('\n'); + } + return StructureHash.sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); + } + + private void appendMap(StringBuilder canonical, Map values) { + for (Map.Entry entry : new TreeMap<>(values).entrySet()) { + canonical.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); + } + } + + private boolean manifestMatchesSource( + StructureOwnershipManifest manifest, + Map sourceHashes + ) { + if (manifest.resourceHashes().isEmpty()) { + return false; + } + String rootStructurePath = STRUCTURES + "/" + manifest.structure().path() + ".json"; + if (!manifest.resourceHashes().containsKey(rootStructurePath)) { + return false; + } + for (Map.Entry owned : manifest.resourceHashes().entrySet()) { + if (!owned.getValue().equals(sourceHashes.get(owned.getKey()))) { + return false; + } + } + return true; + } + + private String canonicalMapHash(Map values) { + StringBuilder canonical = new StringBuilder(); + appendMap(canonical, values); + return StructureHash.sha256(canonical.toString().getBytes(StandardCharsets.UTF_8)); + } + + private String failureDetail(StructureWriteResult writeResult) { + if (!writeResult.conflicts().isEmpty()) { + StructureWriteResult.Conflict conflict = writeResult.conflicts().getFirst(); + return conflict.detail().isBlank() + ? "Transaction conflict at " + conflict.relativePath() + ": " + conflict.reason() + : conflict.detail(); + } + return writeResult.failure().map(IrisStructureAdoptionService::describe) + .orElse("Structure transaction did not commit"); + } + + private IrisStructureAdoptionResult result( + IrisStructureAdoptionResult.Status status, + UUID planId, + IrisStructureAdoptionDiagnostic diagnostic, + Optional receipt, + Optional writeResult + ) { + return new IrisStructureAdoptionResult( + status, + planId, + List.of(diagnostic), + receipt, + writeResult); + } + + private IrisStructureAdoptionDiagnostic diagnostic( + IrisStructureAdoptionDiagnostic.Severity severity, + IrisStructureAdoptionDiagnostic.Code code, + String resource, + String detail, + String recommendation + ) { + return new IrisStructureAdoptionDiagnostic(severity, code, resource, detail, recommendation); + } + + private static Path canonicalRoot(Path root) { + Path normalized = root.toAbsolutePath().normalize(); + if (!Files.exists(normalized)) { + return normalized; + } + try { + return normalized.toRealPath(); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot resolve adoption pack root " + normalized, exception); + } + } + + private static String describe(Throwable throwable) { + String message = throwable.getMessage(); + return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message; + } + + private record HashedResource(long size, String contentHash) { + } + + private record ExclusivityIssue( + IrisStructureAdoptionDiagnostic.Code code, + String resource, + String detail + ) { + } + + private record ExclusivitySnapshot( + Map fileHashes, + Map> directoryEntries, + List issues + ) { + private ExclusivitySnapshot { + fileHashes = Collections.unmodifiableMap(new TreeMap<>(fileHashes)); + directoryEntries = Collections.unmodifiableMap(new TreeMap<>(directoryEntries)); + issues = List.copyOf(issues); + } + + private static ExclusivitySnapshot empty() { + return new ExclusivitySnapshot(Map.of(), Map.of(), List.of()); + } + } + + private static final class PlanDiagnostics { + private final int maximum; + private final List values = new ArrayList<>(); + private boolean blocked; + + private PlanDiagnostics(int maximum) { + this.maximum = maximum; + } + + private void error( + IrisStructureAdoptionDiagnostic.Code code, + String resource, + String detail, + String recommendation + ) { + add(IrisStructureAdoptionDiagnostic.Severity.ERROR, code, resource, detail, recommendation); + } + + private void warning( + IrisStructureAdoptionDiagnostic.Code code, + String resource, + String detail, + String recommendation + ) { + add(IrisStructureAdoptionDiagnostic.Severity.WARNING, code, resource, detail, recommendation); + } + + private void info( + IrisStructureAdoptionDiagnostic.Code code, + String resource, + String detail, + String recommendation + ) { + add(IrisStructureAdoptionDiagnostic.Severity.INFO, code, resource, detail, recommendation); + } + + private void add( + IrisStructureAdoptionDiagnostic.Severity severity, + IrisStructureAdoptionDiagnostic.Code code, + String resource, + String detail, + String recommendation + ) { + if (severity == IrisStructureAdoptionDiagnostic.Severity.ERROR) { + blocked = true; + } + if (values.size() < maximum) { + values.add(new IrisStructureAdoptionDiagnostic( + severity, + code, + resource, + detail, + recommendation)); + } + } + + private boolean blocked() { + return blocked; + } + + private List values() { + return List.copyOf(values); + } + } + + private static final class StalePlanException extends IOException { + private final String resource; + + private StalePlanException(String resource, String message) { + super(message); + this.resource = resource; + } + + private String resource() { + return resource; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionStrategy.java b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionStrategy.java new file mode 100644 index 000000000..170cb2787 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionStrategy.java @@ -0,0 +1,7 @@ +package art.arcane.iris.core.structure.conversion; + +public enum IrisStructureAdoptionStrategy { + AUTO, + IN_PLACE, + CLONE +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/AtomicDatapackPublisher.java b/core/src/main/java/art/arcane/iris/core/structure/export/AtomicDatapackPublisher.java new file mode 100644 index 000000000..57362fc72 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/AtomicDatapackPublisher.java @@ -0,0 +1,160 @@ +package art.arcane.iris.core.structure.export; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import java.util.stream.Stream; + +final class AtomicDatapackPublisher { + Publication publish( + Map resources, + Path output, + VanillaJigsawExportFormat format, + boolean replaceExisting + ) throws IOException { + Path parent = output.getParent(); + if (parent == null) { + throw new IOException("Export output has no parent directory: " + output); + } + Files.createDirectories(parent); + Path staging = Files.createTempDirectory(parent, ".iris-jigsaw-export-"); + List cleanupWarnings = new ArrayList<>(); + try { + Path artifact = format == VanillaJigsawExportFormat.DIRECTORY + ? createDirectoryArtifact(staging, resources) + : createZipArtifact(staging, resources); + replaceAtomically(artifact, output, replaceExisting, cleanupWarnings); + } finally { + try { + deleteTree(staging); + } catch (IOException exception) { + cleanupWarnings.add("Could not remove export staging path '" + staging + "': " + exception.getMessage()); + } + } + return new Publication(List.copyOf(cleanupWarnings)); + } + + private Path createDirectoryArtifact(Path staging, Map resources) throws IOException { + Path root = staging.resolve("pack"); + Files.createDirectory(root); + for (String resource : sortedPaths(resources)) { + Path target = root.resolve(resource).normalize(); + if (!target.startsWith(root)) { + throw new IOException("Export resource escapes staging root: " + resource); + } + Files.createDirectories(target.getParent()); + Files.write(target, resources.get(resource)); + } + return root; + } + + private Path createZipArtifact(Path staging, Map resources) throws IOException { + Path zip = staging.resolve("pack.zip"); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(zip))) { + for (String resource : sortedPaths(resources)) { + ZipEntry entry = new ZipEntry(resource); + entry.setTime(0L); + output.putNextEntry(entry); + output.write(resources.get(resource)); + output.closeEntry(); + } + } + return zip; + } + + private void replaceAtomically( + Path artifact, + Path output, + boolean replaceExisting, + List cleanupWarnings + ) throws IOException { + Path backup = null; + if (Files.exists(output)) { + if (!replaceExisting) { + throw new IOException("Export output already exists: " + output); + } + backup = uniqueBackup(output); + atomicMove(output, backup); + } + + try { + atomicMove(artifact, output); + } catch (IOException publicationFailure) { + if (backup != null && Files.exists(backup) && !Files.exists(output)) { + try { + atomicMove(backup, output); + } catch (IOException restoreFailure) { + publicationFailure.addSuppressed(restoreFailure); + } + } + throw publicationFailure; + } + + if (backup != null) { + try { + deleteTree(backup); + } catch (IOException exception) { + cleanupWarnings.add("Export succeeded, but the replaced output backup remains at '" + + backup + "': " + exception.getMessage()); + } + } + } + + private void atomicMove(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + throw new IOException("Filesystem does not support an atomic export move from '" + + source + "' to '" + target + "'.", exception); + } + } + + private Path uniqueBackup(Path output) { + return output.resolveSibling("." + output.getFileName() + ".iris-backup-" + UUID.randomUUID()); + } + + private List sortedPaths(Map resources) { + List paths = new ArrayList<>(resources.keySet()); + paths.sort(String::compareTo); + return paths; + } + + private void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + List ordered = paths.sorted(Comparator.reverseOrder()).toList(); + IOException failure = null; + for (Path path : ordered) { + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } + } + } + if (failure != null) { + throw failure; + } + } + } + + record Publication(List cleanupWarnings) { + Publication { + cleanupWarnings = List.copyOf(cleanupWarnings); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaBlockState.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaBlockState.java new file mode 100644 index 000000000..888521311 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaBlockState.java @@ -0,0 +1,88 @@ +package art.arcane.iris.core.structure.export; + +import art.arcane.volmlib.util.nbt.tag.CompoundTag; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.regex.Pattern; + +record VanillaBlockState(String name, Map properties) { + private static final Pattern PROPERTY_NAME = Pattern.compile("[a-z0-9_.-]+"); + private static final Pattern PROPERTY_VALUE = Pattern.compile("[a-z0-9_.-]+"); + + VanillaBlockState { + properties = Collections.unmodifiableMap(new LinkedHashMap<>(properties)); + } + + static VanillaBlockState parse(String value) { + if (value == null) { + throw new IllegalArgumentException("Block state must not be null"); + } + String source = value.trim(); + int propertiesStart = source.indexOf('['); + String name = propertiesStart < 0 ? source : source.substring(0, propertiesStart); + if (!VanillaResourceIdentifier.validIdentifier(name)) { + throw new IllegalArgumentException("Invalid block identifier '" + name + "'"); + } + if (!name.startsWith("minecraft:")) { + throw new IllegalArgumentException("Vanilla cannot resolve non-Minecraft block '" + name + "'"); + } + if (propertiesStart < 0) { + return new VanillaBlockState(name, Map.of()); + } + if (!source.endsWith("]") || source.indexOf('[', propertiesStart + 1) >= 0) { + throw new IllegalArgumentException("Invalid property block in '" + value + "'"); + } + String body = source.substring(propertiesStart + 1, source.length() - 1); + if (body.isEmpty()) { + throw new IllegalArgumentException("Empty property block in '" + value + "'"); + } + Map properties = new TreeMap<>(); + for (String property : body.split(",", -1)) { + int separator = property.indexOf('='); + if (separator <= 0 || separator != property.lastIndexOf('=') || separator == property.length() - 1) { + throw new IllegalArgumentException("Invalid block property '" + property + "'"); + } + String propertyName = property.substring(0, separator); + String propertyValue = property.substring(separator + 1); + if (!PROPERTY_NAME.matcher(propertyName).matches() || !PROPERTY_VALUE.matcher(propertyValue).matches()) { + throw new IllegalArgumentException("Invalid block property '" + property + "'"); + } + if (properties.put(propertyName, propertyValue) != null) { + throw new IllegalArgumentException("Duplicate block property '" + propertyName + "'"); + } + } + return new VanillaBlockState(name, properties); + } + + String canonical() { + if (properties.isEmpty()) { + return name; + } + StringBuilder builder = new StringBuilder(name).append('['); + boolean first = true; + for (Map.Entry property : properties.entrySet()) { + if (!first) { + builder.append(','); + } + builder.append(property.getKey()).append('=').append(property.getValue()); + first = false; + } + return builder.append(']').toString(); + } + + CompoundTag toNbt() { + CompoundTag state = new CompoundTag(); + state.putString("Name", name); + if (!properties.isEmpty()) { + CompoundTag propertyTag = new CompoundTag(); + for (Map.Entry property : properties.entrySet()) { + propertyTag.putString(property.getKey(), property.getValue()); + } + state.put("Properties", propertyTag); + } + return state; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporter.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporter.java new file mode 100644 index 000000000..d2e2a9b50 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporter.java @@ -0,0 +1,74 @@ +package art.arcane.iris.core.structure.export; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +public final class VanillaJigsawDatapackExporter { + public VanillaJigsawExportValidation validate(VanillaJigsawExportRequest request) { + VanillaJigsawExportCompiler.Compilation compilation = new VanillaJigsawExportCompiler().compile(request); + List resources = new ArrayList<>(compilation.resources().keySet()); + resources.sort(String::compareTo); + return new VanillaJigsawExportValidation(resources, compilation.diagnostics()); + } + + public VanillaJigsawExportResult export(VanillaJigsawExportRequest request) { + if (Files.exists(request.output()) && !request.replaceExisting()) { + VanillaJigsawExportDiagnostic diagnostic = new VanillaJigsawExportDiagnostic( + VanillaJigsawExportDiagnostic.Severity.ERROR, + VanillaJigsawExportDiagnostic.Code.OUTPUT_EXISTS, + request.output().toString(), + "Export output already exists and replaceExisting is false."); + return new VanillaJigsawExportResult( + VanillaJigsawExportResult.Status.REJECTED, + request.output(), + List.of(), + List.of(diagnostic)); + } + + VanillaJigsawExportCompiler.Compilation compilation = new VanillaJigsawExportCompiler().compile(request); + if (compilation.hasErrors()) { + return new VanillaJigsawExportResult( + VanillaJigsawExportResult.Status.REJECTED, + request.output(), + List.of(), + compilation.diagnostics()); + } + + List diagnostics = new ArrayList<>(compilation.diagnostics()); + try { + AtomicDatapackPublisher.Publication publication = new AtomicDatapackPublisher().publish( + compilation.resources(), + request.output(), + request.format(), + request.replaceExisting()); + for (String warning : publication.cleanupWarnings()) { + diagnostics.add(new VanillaJigsawExportDiagnostic( + VanillaJigsawExportDiagnostic.Severity.WARNING, + VanillaJigsawExportDiagnostic.Code.CLEANUP_FAILED, + request.output().toString(), + warning)); + } + } catch (IOException exception) { + diagnostics.add(new VanillaJigsawExportDiagnostic( + VanillaJigsawExportDiagnostic.Severity.ERROR, + VanillaJigsawExportDiagnostic.Code.PUBLICATION_FAILED, + request.output().toString(), + "Atomic datapack publication failed: " + exception.getMessage())); + return new VanillaJigsawExportResult( + VanillaJigsawExportResult.Status.FAILED, + request.output(), + List.of(), + diagnostics); + } + + List resources = new ArrayList<>(compilation.resources().keySet()); + resources.sort(String::compareTo); + return new VanillaJigsawExportResult( + VanillaJigsawExportResult.Status.EXPORTED, + request.output(), + resources, + diagnostics); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportCompiler.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportCompiler.java new file mode 100644 index 000000000..73f45f97d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportCompiler.java @@ -0,0 +1,815 @@ +package art.arcane.iris.core.structure.export; + +import art.arcane.iris.engine.framework.structure.CompiledStructureGraph; +import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver; +import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; +import art.arcane.iris.engine.framework.structure.StructureGraphCompiler; +import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic; +import art.arcane.iris.engine.object.IrisJigsawCompatibility; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.ObjectPlaceMode; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class VanillaJigsawExportCompiler { + private static final int MAX_DEPTH = 20; + private static final int MAX_HORIZONTAL_DISTANCE = 128; + private static final int MAX_VERTICAL_DISTANCE = 4064; + private static final int MIN_ABSOLUTE_Y = -2032; + private static final int MAX_ABSOLUTE_Y = 2031; + private static final int MAX_POOL_WEIGHT = 150; + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + + Compilation compile(VanillaJigsawExportRequest request) { + List diagnostics = new ArrayList<>(); + validateIdentity(request, diagnostics); + validateSettings(request.settings(), diagnostics); + + IrisStructure structure; + try { + structure = request.source().loadStructure(); + } catch (RuntimeException exception) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.SOURCE_STRUCTURE_MISSING, + request.source().structureKey(), + "Could not load Iris structure '" + request.source().structureKey() + "': " + + exception.getMessage()); + return rejected(diagnostics); + } + if (structure == null) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.SOURCE_STRUCTURE_MISSING, + request.source().structureKey(), + "Iris structure '" + request.source().structureKey() + "' does not exist."); + return rejected(diagnostics); + } + + validateStructure(structure, request.settings(), diagnostics); + StructureGraphCompilation graphCompilation; + try { + graphCompilation = StructureGraphCompiler.compile(structure, request.source().resolver()); + } catch (RuntimeException exception) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.GRAPH_VALIDATION, + request.source().structureKey(), + "Structure graph compilation failed: " + exception.getMessage()); + return rejected(diagnostics); + } + addGraphDiagnostics(graphCompilation, diagnostics); + CompiledStructureGraph graph = graphCompilation.getGraph(); + validateGraph(graph, diagnostics); + if (hasErrors(diagnostics)) { + return rejected(diagnostics); + } + + try { + Map resources = createResources(request, graph); + return new Compilation(resources, List.copyOf(diagnostics)); + } catch (IOException | RuntimeException exception) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.SERIALIZATION_FAILED, + request.source().structureKey(), + "Vanilla datapack serialization failed: " + exception.getMessage()); + return rejected(diagnostics); + } + } + + private void validateIdentity( + VanillaJigsawExportRequest request, + List diagnostics + ) { + if (!VanillaResourceIdentifier.validNamespace(request.namespace())) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_NAMESPACE, + request.namespace(), + "Datapack namespace must match [a-z0-9_.-]+."); + } + if (!VanillaResourceIdentifier.validPath(request.resourcePath())) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH, + request.resourcePath(), + "Datapack resource path must use lowercase resource-path characters without traversal."); + } + } + + private void validateSettings( + VanillaJigsawExportSettings settings, + List diagnostics + ) { + if (settings.biomes().isEmpty()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_BIOME, + "biomes", + "At least one biome identifier is required."); + } + for (String biome : settings.biomes()) { + if (!VanillaResourceIdentifier.validIdentifier(biome)) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_BIOME, + biome, + "Biome identifier '" + biome + "' is not a valid namespaced identifier."); + } + } + if (settings.startHeight() < MIN_ABSOLUTE_Y || settings.startHeight() > MAX_ABSOLUTE_Y) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_SETTINGS, + "start_height", + "Absolute start height must be between " + MIN_ABSOLUTE_Y + " and " + MAX_ABSOLUTE_Y + "."); + } + if (settings.maxDistanceVertical() < 1 || settings.maxDistanceVertical() > MAX_VERTICAL_DISTANCE) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE, + "max_distance_from_center.vertical", + "Vertical maximum distance must be between 1 and " + MAX_VERTICAL_DISTANCE + "."); + } + if (settings.spacing() < 0 || settings.spacing() > 4096 + || settings.separation() < 0 || settings.separation() > 4096 + || settings.spacing() <= settings.separation()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_SETTINGS, + "random_spread", + "Random-spread spacing and separation must be within 0..4096, with spacing greater than separation."); + } + if (settings.salt() < 0) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_SETTINGS, + "random_spread.salt", + "Random-spread salt must be non-negative."); + } + if (!Float.isFinite(settings.frequency()) || settings.frequency() < 0.0F || settings.frequency() > 1.0F) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_SETTINGS, + "random_spread.frequency", + "Random-spread frequency must be finite and within 0..1."); + } + } + + private void validateStructure( + IrisStructure structure, + VanillaJigsawExportSettings settings, + List diagnostics + ) { + if (structure.resolvedCompatibility() != IrisJigsawCompatibility.VANILLA_PORTABLE) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_COMPATIBILITY, + structureKey(structure), + "Strict export requires compatibility VANILLA_PORTABLE."); + } + if (structure.getPlaceMode() != ObjectPlaceMode.STRUCTURE_PIECE) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_PLACE_MODE, + structureKey(structure), + "Vanilla export requires placeMode STRUCTURE_PIECE; Iris terrain and stilt modes have no vanilla jigsaw equivalent."); + } + if (structure.getEdit() != null && !structure.getEdit().isEmpty()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_EDIT, + structureKey(structure), + "Structure-wide Iris block edits cannot be represented losslessly by vanilla template pools."); + } + if (structure.getLoot() != null && !structure.getLoot().isEmpty()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_LOOT, + structureKey(structure), + "Structure-wide Iris loot injection cannot be represented losslessly by this vanilla exporter."); + } + if (structure.getThemeSets() != null && !structure.getThemeSets().isEmpty()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_THEME_METADATA, + structureKey(structure), + "Coherent Iris theme selection has no lossless vanilla jigsaw representation."); + } + if (structure.isRequireCaps()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_REQUIRED_CAPS, + structureKey(structure), + "Vanilla jigsaws cannot enforce Iris requireCaps terminal-closure semantics."); + } + if (structure.getMaxDepth() < 1 || structure.getMaxDepth() > MAX_DEPTH) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DEPTH, + structureKey(structure), + "Minecraft 26.2 jigsaw size must be within 0..20; Iris export requires 1..20."); + } + long horizontalDistance = (long) structure.getMaxSizeChunks() * 16L; + if (horizontalDistance < 1L || horizontalDistance > MAX_HORIZONTAL_DISTANCE) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE, + structureKey(structure), + "Iris maxSizeChunks maps to " + horizontalDistance + + " blocks, outside Minecraft 26.2's 1..128 horizontal limit."); + } + int terrainPadding = settings.terrainAdaptation() == VanillaJigsawExportSettings.TerrainAdaptation.NONE + ? 0 : 12; + if (horizontalDistance + terrainPadding > MAX_HORIZONTAL_DISTANCE) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE, + structureKey(structure), + "Horizontal distance plus vanilla terrain-adaptation padding must not exceed 128 blocks."); + } + } + + private void addGraphDiagnostics( + StructureGraphCompilation graphCompilation, + List diagnostics + ) { + for (StructureGraphDiagnostic diagnostic : graphCompilation.getDiagnostics()) { + VanillaJigsawExportDiagnostic.Severity severity = diagnostic.severity() + == StructureGraphDiagnostic.Severity.ERROR + ? VanillaJigsawExportDiagnostic.Severity.ERROR + : VanillaJigsawExportDiagnostic.Severity.WARNING; + diagnostics.add(new VanillaJigsawExportDiagnostic( + severity, + VanillaJigsawExportDiagnostic.Code.GRAPH_VALIDATION, + diagnostic.code().name(), + diagnostic.message())); + } + } + + private void validateGraph( + CompiledStructureGraph graph, + List diagnostics + ) { + Map workcells = + resolvedWorkcells(graph.getStructure()); + for (Map.Entry poolEntry : graph.getPools().entrySet()) { + String poolKey = poolEntry.getKey(); + if (!VanillaResourceIdentifier.validPath(poolKey)) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH, + poolKey, + "Iris pool keys must be valid lowercase resource paths for vanilla export."); + } + validatePool(graph, workcells, poolKey, poolEntry.getValue(), diagnostics); + } + for (Map.Entry pieceEntry : graph.getPieces().entrySet()) { + if (!pieceEnabled(workcells, pieceEntry.getValue())) { + continue; + } + String pieceKey = pieceEntry.getKey(); + if (!VanillaResourceIdentifier.validPath(pieceKey)) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH, + pieceKey, + "Iris piece keys must be valid lowercase resource paths for vanilla export."); + } + validatePiece(graph, pieceKey, pieceEntry.getValue(), diagnostics); + } + } + + private void validatePool( + CompiledStructureGraph graph, + Map workcells, + String poolKey, + IrisJigsawPool pool, + List diagnostics + ) { + if (pool.isMandatoryFallback()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_REQUIRED_CAPS, + poolKey, + "Vanilla template pools cannot enforce Iris mandatoryFallback terminal closure."); + } + if (pool.getFallback() != null && !pool.getFallback().isBlank() + && !VanillaResourceIdentifier.validPath(pool.getFallback().trim())) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH, + poolKey, + "Fallback pool key '" + pool.getFallback() + "' is not a valid vanilla resource path."); + } + if (pool.getPieces() == null) { + return; + } + for (int index = 0; index < pool.getPieces().size(); index++) { + IrisJigsawPieceEntry entry = pool.getPieces().get(index); + if (entry == null) { + continue; + } + if (!entry.isEmpty() + && !pieceEnabled(workcells, graph.getPieces().get(trim(entry.getPiece())))) { + continue; + } + if (entry.getWeight() < 1 || entry.getWeight() > MAX_POOL_WEIGHT) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_POOL_WEIGHT, + poolKey + "/pieces[" + index + "]", + "Minecraft 26.2 template-pool weights must be within 1..150."); + } + if (entry.getChance() != 1D) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_CHANCE, + poolKey + "/pieces[" + index + "]", + "Independent Iris membership chance cannot be represented by vanilla pool weights."); + } + if (!entry.isEmpty() && !VanillaResourceIdentifier.validPath(trim(entry.getPiece()))) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH, + poolKey + "/pieces[" + index + "]", + "Piece key '" + entry.getPiece() + "' is not a valid vanilla resource path."); + } + } + } + + private void validatePiece( + CompiledStructureGraph graph, + String pieceKey, + IrisJigsawPiece piece, + List diagnostics + ) { + if (piece.getThemes() != null && !piece.getThemes().isEmpty()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_THEME_METADATA, + pieceKey, + "Iris piece theme membership has no vanilla template-pool equivalent."); + } + IrisJigsawPieceRules rules = piece.resolvedRules(); + if (rules.getMinimumDepth() != 0 + || rules.getMaximumDepth() != 30 + || rules.getMinimumPlacements() != 0 + || rules.getMaximumPlacements() != 0 + || rules.isTerminal()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_PIECE_RULES, + pieceKey, + "Iris depth, placement-count, and terminal rules cannot be serialized losslessly to vanilla."); + } + if (!piece.isRotatable()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_FIXED_ROTATION, + pieceKey, + "Vanilla template pools do not provide an exact fixed-rotation equivalent for Iris rotatable=false."); + } + if (!piece.isCollidable()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_NON_COLLIDABLE_PIECE, + pieceKey, + "Vanilla template pools cannot serialize Iris collidable=false assembly metadata."); + } + IrisObject object = graph.getObjects().get(trim(piece.getObject())); + if (object == null) { + return; + } + validateObject(pieceKey, object, diagnostics); + validateConnectors(graph, pieceKey, piece, object, diagnostics); + } + + private void validateObject( + String pieceKey, + IrisObject object, + List diagnostics + ) { + if (!object.getStates().isEmpty()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_TILE_DATA, + pieceKey, + "The .iob contains tile payloads; exact registry-aware block-entity NBT export is not available in core."); + } + for (Map.Entry entry : object.getBlocks()) { + PlatformBlockState state = entry.getValue(); + String resource = pieceKey + "@" + entry.getKey(); + IrisBlockVector position = entry.getKey(); + int x = position.getBlockX() + object.getCenter().getX(); + int y = position.getBlockY() + object.getCenter().getY(); + int z = position.getBlockZ() + object.getCenter().getZ(); + if (x < 0 || x >= object.getW() || y < 0 || y >= object.getH() || z < 0 || z >= object.getD()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_BLOCK_STATE, + resource, + "The .iob contains a block outside its declared dimensions."); + continue; + } + if (state.isCustom()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_CUSTOM_BLOCK, + resource, + "Custom-content block '" + state.key() + "' is not available in unmodded vanilla."); + continue; + } + if (state.hasTileEntity()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_BLOCK_ENTITY, + resource, + "Block '" + state.key() + "' requires block-entity NBT that core cannot export losslessly."); + } + try { + VanillaBlockState parsed = VanillaBlockState.parse(state.key()); + if (parsed.name().equals("minecraft:jigsaw") + || parsed.name().equals("minecraft:structure_block") + || parsed.name().equals("minecraft:structure_void")) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_MARKER_BLOCK, + resource, + "Marker block '" + parsed.name() + "' must be represented by Iris connector metadata, not object blocks."); + } + } catch (IllegalArgumentException exception) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_BLOCK_STATE, + resource, + exception.getMessage()); + } + } + } + + private void validateConnectors( + CompiledStructureGraph graph, + String pieceKey, + IrisJigsawPiece piece, + IrisObject object, + List diagnostics + ) { + if (piece.getConnectors() == null) { + return; + } + Set positions = new LinkedHashSet<>(); + for (int index = 0; index < piece.getConnectors().size(); index++) { + IrisJigsawConnector connector = piece.getConnectors().get(index); + if (connector == null) { + continue; + } + String resource = pieceKey + "/connectors[" + index + "]"; + if (connector.getChannel() != null && !connector.getChannel().isBlank()) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_CHANNEL, + resource, + "Iris connector channels have no vanilla jigsaw NBT equivalent."); + } + validateConnectorIdentifier(connector.getName(), resource + "/name", diagnostics); + validateConnectorIdentifier(connector.getTargetName(), resource + "/target", diagnostics); + String poolKey = trim(connector.getPool()); + if (!VanillaResourceIdentifier.validPath(poolKey) || !graph.getPools().containsKey(poolKey)) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_RESOURCE_PATH, + resource + "/pool", + "Connector pool '" + connector.getPool() + "' cannot be mapped to an exported template pool."); + } + try { + VanillaStructureTemplateEncoder.orientation(connector.getDirection(), connector.getTop()); + } catch (IllegalArgumentException | NullPointerException exception) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_ORIENTATION, + resource, + exception.getMessage() == null ? "Connector orientation is incomplete." : exception.getMessage()); + } + IrisPosition position = connector.getPosition(); + if (position == null || !inside(position, object)) { + continue; + } + String positionKey = position.getX() + "," + position.getY() + "," + position.getZ(); + if (!positions.add(positionKey)) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.DUPLICATE_CONNECTOR_POSITION, + resource, + "Multiple vanilla jigsaw block entities cannot occupy " + positionKey + "."); + } + validateFinalState(object, connector, resource, diagnostics); + } + } + + private void validateConnectorIdentifier( + String value, + String resource, + List diagnostics + ) { + try { + VanillaResourceIdentifier.normalizeConnectorIdentifier(value); + } catch (IllegalArgumentException exception) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_ID, + resource, + exception.getMessage()); + } + } + + private void validateFinalState( + IrisObject object, + IrisJigsawConnector connector, + String resource, + List diagnostics + ) { + VanillaBlockState configured; + try { + configured = VanillaBlockState.parse(connector.getFinalState()); + } catch (IllegalArgumentException exception) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_FINAL_STATE, + resource, + exception.getMessage()); + return; + } + IrisPosition position = connector.getPosition(); + PlatformBlockState objectState = object.getBlocks().get( + object.getSigned(position.getX(), position.getY(), position.getZ())); + String expectedSource = objectState == null ? "minecraft:structure_void" : objectState.key(); + try { + VanillaBlockState expected = VanillaBlockState.parse(expectedSource); + if (!configured.canonical().equals(expected.canonical())) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_FINAL_STATE, + resource, + "Connector finalState '" + configured.canonical() + "' does not match the .iob block '" + + expected.canonical() + "' at that position. Use minecraft:structure_void for an absent block."); + } + } catch (IllegalArgumentException exception) { + addError(diagnostics, + VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_FINAL_STATE, + resource, + "The .iob block under the connector is not vanilla-compatible: " + exception.getMessage()); + } + } + + private Map createResources( + VanillaJigsawExportRequest request, + CompiledStructureGraph graph + ) throws IOException { + Map resources = new LinkedHashMap<>(); + putJson(resources, "pack.mcmeta", packMetadata(request)); + putJson(resources, biomeTagPath(request), biomeTag(request)); + putJson(resources, processorPath(request), processorList()); + putJson(resources, structurePath(request), structure(request, graph.getStructure())); + putJson(resources, structureSetPath(request), structureSet(request)); + + for (Map.Entry poolEntry : graph.getPools().entrySet()) { + putJson(resources, + poolPath(request, poolEntry.getKey()), + templatePool(request, graph, poolEntry.getValue())); + } + + VanillaStructureTemplateEncoder encoder = new VanillaStructureTemplateEncoder(); + Map workcells = + resolvedWorkcells(graph.getStructure()); + for (Map.Entry pieceEntry : graph.getPieces().entrySet()) { + if (!pieceEnabled(workcells, pieceEntry.getValue())) { + continue; + } + IrisObject object = graph.getObjects().get(trim(pieceEntry.getValue().getObject())); + byte[] template = encoder.encode( + object, + pieceEntry.getValue(), + poolKey -> poolIdentifier(request, trim(poolKey))); + resources.put(templatePath(request, pieceEntry.getKey()), template); + } + return resources; + } + + private JsonObject packMetadata(VanillaJigsawExportRequest request) { + JsonObject pack = new JsonObject(); + pack.addProperty("description", request.description()); + JsonArray minimum = new JsonArray(); + minimum.add(107); + minimum.add(1); + pack.add("min_format", minimum); + pack.addProperty("max_format", 107); + JsonObject root = new JsonObject(); + root.add("pack", pack); + return root; + } + + private JsonObject biomeTag(VanillaJigsawExportRequest request) { + JsonObject root = new JsonObject(); + root.addProperty("replace", false); + JsonArray values = new JsonArray(); + for (String biome : request.settings().biomes()) { + values.add(biome); + } + root.add("values", values); + return root; + } + + private JsonObject processorList() { + JsonObject root = new JsonObject(); + root.add("processors", new JsonArray()); + return root; + } + + private JsonObject structure(VanillaJigsawExportRequest request, IrisStructure structure) { + VanillaJigsawExportSettings settings = request.settings(); + JsonObject root = new JsonObject(); + root.addProperty("type", "minecraft:jigsaw"); + root.addProperty("biomes", "#" + request.namespace() + ":" + request.resourcePath()); + int horizontalDistance = structure.getMaxSizeChunks() * 16; + if (horizontalDistance == settings.maxDistanceVertical()) { + root.addProperty("max_distance_from_center", horizontalDistance); + } else { + JsonObject distance = new JsonObject(); + distance.addProperty("horizontal", horizontalDistance); + distance.addProperty("vertical", settings.maxDistanceVertical()); + root.add("max_distance_from_center", distance); + } + if (settings.projectHeightmap() != VanillaJigsawExportSettings.ProjectHeightmap.NONE) { + root.addProperty("project_start_to_heightmap", settings.projectHeightmap().serializedName()); + } + root.addProperty("size", structure.getMaxDepth()); + root.add("spawn_overrides", new JsonObject()); + JsonObject startHeight = new JsonObject(); + startHeight.addProperty("absolute", settings.startHeight()); + root.add("start_height", startHeight); + root.addProperty("start_pool", poolIdentifier(request, trim(structure.getStartPool()))); + root.addProperty("step", settings.generationStep().serializedName()); + root.addProperty("terrain_adaptation", settings.terrainAdaptation().serializedName()); + root.addProperty("use_expansion_hack", settings.expansionHack()); + return root; + } + + private JsonObject structureSet(VanillaJigsawExportRequest request) { + JsonObject structureEntry = new JsonObject(); + structureEntry.addProperty("structure", structureIdentifier(request)); + structureEntry.addProperty("weight", 1); + JsonArray structures = new JsonArray(); + structures.add(structureEntry); + + VanillaJigsawExportSettings settings = request.settings(); + JsonObject placement = new JsonObject(); + placement.addProperty("type", "minecraft:random_spread"); + placement.addProperty("frequency", settings.frequency()); + placement.addProperty("salt", settings.salt()); + placement.addProperty("separation", settings.separation()); + placement.addProperty("spacing", settings.spacing()); + placement.addProperty("spread_type", settings.spreadType().serializedName()); + + JsonObject root = new JsonObject(); + root.add("placement", placement); + root.add("structures", structures); + return root; + } + + private JsonObject templatePool( + VanillaJigsawExportRequest request, + CompiledStructureGraph graph, + IrisJigsawPool pool + ) { + JsonObject root = new JsonObject(); + String fallback = trim(pool.getFallback()); + root.addProperty("fallback", fallback.isEmpty() ? "minecraft:empty" : poolIdentifier(request, fallback)); + JsonArray elements = new JsonArray(); + Map workcells = + resolvedWorkcells(graph.getStructure()); + if (pool.getPieces() != null) { + for (IrisJigsawPieceEntry entry : pool.getPieces()) { + if (!entry.isEmpty() + && !pieceEnabled(workcells, graph.getPieces().get(trim(entry.getPiece())))) { + continue; + } + JsonObject weightedElement = new JsonObject(); + JsonObject element = new JsonObject(); + if (entry.isEmpty()) { + element.addProperty("element_type", "minecraft:empty_pool_element"); + } else { + element.addProperty("element_type", "minecraft:single_pool_element"); + element.addProperty("location", templateIdentifier(request, trim(entry.getPiece()))); + element.addProperty("processors", processorIdentifier(request)); + element.addProperty("projection", "rigid"); + } + weightedElement.add("element", element); + weightedElement.addProperty("weight", entry.getWeight()); + elements.add(weightedElement); + } + } + root.add("elements", elements); + return root; + } + + private static Map + resolvedWorkcells(IrisStructure structure) { + return structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW + ? PlanarJigsawWorkcellResolver.resolve(structure) + : Map.of(); + } + + private static boolean pieceEnabled( + Map workcells, + IrisJigsawPiece piece + ) { + return workcells.isEmpty() + || piece == null + || PlanarJigsawWorkcellResolver.workcell(workcells, piece).enabled(); + } + + private void putJson(Map resources, String path, JsonObject value) { + resources.put(path, (GSON.toJson(value) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + private String biomeTagPath(VanillaJigsawExportRequest request) { + return "data/" + request.namespace() + "/tags/worldgen/biome/" + request.resourcePath() + ".json"; + } + + private String processorPath(VanillaJigsawExportRequest request) { + return "data/" + request.namespace() + "/worldgen/processor_list/" + + request.resourcePath() + "/empty.json"; + } + + private String structurePath(VanillaJigsawExportRequest request) { + return "data/" + request.namespace() + "/worldgen/structure/" + request.resourcePath() + ".json"; + } + + private String structureSetPath(VanillaJigsawExportRequest request) { + return "data/" + request.namespace() + "/worldgen/structure_set/" + request.resourcePath() + ".json"; + } + + private String poolPath(VanillaJigsawExportRequest request, String poolKey) { + return "data/" + request.namespace() + "/worldgen/template_pool/" + + request.resourcePath() + "/pool/" + poolKey + ".json"; + } + + private String templatePath(VanillaJigsawExportRequest request, String pieceKey) { + return "data/" + request.namespace() + "/structure/" + + request.resourcePath() + "/piece/" + pieceKey + ".nbt"; + } + + private String structureIdentifier(VanillaJigsawExportRequest request) { + return request.namespace() + ":" + request.resourcePath(); + } + + private String processorIdentifier(VanillaJigsawExportRequest request) { + return request.namespace() + ":" + request.resourcePath() + "/empty"; + } + + private String poolIdentifier(VanillaJigsawExportRequest request, String poolKey) { + return request.namespace() + ":" + request.resourcePath() + "/pool/" + poolKey; + } + + private String templateIdentifier(VanillaJigsawExportRequest request, String pieceKey) { + return request.namespace() + ":" + request.resourcePath() + "/piece/" + pieceKey; + } + + private boolean inside(IrisPosition position, IrisObject object) { + return position.getX() >= 0 && position.getX() < object.getW() + && position.getY() >= 0 && position.getY() < object.getH() + && position.getZ() >= 0 && position.getZ() < object.getD(); + } + + private boolean hasErrors(List diagnostics) { + for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) { + if (diagnostic.isBlocking()) { + return true; + } + } + return false; + } + + private Compilation rejected(List diagnostics) { + return new Compilation(Map.of(), List.copyOf(diagnostics)); + } + + private void addError( + List diagnostics, + VanillaJigsawExportDiagnostic.Code code, + String resource, + String message + ) { + diagnostics.add(new VanillaJigsawExportDiagnostic( + VanillaJigsawExportDiagnostic.Severity.ERROR, + code, + resource, + message)); + } + + private String structureKey(IrisStructure structure) { + String key = structure.getLoadKey(); + return key == null || key.isBlank() ? "" : key; + } + + private String trim(String value) { + return value == null ? "" : value.trim(); + } + + record Compilation( + Map resources, + List diagnostics + ) { + Compilation { + resources = Map.copyOf(resources); + diagnostics = List.copyOf(diagnostics); + } + + boolean hasErrors() { + for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) { + if (diagnostic.isBlocking()) { + return true; + } + } + return false; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportDiagnostic.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportDiagnostic.java new file mode 100644 index 000000000..42aa3b160 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportDiagnostic.java @@ -0,0 +1,58 @@ +package art.arcane.iris.core.structure.export; + +import java.util.Objects; + +public record VanillaJigsawExportDiagnostic(Severity severity, Code code, String resource, String message) { + public VanillaJigsawExportDiagnostic { + Objects.requireNonNull(severity); + Objects.requireNonNull(code); + resource = resource == null ? "" : resource; + Objects.requireNonNull(message); + } + + public boolean isBlocking() { + return severity == Severity.ERROR; + } + + public enum Severity { + ERROR, + WARNING + } + + public enum Code { + SOURCE_STRUCTURE_MISSING, + GRAPH_VALIDATION, + INVALID_NAMESPACE, + INVALID_RESOURCE_PATH, + INVALID_BIOME, + INVALID_SETTINGS, + UNSUPPORTED_COMPATIBILITY, + UNSUPPORTED_PLACE_MODE, + UNSUPPORTED_EDIT, + UNSUPPORTED_LOOT, + UNSUPPORTED_THEME_METADATA, + UNSUPPORTED_CHANCE, + UNSUPPORTED_PIECE_RULES, + UNSUPPORTED_REQUIRED_CAPS, + UNSUPPORTED_CHANNEL, + UNSUPPORTED_FIXED_ROTATION, + UNSUPPORTED_NON_COLLIDABLE_PIECE, + UNSUPPORTED_TILE_DATA, + UNSUPPORTED_BLOCK_ENTITY, + UNSUPPORTED_CUSTOM_BLOCK, + UNSUPPORTED_MARKER_BLOCK, + INVALID_MAX_DEPTH, + INVALID_MAX_DISTANCE, + INVALID_POOL_WEIGHT, + INVALID_CONNECTOR, + INVALID_CONNECTOR_ID, + INVALID_CONNECTOR_ORIENTATION, + INVALID_CONNECTOR_FINAL_STATE, + DUPLICATE_CONNECTOR_POSITION, + INVALID_BLOCK_STATE, + OUTPUT_EXISTS, + SERIALIZATION_FAILED, + PUBLICATION_FAILED, + CLEANUP_FAILED + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportFormat.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportFormat.java new file mode 100644 index 000000000..f9665dd78 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportFormat.java @@ -0,0 +1,6 @@ +package art.arcane.iris.core.structure.export; + +public enum VanillaJigsawExportFormat { + DIRECTORY, + ZIP +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportRequest.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportRequest.java new file mode 100644 index 000000000..1f0def324 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportRequest.java @@ -0,0 +1,113 @@ +package art.arcane.iris.core.structure.export; + +import java.nio.file.Path; +import java.util.Objects; + +public final class VanillaJigsawExportRequest { + private final VanillaJigsawExportSource source; + private final Path output; + private final String namespace; + private final String resourcePath; + private final String description; + private final VanillaJigsawExportFormat format; + private final VanillaJigsawExportSettings settings; + private final boolean replaceExisting; + + private VanillaJigsawExportRequest(Builder builder) { + source = builder.source; + output = builder.output.toAbsolutePath().normalize(); + namespace = builder.namespace; + resourcePath = builder.resourcePath; + description = builder.description; + format = builder.format; + settings = builder.settings; + replaceExisting = builder.replaceExisting; + } + + public static Builder builder(VanillaJigsawExportSource source, Path output) { + return new Builder(source, output); + } + + public VanillaJigsawExportSource source() { + return source; + } + + public Path output() { + return output; + } + + public String namespace() { + return namespace; + } + + public String resourcePath() { + return resourcePath; + } + + public String description() { + return description; + } + + public VanillaJigsawExportFormat format() { + return format; + } + + public VanillaJigsawExportSettings settings() { + return settings; + } + + public boolean replaceExisting() { + return replaceExisting; + } + + public static final class Builder { + private final VanillaJigsawExportSource source; + private final Path output; + private String namespace = "iris"; + private String resourcePath; + private String description = "Iris vanilla jigsaw export for Minecraft 26.2"; + private VanillaJigsawExportFormat format = VanillaJigsawExportFormat.DIRECTORY; + private VanillaJigsawExportSettings settings = VanillaJigsawExportSettings.defaults(); + private boolean replaceExisting; + + private Builder(VanillaJigsawExportSource source, Path output) { + this.source = Objects.requireNonNull(source); + this.output = Objects.requireNonNull(output); + resourcePath = source.structureKey(); + } + + public Builder namespace(String value) { + namespace = Objects.requireNonNull(value).trim(); + return this; + } + + public Builder resourcePath(String value) { + resourcePath = Objects.requireNonNull(value).trim(); + return this; + } + + public Builder description(String value) { + description = Objects.requireNonNull(value); + return this; + } + + public Builder format(VanillaJigsawExportFormat value) { + format = Objects.requireNonNull(value); + return this; + } + + public Builder settings(VanillaJigsawExportSettings value) { + settings = Objects.requireNonNull(value); + return this; + } + + public Builder replaceExisting(boolean value) { + replaceExisting = value; + return this; + } + + public VanillaJigsawExportRequest build() { + return new VanillaJigsawExportRequest(this); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportResult.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportResult.java new file mode 100644 index 000000000..e3fc24e20 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportResult.java @@ -0,0 +1,38 @@ +package art.arcane.iris.core.structure.export; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record VanillaJigsawExportResult( + Status status, + Path output, + List resources, + List diagnostics +) { + public VanillaJigsawExportResult { + Objects.requireNonNull(status); + Objects.requireNonNull(output); + resources = List.copyOf(resources); + diagnostics = List.copyOf(diagnostics); + } + + public boolean isSuccess() { + return status == Status.EXPORTED; + } + + public boolean hasBlockingDiagnostics() { + for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) { + if (diagnostic.isBlocking()) { + return true; + } + } + return false; + } + + public enum Status { + EXPORTED, + REJECTED, + FAILED + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSettings.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSettings.java new file mode 100644 index 000000000..ae4522815 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSettings.java @@ -0,0 +1,243 @@ +package art.arcane.iris.core.structure.export; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class VanillaJigsawExportSettings { + private final List biomes; + private final int startHeight; + private final ProjectHeightmap projectHeightmap; + private final GenerationStep generationStep; + private final TerrainAdaptation terrainAdaptation; + private final boolean expansionHack; + private final int maxDistanceVertical; + private final int spacing; + private final int separation; + private final int salt; + private final float frequency; + private final SpreadType spreadType; + + private VanillaJigsawExportSettings(Builder builder) { + biomes = List.copyOf(builder.biomes); + startHeight = builder.startHeight; + projectHeightmap = builder.projectHeightmap; + generationStep = builder.generationStep; + terrainAdaptation = builder.terrainAdaptation; + expansionHack = builder.expansionHack; + maxDistanceVertical = builder.maxDistanceVertical; + spacing = builder.spacing; + separation = builder.separation; + salt = builder.salt; + frequency = builder.frequency; + spreadType = builder.spreadType; + } + + public static Builder builder() { + return new Builder(); + } + + public static VanillaJigsawExportSettings defaults() { + return builder().build(); + } + + public List biomes() { + return biomes; + } + + public int startHeight() { + return startHeight; + } + + public ProjectHeightmap projectHeightmap() { + return projectHeightmap; + } + + public GenerationStep generationStep() { + return generationStep; + } + + public TerrainAdaptation terrainAdaptation() { + return terrainAdaptation; + } + + public boolean expansionHack() { + return expansionHack; + } + + public int maxDistanceVertical() { + return maxDistanceVertical; + } + + public int spacing() { + return spacing; + } + + public int separation() { + return separation; + } + + public int salt() { + return salt; + } + + public float frequency() { + return frequency; + } + + public SpreadType spreadType() { + return spreadType; + } + + public enum ProjectHeightmap { + NONE(null), + WORLD_SURFACE_WG("WORLD_SURFACE_WG"), + WORLD_SURFACE("WORLD_SURFACE"), + OCEAN_FLOOR_WG("OCEAN_FLOOR_WG"), + OCEAN_FLOOR("OCEAN_FLOOR"), + MOTION_BLOCKING("MOTION_BLOCKING"), + MOTION_BLOCKING_NO_LEAVES("MOTION_BLOCKING_NO_LEAVES"); + + private final String serializedName; + + ProjectHeightmap(String serializedName) { + this.serializedName = serializedName; + } + + public String serializedName() { + return serializedName; + } + } + + public enum GenerationStep { + RAW_GENERATION("raw_generation"), + LAKES("lakes"), + LOCAL_MODIFICATIONS("local_modifications"), + UNDERGROUND_STRUCTURES("underground_structures"), + SURFACE_STRUCTURES("surface_structures"), + STRONGHOLDS("strongholds"), + UNDERGROUND_ORES("underground_ores"), + UNDERGROUND_DECORATION("underground_decoration"), + FLUID_SPRINGS("fluid_springs"), + VEGETAL_DECORATION("vegetal_decoration"), + TOP_LAYER_MODIFICATION("top_layer_modification"); + + private final String serializedName; + + GenerationStep(String serializedName) { + this.serializedName = serializedName; + } + + public String serializedName() { + return serializedName; + } + } + + public enum TerrainAdaptation { + NONE("none"), + BURY("bury"), + BEARD_THIN("beard_thin"), + BEARD_BOX("beard_box"), + ENCAPSULATE("encapsulate"); + + private final String serializedName; + + TerrainAdaptation(String serializedName) { + this.serializedName = serializedName; + } + + public String serializedName() { + return serializedName; + } + } + + public enum SpreadType { + LINEAR("linear"), + TRIANGULAR("triangular"); + + private final String serializedName; + + SpreadType(String serializedName) { + this.serializedName = serializedName; + } + + public String serializedName() { + return serializedName; + } + } + + public static final class Builder { + private final List biomes = new ArrayList<>(List.of("minecraft:plains")); + private int startHeight; + private ProjectHeightmap projectHeightmap = ProjectHeightmap.WORLD_SURFACE_WG; + private GenerationStep generationStep = GenerationStep.SURFACE_STRUCTURES; + private TerrainAdaptation terrainAdaptation = TerrainAdaptation.NONE; + private boolean expansionHack; + private int maxDistanceVertical = 4064; + private int spacing = 32; + private int separation = 8; + private int salt; + private float frequency = 1.0F; + private SpreadType spreadType = SpreadType.LINEAR; + + private Builder() { + } + + public Builder biomes(List values) { + biomes.clear(); + biomes.addAll(Objects.requireNonNull(values)); + return this; + } + + public Builder startHeight(int value) { + startHeight = value; + return this; + } + + public Builder projectHeightmap(ProjectHeightmap value) { + projectHeightmap = Objects.requireNonNull(value); + return this; + } + + public Builder generationStep(GenerationStep value) { + generationStep = Objects.requireNonNull(value); + return this; + } + + public Builder terrainAdaptation(TerrainAdaptation value) { + terrainAdaptation = Objects.requireNonNull(value); + return this; + } + + public Builder expansionHack(boolean value) { + expansionHack = value; + return this; + } + + public Builder maxDistanceVertical(int value) { + maxDistanceVertical = value; + return this; + } + + public Builder randomSpread(int spacingValue, int separationValue, int saltValue) { + spacing = spacingValue; + separation = separationValue; + salt = saltValue; + return this; + } + + public Builder frequency(float value) { + frequency = value; + return this; + } + + public Builder spreadType(SpreadType value) { + spreadType = Objects.requireNonNull(value); + return this; + } + + public VanillaJigsawExportSettings build() { + return new VanillaJigsawExportSettings(this); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSource.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSource.java new file mode 100644 index 000000000..e063d0faa --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportSource.java @@ -0,0 +1,69 @@ +package art.arcane.iris.core.structure.export; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.structure.StructureGraphResolver; +import art.arcane.iris.engine.object.IrisStructure; + +import java.util.Objects; + +public final class VanillaJigsawExportSource { + private final String structureKey; + private final IrisData data; + private final IrisStructure structure; + private final StructureGraphResolver resolver; + + private VanillaJigsawExportSource( + String structureKey, + IrisData data, + IrisStructure structure, + StructureGraphResolver resolver + ) { + this.structureKey = requireKey(structureKey); + this.data = data; + this.structure = structure; + this.resolver = resolver; + } + + public static VanillaJigsawExportSource forData(IrisData data, String structureKey) { + return new VanillaJigsawExportSource( + structureKey, + Objects.requireNonNull(data), + null, + StructureGraphResolver.forData(data)); + } + + public static VanillaJigsawExportSource forStructure( + String structureKey, + IrisStructure structure, + StructureGraphResolver resolver + ) { + return new VanillaJigsawExportSource( + structureKey, + null, + Objects.requireNonNull(structure), + Objects.requireNonNull(resolver)); + } + + public String structureKey() { + return structureKey; + } + + public IrisStructure loadStructure() { + if (structure != null) { + return structure; + } + return data.load(IrisStructure.class, structureKey, false); + } + + public StructureGraphResolver resolver() { + return resolver; + } + + private static String requireKey(String value) { + String key = Objects.requireNonNull(value).trim(); + if (key.isEmpty()) { + throw new IllegalArgumentException("Structure key must not be blank"); + } + return key; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportValidation.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportValidation.java new file mode 100644 index 000000000..6144e593c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaJigsawExportValidation.java @@ -0,0 +1,22 @@ +package art.arcane.iris.core.structure.export; + +import java.util.List; + +public record VanillaJigsawExportValidation( + List plannedResources, + List diagnostics +) { + public VanillaJigsawExportValidation { + plannedResources = List.copyOf(plannedResources); + diagnostics = List.copyOf(diagnostics); + } + + public boolean isExportable() { + for (VanillaJigsawExportDiagnostic diagnostic : diagnostics) { + if (diagnostic.isBlocking()) { + return false; + } + } + return true; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaResourceIdentifier.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaResourceIdentifier.java new file mode 100644 index 000000000..a457c14de --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaResourceIdentifier.java @@ -0,0 +1,56 @@ +package art.arcane.iris.core.structure.export; + +import java.util.regex.Pattern; + +final class VanillaResourceIdentifier { + private static final Pattern NAMESPACE = Pattern.compile("[a-z0-9_.-]+"); + private static final Pattern PATH = Pattern.compile("[a-z0-9/._-]+"); + + private VanillaResourceIdentifier() { + } + + static boolean validNamespace(String value) { + return value != null && NAMESPACE.matcher(value).matches(); + } + + static boolean validPath(String value) { + if (value == null + || !PATH.matcher(value).matches() + || value.startsWith("/") + || value.endsWith("/") + || value.contains("//")) { + return false; + } + for (String segment : value.split("/")) { + if (segment.equals(".") || segment.equals("..")) { + return false; + } + } + return true; + } + + static boolean validIdentifier(String value) { + if (value == null) { + return false; + } + int separator = value.indexOf(':'); + if (separator <= 0 || separator != value.lastIndexOf(':')) { + return false; + } + return validNamespace(value.substring(0, separator)) && validPath(value.substring(separator + 1)); + } + + static String normalizeConnectorIdentifier(String value) { + if (value == null || value.isBlank()) { + return "minecraft:empty"; + } + String normalized = value.trim(); + if (normalized.indexOf(':') < 0) { + normalized = "minecraft:" + normalized; + } + if (!validIdentifier(normalized)) { + throw new IllegalArgumentException("Invalid resource identifier '" + value + "'"); + } + return normalized; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/structure/export/VanillaStructureTemplateEncoder.java b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaStructureTemplateEncoder.java new file mode 100644 index 000000000..2de7db6c3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/structure/export/VanillaStructureTemplateEncoder.java @@ -0,0 +1,176 @@ +package art.arcane.iris.core.structure.export; + +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.io.NamedTag; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.IntTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; + +final class VanillaStructureTemplateEncoder { + private static final int DATA_VERSION_26_2 = 4903; + private static final Comparator BLOCK_ORDER = Comparator + .comparingInt((BlockEntry entry) -> entry.position().y()) + .thenComparingInt(entry -> entry.position().x()) + .thenComparingInt(entry -> entry.position().z()); + + byte[] encode( + IrisObject object, + IrisJigsawPiece piece, + Function poolIdentifier + ) throws IOException { + Map blocks = objectBlocks(object); + addConnectors(blocks, piece, poolIdentifier); + List orderedBlocks = new ArrayList<>(blocks.values()); + orderedBlocks.sort(BLOCK_ORDER); + + Map paletteIndexes = new LinkedHashMap<>(); + List paletteStates = new ArrayList<>(); + for (BlockEntry block : orderedBlocks) { + String key = block.state().canonical(); + if (!paletteIndexes.containsKey(key)) { + paletteIndexes.put(key, paletteStates.size()); + paletteStates.add(block.state()); + } + } + + CompoundTag root = new CompoundTag(); + root.put("size", intList(object.getW(), object.getH(), object.getD())); + root.put("palette", palette(paletteStates)); + root.put("blocks", blocks(orderedBlocks, paletteIndexes)); + root.put("entities", new ListTag<>(CompoundTag.class)); + root.putInt("DataVersion", DATA_VERSION_26_2); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + NBTUtil.write(new NamedTag("", root), output, true); + return output.toByteArray(); + } + + private Map objectBlocks(IrisObject object) { + Map blocks = new LinkedHashMap<>(); + Vector3i center = object.getCenter(); + for (Map.Entry entry : object.getBlocks()) { + IrisBlockVector signed = entry.getKey(); + BlockPosition position = new BlockPosition( + signed.getBlockX() + center.getX(), + signed.getBlockY() + center.getY(), + signed.getBlockZ() + center.getZ()); + VanillaBlockState state = VanillaBlockState.parse(entry.getValue().key()); + blocks.put(position, new BlockEntry(position, state, null)); + } + return blocks; + } + + private void addConnectors( + Map blocks, + IrisJigsawPiece piece, + Function poolIdentifier + ) { + for (IrisJigsawConnector connector : piece.getConnectors()) { + IrisPosition sourcePosition = connector.getPosition(); + BlockPosition position = new BlockPosition( + sourcePosition.getX(), sourcePosition.getY(), sourcePosition.getZ()); + String orientation = orientation(connector.getDirection(), connector.getTop()); + VanillaBlockState state = VanillaBlockState.parse( + "minecraft:jigsaw[orientation=" + orientation + "]"); + CompoundTag nbt = connectorNbt(connector, poolIdentifier.apply(connector.getPool())); + blocks.put(position, new BlockEntry(position, state, nbt)); + } + } + + private CompoundTag connectorNbt(IrisJigsawConnector connector, String poolIdentifier) { + CompoundTag nbt = new CompoundTag(); + nbt.putString("id", "minecraft:jigsaw"); + nbt.putString("name", VanillaResourceIdentifier.normalizeConnectorIdentifier(connector.getName())); + nbt.putString("target", VanillaResourceIdentifier.normalizeConnectorIdentifier(connector.getTargetName())); + nbt.putString("pool", poolIdentifier); + nbt.putString("final_state", VanillaBlockState.parse(connector.getFinalState()).canonical()); + nbt.putString("joint", connector.getJoint().name().toLowerCase(Locale.ROOT)); + nbt.putInt("selection_priority", connector.getSelectionPriority()); + nbt.putInt("placement_priority", connector.getPlacementPriority()); + return nbt; + } + + static String orientation(IrisDirection front, IrisDirection top) { + if (front == IrisDirection.UP_POSITIVE_Y || front == IrisDirection.DOWN_NEGATIVE_Y) { + if (top == IrisDirection.NORTH_NEGATIVE_Z + || top == IrisDirection.SOUTH_POSITIVE_Z + || top == IrisDirection.EAST_POSITIVE_X + || top == IrisDirection.WEST_NEGATIVE_X) { + return directionName(front) + "_" + directionName(top); + } + throw new IllegalArgumentException("Vertical jigsaw fronts require a horizontal top direction"); + } + if (top != IrisDirection.UP_POSITIVE_Y) { + throw new IllegalArgumentException("Horizontal jigsaw fronts require an upward top direction"); + } + return directionName(front) + "_up"; + } + + private static String directionName(IrisDirection direction) { + return switch (direction) { + case UP_POSITIVE_Y -> "up"; + case DOWN_NEGATIVE_Y -> "down"; + case NORTH_NEGATIVE_Z -> "north"; + case SOUTH_POSITIVE_Z -> "south"; + case EAST_POSITIVE_X -> "east"; + case WEST_NEGATIVE_X -> "west"; + }; + } + + private ListTag palette(List states) { + ListTag palette = new ListTag<>(CompoundTag.class); + for (VanillaBlockState state : states) { + palette.add(state.toNbt()); + } + return palette; + } + + private ListTag blocks( + List entries, + Map paletteIndexes + ) { + ListTag blocks = new ListTag<>(CompoundTag.class); + for (BlockEntry entry : entries) { + CompoundTag block = new CompoundTag(); + block.put("pos", intList(entry.position().x(), entry.position().y(), entry.position().z())); + block.putInt("state", paletteIndexes.get(entry.state().canonical())); + if (entry.nbt() != null) { + block.put("nbt", entry.nbt()); + } + blocks.add(block); + } + return blocks; + } + + private ListTag intList(int... values) { + ListTag list = new ListTag<>(IntTag.class); + for (int value : values) { + list.add(new IntTag(value)); + } + return list; + } + + private record BlockPosition(int x, int y, int z) { + } + + private record BlockEntry(BlockPosition position, VanillaBlockState state, CompoundTag nbt) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 19cbe8cf4..63ce0f7d6 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -129,6 +129,8 @@ public class IrisCreator { */ private boolean benchmark = false; private BiConsumer studioProgressConsumer; + private BiConsumer studioTimingConsumer; + private DatapackPreparation datapackPreparation = DatapackPreparation.INSTALL_IF_CHANGED; public static boolean removeFromBukkitYml(String name) throws IOException { return BukkitWorldConfiguration.remove(BUKKIT_YML, name); @@ -185,7 +187,6 @@ public class IrisCreator { } private World createReserved(NamespacedKey worldKey, IrisDimension resolvedDimension) throws IrisException { - long createStart = System.currentTimeMillis(); File dimensionRoot; try { dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey); @@ -204,7 +205,7 @@ public class IrisCreator { try { reportStudioProgress(0.08D, "resolve_dimension"); reportStudioProgress(0.16D, "prepare_world_pack"); - DatapackInstallResult datapackResult = ServerConfigurator.installDataPacksIfChanged(true); + DatapackInstallResult datapackResult = prepareDatapacks(resolvedDimension); if (!datapackResult.succeeded()) { throw new IrisException("Failed to compile datapacks for dimension \"" + dimension() + "\"."); } @@ -232,13 +233,14 @@ public class IrisCreator { reportStudioProgress(0.28D, "install_datapacks"); AtomicDouble pp = new AtomicDouble(0); AtomicBoolean done = new AtomicBoolean(false); + long generatorPrepareStart = System.nanoTime(); WorldCreator wc = new IrisWorldCreator() .dimension(installedDimension) .name(name) .seed(seed) .studio(studio) .create(); - IrisLogging.debug("[Studio timing] create.packPrep + datapacks = " + (System.currentTimeMillis() - createStart) + "ms (cumulative in create)"); + reportStudioTiming("prepare_studio_generator", generatorPrepareStart); reportStudioProgress(0.40D, "install_datapacks"); PlatformChunkGenerator access = (PlatformChunkGenerator) wc.generator(); @@ -251,14 +253,13 @@ public class IrisCreator { AtomicInteger createProgressTask = startCreateProgressReporter(access, done, createClaim); reportStudioProgress(0.46D, "create_world"); - long nmsStart = System.currentTimeMillis(); + long nmsStartNanos = System.nanoTime(); try { WorldLifecycleCaller callerKind = benchmark ? WorldLifecycleCaller.BENCHMARK : studio() ? WorldLifecycleCaller.STUDIO : WorldLifecycleCaller.CREATE; WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator(wc, studio(), benchmark, callerKind); world = J.sfut(() -> INMS.get().createWorldAsync(wc, request)) .thenCompose(Function.identity()) .get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS); - IrisLogging.debug("[Studio timing] create.createWorldAsync (NMS bukkit world load + spawn prep) = " + (System.currentTimeMillis() - nmsStart) + "ms"); } catch (Throwable e) { done.set(true); cancelRepeatingTask(createProgressTask); @@ -275,6 +276,8 @@ public class IrisCreator { + "Iris queued a restart; run the command again after the server returns.", e); } throw new IrisException("Failed to create world with backend family " + WorldLifecycleService.get().capabilities().serverFamily().id() + "!", e); + } finally { + reportStudioTiming("create_bukkit_world", nmsStartNanos); } done.set(true); @@ -417,6 +420,34 @@ public class IrisCreator { } } + private void reportStudioTiming(String phase, long startedAtNanos) { + BiConsumer consumer = studioTimingConsumer; + if (consumer == null) { + return; + } + + long duration = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos); + try { + consumer.accept(phase, duration); + } catch (Throwable e) { + IrisLogging.reportError("Studio timing consumer failed for world \"" + name() + "\".", e); + } + } + + private DatapackInstallResult prepareDatapacks(IrisDimension resolvedDimension) { + DatapackPreparation preparation = Objects.requireNonNull( + datapackPreparation, + "Datapack preparation mode"); + boolean runtimeReady = preparation == DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY + && ServerConfigurator.isLoadedDatapackRuntimeReady(resolvedDimension); + if (!preparation.requiresInstall(runtimeReady)) { + long reuseStart = System.nanoTime(); + reportStudioTiming("datapack_reuse_loaded_runtime", reuseStart); + return DatapackInstallResult.unchangedResult(); + } + return ServerConfigurator.installDataPacksIfChanged(true, studioTimingConsumer); + } + private AtomicInteger startCreateProgressReporter(PlatformChunkGenerator access, AtomicBoolean done, HudSlotClaim claim) { AtomicInteger taskId = new AtomicInteger(-1); if (benchmark) { @@ -727,4 +758,19 @@ public class IrisCreator { } return cursor; } + + public enum DatapackPreparation { + INSTALL_IF_CHANGED(false), + REUSE_LOADED_RUNTIME_IF_READY(true); + + private final boolean reusesLoadedRuntime; + + DatapackPreparation(boolean reusesLoadedRuntime) { + this.reusesLoadedRuntime = reusesLoadedRuntime; + } + + boolean requiresInstall(boolean runtimeReady) { + return !reusesLoadedRuntime || !runtimeReady; + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java index 232348545..92f51330a 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java @@ -58,6 +58,7 @@ final class EngineHotloader { void hotloadComplex() { synchronized (engine.lifecycleLock) { engine.requireRunning("rebuild the biome complex"); + engine.awaitNativeStructureBootstrap("complex hotload"); engine.lifecycleState = LifecycleState.HOTLOADING; EngineRuntime previous = engine.runtime; IrisComplex nextComplex = null; @@ -105,6 +106,7 @@ final class EngineHotloader { void hotloadSilently() { synchronized (engine.lifecycleLock) { engine.requireRunning("hotload"); + engine.awaitNativeStructureBootstrap("hotload"); engine.lifecycleState = LifecycleState.HOTLOADING; EngineRuntime previousRuntime = engine.runtime; IrisDimension previousDimension = engine.getDimension(); diff --git a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java index 0b7d6d11c..a8af8456f 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java @@ -50,6 +50,7 @@ final class EngineShutdownSequence { if (engine.closed) { return; } + engine.awaitNativeStructureBootstrap("close"); engine.lifecycleState = LifecycleState.CLOSING; engine.getClosing().set(true); engine.backgroundTasks.closeBackgroundTaskAdmission(); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 01a541899..d406bff14 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -67,11 +67,15 @@ import lombok.Getter; import lombok.Setter; import java.util.HashSet; +import java.util.Locale; +import java.util.Objects; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; @Data public class IrisEngine implements Engine { @@ -84,6 +88,7 @@ public class IrisEngine implements Engine { private final AtomicDouble perSecond; private final AtomicLong lastGPS; private final EngineTarget target; + private final InitializationMode initializationMode; private final EngineMantle mantle; private final ChronoLatch perSecondLatch; private final ChronoLatch perSecondBudLatch; @@ -113,6 +118,9 @@ public class IrisEngine implements Engine { final EngineHotloader hotloader = new EngineHotloader(this); @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) + final NativeStructureBootstrapBarrier nativeStructureBootstrapBarrier = new NativeStructureBootstrapBarrier(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) final EngineMetricsReport metricsReport = new EngineMetricsReport(this); private final AtomicBoolean cleaning; private final ChronoLatch cleanLatch; @@ -123,6 +131,7 @@ public class IrisEngine implements Engine { @Setter(AccessLevel.NONE) private final NativeStructureVolumeMemo nativeStructureVolumeMemo = new NativeStructureVolumeMemo(); private final AtomicBoolean closing; + private final AtomicBoolean nativeStructureVolumeQueriesEnabled; @Setter(AccessLevel.NONE) volatile IrisEngineData engineData; @Getter(AccessLevel.NONE) @@ -158,13 +167,16 @@ public class IrisEngine implements Engine { return System.identityHashCode(this); } - public IrisEngine(EngineTarget target, boolean studio) { - this.studio = studio; + public IrisEngine(EngineTarget target, InitializationMode initializationMode) { + InitializationMode requiredMode = Objects.requireNonNull(initializationMode, "initialization mode"); + this.initializationMode = requiredMode; + this.studio = requiredMode.studio(); this.target = target; this.publishedTarget = target; this.platformHooks = IrisServices.get(EnginePlatformHooks.class); this.generationSessions = new GenerationSessionManager(); this.closing = new AtomicBoolean(true); + this.nativeStructureVolumeQueriesEnabled = new AtomicBoolean(!requiredMode.studio()); this.lifecycleState = LifecycleState.INITIALIZING; this.closed = false; this.failing = false; @@ -204,8 +216,10 @@ public class IrisEngine implements Engine { } getData().registerEngine(this); _t0 = M.ms(); + long phaseStartedAt = System.nanoTime(); getData().loadPrefetch(this); IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms"); + logStudioInitializationPhase("load_prefetch", phaseStartedAt, false); try { StructureIndexService.writeOnce(getData()); } catch (Throwable e) { @@ -214,12 +228,21 @@ public class IrisEngine implements Engine { } IrisLogging.info("Engine init: " + target.getWorld().name() + "/" + target.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); _t0 = M.ms(); + phaseStartedAt = System.nanoTime(); EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(); runtimeBuilder.publishRuntime(initialRuntime, null); IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); + logStudioInitializationPhase("build_runtime", phaseStartedAt, false); _t0 = M.ms(); - GenerationCacheWarmer.warm(this); + phaseStartedAt = System.nanoTime(); + if (requiredMode.warmGenerationCaches()) { + GenerationCacheWarmer.warm(this); + } IrisLogging.debug("[IrisEngine timing] cache warm total=" + (M.ms() - _t0) + "ms"); + logStudioInitializationPhase( + "generation_cache_warm", + phaseStartedAt, + !requiredMode.warmGenerationCaches()); EngineTickRegistry.registerTicking(this); } catch (Throwable e) { shutdownSequence.cleanupFailedConstruction(e); @@ -228,6 +251,18 @@ public class IrisEngine implements Engine { IrisLogging.debug("Engine Initialized " + getCacheID()); } + private void logStudioInitializationPhase(String phase, long startedAtNanos, boolean skipped) { + if (!studio) { + return; + } + IrisLogging.info("[Studio engine timing] world=%s kind=%s phase=%s duration=%dms skipped=%s", + target.getWorld().name(), + initializationMode.name().toLowerCase(Locale.ROOT), + phase, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNanos), + Boolean.toString(skipped)); + } + private void verifySeed() { if (getEngineData().getSeed() != null && getEngineData().getSeed() != target.getWorld().getRawWorldSeed()) { target.getWorld().setRawWorldSeed(getEngineData().getSeed()); @@ -270,6 +305,23 @@ public class IrisEngine implements Engine { } } + public CompletableFuture startNativeStructureBootstrap( + Runnable claim, + Supplier> starter, + Runnable activation + ) { + synchronized (lifecycleLock) { + requireRunning("prepare native structure placements"); + CompletableFuture completion = nativeStructureBootstrapBarrier.start(claim, starter); + activation.run(); + return completion; + } + } + + void awaitNativeStructureBootstrap(String transition) { + nativeStructureBootstrapBarrier.await(transition); + } + @Override public void generateMatter(int x, int z, boolean multicore, ChunkContext context) { try (GenerationSessionLease lease = acquireGenerationLease("matter_generate"); @@ -328,9 +380,22 @@ public class IrisEngine implements Engine { @Override public KList getNativeStructureVolumes(int minX, int minZ, int maxX, int maxZ) { + if (!nativeStructureVolumeQueriesEnabled.get()) { + return NativeStructureVolume.NONE; + } return nativeStructureVolumeMemo.volumes(this, platformHooks, minX, minZ, maxX, maxZ); } + public void setNativeStructureVolumeQueriesEnabled(boolean enabled) { + if (!enabled) { + nativeStructureVolumeQueriesEnabled.set(false); + nativeStructureVolumeMemo.clear(); + return; + } + nativeStructureVolumeMemo.clear(); + nativeStructureVolumeQueriesEnabled.set(true); + } + @Override public IrisEngineData getEngineData() { return engineDataStore.getEngineData(); @@ -637,4 +702,26 @@ public class IrisEngine implements Engine { FAILED } + public enum InitializationMode { + RUNTIME(false, true), + STUDIO(true, true), + JIGSAW_STUDIO(true, false); + + private final boolean studio; + private final boolean warmGenerationCaches; + + InitializationMode(boolean studio, boolean warmGenerationCaches) { + this.studio = studio; + this.warmGenerationCaches = warmGenerationCaches; + } + + public boolean studio() { + return studio; + } + + public boolean warmGenerationCaches() { + return warmGenerationCaches; + } + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/NativeStructureBootstrapBarrier.java b/core/src/main/java/art/arcane/iris/engine/NativeStructureBootstrapBarrier.java new file mode 100644 index 000000000..0817e0829 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/NativeStructureBootstrapBarrier.java @@ -0,0 +1,95 @@ +package art.arcane.iris.engine; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; + +final class NativeStructureBootstrapBarrier { + private static final long TRANSITION_TIMEOUT_SECONDS = 120L; + + private CompletableFuture active; + private CompletableFuture poisoned; + + synchronized CompletableFuture start( + Runnable claim, + Supplier> starter + ) { + Objects.requireNonNull(claim, "Native structure bootstrap claim"); + Objects.requireNonNull(starter, "Native structure bootstrap starter"); + if (active != null) { + throw new IllegalStateException("Native structure bootstrap is already active."); + } + claim.run(); + CompletableFuture bridge = new CompletableFuture<>(); + active = bridge; + bridge.whenComplete((ignored, failure) -> clearSuccessful(bridge, failure)); + try { + CompletableFuture completion = Objects.requireNonNull( + starter.get(), + "Native structure bootstrap completion"); + completion.whenComplete((ignored, failure) -> completeBridge(bridge, failure)); + return bridge; + } catch (Throwable failure) { + poisoned = bridge; + bridge.completeExceptionally(failure); + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + throw new IllegalStateException("Native structure bootstrap could not start.", failure); + } + } + + synchronized void await(String transition) { + CompletableFuture completion = active; + if (completion == null) { + return; + } + try { + completion.get(TRANSITION_TIMEOUT_SECONDS, TimeUnit.SECONDS); + active = null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting for native structure bootstrap before " + transition + ".", e); + } catch (ExecutionException e) { + if (poisoned != completion) { + active = null; + } + Throwable cause = e.getCause() == null ? e : e.getCause(); + throw new IllegalStateException( + "Native structure bootstrap failed before " + transition + ".", cause); + } catch (TimeoutException e) { + throw new IllegalStateException( + "Native structure bootstrap did not finish before " + transition + " within " + + TRANSITION_TIMEOUT_SECONDS + " seconds.", e); + } + } + + synchronized boolean isActive() { + return active != null; + } + + synchronized boolean isPoisoned() { + return poisoned != null; + } + + private void completeBridge(CompletableFuture bridge, Throwable failure) { + if (failure == null) { + bridge.complete(null); + return; + } + bridge.completeExceptionally(failure); + } + + private synchronized void clearSuccessful(CompletableFuture completion, Throwable failure) { + if (failure == null && active == completion) { + active = null; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index 963f44bde..0a920b312 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -21,11 +21,13 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.structure.StructureGraphCatalog; import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructure; 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.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructureCarveShape; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.IrisStructureTerrain; @@ -66,6 +68,7 @@ public final class IrisStructureLocator { private static final int CANDIDATE_BUDGET = 4_096; private static final int MAX_BURIAL_COLUMNS = 2_000_000; private static final int UNDERGROUND_SURFACE_CLEARANCE = 1; + private static final long CAVE_ANCHOR_DOMAIN = 0x43415645414E4348L; private static final Pattern NAMESPACED_RESOURCE_KEY = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); private static final Cache INDEX_CACHE = Caffeine.newBuilder().weakKeys().build(); @@ -487,10 +490,13 @@ public final class IrisStructureLocator { RNG rng = StructurePlacementGrid.placementRng(placement, cx, cz, seed); int originX = (cx << 4) + rng.nextInt(16); int originZ = (cz << 4) + rng.nextInt(16); - Integer baseY = resolveBaseY(engine, placement, originX, originZ, rng); - if (baseY == null) { + PlacementAnchor anchor = resolveAnchor(engine, placement, cx, cz, originX, originZ, rng); + if (anchor == null) { return null; } + originX = anchor.x(); + originZ = anchor.z(); + int baseY = anchor.y(); String selectedKey = selectStructureKey(placement, rng); if (selectedKey == null || selectedKey.isBlank()) { @@ -509,11 +515,13 @@ public final class IrisStructureLocator { StructureAssembler assembler = StructureAssembler.forData( engine.getData(), structure, new IrisPosition(originX, baseY, originZ)); - KList pieces = assembler.assemble(rng); + StructureAssemblyResult assembly = assembler.assemble(rng); if (!requirePlacementOutput(placement, selectedKey, cx, cz, - pieces != null && !pieces.isEmpty(), "runtime assembly produced no pieces")) { + assembly.hasOutput(), "runtime assembly produced " + assembly.status() + ": " + assembly.detail())) { return null; } + KList pieces = new KList<>(assembly.pieces()); + pieces = alignCavePieces(pieces, placement, baseY); pieces = alignSurfacePieces(pieces, placement, structure, baseY); if (!requirePlacementOutput(placement, selectedKey, cx, cz, pieces != null && !pieces.isEmpty(), "surface alignment produced no pieces")) { @@ -534,7 +542,7 @@ public final class IrisStructureLocator { baseY += verticalShift; } } - if (placement.isUnderground()) { + if (placement.isAnchoredUnderground() && !placement.resolvedAnchor().isCave()) { Integer burialShift = resolveUndergroundBurialShift( engine, pieces, placement, baseY, worldMin, worldMax); if (!requirePlacementOutput(placement, selectedKey, cx, cz, burialShift != null, @@ -614,6 +622,24 @@ public final class IrisStructureLocator { return null; } + private static PlacementAnchor resolveAnchor( + Engine engine, + IrisStructurePlacement placement, + int chunkX, + int chunkZ, + int originX, + int originZ, + RNG rng + ) { + if (placement.resolvedAnchor().isCave()) { + StructureCaveAnchorResolver.Anchor caveAnchor = StructureCaveAnchorResolver.resolve( + engine, placement, chunkX, chunkZ, rng.nextParallelRNG(CAVE_ANCHOR_DOMAIN)); + return caveAnchor == null ? null : new PlacementAnchor(caveAnchor.x(), caveAnchor.y(), caveAnchor.z()); + } + Integer baseY = resolveBaseY(engine, placement, originX, originZ, rng); + return baseY == null ? null : new PlacementAnchor(originX, baseY, originZ); + } + private static Integer resolveBaseY(Engine engine, IrisStructurePlacement placement, int originX, int originZ, RNG rng) { int worldMin = engine.getMinHeight() + 1; int worldMax = engine.getMinHeight() + engine.getHeight() - 1; @@ -624,7 +650,7 @@ public final class IrisStructureLocator { && NativeStructurePlacementPlanner.isSubmerged(engine, originX, originZ)) { return null; } - if (!placement.isUnderground()) { + if (placement.resolvedAnchor() == IrisStructureAnchorMode.SURFACE) { int surfaceY = engine.getHeight(originX, originZ, true) + engine.getMinHeight(); return surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight() ? null : surfaceY; } @@ -642,7 +668,7 @@ public final class IrisStructureLocator { if (bounds == null) { return null; } - if (!placement.isUnderground()) { + if (!placement.isAnchoredUnderground() || placement.resolvedAnchor().isCave()) { return bounds[1] >= worldMin && bounds[4] <= worldMax ? 0 : null; } int bandMin = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight())); @@ -665,7 +691,8 @@ public final class IrisStructureLocator { IrisStructurePlacement placement, int baseY, int worldMin, int worldMax) { int[] bounds = computeBounds(pieces); - if (engine == null || bounds == null || placement == null || !placement.isUnderground()) { + if (engine == null || bounds == null || placement == null || !placement.isAnchoredUnderground() + || placement.resolvedAnchor().isCave()) { return null; } @@ -752,10 +779,32 @@ public final class IrisStructureLocator { return placement.getStructures().get(rng.nextInt(placement.getStructures().size())); } + static KList alignCavePieces( + KList pieces, + IrisStructurePlacement placement, + int anchorY + ) { + IrisStructureAnchorMode mode = placement.resolvedAnchor(); + if (!mode.isCave()) { + return pieces; + } + int[] bounds = computeBounds(pieces); + if (bounds == null) { + return pieces; + } + int targetY = switch (mode) { + case CAVE_FLOOR -> bounds[1]; + case CAVE_CEILING -> bounds[4]; + case CAVE_CENTER, CAVE_ANY -> bounds[1] + Math.floorDiv(bounds[4] - bounds[1], 2); + default -> anchorY; + }; + return shiftPieces(pieces, anchorY - targetY); + } + private static KList alignSurfacePieces(KList pieces, IrisStructurePlacement placement, IrisStructure structure, int baseY) { - if (placement.isUnderground() || pieces.size() <= 1 + if (placement.isAnchoredUnderground() || pieces.size() <= 1 || structure.getPlaceMode() == ObjectPlaceMode.STRUCTURE_PIECE || structure.getPlaceMode() == ObjectPlaceMode.FLOATING) { return pieces; @@ -766,7 +815,7 @@ public final class IrisStructureLocator { static boolean hasExactY(IrisStructurePlacement placement, IrisStructure structure, KList pieces) { - return placement.isUnderground() || pieces.size() > 1 + return placement.isAnchoredUnderground() || pieces.size() > 1 || structure.getPlaceMode() == ObjectPlaceMode.STRUCTURE_PIECE || structure.getPlaceMode() == ObjectPlaceMode.FLOATING; } @@ -867,12 +916,16 @@ public final class IrisStructureLocator { && !(placement.getDensity() > 0.0)) { return false; } - long worldMin = (long) engine.getMinHeight() + (placement.isUnderground() ? 1L : 0L); - long worldMax = (long) engine.getMinHeight() + engine.getHeight() - 1L; - long configuredMin = placement.isUnderground() + long worldMin = (long) engine.getMinHeight() + (placement.isAnchoredUnderground() ? 1L : 0L); + long worldMax = (long) engine.getMinHeight() + engine.getHeight() + - (placement.resolvedAnchor().isCave() ? 2L : 1L); + if (worldMin > worldMax) { + return false; + } + long configuredMin = placement.isAnchoredUnderground() ? Math.min(placement.getMinHeight(), placement.getMaxHeight()) : placement.getMinHeight(); - long configuredMax = placement.isUnderground() + long configuredMax = placement.isAnchoredUnderground() ? Math.max(placement.getMinHeight(), placement.getMaxHeight()) : placement.getMaxHeight(); return configuredMin <= configuredMax && configuredMin <= worldMax && configuredMax >= worldMin; @@ -949,6 +1002,9 @@ public final class IrisStructureLocator { return ((long) cx << 32) ^ (cz & 0xffffffffL); } + private record PlacementAnchor(int x, int y, int z) { + } + private static boolean matches(IrisStructurePlacement placement, String key, IrisData data) { if (placement == null || key == null || data == null) { return false; 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 502e5cad3..153da99d4 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 @@ -2,6 +2,7 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisNativeStructure; import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.volmlib.util.collection.KList; @@ -94,6 +95,10 @@ public final class NativeStructurePlacementPlanner { throw new IllegalStateException("Structure placement must declare exactly one backend: " + "structures or nativeStructures"); } + if (nativeStructure && placement.getAnchor() != null + && placement.getAnchor() != IrisStructureAnchorMode.LEGACY) { + throw new IllegalStateException("Native structure placements do not support the Iris anchor field"); + } } static IrisNativeStructure selectSource(KList sources, RNG rng) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java index fe6866dcf..3ff75d307 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java @@ -19,17 +19,24 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.structure.CompiledStructureGraph; import art.arcane.iris.engine.framework.structure.JigsawPoolSelection; +import art.arcane.iris.engine.framework.structure.PlanarJigsawWorkcellResolver; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; +import art.arcane.iris.engine.framework.structure.StructureAssemblyStatus; import art.arcane.iris.engine.framework.structure.StructureGraphCatalog; import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; import art.arcane.iris.engine.framework.structure.StructureGraphCompiler; import art.arcane.iris.engine.framework.structure.StructureGraphDiagnostic; import art.arcane.iris.engine.framework.structure.StructureGraphResolver; import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisJigsawPieceEntry; import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.engine.object.IrisPosition; @@ -39,8 +46,16 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; import java.util.Deque; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; public final class StructureAssembler { private static final int HARD_PIECE_CAP = 512; @@ -50,18 +65,25 @@ public final class StructureAssembler { private static final int[] Y_DEGREES = {0, 90, 180, 270}; private final StructureGraphResolver resolver; + private final CompiledStructureGraph graph; private final IrisStructure structure; private final int originX; private final int originY; private final int originZ; private final int radius; + private final Map + planarWorkcells; private StructureAssembler(AssemblyOptions options) { this.resolver = options.resolver(); + this.graph = options.graph(); this.structure = options.structure(); this.originX = options.origin().getX(); this.originY = options.origin().getY(); this.originZ = options.origin().getZ(); + this.planarWorkcells = this.structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW + ? PlanarJigsawWorkcellResolver.resolve(this.structure) + : Map.of(); this.radius = this.structure.getMaxSizeChunks() >= 1 && this.structure.getMaxSizeChunks() <= MAX_SIZE_CHUNKS ? this.structure.getMaxSizeChunks() * 16 : 0; @@ -91,10 +113,10 @@ public final class StructureAssembler { } return new StructureAssembler(new AssemblyOptions( StructureGraphResolver.forCompiledGraph(activeCompilation.getGraph()), - activeCompilation.getGraph().getStructure(), origin)); + activeCompilation.getGraph(), activeCompilation.getGraph().getStructure(), origin)); } - public KList assemble(RNG rng) { + public StructureAssemblyResult assemble(RNG rng) { Objects.requireNonNull(rng, "Structure assembly RNG must not be null"); if (radius == 0) { throw new IllegalStateException("Structure '" + structure.getLoadKey() @@ -104,32 +126,58 @@ public final class StructureAssembler { throw new IllegalStateException("Structure '" + structure.getLoadKey() + "' has maxDepth outside 1.." + MAX_DEPTH); } - IrisJigsawPool startPool = resolver.loadPool(structure.getStartPool()); + String startPoolKey = normalize(structure.getStartPool()); + IrisJigsawPool startPool = resolver.loadPool(startPoolKey); if (startPool == null || startPool.getPieces() == null || startPool.getPieces().isEmpty()) { throw new IllegalStateException("Structure '" + structure.getLoadKey() - + "' has no resolvable start pool '" + structure.getStartPool() + "'"); + + "' has no resolvable start pool '" + startPoolKey + "'"); } KList placed = new KList<>(); - Deque open = new ArrayDeque<>(); + Deque pending = new ArrayDeque<>(); + Map placementCounts = new HashMap<>(); + Map> consumedConnectors = new IdentityHashMap<>(); + Map> openConnectors = + new IdentityHashMap<>(); + String selectedTheme = JigsawPoolSelection.selectTheme(structure, rng); - IrisJigsawPieceEntry startEntry = weightedPick(startPool, rng); + List eligibleStartEntries = eligibleEntries( + startPool, selectedTheme, 0, placementCounts, true, false); + if (eligibleStartEntries.isEmpty()) { + return StructureAssemblyResult.failed( + StructureAssemblyStatus.FAILED_RULES, + placed, + selectedTheme, + "The start pool has no entry eligible for theme '" + themeLabel(selectedTheme) + + "' at depth zero"); + } + List startEntries = chanceEligibleEntries(eligibleStartEntries, rng); + if (startEntries.isEmpty()) { + return StructureAssemblyResult.intentionalEmpty( + selectedTheme, + "No start-pool membership passed its chance gate"); + } + IrisJigsawPieceEntry startEntry = weightedPick(startEntries, rng); if (startEntry == null) { throw new IllegalStateException("Structure '" + structure.getLoadKey() + "' start pool has no positively weighted entry"); } if (startEntry.isEmpty()) { - return placed; + return StructureAssemblyResult.intentionalEmpty( + selectedTheme, + "The selected start-pool membership intentionally produces no structure"); } - IrisJigsawPiece startPiece = resolver.loadPiece(startEntry.getPiece()); + String startPieceKey = normalize(startEntry.getPiece()); + IrisJigsawPiece startPiece = resolver.loadPiece(startPieceKey); if (startPiece == null) { throw new IllegalStateException("Structure '" + structure.getLoadKey() - + "' start pool references missing piece '" + startEntry.getPiece() + "'"); + + "' start pool references missing piece '" + startPieceKey + "'"); } - IrisObject startObject = resolver.loadObject(startPiece.getObject()); + String startObjectKey = normalize(startPiece.getObject()); + IrisObject startObject = resolver.loadObject(startObjectKey); if (startObject == null) { throw new IllegalStateException("Structure '" + structure.getLoadKey() - + "' start piece references missing object '" + startPiece.getObject() + "'"); + + "' start piece references missing object '" + startObjectKey + "'"); } if (startPiece.getConnectors() == null) { throw new IllegalStateException("Structure '" + structure.getLoadKey() @@ -140,48 +188,116 @@ public final class StructureAssembler { IrisObjectRotation startRot = IrisObjectRotation.of(0, startRotY, 0); PlacedStructurePiece start = build(startPiece, startObject, originX, originY, originZ, startRot); placed.add(start); - enqueueConnectors(open, start, startObject, 0, null); - - while (!open.isEmpty() && placed.size() < HARD_PIECE_CAP) { - OpenConnector c = open.poll(); - int depth = c.depth; - if (depth > structure.getMaxDepth()) { - continue; - } - - IrisJigsawPool pool = resolver.loadPool(c.pool); - if (pool == null) { - throw new IllegalStateException("Structure '" + structure.getLoadKey() - + "' references missing connector pool '" + c.pool + "'"); - } - - if (!attachOne(open, placed, pool, c, depth, rng)) { - return null; - } + registerPlacedPiece(consumedConnectors, start, null); + incrementPlacement(placementCounts, startPieceKey); + if (!startPiece.resolvedRules().isTerminal()) { + enqueuePieceExpansion(pending, openConnectors, start, startObject, 0, 1, null, 0); } - if (!open.isEmpty()) { - throw assemblyFailure("exceeded the hard piece cap of " + HARD_PIECE_CAP - + " with " + open.size() + " connector(s) unresolved"); + while (!pending.isEmpty()) { + PieceExpansion expansion = removeNextPieceExpansion(pending); + for (OpenConnector connector : expansion.connectors()) { + if (isConsumed(consumedConnectors, connector)) { + continue; + } + if (placed.size() >= HARD_PIECE_CAP) { + return StructureAssemblyResult.failed( + StructureAssemblyStatus.HARD_CAP, + placed, + selectedTheme, + "Exceeded the hard piece cap of " + HARD_PIECE_CAP + + " with queued piece expansions unresolved"); + } + int depth = connector.depth; + if (depth > structure.getMaxDepth()) { + continue; + } + + IrisJigsawPool pool = resolver.loadPool(connector.pool); + if (pool == null) { + throw new IllegalStateException("Structure '" + structure.getLoadKey() + + "' references missing connector pool '" + connector.pool + "'"); + } + + AttachmentResult attachment = attachOne( + pending, + placed, + placementCounts, + consumedConnectors, + openConnectors, + pool, + connector, + depth, + selectedTheme, + rng); + consume(consumedConnectors, connector.sourcePiece(), connector.sourceConnector()); + if (attachment.state() == AttachmentState.FAILED) { + return StructureAssemblyResult.failed( + StructureAssemblyStatus.FAILED_UNCAPPED, + placed, + selectedTheme, + attachment.detail()); + } + } } - return placed; + String unsatisfiedRule = unsatisfiedMinimumPlacement(placementCounts, selectedTheme); + if (!unsatisfiedRule.isEmpty()) { + return StructureAssemblyResult.failed( + StructureAssemblyStatus.FAILED_RULES, + placed, + selectedTheme, + unsatisfiedRule); + } + return StructureAssemblyResult.complete(placed, selectedTheme); } - private boolean attachOne(Deque open, KList placed, - IrisJigsawPool pool, OpenConnector c, int depth, RNG rng) { + private AttachmentResult attachOne( + Deque pending, + KList placed, + Map placementCounts, + Map> consumedConnectors, + Map> openConnectors, + IrisJigsawPool pool, + OpenConnector connector, + int depth, + String selectedTheme, + RNG rng + ) { if (pool.getPieces() == null) { - throw assemblyFailure("connector pool '" + c.pool + "' has no piece list"); + throw assemblyFailure("connector pool '" + connector.pool + "' has no piece list"); } - if (pool.getPieces().isEmpty()) { - return true; + if (closeAgainstPlacedNeighbor(placed, consumedConnectors, connector)) { + return AttachmentResult.success(AttachmentState.CLOSED); } - if (depth < structure.getMaxDepth() && attachFromPool(open, placed, pool, c, depth, rng)) { - return true; + boolean fallbackRequired = pool.requiresFallback(structure.isRequireCaps()); + if (depth < structure.getMaxDepth() && !pool.getPieces().isEmpty()) { + AttachmentState primary = attachFromPool( + pending, + placed, + placementCounts, + consumedConnectors, + openConnectors, + pool, + connector, + selectedTheme, + false, + !fallbackRequired, + rng); + if (primary != AttachmentState.NO_MATCH) { + return AttachmentResult.success(primary); + } + } else if (pool.getPieces().isEmpty() && !fallbackRequired) { + return AttachmentResult.success(AttachmentState.TERMINATED); } - String fallbackKey = JigsawPoolSelection.directFallbackKey(pool); if (fallbackKey.isEmpty()) { - return depth >= structure.getMaxDepth(); + if (branchFailureRequiresAssemblyFailure(fallbackRequired, depth)) { + return AttachmentResult.failed( + "Connector pool '" + connector.pool + + "' could not place a piece and has no direct fallback at " + + connectorLocation(connector)); + } + return AttachmentResult.success(AttachmentState.TERMINATED); } IrisJigsawPool fallback = resolver.loadPool(fallbackKey); if (fallback == null) { @@ -191,70 +307,118 @@ public final class StructureAssembler { throw assemblyFailure("authored fallback pool '" + fallbackKey + "' has no piece list"); } if (fallback.getPieces().isEmpty()) { - return true; + return fallbackRequired + ? AttachmentResult.failed( + "Required fallback pool '" + fallbackKey + "' has no physical terminal piece") + : AttachmentResult.success(AttachmentState.TERMINATED); } - if (attachFromPool(open, placed, fallback, c, depth, rng)) { - return true; + AttachmentState fallbackState = attachFromPool( + pending, + placed, + placementCounts, + consumedConnectors, + openConnectors, + fallback, + connector, + selectedTheme, + fallbackRequired, + !fallbackRequired, + rng); + if (fallbackState != AttachmentState.NO_MATCH) { + return AttachmentResult.success(fallbackState); } - return depth >= structure.getMaxDepth(); + if (branchFailureRequiresAssemblyFailure(fallbackRequired, depth)) { + return AttachmentResult.failed( + "Direct fallback pool '" + fallbackKey + "' could not place" + + (fallbackRequired ? " a required terminal piece" : " a compatible piece") + + " at " + connectorLocation(connector)); + } + return AttachmentResult.success(AttachmentState.TERMINATED); } - private boolean attachFromPool(Deque open, KList placed, IrisJigsawPool pool, - OpenConnector c, int depth, RNG rng) { - for (IrisJigsawPieceEntry entry : weightedOrder(pool, rng)) { + private boolean branchFailureRequiresAssemblyFailure(boolean fallbackRequired, int depth) { + return fallbackRequired + || depth < structure.getMaxDepth() + && structure.resolvedBranchFailurePolicy() == IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY; + } + + private AttachmentState attachFromPool( + Deque pending, + KList placed, + Map placementCounts, + Map> consumedConnectors, + Map> openConnectors, + IrisJigsawPool pool, + OpenConnector connector, + String selectedTheme, + boolean terminalOnly, + boolean allowEmpty, + RNG rng + ) { + int ruleDepth = Math.min(connector.pieceDepth, structure.getMaxDepth()); + for (IrisJigsawPieceEntry entry : weightedOrder( + pool, + selectedTheme, + ruleDepth, + placementCounts, + allowEmpty, + terminalOnly, + rng)) { if (entry.isEmpty()) { - return true; + return AttachmentState.TERMINATED; } - String pieceName = entry.getPiece(); - if (pieceName == null || pieceName.isBlank()) { + String pieceName = normalize(entry.getPiece()); + if (pieceName.isEmpty()) { throw assemblyFailure("pool contains a weighted entry without a piece key"); } IrisJigsawPiece piece = resolver.loadPiece(pieceName); if (piece == null) { throw assemblyFailure("pool references missing piece '" + pieceName + "'"); } - if (piece.getObject() == null || piece.getObject().isBlank()) { + String objectKey = normalize(piece.getObject()); + if (objectKey.isEmpty()) { throw assemblyFailure("piece '" + pieceName + "' has no object key"); } - IrisObject object = resolver.loadObject(piece.getObject()); + IrisObject object = resolver.loadObject(objectKey); if (object == null) { throw assemblyFailure("piece '" + pieceName + "' references missing object '" - + piece.getObject() + "'"); + + objectKey + "'"); } if (piece.getConnectors() == null) { throw assemblyFailure("piece '" + pieceName + "' has no connector list"); } - for (IrisJigsawConnector cb : piece.getConnectors()) { - requireConnector(cb, pieceName); - if (c.targetName == null) { - throw assemblyFailure("open connector from pool '" + c.pool + "' has no target name"); + for (IrisJigsawConnector pieceConnector : piece.getConnectors()) { + requireConnector(pieceConnector, pieceName); + } + List candidateConnectors = orderedConnectors(piece.getConnectors(), null); + for (IrisJigsawConnector cb : candidateConnectors) { + if (connector.targetName == null) { + throw assemblyFailure("open connector from pool '" + connector.pool + "' has no target name"); } - if (!cb.getName().equals(c.targetName)) { + if (!Objects.equals(connector.channel, cb.getChannel())) { + continue; + } + if (!cb.getName().equals(connector.targetName)) { continue; } - IrisDirection needed = c.facing.reverse(); - int[] rotations = piece.isRotatable() ? rotationCandidates(c.joint, rng) : NO_ROTATION; + IrisDirection needed = connector.facing.reverse(); + int[] rotations = piece.isRotatable() ? rotationCandidates(connector.joint, rng) : NO_ROTATION; for (int yDeg : rotations) { IrisObjectRotation rot = IrisObjectRotation.of(0, yDeg, 0); IrisDirection rotatedFace = rot.rotate(cb.getDirection()); if (rotatedFace != needed) { continue; } - if (c.joint == JigsawJoint.ALIGNED && rot.rotate(cb.getTop()) != c.top) { + if (connector.joint == JigsawJoint.ALIGNED && rot.rotate(cb.getTop()) != connector.top) { continue; } - IrisPosition center = centerOf(object); - IrisPosition cr = new IrisPosition( - cb.getPosition().getX() - center.getX(), - cb.getPosition().getY() - center.getY(), - cb.getPosition().getZ() - center.getZ()); - IrisPosition rcr = rot.rotate(cr, 0, 0, 0); - int wcx = c.wx + c.facing.x(); - int wcy = c.wy + c.facing.y(); - int wcz = c.wz + c.facing.z(); + IrisPosition rcr = connectorOffset(object, cb, rot); + int wcx = connector.wx + connector.facing.x(); + int wcy = connector.wy + connector.facing.y(); + int wcz = connector.wz + connector.facing.z(); int px = wcx - rcr.getX(); int py = wcy - rcr.getY(); int pz = wcz - rcr.getZ(); @@ -263,50 +427,570 @@ public final class StructureAssembler { if (!withinRadius(candidate) || collides(placed, candidate)) { continue; } + List closures = requiredClosures( + placed, + consumedConnectors, + connector, + candidate, + cb); + if (closures == null) { + continue; + } + if (!capReservationsRemainViable( + placed, + placementCounts, + consumedConnectors, + openConnectors, + connector, + candidate, + cb, + closures, + pieceName, + selectedTheme)) { + continue; + } placed.add(candidate); - if (depth < structure.getMaxDepth()) { - enqueueConnectors(open, candidate, object, depth + 1, cb); + registerPlacedPiece(consumedConnectors, candidate, cb); + for (ConnectorClosure closure : closures) { + consume(consumedConnectors, closure.existingPiece(), closure.existingConnector()); + consume(consumedConnectors, candidate, closure.candidateConnector()); } - return true; + incrementPlacement(placementCounts, pieceName); + if (!piece.resolvedRules().isTerminal() && connector.depth < structure.getMaxDepth()) { + enqueuePieceExpansion( + pending, + openConnectors, + candidate, + object, + connector.depth + 1, + connector.pieceDepth + 1, + cb, + connector.placementPriority); + } + return AttachmentState.ATTACHED; } } } + return AttachmentState.NO_MATCH; + } + + private boolean closeAgainstPlacedNeighbor( + KList placed, + Map> consumedConnectors, + OpenConnector source + ) { + int targetX = source.wx() + source.facing().x(); + int targetY = source.wy() + source.facing().y(); + int targetZ = source.wz() + source.facing().z(); + for (PlacedStructurePiece candidatePiece : placed) { + if (candidatePiece == source.sourcePiece() || candidatePiece.getPiece().getConnectors() == null) { + continue; + } + for (IrisJigsawConnector candidate : candidatePiece.getPiece().getConnectors()) { + if (isConsumed(consumedConnectors, candidatePiece, candidate) + || !existingConnectorMatches(source, candidatePiece, candidate, + targetX, targetY, targetZ)) { + continue; + } + consume(consumedConnectors, candidatePiece, candidate); + return true; + } + } return false; } - private void enqueueConnectors(Deque open, PlacedStructurePiece p, IrisObject object, int depth, IrisJigsawConnector skip) { + private List requiredClosures( + KList placed, + Map> consumedConnectors, + OpenConnector attachmentSource, + PlacedStructurePiece candidatePiece, + IrisJigsawConnector attachmentConnector + ) { + if (!structure.isRequireCaps()) { + return List.of(); + } + List closures = new ArrayList<>(); + Set matchedCandidate = Collections.newSetFromMap(new IdentityHashMap<>()); + Map> matchedExisting = new IdentityHashMap<>(); + matchedCandidate.add(attachmentConnector); + matchedExisting.computeIfAbsent( + attachmentSource.sourcePiece(), + ignored -> Collections.newSetFromMap(new IdentityHashMap<>())) + .add(attachmentSource.sourceConnector()); + + for (PlacedStructurePiece existingPiece : placed) { + for (IrisJigsawConnector existingConnector : existingPiece.getPiece().getConnectors()) { + if (normalize(existingConnector.getPool()).isEmpty() + || isConsumed(consumedConnectors, existingPiece, existingConnector) + || isMatched(matchedExisting, existingPiece, existingConnector)) { + continue; + } + IrisPosition target = connectorTarget(existingPiece, existingConnector); + if (!contains(candidatePiece, target)) { + continue; + } + IrisJigsawConnector matchingCandidate = matchingConnector( + existingPiece, + existingConnector, + candidatePiece, + matchedCandidate); + if (matchingCandidate == null) { + return null; + } + matchedCandidate.add(matchingCandidate); + matchedExisting.computeIfAbsent( + existingPiece, + ignored -> Collections.newSetFromMap(new IdentityHashMap<>())) + .add(existingConnector); + closures.add(new ConnectorClosure(existingPiece, existingConnector, matchingCandidate)); + } + } + + if (candidatePiece.getPiece().resolvedRules().isTerminal()) { + return closures; + } + for (IrisJigsawConnector candidateConnector : candidatePiece.getPiece().getConnectors()) { + if (matchedCandidate.contains(candidateConnector) + || normalize(candidateConnector.getPool()).isEmpty()) { + continue; + } + IrisPosition target = connectorTarget(candidatePiece, candidateConnector); + PlacedStructurePiece occupied = containingPiece(placed, target); + if (occupied == null) { + continue; + } + Set unavailable = matchedExisting.getOrDefault(occupied, Set.of()); + IrisJigsawConnector matchingExisting = matchingConnector( + candidatePiece, + candidateConnector, + occupied, + unavailable); + if (matchingExisting == null + || isConsumed(consumedConnectors, occupied, matchingExisting)) { + return null; + } + matchedCandidate.add(candidateConnector); + matchedExisting.computeIfAbsent( + occupied, + ignored -> Collections.newSetFromMap(new IdentityHashMap<>())) + .add(matchingExisting); + closures.add(new ConnectorClosure(occupied, matchingExisting, candidateConnector)); + } + return closures; + } + + private boolean capReservationsRemainViable( + KList placed, + Map placementCounts, + Map> consumedConnectors, + Map> openConnectors, + OpenConnector attachmentSource, + PlacedStructurePiece candidatePiece, + IrisJigsawConnector attachmentConnector, + List closures, + String candidatePieceKey, + String selectedTheme + ) { + if (!structure.isRequireCaps() || structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW) { + return true; + } + List unresolved = new ArrayList<>(); + for (PlacedStructurePiece placedPiece : placed) { + Map definitions = openConnectors.get(placedPiece); + if (definitions == null) { + continue; + } + for (IrisJigsawConnector connector : placedPiece.getPiece().getConnectors()) { + OpenConnector open = definitions.get(connector); + if (open == null + || isConsumed(consumedConnectors, placedPiece, connector) + || connectorWillBeConsumed( + placedPiece, connector, attachmentSource, candidatePiece, attachmentConnector, closures)) { + continue; + } + unresolved.add(open); + } + } + if (!candidatePiece.getPiece().resolvedRules().isTerminal() + && attachmentSource.depth() < structure.getMaxDepth()) { + for (IrisJigsawConnector connector : candidatePiece.getPiece().getConnectors()) { + if (normalize(connector.getPool()).isEmpty() + || connectorWillBeConsumed( + candidatePiece, connector, attachmentSource, candidatePiece, attachmentConnector, closures)) { + continue; + } + unresolved.add(openConnector( + candidatePiece, + candidatePiece.getObject(), + connector, + attachmentSource.depth() + 1, + attachmentSource.pieceDepth() + 1)); + } + } + + List occupied = new ArrayList<>(placed.size() + unresolved.size() + 1); + occupied.addAll(placed); + occupied.add(candidatePiece); + Map reservedCounts = new HashMap<>(placementCounts); + incrementPlacement(reservedCounts, candidatePieceKey); + for (OpenConnector open : unresolved) { + CapReservation reservation = reserveTerminalCap( + open, + occupied, + reservedCounts, + selectedTheme); + if (reservation == null) { + return false; + } + occupied.add(reservation.piece()); + incrementPlacement(reservedCounts, reservation.pieceKey()); + } + return true; + } + + private boolean connectorWillBeConsumed( + PlacedStructurePiece piece, + IrisJigsawConnector connector, + OpenConnector attachmentSource, + PlacedStructurePiece candidatePiece, + IrisJigsawConnector attachmentConnector, + List closures + ) { + if (piece == attachmentSource.sourcePiece() && connector == attachmentSource.sourceConnector() + || piece == candidatePiece && connector == attachmentConnector + || piece == candidatePiece && candidatePiece.getPiece().resolvedRules().isTerminal()) { + return true; + } + for (ConnectorClosure closure : closures) { + if (piece == closure.existingPiece() && connector == closure.existingConnector() + || piece == candidatePiece && connector == closure.candidateConnector()) { + return true; + } + } + return false; + } + + private CapReservation reserveTerminalCap( + OpenConnector source, + List occupied, + Map placementCounts, + String selectedTheme + ) { + IrisJigsawPool sourcePool = resolver.loadPool(source.pool()); + if (sourcePool == null) { + return null; + } + String fallbackKey = JigsawPoolSelection.directFallbackKey(sourcePool); + if (fallbackKey.isEmpty()) { + return null; + } + IrisJigsawPool fallback = resolver.loadPool(fallbackKey); + if (fallback == null || fallback.getPieces() == null) { + return null; + } + int ruleDepth = Math.min(source.pieceDepth(), structure.getMaxDepth()); + for (IrisJigsawPieceEntry entry : eligibleEntries( + fallback, + selectedTheme, + ruleDepth, + placementCounts, + false, + true)) { + if (entry.isEmpty() || entry.getChance() <= 0D) { + continue; + } + String pieceKey = normalize(entry.getPiece()); + IrisJigsawPiece piece = resolver.loadPiece(pieceKey); + if (piece == null || piece.getConnectors() == null) { + continue; + } + IrisObject object = resolver.loadObject(normalize(piece.getObject())); + if (object == null) { + continue; + } + for (IrisJigsawConnector connector : orderedConnectors(piece.getConnectors(), null)) { + if (!Objects.equals(source.channel(), connector.getChannel()) + || !Objects.equals(source.targetName(), connector.getName())) { + continue; + } + int[] rotations = piece.isRotatable() ? Y_DEGREES : NO_ROTATION; + for (int yDegrees : rotations) { + IrisObjectRotation rotation = IrisObjectRotation.of(0, yDegrees, 0); + if (rotation.rotate(connector.getDirection()) != source.facing().reverse() + || source.joint() == JigsawJoint.ALIGNED + && rotation.rotate(connector.getTop()) != source.top()) { + continue; + } + IrisPosition offset = connectorOffset(object, connector, rotation); + int logicalX = source.wx() + source.facing().x() - offset.getX(); + int logicalY = source.wy() + source.facing().y() - offset.getY(); + int logicalZ = source.wz() + source.facing().z() - offset.getZ(); + PlacedStructurePiece reservation = build( + piece, + object, + logicalX, + logicalY, + logicalZ, + rotation); + if (withinRadius(reservation) && !collides(occupied, reservation)) { + return new CapReservation(pieceKey, reservation); + } + } + } + } + return null; + } + + private IrisJigsawConnector matchingConnector( + PlacedStructurePiece sourcePiece, + IrisJigsawConnector sourceConnector, + PlacedStructurePiece targetPiece, + Set unavailable + ) { + IrisPosition target = connectorTarget(sourcePiece, sourceConnector); + IrisDirection sourceFacing = sourcePiece.getRotation().rotate(sourceConnector.getDirection()); + IrisDirection sourceTop = sourcePiece.getRotation().rotate(sourceConnector.getTop()); + for (IrisJigsawConnector candidate : targetPiece.getPiece().getConnectors()) { + if (unavailable.contains(candidate) + || !Objects.equals(sourceConnector.getChannel(), candidate.getChannel()) + || !Objects.equals(sourceConnector.getTargetName(), candidate.getName())) { + continue; + } + IrisPosition candidatePosition = connectorPosition(targetPiece, candidate); + if (!target.equals(candidatePosition) + || targetPiece.getRotation().rotate(candidate.getDirection()) != sourceFacing.reverse()) { + continue; + } + if (sourceConnector.getJoint() != JigsawJoint.ALIGNED + || targetPiece.getRotation().rotate(candidate.getTop()) == sourceTop) { + return candidate; + } + } + return null; + } + + private IrisPosition connectorPosition(PlacedStructurePiece piece, IrisJigsawConnector connector) { + IrisPosition center = logicalCenter(piece); + IrisPosition offset = connectorOffset(piece.getObject(), connector, piece.getRotation()); + return new IrisPosition( + center.getX() + offset.getX(), + center.getY() + offset.getY(), + center.getZ() + offset.getZ()); + } + + private IrisPosition connectorTarget(PlacedStructurePiece piece, IrisJigsawConnector connector) { + IrisPosition position = connectorPosition(piece, connector); + IrisDirection facing = piece.getRotation().rotate(connector.getDirection()); + return new IrisPosition( + position.getX() + facing.x(), + position.getY() + facing.y(), + position.getZ() + facing.z()); + } + + private PlacedStructurePiece containingPiece( + KList pieces, + IrisPosition position + ) { + for (PlacedStructurePiece piece : pieces) { + if (contains(piece, position)) { + return piece; + } + } + return null; + } + + private boolean contains(PlacedStructurePiece piece, IrisPosition position) { + return position.getX() >= piece.getMinX() && position.getX() <= piece.getMaxX() + && position.getY() >= piece.getMinY() && position.getY() <= piece.getMaxY() + && position.getZ() >= piece.getMinZ() && position.getZ() <= piece.getMaxZ(); + } + + private boolean isMatched( + Map> matched, + PlacedStructurePiece piece, + IrisJigsawConnector connector + ) { + Set connectors = matched.get(piece); + return connectors != null && connectors.contains(connector); + } + + private boolean existingConnectorMatches( + OpenConnector source, + PlacedStructurePiece candidatePiece, + IrisJigsawConnector candidate, + int targetX, + int targetY, + int targetZ + ) { + requireConnector(candidate, candidatePiece.getObject().getLoadKey()); + if (!Objects.equals(source.channel(), candidate.getChannel()) + || !Objects.equals(source.targetName(), candidate.getName())) { + return false; + } + IrisPosition rotated = connectorOffset( + candidatePiece.getObject(), candidate, candidatePiece.getRotation()); + IrisPosition center = logicalCenter(candidatePiece); + if (center.getX() + rotated.getX() != targetX + || center.getY() + rotated.getY() != targetY + || center.getZ() + rotated.getZ() != targetZ) { + return false; + } + if (candidatePiece.getRotation().rotate(candidate.getDirection()) != source.facing().reverse()) { + return false; + } + return source.joint() != JigsawJoint.ALIGNED + || candidatePiece.getRotation().rotate(candidate.getTop()) == source.top(); + } + + private void registerPlacedPiece( + Map> consumedConnectors, + PlacedStructurePiece piece, + IrisJigsawConnector attachment + ) { + Set consumed = Collections.newSetFromMap(new IdentityHashMap<>()); + if (piece.getPiece().resolvedRules().isTerminal()) { + consumed.addAll(piece.getPiece().getConnectors()); + } else if (attachment != null) { + consumed.add(attachment); + } + consumedConnectors.put(piece, consumed); + } + + private boolean isConsumed( + Map> consumedConnectors, + OpenConnector connector + ) { + return isConsumed(consumedConnectors, connector.sourcePiece(), connector.sourceConnector()); + } + + private boolean isConsumed( + Map> consumedConnectors, + PlacedStructurePiece piece, + IrisJigsawConnector connector + ) { + Set consumed = consumedConnectors.get(piece); + return consumed != null && consumed.contains(connector); + } + + private void consume( + Map> consumedConnectors, + PlacedStructurePiece piece, + IrisJigsawConnector connector + ) { + Set consumed = consumedConnectors.get(piece); + if (consumed == null) { + throw assemblyFailure("placed piece connector ownership was not registered"); + } + consumed.add(connector); + } + + private void enqueuePieceExpansion( + Deque pending, + Map> openConnectors, + PlacedStructurePiece p, + IrisObject object, int depth, int pieceDepth, IrisJigsawConnector skip, + int placementPriority) { if (p.getPiece().getConnectors() == null) { throw assemblyFailure("placed piece for object '" + object.getLoadKey() + "' has no connector list"); } - IrisPosition center = centerOf(object); - for (IrisJigsawConnector con : p.getPiece().getConnectors()) { - if (con == skip) { - continue; - } + List connectors = new ArrayList<>(); + Map definitions = new IdentityHashMap<>(); + for (IrisJigsawConnector con : orderedConnectors(p.getPiece().getConnectors(), skip)) { requireConnector(con, object.getLoadKey()); if (con.getPool() == null || con.getPool().isBlank()) { continue; } - IrisPosition cr = new IrisPosition( - con.getPosition().getX() - center.getX(), - con.getPosition().getY() - center.getY(), - con.getPosition().getZ() - center.getZ()); - IrisPosition rcr = p.getRotation().rotate(cr, 0, 0, 0); - IrisDirection facing = p.getRotation().rotate(con.getDirection()); - IrisDirection top = p.getRotation().rotate(con.getTop()); - open.add(new OpenConnector( - p.getX() + rcr.getX(), - p.getY() + rcr.getY(), - p.getZ() + rcr.getZ(), - facing, top, con.getPool(), con.getName(), con.getTargetName(), con.getJoint(), depth)); + OpenConnector openConnector = openConnector(p, object, con, depth, pieceDepth); + connectors.add(openConnector); + definitions.put(con, openConnector); } + openConnectors.put(p, definitions); + if (!connectors.isEmpty()) { + pending.addLast(new PieceExpansion(connectors, placementPriority)); + } + } + + private OpenConnector openConnector( + PlacedStructurePiece piece, + IrisObject object, + IrisJigsawConnector connector, + int depth, + int pieceDepth + ) { + IrisPosition offset = connectorOffset(object, connector, piece.getRotation()); + IrisPosition center = logicalCenter(piece); + return new OpenConnector( + piece, + connector, + center.getX() + offset.getX(), + center.getY() + offset.getY(), + center.getZ() + offset.getZ(), + piece.getRotation().rotate(connector.getDirection()), + piece.getRotation().rotate(connector.getTop()), + normalize(connector.getPool()), + connector.getName(), + connector.getTargetName(), + connector.getChannel(), + connector.getJoint(), + depth, + pieceDepth, + connector.getPlacementPriority()); + } + + private List orderedConnectors(List connectors, + IrisJigsawConnector skip) { + List ordered = new ArrayList<>(connectors.size()); + for (IrisJigsawConnector connector : connectors) { + if (connector != skip) { + ordered.add(connector); + } + } + ordered.sort((first, second) -> Integer.compare( + second.getSelectionPriority(), first.getSelectionPriority())); + return ordered; + } + + private PieceExpansion removeNextPieceExpansion(Deque pending) { + int highestPriority = Integer.MIN_VALUE; + for (PieceExpansion expansion : pending) { + highestPriority = Math.max(highestPriority, expansion.placementPriority()); + } + Iterator iterator = pending.iterator(); + while (iterator.hasNext()) { + PieceExpansion expansion = iterator.next(); + if (expansion.placementPriority() == highestPriority) { + iterator.remove(); + return expansion; + } + } + throw assemblyFailure("piece expansion queue was unexpectedly empty"); } private PlacedStructurePiece build(IrisJigsawPiece piece, IrisObject object, int x, int y, int z, IrisObjectRotation rot) { int width = Math.max(1, object.getW()); int height = Math.max(1, object.getH()); int depth = Math.max(1, object.getD()); + if (structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW) { + IrisPosition dimensions = rotatedDimensions(object, rot); + IrisPosition offset = planarPlacementOffset(object, rot); + int minX = x - dimensions.getX() / 2; + int minY = y - dimensions.getY() / 2; + int minZ = z - dimensions.getZ() / 2; + return new PlacedStructurePiece( + piece, + object, + x + offset.getX(), + y + offset.getY(), + z + offset.getZ(), + rot, + minX, + minY, + minZ, + minX + dimensions.getX() - 1, + minY + dimensions.getY() - 1, + minZ + dimensions.getZ() - 1); + } int localMinX = -(width / 2); int localMinY = -(height / 2); int localMinZ = -(depth / 2); @@ -330,9 +1014,12 @@ public final class StructureAssembler { && p.getMinZ() >= originZ - radius && p.getMaxZ() <= originZ + radius; } - private boolean collides(KList placed, PlacedStructurePiece candidate) { + private boolean collides(List placed, PlacedStructurePiece candidate) { + if (!candidate.getPiece().isCollidable()) { + return false; + } for (PlacedStructurePiece p : placed) { - if (p.intersects(candidate)) { + if (p.getPiece().isCollidable() && p.intersects(candidate)) { return true; } } @@ -357,40 +1044,110 @@ public final class StructureAssembler { return new IrisPosition(object.getW() / 2, object.getH() / 2, object.getD() / 2); } - private IrisJigsawPieceEntry weightedPick(IrisJigsawPool pool, RNG rng) { - if (pool.getPieces() == null) { - throw assemblyFailure("pool has no piece list"); + private IrisPosition connectorOffset( + IrisObject object, + IrisJigsawConnector connector, + IrisObjectRotation rotation + ) { + if (structure.resolvedMode() == IrisJigsawMode.PLANAR_JIGSAW) { + IrisPosition dimensions = rotatedDimensions(object, rotation); + IrisPosition position = IrisJigsawConnector.canonicalPlanarPosition( + dimensions, + rotation.rotate(connector.getDirection())); + return new IrisPosition( + position.getX() - dimensions.getX() / 2, + position.getY() - dimensions.getY() / 2, + position.getZ() - dimensions.getZ() / 2); } + IrisPosition center = centerOf(object); + IrisPosition relative = new IrisPosition( + connector.getPosition().getX() - center.getX(), + connector.getPosition().getY() - center.getY(), + connector.getPosition().getZ() - center.getZ()); + return rotation.rotate(relative, 0, 0, 0); + } + + private IrisPosition logicalCenter(PlacedStructurePiece piece) { + if (structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW) { + return new IrisPosition(piece.getX(), piece.getY(), piece.getZ()); + } + IrisPosition offset = planarPlacementOffset(piece.getObject(), piece.getRotation()); + return new IrisPosition( + piece.getX() - offset.getX(), + piece.getY() - offset.getY(), + piece.getZ() - offset.getZ()); + } + + private IrisPosition rotatedDimensions(IrisObject object, IrisObjectRotation rotation) { + int width = Math.max(1, object.getW()); + int height = Math.max(1, object.getH()); + int depth = Math.max(1, object.getD()); + return quarterTurns(rotation) % 2 == 0 + ? new IrisPosition(width, height, depth) + : new IrisPosition(depth, height, width); + } + + private IrisPosition planarPlacementOffset(IrisObject object, IrisObjectRotation rotation) { + int width = Math.max(1, object.getW()); + int depth = Math.max(1, object.getD()); + return switch (quarterTurns(rotation)) { + case 0 -> new IrisPosition(); + case 1 -> new IrisPosition(0, 0, width % 2 == 0 ? -1 : 0); + case 2 -> new IrisPosition( + width % 2 == 0 ? -1 : 0, + 0, + depth % 2 == 0 ? -1 : 0); + case 3 -> new IrisPosition(depth % 2 == 0 ? -1 : 0, 0, 0); + default -> throw new IllegalStateException("Unsupported planar quarter-turn count"); + }; + } + + private int quarterTurns(IrisObjectRotation rotation) { + return switch (rotation.rotate(IrisDirection.NORTH_NEGATIVE_Z)) { + case NORTH_NEGATIVE_Z -> 0; + case WEST_NEGATIVE_X -> 1; + case SOUTH_POSITIVE_Z -> 2; + case EAST_POSITIVE_X -> 3; + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new IllegalStateException( + "Planar jigsaw rotation moved north onto a vertical axis"); + }; + } + + private IrisJigsawPieceEntry weightedPick(List entries, RNG rng) { long total = 0L; - for (IrisJigsawPieceEntry e : pool.getPieces()) { - if (e == null || e.getWeight() <= 0) { - throw assemblyFailure("pool contains a null or non-positive weighted entry"); - } - total += e.getWeight(); + for (IrisJigsawPieceEntry entry : entries) { + total += entry.getWeight(); } if (total <= 0) { return null; } - long t = rng.nextLong(total); - for (IrisJigsawPieceEntry e : pool.getPieces()) { - t -= e.getWeight(); - if (t < 0) { - return e; + long target = rng.nextLong(total); + for (IrisJigsawPieceEntry entry : entries) { + target -= entry.getWeight(); + if (target < 0) { + return entry; } } return null; } - private KList weightedOrder(IrisJigsawPool pool, RNG rng) { - KList remaining = new KList<>(); - if (pool.getPieces() != null) { - for (IrisJigsawPieceEntry entry : pool.getPieces()) { - if (entry == null || entry.getWeight() <= 0) { - throw assemblyFailure("pool contains a null or non-positive weighted entry"); - } - remaining.add(entry); - } - } + private KList weightedOrder( + IrisJigsawPool pool, + String selectedTheme, + int depth, + Map placementCounts, + boolean allowEmpty, + boolean terminalOnly, + RNG rng + ) { + List eligible = eligibleEntries( + pool, + selectedTheme, + depth, + placementCounts, + allowEmpty, + terminalOnly); + KList remaining = new KList<>(chanceEligibleEntries(eligible, rng)); KList order = new KList<>(); while (!remaining.isEmpty()) { long total = 0L; @@ -411,10 +1168,112 @@ public final class StructureAssembler { return order; } + private List eligibleEntries( + IrisJigsawPool pool, + String selectedTheme, + int depth, + Map placementCounts, + boolean allowEmpty, + boolean terminalOnly + ) { + if (pool.getPieces() == null) { + throw assemblyFailure("pool has no piece list"); + } + List eligible = new ArrayList<>(); + List required = new ArrayList<>(); + for (IrisJigsawPieceEntry entry : pool.getPieces()) { + if (entry == null || entry.getWeight() <= 0) { + throw assemblyFailure("pool contains a null or non-positive weighted entry"); + } + if (entry.isEmpty()) { + if (allowEmpty) { + eligible.add(entry); + } + continue; + } + String pieceKey = normalize(entry.getPiece()); + if (pieceKey.isEmpty()) { + throw assemblyFailure("pool contains a weighted entry without a piece key"); + } + IrisJigsawPiece piece = resolver.loadPiece(pieceKey); + if (piece == null) { + throw assemblyFailure("pool references missing piece '" + pieceKey + "'"); + } + int placements = placementCounts.getOrDefault(pieceKey, 0); + if (!pieceEnabled(piece) + || terminalOnly && !piece.resolvedRules().isTerminal() + || !JigsawPoolSelection.pieceEligible(piece, selectedTheme, depth, placements)) { + continue; + } + eligible.add(entry); + if (JigsawPoolSelection.needsMinimumPlacement(piece, placements)) { + required.add(entry); + } + } + return required.isEmpty() ? eligible : required; + } + + private List chanceEligibleEntries( + List entries, + RNG rng + ) { + List eligible = new ArrayList<>(entries.size()); + for (IrisJigsawPieceEntry entry : entries) { + if (JigsawPoolSelection.passesChance(entry, rng)) { + eligible.add(entry); + } + } + return eligible; + } + + private boolean pieceEnabled(IrisJigsawPiece piece) { + return structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW + || PlanarJigsawWorkcellResolver.workcell(planarWorkcells, piece).enabled(); + } + + private void incrementPlacement(Map placementCounts, String pieceKey) { + placementCounts.merge(normalize(pieceKey), 1, Integer::sum); + } + + private String unsatisfiedMinimumPlacement( + Map placementCounts, + String selectedTheme + ) { + for (Map.Entry entry : graph.getPieces().entrySet()) { + IrisJigsawPiece piece = entry.getValue(); + if (!pieceEnabled(piece) || !piece.supportsTheme(selectedTheme)) { + continue; + } + int actual = placementCounts.getOrDefault(entry.getKey(), 0); + int minimum = piece.resolvedRules().getMinimumPlacements(); + if (actual < minimum) { + return "Piece '" + entry.getKey() + "' requires at least " + minimum + + " placement(s) for theme '" + themeLabel(selectedTheme) + + "' but assembled " + actual; + } + } + return ""; + } + + private String themeLabel(String selectedTheme) { + return selectedTheme == null || selectedTheme.isBlank() ? "" : selectedTheme; + } + + private String connectorLocation(OpenConnector connector) { + return connector.wx() + "," + connector.wy() + "," + connector.wz() + + " facing " + connector.facing(); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + private void requireConnector(IrisJigsawConnector connector, String pieceKey) { if (connector == null || connector.getPosition() == null || connector.getDirection() == null || connector.getTop() == null || connector.getName() == null - || connector.getTargetName() == null || connector.getJoint() == null) { + || connector.getTargetName() == null || connector.getChannel() == null + || connector.getJoint() == null || connector.getFinalState() == null + || connector.getFinalState().isBlank()) { throw assemblyFailure("piece '" + pieceKey + "' contains a malformed connector"); } } @@ -439,15 +1298,58 @@ public final class StructureAssembler { + "' assembly rejected its invalid graph: " + detail); } - private record OpenConnector(int wx, int wy, int wz, IrisDirection facing, IrisDirection top, + private record OpenConnector(PlacedStructurePiece sourcePiece, IrisJigsawConnector sourceConnector, + int wx, int wy, int wz, IrisDirection facing, IrisDirection top, String pool, String name, - String targetName, JigsawJoint joint, int depth) { + String targetName, String channel, JigsawJoint joint, int depth, + int pieceDepth, int placementPriority) { } - private record AssemblyOptions(StructureGraphResolver resolver, IrisStructure structure, + private record PieceExpansion(List connectors, int placementPriority) { + } + + private record ConnectorClosure( + PlacedStructurePiece existingPiece, + IrisJigsawConnector existingConnector, + IrisJigsawConnector candidateConnector + ) { + } + + private record CapReservation(String pieceKey, PlacedStructurePiece piece) { + } + + private enum AttachmentState { + ATTACHED, + CLOSED, + TERMINATED, + NO_MATCH, + FAILED + } + + private record AttachmentResult(AttachmentState state, String detail) { + private AttachmentResult { + Objects.requireNonNull(state); + detail = detail == null ? "" : detail; + } + + private static AttachmentResult success(AttachmentState state) { + if (state == AttachmentState.NO_MATCH || state == AttachmentState.FAILED) { + throw new IllegalArgumentException("A successful attachment requires a resolved state"); + } + return new AttachmentResult(state, ""); + } + + private static AttachmentResult failed(String detail) { + return new AttachmentResult(AttachmentState.FAILED, detail); + } + } + + private record AssemblyOptions(StructureGraphResolver resolver, CompiledStructureGraph graph, + IrisStructure structure, IrisPosition origin) { private AssemblyOptions { Objects.requireNonNull(resolver, "Structure assembly resolver must not be null"); + Objects.requireNonNull(graph, "Compiled structure graph must not be null"); Objects.requireNonNull(structure, "Structure assembly structure must not be null"); Objects.requireNonNull(origin, "Structure assembly origin must not be null"); } 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 new file mode 100644 index 000000000..2c120b114 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java @@ -0,0 +1,359 @@ +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.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.MatterCavern; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.IntPredicate; + +public final class StructureCaveAnchorResolver { + private static final int CHUNK_COLUMN_COUNT = 256; + private static final int MAX_ATTEMPTS = 64; + private static final int MAX_SCAN_STEP = 16; + private static final int MAX_CLEARANCE = 64; + private static final int MAX_GENERIC_CENTER_SEARCH = 4096; + private static final byte LIQUID_FORCED_AIR = 3; + + private StructureCaveAnchorResolver() { + } + + public static Anchor resolve(Engine engine, IrisStructurePlacement placement, int chunkX, int chunkZ, RNG rng) { + Objects.requireNonNull(engine, "Cave structure anchor requires an engine"); + Objects.requireNonNull(placement, "Cave structure anchor requires a placement"); + Objects.requireNonNull(rng, "Cave structure anchor requires a random source"); + IrisStructureAnchorMode mode = placement.resolvedAnchor(); + if (!mode.isCave() || engine.getMantle() == null || engine.getMantle().getMantle() == null) { + return null; + } + + int worldMin = engine.getMinHeight() + 1; + int worldMax = engine.getMinHeight() + engine.getHeight() - 2; + int minimumY = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight())); + int maximumY = Math.min(worldMax, Math.max(placement.getMinHeight(), placement.getMaxHeight())); + if (minimumY > maximumY) { + return null; + } + + int attempts = Math.min(MAX_ATTEMPTS, Math.max(1, placement.getCaveAnchorAttempts())); + int scanStep = Math.min(MAX_SCAN_STEP, Math.max(1, placement.getCaveAnchorScanStep())); + int clearance = Math.min(MAX_CLEARANCE, Math.max(1, placement.getCaveMinimumClearance())); + int chunkMinX = chunkX << 4; + int chunkMinZ = chunkZ << 4; + int[] candidateColumns = candidateColumnIndices(rng, attempts); + for (int candidateColumn : candidateColumns) { + int blockX = chunkMinX + (candidateColumn & 15); + int blockZ = chunkMinZ + (candidateColumn >>> 4); + List anchors = anchorsInColumn( + engine, placement, mode, blockX, blockZ, + minimumY, maximumY, scanStep, clearance); + if (!anchors.isEmpty()) { + return new Anchor(blockX, anchors.get(rng.nextInt(anchors.size())), blockZ); + } + } + return null; + } + + static int[] candidateColumnIndices(RNG rng, int attempts) { + Objects.requireNonNull(rng, "Cave structure column selection requires a random source"); + int count = Math.min(MAX_ATTEMPTS, Math.max(1, attempts)); + int start = rng.nextInt(CHUNK_COLUMN_COUNT); + int stride = (rng.nextInt(CHUNK_COLUMN_COUNT / 2) * 2) + 1; + int[] columns = new int[count]; + for (int index = 0; index < count; index++) { + columns[index] = (start + (index * stride)) & (CHUNK_COLUMN_COUNT - 1); + } + return columns; + } + + static List anchorsInColumn( + Engine engine, + IrisStructurePlacement placement, + IrisStructureAnchorMode mode, + int blockX, + int blockZ, + int minimumY, + int maximumY, + int scanStep, + int clearance + ) { + if (mode == IrisStructureAnchorMode.CAVE_CENTER) { + return centerAnchorsInColumn( + engine, placement, blockX, blockZ, + minimumY, maximumY, scanStep, clearance); + } + List anchors = new ArrayList<>(); + int step = Math.max(1, scanStep); + for (int worldY = minimumY; worldY <= maximumY; worldY += step) { + int mantleY = toMantleY(worldY, engine.getMinHeight()); + if (!matchesGeometry(engine, mode, blockX, mantleY, blockZ, clearance)) { + continue; + } + if (!matchesAnchorFluid(engine, placement, blockX, mantleY, blockZ)) { + continue; + } + if (!matchesBiome(engine, placement, blockX, mantleY, blockZ)) { + continue; + } + anchors.add(worldY); + } + return List.copyOf(anchors); + } + + private static List centerAnchorsInColumn( + Engine engine, + IrisStructurePlacement placement, + int blockX, + int blockZ, + int minimumY, + int maximumY, + int scanStep, + int clearance + ) { + List anchors = new ArrayList<>(); + int step = Math.max(1, scanStep); + int worldOffset = engine.getMinHeight(); + int minimumMantleY = 0; + int maximumMantleY = engine.getHeight() - 1; + int processedThroughWorldY = Integer.MIN_VALUE; + IntPredicate carved = y -> isCarved(engine, blockX, y, blockZ); + for (int worldY = minimumY; worldY <= maximumY; worldY += step) { + if (worldY <= processedThroughWorldY) { + continue; + } + int mantleY = toMantleY(worldY, worldOffset); + if (!carved.test(mantleY)) { + continue; + } + int lower = cavernLowerBound(carved, mantleY, minimumMantleY); + int upper = cavernUpperBound(carved, mantleY, maximumMantleY); + processedThroughWorldY = upper + worldOffset; + if ((long) upper - lower + 1L < Math.max(1, clearance)) { + continue; + } + int lowerCenterWorldY = lower + Math.floorDiv(upper - lower, 2) + worldOffset; + addCenterAnchor( + anchors, engine, placement, blockX, blockZ, + lowerCenterWorldY, minimumY, maximumY); + int upperCenterWorldY = lower + Math.ceilDiv(upper - lower, 2) + worldOffset; + if (upperCenterWorldY != lowerCenterWorldY) { + addCenterAnchor( + anchors, engine, placement, blockX, blockZ, + upperCenterWorldY, minimumY, maximumY); + } + } + return List.copyOf(anchors); + } + + private static void addCenterAnchor( + List anchors, + Engine engine, + IrisStructurePlacement placement, + int blockX, + int blockZ, + int worldY, + int minimumY, + int maximumY + ) { + if (worldY < minimumY || worldY > maximumY) { + return; + } + int mantleY = toMantleY(worldY, engine.getMinHeight()); + if (matchesAnchorFluid(engine, placement, blockX, mantleY, blockZ) + && matchesBiome(engine, placement, blockX, mantleY, blockZ)) { + anchors.add(worldY); + } + } + + static boolean matchesGeometry( + Engine engine, + IrisStructureAnchorMode mode, + int blockX, + int mantleY, + int blockZ, + int clearance + ) { + IntPredicate carved = y -> isCarved(engine, blockX, y, blockZ); + if (mode == IrisStructureAnchorMode.CAVE_CENTER) { + return matchesCenterGeometry( + carved, mantleY, Math.max(1, clearance), 0, engine.getHeight() - 1); + } + return matchesGeometry(carved, mode, mantleY, clearance); + } + + static boolean matchesGeometry( + IntPredicate carved, + IrisStructureAnchorMode mode, + int mantleY, + int clearance + ) { + if (!carved.test(mantleY)) { + return false; + } + int required = Math.max(1, clearance); + return switch (mode) { + case CAVE_FLOOR -> !carved.test(mantleY - 1) + && carvedRun(carved, mantleY, 1, required); + case CAVE_CEILING -> !carved.test(mantleY + 1) + && carvedRun(carved, mantleY, -1, required); + case CAVE_CENTER -> matchesCenterGeometry( + carved, mantleY, required, + saturatedSubtract(mantleY, MAX_GENERIC_CENTER_SEARCH), + saturatedAdd(mantleY, MAX_GENERIC_CENTER_SEARCH)); + case CAVE_ANY -> carvedRun( + carved, mantleY - Math.floorDiv(required - 1, 2), 1, required); + default -> false; + }; + } + + private static boolean matchesCenterGeometry( + IntPredicate carved, + int mantleY, + int clearance, + int minimumY, + int maximumY + ) { + if (!carved.test(mantleY)) { + return false; + } + int lower = cavernLowerBound(carved, mantleY, minimumY); + int upper = cavernUpperBound(carved, mantleY, maximumY); + if ((long) upper - lower + 1L < clearance) { + return false; + } + int lowerCenter = lower + Math.floorDiv(upper - lower, 2); + int upperCenter = lower + Math.ceilDiv(upper - lower, 2); + return mantleY == lowerCenter || mantleY == upperCenter; + } + + private static int cavernLowerBound(IntPredicate carved, int mantleY, int minimumY) { + int lower = mantleY; + while (lower > minimumY && carved.test(lower - 1)) { + lower--; + } + return lower; + } + + private static int cavernUpperBound(IntPredicate carved, int mantleY, int maximumY) { + int upper = mantleY; + while (upper < maximumY && carved.test(upper + 1)) { + upper++; + } + return upper; + } + + private static int saturatedSubtract(int value, int amount) { + long result = (long) value - amount; + return result < Integer.MIN_VALUE ? Integer.MIN_VALUE : (int) result; + } + + private static int saturatedAdd(int value, int amount) { + long result = (long) value + amount; + return result > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) result; + } + + private static boolean carvedRun( + IntPredicate carved, + int mantleY, + int direction, + int length + ) { + for (int offset = 0; offset < length; offset++) { + if (!carved.test(mantleY + (direction * offset))) { + return false; + } + } + return true; + } + + private static boolean isCarved(Engine engine, int blockX, int mantleY, int blockZ) { + if (mantleY < 0 || mantleY >= engine.getHeight()) { + return false; + } + MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ); + return cavern != null && cavern.isCavern(); + } + + private static boolean matchesAnchorFluid( + Engine engine, + IrisStructurePlacement placement, + int blockX, + int mantleY, + int blockZ + ) { + MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ); + return acceptsAnchorFluid( + placement.isUnderwater(), + cavern, + mantleY, + engine.getDimension().getCaveLavaHeight()); + } + + static boolean acceptsAnchorFluid( + boolean underwater, + MatterCavern cavern, + int mantleY, + int defaultLavaHeight + ) { + if (cavern == null || !cavern.isCavern()) { + return false; + } + if (underwater) { + return true; + } + if (cavern.getLiquid() == LIQUID_FORCED_AIR) { + return true; + } + return cavern.isAir() && mantleY > defaultLavaHeight; + } + + private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) { + return engine.getMantle().getMantle().get(blockX, mantleY, blockZ, MatterCavern.class); + } + + static int toMantleY(int worldY, int worldMinHeight) { + return Math.subtractExact(worldY, worldMinHeight); + } + + private static boolean matchesBiome( + Engine engine, + IrisStructurePlacement placement, + int blockX, + int mantleY, + int blockZ + ) { + if (placement.getCaveBiomes() == null || placement.getCaveBiomes().isEmpty()) { + return true; + } + IrisBiome biome = engine.getCaveOrMantleBiome(blockX, mantleY, blockZ); + if (biome == null || biome.getLoadKey() == null) { + return false; + } + String actual = biome.getLoadKey().toLowerCase(Locale.ROOT); + for (String allowed : placement.getCaveBiomes()) { + if (matchesBiomeKey(actual, allowed)) { + return true; + } + } + return false; + } + + static boolean matchesBiomeKey(String actual, String allowed) { + if (actual == null || actual.isBlank() || allowed == null || allowed.isBlank()) { + return false; + } + String normalizedActual = actual.trim().toLowerCase(Locale.ROOT); + String normalizedAllowed = allowed.trim().toLowerCase(Locale.ROOT); + return normalizedActual.equals(normalizedAllowed) + || normalizedActual.endsWith(":" + normalizedAllowed) + || normalizedAllowed.endsWith(":" + normalizedActual); + } + + public record Anchor(int x, int y, int z) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java index ff2b374cd..4ae945a10 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java @@ -19,9 +19,14 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.volmlib.util.math.RNG; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; + public final class StructurePlacementGrid { private static final long DENSITY_SIGNATURE = 0x6A09E667F3BCC909L; @@ -155,6 +160,7 @@ public final class StructurePlacementGrid { signature = appendLong(signature, placement.isUnderground() ? 1L : 0L); signature = appendLong(signature, placement.isUnderwater() ? 1L : 0L); signature = appendSources(signature, placement); + signature = appendAnchor(signature, placement); } return signature; } @@ -211,6 +217,35 @@ public final class StructurePlacementGrid { return result; } + private static long appendAnchor(long signature, IrisStructurePlacement placement) { + IrisStructureAnchorMode anchor = placement.getAnchor(); + if (anchor == null || anchor == IrisStructureAnchorMode.LEGACY) { + return signature; + } + long result = appendLong(signature, 0x4A49475341574C41L); + result = appendLong(result, anchor.ordinal()); + if (!anchor.isCave()) { + return result; + } + result = appendLong(result, placement.getCaveAnchorAttempts()); + result = appendLong(result, placement.getCaveAnchorScanStep()); + result = appendLong(result, placement.getCaveMinimumClearance()); + if (placement.getCaveBiomes() == null) { + return appendLong(result, 0L); + } + Set biomes = new TreeSet<>(); + for (String biome : placement.getCaveBiomes()) { + if (biome != null && !biome.isBlank()) { + biomes.add(biome.trim().toLowerCase(Locale.ROOT)); + } + } + result = appendLong(result, biomes.size()); + for (String biome : biomes) { + result = appendSignature(result, biome); + } + return result; + } + static int placementSalt(IrisStructurePlacement placement) { String placementId = placement.getPlacementId(); long identity; @@ -230,6 +265,7 @@ public final class StructurePlacementGrid { identity = appendLong(identity, placement.isUnderground() ? 1L : 0L); identity = appendLong(identity, placement.isUnderwater() ? 1L : 0L); identity = appendSources(identity, placement); + identity = appendAnchor(identity, placement); } return placement.getSalt() ^ (int) (identity ^ (identity >>> 32)); } 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 8653dc525..a7ae4cd2d 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 @@ -24,8 +24,10 @@ public final class StructurePlacementScope { Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); 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 (activeEngine.getDimension() != null) { @@ -46,4 +48,17 @@ public final class StructurePlacementScope { } } } + + private static void addCaveUnique(KList destination, + Set seen, + KList source) { + if (source == null) { + return; + } + for (IrisStructurePlacement placement : source) { + if (placement != null && placement.resolvedAnchor().isCave() && seen.add(placement)) { + destination.add(placement); + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelection.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelection.java index 5dc3b3c25..3cf93c2f7 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelection.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelection.java @@ -1,7 +1,14 @@ package art.arcane.iris.engine.framework.structure; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.volmlib.util.math.RNG; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -33,6 +40,64 @@ public final class JigsawPoolSelection { : List.of(normalizedPrimary, directFallback); } + public static String selectTheme(IrisStructure structure, RNG rng) { + IrisStructure activeStructure = Objects.requireNonNull(structure); + RNG activeRng = Objects.requireNonNull(rng); + if (activeStructure.getThemeSets() == null || activeStructure.getThemeSets().isEmpty()) { + return ""; + } + List themes = new ArrayList<>(activeStructure.getThemeSets().size()); + long totalWeight = 0L; + for (IrisJigsawThemeSet theme : activeStructure.getThemeSets()) { + if (theme == null || normalize(theme.getKey()).isEmpty() || theme.getWeight() <= 0) { + throw new IllegalArgumentException("Jigsaw theme sets require non-blank keys and positive weights"); + } + themes.add(theme); + totalWeight = Math.addExact(totalWeight, theme.getWeight()); + } + if (themes.size() == 1) { + return normalize(themes.getFirst().getKey()); + } + long target = activeRng.nextLong(totalWeight); + for (IrisJigsawThemeSet theme : themes) { + target -= theme.getWeight(); + if (target < 0L) { + return normalize(theme.getKey()); + } + } + return normalize(themes.getLast().getKey()); + } + + public static boolean passesChance(IrisJigsawPieceEntry entry, RNG rng) { + IrisJigsawPieceEntry activeEntry = Objects.requireNonNull(entry); + RNG activeRng = Objects.requireNonNull(rng); + double chance = activeEntry.getChance(); + if (!Double.isFinite(chance) || chance < 0D || chance > 1D) { + throw new IllegalArgumentException("Jigsaw pool membership chance must be finite and within 0 and 1"); + } + if (!activeEntry.requiresChanceRoll()) { + return chance >= 1D; + } + return activeEntry.passesChance(activeRng.nextDouble()); + } + + public static boolean pieceEligible( + IrisJigsawPiece piece, + String selectedTheme, + int depth, + int existingPlacements + ) { + IrisJigsawPiece activePiece = Objects.requireNonNull(piece); + IrisJigsawPieceRules rules = activePiece.resolvedRules(); + return activePiece.supportsTheme(normalize(selectedTheme)) + && rules.allowsDepth(depth) + && rules.allowsPlacement(existingPlacements); + } + + public static boolean needsMinimumPlacement(IrisJigsawPiece piece, int existingPlacements) { + return Objects.requireNonNull(piece).resolvedRules().requiresMorePlacements(existingPlacements); + } + private static String normalize(String value) { return value == null ? "" : value.trim(); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolver.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolver.java new file mode 100644 index 000000000..26237e087 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolver.java @@ -0,0 +1,139 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawWorkcell; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + +public final class PlanarJigsawWorkcellResolver { + public static final int DEFAULT_SIZE = 16; + public static final int MAX_WIDTH = 128; + public static final int MAX_HEIGHT = 192; + public static final int MAX_DEPTH = 128; + public static final long MAX_VOLUME = 2_097_152L; + + private PlanarJigsawWorkcellResolver() { + } + + public static Map resolve(IrisStructure structure) { + IrisStructure source = Objects.requireNonNull(structure, "Planar jigsaw structure"); + EnumMap resolved = + new EnumMap<>(IrisJigsawWorkcellArchetype.class); + if (source.getPlanarWorkcells() == null || source.getPlanarWorkcells().isEmpty()) { + IrisPosition fallback = source.getCellSize() == null + ? new IrisPosition(DEFAULT_SIZE, DEFAULT_SIZE, DEFAULT_SIZE) + : source.getCellSize(); + for (IrisJigsawWorkcellArchetype archetype : IrisJigsawWorkcellArchetype.values()) { + ResolvedWorkcell workcell = new ResolvedWorkcell( + archetype, + "", + fallback.getX(), + fallback.getY(), + fallback.getZ(), + true); + validate(workcell); + resolved.put(archetype, workcell); + } + return Collections.unmodifiableMap(resolved); + } + + for (IrisJigsawWorkcell configured : source.getPlanarWorkcells()) { + if (configured == null || configured.getArchetype() == null) { + throw new IllegalArgumentException("Planar jigsaw workcells require an archetype"); + } + ResolvedWorkcell workcell = new ResolvedWorkcell( + configured.getArchetype(), + normalizeDisplayName(configured.getDisplayName()), + configured.getWidth(), + configured.getHeight(), + configured.getDepth(), + configured.isEnabled()); + validate(workcell); + if (resolved.putIfAbsent(workcell.archetype(), workcell) != null) { + throw new IllegalArgumentException( + "Planar jigsaw workcell " + workcell.archetype() + " is configured more than once"); + } + } + if (resolved.size() != IrisJigsawWorkcellArchetype.values().length) { + for (IrisJigsawWorkcellArchetype archetype : IrisJigsawWorkcellArchetype.values()) { + if (!resolved.containsKey(archetype)) { + throw new IllegalArgumentException("Planar jigsaw workcell " + archetype + " is missing"); + } + } + } + return Collections.unmodifiableMap(resolved); + } + + public static ResolvedWorkcell workcell( + Map workcells, + IrisJigsawPiece piece + ) { + Map resolved = Objects.requireNonNull( + workcells, + "Planar jigsaw workcells"); + IrisJigsawWorkcellArchetype archetype = IrisJigsawWorkcellArchetype.fromPiece(piece); + ResolvedWorkcell workcell = resolved.get(archetype); + if (workcell == null) { + throw new IllegalArgumentException("Planar jigsaw workcell " + archetype + " is not configured"); + } + return workcell; + } + + public static IrisPosition canonicalDimensions(IrisJigsawPiece piece, IrisObject object) { + IrisJigsawPiece sourcePiece = Objects.requireNonNull(piece, "Planar jigsaw piece"); + IrisObject sourceObject = Objects.requireNonNull(object, "Planar jigsaw object"); + IrisJigsawWorkcellArchetype archetype = IrisJigsawWorkcellArchetype.fromPiece(sourcePiece); + int quarterTurns = archetype.sourceToCanonicalQuarterTurns(sourcePiece); + return Math.floorMod(quarterTurns, 2) == 0 + ? new IrisPosition(sourceObject.getW(), sourceObject.getH(), sourceObject.getD()) + : new IrisPosition(sourceObject.getD(), sourceObject.getH(), sourceObject.getW()); + } + + public record ResolvedWorkcell( + IrisJigsawWorkcellArchetype archetype, + String displayName, + int width, + int height, + int depth, + boolean enabled + ) { + public ResolvedWorkcell { + Objects.requireNonNull(archetype, "Planar jigsaw workcell archetype"); + displayName = normalizeDisplayName(displayName); + } + + public IrisPosition dimensions() { + return new IrisPosition(width, height, depth); + } + + public boolean contains(IrisPosition dimensions) { + IrisPosition size = Objects.requireNonNull(dimensions, "Planar jigsaw object dimensions"); + return size.getX() <= width && size.getY() <= height && size.getZ() <= depth; + } + } + + private static String normalizeDisplayName(String displayName) { + return displayName == null ? "" : displayName.trim(); + } + + private static void validate(ResolvedWorkcell workcell) { + if (workcell.width() < 3 || workcell.width() > MAX_WIDTH + || workcell.height() < 1 || workcell.height() > MAX_HEIGHT + || workcell.depth() < 3 || workcell.depth() > MAX_DEPTH) { + throw new IllegalArgumentException("Planar jigsaw workcell " + workcell.archetype() + + " dimensions must keep width/depth within 3..128 and height within 1..192"); + } + long volume = (long) workcell.width() * workcell.height() * workcell.depth(); + if (volume > MAX_VOLUME) { + throw new IllegalArgumentException("Planar jigsaw workcell " + workcell.archetype() + + " volume cannot exceed " + MAX_VOLUME + " blocks"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResult.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResult.java new file mode 100644 index 000000000..02d7d1e87 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResult.java @@ -0,0 +1,71 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.framework.PlacedStructurePiece; + +import java.util.List; +import java.util.Objects; + +public record StructureAssemblyResult( + StructureAssemblyStatus status, + List pieces, + String selectedTheme, + String detail +) { + public StructureAssemblyResult { + status = Objects.requireNonNull(status, "Structure assembly status"); + pieces = List.copyOf(Objects.requireNonNull(pieces, "Structure assembly pieces")); + selectedTheme = normalize(selectedTheme); + detail = normalize(detail); + if (status == StructureAssemblyStatus.COMPLETE && pieces.isEmpty()) { + throw new IllegalArgumentException("A complete structure assembly must contain at least one piece"); + } + if (status == StructureAssemblyStatus.INTENTIONAL_EMPTY && !pieces.isEmpty()) { + throw new IllegalArgumentException("An intentionally empty structure assembly cannot contain pieces"); + } + if (status.isFailure() && detail.isEmpty()) { + throw new IllegalArgumentException("A failed structure assembly requires failure detail"); + } + } + + public static StructureAssemblyResult complete( + List pieces, + String selectedTheme + ) { + return new StructureAssemblyResult( + StructureAssemblyStatus.COMPLETE, + pieces, + selectedTheme, + ""); + } + + public static StructureAssemblyResult intentionalEmpty( + String selectedTheme, + String detail + ) { + return new StructureAssemblyResult( + StructureAssemblyStatus.INTENTIONAL_EMPTY, + List.of(), + selectedTheme, + detail); + } + + public static StructureAssemblyResult failed( + StructureAssemblyStatus status, + List pieces, + String selectedTheme, + String detail + ) { + if (Objects.requireNonNull(status, "Structure assembly failure status").isComplete()) { + throw new IllegalArgumentException("A failed structure assembly requires a failure status"); + } + return new StructureAssemblyResult(status, pieces, selectedTheme, detail); + } + + public boolean hasOutput() { + return status == StructureAssemblyStatus.COMPLETE && !pieces.isEmpty(); + } + + private static String normalize(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyStatus.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyStatus.java new file mode 100644 index 000000000..c8642ab59 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureAssemblyStatus.java @@ -0,0 +1,17 @@ +package art.arcane.iris.engine.framework.structure; + +public enum StructureAssemblyStatus { + COMPLETE, + INTENTIONAL_EMPTY, + FAILED_UNCAPPED, + FAILED_RULES, + HARD_CAP; + + public boolean isComplete() { + return this == COMPLETE || this == INTENTIONAL_EMPTY; + } + + public boolean isFailure() { + return !isComplete(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphAssemblySample.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphAssemblySample.java index 526392fad..b045843f6 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphAssemblySample.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphAssemblySample.java @@ -1,24 +1,30 @@ package art.arcane.iris.engine.framework.structure; import java.util.List; +import java.util.Objects; public record StructureGraphAssemblySample(long seed, Outcome outcome) { - public record Outcome(List pieceKeys, int unresolvedConnectorCount, boolean pieceCapReached, - boolean intentionalEmpty) { + public record Outcome( + List pieceKeys, + StructureAssemblyStatus status, + String detail + ) { public Outcome { - pieceKeys = List.copyOf(pieceKeys); + pieceKeys = List.copyOf(Objects.requireNonNull(pieceKeys)); + status = Objects.requireNonNull(status); + detail = Objects.requireNonNull(detail); } - public Outcome(List pieceKeys, int unresolvedConnectorCount, boolean pieceCapReached) { - this(pieceKeys, unresolvedConnectorCount, pieceCapReached, false); + public boolean pieceCapReached() { + return status == StructureAssemblyStatus.HARD_CAP; } - public boolean isViable() { - return (intentionalEmpty || !pieceKeys.isEmpty()) && !pieceCapReached; + public boolean intentionalEmpty() { + return status == StructureAssemblyStatus.INTENTIONAL_EMPTY; } public boolean isComplete() { - return isViable() && unresolvedConnectorCount == 0; + return status.isComplete(); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCatalog.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCatalog.java index 7ad69f4c2..938bf048f 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCatalog.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCatalog.java @@ -1,12 +1,10 @@ package art.arcane.iris.engine.framework.structure; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructureAssembler; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.spi.IrisLogging; -import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; @@ -87,8 +85,8 @@ public final class StructureGraphCatalog { try { StructureAssembler assembler = StructureAssembler.forData( data, structure, new IrisPosition(0, 64, 0)); - KList pieces = assembler.assemble(new RNG(seed)); - if (pieces == null || pieces.isEmpty()) { + StructureAssemblyResult result = assembler.assemble(new RNG(seed)); + if (!result.hasOutput()) { return false; } } catch (Throwable e) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilation.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilation.java index 9cfd5aa63..a0accb9c7 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilation.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilation.java @@ -1,9 +1,11 @@ package art.arcane.iris.engine.framework.structure; import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisJigsawPieceEntry; import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; import art.arcane.iris.engine.object.IrisObject; import java.util.ArrayList; @@ -52,8 +54,7 @@ public final class StructureGraphCompilation { return false; } for (StructureGraphAssemblySample sample : assemblySamples) { - StructureGraphAssemblySample.Outcome outcome = sample.outcome(); - if (!outcome.isComplete() && !isGeometryBoundedCandidate(outcome)) { + if (!sample.outcome().isComplete()) { return false; } } @@ -72,34 +73,64 @@ public final class StructureGraphCompilation { if (startPool == null || startPool.getPieces() == null || startPool.getPieces().isEmpty()) { return false; } + for (String theme : themes()) { + if (!startPoolGuaranteesOutput(startPool, theme) + || !reachableBranchesCanTerminate(theme)) { + return false; + } + } + return true; + } + + private boolean startPoolGuaranteesOutput(IrisJigsawPool startPool, String theme) { + boolean certainPhysicalEntry = false; for (IrisJigsawPieceEntry entry : startPool.getPieces()) { - if (entry == null || entry.getWeight() <= 0 || entry.isEmpty()) { + if (entry == null || entry.getWeight() <= 0 || !Double.isFinite(entry.getChance()) + || entry.getChance() <= 0D || entry.getChance() > 1D) { + continue; + } + if (entry.isEmpty()) { return false; } IrisJigsawPiece piece = graph.getPieces().get(normalize(entry.getPiece())); - if (piece == null) { - return false; + if (piece == null || !JigsawPoolSelection.pieceEligible(piece, theme, 0, 0)) { + continue; } IrisObject object = graph.getObjects().get(normalize(piece.getObject())); if (object == null || !fitsStartRadius(object)) { return false; } + if (entry.getChance() == 1D) { + certainPhysicalEntry = true; + } } - return reachableBranchesCanTerminate(); + return certainPhysicalEntry; } - private boolean reachableBranchesCanTerminate() { + private boolean reachableBranchesCanTerminate(String theme) { for (String pieceKey : graph.getReachablePieces()) { IrisJigsawPiece piece = graph.getPieces().get(pieceKey); if (piece == null || piece.getConnectors() == null) { return false; } + if (!piece.supportsTheme(theme) || piece.resolvedRules().isTerminal()) { + continue; + } for (IrisJigsawConnector connector : piece.getConnectors()) { if (connector == null) { return false; } IrisJigsawPool pool = graph.getPools().get(normalize(connector.getPool())); - if (!canTerminateWithoutPlacement(pool)) { + if (pool == null) { + return false; + } + if (pool.requiresFallback(graph.getStructure().isRequireCaps())) { + if (!hasRequiredTerminal(pool, theme)) { + return false; + } + } else if (graph.getStructure().resolvedBranchFailurePolicy() + != IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH + && !canTerminateWithoutPlacement(pool)) { return false; } } @@ -107,6 +138,27 @@ public final class StructureGraphCompilation { return true; } + private boolean hasRequiredTerminal(IrisJigsawPool pool, String theme) { + String fallbackKey = JigsawPoolSelection.directFallbackKey(pool); + if (fallbackKey.isEmpty()) { + return false; + } + IrisJigsawPool fallback = graph.getPools().get(fallbackKey); + if (fallback == null || fallback.getPieces() == null) { + return false; + } + for (IrisJigsawPieceEntry entry : fallback.getPieces()) { + if (entry == null || entry.isEmpty() || entry.getWeight() <= 0 || entry.getChance() != 1D) { + continue; + } + IrisJigsawPiece piece = graph.getPieces().get(normalize(entry.getPiece())); + if (piece != null && piece.supportsTheme(theme) && piece.resolvedRules().isTerminal()) { + return true; + } + } + return false; + } + private boolean canTerminateWithoutPlacement(IrisJigsawPool pool) { if (hasEmptyChoice(pool)) { return true; @@ -126,7 +178,7 @@ public final class StructureGraphCompilation { return true; } for (IrisJigsawPieceEntry entry : pool.getPieces()) { - if (entry != null && entry.getWeight() > 0 && entry.isEmpty()) { + if (entry != null && entry.getWeight() > 0 && entry.getChance() == 1D && entry.isEmpty()) { return true; } } @@ -138,6 +190,19 @@ public final class StructureGraphCompilation { return axisFitsRadius(object.getW(), radius) && axisFitsRadius(object.getD(), radius); } + private List themes() { + if (graph.getStructure().getThemeSets() == null || graph.getStructure().getThemeSets().isEmpty()) { + return List.of(""); + } + List themes = new ArrayList<>(graph.getStructure().getThemeSets().size()); + for (IrisJigsawThemeSet theme : graph.getStructure().getThemeSets()) { + if (theme != null && theme.getKey() != null && !theme.getKey().isBlank()) { + themes.add(theme.getKey().trim()); + } + } + return List.copyOf(themes); + } + private boolean axisFitsRadius(int size, long radius) { if (size < 1 || radius < 1L) { return false; @@ -151,12 +216,6 @@ public final class StructureGraphCompilation { return value == null ? "" : value.trim(); } - private boolean isGeometryBoundedCandidate(StructureGraphAssemblySample.Outcome outcome) { - return outcome.pieceCapReached() - && !outcome.pieceKeys().isEmpty() - && outcome.unresolvedConnectorCount() == 0; - } - static final class Builder { private final CompiledStructureGraph graph; private final List diagnostics = new ArrayList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompiler.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompiler.java index 6089ba206..48546695e 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphCompiler.java @@ -1,28 +1,37 @@ package art.arcane.iris.engine.framework.structure; +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.framework.StructureAssembler; import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; +import art.arcane.iris.engine.object.IrisJigsawCompatibility; import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.JigsawJoint; import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.SplittableRandom; public final class StructureGraphCompiler { private static final int ASSEMBLY_PIECE_CAP = 512; @@ -41,9 +50,15 @@ public final class StructureGraphCompiler { state.loadClosure(); state.detectFallbackCycles(); state.analyzeReachability(); - List samples = state.sampleAssemblies(); - state.reportPieceCapSamples(samples); + state.validatePlanarReachableObjects(); + state.validateRuleFeasibility(); + state.validateRequiredCaps(); CompiledStructureGraph graph = state.buildGraph(); + StructureGraphCompilation validated = StructureGraphCompilation.builder(graph) + .diagnostics(state.diagnostics()) + .build(); + List samples = state.sampleAssemblies(validated); + state.reportPieceCapSamples(samples); return StructureGraphCompilation.builder(graph) .diagnostics(state.diagnostics()) .assemblySamples(samples) @@ -67,9 +82,12 @@ public final class StructureGraphCompiler { private final Set missingObjects = new HashSet<>(); private final Set reachableEntries = new LinkedHashSet<>(); private final Set reachablePieceStates = new LinkedHashSet<>(); - private final Map> activeConnectors = new LinkedHashMap<>(); + private final Map> reachablePiecesByTheme = new LinkedHashMap<>(); + private final Map> activeConnectors = new LinkedHashMap<>(); private final List diagnostics = new ArrayList<>(); private final Set diagnosticKeys = new HashSet<>(); + private Map + planarWorkcells = Map.of(); private CompilationState(IrisStructure structure, StructureGraphResolver resolver) { this.structure = structure; @@ -96,6 +114,7 @@ public final class StructureGraphCompiler { } private void validateStructureLimits() { + validateThemes(); if (structure.getMaxDepth() < 1 || structure.getMaxDepth() > MAX_DEPTH) { addDiagnostic(StructureGraphDiagnostic.Code.INVALID_MAX_DEPTH, "Structure '" + structureKey() + "' has maxDepth " + structure.getMaxDepth() @@ -108,6 +127,80 @@ public final class StructureGraphCompiler { + "; it must be between 1 and " + MAX_SIZE_CHUNKS + ".", "structure:max-size"); } + if (structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW) { + return; + } + try { + planarWorkcells = PlanarJigsawWorkcellResolver.resolve(structure); + } catch (IllegalArgumentException exception) { + boolean legacy = structure.getPlanarWorkcells() == null || structure.getPlanarWorkcells().isEmpty(); + addDiagnostic( + legacy + ? StructureGraphDiagnostic.Code.INVALID_PLANAR_CELL_SIZE + : StructureGraphDiagnostic.Code.INVALID_PLANAR_WORKCELL, + "Structure '" + structureKey() + "' has invalid planar workcells: " + + exception.getMessage() + ".", + "structure:planar-workcells"); + } + } + + private void validateThemes() { + KList themeSets = structure.getThemeSets(); + if (themeSets == null) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_THEME, + "Structure '" + structureKey() + "' declares a null themeSets list.", + "structure:themes:null"); + return; + } + Set keys = new LinkedHashSet<>(); + for (int index = 0; index < themeSets.size(); index++) { + IrisJigsawThemeSet theme = themeSets.get(index); + if (theme == null) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_THEME, + "Structure '" + structureKey() + "' themeSets[" + index + "] is null.", + "structure:theme:null:" + index); + continue; + } + String key = normalize(theme.getKey()); + if (key.isEmpty() || !key.equals(theme.getKey())) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_THEME, + "Structure '" + structureKey() + "' themeSets[" + index + + "] must declare a non-blank, whitespace-normalized key.", + "structure:theme:key:" + index); + } else if (!keys.add(key)) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_THEME, + "Structure '" + structureKey() + "' declares duplicate theme key '" + key + "'.", + "structure:theme:duplicate:" + key); + } + if (theme.getWeight() <= 0) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_WEIGHT, + "Structure '" + structureKey() + "' theme '" + key + + "' has non-positive weight " + theme.getWeight() + ".", + "structure:theme:weight:" + index); + } + } + if (!themeSets.isEmpty() + && structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE) { + addDiagnostic(StructureGraphDiagnostic.Code.NON_PORTABLE_METADATA, + "Structure '" + structureKey() + + "' declares coherent Iris theme sets in a VANILLA_PORTABLE graph.", + "structure:portable:themes"); + } + if (structure.isRequireCaps() + && structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE) { + addDiagnostic(StructureGraphDiagnostic.Code.NON_PORTABLE_METADATA, + "Structure '" + structureKey() + + "' requires physical terminal caps, which vanilla jigsaws cannot enforce.", + "structure:portable:require-caps"); + } + if (structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE + && structure.resolvedBranchFailurePolicy() + != IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH) { + addDiagnostic(StructureGraphDiagnostic.Code.NON_PORTABLE_METADATA, + "Structure '" + structureKey() + + "' fails unresolved optional connector branches, which vanilla jigsaws terminate.", + "structure:portable:branch-failure-policy"); + } } private void requestPool(String rawKey, PoolReference reference) { @@ -156,6 +249,19 @@ public final class StructureGraphCompiler { } String fallback = JigsawPoolSelection.directFallbackKey(pool); + if (pool.isMandatoryFallback() && fallback.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_REQUIRED_FALLBACK, + "Jigsaw pool '" + key + + "' requires a direct fallback but does not declare one.", + "pool:required-fallback:" + key); + } + if (pool.isMandatoryFallback() + && structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE) { + addDiagnostic(StructureGraphDiagnostic.Code.NON_PORTABLE_METADATA, + "Jigsaw pool '" + key + + "' declares mandatoryFallback, which vanilla jigsaws cannot enforce.", + "pool:portable:mandatory-fallback:" + key); + } if (!fallback.isEmpty()) { requestPool(fallback, new PoolReference( "Jigsaw pool '" + key + "' references missing fallback pool '" + fallback + "'.", @@ -174,7 +280,7 @@ public final class StructureGraphCompiler { String pieceKey = normalize(entry.getPiece()); PoolEntryReference entryReference = new PoolEntryReference( - poolKey, index, pieceKey, entry.getWeight(), entry.isEmpty()); + poolKey, index, pieceKey, entry.getWeight(), entry.getChance(), entry.isEmpty()); entries.add(entryReference); if (entry.getWeight() <= 0) { addDiagnostic(StructureGraphDiagnostic.Code.INVALID_WEIGHT, @@ -182,6 +288,19 @@ public final class StructureGraphCompiler { + entry.getWeight() + ".", "pool-entry:weight:" + poolKey + ":" + index); } + if (!Double.isFinite(entry.getChance()) || entry.getChance() < 0D || entry.getChance() > 1D) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CHANCE, + "Jigsaw pool '" + poolKey + "' pieces[" + index + "] has chance " + + entry.getChance() + "; it must be finite and within 0..1.", + "pool-entry:chance:" + poolKey + ":" + index); + } + if (entry.getChance() != 1D + && structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE) { + addDiagnostic(StructureGraphDiagnostic.Code.NON_PORTABLE_METADATA, + "Jigsaw pool '" + poolKey + "' pieces[" + index + + "] declares an Iris chance gate in a VANILLA_PORTABLE graph.", + "pool-entry:portable-chance:" + poolKey + ":" + index); + } if (entry.isEmpty()) { if (!pieceKey.isEmpty()) { addDiagnostic(StructureGraphDiagnostic.Code.INVALID_POOL_ENTRY, @@ -221,9 +340,85 @@ public final class StructureGraphCompiler { graph.pieces().put(key, piece); IrisObject object = loadPieceObject(key, piece); + validatePieceMetadata(key, piece); scanConnectors(key, piece, object); } + private void validatePieceMetadata(String pieceKey, IrisJigsawPiece piece) { + KList themes = piece.getThemes(); + if (themes == null) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_THEME, + "Jigsaw piece '" + pieceKey + "' declares a null themes list.", + "piece:themes:null:" + pieceKey); + } else { + Set declaredThemes = declaredThemeKeys(); + Set seenThemes = new LinkedHashSet<>(); + for (int index = 0; index < themes.size(); index++) { + String configured = themes.get(index); + String theme = normalize(configured); + if (theme.isEmpty() || !theme.equals(configured)) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_THEME, + "Jigsaw piece '" + pieceKey + "' themes[" + index + + "] must be non-blank and whitespace-normalized.", + "piece:theme:key:" + pieceKey + ":" + index); + } else if (!seenThemes.add(theme)) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_THEME, + "Jigsaw piece '" + pieceKey + "' declares duplicate theme '" + theme + "'.", + "piece:theme:duplicate:" + pieceKey + ":" + theme); + } else if (!declaredThemes.contains(theme)) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_THEME, + "Jigsaw piece '" + pieceKey + "' references undeclared theme '" + theme + "'.", + "piece:theme:undeclared:" + pieceKey + ":" + theme); + } + } + } + + IrisJigsawPieceRules rules = piece.resolvedRules(); + boolean validDepths = rules.getMinimumDepth() >= 0 + && rules.getMaximumDepth() >= rules.getMinimumDepth() + && rules.getMaximumDepth() <= MAX_DEPTH; + boolean validPlacements = rules.getMinimumPlacements() >= 0 + && rules.getMaximumPlacements() >= 0 + && rules.getMinimumPlacements() <= ASSEMBLY_PIECE_CAP + && rules.getMaximumPlacements() <= ASSEMBLY_PIECE_CAP + && (rules.getMaximumPlacements() == 0 + || rules.getMinimumPlacements() <= rules.getMaximumPlacements()); + if (!validDepths || !validPlacements) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_PIECE_RULE, + "Jigsaw piece '" + pieceKey + + "' has invalid depth or placement-count rules.", + "piece:rules:" + pieceKey); + } + if (structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE + && (themes != null && !themes.isEmpty() || !defaultRules(rules))) { + addDiagnostic(StructureGraphDiagnostic.Code.NON_PORTABLE_METADATA, + "Jigsaw piece '" + pieceKey + + "' declares Iris theme or placement rules in a VANILLA_PORTABLE graph.", + "piece:portable:metadata:" + pieceKey); + } + } + + private Set declaredThemeKeys() { + Set themes = new LinkedHashSet<>(); + if (structure.getThemeSets() == null) { + return themes; + } + for (IrisJigsawThemeSet theme : structure.getThemeSets()) { + if (theme != null && !normalize(theme.getKey()).isEmpty()) { + themes.add(normalize(theme.getKey())); + } + } + return themes; + } + + private boolean defaultRules(IrisJigsawPieceRules rules) { + return rules.getMinimumDepth() == 0 + && rules.getMaximumDepth() == MAX_DEPTH + && rules.getMinimumPlacements() == 0 + && rules.getMaximumPlacements() == 0 + && !rules.isTerminal(); + } + private IrisObject loadPieceObject(String pieceKey, IrisJigsawPiece piece) { String objectKey = normalize(piece.getObject()); pieceObjects.put(pieceKey, objectKey); @@ -317,6 +512,27 @@ public final class StructureGraphCompiler { "connector:names:" + pieceKey + ":" + index); } + boolean validMetadata = connector.getChannel() != null + && connector.getFinalState() != null + && !connector.getFinalState().isBlank(); + if (!validMetadata) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_CONNECTOR_METADATA, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] must declare a channel and non-blank finalState.", + "connector:metadata:" + pieceKey + ":" + index); + } + if (structure.resolvedCompatibility() == IrisJigsawCompatibility.VANILLA_PORTABLE + && connector.getChannel() != null && !connector.getChannel().isBlank()) { + addDiagnostic(StructureGraphDiagnostic.Code.NON_PORTABLE_CONNECTOR, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + + "] declares Iris-only channel '" + connector.getChannel() + + "' in a VANILLA_PORTABLE structure.", + "connector:portable-channel:" + pieceKey + ":" + index); + } + + boolean validPlanarContract = validatePlanarConnector( + pieceKey, index, connector, object, validDirection, validTop, validPosition); + String poolKey = normalize(connector.getPool()); if (poolKey.isEmpty()) { addDiagnostic(StructureGraphDiagnostic.Code.MISSING_CONNECTOR_POOL, @@ -330,10 +546,56 @@ public final class StructureGraphCompiler { StructureGraphDiagnostic.Code.MISSING_POOL)); } references.add(new ConnectorReference( - pieceKey, index, connector, validPosition, validDirection, validTop, validJoint, validNames)); + pieceKey, index, connector, validPosition, validDirection, validTop, validJoint, validNames, + validMetadata, validPlanarContract)); } } + private boolean validatePlanarConnector(String pieceKey, int index, IrisJigsawConnector connector, + IrisObject object, boolean validDirection, boolean validTop, + boolean validPosition) { + if (structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW) { + return true; + } + + boolean valid = true; + if (validDirection && connector.getDirection().isVertical()) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_PLANAR_CONNECTOR, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + "] uses vertical direction " + + connector.getDirection().name() + + "; planar connectors must face north, east, south," + + " or west.", + "connector:planar-direction:" + pieceKey + ":" + index); + valid = false; + } + if (validTop && connector.getTop() != IrisDirection.UP_POSITIVE_Y) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_PLANAR_CONNECTOR, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + "] has top " + + connector.getTop().name() + + "; planar connectors must use UP_POSITIVE_Y to keep" + + " connected cells on one Y level.", + "connector:planar-top:" + pieceKey + ":" + index); + valid = false; + } + + if (!validDirection || connector.getDirection().isVertical() + || object == null || !validPosition) { + return valid; + } + IrisPosition objectSize = new IrisPosition(object.getW(), object.getH(), object.getD()); + IrisPosition expected = IrisJigsawConnector.canonicalPlanarPosition( + objectSize, connector.getDirection()); + if (!expected.equals(connector.getPosition())) { + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_PLANAR_CONNECTOR, + "Jigsaw piece '" + pieceKey + "' connectors[" + index + "] is at " + + connector.getPosition() + "; the canonical " + connector.getDirection().name() + + " face-center socket for object dimensions " + objectSize + " is " + expected + ".", + "connector:planar-position:" + pieceKey + ":" + index); + valid = false; + } + return valid; + } + private String invalidPositionMessage(String pieceKey, int index, IrisPosition position, IrisObject object) { if (position == null) { return "Jigsaw piece '" + pieceKey + "' connectors[" + index @@ -399,11 +661,23 @@ public final class StructureGraphCompiler { if (!graph.pools().containsKey(startPool)) { return; } - graph.reachablePools().add(startPool); + for (String theme : reachabilityThemes()) { + analyzeThemeReachability(startPool, theme); + } + reportUnmatchedReachableConnectors(); + reportUnreachableResources(); + } + + private void analyzeThemeReachability(String startPool, String theme) { Deque pendingPieces = new ArrayDeque<>(); + boolean viableStart = false; for (PoolEntryReference entry : poolEntries.getOrDefault(startPool, List.of())) { - markReachableEntry(entry); + if (!entryIsViable(entry, theme, 0)) { + continue; + } + viableStart = true; + markReachableEntry(entry, theme); if (entry.empty()) { continue; } @@ -412,14 +686,35 @@ public final class StructureGraphCompiler { continue; } for (int rotation : rotationsFor(piece)) { - markReachablePieceState(new PieceReachState(entry.pieceKey(), -1, rotation), pendingPieces); + markReachablePieceState( + new PieceReachState(entry.pieceKey(), -1, rotation, 0, theme), + pendingPieces); } } - if (pendingPieces.isEmpty()) { - addDiagnostic(StructureGraphDiagnostic.Code.NO_VIABLE_START_PIECE, - "Structure '" + structureKey() + "' has no start-pool entry with a positive weight," - + " resolvable piece, and resolvable object.", - "structure:no-viable-start"); + if (!viableStart) { + boolean disabledStart = false; + for (PoolEntryReference entry : poolEntries.getOrDefault(startPool, List.of())) { + if (!entry.empty() && entryIsResolvable(entry) && !pieceEnabled(entry.pieceKey())) { + disabledStart = true; + break; + } + } + if (disabledStart) { + addDiagnostic(StructureGraphDiagnostic.Code.NO_ENABLED_START_PIECE, + "Structure '" + structureKey() + + "' has no enabled planar workcell represented by a viable start entry.", + "structure:no-enabled-start:" + theme); + } else if (!theme.isEmpty()) { + addDiagnostic(StructureGraphDiagnostic.Code.UNSATISFIABLE_PIECE_RULE, + "Structure '" + structureKey() + "' theme '" + theme + + "' has no positive-chance start entry eligible at depth zero.", + "structure:no-theme-start:" + theme); + } else { + addDiagnostic(StructureGraphDiagnostic.Code.NO_VIABLE_START_PIECE, + "Structure '" + structureKey() + "' has no start-pool entry with a positive weight," + + " positive chance, resolvable piece, and resolvable object.", + "structure:no-viable-start"); + } } while (!pendingPieces.isEmpty()) { @@ -429,23 +724,37 @@ public final class StructureGraphCompiler { continue; } OrientedConnector orientedSource = new OrientedConnector(source, pieceState.rotation()); - activeConnectors.computeIfAbsent(source.id(), ignored -> new ArrayList<>()) + ReachableConnectorKey connectorKey = new ReachableConnectorKey( + pieceState.theme(), source.id(), pieceState.depth()); + activeConnectors.computeIfAbsent(connectorKey, ignored -> new ArrayList<>()) .add(orientedSource); - List candidatePools = candidatePools(normalize(source.connector().getPool()), true); + boolean includePrimary = pieceState.depth() < Math.max(1, structure.getMaxDepth()); + int candidateDepth = Math.min( + pieceState.depth() + 1, + Math.max(1, structure.getMaxDepth())); + List candidatePools = candidatePools( + normalize(source.connector().getPool()), includePrimary); for (String candidatePool : candidatePools) { graph.reachablePools().add(candidatePool); for (PoolEntryReference entry : poolEntries.getOrDefault(candidatePool, List.of())) { - if (!entryIsViable(entry)) { + if (!entryIsViable(entry, pieceState.theme(), candidateDepth)) { + continue; + } + if (entry.empty()) { + markReachableEntry(entry, pieceState.theme()); continue; } List compatible = findCompatibleConnectors( orientedSource, entry.pieceKey()); if (!compatible.isEmpty()) { - markReachableEntry(entry); + markReachableEntry(entry, pieceState.theme()); + if (!includePrimary) { + continue; + } for (ConnectorMatch target : compatible) { markReachablePieceState( new PieceReachState(entry.pieceKey(), target.connector().index(), - target.rotation()), + target.rotation(), candidateDepth, pieceState.theme()), pendingPieces); } } @@ -453,18 +762,56 @@ public final class StructureGraphCompiler { } } } - - reportUnmatchedReachableConnectors(); - reportUnreachableResources(); } - private void markReachableEntry(PoolEntryReference entry) { - if (!entryIsViable(entry)) { + private List reachabilityThemes() { + if (structure.getThemeSets() == null || structure.getThemeSets().isEmpty()) { + return List.of(""); + } + List themes = new ArrayList<>(structure.getThemeSets().size()); + for (IrisJigsawThemeSet theme : structure.getThemeSets()) { + if (theme != null && !normalize(theme.getKey()).isEmpty()) { + themes.add(normalize(theme.getKey())); + } + } + return themes.isEmpty() ? List.of("") : List.copyOf(themes); + } + + private void validatePlanarReachableObjects() { + if (structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW + || planarWorkcells.isEmpty()) { return; } + for (String pieceKey : graph.reachablePieces()) { + IrisJigsawPiece piece = graph.pieces().get(pieceKey); + String objectKey = pieceObjects.get(pieceKey); + IrisObject object = graph.objects().get(objectKey); + if (piece == null || object == null) { + continue; + } + PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell = + PlanarJigsawWorkcellResolver.workcell(planarWorkcells, piece); + IrisPosition dimensions = PlanarJigsawWorkcellResolver.canonicalDimensions(piece, object); + if (workcell.contains(dimensions)) { + continue; + } + addDiagnostic(StructureGraphDiagnostic.Code.INVALID_PLANAR_OBJECT_SIZE, + "Object '" + objectKey + "' used by reachable planar piece '" + pieceKey + + "' is " + object.getW() + "x" + object.getH() + "x" + object.getD() + + " and canonically occupies " + dimensions.getX() + "x" + dimensions.getY() + + "x" + dimensions.getZ() + "; it does not fit " + workcell.archetype() + + " workcell " + workcell.width() + "x" + workcell.height() + "x" + + workcell.depth() + ".", + "object:planar-size:" + pieceKey); + } + } + + private void markReachableEntry(PoolEntryReference entry, String theme) { reachableEntries.add(entry.id()); if (!entry.empty()) { graph.reachablePieces().add(entry.pieceKey()); + reachablePiecesByTheme.computeIfAbsent(theme, ignored -> new LinkedHashSet<>()) + .add(entry.pieceKey()); } } @@ -472,13 +819,31 @@ public final class StructureGraphCompiler { if (!graph.reachablePieces().contains(state.pieceKey())) { return; } + IrisJigsawPiece piece = graph.pieces().get(state.pieceKey()); + if (piece == null || piece.resolvedRules().isTerminal()) { + return; + } if (reachablePieceStates.add(state)) { pendingPieces.addLast(state); } } - private boolean entryIsViable(PoolEntryReference entry) { - if (entry.weight() <= 0) { + private boolean entryIsViable(PoolEntryReference entry, String theme, int depth) { + if (!entryIsResolvable(entry)) { + return false; + } + if (entry.empty()) { + return true; + } + IrisJigsawPiece piece = graph.pieces().get(entry.pieceKey()); + return piece != null + && pieceEnabled(entry.pieceKey()) + && JigsawPoolSelection.pieceEligible(piece, theme, depth, 0); + } + + private boolean entryIsResolvable(PoolEntryReference entry) { + if (entry.weight() <= 0 || !Double.isFinite(entry.chance()) || entry.chance() <= 0D + || entry.chance() > 1D) { return false; } if (entry.empty()) { @@ -491,16 +856,41 @@ public final class StructureGraphCompiler { return !objectKey.isEmpty() && graph.objects().containsKey(objectKey); } + private boolean entryIsViableForAnyTheme(PoolEntryReference entry) { + for (String theme : reachabilityThemes()) { + for (int depth = 0; depth <= Math.max(1, structure.getMaxDepth()); depth++) { + if (entryIsViable(entry, theme, depth)) { + return true; + } + } + } + return false; + } + + private boolean pieceEnabled(String pieceKey) { + if (structure.resolvedMode() != IrisJigsawMode.PLANAR_JIGSAW || planarWorkcells.isEmpty()) { + return true; + } + IrisJigsawPiece piece = graph.pieces().get(pieceKey); + return piece == null || PlanarJigsawWorkcellResolver.workcell(planarWorkcells, piece).enabled(); + } + private void reportUnmatchedReachableConnectors() { - for (List orientations : activeConnectors.values()) { + for (Map.Entry> active : activeConnectors.entrySet()) { + ReachableConnectorKey connectorKey = active.getKey(); + List orientations = active.getValue(); OrientedConnector representative = orientations.getFirst(); + boolean includePrimary = connectorKey.depth() < Math.max(1, structure.getMaxDepth()); + int candidateDepth = Math.min( + connectorKey.depth() + 1, + Math.max(1, structure.getMaxDepth())); List candidates = candidatePools( - normalize(representative.definition().getPool()), true); + normalize(representative.definition().getPool()), includePrimary); boolean hasViableEntry = false; boolean hasCompatibleEntry = false; for (String candidatePool : candidates) { for (PoolEntryReference entry : poolEntries.getOrDefault(candidatePool, List.of())) { - if (!entryIsViable(entry)) { + if (!entryIsViable(entry, connectorKey.theme(), candidateDepth)) { continue; } hasViableEntry = true; @@ -528,9 +918,10 @@ public final class StructureGraphCompiler { + representative.connector().index() + "] targets pool '" + normalize(representative.definition().getPool()) + "', but no reachable candidate exposes the requested target name with a" - + " compatible direction.", + + " compatible direction for theme '" + themeLabel(connectorKey.theme()) + "'.", "connector:no-match:" + representative.connector().pieceKey() + ":" - + representative.connector().index()); + + representative.connector().index() + ":" + connectorKey.theme() + + ":" + connectorKey.depth()); } } } @@ -545,7 +936,8 @@ public final class StructureGraphCompiler { continue; } for (PoolEntryReference entry : poolEntries.getOrDefault(poolKey, List.of())) { - if (!entry.empty() && entryIsViable(entry) && !reachableEntries.contains(entry.id())) { + if (!entry.empty() && entryIsViableForAnyTheme(entry) + && !reachableEntries.contains(entry.id())) { addDiagnostic(StructureGraphDiagnostic.Code.UNREACHABLE_PIECE, "Jigsaw pool '" + poolKey + "' pieces[" + entry.index() + "] references piece '" + entry.pieceKey() + "', but no reachable connector can attach it.", @@ -555,6 +947,10 @@ public final class StructureGraphCompiler { } } + private String themeLabel(String theme) { + return theme == null || theme.isEmpty() ? "" : theme; + } + private List findCompatibleConnectors(OrientedConnector source, String candidatePieceKey) { List compatible = new ArrayList<>(); @@ -569,6 +965,9 @@ public final class StructureGraphCompiler { } } } + compatible.sort((first, second) -> Integer.compare( + second.connector().connector().getSelectionPriority(), + first.connector().connector().getSelectionPriority())); return compatible; } @@ -579,7 +978,10 @@ public final class StructureGraphCompiler { } IrisJigsawConnector sourceConnector = source.definition(); IrisJigsawConnector candidateConnector = candidate.connector(); - if (!normalize(sourceConnector.getTargetName()).equals(normalize(candidateConnector.getName()))) { + if (!Objects.equals(sourceConnector.getChannel(), candidateConnector.getChannel())) { + return false; + } + if (!Objects.equals(sourceConnector.getTargetName(), candidateConnector.getName())) { return false; } @@ -612,157 +1014,135 @@ public final class StructureGraphCompiler { return result; } - private List sampleAssemblies() { + private void validateRuleFeasibility() { + for (String theme : reachabilityThemes()) { + int minimumTotal = 0; + Set reachable = reachablePiecesByTheme.getOrDefault(theme, Set.of()); + for (Map.Entry entry : graph.pieces().entrySet()) { + String pieceKey = entry.getKey(); + IrisJigsawPiece piece = entry.getValue(); + if (!pieceEnabled(pieceKey) || !piece.supportsTheme(theme)) { + continue; + } + IrisJigsawPieceRules rules = piece.resolvedRules(); + if (rules.getMinimumPlacements() <= 0) { + continue; + } + minimumTotal = Math.addExact(minimumTotal, rules.getMinimumPlacements()); + if (!reachable.contains(pieceKey)) { + addDiagnostic(StructureGraphDiagnostic.Code.UNSATISFIABLE_PIECE_RULE, + "Jigsaw piece '" + pieceKey + "' requires at least " + + rules.getMinimumPlacements() + " placement(s) for theme '" + + themeLabel(theme) + "' but is not reachable for that theme.", + "piece:minimum:unreachable:" + pieceKey + ":" + theme); + } + if (rules.getMinimumDepth() > Math.max(1, structure.getMaxDepth())) { + addDiagnostic(StructureGraphDiagnostic.Code.UNSATISFIABLE_PIECE_RULE, + "Jigsaw piece '" + pieceKey + "' requires placement for theme '" + + themeLabel(theme) + "' but its minimumDepth " + + rules.getMinimumDepth() + " exceeds the structure depth boundary.", + "piece:minimum:depth:" + pieceKey + ":" + theme); + } + } + if (minimumTotal > ASSEMBLY_PIECE_CAP) { + addDiagnostic(StructureGraphDiagnostic.Code.UNSATISFIABLE_PIECE_RULE, + "Structure '" + structureKey() + "' requires " + minimumTotal + + " minimum piece placements for theme '" + themeLabel(theme) + + "', exceeding the " + ASSEMBLY_PIECE_CAP + "-piece safety cap.", + "structure:minimum-total:" + theme); + } + } + } + + private void validateRequiredCaps() { + for (Map.Entry> active : activeConnectors.entrySet()) { + ReachableConnectorKey connectorKey = active.getKey(); + List orientations = active.getValue(); + OrientedConnector representative = orientations.getFirst(); + String poolKey = normalize(representative.definition().getPool()); + IrisJigsawPool pool = graph.pools().get(poolKey); + if (pool == null || !pool.requiresFallback(structure.isRequireCaps())) { + continue; + } + String fallbackKey = JigsawPoolSelection.directFallbackKey(pool); + if (fallbackKey.isEmpty() || !graph.pools().containsKey(fallbackKey)) { + addDiagnostic(StructureGraphDiagnostic.Code.MISSING_REQUIRED_FALLBACK, + "Reachable connector '" + representative.connector().id() + + "' requires a direct fallback from pool '" + poolKey + "'.", + "connector:required-fallback:" + connectorKey); + continue; + } + int candidateDepth = Math.min( + connectorKey.depth() + 1, + Math.max(1, structure.getMaxDepth())); + boolean compatibleTerminal = false; + for (PoolEntryReference entry : poolEntries.getOrDefault(fallbackKey, List.of())) { + if (entry.empty() || !entryIsViable(entry, connectorKey.theme(), candidateDepth)) { + continue; + } + IrisJigsawPiece piece = graph.pieces().get(entry.pieceKey()); + if (piece == null || !piece.resolvedRules().isTerminal()) { + continue; + } + for (OrientedConnector source : orientations) { + if (!findCompatibleConnectors(source, entry.pieceKey()).isEmpty()) { + compatibleTerminal = true; + break; + } + } + if (compatibleTerminal) { + break; + } + } + if (!compatibleTerminal) { + addDiagnostic(StructureGraphDiagnostic.Code.NO_REQUIRED_TERMINAL, + "Direct fallback pool '" + fallbackKey + "' has no compatible positive-chance" + + " terminal piece for connector '" + representative.connector().id() + + "' and theme '" + themeLabel(connectorKey.theme()) + "'.", + "connector:required-terminal:" + connectorKey); + } + } + } + + private List sampleAssemblies(StructureGraphCompilation compilation) { + if (compilation.hasErrors()) { + return List.of(); + } List samples = new ArrayList<>(ASSEMBLY_SEEDS.size()); + Map pieceKeys = new IdentityHashMap<>(); + for (Map.Entry entry : compilation.getGraph().getPieces().entrySet()) { + pieceKeys.put(entry.getValue(), entry.getKey()); + } for (long seed : ASSEMBLY_SEEDS) { - samples.add(sampleAssembly(seed)); + try { + StructureAssembler assembler = StructureAssembler.forCompilation( + compilation, new IrisPosition(0, 64, 0)); + StructureAssemblyResult result = assembler.assemble(new RNG(seed)); + List pieces = new ArrayList<>(result.pieces().size()); + for (PlacedStructurePiece placed : result.pieces()) { + String pieceKey = pieceKeys.get(placed.getPiece()); + pieces.add(pieceKey == null ? "" : pieceKey); + } + samples.add(new StructureGraphAssemblySample( + seed, + new StructureGraphAssemblySample.Outcome( + pieces, + result.status(), + result.detail()))); + } catch (RuntimeException exception) { + addDiagnostic(StructureGraphDiagnostic.Code.ASSEMBLY_RUNTIME_FAILURE, + "Runtime assembly sampling failed at seed " + seed + ": " + + exception.getClass().getSimpleName() + ": " + + failureMessage(exception) + ".", + "assembly:runtime:" + seed); + } } return samples; } - private StructureGraphAssemblySample sampleAssembly(long seed) { - SplittableRandom random = new SplittableRandom(seed); - List pieces = new ArrayList<>(); - Deque open = new ArrayDeque<>(); - String startPool = normalize(structure.getStartPool()); - PoolEntryReference start = weightedPick(viableEntries(startPool), random); - if (start == null) { - return new StructureGraphAssemblySample(seed, - new StructureGraphAssemblySample.Outcome(pieces, 1, false)); - } - if (start.empty()) { - return new StructureGraphAssemblySample(seed, - new StructureGraphAssemblySample.Outcome(pieces, 0, false, true)); - } - - IrisJigsawPiece startPiece = graph.pieces().get(start.pieceKey()); - int startRotation = startPiece != null && startPiece.isRotatable() - ? Y_ROTATIONS[random.nextInt(Y_ROTATIONS.length)] - : 0; - addSamplePiece(start.pieceKey(), -1, 0, startRotation, pieces, open); - int unresolvedConnectors = 0; - while (!open.isEmpty() && pieces.size() < ASSEMBLY_PIECE_CAP) { - SampleConnector source = open.removeFirst(); - AttachmentCandidate candidate = sampleCandidate(source, random); - if (candidate == null) { - if (!isIntentionalTermination(source)) { - unresolvedConnectors++; - } - continue; - } - if (candidate.entry().empty()) { - continue; - } - if (source.depth() < Math.max(1, structure.getMaxDepth())) { - addSamplePiece(candidate.entry().pieceKey(), candidate.match().connector().index(), - source.depth() + 1, candidate.match().rotation(), pieces, open); - } else { - pieces.add(candidate.entry().pieceKey()); - } - } - boolean capped = !open.isEmpty(); - return new StructureGraphAssemblySample(seed, - new StructureGraphAssemblySample.Outcome(pieces, unresolvedConnectors, capped)); - } - - private AttachmentCandidate sampleCandidate(SampleConnector source, SplittableRandom random) { - String primaryPool = normalize(source.connector().definition().getPool()); - boolean withinDepth = source.depth() < Math.max(1, structure.getMaxDepth()); - List candidates = candidatePools(primaryPool, withinDepth); - for (String candidatePool : candidates) { - List attachments = new ArrayList<>(); - for (PoolEntryReference entry : viableEntries(candidatePool)) { - if (entry.empty()) { - attachments.add(new AttachmentCandidate(entry, null)); - continue; - } - List matching = findCompatibleConnectors( - source.connector(), entry.pieceKey()); - if (!matching.isEmpty()) { - attachments.add(new AttachmentCandidate(entry, matching.getFirst())); - } - } - AttachmentCandidate selected = weightedPickAttachments(attachments, random); - if (selected != null) { - return selected; - } - } - return null; - } - - private boolean isIntentionalTermination(SampleConnector source) { - String poolKey = normalize(source.connector().definition().getPool()); - IrisJigsawPool pool = graph.pools().get(poolKey); - if (pool == null) { - return false; - } - boolean includePrimary = source.depth() < Math.max(1, structure.getMaxDepth()); - for (String candidate : candidatePools(poolKey, includePrimary)) { - IrisJigsawPool candidatePool = graph.pools().get(candidate); - if (candidatePool != null && candidatePool.getPieces() != null - && candidatePool.getPieces().isEmpty()) { - return true; - } - } - return !includePrimary; - } - - private void addSamplePiece(String pieceKey, int skippedConnector, int connectorDepth, int rotation, - List pieces, Deque open) { - pieces.add(pieceKey); - for (ConnectorReference connector : pieceConnectors.getOrDefault(pieceKey, List.of())) { - if (connector.index() != skippedConnector && connector.canSource()) { - open.addLast(new SampleConnector( - new OrientedConnector(connector, rotation), connectorDepth)); - } - } - } - - private List viableEntries(String poolKey) { - List result = new ArrayList<>(); - for (PoolEntryReference entry : poolEntries.getOrDefault(poolKey, List.of())) { - if (entryIsViable(entry)) { - result.add(entry); - } - } - return result; - } - - private PoolEntryReference weightedPick(List entries, SplittableRandom random) { - long totalWeight = 0L; - for (PoolEntryReference entry : entries) { - totalWeight += entry.weight(); - } - if (totalWeight <= 0L) { - return null; - } - long target = random.nextLong(totalWeight); - for (PoolEntryReference entry : entries) { - target -= entry.weight(); - if (target < 0L) { - return entry; - } - } - return entries.getLast(); - } - - private AttachmentCandidate weightedPickAttachments(List attachments, - SplittableRandom random) { - long totalWeight = 0L; - for (AttachmentCandidate attachment : attachments) { - totalWeight += attachment.entry().weight(); - } - if (totalWeight <= 0L) { - return null; - } - long target = random.nextLong(totalWeight); - for (AttachmentCandidate attachment : attachments) { - target -= attachment.entry().weight(); - if (target < 0L) { - return attachment; - } - } - return attachments.getLast(); + private String failureMessage(RuntimeException exception) { + return exception.getMessage() == null || exception.getMessage().isBlank() + ? "no failure detail" : exception.getMessage(); } private void reportPieceCapSamples(List samples) { @@ -829,7 +1209,14 @@ public final class StructureGraphCompiler { private record PoolReference(String message, StructureGraphDiagnostic.Code code) { } - private record PoolEntryReference(String poolKey, int index, String pieceKey, int weight, boolean empty) { + private record PoolEntryReference( + String poolKey, + int index, + String pieceKey, + int weight, + double chance, + boolean empty + ) { private String id() { return poolKey + "#" + index; } @@ -837,7 +1224,8 @@ public final class StructureGraphCompiler { private record ConnectorReference(String pieceKey, int index, IrisJigsawConnector connector, boolean validPosition, boolean validDirection, boolean validTop, - boolean validJoint, boolean validNames) { + boolean validJoint, boolean validNames, boolean validMetadata, + boolean validPlanarContract) { private String id() { return pieceKey + "#" + index; } @@ -847,11 +1235,21 @@ public final class StructureGraphCompiler { } private boolean canTarget() { - return connector != null && validPosition && validDirection && validTop && validJoint && validNames; + return connector != null && validPosition && validDirection && validTop && validJoint && validNames + && validMetadata && validPlanarContract; } } - private record PieceReachState(String pieceKey, int skippedConnectorIndex, int rotation) { + private record PieceReachState( + String pieceKey, + int skippedConnectorIndex, + int rotation, + int depth, + String theme + ) { + } + + private record ReachableConnectorKey(String theme, String connectorId, int depth) { } private record OrientedConnector(ConnectorReference connector, int rotation) { @@ -863,9 +1261,4 @@ public final class StructureGraphCompiler { private record ConnectorMatch(ConnectorReference connector, int rotation) { } - private record SampleConnector(OrientedConnector connector, int depth) { - } - - private record AttachmentCandidate(PoolEntryReference entry, ConnectorMatch match) { - } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphDiagnostic.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphDiagnostic.java index 104f94e01..e6fc5a636 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphDiagnostic.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphDiagnostic.java @@ -25,18 +25,33 @@ public record StructureGraphDiagnostic(Code code, String message) { INVALID_MAX_SIZE(Severity.ERROR), INVALID_POOL_ENTRY(Severity.ERROR), INVALID_WEIGHT(Severity.ERROR), + INVALID_CHANCE(Severity.ERROR), + INVALID_THEME(Severity.ERROR), + INVALID_PIECE_RULE(Severity.ERROR), + UNSATISFIABLE_PIECE_RULE(Severity.ERROR), + MISSING_REQUIRED_FALLBACK(Severity.ERROR), + NO_REQUIRED_TERMINAL(Severity.ERROR), + NON_PORTABLE_METADATA(Severity.ERROR), MISSING_PIECE(Severity.ERROR), MISSING_OBJECT(Severity.ERROR), INVALID_OBJECT_BOUNDS(Severity.ERROR), INVALID_CONNECTOR(Severity.ERROR), + INVALID_CONNECTOR_METADATA(Severity.ERROR), INVALID_CONNECTOR_POSITION(Severity.ERROR), INVALID_CONNECTOR_DIRECTION(Severity.ERROR), + INVALID_PLANAR_CELL_SIZE(Severity.ERROR), + INVALID_PLANAR_WORKCELL(Severity.ERROR), + INVALID_PLANAR_OBJECT_SIZE(Severity.ERROR), + INVALID_PLANAR_CONNECTOR(Severity.ERROR), + NON_PORTABLE_CONNECTOR(Severity.ERROR), MISSING_CONNECTOR_POOL(Severity.ERROR), FALLBACK_CYCLE(Severity.ERROR), + NO_ENABLED_START_PIECE(Severity.ERROR), NO_VIABLE_START_PIECE(Severity.WARNING), NO_COMPATIBLE_CONNECTOR(Severity.WARNING), UNREACHABLE_POOL(Severity.WARNING), UNREACHABLE_PIECE(Severity.WARNING), + ASSEMBLY_RUNTIME_FAILURE(Severity.ERROR), ASSEMBLY_PIECE_CAP_REACHED(Severity.WARNING); private final Severity severity; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphValidationException.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphValidationException.java new file mode 100644 index 000000000..fc2b25643 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureGraphValidationException.java @@ -0,0 +1,11 @@ +package art.arcane.iris.engine.framework.structure; + +public final class StructureGraphValidationException extends IllegalArgumentException { + public StructureGraphValidationException(String message) { + super(message); + } + + public StructureGraphValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompiler.java b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompiler.java index 8c2d3ae9b..6365de942 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompiler.java @@ -19,14 +19,12 @@ package art.arcane.iris.engine.framework.structure; import art.arcane.iris.core.structure.authoring.StructureResourceBundle; -import art.arcane.iris.engine.framework.PlacedStructurePiece; import art.arcane.iris.engine.framework.StructureAssembler; import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisJigsawPool; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; -import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -60,20 +58,34 @@ public final class StructureResourceBundleGraphCompiler { public static void requireViable(StructureResourceBundle bundle) { BundleGraph graph = parse(Objects.requireNonNull(bundle)); if (graph.structures().isEmpty()) { - throw new IllegalArgumentException("Structure bundle does not contain a root structure resource"); + throw new StructureGraphValidationException("Structure bundle does not contain a root structure resource"); } for (IrisStructure structure : graph.structures().values()) { StructureGraphCompilation compilation = StructureGraphCompiler.compile(structure, graph.resolver()); if (!compilation.isAssemblyViable()) { - String diagnostic = compilation.getDiagnostics().isEmpty() - ? "deterministic assembly did not complete" - : compilation.getDiagnostics().getFirst().message(); - throw new IllegalArgumentException("Structure bundle graph is not viable: " + diagnostic); + throw new StructureGraphValidationException( + "Structure bundle graph is not viable: " + viabilityFailure(compilation)); } requireGeometryViable(compilation); } } + private static String viabilityFailure(StructureGraphCompilation compilation) { + for (StructureGraphDiagnostic diagnostic : compilation.getDiagnostics()) { + if (diagnostic.severity() == StructureGraphDiagnostic.Severity.ERROR) { + return diagnostic.message(); + } + } + for (StructureGraphAssemblySample sample : compilation.getAssemblySamples()) { + StructureGraphAssemblySample.Outcome outcome = sample.outcome(); + if (!outcome.isComplete()) { + return "sampled assembly at seed " + sample.seed() + " returned " + + outcome.status() + ": " + outcome.detail(); + } + } + return "deterministic assembly did not complete"; + } + private static BundleGraph parse(StructureResourceBundle activeBundle) { Map structures = new LinkedHashMap<>(); Map pools = new LinkedHashMap<>(); @@ -103,19 +115,17 @@ public final class StructureResourceBundleGraphCompiler { try { StructureAssembler assembler = StructureAssembler.forCompilation( compilation, new IrisPosition(0, 64, 0)); - KList pieces = assembler.assemble(new RNG(seed)); - if (pieces == null) { - throw new IllegalArgumentException("Structure bundle graph '" + structureKey - + "' fails sampled runtime geometry assembly at seed " + seed); + StructureAssemblyResult result = assembler.assemble(new RNG(seed)); + if (result.status().isFailure()) { + throw new StructureGraphValidationException("Structure bundle graph '" + structureKey + + "' fails sampled runtime geometry assembly at seed " + seed + ": " + + result.status() + ": " + result.detail()); } + } catch (StructureGraphValidationException e) { + throw e; } catch (RuntimeException e) { - if (e instanceof IllegalArgumentException - && e.getMessage() != null - && e.getMessage().startsWith("Structure bundle graph '")) { - throw e; - } - throw new IllegalArgumentException("Structure bundle graph '" + structureKey - + "' fails sampled runtime geometry assembly at seed " + seed + ": " + throw new IllegalStateException("Structure bundle graph '" + structureKey + + "' encountered an unexpected sampled runtime geometry failure at seed " + seed + ": " + e.getClass().getSimpleName() + ": " + failureMessage(e), e); } } @@ -139,10 +149,10 @@ public final class StructureResourceBundleGraphCompiler { try { value = GSON.fromJson(new String(resource.content(), StandardCharsets.UTF_8), type); } catch (RuntimeException e) { - throw new IllegalArgumentException("Malformed structure resource " + resource.relativePath(), e); + throw new StructureGraphValidationException("Malformed structure resource " + resource.relativePath(), e); } if (value == null) { - throw new IllegalArgumentException("Empty structure resource " + resource.relativePath()); + throw new StructureGraphValidationException("Empty structure resource " + resource.relativePath()); } return value; } @@ -151,7 +161,7 @@ public final class StructureResourceBundleGraphCompiler { try (ByteArrayInputStream input = new ByteArrayInputStream(resource.content())) { return IrisObjectFrameReader.readBounds(input, resource.relativePath()); } catch (IOException e) { - throw new IllegalArgumentException(e.getMessage(), e); + throw new StructureGraphValidationException(e.getMessage(), e); } } 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 a0efebda2..210eb8f4f 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 @@ -20,7 +20,6 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.link.Identifier; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.framework.Engine; @@ -34,7 +33,6 @@ import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.iris.util.project.hunk.Hunk; -import art.arcane.iris.util.project.matter.TileWrapper; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.flag.MantleFlag; @@ -262,8 +260,6 @@ public interface EngineMantle extends MatterGenerator { chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> { chunk.deleteSlices(PlatformBlockState.class); chunk.deleteSlices(String.class); - chunk.deleteSlices(TileWrapper.class); - chunk.deleteSlices(Identifier.class); chunk.deleteSlices(UpdateMatter.class); chunk.deleteSlices(MatterCavern.class); chunk.deleteSlices(MatterFluidBody.class); @@ -281,8 +277,6 @@ public interface EngineMantle extends MatterGenerator { try { chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> { chunk.deleteSlices(PlatformBlockState.class); - chunk.deleteSlices(TileWrapper.class); - chunk.deleteSlices(Identifier.class); chunk.deleteSlices(UpdateMatter.class); chunk.deleteSlices(MatterCavern.class); chunk.deleteSlices(MatterFluidBody.class); 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 9fa24b959..dd76a03c0 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 @@ -96,7 +96,7 @@ public class IrisStructureComponent extends IrisMantleComponent { boolean trace = IrisSettings.get().getGeneral().isDebug(); if (trace) { IrisLogging.info("[StructTrace] ORIGIN chunk=" + cx + "," + cz + " structures=" + placement.getStructures() - + " underground=" + placement.isUnderground() + " band=" + placement.getMinHeight() + ".." + placement.getMaxHeight()); + + " anchor=" + placement.resolvedAnchor() + " band=" + placement.getMinHeight() + ".." + placement.getMaxHeight()); } String key = resolved.structureKey(); @@ -111,7 +111,7 @@ public class IrisStructureComponent extends IrisMantleComponent { IrisLogging.info("[StructTrace] ASSEMBLED chunk=" + cx + "," + cz + " key=" + key + " baseY=" + baseY + " pieces=" + pieces.size()); } - if (!placement.isUnderground()) { + if (!placement.isAnchoredUnderground()) { clearIntersectingObjectTrees(writer, resolved); } @@ -133,7 +133,7 @@ public class IrisStructureComponent extends IrisMantleComponent { Long2IntOpenHashMap foundationColumns = stilt == null ? null : new Long2IntOpenHashMap(); boolean supportNonOccluding = stilt != null && stilt.isSupportNonOccluding(); - if (placement.isUnderground()) { + if (placement.isAnchoredUnderground()) { ObjectPlaceMode undergroundMode = (mode == ObjectPlaceMode.ORGANIC_STILT || mode == ObjectPlaceMode.CEILING_HANG) ? mode : ObjectPlaceMode.STRUCTURE_PIECE; for (PlacedStructurePiece p : pieces) { @@ -164,7 +164,7 @@ public class IrisStructureComponent extends IrisMantleComponent { } requireAppliedPieces(resolved, cx, cz, failedPieces); if (failedPieces == 0 && stilt != null) { - placeFoundation(writer, foundationColumns, stilt, rng, !placement.isUnderground()); + placeFoundation(writer, foundationColumns, stilt, rng, !placement.isAnchoredUnderground()); } } 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 8ff04f53d..7afc01df8 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 @@ -275,7 +275,7 @@ public class IrisDimension extends IrisRegistrant { @Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation (ores, trees, plants, springs, geodes) for this dimension. Disabled by default: leaving this out generates exactly the terrain Iris always has. Set 'enabled' true to run the vanilla decoration feature pass over Iris terrain. Carvers are never imported.") private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl(); @ArrayType(type = String.class, min = 1) - @Desc("External datapack sources requested by this dimension. Bukkit installs them into Minecraft's server-wide datapack registry, so native registry contents and conflicts cannot be isolated per dimension. Editable auto-import is limited to resources supplied by the URLs declared by each Iris pack. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.") + @Desc("External datapack sources owned by this dimension. Bukkit registers their resources server-wide, but Iris excludes their structure sets and definitions from generation and locate state in vanilla worlds and Iris dimensions that do not declare the same source. Editable auto-import is limited to resources supplied by the URLs declared by each Iris pack. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.") private KList datapackImports = new KList<>(); @MinNumber(0) @MaxNumber(318) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawBranchFailurePolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawBranchFailurePolicy.java new file mode 100644 index 000000000..b9d08ab07 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawBranchFailurePolicy.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls how an Iris jigsaw assembly handles an optional connector branch that exhausts its primary pool and direct fallback without attaching a piece.") +public enum IrisJigsawBranchFailurePolicy { + @Desc("Fails the complete assembly when an optional connector branch cannot attach before the maximum depth.") + FAIL_ASSEMBLY, + + @Desc("Ends only the unresolved optional connector branch, matching vanilla jigsaw placement behavior.") + TERMINATE_BRANCH +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawCompatibility.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawCompatibility.java new file mode 100644 index 000000000..3d71fd251 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawCompatibility.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Declares the resource compatibility target of an Iris jigsaw structure.") +public enum IrisJigsawCompatibility { + @Desc("Allows Iris-specific connector metadata and placement behavior.") + IRIS_EXTENDED, + + @Desc("Restricts the graph to metadata that can be represented by vanilla jigsaw resources.") + VANILLA_PORTABLE +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java index 220025e07..3c2477950 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawConnector.java @@ -26,12 +26,33 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import java.util.Objects; + @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor @Desc("A jigsaw connection point on a piece. When two connectors face each other and their names match, the assembler may join the pieces.") @Data public class IrisJigsawConnector { + public static IrisPosition canonicalPlanarPosition(IrisPosition cellSize, IrisDirection direction) { + IrisPosition dimensions = Objects.requireNonNull(cellSize, "Planar jigsaw cell size"); + IrisDirection face = Objects.requireNonNull(direction, "Planar jigsaw connector direction"); + if (dimensions.getX() < 1 || dimensions.getY() < 1 || dimensions.getZ() < 1) { + throw new IllegalArgumentException("Planar jigsaw cell dimensions must be positive"); + } + int centerX = dimensions.getX() / 2; + int centerY = dimensions.getY() / 2; + int centerZ = dimensions.getZ() / 2; + return switch (face) { + case NORTH_NEGATIVE_Z -> new IrisPosition(centerX, centerY, 0); + case EAST_POSITIVE_X -> new IrisPosition(dimensions.getX() - 1, centerY, centerZ); + case SOUTH_POSITIVE_Z -> new IrisPosition(centerX, centerY, dimensions.getZ() - 1); + case WEST_NEGATIVE_X -> new IrisPosition(0, centerY, centerZ); + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> throw new IllegalArgumentException( + "Planar jigsaw connector direction must be horizontal"); + }; + } + @Required @Desc("The position of this connector relative to the piece object's origin (0,0,0 is the lowest-corner of the object).") private IrisPosition position = new IrisPosition(); @@ -55,6 +76,19 @@ public class IrisJigsawConnector { @Desc("The name this connector wants to connect to on the other piece.") private String targetName = ""; + @Desc("Optional Iris-only connection channel. Matching is exact, case-sensitive, and whitespace-sensitive; empty channels match only other empty channels.") + private String channel = ""; + @Desc("How the connecting piece may be rotated relative to this connector.") private JigsawJoint joint = JigsawJoint.ROLLABLE; + + @Required + @Desc("Block state placed where this jigsaw marker was authored after assembly. Used when exporting or capturing Mojang-style jigsaw templates.") + private String finalState = "minecraft:air"; + + @Desc("Signed priority for selecting this connector within its piece. Higher values are processed before lower values; equal priorities preserve authored order.") + private int selectionPriority = 0; + + @Desc("Signed priority assigned to a child piece attached through this connector. Higher-priority child pieces expand first; equal priorities preserve attachment order.") + private int placementPriority = 0; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawMode.java new file mode 100644 index 000000000..0a4182277 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawMode.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Controls the topology used by an Iris jigsaw structure.") +public enum IrisJigsawMode { + @Desc("A cell-aligned planar jigsaw intended for villages, paths, and other two-dimensional layouts.") + PLANAR_JIGSAW, + + @Desc("A freeform spatial connector graph intended for multi-level and three-dimensional structures.") + SPATIAL_JIGSAW +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java index 2a59611c7..a8e943bb3 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java @@ -37,6 +37,9 @@ import lombok.experimental.Accessors; @Data @EqualsAndHashCode(callSuper = false) public class IrisJigsawPiece extends IrisRegistrant { + @Desc("Optional author-facing name shown by Jigsaw Studio. The resource key is shown when this is blank.") + private String displayName = ""; + @Required @RegistryListResource(IrisObject.class) @Desc("The object (schematic) that makes up this piece.") @@ -49,6 +52,35 @@ public class IrisJigsawPiece extends IrisRegistrant { @Desc("If true, this piece may be rotated around the Y axis when placed so the assembler can match its connectors. Disable for pieces that must keep a fixed orientation.") private boolean rotatable = true; + @Desc("If true, this piece's object bounds block overlapping jigsaw pieces. Disable for connector-only scaffolds whose stored volume is intentionally shared with physical pieces.") + private boolean collidable = true; + + @ArrayType(type = String.class, min = 1) + @Desc("Theme keys that may select this piece. An empty list makes the piece available to every selected theme.") + private KList themes = new KList<>(); + + @Desc("Deterministic depth, placement-count, and terminal-role rules for this piece.") + private IrisJigsawPieceRules rules = new IrisJigsawPieceRules(); + + public boolean supportsTheme(String selectedTheme) { + if (themes == null || themes.isEmpty()) { + return true; + } + if (selectedTheme == null || selectedTheme.isBlank()) { + return false; + } + for (String theme : themes) { + if (selectedTheme.equals(theme)) { + return true; + } + } + return false; + } + + public IrisJigsawPieceRules resolvedRules() { + return rules == null ? new IrisJigsawPieceRules() : rules; + } + @Override public String getFolderName() { return "jigsaw-pieces"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java index 20419ecee..46bd3afca 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceEntry.java @@ -19,6 +19,7 @@ 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 art.arcane.iris.engine.object.annotations.RegistryListResource; import lombok.AllArgsConstructor; @@ -33,17 +34,33 @@ import lombok.experimental.Accessors; @Data public class IrisJigsawPieceEntry { @RegistryListResource(IrisJigsawPiece.class) - @Desc("The jigsaw piece this entry refers to. Leave empty only when empty is true.") - private String piece = ""; + @Desc("The jigsaw piece this entry refers to. Omit when empty is true.") + private String piece; @MinNumber(1) @Desc("The relative weight of this piece within its pool. Higher weights are chosen more often.") private int weight = 1; + @MinNumber(0) + @MaxNumber(1) + @Desc("The independent eligibility chance applied to this exact pool membership before weighted selection. Zero never passes and one always passes.") + private double chance = 1D; + @Desc("When true, choosing this entry successfully terminates the current branch without placing a piece.") private boolean empty = false; public IrisJigsawPieceEntry(String piece, int weight) { - this(piece, weight, false); + this(piece, weight, 1D, false); + } + + public boolean requiresChanceRoll() { + return chance > 0D && chance < 1D; + } + + public boolean passesChance(double roll) { + if (!Double.isFinite(roll) || roll < 0D || roll >= 1D) { + throw new IllegalArgumentException("Jigsaw chance roll must be finite and within 0 inclusive and 1 exclusive"); + } + return chance >= 1D || chance > 0D && roll < chance; } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceRules.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceRules.java new file mode 100644 index 000000000..0072dc4f2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPieceRules.java @@ -0,0 +1,52 @@ +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.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("Deterministic eligibility, placement-count, and terminal-role rules for one jigsaw piece.") +@Data +public class IrisJigsawPieceRules { + @MinNumber(0) + @MaxNumber(30) + @Desc("The shallowest assembly depth at which this piece may be placed. The start piece is depth zero.") + private int minimumDepth = 0; + + @MinNumber(0) + @MaxNumber(30) + @Desc("The deepest assembly depth at which this piece may be placed.") + private int maximumDepth = 30; + + @MinNumber(0) + @MaxNumber(512) + @Desc("The minimum number of placements required for this piece in a completed assembly.") + private int minimumPlacements = 0; + + @MinNumber(0) + @MaxNumber(512) + @Desc("The maximum number of placements allowed for this piece. Zero means unbounded within the structure safety cap.") + private int maximumPlacements = 0; + + @Desc("Whether this piece is a physical terminal cap. A terminal piece may consume a matching connector but must not continue expansion.") + private boolean terminal = false; + + public boolean allowsDepth(int depth) { + return depth >= minimumDepth && depth <= maximumDepth; + } + + public boolean allowsPlacement(int existingPlacements) { + return existingPlacements >= 0 + && (maximumPlacements == 0 || existingPlacements < maximumPlacements); + } + + public boolean requiresMorePlacements(int existingPlacements) { + return existingPlacements >= 0 && existingPlacements < minimumPlacements; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java index 437437fb8..849e3655d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java @@ -44,6 +44,13 @@ public class IrisJigsawPool extends IrisRegistrant { @Desc("The direct fallback pool tried after this pool, or used alone at maximum depth. The fallback pool's own fallback is not part of the same selection. Leave empty to stop expanding at maximum depth.") private String fallback = ""; + @Desc("Whether failure to place from this pool must be resolved through its direct fallback even when the structure does not require every branch to be capped.") + private boolean mandatoryFallback = false; + + public boolean requiresFallback(boolean structureRequiresCaps) { + return structureRequiresCaps || mandatoryFallback; + } + @Override public String getFolderName() { return "jigsaw-pools"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawThemeSet.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawThemeSet.java new file mode 100644 index 000000000..15ed96804 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawThemeSet.java @@ -0,0 +1,24 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("A weighted structure-wide jigsaw theme. One declared theme is selected for an assembly, and pieces may opt into one or more themes.") +@Data +public class IrisJigsawThemeSet { + @Required + @Desc("The exact theme key referenced by jigsaw pieces.") + private String key = ""; + + @MinNumber(1) + @Desc("The relative weight used when selecting one theme for an assembly.") + private int weight = 1; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcell.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcell.java new file mode 100644 index 000000000..703b5cd4c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcell.java @@ -0,0 +1,40 @@ +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.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Data +@Desc("Persistent authoring bounds and runtime availability for one planar jigsaw workcell.") +public class IrisJigsawWorkcell { + @Desc("Optional author-facing name shown by Jigsaw Studio. The canonical archetype name is shown when this is blank.") + private String displayName = ""; + + @Desc("The orientation-independent planar connector shape configured by this workcell.") + private IrisJigsawWorkcellArchetype archetype = IrisJigsawWorkcellArchetype.BLANK; + + @MinNumber(3) + @MaxNumber(128) + @Desc("The maximum canonical variant width that this workcell can contain, in blocks.") + private int width = 16; + + @MinNumber(1) + @MaxNumber(192) + @Desc("The maximum variant height that this workcell can contain, in blocks.") + private int height = 16; + + @MinNumber(3) + @MaxNumber(128) + @Desc("The maximum canonical variant depth that this workcell can contain, in blocks.") + private int depth = 16; + + @Desc("Whether pieces with this planar connector shape participate in assembly and vanilla export.") + private boolean enabled = true; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcellArchetype.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcellArchetype.java new file mode 100644 index 000000000..963503896 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawWorkcellArchetype.java @@ -0,0 +1,77 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("The orientation-independent connector shape represented by a planar jigsaw workcell.") +public enum IrisJigsawWorkcellArchetype { + BLANK(0), + END(1), + STRAIGHT(5), + CORNER(3), + TEE(11), + CROSS(15); + + private final int canonicalMask; + + IrisJigsawWorkcellArchetype(int canonicalMask) { + this.canonicalMask = canonicalMask; + } + + public static IrisJigsawWorkcellArchetype fromPiece(IrisJigsawPiece piece) { + if (piece == null || piece.getConnectors() == null) { + return BLANK; + } + return fromMask(horizontalMask(piece)); + } + + public static IrisJigsawWorkcellArchetype fromMask(int mask) { + if (mask < 0 || mask > 15) { + throw new IllegalArgumentException("Planar jigsaw topology mask must be between 0 and 15"); + } + int connections = Integer.bitCount(mask); + return switch (connections) { + case 0 -> BLANK; + case 1 -> END; + case 2 -> mask == 5 || mask == 10 ? STRAIGHT : CORNER; + case 3 -> TEE; + case 4 -> CROSS; + default -> throw new IllegalStateException("Unreachable planar jigsaw topology"); + }; + } + + public static int horizontalMask(IrisJigsawPiece piece) { + if (piece == null || piece.getConnectors() == null) { + return 0; + } + int mask = 0; + for (IrisJigsawConnector connector : piece.getConnectors()) { + if (connector == null || connector.getDirection() == null) { + continue; + } + mask |= switch (connector.getDirection()) { + case NORTH_NEGATIVE_Z -> 1; + case EAST_POSITIVE_X -> 2; + case SOUTH_POSITIVE_Z -> 4; + case WEST_NEGATIVE_X -> 8; + case UP_POSITIVE_Y, DOWN_NEGATIVE_Y -> 0; + }; + } + return mask; + } + + public int sourceToCanonicalQuarterTurns(IrisJigsawPiece piece) { + int sourceMask = horizontalMask(piece); + if (fromMask(sourceMask) != this) { + throw new IllegalArgumentException("Jigsaw piece does not match workcell archetype " + name()); + } + for (int quarterTurns = 0; quarterTurns < 4; quarterTurns++) { + int rotated = quarterTurns == 0 + ? sourceMask + : ((sourceMask << quarterTurns) | (sourceMask >>> (4 - quarterTurns))) & 15; + if (rotated == canonicalMask) { + return quarterTurns; + } + } + throw new IllegalStateException("No canonical rotation exists for planar jigsaw piece"); + } +} 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 eef62362e..51533a6f4 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 @@ -62,17 +62,17 @@ final class IrisObjectPlacementRunner { int place(int x, int yv, int z, IObjectPlacer oplacer, IrisObjectPlacement config, RNG rng, BiConsumer listener, CarveResult c, IrisData rdata) { IObjectPlacer placer = config.getHeightmap() != null ? new HeightmapObjectPlacer(rng, x, yv, z, config, oplacer) : oplacer; - if (rdata != null) { - // Slope condition - if (!config.getSlopeCondition().isDefault() && - !config.getSlopeCondition().isValid(rdata.getEngine().getComplex().getSlopeStream().get(x, z)) && !config.isForcePlace()) { + boolean evaluateSlopeCondition = !config.isForcePlace() && !config.getSlopeCondition().isDefault(); + if (rdata != null && (evaluateSlopeCondition || config.isRotateTowardsSlope())) { + Engine placementEngine = requireSlopeEngine(placer); + if (evaluateSlopeCondition && + !config.getSlopeCondition().isValid(placementEngine.getComplex().getSlopeStream().get(x, z))) { return -1; } - // Rotation calculation - int slopeRotationY = 0; - ProceduralStream heightStream = rdata.getEngine().getComplex().getHeightStream(); if (config.isRotateTowardsSlope()) { + int slopeRotationY = 0; + ProceduralStream heightStream = placementEngine.getComplex().getHeightStream(); // Whichever side of the rectangle that bounds the object is lowest is the 'direction' of the slope (simply said). double hNorth = heightStream.get(x, z + ((float) self.d) / 2); double hEast = heightStream.get(x + ((float) self.w) / 2, z); @@ -848,6 +848,14 @@ final class IrisObjectPlacementRunner { return !wouldReplace && (rawStructurePiece || !air); } + private static Engine requireSlopeEngine(IObjectPlacer placer) { + Engine engine = placer.getEngine(); + if (engine == null) { + throw new IllegalStateException("Object placement slope settings require an active Iris engine for the target world."); + } + return engine; + } + /** * Objects may never intersect a native structure piece. The rect query is the fast path: no native structures * near this placement means no per block work at all. Only when the placement envelope meets a piece does the diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java index 747bac401..cc7f25e8a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java @@ -54,6 +54,32 @@ public class IrisStructure extends IrisRegistrant { @Desc("The maximum radius of the structure in chunks from its start piece. Acts as a hard bound on assembly size and collision checks.") private int maxSizeChunks = 8; + @Desc("Assembly topology. SPATIAL_JIGSAW preserves the existing freeform connector graph; PLANAR_JIGSAW declares a cell-aligned planar graph.") + private IrisJigsawMode mode = IrisJigsawMode.SPATIAL_JIGSAW; + + @Desc("Target compatibility contract for this structure. IRIS_EXTENDED permits Iris-only metadata; VANILLA_PORTABLE requires a graph that can be represented by vanilla jigsaw resources.") + private IrisJigsawCompatibility compatibility = IrisJigsawCompatibility.IRIS_EXTENDED; + + @Desc("How unresolved optional connector branches affect assembly. FAIL_ASSEMBLY preserves strict Iris behavior; TERMINATE_BRANCH matches vanilla jigsaw branch termination.") + private IrisJigsawBranchFailurePolicy branchFailurePolicy = IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY; + + @Desc("Default Studio cell dimensions. Legacy planar structures use this value for every workcell when planarWorkcells is empty.") + private IrisPosition cellSize = new IrisPosition(16, 16, 16); + + @Desc("Optional author-facing name for the single spatial Jigsaw Studio workcell. Spatial is shown when this is blank.") + private String spatialWorkcellDisplayName = ""; + + @ArrayType(type = IrisJigsawWorkcell.class) + @Desc("The six persistent planar Studio workcells. Leave empty only for the legacy cellSize fallback.") + private KList planarWorkcells = new KList<>(); + + @ArrayType(type = IrisJigsawThemeSet.class, min = 1) + @Desc("Weighted themes available to an assembly. Leave empty for one implicit unthemed assembly path.") + private KList themeSets = new KList<>(); + + @Desc("Whether every open connector must close with a real terminal piece through a mandatory direct fallback.") + private boolean requireCaps = false; + @Desc("The place mode used when stamping each piece object into the world.") private ObjectPlaceMode placeMode = ObjectPlaceMode.STRUCTURE_PIECE; @@ -69,6 +95,20 @@ public class IrisStructure extends IrisRegistrant { @Desc("If this structure was generated by importing a vanilla or datapack structure, this is that structure's key (provenance). Empty for hand-authored structures.") private String vanillaSource = ""; + public IrisJigsawMode resolvedMode() { + return mode == null ? IrisJigsawMode.SPATIAL_JIGSAW : mode; + } + + public IrisJigsawCompatibility resolvedCompatibility() { + return compatibility == null ? IrisJigsawCompatibility.IRIS_EXTENDED : compatibility; + } + + public IrisJigsawBranchFailurePolicy resolvedBranchFailurePolicy() { + return branchFailurePolicy == null + ? IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY + : branchFailurePolicy; + } + public IrisObjectPlacement createLootPlacement(String objectKey) { IrisObjectPlacement placement = new IrisObjectPlacement(); placement.getPlace().add(objectKey); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureAnchorMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureAnchorMode.java new file mode 100644 index 000000000..3956b5b34 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureAnchorMode.java @@ -0,0 +1,34 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; + +@Desc("Selects the vertical environment used to anchor an Iris structure placement.") +public enum IrisStructureAnchorMode { + @Desc("Preserve the historical underground boolean behavior.") + LEGACY, + + @Desc("Anchor to the terrain surface and use minHeight/maxHeight as a surface gate.") + SURFACE, + + @Desc("Choose a deterministic Y inside the minHeight/maxHeight band.") + HEIGHT_BAND, + + @Desc("Anchor in carved space with solid support below.") + CAVE_FLOOR, + + @Desc("Anchor in carved space with solid support above.") + CAVE_CEILING, + + @Desc("Anchor in carved space without nearby floor or ceiling support.") + CAVE_CENTER, + + @Desc("Anchor at any carved-space position.") + CAVE_ANY; + + public boolean isCave() { + return switch (this) { + case CAVE_FLOOR, CAVE_CEILING, CAVE_CENTER, CAVE_ANY -> true; + default -> false; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java index 829f25d6d..ce246660d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java @@ -82,22 +82,44 @@ public class IrisStructurePlacement { @Desc("CONCENTRIC_RINGS only: how many placements share each ring before moving outward.") private int ringSpread = 3; - @Desc("When underground=false this is the minimum surface Y the placement is allowed at (a gate); when underground=true this is the lower bound of the Y band the structure is placed within.") + @Desc("Minimum world Y. SURFACE uses it as a terrain-height gate; HEIGHT_BAND and cave anchors use it as the lower candidate bound. LEGACY follows underground.") private int minHeight = -2032; - @Desc("When underground=false this is the maximum surface Y the placement is allowed at (a gate); when underground=true this is the upper bound of the Y band the structure is placed within.") + @Desc("Maximum world Y. SURFACE uses it as a terrain-height gate; HEIGHT_BAND and cave anchors use it as the upper candidate bound. LEGACY follows underground.") private int maxHeight = 2032; - @Desc("If true, the structure starts at a deterministic random world Y inside [minHeight, maxHeight]. Terrain integration is then controlled independently by terrain.") + @Desc("LEGACY anchor only: if true, choose a deterministic world Y inside [minHeight, maxHeight]; otherwise gate the terrain surface. Explicit anchor modes override this field.") private boolean underground = false; + @Desc("Vertical anchor policy. LEGACY preserves the underground boolean. Cave modes search existing carved-space data and implicitly behave as underground placements.") + private IrisStructureAnchorMode anchor = IrisStructureAnchorMode.LEGACY; + + @ArrayType(type = String.class, min = 1) + @Desc("Optional cave-biome allowlist for cave anchor modes. Empty accepts the cave biome resolved at the selected anchor.") + private KList caveBiomes = new KList<>(); + + @MinNumber(1) + @MaxNumber(64) + @Desc("Cave anchor modes only: deterministic X/Z columns tested inside the start chunk before the placement is skipped.") + private int caveAnchorAttempts = 8; + + @MinNumber(1) + @MaxNumber(16) + @Desc("Cave anchor modes only: vertical scan step in blocks. One is exact; larger values trade precision for speed.") + private int caveAnchorScanStep = 1; + + @MinNumber(1) + @MaxNumber(64) + @Desc("Cave anchor modes only: minimum contiguous carved-space height required at the selected anchor.") + private int caveMinimumClearance = 3; + @Desc("Terrain integration for this placement. The editable structures backend supports SOURCE, PRESERVE, BORE, and FORCE_CARVE. The nativeStructures backend supports every terrain mode.") private IrisStructureTerrain terrain = new IrisStructureTerrain(); @Desc("Optional foundation columns placed beneath the assembled structure's occupied bottom cells. Columns pass through air and fluids until they reach solid ground, up to maxDepth.") private IrisStructureStiltSettings stilt = null; - @Desc("If false, this placement is skipped underwater.") + @Desc("If false, the placement is skipped when its selected surface or cave column is submerged.") private boolean underwater = false; public IrisStructureTerrain resolvedTerrain() { @@ -111,4 +133,16 @@ public class IrisStructurePlacement { public boolean hasNativeStructures() { return nativeStructures != null && !nativeStructures.isEmpty(); } + + public IrisStructureAnchorMode resolvedAnchor() { + if (anchor == null || anchor == IrisStructureAnchorMode.LEGACY) { + return underground ? IrisStructureAnchorMode.HEIGHT_BAND : IrisStructureAnchorMode.SURFACE; + } + return anchor; + } + + public boolean isAnchoredUnderground() { + IrisStructureAnchorMode activeAnchor = resolvedAnchor(); + return activeAnchor == IrisStructureAnchorMode.HEIGHT_BAND || activeAnchor.isCave(); + } } 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 34c9b4c96..db2462601 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,8 @@ 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.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.IrisEngine; @@ -41,12 +43,14 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisDimensionContractException; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.object.StudioMode; import art.arcane.iris.engine.platform.studio.StudioGenerator; +import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; @@ -83,10 +87,13 @@ import java.util.Date; import java.util.List; import java.util.Objects; import java.util.Random; +import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; @EqualsAndHashCode(callSuper = true) @Data @@ -94,7 +101,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private static final int LOAD_LOCKS = Runtime.getRuntime().availableProcessors() * 4; private static final long HOTLOAD_LOOP_DELAY_MS = 250L; private static final long HOTLOAD_MAINTENANCE_DELAY_MS = 4000L; - private final Semaphore loadLock; + private final GenerationStageGate loadLock; private final IrisWorld world; private final File dataLocation; private final String dimensionKey; @@ -104,6 +111,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private final ChronoLatch hotloadChecker; private final AtomicBoolean setup; private final boolean studio; + private final AtomicBoolean studioEntryBootstrapActive; private final AtomicInteger a = new AtomicInteger(0); private volatile long lastChunkGenTime = 0L; private final CompletableFuture spawnChunks = new CompletableFuture<>(); @@ -112,6 +120,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private volatile Engine engine; private volatile Looper hotloader; private volatile StudioMode lastMode; + private volatile UUID lastJigsawStudioRequestId; + private volatile boolean jigsawStudioActive; private volatile DummyBiomeProvider dummyBiomeProvider; private volatile Throwable initializationFailure; private volatile IrisDimension validatedDimension; @@ -125,10 +135,11 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun studioGenerator = null; dummyBiomeProvider = new DummyBiomeProvider(); populators = new KList<>(); - loadLock = new Semaphore(LOAD_LOCKS); + loadLock = new GenerationStageGate(LOAD_LOCKS, () -> closing); this.world = world; this.hotloadChecker = new ChronoLatch(1000, false); this.studio = studio; + this.studioEntryBootstrapActive = new AtomicBoolean(studio); this.dataLocation = dataLocation; this.dimensionKey = dimensionKey; this.folder = new ReactiveFolder( @@ -139,6 +150,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun new KList<>() ); this.initializationFailure = null; + this.jigsawStudioActive = false; this.validatedDimension = null; this.validatedWorldContract = null; this.closing = false; @@ -256,11 +268,39 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private void setupEngine() { lastMode = StudioMode.NORMAL; - engine = new IrisEngine(getTarget(), studio); + lastJigsawStudioRequestId = null; + EngineTarget engineTarget = getTarget(); + String packKey = engineTarget.getDimension().getLoadKey(); + JigsawStudioActivation.Request request = studio + ? JigsawStudioActivation.getGeneratorRequest(packKey) + : null; + JigsawStudioSession session = request == null + ? null + : JigsawStudioActivation.getGeneratorSession(packKey); + jigsawStudioActive = request != null + && session != null + && session.sessionId().equals(request.requestId()); + IrisEngine createdEngine = new IrisEngine( + engineTarget, + selectInitializationMode(studio, jigsawStudioActive)); + createdEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( + jigsawStudioActive, + studioEntryBootstrapActive.get(), + initializationFailure != null)); + engine = createdEngine; populators.clear(); targetCache.reset(); } + static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive) { + if (!studio) { + return IrisEngine.InitializationMode.RUNTIME; + } + return jigsawStudioActive + ? IrisEngine.InitializationMode.JIGSAW_STUDIO + : IrisEngine.InitializationMode.STUDIO; + } + @NotNull @Override public EngineTarget getTarget() { @@ -316,7 +356,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun getWorld().setRawWorldSeed(world.getSeed()); setupEngine(); setup.set(true); - this.hotloader = studio ? new Looper() { + this.hotloader = shouldRunStudioHotload(studio, closing, jigsawStudioActive) ? new Looper() { @Override protected long loop() { if (shouldThrottleHotload()) { @@ -331,7 +371,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } : null; - if (studio) { + if (hotloader != null) { hotloader.setPriority(Thread.MIN_PRIORITY); hotloader.start(); hotloader.setName(getTarget().getWorld().name() + " Hotloader"); @@ -437,20 +477,121 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return studio; } + public void endStudioEntryBootstrap() { + Engine activeEngine = engine; + if (activeEngine instanceof IrisEngine irisEngine) { + irisEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures( + jigsawStudioActive, + false, + initializationFailure != null)); + } + studioEntryBootstrapActive.set(false); + } + + public boolean isStudioEntryBootstrapActive() { + return studioEntryBootstrapActive.get(); + } + + public boolean isJigsawStudioActive() { + return jigsawStudioActive; + } + @Override public void hotload() { - if (!isStudio() || closing) { + if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { return; } withExclusiveControl(() -> getEngine().hotload()); } + static boolean shouldRunStudioHotload(boolean studio, boolean closing, boolean jigsawStudioActive) { + return studio && !closing && !jigsawStudioActive; + } + + public GenerationStagePermit acquireGenerationStage(String operation) { + return loadLock.acquireStage(operation); + } + + public GenerationStagePermit acquireNoiseGenerationStage( + Engine expectedEngine, + int chunkX, + int chunkZ, + String operation + ) { + Engine activeEngine = Objects.requireNonNull(expectedEngine, "Noise generation engine"); + return acquirePreparedGenerationStage( + loadLock, + operation, + () -> resolveStudioGeneratorForNoise(activeEngine), + activeEngine, + chunkX, + chunkZ); + } + + static GenerationStagePermit acquirePreparedGenerationStage( + GenerationStageGate gate, + String operation, + Supplier resolver, + Engine engine, + int chunkX, + int chunkZ + ) { + GenerationStageGate activeGate = Objects.requireNonNull(gate, "Generation stage gate"); + Supplier activeResolver = Objects.requireNonNull(resolver, "Studio generator resolver"); + Engine activeEngine = Objects.requireNonNull(engine, "Prepared generation engine"); + while (true) { + GenerationStagePermit stage = activeGate.acquireStage(operation); + StudioGenerator selected; + boolean retained = false; + try { + selected = activeResolver.get(); + if (selected == null || !selected.requiresPreSessionPreparation()) { + if (activeResolver.get() == selected) { + retained = true; + return stage; + } + continue; + } + } finally { + if (!retained) { + stage.close(); + } + } + + GenerationStageExclusivePermit exclusive = activeGate.acquireExclusiveStage(operation); + try { + StudioGenerator active = activeResolver.get(); + if (active != selected) { + continue; + } + active.prepareChunkBeforeSession(activeEngine, chunkX, chunkZ); + return exclusive.downgradeToStage(); + } catch (WrongEngineBroException e) { + throw new IllegalStateException("Iris generation stage " + operation + + " could not prepare its Studio generator.", e); + } finally { + exclusive.close(); + } + } + } + + private StudioGenerator resolveStudioGeneratorForNoise(Engine expectedEngine) { + if (engine != expectedEngine) { + throw new IllegalStateException("Iris noise generation belongs to a replaced engine runtime."); + } + computeStudioGenerator(); + if (engine != expectedEngine) { + throw new IllegalStateException("Iris noise generation changed engine runtime during Studio resolution."); + } + return studioGenerator; + } + public void withExclusiveControl(Runnable r) { J.a(() -> { boolean acquired = false; try { - loadLock.acquire(LOAD_LOCKS); + loadLock.acquireExclusive(); acquired = true; r.run(); } catch (InterruptedException e) { @@ -462,7 +603,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun e.printStackTrace(); } finally { if (acquired) { - loadLock.release(LOAD_LOCKS); + loadLock.releaseExclusive(); } } }); @@ -470,27 +611,42 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun public CompletableFuture withExclusiveControlFuture(Runnable r) { CompletableFuture future = new CompletableFuture<>(); - J.a(() -> { - boolean acquired = false; - try { - loadLock.acquire(LOAD_LOCKS); - acquired = true; - r.run(); - future.complete(null); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - future.completeExceptionally(e); - } catch (Throwable e) { - future.completeExceptionally(e); - } finally { - if (acquired) { - loadLock.release(LOAD_LOCKS); - } - } - }); + J.a(() -> completeExclusiveControlFuture(loadLock, r, future)); return future; } + static void completeExclusiveControlFuture( + GenerationStageGate gate, + Runnable operation, + CompletableFuture future + ) { + GenerationStageGate activeGate = Objects.requireNonNull(gate, "Exclusive control gate"); + Runnable activeOperation = Objects.requireNonNull(operation, "Exclusive control operation"); + CompletableFuture outward = Objects.requireNonNull(future, "Exclusive control future"); + boolean acquired = false; + Throwable failure = null; + try { + activeGate.acquireExclusive(); + acquired = true; + activeOperation.run(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + failure = e; + } catch (Throwable e) { + failure = e; + } finally { + if (acquired) { + activeGate.releaseExclusive(); + } + } + + if (failure == null) { + outward.complete(null); + } else { + outward.completeExceptionally(failure); + } + } + public void touch(World world) { getEngine(world); } @@ -612,6 +768,25 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private void computeStudioGenerator() { + String packKey = getEngine().getDimension().getLoadKey(); + JigsawStudioActivation.Request jigsawRequest = studio + ? JigsawStudioActivation.getGeneratorRequest(packKey) + : null; + if (jigsawRequest != null) { + JigsawStudioSession session = JigsawStudioActivation.getGeneratorSession(packKey); + if (session != null && session.sessionId().equals(jigsawRequest.requestId())) { + if (!jigsawRequest.requestId().equals(lastJigsawStudioRequestId)) { + setStudioGenerator(new JigsawStudioGenerator(getEngine(), jigsawRequest, session)); + lastJigsawStudioRequestId = jigsawRequest.requestId(); + lastMode = null; + } + return; + } + } + if (lastJigsawStudioRequestId != null) { + lastJigsawStudioRequestId = null; + lastMode = null; + } StudioMode desired = getEngine().getDimension().getStudioMode(); if (studio && art.arcane.iris.core.runtime.ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) { desired = StudioMode.OBJECT_BUFFET; @@ -645,10 +820,18 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public boolean shouldGenerateStructures() { - if (initializationFailure != null) { - return false; - } - return true; + return shouldGenerateNativeStructures( + jigsawStudioActive, + studioEntryBootstrapActive.get(), + initializationFailure != null); + } + + static boolean shouldGenerateNativeStructures( + boolean jigsawStudioActive, + boolean studioEntryBootstrapActive, + boolean initializationFailed + ) { + return !jigsawStudioActive && !studioEntryBootstrapActive && !initializationFailed; } @Override @@ -671,4 +854,131 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun public BiomeProvider getDefaultBiomeProvider(@NotNull WorldInfo worldInfo) { return dummyBiomeProvider; } + + static final class GenerationStageGate { + private final int permitCount; + private final @Nullable Semaphore permits; + private final BooleanSupplier closing; + + GenerationStageGate(int permitCount, BooleanSupplier closing) { + if (permitCount <= 0) { + throw new IllegalArgumentException("Generation stage permit count must be positive."); + } + this.permitCount = permitCount; + this.permits = new Semaphore(permitCount, true); + this.closing = Objects.requireNonNull(closing, "Generation stage close state"); + } + + GenerationStagePermit acquireStage(String operation) { + String activeOperation = Objects.requireNonNull(operation, "Generation stage operation"); + if (closing.getAsBoolean()) { + throw rejected(activeOperation); + } + try { + permits.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Iris generation stage " + activeOperation + + " was interrupted while waiting for engine access.", e); + } + if (closing.getAsBoolean()) { + permits.release(); + throw rejected(activeOperation); + } + return new GenerationStagePermit(permits); + } + + GenerationStageExclusivePermit acquireExclusiveStage(String operation) { + String activeOperation = Objects.requireNonNull(operation, "Generation stage operation"); + if (closing.getAsBoolean()) { + throw rejected(activeOperation); + } + try { + permits.acquire(permitCount); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Iris generation stage " + activeOperation + + " was interrupted while waiting for exclusive engine access.", e); + } + if (closing.getAsBoolean()) { + permits.release(permitCount); + throw rejected(activeOperation); + } + return new GenerationStageExclusivePermit(permits, permitCount); + } + + void acquireExclusive() throws InterruptedException { + permits.acquire(permitCount); + } + + void releaseExclusive() { + permits.release(permitCount); + } + + int queueLength() { + return permits.getQueueLength(); + } + + int availablePermits() { + return permits.availablePermits(); + } + + private IllegalStateException rejected(String operation) { + return new IllegalStateException("Iris generation stage " + operation + + " was rejected while the generator is closing."); + } + } + + static final class GenerationStageExclusivePermit implements AutoCloseable { + private final Semaphore permits; + private final int permitCount; + private final AtomicBoolean released; + + private GenerationStageExclusivePermit(Semaphore permits, int permitCount) { + this.permits = permits; + this.permitCount = permitCount; + this.released = new AtomicBoolean(false); + } + + GenerationStagePermit downgradeToStage() { + if (!released.compareAndSet(false, true)) { + throw new IllegalStateException("Exclusive Iris generation stage was already released."); + } + GenerationStagePermit stage = new GenerationStagePermit(permits); + if (permitCount > 1) { + permits.release(permitCount - 1); + } + return stage; + } + + @Override + public void close() { + if (released.compareAndSet(false, true)) { + permits.release(permitCount); + } + } + } + + public static final class GenerationStagePermit implements AutoCloseable { + private static final GenerationStagePermit NOOP = new GenerationStagePermit(null); + + private final Semaphore permits; + private final AtomicBoolean released; + + private GenerationStagePermit(@Nullable Semaphore permits) { + this.permits = permits; + this.released = new AtomicBoolean(permits == null); + } + + public static GenerationStagePermit noop() { + return NOOP; + } + + @Override + public void close() { + if (permits != null && released.compareAndSet(false, true)) { + permits.release(); + } + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index 19bc64dd3..dd3eba0ad 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -78,9 +78,9 @@ import java.lang.reflect.Method; import java.util.UUID; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicLong; import java.util.function.BooleanSupplier; import java.util.function.Consumer; @@ -127,31 +127,8 @@ public final class EngineBukkitOps { MantleChunk chunk = mantle.getChunk(c).use(); try { - Runnable tileTask = () -> { - chunk.iterate(TileWrapper.class, (x, y, z, v) -> { - int worldY = y + engine.getWorld().minHeight(); - if (worldY < c.getWorld().getMinHeight() || worldY >= c.getWorld().getMaxHeight()) { - return; - } - Block block = c.getBlock(x & 15, worldY, z & 15); - if (!TileData.setTileState(block, v.getData())) { - NamespacedKey blockTypeKey = KeyedType.getKey(block.getType()); - String blockType = blockTypeKey == null ? block.getType().name() : blockTypeKey.toString(); - String tileType = v.getData().getMaterialKey(); - IrisLogging.warn("Failed to set tile entity data at [%d %d %d | %s] for tile %s!", block.getX(), block.getY(), block.getZ(), blockType, tileType); - } - }); - }; - - Runnable customTask = () -> { - chunk.iterate(Identifier.class, (x, y, z, v) -> { - int worldY = y + engine.getWorld().minHeight(); - if (worldY < c.getWorld().getMinHeight() || worldY >= c.getWorld().getMaxHeight()) { - return; - } - IrisServices.get(ExternalDataSVC.class).processUpdate(engine, c.getBlock(x & 15, worldY, z & 15), v); - }); - }; + Runnable tileTask = () -> materializeTiles(engine, c, chunk); + Runnable customTask = () -> materializeCustomBlocks(engine, c, chunk); Runnable updateTask = () -> { PrecisionStopwatch p = PrecisionStopwatch.start(); @@ -199,32 +176,61 @@ public final class EngineBukkitOps { }; if (shouldRunChunkUpdateInline(c)) { - chunk.raiseFlagUnchecked(MantleFlag.ETCHED, () -> { - chunk.raiseFlagUnchecked(MantleFlag.TILE, tileTask); - chunk.raiseFlagUnchecked(MantleFlag.CUSTOM, customTask); - chunk.raiseFlagUnchecked(MantleFlag.UPDATE, updateTask); - }); + runMaterializationPasses(chunk, tileTask, customTask, updateTask); return; } - Semaphore semaphore = new Semaphore(1024); - chunk.raiseFlagUnchecked(MantleFlag.ETCHED, () -> { - chunk.raiseFlagUnchecked(MantleFlag.TILE, run(semaphore, c, tileTask, 0)); - chunk.raiseFlagUnchecked(MantleFlag.CUSTOM, run(semaphore, c, customTask, 0)); - chunk.raiseFlagUnchecked(MantleFlag.UPDATE, run(semaphore, c, updateTask, RNG.r.i(1, 20))); - }); - - try { - semaphore.acquire(1024); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - IrisLogging.reportError(ex); - } + runScheduledMaterializationPasses(c, () -> runMaterializationPasses(chunk, tileTask, customTask, updateTask)); } finally { chunk.release(); } } + static void materializeTiles(Engine engine, Chunk chunk, MantleChunk mantleChunk) { + mantleChunk.iterate(TileWrapper.class, (x, y, z, value) -> { + int worldY = y + engine.getWorld().minHeight(); + if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) { + return; + } + Block block = chunk.getBlock(x & 15, worldY, z & 15); + if (!TileData.setTileState(block, value.getData())) { + NamespacedKey blockTypeKey = KeyedType.getKey(block.getType()); + String blockType = blockTypeKey == null ? block.getType().name() : blockTypeKey.toString(); + String tileType = value.getData().getMaterialKey(); + IrisLogging.warn("Failed to set tile entity data at [%d %d %d | %s] for tile %s!", block.getX(), block.getY(), block.getZ(), blockType, tileType); + } + }); + mantleChunk.deleteSlices(TileWrapper.class); + } + + static void materializeCustomBlocks(Engine engine, Chunk chunk, MantleChunk mantleChunk) { + mantleChunk.iterate(Identifier.class, (x, y, z, value) -> { + int worldY = y + engine.getWorld().minHeight(); + if (worldY < chunk.getWorld().getMinHeight() || worldY >= chunk.getWorld().getMaxHeight()) { + return; + } + IrisServices.get(ExternalDataSVC.class).processUpdate( + engine, + chunk.getBlock(x & 15, worldY, z & 15), + value + ); + }); + mantleChunk.deleteSlices(Identifier.class); + } + + static void runMaterializationPasses( + MantleChunk chunk, + Runnable tileTask, + Runnable customTask, + Runnable updateTask + ) { + chunk.raiseFlagUnchecked(MantleFlag.ETCHED, () -> { + chunk.raiseFlagUnchecked(MantleFlag.TILE, tileTask); + chunk.raiseFlagUnchecked(MantleFlag.CUSTOM, customTask); + chunk.raiseFlagUnchecked(MantleFlag.UPDATE, updateTask); + }); + } + private static boolean shouldRunChunkUpdateInline(Chunk chunk) { if (chunk == null) { return false; @@ -237,33 +243,34 @@ public final class EngineBukkitOps { return J.isOwnedByCurrentRegion(chunk.getWorld(), chunk.getX(), chunk.getZ()); } - private static Runnable run(Semaphore semaphore, Chunk contextChunk, Runnable runnable, int delay) { - return () -> { + private static void runScheduledMaterializationPasses(Chunk contextChunk, Runnable runnable) { + CompletableFuture completion = new CompletableFuture<>(); + boolean scheduled = J.runRegion(contextChunk.getWorld(), contextChunk.getX(), contextChunk.getZ(), () -> { try { - semaphore.acquire(); - } catch (InterruptedException e) { - throw new RuntimeException(e); + runnable.run(); + completion.complete(null); + } catch (Throwable error) { + completion.completeExceptionally(error); } + }); - int effectiveDelay = J.isFolia() ? 0 : delay; - boolean scheduled = J.runRegion(contextChunk.getWorld(), contextChunk.getX(), contextChunk.getZ(), () -> { - try { - runnable.run(); - } finally { - semaphore.release(); - } - }, effectiveDelay); - - if (!scheduled) { - try { - if (J.isPrimaryThread()) { - runnable.run(); - } - } finally { - semaphore.release(); - } + if (!scheduled) { + if (J.isPrimaryThread()) { + runnable.run(); } - }; + return; + } + + try { + completion.join(); + } catch (CompletionException error) { + Throwable cause = error.getCause(); + IrisLogging.reportError( + "Failed deferred chunk materialization for " + contextChunk.getWorld().getName() + + " at chunk " + contextChunk.getX() + "," + contextChunk.getZ() + ".", + cause == null ? error : cause + ); + } } public static void update(Engine engine, int x, int y, int z, Chunk c, MantleChunk mc) { diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java index bda46bed0..13fc9bb8b 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/StudioGenerator.java @@ -23,5 +23,12 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.WrongEngineBroException; public interface StudioGenerator { + default boolean requiresPreSessionPreparation() { + return false; + } + + default void prepareChunkBeforeSession(Engine engine, int x, int z) throws WrongEngineBroException { + } + void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException; } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java index bc3c0a777..39f58d409 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGenerator.java @@ -45,10 +45,25 @@ public class BiomeBuffetGenerator extends EnginedStudioGenerator { } @Override - public synchronized void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException { - int id = Cache.to1D(x / biomeSize, 0, z / biomeSize, width, 1); + public boolean requiresPreSessionPreparation() { + return true; + } - if (id < 0 || id >= biomes.length) { + @Override + public synchronized void prepareChunkBeforeSession(Engine engine, int x, int z) { + IrisBiome biome = biomeAt(x, z); + if (biome == null || Objects.equals(engine.getDimension().getFocus(), biome.getLoadKey())) { + return; + } + + engine.getDimension().setFocus(biome.getLoadKey()); + engine.hotloadComplex(); + } + + @Override + public synchronized void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException { + IrisBiome biome = biomeAt(x, z); + if (biome == null) { try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_biome_buffet_stage"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { tc.setRegion(0, 0, 0, 16, 1, 16, FLOOR); @@ -56,17 +71,16 @@ public class BiomeBuffetGenerator extends EnginedStudioGenerator { return; } - IrisBiome biome = biomes[id]; - String focus = engine.getDimension().getFocus(); - - if (!Objects.equals(focus, biome.getLoadKey())) { - engine.getDimension().setFocus(biome.getLoadKey()); - engine.hotloadComplex(); - } + prepareChunkBeforeSession(engine, x, z); try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_biome_buffet_stage"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { engine.generate(x << 4, z << 4, tc, true); } } + + private IrisBiome biomeAt(int x, int z) { + int id = Cache.to1D(x / biomeSize, 0, z / biomeSize, width, 1); + return id < 0 || id >= biomes.length ? null : biomes[id]; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java new file mode 100644 index 000000000..cc48c0e1a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGenerator.java @@ -0,0 +1,874 @@ +package art.arcane.iris.engine.platform.studio.generators; + +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarDirection; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioControlPosition; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.core.service.JigsawStudioService; +import art.arcane.iris.engine.data.chunk.TerrainChunk; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.framework.WrongEngineBroException; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.common.data.VectorMap; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.common.math.Vector3i; +import art.arcane.iris.util.project.context.IrisContext; +import art.arcane.volmlib.util.collection.KList; +import org.bukkit.World; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class JigsawStudioGenerator extends EnginedStudioGenerator { + private static final int CHECKER_SIZE = 4; + + private final JigsawStudioActivation.Request request; + private final JigsawStudioSession session; + private final PlatformBlockState lightFloor; + private final PlatformBlockState darkFloor; + private final PlatformBlockState frame; + private final PlatformBlockState topologyBase; + private final PlatformBlockState topologyPath; + private final PlatformBlockState connectorCap; + private final PlatformBlockState invalidMarker; + private final PlatformBlockState controlChest; + private final FinalStateRenderer finalStateRenderer; + private final AtomicBoolean serviceRegistered = new AtomicBoolean(false); + private final ConcurrentMap renderedBays = new ConcurrentHashMap<>(); + private final ConcurrentMap jigsawStates = new ConcurrentHashMap<>(); + + public JigsawStudioGenerator( + Engine engine, + JigsawStudioActivation.Request request, + JigsawStudioSession session + ) { + this( + engine, + request, + session, + B.getState("minecraft:smooth_stone"), + B.getState("minecraft:polished_deepslate"), + B.getState("minecraft:smooth_quartz"), + B.getState("minecraft:light_gray_wool"), + B.getState("minecraft:red_wool"), + B.getState("minecraft:sea_lantern"), + B.getState("minecraft:red_stained_glass"), + B.getState("minecraft:chest[facing=south,type=single,waterlogged=false]"), + JigsawStudioGenerator::rotatedFinalState + ); + } + + JigsawStudioGenerator( + Engine engine, + JigsawStudioActivation.Request request, + JigsawStudioSession session, + PlatformBlockState lightFloor, + PlatformBlockState darkFloor, + PlatformBlockState frame, + PlatformBlockState topologyBase, + PlatformBlockState topologyPath, + PlatformBlockState connectorCap, + PlatformBlockState invalidMarker, + PlatformBlockState controlChest, + FinalStateRenderer finalStateRenderer + ) { + super(engine); + this.request = Objects.requireNonNull(request, "Jigsaw Studio request"); + this.session = Objects.requireNonNull(session, "Jigsaw Studio session"); + this.lightFloor = Objects.requireNonNull(lightFloor, "Jigsaw Studio light floor block"); + this.darkFloor = Objects.requireNonNull(darkFloor, "Jigsaw Studio dark floor block"); + this.frame = Objects.requireNonNull(frame, "Jigsaw Studio frame block"); + this.topologyBase = Objects.requireNonNull(topologyBase, "Jigsaw Studio topology base block"); + this.topologyPath = Objects.requireNonNull(topologyPath, "Jigsaw Studio topology path block"); + this.connectorCap = Objects.requireNonNull(connectorCap, "Jigsaw Studio connector cap block"); + this.invalidMarker = Objects.requireNonNull(invalidMarker, "Jigsaw Studio invalid marker block"); + this.controlChest = Objects.requireNonNull(controlChest, "Jigsaw Studio control chest block"); + this.finalStateRenderer = Objects.requireNonNull( + finalStateRenderer, + "Jigsaw Studio connector final-state renderer"); + if (!request.requestId().equals(session.sessionId())) { + throw new IllegalArgumentException("Jigsaw Studio request and session IDs do not match"); + } + if (request.mode() != session.layout().mode()) { + throw new IllegalArgumentException("Jigsaw Studio request and session modes do not match"); + } + } + + public JigsawStudioActivation.Request getRequest() { + return request; + } + + public JigsawStudioSession getSession() { + return session; + } + + public JigsawStudioLayout getLayout() { + return session.layout(); + } + + public RenderedBay renderBay(JigsawStudioBay workcell) { + JigsawStudioBay activeWorkcell = requireWorkcell(workcell); + JigsawStudioSession.WorkcellSnapshot snapshot = session.workcellSnapshot(activeWorkcell.stableId()); + if (snapshot.activeVariantKey().isEmpty()) { + return RenderedBay.empty(activeWorkcell.bounds().dimensions()); + } + JigsawStudioVariant variant = session.activeVariant(activeWorkcell.stableId()).orElse(null); + if (variant == null) { + return RenderedBay.invalid( + activeWorkcell.bounds().dimensions(), + "active variant '" + snapshot.activeVariantKey() + "' is missing from the Studio catalog"); + } + return renderVariant(activeWorkcell, variant, snapshot.loadGeneration()); + } + + public RenderedBay renderVariant(JigsawStudioBay workcell, JigsawStudioVariant variant) { + return buildRenderedBay(requireAcceptedVariant(workcell, variant), variant); + } + + public RenderedBay renderVariant( + JigsawStudioBay workcell, + JigsawStudioVariant variant, + long loadGeneration + ) { + JigsawStudioBay activeWorkcell = requireAcceptedVariant(workcell, variant); + RenderKey key = new RenderKey(activeWorkcell.stableId(), variant.pieceKey(), loadGeneration); + return renderedBays.computeIfAbsent(key, ignored -> buildRenderedBay(activeWorkcell, variant)); + } + + public void invalidateRender(String workcellId) { + if (workcellId != null) { + renderedBays.keySet().removeIf(key -> key.workcellId().equals(workcellId)); + } + } + + public void invalidateRender(String workcellId, String pieceKey) { + if (workcellId != null && pieceKey != null) { + renderedBays.keySet().removeIf(key -> key.workcellId().equals(workcellId) + && key.pieceKey().equals(pieceKey)); + } + } + + @Override + public void generateChunk(Engine engine, TerrainChunk terrainChunk, int chunkX, int chunkZ) + throws WrongEngineBroException { + try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_jigsaw_studio_stage"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + paintChunk(terrainChunk, chunkX, chunkZ); + ensureServiceRegistered(engine); + if (serviceRegistered.get()) { + JigsawStudioService.get().markChunkGenerated(engine, this, chunkX, chunkZ); + } + } + } + + void paintChunk(TerrainChunk terrainChunk, int chunkX, int chunkZ) { + Objects.requireNonNull(terrainChunk, "Jigsaw Studio terrain chunk"); + int floorY = Math.max(terrainChunk.getMinHeight(), JigsawStudioLayout.FLOOR_Y); + if (floorY >= terrainChunk.getMaxHeight()) { + return; + } + + int chunkWorldX = chunkX << 4; + int chunkWorldZ = chunkZ << 4; + paintFloor(terrainChunk, floorY, chunkWorldX, chunkWorldZ); + paintControlChest(terrainChunk, chunkWorldX, chunkWorldZ); + + int chunkMaxX = chunkWorldX + 15; + int chunkMaxZ = chunkWorldZ + 15; + for (JigsawStudioBay workcell : getLayout().bays()) { + JigsawStudioBounds bounds = workcell.bounds(); + if (!bounds.intersectsHorizontal( + chunkWorldX - 1, + chunkWorldZ - 1, + chunkMaxX + 1, + chunkMaxZ + 1)) { + continue; + } + paintCage(terrainChunk, workcell, chunkWorldX, chunkWorldZ); + if (workcell.archetype().isPresent()) { + paintTopologyGlyph(terrainChunk, workcell, chunkWorldX, chunkWorldZ); + } + RenderedBay renderedBay = renderBay(workcell); + if (!renderedBay.valid()) { + paintInvalidBay(terrainChunk, workcell, chunkWorldX, chunkWorldZ); + continue; + } + paintObject(terrainChunk, workcell, renderedBay, chunkWorldX, chunkWorldZ); + paintConnectors(terrainChunk, workcell, renderedBay, chunkWorldX, chunkWorldZ); + } + } + + private void ensureServiceRegistered(Engine engine) { + if (serviceRegistered.get()) { + return; + } + World world = BukkitWorldBinding.world(engine.getTarget().getWorld()); + if (world == null) { + return; + } + publishServiceRegistration(() -> JigsawStudioService.get().register(engine, this)); + } + + void publishServiceRegistration(Runnable registration) { + Objects.requireNonNull(registration, "Jigsaw Studio service registration"); + if (serviceRegistered.get()) { + return; + } + synchronized (serviceRegistered) { + if (serviceRegistered.get()) { + return; + } + registration.run(); + serviceRegistered.set(true); + } + } + + private void paintFloor(TerrainChunk terrainChunk, int floorY, int chunkWorldX, int chunkWorldZ) { + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int worldX = chunkWorldX + localX; + int worldZ = chunkWorldZ + localZ; + int tileX = Math.floorDiv(worldX, CHECKER_SIZE); + int tileZ = Math.floorDiv(worldZ, CHECKER_SIZE); + PlatformBlockState block = ((tileX + tileZ) & 1) == 0 ? lightFloor : darkFloor; + terrainChunk.setBlock(localX, floorY, localZ, block); + } + } + } + + private void paintControlChest(TerrainChunk terrainChunk, int chunkWorldX, int chunkWorldZ) { + JigsawStudioControlPosition control = getLayout().controlPosition(); + setWorldBlock( + terrainChunk, + control.worldX(), + control.worldY(), + control.worldZ(), + controlChest, + chunkWorldX, + chunkWorldZ); + } + + private void paintCage( + TerrainChunk terrainChunk, + JigsawStudioBay workcell, + int chunkWorldX, + int chunkWorldZ + ) { + JigsawStudioBounds bounds = workcell.bounds(); + int minX = bounds.originX() - 1; + int maxX = bounds.maxX() + 1; + int minZ = bounds.originZ() - 1; + int maxZ = bounds.maxZ() + 1; + int bottomY = bounds.originY(); + int topY = bounds.maxY() + 1; + + paintRectangle(terrainChunk, minX, maxX, bottomY, minZ, maxZ, frame, chunkWorldX, chunkWorldZ); + paintRectangle(terrainChunk, minX, maxX, topY, minZ, maxZ, frame, chunkWorldX, chunkWorldZ); + for (int y = bottomY + 1; y < topY; y++) { + setWorldBlock(terrainChunk, minX, y, minZ, frame, chunkWorldX, chunkWorldZ); + setWorldBlock(terrainChunk, maxX, y, minZ, frame, chunkWorldX, chunkWorldZ); + setWorldBlock(terrainChunk, minX, y, maxZ, frame, chunkWorldX, chunkWorldZ); + setWorldBlock(terrainChunk, maxX, y, maxZ, frame, chunkWorldX, chunkWorldZ); + } + } + + private void paintRectangle( + TerrainChunk terrainChunk, + int minX, + int maxX, + int y, + int minZ, + int maxZ, + PlatformBlockState block, + int chunkWorldX, + int chunkWorldZ + ) { + for (int x = minX; x <= maxX; x++) { + setWorldBlock(terrainChunk, x, y, minZ, block, chunkWorldX, chunkWorldZ); + setWorldBlock(terrainChunk, x, y, maxZ, block, chunkWorldX, chunkWorldZ); + } + for (int z = minZ + 1; z < maxZ; z++) { + setWorldBlock(terrainChunk, minX, y, z, block, chunkWorldX, chunkWorldZ); + setWorldBlock(terrainChunk, maxX, y, z, block, chunkWorldX, chunkWorldZ); + } + } + + private void paintTopologyGlyph( + TerrainChunk terrainChunk, + JigsawStudioBay workcell, + int chunkWorldX, + int chunkWorldZ + ) { + JigsawPlanarTopology topology = workcell.topology().orElseThrow(); + JigsawStudioBounds bounds = workcell.bounds(); + int minimumX = Math.max(bounds.originX(), chunkWorldX); + int maximumX = Math.min(bounds.maxX(), chunkWorldX + 15); + int minimumZ = Math.max(bounds.originZ(), chunkWorldZ); + int maximumZ = Math.min(bounds.maxZ(), chunkWorldZ + 15); + if (minimumX > maximumX || minimumZ > maximumZ) { + return; + } + int glyphY = bounds.originY() - 1; + int centerX = bounds.dimensions().width() / 2; + int centerZ = bounds.dimensions().depth() / 2; + for (int worldX = minimumX; worldX <= maximumX; worldX++) { + int localX = worldX - bounds.originX(); + for (int worldZ = minimumZ; worldZ <= maximumZ; worldZ++) { + int localZ = worldZ - bounds.originZ(); + PlatformBlockState state = topologyGlyphState(topology, bounds.dimensions(), localX, localZ, + centerX, centerZ); + setWorldBlock(terrainChunk, worldX, glyphY, worldZ, state, chunkWorldX, chunkWorldZ); + } + } + } + + private PlatformBlockState topologyGlyphState( + JigsawPlanarTopology topology, + JigsawStudioCellDimensions dimensions, + int localX, + int localZ, + int centerX, + int centerZ + ) { + if (isConnectorCap(topology, dimensions, localX, localZ, centerX, centerZ)) { + return connectorCap; + } + if (isTopologyPath(topology, localX, localZ, centerX, centerZ)) { + return topologyPath; + } + return topologyBase; + } + + static boolean isConnectorCap( + JigsawPlanarTopology topology, + JigsawStudioCellDimensions dimensions, + int localX, + int localZ, + int centerX, + int centerZ + ) { + return topology.connects(JigsawPlanarDirection.NORTH) && localX == centerX && localZ == 0 + || topology.connects(JigsawPlanarDirection.EAST) + && localX == dimensions.width() - 1 && localZ == centerZ + || topology.connects(JigsawPlanarDirection.SOUTH) + && localX == centerX && localZ == dimensions.depth() - 1 + || topology.connects(JigsawPlanarDirection.WEST) && localX == 0 && localZ == centerZ; + } + + static boolean isTopologyPath( + JigsawPlanarTopology topology, + int localX, + int localZ, + int centerX, + int centerZ + ) { + if (topology == JigsawPlanarTopology.BLANK) { + return false; + } + boolean vertical = localX == centerX + && (topology.connects(JigsawPlanarDirection.NORTH) && localZ <= centerZ + || topology.connects(JigsawPlanarDirection.SOUTH) && localZ >= centerZ); + boolean horizontal = localZ == centerZ + && (topology.connects(JigsawPlanarDirection.WEST) && localX <= centerX + || topology.connects(JigsawPlanarDirection.EAST) && localX >= centerX); + return vertical || horizontal; + } + + private RenderedBay buildRenderedBay(JigsawStudioBay workcell, JigsawStudioVariant variant) { + IrisJigsawPiece piece = request.source().getJigsawPieceLoader().load(variant.pieceKey(), false); + if (piece == null) { + return RenderedBay.invalid( + workcell.bounds().dimensions(), + "piece '" + variant.pieceKey() + "' is missing"); + } + if (piece.getObject() == null || piece.getObject().isBlank()) { + return RenderedBay.invalid( + workcell.bounds().dimensions(), + "piece '" + variant.pieceKey() + "' has no object"); + } + if (!piece.getObject().equals(variant.objectKey())) { + return RenderedBay.invalid( + workcell.bounds().dimensions(), + "piece '" + variant.pieceKey() + "' changed its object from '" + variant.objectKey() + + "' to '" + piece.getObject() + "'"); + } + IrisObject object = request.source().getObjectLoader().load(variant.objectKey(), false); + if (object == null) { + return RenderedBay.invalid( + workcell.bounds().dimensions(), + "object '" + variant.objectKey() + "' is missing"); + } + if (object.getW() < 1 || object.getH() < 1 || object.getD() < 1) { + return RenderedBay.invalid( + workcell.bounds().dimensions(), + "object '" + variant.objectKey() + "' has invalid dimensions"); + } + + int quarterTurns = variant.sourceToCanonicalQuarterTurns(); + JigsawStudioCellDimensions dimensions = rotatedDimensions(object, quarterTurns); + if (!fits(dimensions, workcell.bounds().dimensions())) { + return RenderedBay.invalid( + dimensions, + "object '" + variant.objectKey() + "' does not fit workcell '" + workcell.stableId() + "'"); + } + + IrisObjectRotation rotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0); + List blocks = renderBlocks(object, dimensions, quarterTurns, rotation); + if (blocks == null) { + return RenderedBay.invalid( + dimensions, + "object '" + variant.objectKey() + "' contains an invalid or out-of-bounds block"); + } + + ConnectorRenderResult connectors = renderConnectors(piece, object, dimensions, quarterTurns, rotation); + if (!connectors.failure().isEmpty()) { + return RenderedBay.invalid(dimensions, connectors.failure()); + } + return RenderedBay.valid(dimensions, blocks, connectors.connectors()); + } + + private List renderBlocks( + IrisObject object, + JigsawStudioCellDimensions dimensions, + int quarterTurns, + IrisObjectRotation rotation + ) { + VectorMap sourceBlocks = object.getBlocks(); + VectorMap sourceTiles = object.getStates(); + Vector3i center = object.getCenter(); + List blocks = new ArrayList<>(sourceBlocks.size()); + for (Map.Entry entry : sourceBlocks) { + IrisBlockVector signed = entry.getKey(); + int sourceX = signed.getBlockX() + center.getBlockX(); + int sourceY = signed.getBlockY() + center.getBlockY(); + int sourceZ = signed.getBlockZ() + center.getBlockZ(); + if (!inside(sourceX, sourceY, sourceZ, object.getW(), object.getH(), object.getD())) { + return null; + } + RotatedPosition position = rotatePosition( + sourceX, + sourceY, + sourceZ, + object.getW(), + object.getD(), + quarterTurns); + if (!inside( + position.x(), + position.y(), + position.z(), + dimensions.width(), + dimensions.height(), + dimensions.depth())) { + return null; + } + PlatformBlockState blockState = quarterTurns == 0 + ? entry.getValue() + : rotation.rotate(entry.getValue(), 0, 0, 0); + if (blockState == null) { + return null; + } + TileData tileData = sourceTiles.get(signed); + blocks.add(new RenderedBlock( + position.x(), + position.y(), + position.z(), + blockState, + tileData == null ? null : tileData.clone())); + } + return List.copyOf(blocks); + } + + private ConnectorRenderResult renderConnectors( + IrisJigsawPiece piece, + IrisObject object, + JigsawStudioCellDimensions dimensions, + int quarterTurns, + IrisObjectRotation rotation + ) { + KList sourceConnectors = piece.getConnectors(); + if (sourceConnectors == null) { + return ConnectorRenderResult.invalid("piece '" + piece.getLoadKey() + "' has no connector list"); + } + List connectors = new ArrayList<>(sourceConnectors.size()); + for (int index = 0; index < sourceConnectors.size(); index++) { + IrisJigsawConnector source = sourceConnectors.get(index); + if (!validConnectorMetadata(source)) { + return ConnectorRenderResult.invalid("connector " + index + " has incomplete metadata"); + } + IrisPosition sourcePosition = source.getPosition(); + if (!inside( + sourcePosition.getX(), + sourcePosition.getY(), + sourcePosition.getZ(), + object.getW(), + object.getH(), + object.getD())) { + return ConnectorRenderResult.invalid("connector " + index + " is outside its object bounds"); + } + RotatedPosition position = rotatePosition( + sourcePosition.getX(), + sourcePosition.getY(), + sourcePosition.getZ(), + object.getW(), + object.getD(), + quarterTurns); + IrisDirection front = quarterTurns == 0 + ? source.getDirection() + : rotation.rotate(source.getDirection()); + IrisDirection top = quarterTurns == 0 + ? source.getTop() + : rotation.rotate(source.getTop()); + String orientation; + try { + orientation = orientation(front, top); + } catch (IllegalArgumentException exception) { + return ConnectorRenderResult.invalid("connector " + index + " has an invalid orientation: " + + exception.getMessage()); + } + String finalState = finalStateRenderer.render(source.getFinalState(), rotation, quarterTurns); + if (finalState == null) { + return ConnectorRenderResult.invalid("connector " + index + " final state '" + + source.getFinalState() + "' cannot be parsed and rotated"); + } + IrisJigsawConnector connector = copyConnector(source) + .setPosition(new IrisPosition(position.x(), position.y(), position.z())) + .setDirection(front) + .setTop(top) + .setFinalState(finalState); + if (!inside( + position.x(), + position.y(), + position.z(), + dimensions.width(), + dimensions.height(), + dimensions.depth())) { + return ConnectorRenderResult.invalid("connector " + index + " rotated outside its object bounds"); + } + connectors.add(new RenderedConnector( + position.x(), position.y(), position.z(), connector, orientation)); + } + return ConnectorRenderResult.valid(connectors); + } + + private static String rotatedFinalState( + String finalState, + IrisObjectRotation rotation, + int quarterTurns + ) { + PlatformBlockState state = B.getStateOrNull(finalState, false); + if (state == null) { + return null; + } + if (quarterTurns == 0) { + return state.key(); + } + PlatformBlockState rotated = rotation.rotate(state, 0, 0, 0); + return rotated == null ? null : rotated.key(); + } + + private void paintObject( + TerrainChunk terrainChunk, + JigsawStudioBay workcell, + RenderedBay renderedBay, + int chunkWorldX, + int chunkWorldZ + ) { + JigsawStudioBounds bounds = workcell.bounds(); + for (RenderedBlock block : renderedBay.blocks()) { + setWorldBlock( + terrainChunk, + bounds.originX() + block.x(), + bounds.originY() + block.y(), + bounds.originZ() + block.z(), + block.state(), + chunkWorldX, + chunkWorldZ); + } + } + + private void paintConnectors( + TerrainChunk terrainChunk, + JigsawStudioBay workcell, + RenderedBay renderedBay, + int chunkWorldX, + int chunkWorldZ + ) { + JigsawStudioBounds bounds = workcell.bounds(); + for (RenderedConnector connector : renderedBay.connectors()) { + PlatformBlockState state = jigsawStates.computeIfAbsent( + connector.orientation(), + key -> B.getState("minecraft:jigsaw[orientation=" + key + "]")); + setWorldBlock( + terrainChunk, + bounds.originX() + connector.x(), + bounds.originY() + connector.y(), + bounds.originZ() + connector.z(), + state == null ? invalidMarker : state, + chunkWorldX, + chunkWorldZ); + } + } + + private void paintInvalidBay( + TerrainChunk terrainChunk, + JigsawStudioBay workcell, + int chunkWorldX, + int chunkWorldZ + ) { + JigsawStudioBounds bounds = workcell.bounds(); + int y = bounds.originY(); + int length = Math.min(bounds.dimensions().width(), bounds.dimensions().depth()); + for (int offset = 0; offset < length; offset++) { + setWorldBlock( + terrainChunk, + bounds.originX() + offset, + y, + bounds.originZ() + offset, + invalidMarker, + chunkWorldX, + chunkWorldZ); + setWorldBlock( + terrainChunk, + bounds.maxX() - offset, + y, + bounds.originZ() + offset, + invalidMarker, + chunkWorldX, + chunkWorldZ); + } + } + + private JigsawStudioBay requireWorkcell(JigsawStudioBay workcell) { + JigsawStudioBay activeWorkcell = Objects.requireNonNull(workcell, "Jigsaw Studio workcell"); + if (getLayout().get(activeWorkcell.stableId()) != activeWorkcell) { + throw new IllegalArgumentException("Workcell does not belong to this Jigsaw Studio layout"); + } + return activeWorkcell; + } + + private JigsawStudioBay requireAcceptedVariant(JigsawStudioBay workcell, JigsawStudioVariant variant) { + JigsawStudioBay activeWorkcell = requireWorkcell(workcell); + if (!getLayout().accepts(activeWorkcell, Objects.requireNonNull(variant, "Jigsaw Studio variant"))) { + throw new IllegalArgumentException("Variant '" + variant.pieceKey() + + "' does not belong to workcell '" + activeWorkcell.stableId() + "'"); + } + return activeWorkcell; + } + + static RotatedPosition rotatePosition( + int x, + int y, + int z, + int width, + int depth, + int quarterTurns + ) { + return switch (Math.floorMod(quarterTurns, 4)) { + case 0 -> new RotatedPosition(x, y, z); + case 1 -> new RotatedPosition(depth - 1 - z, y, x); + case 2 -> new RotatedPosition(width - 1 - x, y, depth - 1 - z); + case 3 -> new RotatedPosition(z, y, width - 1 - x); + default -> throw new IllegalStateException("Unreachable Jigsaw Studio rotation"); + }; + } + + static String orientation(IrisDirection front, IrisDirection top) { + IrisDirection activeFront = Objects.requireNonNull(front, "Jigsaw connector front"); + IrisDirection activeTop = Objects.requireNonNull(top, "Jigsaw connector top"); + if (activeFront == IrisDirection.UP_POSITIVE_Y || activeFront == IrisDirection.DOWN_NEGATIVE_Y) { + if (activeTop.isVertical()) { + throw new IllegalArgumentException("Vertical jigsaw fronts require a horizontal top direction"); + } + return directionName(activeFront) + "_" + directionName(activeTop); + } + if (activeTop != IrisDirection.UP_POSITIVE_Y) { + throw new IllegalArgumentException("Horizontal jigsaw fronts require an upward top direction"); + } + return directionName(activeFront) + "_up"; + } + + private static JigsawStudioCellDimensions rotatedDimensions(IrisObject object, int quarterTurns) { + return Math.floorMod(quarterTurns, 2) == 0 + ? new JigsawStudioCellDimensions(object.getW(), object.getH(), object.getD()) + : new JigsawStudioCellDimensions(object.getD(), object.getH(), object.getW()); + } + + private static boolean fits(JigsawStudioCellDimensions object, JigsawStudioCellDimensions bay) { + return object.width() <= bay.width() + && object.height() <= bay.height() + && object.depth() <= bay.depth(); + } + + private static boolean inside(int x, int y, int z, int width, int height, int depth) { + return x >= 0 && x < width + && y >= 0 && y < height + && z >= 0 && z < depth; + } + + private static boolean validConnectorMetadata(IrisJigsawConnector connector) { + return connector != null + && connector.getPosition() != null + && connector.getDirection() != null + && connector.getTop() != null + && connector.getJoint() != null + && connector.getPool() != null + && !connector.getPool().isBlank() + && connector.getName() != null + && !connector.getName().isBlank() + && connector.getTargetName() != null + && !connector.getTargetName().isBlank() + && connector.getFinalState() != null + && !connector.getFinalState().isBlank(); + } + + private static IrisJigsawConnector copyConnector(IrisJigsawConnector source) { + return new IrisJigsawConnector() + .setPosition(new IrisPosition( + source.getPosition().getX(), + source.getPosition().getY(), + source.getPosition().getZ())) + .setDirection(source.getDirection()) + .setTop(source.getTop()) + .setPool(source.getPool()) + .setName(source.getName()) + .setTargetName(source.getTargetName()) + .setChannel(source.getChannel() == null ? "" : source.getChannel()) + .setJoint(source.getJoint() == null ? JigsawJoint.ROLLABLE : source.getJoint()) + .setFinalState(source.getFinalState()) + .setSelectionPriority(source.getSelectionPriority()) + .setPlacementPriority(source.getPlacementPriority()); + } + + private static String directionName(IrisDirection direction) { + return switch (direction) { + case UP_POSITIVE_Y -> "up"; + case DOWN_NEGATIVE_Y -> "down"; + case NORTH_NEGATIVE_Z -> "north"; + case SOUTH_POSITIVE_Z -> "south"; + case EAST_POSITIVE_X -> "east"; + case WEST_NEGATIVE_X -> "west"; + }; + } + + private void setWorldBlock( + TerrainChunk terrainChunk, + int worldX, + int worldY, + int worldZ, + PlatformBlockState block, + int chunkWorldX, + int chunkWorldZ + ) { + int localX = worldX - chunkWorldX; + int localZ = worldZ - chunkWorldZ; + if (localX < 0 || localX >= 16 || localZ < 0 || localZ >= 16) { + return; + } + if (worldY < terrainChunk.getMinHeight() || worldY >= terrainChunk.getMaxHeight()) { + return; + } + terrainChunk.setBlock(localX, worldY, localZ, block); + } + + public record RenderedBay( + JigsawStudioCellDimensions dimensions, + List blocks, + List connectors, + String failure + ) { + public RenderedBay { + dimensions = Objects.requireNonNull(dimensions, "Jigsaw Studio rendered dimensions"); + blocks = List.copyOf(blocks); + connectors = List.copyOf(connectors); + failure = failure == null ? "" : failure.trim(); + } + + public static RenderedBay valid( + JigsawStudioCellDimensions dimensions, + List blocks, + List connectors + ) { + return new RenderedBay(dimensions, blocks, connectors, ""); + } + + public static RenderedBay empty(JigsawStudioCellDimensions dimensions) { + return valid(dimensions, List.of(), List.of()); + } + + public static RenderedBay invalid(JigsawStudioCellDimensions dimensions, String failure) { + return new RenderedBay(dimensions, List.of(), List.of(), failure); + } + + public boolean valid() { + return failure.isEmpty(); + } + } + + public record RenderedBlock(int x, int y, int z, PlatformBlockState state, TileData tileData) { + public RenderedBlock { + state = Objects.requireNonNull(state, "Jigsaw Studio rendered block state"); + } + } + + public record RenderedConnector( + int x, + int y, + int z, + IrisJigsawConnector connector, + String orientation + ) { + public RenderedConnector { + connector = Objects.requireNonNull(connector, "Jigsaw Studio rendered connector"); + orientation = Objects.requireNonNull(orientation, "Jigsaw Studio connector orientation"); + } + } + + private record RenderKey(String workcellId, String pieceKey, long loadGeneration) { + } + + private record ConnectorRenderResult(List connectors, String failure) { + private ConnectorRenderResult { + connectors = List.copyOf(connectors); + failure = failure == null ? "" : failure; + } + + private static ConnectorRenderResult valid(List connectors) { + return new ConnectorRenderResult(connectors, ""); + } + + private static ConnectorRenderResult invalid(String failure) { + return new ConnectorRenderResult(List.of(), failure); + } + } + + @FunctionalInterface + interface FinalStateRenderer { + String render(String finalState, IrisObjectRotation rotation, int quarterTurns); + } + + record RotatedPosition(int x, int y, int z) { + } +} diff --git a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java new file mode 100644 index 000000000..bc4e0ca49 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerInputFingerprintTest.java @@ -0,0 +1,171 @@ +package art.arcane.iris.core; + +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class IrisDatapackCompilerInputFingerprintTest { + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void compilerInputsIncludeDimensionsBiomesAndSnippets() throws Exception { + Path pack = activePack("compiler-inputs"); + Path biome = write(pack.resolve("biomes/plains.json"), "biome-a"); + Path snippet = write(pack.resolve("snippet/dimension/options.json"), "snippet-a"); + String initial = fingerprint(pack, false, "compiler-a"); + + Files.writeString(biome, "biome-b", StandardCharsets.UTF_8); + String biomeChanged = fingerprint(pack, false, "compiler-a"); + Files.writeString(snippet, "snippet-b", StandardCharsets.UTF_8); + String snippetChanged = fingerprint(pack, false, "compiler-a"); + Files.writeString(pack.resolve("dimensions/overworld.json"), "dimension-b", StandardCharsets.UTF_8); + String dimensionChanged = fingerprint(pack, false, "compiler-a"); + + assertNotEquals(initial, biomeChanged); + assertNotEquals(biomeChanged, snippetChanged); + assertNotEquals(snippetChanged, dimensionChanged); + } + + @Test + public void compilerInputsExcludeObjectsAndJigsawResources() throws Exception { + Path pack = activePack("non-compiler-inputs"); + Path object = write(pack.resolve("objects/house.iob"), "object-a"); + Path structure = write(pack.resolve("structures/village.json"), "structure-a"); + Path piece = write(pack.resolve("jigsaw-pieces/road.json"), "piece-a"); + String initial = fingerprint(pack, false, "compiler-a"); + + Files.writeString(object, "object-b", StandardCharsets.UTF_8); + Files.writeString(structure, "structure-b", StandardCharsets.UTF_8); + Files.writeString(piece, "piece-b", StandardCharsets.UTF_8); + + assertEquals(initial, fingerprint(pack, false, "compiler-a")); + } + + @Test + public void contentHashDetectsEqualSizeEditsWithRestoredMtime() throws Exception { + Path pack = activePack("restored-mtime"); + Path dimension = pack.resolve("dimensions/overworld.json"); + Files.writeString(dimension, "aaaa", StandardCharsets.UTF_8); + FileTime originalMtime = Files.getLastModifiedTime(dimension); + String initial = fingerprint(pack, false, "compiler-a"); + + Files.writeString(dimension, "bbbb", StandardCharsets.UTF_8); + Files.setLastModifiedTime(dimension, originalMtime); + + assertNotEquals(initial, fingerprint(pack, false, "compiler-a")); + } + + @Test + public void outputPolicyAndCompilerIdentitySaltTheFingerprint() throws Exception { + Path pack = activePack("compiler-salt"); + String initial = fingerprint(pack, false, "compiler-a"); + + assertNotEquals(initial, fingerprint(pack, true, "compiler-a")); + assertNotEquals(initial, fingerprint(pack, false, "compiler-b")); + } + + @Test + public void compilerInputsRejectNestedSymbolicLinks() throws Exception { + Path pack = activePack("unsafe-input"); + Path outside = tmp.newFile("outside.json").toPath(); + Path link = pack.resolve("biomes/linked.json"); + Files.createDirectories(link.getParent()); + try { + Files.createSymbolicLink(link, outside); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + try { + fingerprint(pack, false, "compiler-a"); + fail("Compiler-input symbolic links must be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("symbolic link")); + } + } + + @Test + public void inputRootDiscoveryDoesNotTraverseUnrelatedPackTrees() throws Exception { + Path dataDirectory = tmp.newFolder("input-root-data").toPath(); + Path serverRoot = tmp.newFolder("input-root-server").toPath(); + Path pack = dataDirectory.resolve("packs/example"); + write(pack.resolve("dimensions/example.json"), "{}"); + Path unrelated = tmp.newFile("unrelated.json").toPath(); + Path link = pack.resolve("objects/unsafe-link.json"); + Files.createDirectories(link.getParent()); + try { + Files.createSymbolicLink(link, unrelated); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + List compilerRoots = IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot); + + assertEquals(List.of(pack.toAbsolutePath().normalize().toFile()), compilerRoots); + try { + IrisDatapackCompiler.collectPackRoots(dataDirectory, serverRoot); + fail("Whole-pack discovery must continue rejecting unrelated symbolic links"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("symbolic link")); + } + } + + @Test + public void inputRootDiscoveryAcceptsTopLevelWorkspaceSymbolicLinks() throws Exception { + Path dataDirectory = tmp.newFolder("linked-root-data").toPath(); + Path serverRoot = tmp.newFolder("linked-root-server").toPath(); + Path realPack = tmp.newFolder("linked-root-pack").toPath(); + write(realPack.resolve("dimensions/example.json"), "{}"); + Path linkedPack = dataDirectory.resolve("packs/example"); + Files.createDirectories(linkedPack.getParent()); + try { + Files.createSymbolicLink(linkedPack, realPack); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + List compilerRoots = IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot); + List compilationRoots = IrisDatapackCompiler.collectPackRoots(dataDirectory, serverRoot); + + assertEquals(List.of(linkedPack.toAbsolutePath().normalize().toFile()), compilerRoots); + assertEquals(compilerRoots, compilationRoots); + assertTrue(!IrisDatapackCompiler.computeInputFingerprint( + compilerRoots, + false, + "compiler-a").isBlank()); + } + + private Path activePack(String name) throws IOException { + Path pack = tmp.newFolder(name).toPath(); + write(pack.resolve("dimensions/overworld.json"), "dimension-a"); + return pack; + } + + private Path write(Path path, String value) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString(path, value, StandardCharsets.UTF_8); + return path; + } + + private String fingerprint(Path pack, boolean adjustVanillaHeight, String compilerIdentity) throws IOException { + return IrisDatapackCompiler.computeInputFingerprint( + List.of(pack.toFile()), + adjustVanillaHeight, + compilerIdentity); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java b/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java new file mode 100644 index 000000000..0e1a0e157 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/IrisSettingsDefaultsTest.java @@ -0,0 +1,16 @@ +package art.arcane.iris.core; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisSettingsDefaultsTest { + @Test + public void datapackIngestStaysAutomaticWhileEditableConversionIsOptIn() { + IrisSettings.IrisSettingsGeneral settings = new IrisSettings.IrisSettingsGeneral(); + + assertTrue(settings.isAutoIngestDatapacks()); + assertFalse(settings.isAutoImportDatapackStructures()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java index dce4e75e4..01f229257 100644 --- a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.core; +import art.arcane.iris.core.datapack.DatapackIngestService; import org.junit.Rule; import org.junit.Test; import org.junit.Assume; @@ -8,6 +9,7 @@ import org.junit.rules.TemporaryFolder; import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -15,6 +17,7 @@ import java.nio.file.Path; import java.nio.file.attribute.FileTime; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -156,6 +159,67 @@ public class ServerConfiguratorDatapackFingerprintTest { assertEquals(ServerConfigurator.computePackFingerprint(packsDir), rehashed.content()); } + @Test + public void recoveryForcesAPostRecoveryContentFingerprint() throws Exception { + File packsDir = tmp.newFolder("recovered-packs"); + Path dimension = packsDir.toPath().resolve("testpack/dimensions/overworld.json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, "aaaa", StandardCharsets.UTF_8); + ServerConfigurator.PackFingerprint cached = + ServerConfigurator.resolvePackFingerprint(packsDir, "", ""); + FileTime originalMtime = Files.getLastModifiedTime(dimension); + + Files.writeString(dimension, "bbbb", StandardCharsets.UTF_8); + Files.setLastModifiedTime(dimension, originalMtime); + + ServerConfigurator.PackFingerprint reused = + ServerConfigurator.resolvePostRecoveryPackFingerprint( + packsDir, + cached.metadata(), + cached.content(), + DatapackIngestService.ReapplyOutcome.success(false, false)); + ServerConfigurator.PackFingerprint recovered = + ServerConfigurator.resolvePostRecoveryPackFingerprint( + packsDir, + cached.metadata(), + cached.content(), + DatapackIngestService.ReapplyOutcome.success(true, false)); + + assertEquals(cached.content(), reused.content()); + assertNotEquals(cached.content(), recovered.content()); + assertEquals(ServerConfigurator.computePackFingerprint(packsDir), recovered.content()); + } + + @Test + public void fullInstallRequiresRestartWhenRecoveryOrRepairChangedFiles() { + assertEquals( + DatapackInstallResult.Status.UNCHANGED, + ServerConfigurator.resultForUnchangedFingerprint( + true, + DatapackIngestService.ReapplyOutcome.success(false, false)).status()); + assertEquals( + DatapackInstallResult.Status.RESTART_REQUIRED, + ServerConfigurator.resultForUnchangedFingerprint( + true, + DatapackIngestService.ReapplyOutcome.success(true, false)).status()); + assertEquals( + DatapackInstallResult.Status.RESTART_REQUIRED, + ServerConfigurator.resultForUnchangedFingerprint( + true, + DatapackIngestService.ReapplyOutcome.success(false, true)).status()); + assertEquals( + DatapackInstallResult.Status.READY, + ServerConfigurator.resultForUnchangedFingerprint( + false, + DatapackIngestService.ReapplyOutcome.success(true, true)).status()); + assertEquals( + DatapackInstallResult.Status.FAILED, + ServerConfigurator.resultForUnchangedFingerprint( + true, + DatapackIngestService.ReapplyOutcome.failed( + new IOException("recovery failed"))).status()); + } + @Test public void computePackMetadataDigestIgnoresGeneratedCodeWorkspaceFiles() throws Exception { File packsDir = tmp.newFolder("metadata-workspace-packs"); @@ -252,15 +316,61 @@ public class ServerConfiguratorDatapackFingerprintTest { } @Test - public void incompleteExternalDatapackRecoveryBlocksCompilation() throws Exception { + public void recoveryRunsBeforeFingerprintEarlyReturnAndCompilation() throws Exception { String source = Files.readString(Path.of( "src/main/java/art/arcane/iris/core/ServerConfigurator.java")); - int recovery = source.indexOf("if (!DatapackIngestService.reapplyFromStaging(datapacksFolders))"); - int blocked = source.indexOf("return DatapackInstallResult.failedResult();", recovery); - int compile = source.indexOf("IrisDatapackCompiler.compile(", recovery); + int installIfChanged = source.indexOf("installDataPacksIfChanged(boolean fullInstall)"); + int recovery = source.indexOf("DatapackIngestService.reapplyFromStaging", installIfChanged); + int fingerprint = source.indexOf("computeCurrentDatapackCompilerInputFingerprint", recovery); + int earlyReturn = source.indexOf("resultForUnchangedFingerprint", fingerprint); + int compile = source.indexOf("compileDataPacksLocked(", earlyReturn); + int cache = source.indexOf("writeCompilerInputFingerprintCache(cacheFile.toPath(), current)", compile); assertTrue(recovery >= 0); - assertTrue(blocked > recovery); - assertTrue(compile > blocked); + assertTrue(fingerprint > recovery); + assertTrue(earlyReturn > fingerprint); + assertTrue(compile > earlyReturn); + assertTrue(cache > compile); + } + + @Test + public void studioTimingSeparatesRecoveryFingerprintCompilationAndTotal() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/ServerConfigurator.java")); + int timedInstall = source.indexOf("BiConsumer timingConsumer"); + int recovery = source.indexOf("\"datapack_external_recovery\"", timedInstall); + int fingerprint = source.indexOf("\"datapack_compiler_input_fingerprint\"", recovery); + int compile = source.indexOf("\"datapack_compile_publish\"", fingerprint); + int total = source.indexOf("\"datapack_install_if_changed_total\"", compile); + + assertTrue(timedInstall >= 0); + assertTrue(recovery > timedInstall); + assertTrue(fingerprint > recovery); + assertTrue(compile > fingerprint); + assertTrue(total > compile); + } + + @Test + public void loadedRuntimeReuseRequiresAnExactPinnedCompilerInputFingerprint() { + assertTrue(ServerConfigurator.reusableRuntimeFingerprint("abc", "abc")); + assertFalse(ServerConfigurator.reusableRuntimeFingerprint("abc", "def")); + assertFalse(ServerConfigurator.reusableRuntimeFingerprint("", "")); + assertFalse(ServerConfigurator.reusableRuntimeFingerprint(null, "abc")); + } + + @Test + public void externalDatapackMutationInvalidatesReadinessAndRetainsComparisonPin() throws Exception { + Field ready = ServerConfigurator.class.getDeclaredField("loadedDatapackRuntimeReady"); + Field fingerprint = ServerConfigurator.class.getDeclaredField( + "loadedDatapackCompilerInputFingerprint"); + ready.setAccessible(true); + fingerprint.setAccessible(true); + ready.setBoolean(null, true); + fingerprint.set(null, "abc"); + + ServerConfigurator.invalidateLoadedDatapackRuntime(); + + assertFalse(ready.getBoolean(null)); + assertEquals("abc", fingerprint.get(null)); } } diff --git a/core/src/test/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleterTest.java b/core/src/test/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleterTest.java new file mode 100644 index 000000000..20339f278 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/SnapshotDirectoryTreeDeleterTest.java @@ -0,0 +1,68 @@ +package art.arcane.iris.core; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import 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 SnapshotDirectoryTreeDeleterTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void deletesNestedDirectoryWithLargeDirectChildSnapshot() throws IOException { + Path root = temporaryFolder.newFolder("large-tree").toPath(); + Path nested = Files.createDirectories(root.resolve("one/two/three")); + for (int index = 0; index < 257; index++) { + Files.writeString(nested.resolve("entry-" + index + ".dat"), "value-" + index); + } + for (int index = 0; index < 17; index++) { + Path child = Files.createDirectory(nested.resolve("branch-" + index)); + Files.writeString(child.resolve("payload.dat"), "payload-" + index); + } + + SnapshotDirectoryTreeDeleter.delete(root); + + assertFalse(Files.exists(root)); + } + + @Test + public void rejectsDirectSymlinkBeforeMutatingItsDirectory() throws IOException { + Path external = temporaryFolder.newFolder("external").toPath(); + Path externalFile = Files.writeString(external.resolve("preserved.dat"), "preserved"); + Path root = temporaryFolder.newFolder("symlink-tree").toPath(); + Path sibling = Files.writeString(root.resolve("sibling.dat"), "sibling"); + Files.createSymbolicLink(root.resolve("external-link"), external); + + IOException failure = assertThrows( + IOException.class, + () -> SnapshotDirectoryTreeDeleter.delete(root) + ); + + assertTrue(failure.getMessage().contains("symbolic link")); + assertTrue(Files.exists(root)); + assertTrue(Files.exists(sibling)); + assertEquals("preserved", Files.readString(externalFile)); + } + + @Test + public void rejectsNonDirectoryTarget() throws IOException { + Path file = temporaryFolder.newFile("ordinary-file.dat").toPath(); + + IOException failure = assertThrows( + IOException.class, + () -> SnapshotDirectoryTreeDeleter.delete(file) + ); + + assertTrue(failure.getMessage().contains("not a directory")); + assertTrue(Files.exists(file)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestRuntimeInvalidationTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestRuntimeInvalidationTest.java new file mode 100644 index 000000000..f5f199c73 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestRuntimeInvalidationTest.java @@ -0,0 +1,51 @@ +package art.arcane.iris.core.datapack; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class DatapackIngestRuntimeInvalidationTest { + @Test + public void everyPublicMutationInvalidatesLoadedRuntimeBeforeLocking() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java")); + + assertInvalidatesBeforeLock(source, "public static Report ingest("); + assertInvalidatesBeforeLock(source, "public static ReapplyOutcome reapplyFromStaging("); + assertInvalidatesBeforeLock(source, "public static boolean remove("); + } + + @Test + public void noChangeChecksRestoreWhileChangedIngestRemainsRestartBound() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java")); + int ingest = source.indexOf("public static Report ingest("); + int unlock = source.indexOf("TRANSACTION_LOCK.unlock();", ingest); + int restore = source.indexOf( + "ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);", + unlock); + int changed = source.indexOf("else if (report.changed())", restore); + int restart = source.indexOf("ServerConfigurator.restart();", changed); + int restartRequired = source.indexOf("ServerConfigurator.requireDatapackRestart();", restart); + + assertTrue(ingest >= 0); + assertTrue(unlock > ingest); + assertTrue(restore > unlock); + assertTrue(changed > restore); + assertTrue(restart > changed); + assertTrue(restartRequired > restart); + } + + private void assertInvalidatesBeforeLock(String source, String signature) { + int method = source.indexOf(signature); + int invalidation = source.indexOf("ServerConfigurator.invalidateLoadedDatapackRuntime();", method); + int lock = source.indexOf("TRANSACTION_LOCK.lock();", method); + + assertTrue("Missing datapack mutation entry point " + signature, method >= 0); + assertTrue("Loaded runtime must be invalidated before " + signature, invalidation > method); + assertTrue("Loaded runtime invalidation must precede the transaction lock", invalidation < lock); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java index 0701340b0..5bbe3baf9 100644 --- a/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackIngestServiceTest.java @@ -37,6 +37,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; @@ -309,11 +310,11 @@ public class DatapackIngestServiceTest { StructureKey alphaSource = StructureKey.parse("example:alpha"); StructureKey zetaSource = StructureKey.parse("example:zeta"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - StructureWriteResult alphaWrite = writer.write( + StructureWriteResult alphaWrite = writer.writeManagedDatapack( importedBundle(alphaKey, alphaSource, "alpha"), StructureWriteMode.ADD_ONLY ); - StructureWriteResult zetaWrite = writer.write( + StructureWriteResult zetaWrite = writer.writeManagedDatapack( importedBundle(zetaKey, zetaSource, "zeta"), StructureWriteMode.ADD_ONLY ); @@ -360,7 +361,7 @@ public class DatapackIngestServiceTest { StructureKey targetKey = StructureKey.parse("iris:owned"); StructureKey sourceKey = StructureKey.parse("example:owned"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - StructureWriteResult write = writer.write( + StructureWriteResult write = writer.writeManagedDatapack( importedBundle(targetKey, sourceKey, "owned"), StructureWriteMode.ADD_ONLY ); @@ -393,6 +394,29 @@ public class DatapackIngestServiceTest { assertFalse(Files.readString(manifest.toPath(), StandardCharsets.UTF_8).contains("managed")); } + @Test + public void removalPreservesAnOrdinaryEditableBundleWithMatchingInventory() throws Exception { + File root = temporaryFolder.newFolder("ordinary-editable-removal-root"); + File editablePack = temporaryFolder.newFolder("ordinary-editable-removal-pack"); + StructureKey targetKey = StructureKey.parse("iris:owned"); + StructureKey sourceKey = StructureKey.parse("example:owned"); + StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); + assertEquals(StructureWriteResult.Status.ADDED, writer.write( + importedBundle(targetKey, sourceKey, "ordinary"), + StructureWriteMode.ADD_ONLY + ).status()); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + String targetId = editablePack.getAbsolutePath(); + entry.importedTargets.put(targetId, "revision"); + entry.importedBundles.put(targetId, Map.of(targetKey.value(), sourceKey.value())); + writeManifest(root, entry); + + assertTrue(DatapackIngestService.removeLocked(null, entry.id, root, List.of())); + + assertEquals("ordinary", Files.readString(new File(editablePack, "objects/owned.iob").toPath())); + assertTrue(Files.exists(writer.ownershipManifestPath(targetKey))); + } + @Test public void manifestEntryDoesNotAdoptUnmarkedStaging() throws Exception { File staging = datapackDirectory("legacy-staging"); @@ -1234,6 +1258,42 @@ public class DatapackIngestServiceTest { assertTrue(DatapackIngestService.freshInstallRequiresRestart(true, false)); } + @Test + public void installedStructureScopeReadsEffectiveStructureSetsWithoutManifestMigration() throws Exception { + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + File installed = datapackDirectory("effective-scope"); + Path set = new File(installed, + "data/nova_structures/worldgen/structure_set/illager_barracks.json").toPath(); + Files.createDirectories(set.getParent()); + Files.writeString(set, "{}", StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(installed, entry); + + DatapackIngestService.StructureScopeResources resources = + DatapackIngestService.scanInstalledStructureScope(installed, entry); + + assertEquals(entry.url, resources.source()); + assertEquals(List.of(), resources.structureKeys()); + assertEquals(List.of("nova_structures:illager_barracks"), resources.structureSetKeys()); + } + + @Test + public void installedStructureScopeDoesNotClaimOverridesStrippedFromEffectiveCopy() throws Exception { + DatapackIngestService.Entry entry = entry("managed-stripped", "v1", "1", "sha"); + File installed = datapackDirectory("effective-stripped-scope"); + Path retained = new File(installed, + "data/nova_structures/worldgen/structure_set/taverns.json").toPath(); + Files.createDirectories(retained.getParent()); + Files.writeString(retained, "{}", StandardCharsets.UTF_8); + Files.writeString(new File(installed, ".iris-overrides-stripped").toPath(), "", StandardCharsets.UTF_8); + DatapackIngestService.writeOwnership(installed, entry); + + DatapackIngestService.StructureScopeResources resources = + DatapackIngestService.scanInstalledStructureScope(installed, entry); + + assertEquals(List.of("nova_structures:taverns"), resources.structureSetKeys()); + assertFalse(resources.structureSetKeys().contains("minecraft:villages")); + } + @Test public void unchangedContentRefreshesRecoverableOwnershipMetadataWithoutRestart() throws Exception { DatapackIngestService.Entry current = new DatapackIngestService.Entry(); @@ -1307,6 +1367,75 @@ public class DatapackIngestServiceTest { assertFalse(entry.structuresImported); } + @Test + public void deterministicPartialImportIsRetainedWithoutRetryingSameRevision() { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha-two"); + entry.importedBundles.put("pack", Map.of("iris:test_castle", "test:castle")); + + DatapackIngestService.recordDeterministicImportAttempt(entry, "pack"); + + assertFalse(DatapackIngestService.importPending(entry, "pack")); + assertEquals(Map.of("iris:test_castle", "test:castle"), entry.importedBundles.get("pack")); + assertFalse(entry.importedTargets.containsKey("pack")); + } + + @Test + public void changedSourceRevisionRetriesDeterministicImportAttempt() { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha-two"); + DatapackIngestService.recordDeterministicImportAttempt(entry, "pack"); + + entry.sha1 = "sha-three"; + + assertTrue(DatapackIngestService.importPending(entry, "pack")); + } + + @Test + public void changedImporterFormatRetriesDeterministicImportAttempt() { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha-two"); + entry.importAttempts.put("pack", DatapackIngestService.importRevision(entry, 1)); + + assertTrue(DatapackIngestService.importPending(entry, "pack")); + } + + @Test + public void provenanceImportFormatRetriesVersionTwoEditableImports() { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha-two"); + entry.importedTargets.put("pack", DatapackIngestService.importRevision(entry, 2)); + + assertTrue(DatapackIngestService.importPending(entry, "pack")); + } + + @Test + public void newTargetRetriesDeterministicImportAttempt() { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha-two"); + DatapackIngestService.recordDeterministicImportAttempt(entry, "pack-one"); + + assertTrue(DatapackIngestService.importPending(entry, "pack-two")); + } + + @Test + public void retryableImportPreparationClearsPriorAttempt() { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha-two"); + DatapackIngestService.recordDeterministicImportAttempt(entry, "pack"); + + DatapackIngestService.prepareImportRecoveryInventory(entry, "pack"); + + assertTrue(DatapackIngestService.importPending(entry, "pack")); + assertFalse(entry.importAttempts.containsKey("pack")); + } + + @Test + public void successfulImportReplacesDeterministicAttempt() { + DatapackIngestService.Entry entry = entry("managed", "v2", "2", "sha-two"); + DatapackIngestService.recordDeterministicImportAttempt(entry, "pack"); + + DatapackIngestService.recordSuccessfulImport(entry, "pack"); + + assertFalse(DatapackIngestService.importPending(entry, "pack")); + assertEquals(DatapackIngestService.importRevision(entry), entry.importedTargets.get("pack")); + assertFalse(entry.importAttempts.containsKey("pack")); + } + @Test public void removingOneDatapackPreservesAStillClaimedEditableBundle() throws Exception { File root = temporaryFolder.newFolder("shared-removal-root"); @@ -1314,7 +1443,7 @@ public class DatapackIngestServiceTest { StructureKey targetKey = StructureKey.parse("iris:owned"); StructureKey sourceKey = StructureKey.parse("example:owned"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - assertEquals(StructureWriteResult.Status.ADDED, writer.write( + assertEquals(StructureWriteResult.Status.ADDED, writer.writeManagedDatapack( importedBundle(targetKey, sourceKey, "owned"), StructureWriteMode.ADD_ONLY ).status()); @@ -1350,7 +1479,7 @@ public class DatapackIngestServiceTest { StructureKey targetKey = StructureKey.parse("iris:foo_a_b"); StructureKey currentSource = StructureKey.parse("foo:a/b"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - assertEquals(StructureWriteResult.Status.ADDED, writer.write( + assertEquals(StructureWriteResult.Status.ADDED, writer.writeManagedDatapack( importedBundle(targetKey, currentSource, "current"), StructureWriteMode.ADD_ONLY ).status()); @@ -1385,11 +1514,11 @@ public class DatapackIngestServiceTest { StructureKey staleTarget = StructureKey.parse("iris:stale"); StructureKey staleSource = StructureKey.parse("old:stale"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - assertEquals(StructureWriteResult.Status.ADDED, writer.write( + assertEquals(StructureWriteResult.Status.ADDED, writer.writeManagedDatapack( importedBundle(collisionTarget, removedCollisionSource, "current"), StructureWriteMode.ADD_ONLY ).status()); - assertEquals(StructureWriteResult.Status.ADDED, writer.write( + assertEquals(StructureWriteResult.Status.ADDED, writer.writeManagedDatapack( importedBundle(staleTarget, staleSource, "stale"), StructureWriteMode.ADD_ONLY ).status()); @@ -1409,6 +1538,7 @@ public class DatapackIngestServiceTest { DatapackIngestService.Entry retained = entry("retained", "v1", "1", "sha-two"); retained.structureKeys = List.of("foo:a_b"); retained.importedTargets.put(targetId, "retained-revision"); + retained.importAttempts.put(targetId, "retained-attempt"); retained.importedBundles.put(targetId, Map.of(collisionTarget.value(), "foo:a_b")); retained.structuresImported = true; IrisData data = mock(IrisData.class); @@ -1426,6 +1556,7 @@ public class DatapackIngestServiceTest { assertEquals("modified", Files.readString( new File(editablePack, "objects/stale.iob").toPath(), StandardCharsets.UTF_8)); assertFalse(retained.importedTargets.containsKey(targetId)); + assertFalse(retained.importAttempts.containsKey(targetId)); assertFalse(retained.structuresImported); } @@ -1436,7 +1567,7 @@ public class DatapackIngestServiceTest { StructureKey targetKey = StructureKey.parse("iris:foo_a_b"); StructureKey currentSource = StructureKey.parse("foo:a/b"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - assertEquals(StructureWriteResult.Status.ADDED, writer.write( + assertEquals(StructureWriteResult.Status.ADDED, writer.writeManagedDatapack( importedBundle(targetKey, currentSource, "current"), StructureWriteMode.ADD_ONLY ).status()); @@ -1466,7 +1597,7 @@ public class DatapackIngestServiceTest { StructureKey targetKey = StructureKey.parse("iris:owned"); StructureKey actualSource = StructureKey.parse("other:owner"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - assertEquals(StructureWriteResult.Status.ADDED, writer.write( + assertEquals(StructureWriteResult.Status.ADDED, writer.writeManagedDatapack( importedBundle(targetKey, actualSource, "other"), StructureWriteMode.ADD_ONLY ).status()); @@ -1526,17 +1657,20 @@ public class DatapackIngestServiceTest { committed.versionId = "v1"; committed.structureKeys = new ArrayList<>(List.of("test:old")); committed.importedTargets.put("pack", "old"); + committed.importAttempts.put("pack", "old-attempt"); committed.importedBundles.put("pack", new HashMap<>(Map.of("iris:old", "test:old"))); DatapackIngestService.Entry candidate = DatapackIngestService.copyEntry(committed); candidate.versionId = "v2"; candidate.structureKeys.add("test:new"); candidate.importedTargets.put("pack", "new"); + candidate.importAttempts.put("pack", "new-attempt"); candidate.importedBundles.get("pack").put("iris:new", "test:new"); assertEquals("v1", committed.versionId); assertEquals(List.of("test:old"), committed.structureKeys); assertEquals("old", committed.importedTargets.get("pack")); + assertEquals("old-attempt", committed.importAttempts.get("pack")); assertEquals(Map.of("iris:old", "test:old"), committed.importedBundles.get("pack")); } @@ -1974,8 +2108,16 @@ public class DatapackIngestServiceTest { writeManifest(root, entry); File stagingRoot = new File(root, "staging"); - assertFalse(DatapackIngestService.reapplyStagingRoot( - root, stagingRoot, new KList<>(), false)); + DatapackIngestService.ReapplyOutcome outcome = + DatapackIngestService.reapplyStagingRootOutcome( + root, + stagingRoot, + new KList<>(), + false); + + assertEquals(DatapackIngestService.ReapplyStatus.FAILED, outcome.status()); + assertFalse(outcome.succeeded()); + assertTrue(outcome.failure().orElseThrow().getMessage().contains(stagingRoot.getPath())); } @Test @@ -2222,7 +2364,7 @@ public class DatapackIngestServiceTest { StructureKey targetKey = StructureKey.parse("iris:owned"); StructureKey sourceKey = StructureKey.parse("example:owned"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - StructureWriteResult write = writer.write( + StructureWriteResult write = writer.writeManagedDatapack( importedBundle(targetKey, sourceKey, "owned"), StructureWriteMode.ADD_ONLY ); @@ -2231,7 +2373,9 @@ public class DatapackIngestServiceTest { new StructureTransactionWriter.OwnedRemoval( targetKey, StructureSource.Kind.DATAPACK, - sourceKey + sourceKey, + Optional.empty(), + Optional.empty() ) )); StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); @@ -2266,7 +2410,7 @@ public class DatapackIngestServiceTest { StructureKey targetKey = StructureKey.parse("iris:owned"); StructureKey sourceKey = StructureKey.parse("example:owned"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - StructureWriteResult write = writer.write( + StructureWriteResult write = writer.writeManagedDatapack( importedBundle(targetKey, sourceKey, "owned"), StructureWriteMode.ADD_ONLY ); @@ -2275,7 +2419,9 @@ public class DatapackIngestServiceTest { new StructureTransactionWriter.OwnedRemoval( targetKey, StructureSource.Kind.DATAPACK, - sourceKey + sourceKey, + Optional.empty(), + Optional.empty() ) )); StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); @@ -2423,11 +2569,18 @@ public class DatapackIngestServiceTest { File metadata = new File(transactions, ".DS_Store"); Files.writeString(metadata.toPath(), "finder", StandardCharsets.UTF_8); - DatapackIngestService.recoverTransactions(root, List.of()); + assertTrue(DatapackIngestService.recoverTransactions(root, List.of())); assertFalse(metadata.exists()); } + @Test + public void recoveryReportsUnchangedWhenNoRecoveryArtifactsExist() throws Exception { + File root = temporaryFolder.newFolder("unchanged-recovery-root"); + + assertFalse(DatapackIngestService.recoverTransactions(root, List.of())); + } + @Test public void recoveryDeletesOnlyExactlyNamedPendingStagingScratch() throws Exception { File root = temporaryFolder.newFolder("pending-staging-recovery-root"); @@ -2572,6 +2725,31 @@ public class DatapackIngestServiceTest { .has(fixture.target().toPath().toAbsolutePath().normalize().toString())); } + @Test + public void reapplyOutcomeDistinguishesRepairFromAnUnchangedPass() throws Exception { + ReapplyFixture fixture = reapplyFixture("reapply-outcome"); + + DatapackIngestService.ReapplyOutcome repaired = + DatapackIngestService.reapplyStagingRootOutcome( + fixture.root(), + fixture.stagingRoot(), + fixture.worlds(), + false); + DatapackIngestService.ReapplyOutcome unchanged = + DatapackIngestService.reapplyStagingRootOutcome( + fixture.root(), + fixture.stagingRoot(), + fixture.worlds(), + false); + + assertEquals(DatapackIngestService.ReapplyStatus.REPAIRED, repaired.status()); + assertTrue(repaired.succeeded()); + assertTrue(repaired.changed()); + assertEquals(DatapackIngestService.ReapplyStatus.UNCHANGED, unchanged.status()); + assertTrue(unchanged.succeeded()); + assertFalse(unchanged.changed()); + } + @Test public void unchangedStagingAndTargetSkipContentHashingOnReapply() throws Exception { ReapplyFixture fixture = reapplyFixture("reapply-shortcircuit"); @@ -2926,7 +3104,7 @@ public class DatapackIngestServiceTest { StructureKey targetKey = StructureKey.parse("iris:owned"); StructureKey sourceKey = StructureKey.parse("example:owned"); StructureTransactionWriter writer = new StructureTransactionWriter(editablePack.toPath()); - StructureWriteResult write = writer.write( + StructureWriteResult write = writer.writeManagedDatapack( importedBundle(targetKey, sourceKey, "owned"), StructureWriteMode.ADD_ONLY ); @@ -2935,7 +3113,9 @@ public class DatapackIngestServiceTest { new StructureTransactionWriter.OwnedRemoval( targetKey, StructureSource.Kind.DATAPACK, - sourceKey + sourceKey, + Optional.empty(), + Optional.empty() ) )); StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); diff --git a/core/src/test/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndexTest.java b/core/src/test/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndexTest.java new file mode 100644 index 000000000..b4bba0d2f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/datapack/DatapackStructureScopeIndexTest.java @@ -0,0 +1,117 @@ +package art.arcane.iris.core.datapack; + +import org.junit.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class DatapackStructureScopeIndexTest { + private static final String TOWNS_URL = "https://modrinth.com/datapack/towns-and-towers"; + private static final String TAVERNS_URL = "https://modrinth.com/datapack/dungeons-and-taverns"; + + @Test + public void declaringDimensionAllowsOnlyItsManagedStructureSets() { + DatapackStructureScopeIndex index = index( + resource(TOWNS_URL, "towns_and_towers:villages"), + resource(TAVERNS_URL, "nova_structures:taverns")); + Set declared = index.declaredSources(List.of(TOWNS_URL)); + + assertTrue(index.allowsStructureSet("towns_and_towers:villages", declared)); + assertFalse(index.allowsStructureSet("nova_structures:taverns", declared)); + assertTrue(index.allowsStructureSet("minecraft:villages", declared)); + } + + @Test + public void vanillaAndUnrelatedIrisDimensionsExcludeManagedSets() { + DatapackStructureScopeIndex index = index( + resource(TAVERNS_URL, "nova_structures:illager_barracks")); + + assertFalse(index.allowsStructureSet("nova_structures:illager_barracks", Set.of())); + assertFalse(index.allowsStructureSet("nova_structures:illager_barracks", + index.declaredSources(List.of(TOWNS_URL)))); + assertTrue(index.allowsStructureSet("minecraft:pillager_outposts", Set.of())); + } + + @Test + public void oneSourceCanBeSharedByMultipleDimensions() { + DatapackStructureScopeIndex index = index( + resource(TAVERNS_URL, "nova_structures:taverns")); + + Set firstDimension = index.declaredSources(List.of(TAVERNS_URL)); + Set secondDimension = index.declaredSources(List.of(TAVERNS_URL)); + + assertTrue(index.allowsStructureSet("nova_structures:taverns", firstDimension)); + assertTrue(index.allowsStructureSet("nova_structures:taverns", secondDimension)); + } + + @Test + public void duplicateSetOwnershipFailsClosedUntilEverySourceIsDeclared() { + DatapackStructureScopeIndex index = index( + resource(TOWNS_URL, "shared:watchtowers"), + resource(TAVERNS_URL, "shared:watchtowers")); + + assertFalse(index.allowsStructureSet("shared:watchtowers", + index.declaredSources(List.of(TOWNS_URL)))); + assertTrue(index.allowsStructureSet("SHARED:WATCHTOWERS", + index.declaredSources(List.of(TOWNS_URL, TAVERNS_URL)))); + } + + @Test + public void structureDefinitionsUseTheSameOwnerBoundary() { + DatapackStructureScopeIndex index = DatapackStructureScopeIndex.create(List.of( + new DatapackIngestService.StructureScopeResources( + TAVERNS_URL, + List.of("nova_structures:illager_barracks", "minecraft:pillager_outpost"), + List.of("nova_structures:illager_barracks")))); + + assertFalse(index.allowsStructure("nova_structures:illager_barracks", Set.of())); + assertFalse(index.allowsStructure("minecraft:pillager_outpost", Set.of())); + assertTrue(index.allowsStructure("minecraft:village_plains", Set.of())); + assertTrue(index.allowsStructure("minecraft:pillager_outpost", + index.declaredSources(List.of(TAVERNS_URL)))); + } + + @Test + public void duplicateStructureOwnershipFailsClosedUntilEverySourceIsDeclared() { + DatapackStructureScopeIndex index = DatapackStructureScopeIndex.create(List.of( + new DatapackIngestService.StructureScopeResources( + TOWNS_URL, List.of("shared:watchtower"), List.of()), + new DatapackIngestService.StructureScopeResources( + TAVERNS_URL, List.of("shared:watchtower"), List.of()))); + + assertFalse(index.allowsStructure("shared:watchtower", + index.declaredSources(List.of(TOWNS_URL)))); + assertTrue(index.allowsStructure("shared:watchtower", + index.declaredSources(List.of(TOWNS_URL, TAVERNS_URL)))); + } + + @Test + public void emptyResourcesDoNotClaimStructureSets() { + DatapackStructureScopeIndex index = DatapackStructureScopeIndex.create(List.of( + new DatapackIngestService.StructureScopeResources(null, List.of(), List.of()), + new DatapackIngestService.StructureScopeResources(TOWNS_URL, null, null))); + + assertTrue(index.allowsStructureSet("claimed:structure_set", Set.of())); + assertTrue(index.declaredSources(null).isEmpty()); + assertTrue(index.managedStructureSetCount() == 0); + } + + private static DatapackStructureScopeIndex index( + DatapackIngestService.StructureScopeResources... resources + ) { + return DatapackStructureScopeIndex.create(List.of(resources)); + } + + private static DatapackIngestService.StructureScopeResources resource( + String source, + String... structureSetKeys + ) { + return new DatapackIngestService.StructureScopeResources( + source, + List.of(), + List.of(structureSetKeys)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java index 1bc9099b3..d2db21662 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleUnloadAsyncTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.lifecycle; +import art.arcane.volmlib.util.bukkit.WorldIdentity; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.junit.Test; @@ -7,10 +8,12 @@ import org.junit.Test; import java.lang.reflect.Method; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; import java.util.function.Consumer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -101,6 +104,26 @@ public class WorldLifecycleUnloadAsyncTest { assertEquals("fallback", service.backendNameForWorld(worldKey)); } + @Test + public void servicePublishesRawUnloadCompletionForTheWorldEventConsumer() { + ControlledBackend remembered = new ControlledBackend("remembered"); + ControlledBackend fallback = new ControlledBackend("fallback"); + WorldLifecycleService service = service(remembered, fallback); + NamespacedKey worldKey = new NamespacedKey("iris", "raw_boundary"); + World world = world(worldKey); + service.rememberBackend(worldKey, remembered.backendName()); + + CompletableFuture result = service.unloadAsync(world, false); + CompletionStage boundary = WorldUnloadBoundaryRegistry.claim(WorldIdentity.serialize(world)); + + assertNotNull(boundary); + assertFalse(boundary.toCompletableFuture().isDone()); + assertFalse(result.isDone()); + remembered.complete(true); + assertTrue(boundary.toCompletableFuture().join()); + assertTrue(result.join()); + } + @Test public void serviceRetainsRememberedBackendAfterFalseCompletion() { ControlledBackend remembered = new ControlledBackend("remembered"); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistryTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistryTest.java new file mode 100644 index 000000000..28febbdd7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldUnloadBoundaryRegistryTest.java @@ -0,0 +1,67 @@ +package art.arcane.iris.core.lifecycle; + +import org.junit.Test; + +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class WorldUnloadBoundaryRegistryTest { + @Test + public void claimedBoundaryCompletesOnlyWhenRawUnloadCompletesTrue() { + String worldIdentity = "iris:boundary_true"; + WorldUnloadBoundaryRegistry.Boundary boundary = WorldUnloadBoundaryRegistry.begin(worldIdentity); + CompletionStage claimed = WorldUnloadBoundaryRegistry.claim(worldIdentity); + + assertFalse(claimed.toCompletableFuture().isDone()); + WorldUnloadBoundaryRegistry.complete(boundary, true, null); + assertTrue(claimed.toCompletableFuture().join()); + assertNull(WorldUnloadBoundaryRegistry.claim(worldIdentity)); + } + + @Test + public void rawFalseCompletionRemainsFalse() { + String worldIdentity = "iris:boundary_false"; + WorldUnloadBoundaryRegistry.Boundary boundary = WorldUnloadBoundaryRegistry.begin(worldIdentity); + CompletionStage claimed = WorldUnloadBoundaryRegistry.claim(worldIdentity); + + WorldUnloadBoundaryRegistry.complete(boundary, false, null); + + assertFalse(claimed.toCompletableFuture().join()); + } + + @Test + public void rawFailureCompletesTheClaimedBoundaryExceptionally() { + String worldIdentity = "iris:boundary_failure"; + WorldUnloadBoundaryRegistry.Boundary boundary = WorldUnloadBoundaryRegistry.begin(worldIdentity); + CompletionStage claimed = WorldUnloadBoundaryRegistry.claim(worldIdentity); + + WorldUnloadBoundaryRegistry.complete( + boundary, + null, + new CompletionException(new IllegalStateException("raw unload failed"))); + + CompletionException failure = assertThrows( + CompletionException.class, + () -> claimed.toCompletableFuture().join()); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertTrue("raw unload failed".equals(failure.getCause().getMessage())); + } + + @Test + public void duplicateActiveBoundaryIsRejected() { + String worldIdentity = "iris:boundary_duplicate"; + WorldUnloadBoundaryRegistry.Boundary boundary = WorldUnloadBoundaryRegistry.begin(worldIdentity); + try { + assertThrows( + IllegalStateException.class, + () -> WorldUnloadBoundaryRegistry.begin(worldIdentity)); + } finally { + WorldUnloadBoundaryRegistry.complete(boundary, false, null); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/loader/IrisDataDatapackCompilerCacheTest.java b/core/src/test/java/art/arcane/iris/core/loader/IrisDataDatapackCompilerCacheTest.java index f07966444..3fefbe8c6 100644 --- a/core/src/test/java/art/arcane/iris/core/loader/IrisDataDatapackCompilerCacheTest.java +++ b/core/src/test/java/art/arcane/iris/core/loader/IrisDataDatapackCompilerCacheTest.java @@ -12,7 +12,9 @@ import org.junit.rules.TemporaryFolder; import java.io.File; import java.util.concurrent.ExecutorService; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; public class IrisDataDatapackCompilerCacheTest { @Rule @@ -49,6 +51,16 @@ public class IrisDataDatapackCompilerCacheTest { assertSame(runtimeData, IrisData.getLoaded(packDirectory).orElseThrow()); } + @Test + public void structureInvalidationMatchesNormalizedLoadedPackPaths() throws Exception { + File packDirectory = temporaryFolder.newFolder("invalidate-pack"); + runtimeData = IrisData.get(new File(packDirectory, ".")); + + assertTrue(IrisData.invalidateLoadedStructureResources(packDirectory)); + assertFalse(IrisData.invalidateLoadedStructureResources( + temporaryFolder.newFolder("unloaded-pack"))); + } + private static final class NoOpPreservationRegistry implements PreservationRegistry { @Override public void register(Thread thread) { diff --git a/core/src/test/java/art/arcane/iris/core/pack/StructureGraphPackValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/StructureGraphPackValidatorTest.java index e91f30bf4..b0f1b0659 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/StructureGraphPackValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/StructureGraphPackValidatorTest.java @@ -95,6 +95,32 @@ public class StructureGraphPackValidatorTest { message -> message.contains("does not produce a complete deterministic assembly"))); } + @Test + public void branchTerminationPolicyAcceptsTheSameOptionalPartialGraph() throws Exception { + Path pack = temporaryFolder.newFolder("terminating-pack").toPath(); + write(pack, "structures/castle.json", + "{\"startPool\":\"castle/start\",\"maxDepth\":2," + + "\"branchFailurePolicy\":\"TERMINATE_BRANCH\"}"); + write(pack, "jigsaw-pools/castle/start.json", + "{\"pieces\":[{\"piece\":\"castle/start\"}]}"); + write(pack, "jigsaw-pools/castle/target.json", + "{\"pieces\":[{\"piece\":\"castle/target\"}]}"); + write(pack, "jigsaw-pieces/castle/start.json", + "{\"object\":\"castle/start\",\"connectors\":[{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"pool\":\"castle/target\"," + + "\"name\":\"source\",\"targetName\":\"door\"}]}"); + write(pack, "jigsaw-pieces/castle/target.json", + "{\"object\":\"castle/target\",\"rotatable\":false,\"connectors\":[{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"NORTH_NEGATIVE_Z\",\"pool\":\"castle/target\"," + + "\"name\":\"door\",\"targetName\":\"unused\"}]}"); + writeObjectHeader(pack.resolve("objects/castle/start.iob"), 1, 1, 1); + writeObjectHeader(pack.resolve("objects/castle/target.iob"), 1, 1, 1); + + StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); + + assertTrue(validation.errors().toString(), validation.errors().isEmpty()); + } + @Test public void inactiveLibraryGraphDoesNotEnterTheRuntimeGate() throws Exception { Path pack = temporaryFolder.newFolder("pack").toPath(); @@ -260,7 +286,7 @@ public class StructureGraphPackValidatorTest { } @Test - public void reportsSampledRuntimeGeometryFailureWithoutEscapingValidation() throws Exception { + public void reportsAuthoritativeGeometryFailureWithoutEscapingValidation() throws Exception { Path pack = temporaryFolder.newFolder("pack").toPath(); String source = "{\"position\":{\"x\":1,\"y\":1,\"z\":0}," + "\"direction\":\"NORTH_NEGATIVE_Z\",\"top\":\"UP_POSITIVE_Y\"," @@ -287,7 +313,7 @@ public class StructureGraphPackValidatorTest { StructureGraphPackValidator.Validation validation = StructureGraphPackValidator.validate(pack); assertTrue(validation.errors().toString(), validation.errors().stream().anyMatch( - message -> message.contains("fails sampled collision- and radius-aware assembly") + message -> message.contains("does not produce a complete deterministic assembly") && message.contains("seed 0"))); } diff --git a/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureLimitsTest.java b/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureLimitsTest.java new file mode 100644 index 000000000..a1d8def82 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureLimitsTest.java @@ -0,0 +1,59 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +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 StructurePackageClosureLimitsTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void boundedCollectionStopsAtResourceLimit() throws Exception { + Path root = temporaryFolder.newFolder("resources").toPath(); + write(root, "structures/village.json", "{\"startPool\":\"village/start\"}"); + write(root, "jigsaw-pools/village/start.json", + "{\"pieces\":[{\"piece\":\"village/house\"}]}"); + write(root, "jigsaw-pieces/village/house.json", + "{\"object\":\"village/house\",\"connectors\":[]}"); + write(root, "objects/village/house.iob", "object"); + + StructurePackageClosure closure = StructurePackageClosure.collect( + root.toFile(), + List.of("village"), + new StructurePackageClosure.Limits(2, 1024)); + + assertFalse(closure.isValid()); + assertTrue(closure.errors().toString(), closure.errors().stream().anyMatch(error -> + error.contains("exceeds 2 resources"))); + } + + @Test + public void boundedCollectionRejectsOversizedJsonBeforeParsing() throws Exception { + Path root = temporaryFolder.newFolder("json").toPath(); + write(root, "structures/village.json", "{\"startPool\":\"village/start\"}"); + + StructurePackageClosure closure = StructurePackageClosure.collect( + root.toFile(), + List.of("village"), + new StructurePackageClosure.Limits(100, 8)); + + assertFalse(closure.isValid()); + assertTrue(closure.errors().toString(), closure.errors().stream().anyMatch(error -> + error.contains("exceeds 8 bytes"))); + } + + private void write(Path root, String relativePath, String content) throws Exception { + Path target = root.resolve(relativePath); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureTest.java b/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureTest.java index c1b4ca8a5..36e730061 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/StructurePackageClosureTest.java @@ -158,7 +158,8 @@ public class StructurePackageClosureTest { File pack = temporaryFolder.newFolder("pack"); write(pack, "structures/broken.json", "{\"startPool\":\"broken/start\"}"); write(pack, "jigsaw-pools/broken/start.json", - "{\"pieces\":[{},{\"empty\":\"true\"},{\"empty\":true,\"piece\":\"broken/piece\"}]}"); + "{\"pieces\":[{},{\"empty\":\"true\"},{\"empty\":true,\"piece\":\"broken/piece\"}," + + "{\"empty\":true,\"piece\":7}]}"); StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("broken")); @@ -168,14 +169,18 @@ public class StructurePackageClosureTest { assertTrue(closure.errors().toString(), closure.errors().contains( "Piece entry 1 in jigsaw pool 'broken/start' requires boolean field 'empty'.")); assertTrue(closure.errors().toString(), closure.errors().contains( - "Empty piece entry 2 in jigsaw pool 'broken/start' cannot define field 'piece'.")); + "Empty piece entry 2 in jigsaw pool 'broken/start' cannot define non-empty field 'piece'.")); + assertTrue(closure.errors().toString(), closure.errors().contains( + "Empty piece entry 3 in jigsaw pool 'broken/start' requires string field 'piece' when defined.")); } @Test public void acceptsExplicitEmptyPoolEntry() throws Exception { File pack = temporaryFolder.newFolder("pack"); write(pack, "structures/empty.json", "{\"startPool\":\"empty/start\"}"); - write(pack, "jigsaw-pools/empty/start.json", "{\"pieces\":[{\"empty\":true,\"weight\":3}]}"); + write(pack, "jigsaw-pools/empty/start.json", + "{\"pieces\":[{\"empty\":true,\"weight\":3},{\"empty\":true,\"piece\":\"\"}," + + "{\"empty\":true,\"piece\":\" \"}]}"); StructurePackageClosure closure = StructurePackageClosure.collect(pack, List.of("empty")); diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java index 67e4b2912..fd4c14809 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java @@ -252,6 +252,17 @@ public class SchemaBuilderParityTest { assertEquals("boolean", properties.getJSONObject("requireObjectSurfaceSupport").getString("type")); } + @Test + public void jigsawStructureSchemaExposesBranchFailurePolicy() { + JSONObject schema = new SchemaBuilder(IrisStructure.class, structureSchemaData()).construct(); + JSONObject policy = rootProperties(schema).getJSONObject("branchFailurePolicy"); + String policyDefinition = definitionKey(policy); + + assertEquals(List.of("FAIL_ASSEMBLY", "TERMINATE_BRANCH"), + oneOfValues(schema.getJSONObject("definitions"), policyDefinition)); + assertTrue(policy.getString("description").contains("Default Value is FAIL_ASSEMBLY")); + } + @Test public void structurePolicyArraysUseLiveRegistryKeys() { JSONObject schema = new SchemaBuilder(StructureArrayModel.class, (IrisData) null).construct(); diff --git a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorCloseSequenceTest.java b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorCloseSequenceTest.java index 7f7af9c6a..be252c212 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorCloseSequenceTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorCloseSequenceTest.java @@ -7,9 +7,12 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class StudioOpenCoordinatorCloseSequenceTest { @@ -100,6 +103,129 @@ public class StudioOpenCoordinatorCloseSequenceTest { assertEquals(List.of("evacuate", "unload"), phases); } + @Test + public void pendingEntryLoadDefersCleanupUntilSettlement() { + CompletableFuture entryLoad = new CompletableFuture<>(); + AtomicInteger cleanups = new AtomicInteger(0); + + CompletableFuture deferred = StudioOpenCoordinator.deferCleanupUntilEntrySettlement( + entryLoad, + () -> { + cleanups.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }); + + assertEquals(0, cleanups.get()); + assertFalse(deferred.isDone()); + entryLoad.complete(null); + deferred.join(); + assertEquals(1, cleanups.get()); + entryLoad.complete(null); + assertEquals(1, cleanups.get()); + } + + @Test + public void onlyAnUnsettledEntryLoadRequiresDeferredCleanup() { + CompletableFuture pending = new CompletableFuture<>(); + CompletableFuture failed = CompletableFuture.failedFuture( + new IllegalStateException("entry failed")); + + assertTrue(StudioOpenCoordinator.requiresDeferredEntryCleanup(pending)); + assertFalse(StudioOpenCoordinator.requiresDeferredEntryCleanup( + CompletableFuture.completedFuture(null))); + assertFalse(StudioOpenCoordinator.requiresDeferredEntryCleanup(failed)); + assertFalse(StudioOpenCoordinator.requiresDeferredEntryCleanup(null)); + } + + @Test + public void retryRemainsFencedUntilDeferredCleanupCompletes() { + StudioOpenCoordinator.EntryLoadRegistry registry = + new StudioOpenCoordinator.EntryLoadRegistry(); + CompletableFuture entryLoad = new CompletableFuture<>(); + CompletableFuture cleanup = new CompletableFuture<>(); + registry.register("iris-entry-timeout", entryLoad); + CompletableFuture deferred = StudioOpenCoordinator.deferCleanupUntilEntrySettlement( + entryLoad, + () -> cleanup); + registry.releaseAfterSuccessfulCompletion( + "iris-entry-timeout", + entryLoad, + deferred); + + entryLoad.complete(null); + assertFalse(deferred.isDone()); + assertTrue(registry.isFenced("iris-entry-timeout")); + try { + registry.rejectNewOpen(); + fail("Expected the retry to be rejected"); + } catch (IllegalStateException exception) { + assertTrue(exception.getMessage().contains("iris-entry-timeout")); + } + + cleanup.complete(null); + deferred.join(); + assertFalse(registry.isFenced("iris-entry-timeout")); + registry.rejectNewOpen(); + } + + @Test + public void exceptionalEntrySettlementStillTriggersCleanupOnce() { + CompletableFuture entryLoad = new CompletableFuture<>(); + AtomicInteger cleanups = new AtomicInteger(0); + + CompletableFuture deferred = StudioOpenCoordinator.deferCleanupUntilEntrySettlement( + entryLoad, + () -> { + cleanups.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }); + + entryLoad.completeExceptionally(new IllegalStateException("entry failed")); + deferred.join(); + assertEquals(1, cleanups.get()); + } + + @Test + public void nonSettlingEntryLoadNeverStartsCleanup() { + CompletableFuture entryLoad = new CompletableFuture<>(); + AtomicInteger cleanups = new AtomicInteger(0); + + CompletableFuture deferred = StudioOpenCoordinator.deferCleanupUntilEntrySettlement( + entryLoad, + () -> { + cleanups.incrementAndGet(); + return CompletableFuture.completedFuture(null); + }); + + assertFalse(deferred.isDone()); + assertEquals(0, cleanups.get()); + } + + @Test + public void deferredCleanupFailureIsPreserved() { + StudioOpenCoordinator.EntryLoadRegistry registry = + new StudioOpenCoordinator.EntryLoadRegistry(); + CompletableFuture entryLoad = new CompletableFuture<>(); + IllegalStateException cleanupFailure = new IllegalStateException("cleanup failed"); + registry.register("iris-cleanup-failure", entryLoad); + CompletableFuture deferred = StudioOpenCoordinator.deferCleanupUntilEntrySettlement( + entryLoad, + () -> CompletableFuture.failedFuture(cleanupFailure)); + registry.releaseAfterSuccessfulCompletion( + "iris-cleanup-failure", + entryLoad, + deferred); + + entryLoad.complete(null); + try { + deferred.join(); + fail("Expected deferred cleanup failure"); + } catch (CompletionException exception) { + assertSame(cleanupFailure, exception.getCause()); + } + assertTrue(registry.isFenced("iris-cleanup-failure")); + } + private static CompletableFuture phase(List phases, String phase) { phases.add(phase); return CompletableFuture.completedFuture(null); diff --git a/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java new file mode 100644 index 000000000..57a8c3983 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/StudioOpenCoordinatorOpenKindTest.java @@ -0,0 +1,134 @@ +package art.arcane.iris.core.runtime; + +import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.tools.IrisCreator; +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class StudioOpenCoordinatorOpenKindTest { + @Test + public void standardStudioOwnsWorkspaceAndEntryTeleport() { + StudioOpenCoordinator.StudioOpenKind kind = StudioOpenCoordinator.StudioOpenKind.STANDARD; + + assertTrue(kind.openWorkspace()); + assertTrue(kind.teleportThroughStandardEntry()); + assertTrue(kind.prepareGeneratorState()); + assertEquals( + IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY, + kind.datapackPreparation()); + } + + @Test + public void bothStudioKindsReuseOnlyAnUnchangedLoadedRuntime() { + for (StudioOpenCoordinator.StudioOpenKind kind : StudioOpenCoordinator.StudioOpenKind.values()) { + assertEquals( + IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY, + kind.datapackPreparation()); + } + } + + @Test + public void jigsawStudioLeavesWorkspaceClosedAndUsesItsDestinationTeleport() { + StudioOpenCoordinator.StudioOpenKind kind = StudioOpenCoordinator.StudioOpenKind.JIGSAW; + + assertFalse(kind.openWorkspace()); + assertFalse(kind.teleportThroughStandardEntry()); + assertFalse(kind.prepareGeneratorState()); + assertEquals( + IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY, + kind.datapackPreparation()); + } + + @Test + public void studioRequestRetainsExplicitOpenKind() { + IrisProject project = new IrisProject(new File("overworld")); + StudioOpenCoordinator.StudioOpenRequest request = + StudioOpenCoordinator.StudioOpenRequest.studioProject( + project, + null, + 1337L, + StudioOpenCoordinator.StudioOpenKind.JIGSAW, + null, + null); + + assertEquals(StudioOpenCoordinator.StudioOpenKind.JIGSAW, request.openKind()); + } + + @Test + public void studioTimingSeparatesOrderedLifecyclePhases() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")); + int previous = -1; + for (String phase : List.of( + "resolve_dimension_and_cleanup", + "create_world_total", + "apply_world_rules", + "prepare_generator", + "resolve_entry_anchor", + "load_entry_chunk", + "resolve_safe_entry", + "teleport_standard_entry", + "finalize_open")) { + int current = source.indexOf("\"" + phase + "\"", previous + 1); + assertTrue("Missing or out-of-order Studio timing phase " + phase, current > previous); + previous = current; + } + assertTrue(source.contains("long openStart = System.nanoTime();")); + } + + @Test + public void structureStateActivatesAfterStandardTeleportBeforeFinalization() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")); + int entryReady = source.indexOf( + "entryLoadFuture.get(STUDIO_ENTRY_LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS)"); + 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); + int futureComplete = source.indexOf( + "future.complete(new StudioOpenResult(world, safeEntry))", finalizeOpen); + int methodStart = source.indexOf( + "private void endStudioEntryBootstrap(World world, PlatformChunkGenerator provider)"); + int methodEnd = source.indexOf("private void abandonStudioEntryBootstrap", methodStart); + String method = source.substring(methodStart, methodEnd); + 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()"); + + assertTrue(entryReady >= 0); + assertTrue(teleport > entryReady); + assertTrue(completionCall > teleport); + assertTrue(finalizeOpen > completionCall); + assertTrue(futureComplete > finalizeOpen); + assertTrue(scheduled >= 0); + assertTrue(claim > scheduled); + assertTrue(activation > claim); + assertTrue(gateRelease > activation); + assertTrue(source.contains("abandonStudioEntryBootstrap(world, e);")); + } + + @Test + public void failedOpenMarshalsRetainedStateAbandonmentToTheServerScheduler() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")); + int methodStart = source.indexOf( + "private void abandonStudioEntryBootstrap(World world, Throwable failure)"); + int methodEnd = source.indexOf("private void deferFailedOpenCleanup", methodStart); + String method = source.substring(methodStart, methodEnd); + + assertTrue(method.contains("if (J.isPrimaryThread())")); + assertTrue(method.contains("CompletableFuture abandonment = J.sfut(")); + assertTrue(method.contains("abandonment.get(STUDIO_STRUCTURE_ACTIVATION_TIMEOUT_SECONDS")); + } +} 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 bde4e18c2..893515aa9 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 @@ -2,9 +2,12 @@ package art.arcane.iris.core.runtime; import org.junit.Test; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class StudioOpenCoordinatorSpawnStuckRegressionTest { @Test @@ -27,4 +30,30 @@ public class StudioOpenCoordinatorSpawnStuckRegressionTest { .anyMatch(m -> m.getName().equals("waitForEntryChunk")); assertFalse("waitForEntryChunk retry loop must be removed", found); } + + @Test + public void paperEntryChunkLoadIsAsyncBeforeTheRetentionTicket() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")); + 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); + } + + @Test + public void foliaRetainsItsNonBlockingTicketBootstrapPath() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java")); + int foliaBranch = source.indexOf("if (!J.isFolia())"); + int retentionSchedule = source.indexOf( + "return scheduleEntryChunkRetention(world, chunkX, chunkZ);", + foliaBranch); + + assertTrue(foliaBranch >= 0); + assertTrue(retentionSchedule > foliaBranch); + } } diff --git a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackendUrgencyTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackendUrgencyTest.java new file mode 100644 index 000000000..a2005b106 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlBackendUrgencyTest.java @@ -0,0 +1,80 @@ +package art.arcane.iris.core.runtime; + +import org.bukkit.Chunk; +import org.bukkit.World; +import org.junit.Test; + +import java.util.OptionalLong; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorldRuntimeControlBackendUrgencyTest { + @Test + public void urgentRequestsUsePaperUrgencyFlag() { + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + CompletableFuture expected = CompletableFuture.completedFuture(chunk); + when(world.getChunkAtAsync(4, -3, true, true)).thenReturn(expected); + RecordingBackend backend = new RecordingBackend(); + + CompletableFuture actual = backend.requestChunkAsync(world, 4, -3, true, true); + + assertSame(expected, actual); + assertEquals(0, backend.regularRequests); + verify(world).getChunkAtAsync(4, -3, true, true); + } + + @Test + public void backgroundRequestsRemainNonUrgent() { + World world = mock(World.class); + RecordingBackend backend = new RecordingBackend(); + + backend.requestChunkAsync(world, 4, -3, true, false); + + assertEquals(1, backend.regularRequests); + } + + private static final class RecordingBackend implements WorldRuntimeControlBackend { + private int regularRequests; + + @Override + public String backendName() { + return "recording"; + } + + @Override + public String describeCapabilities() { + return "recording"; + } + + @Override + public OptionalLong readDayTime(World world) { + return OptionalLong.empty(); + } + + @Override + public boolean writeDayTime(World world, long dayTime) { + return false; + } + + @Override + public void syncTime(World world) { + } + + @Override + public CompletableFuture requestChunkAsync( + World world, + int chunkX, + int chunkZ, + boolean generate + ) { + regularRequests++; + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyTest.java new file mode 100644 index 000000000..9bfa61c4e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawPlanarTopologyTest.java @@ -0,0 +1,102 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.engine.object.IrisPosition; +import org.junit.Test; + +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class JigsawPlanarTopologyTest { + @Test + public void representsEveryFourDirectionMaskExactlyOnce() { + Set masks = new HashSet<>(); + Map kindCounts = new EnumMap<>(JigsawPlanarTopologyKind.class); + + for (JigsawPlanarTopology topology : JigsawPlanarTopology.values()) { + assertTrue(masks.add(topology.mask())); + assertSame(topology, JigsawPlanarTopology.fromMask(topology.mask())); + kindCounts.merge(topology.kind(), 1, Integer::sum); + } + + assertEquals(16, masks.size()); + assertEquals(Integer.valueOf(1), kindCounts.get(JigsawPlanarTopologyKind.BLANK)); + assertEquals(Integer.valueOf(4), kindCounts.get(JigsawPlanarTopologyKind.END)); + assertEquals(Integer.valueOf(2), kindCounts.get(JigsawPlanarTopologyKind.STRAIGHT)); + assertEquals(Integer.valueOf(4), kindCounts.get(JigsawPlanarTopologyKind.CORNER)); + assertEquals(Integer.valueOf(4), kindCounts.get(JigsawPlanarTopologyKind.TEE)); + assertEquals(Integer.valueOf(1), kindCounts.get(JigsawPlanarTopologyKind.CROSS)); + } + + @Test + public void rotatesMasksAndDirectionsClockwise() { + assertSame( + JigsawPlanarTopology.EAST_SOUTH_CORNER, + JigsawPlanarTopology.NORTH_EAST_CORNER.rotateClockwise(1) + ); + assertSame( + JigsawPlanarTopology.SOUTH_WEST_CORNER, + JigsawPlanarTopology.NORTH_EAST_CORNER.rotateClockwise(2) + ); + assertSame( + JigsawPlanarTopology.NORTH_WEST_CORNER, + JigsawPlanarTopology.NORTH_EAST_CORNER.rotateClockwise(-1) + ); + assertSame(JigsawPlanarDirection.WEST, JigsawPlanarDirection.NORTH.rotateClockwise(-1)); + assertSame(JigsawPlanarDirection.SOUTH, JigsawPlanarDirection.NORTH.opposite()); + } + + @Test + public void collapsesAllMasksIntoCanonicalWorkcellArchetypes() { + int[] canonicalMasks = {0, 1, 1, 3, 1, 5, 3, 11, 1, 3, 5, 11, 3, 11, 11, 15}; + int[] sourceToCanonicalTurns = {0, 0, 3, 0, 2, 0, 3, 3, 1, 1, 1, 0, 2, 1, 2, 0}; + + for (int mask = 0; mask < 16; mask++) { + JigsawPlanarTopology source = JigsawPlanarTopology.fromMask(mask); + JigsawPlanarArchetype archetype = JigsawPlanarArchetype.fromTopology(source); + int sourceToCanonical = archetype.sourceToCanonicalQuarterTurns(source); + + assertEquals(canonicalMasks[mask], archetype.canonicalTopology().mask()); + assertEquals(sourceToCanonicalTurns[mask], sourceToCanonical); + assertSame(archetype.canonicalTopology(), source.rotateClockwise(sourceToCanonical)); + assertSame(source, archetype.canonicalTopology().rotateClockwise( + archetype.canonicalToSourceQuarterTurns(source))); + } + } + + @Test + public void inverseDisplayPositionRestoresRotatedSourceConnectorCoordinates() { + JigsawStudioVariant east = new JigsawStudioVariant( + "village/east", + "village/east", + "", + Optional.of(new JigsawStudioCellDimensions(5, 3, 3)), + JigsawStudioMode.PLANAR_JIGSAW, + Optional.of(JigsawPlanarTopology.EAST_END), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + JigsawStudioCellDimensions sourceDimensions = new JigsawStudioCellDimensions(3, 3, 5); + IrisPosition source = new IrisPosition(2, 1, 1); + + IrisPosition canonical = east.sourceToCanonicalPosition(source, sourceDimensions); + IrisPosition restored = east.canonicalToSourcePosition(canonical, sourceDimensions); + + assertEquals(new JigsawStudioCellDimensions(5, 3, 3), east.canonicalDimensions(sourceDimensions)); + assertEquals(1, canonical.getX()); + assertEquals(1, canonical.getY()); + assertEquals(0, canonical.getZ()); + assertEquals(source.getX(), restored.getX()); + assertEquals(source.getY(), restored.getY()); + assertEquals(source.getZ(), restored.getZ()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivationTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivationTest.java new file mode 100644 index 000000000..b8e2076ac --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioActivationTest.java @@ -0,0 +1,233 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.loader.IrisData; +import org.junit.After; +import org.junit.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +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.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class JigsawStudioActivationTest { + private static final UUID OWNER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + + @After + public void clearActivation() { + JigsawStudioActivation.finishOpen(OWNER); + JigsawStudioActivation.deactivate("overworld"); + JigsawStudioActivation.deactivate("nether"); + } + + @Test + public void storesRequestAndSessionByNormalizedPackKey() { + IrisData source = mock(IrisData.class); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(11, 7, 11); + JigsawStudioActivation.Request request = JigsawStudioActivation.activate( + "OverWorld", + "village/plains", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, + dimensions, + source + ); + + assertTrue(JigsawStudioActivation.isActive("overworld")); + assertSame(request, JigsawStudioActivation.getRequest("OVERWORLD")); + assertSame(source, request.source()); + assertEquals(JigsawStudioMode.PLANAR_JIGSAW, request.mode()); + assertEquals(JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, request.compatibilityTarget()); + assertEquals(dimensions, request.cellDimensions()); + assertNotNull(JigsawStudioActivation.getSession("overworld")); + assertEquals(6, JigsawStudioActivation.getLayout("overworld").bays().size()); + } + + @Test + public void replacementGetsANewRequestScopedSession() { + IrisData source = mock(IrisData.class); + JigsawStudioActivation.Request first = JigsawStudioActivation.activate( + "overworld", + "village", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(7, 5, 7), + source + ); + JigsawStudioActivation.Request second = JigsawStudioActivation.activate( + "overworld", + "stronghold", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(13, 9, 13), + source + ); + + assertNotEquals(first.requestId(), second.requestId()); + assertEquals(second.requestId(), JigsawStudioActivation.getSession("overworld").sessionId()); + assertEquals(JigsawStudioMode.SPATIAL_JIGSAW, JigsawStudioActivation.getLayout("overworld").mode()); + assertEquals(1, JigsawStudioActivation.getLayout("overworld").bays().size()); + + JigsawStudioActivation.deactivate("overworld"); + assertFalse(JigsawStudioActivation.isActive("overworld")); + } + + @Test + public void staleRequestCannotDeactivateItsReplacement() { + IrisData source = mock(IrisData.class); + JigsawStudioActivation.Request first = JigsawStudioActivation.activate( + "overworld", + "village", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16), + source + ); + JigsawStudioActivation.Request second = JigsawStudioActivation.activate( + "overworld", + "stronghold", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16), + source + ); + + assertFalse(JigsawStudioActivation.deactivate("overworld", first.requestId())); + assertSame(second, JigsawStudioActivation.getRequest("overworld")); + assertTrue(JigsawStudioActivation.deactivate("overworld", second.requestId())); + assertFalse(JigsawStudioActivation.isActive("overworld")); + } + + @Test + public void acceptsExistingGraphLayoutAsCleanInitialState() { + IrisData source = mock(IrisData.class); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(9, 6, 9); + JigsawStudioVariant variant = new JigsawStudioVariant( + "stronghold/hall", + "stronghold/hall", + "", + Optional.of(dimensions), + JigsawStudioMode.SPATIAL_JIGSAW, + Optional.empty(), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + JigsawStudioLayout initialLayout = JigsawStudioLayout.create( + JigsawStudioMode.SPATIAL_JIGSAW, + dimensions, + new JigsawStudioVariantCatalog(List.of(variant)) + ); + + JigsawStudioActivation.activate( + "overworld", + "stronghold", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + dimensions, + source, + initialLayout + ); + + assertSame(initialLayout, JigsawStudioActivation.getLayout("overworld")); + assertSame(variant, JigsawStudioActivation.getSession("overworld") + .activeVariant(JigsawStudioLayout.SPATIAL_WORKCELL_ID).orElseThrow()); + assertFalse(JigsawStudioActivation.getSession("overworld").isDirty()); + } + + @Test + public void stagedSamePackReplacementKeepsPriorActivationUntilCommit() { + IrisData source = mock(IrisData.class); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + JigsawStudioLayout oldLayout = JigsawStudioLayout.create( + JigsawStudioMode.SPATIAL_JIGSAW, + dimensions, + JigsawStudioVariantCatalog.empty()); + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + JigsawStudioActivation.Request previous = JigsawStudioActivation.activate( + "overworld", + "stronghold/old", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + dimensions, + source, + oldLayout, + OWNER); + JigsawStudioSession previousSession = JigsawStudioActivation.getSession("overworld"); + JigsawStudioActivation.finishOpen(OWNER); + + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + JigsawStudioActivation.StagedActivation staged = JigsawStudioActivation.stage( + "overworld", + "stronghold/new", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + dimensions, + source, + oldLayout, + OWNER, + previous.requestId()); + + assertSame(previous, JigsawStudioActivation.getRequest("overworld")); + assertSame(previousSession, JigsawStudioActivation.getSession("overworld")); + assertSame(previous, JigsawStudioActivation.getGeneratorRequest("overworld")); + assertTrue(JigsawStudioActivation.beginStagedGeneration(staged)); + assertSame(staged.request(), JigsawStudioActivation.getGeneratorRequest("overworld")); + assertSame(staged.session(), JigsawStudioActivation.getGeneratorSession("overworld")); + assertSame(previous, JigsawStudioActivation.getRequest("overworld")); + assertFalse(JigsawStudioActivation.deactivate("overworld", previous.requestId())); + assertSame(previous, JigsawStudioActivation.getRequest("overworld")); + + assertTrue(JigsawStudioActivation.commit(staged)); + assertSame(staged.request(), JigsawStudioActivation.getRequest("overworld")); + assertSame(staged.session(), JigsawStudioActivation.getSession("overworld")); + } + + @Test + public void failedSamePackReplacementRollsBackWithoutReplacingPriorSession() { + IrisData source = mock(IrisData.class); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.SPATIAL_JIGSAW, + dimensions, + JigsawStudioVariantCatalog.empty()); + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + JigsawStudioActivation.Request previous = JigsawStudioActivation.activate( + "overworld", + "stronghold/old", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + dimensions, + source, + layout, + OWNER); + JigsawStudioSession previousSession = JigsawStudioActivation.getSession("overworld"); + JigsawStudioActivation.finishOpen(OWNER); + + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + JigsawStudioActivation.StagedActivation staged = JigsawStudioActivation.stage( + "overworld", + "stronghold/new", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + dimensions, + source, + layout, + OWNER, + previous.requestId()); + assertTrue(JigsawStudioActivation.beginStagedGeneration(staged)); + assertFalse(JigsawStudioActivation.deactivate("overworld", previous.requestId())); + + assertTrue(JigsawStudioActivation.rollback(staged)); + assertSame(previous, JigsawStudioActivation.getRequest("overworld")); + assertSame(previousSession, JigsawStudioActivation.getSession("overworld")); + assertSame(previous, JigsawStudioActivation.getGeneratorRequest("overworld")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditorResizeTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditorResizeTest.java new file mode 100644 index 000000000..10c6ce001 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphEditorResizeTest.java @@ -0,0 +1,262 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class JigsawStudioGraphEditorResizeTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void resizesOnlyTheRequestedVariantWithinWorkcellCapacity() throws Exception { + Path packRoot = createPlanarProject("resize/atomic"); + JigsawStudioGraphEditor.duplicateActiveFamily( + packRoot, + "resize/atomic", + planarSources("resize/atomic"), + "variant-2"); + + JigsawStudioGraphEditor.VariantResizeResult result = + JigsawStudioGraphEditor.resizePieceObject( + packRoot, + "resize/atomic", + "resize/atomic/variants/corner/variant-2", + new JigsawStudioCellDimensions(7, 4, 9)); + + assertTrue(result.writeResult().successful()); + assertEquals(new JigsawStudioCellDimensions(16, 16, 16), result.previousDimensions()); + assertEquals(new JigsawStudioCellDimensions(7, 4, 9), result.dimensions()); + assertEquals(2, result.relocatedConnectors()); + assertEquals(new IrisBlockVector(16, 16, 16), IrisObject.sampleSize( + packRoot.resolve("objects/resize/atomic/corner.iob").toFile())); + assertEquals(new IrisBlockVector(7, 4, 9), IrisObject.sampleSize( + packRoot.resolve("objects/resize/atomic/variants/corner/variant-2.iob").toFile())); + assertEquals(new IrisBlockVector(16, 16, 16), IrisObject.sampleSize( + packRoot.resolve("objects/resize/atomic/end.iob").toFile())); + + JsonObject structure = readJson(packRoot.resolve("structures/resize/atomic.json")); + JsonObject workcell = workcell(structure.getAsJsonArray("planarWorkcells"), "CORNER"); + assertEquals(16, workcell.get("width").getAsInt()); + assertEquals(16, workcell.get("height").getAsInt()); + assertEquals(16, workcell.get("depth").getAsInt()); + JsonArray connectors = readJson(packRoot.resolve( + "jigsaw-pieces/resize/atomic/variants/corner/variant-2.json")) + .getAsJsonArray("connectors"); + assertEquals(new IrisPosition(3, 2, 0), connectorPosition(connectors, "NORTH_NEGATIVE_Z")); + assertEquals(new IrisPosition(6, 2, 4), connectorPosition(connectors, "EAST_POSITIVE_X")); + } + + @Test + public void capacityChangesNeverRewriteVariantObjects() throws Exception { + Path packRoot = createPlanarProject("resize/capacity"); + JigsawStudioGraphEditor.duplicateActiveFamily( + packRoot, + "resize/capacity", + planarSources("resize/capacity"), + "variant-2"); + Path sourceObject = packRoot.resolve("objects/resize/capacity/corner.iob"); + Path familyObject = packRoot.resolve("objects/resize/capacity/variants/corner/variant-2.iob"); + byte[] sourceBefore = Files.readAllBytes(sourceObject); + byte[] familyBefore = Files.readAllBytes(familyObject); + + IOException rejected = assertThrows( + IOException.class, + () -> JigsawStudioGraphEditor.updatePlanarWorkcellCapacity( + packRoot, + "resize/capacity", + JigsawPlanarArchetype.CORNER, + new JigsawStudioCellDimensions(7, 4, 9))); + + assertTrue(rejected.getMessage().contains("fit") || rejected.getMessage().contains("capacity")); + assertArrayEquals(sourceBefore, Files.readAllBytes(sourceObject)); + assertArrayEquals(familyBefore, Files.readAllBytes(familyObject)); + + JigsawStudioGraphEditor.WorkcellCapacityResult result = + JigsawStudioGraphEditor.updatePlanarWorkcellCapacity( + packRoot, + "resize/capacity", + JigsawPlanarArchetype.CORNER, + new JigsawStudioCellDimensions(24, 18, 20)); + + assertTrue(result.writeResult().successful()); + assertEquals(2, result.checkedVariants()); + assertArrayEquals(sourceBefore, Files.readAllBytes(sourceObject)); + assertArrayEquals(familyBefore, Files.readAllBytes(familyObject)); + JsonObject structure = readJson(packRoot.resolve("structures/resize/capacity.json")); + JsonObject workcell = workcell(structure.getAsJsonArray("planarWorkcells"), "CORNER"); + assertEquals(24, workcell.get("width").getAsInt()); + assertEquals(18, workcell.get("height").getAsInt()); + assertEquals(20, workcell.get("depth").getAsInt()); + } + + @Test + public void preservesCanonicalCoordinatesForRotatedVariantsAndMovesExplicitAirUnderlays() throws Exception { + IrisObject source = new IrisObject(7, 3, 5); + PlatformBlockState explicitAir = state("minecraft:air", true); + PlatformBlockState stone = state("minecraft:stone", false); + source.setUnsigned(6, 1, 2, explicitAir); + source.setUnsigned(4, 1, 1, stone); + IrisJigsawPiece piece = endPiece(IrisDirection.EAST_POSITIVE_X, new IrisPosition(6, 1, 2)); + + JigsawStudioGraphEditor.PlanarPieceObjectResize result = + JigsawStudioGraphEditor.resizePlanarPieceObject( + source, + piece, + JigsawPlanarArchetype.END, + new JigsawStudioCellDimensions(9, 3, 11), + "resize/rotated-east"); + + IrisObject resized = result.object(); + assertEquals(11, resized.getW()); + assertEquals(3, resized.getH()); + assertEquals(9, resized.getD()); + assertEquals(1, result.relocatedConnectors()); + assertEquals(new IrisPosition(10, 1, 4), piece.getConnectors().getFirst().getPosition()); + assertSame(explicitAir, blockAt(resized, 10, 1, 4)); + assertFalse(resized.getBlocks().containsKey(resized.getSigned(10, 1, 2))); + assertSame(stone, blockAt(resized, 8, 1, 1)); + } + + @Test + public void rejectsExplicitAirOutsideShrunkBoundsWithoutMutatingTheSource() { + IrisObject source = new IrisObject(8, 4, 8); + PlatformBlockState explicitAir = state("minecraft:air", true); + source.setUnsigned(7, 1, 1, explicitAir); + IrisJigsawPiece piece = new IrisJigsawPiece().setObject("resize/blank"); + + IOException failure = assertThrows( + IOException.class, + () -> JigsawStudioGraphEditor.resizePlanarPieceObject( + source, + piece, + JigsawPlanarArchetype.BLANK, + new JigsawStudioCellDimensions(6, 4, 6), + "resize/blank")); + + assertTrue(failure.getMessage().contains("including explicit air")); + assertEquals(8, source.getW()); + assertSame(explicitAir, blockAt(source, 7, 1, 1)); + } + + @Test + public void rejectsConnectorDestinationCollisionsBeforeChangingThePiece() { + IrisObject source = new IrisObject(5, 3, 5); + PlatformBlockState underlay = state("minecraft:air", true); + PlatformBlockState collision = state("minecraft:stone", false); + source.setUnsigned(2, 1, 0, underlay); + source.setUnsigned(3, 1, 0, collision); + IrisPosition originalPosition = new IrisPosition(2, 1, 0); + IrisJigsawPiece piece = endPiece(IrisDirection.NORTH_NEGATIVE_Z, originalPosition.copy()); + + IOException failure = assertThrows( + IOException.class, + () -> JigsawStudioGraphEditor.resizePlanarPieceObject( + source, + piece, + JigsawPlanarArchetype.END, + new JigsawStudioCellDimensions(7, 3, 7), + "resize/collision")); + + assertTrue(failure.getMessage().contains("destination contains a stored block")); + assertEquals(originalPosition, piece.getConnectors().getFirst().getPosition()); + assertSame(underlay, blockAt(source, 2, 1, 0)); + assertSame(collision, blockAt(source, 3, 1, 0)); + } + + private Path createPlanarProject(String structureKey) throws Exception { + Path packRoot = temporaryFolder.newFolder(structureKey.replace('/', '-')).toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + structureKey, + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + assertTrue(JigsawStudioProjectCreator.create(packRoot, options).successful()); + return packRoot; + } + + private static IrisJigsawPiece endPiece(IrisDirection direction, IrisPosition position) { + IrisJigsawPiece piece = new IrisJigsawPiece().setObject("resize/end"); + piece.getConnectors().add(new IrisJigsawConnector() + .setDirection(direction) + .setPosition(position)); + return piece; + } + + private static Map planarSources(String structureKey) { + Map sources = new LinkedHashMap<>(); + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + sources.put( + archetype.stableId(), + structureKey + "/" + archetype.name().toLowerCase(Locale.ROOT)); + } + return sources; + } + + private static PlatformBlockState state(String key, boolean air) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(key); + when(state.isAir()).thenReturn(air); + return state; + } + + private static PlatformBlockState blockAt(IrisObject object, int x, int y, int z) { + return object.getBlocks().get(object.getSigned(x, y, z)); + } + + private static JsonObject readJson(Path path) throws IOException { + return JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private static JsonObject workcell(JsonArray workcells, String archetype) { + for (int index = 0; index < workcells.size(); index++) { + JsonObject workcell = workcells.get(index).getAsJsonObject(); + if (archetype.equals(workcell.get("archetype").getAsString())) { + return workcell; + } + } + throw new AssertionError("Missing planar workcell " + archetype); + } + + private static IrisPosition connectorPosition(JsonArray connectors, String direction) { + for (int index = 0; index < connectors.size(); index++) { + JsonObject connector = connectors.get(index).getAsJsonObject(); + if (!direction.equals(connector.get("direction").getAsString())) { + continue; + } + JsonObject position = connector.getAsJsonObject("position"); + return new IrisPosition( + position.get("x").getAsInt(), + position.get("y").getAsInt(), + position.get("z").getAsInt()); + } + throw new AssertionError("Missing connector " + direction); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapperTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapperTest.java new file mode 100644 index 000000000..23db73647 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioGraphMapperTest.java @@ -0,0 +1,338 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawWorkcell; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +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 JigsawStudioGraphMapperTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void horizontalConnectorsMapToPlanarTopology() { + IrisJigsawPiece corner = new IrisJigsawPiece().setConnectors(new KList<>()); + corner.getConnectors().add(connector(IrisDirection.NORTH_NEGATIVE_Z)); + corner.getConnectors().add(connector(IrisDirection.EAST_POSITIVE_X)); + + assertEquals(JigsawPlanarTopology.NORTH_EAST_CORNER, + JigsawStudioGraphMapper.topologyOf(corner)); + } + + @Test + public void verticalConnectorsDoNotChangePlanarMask() { + IrisJigsawPiece end = new IrisJigsawPiece().setConnectors(new KList<>()); + end.getConnectors().add(connector(IrisDirection.WEST_NEGATIVE_X)); + end.getConnectors().add(connector(IrisDirection.UP_POSITIVE_Y)); + + assertEquals(JigsawPlanarTopology.WEST_END, + JigsawStudioGraphMapper.topologyOf(end)); + } + + @Test + @SuppressWarnings("unchecked") + public void catalogPreservesDuplicateWeightedPoolEntries() { + IrisData data = mock(IrisData.class); + ResourceLoader poolLoader = mock(ResourceLoader.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + when(data.getJigsawPoolLoader()).thenReturn(poolLoader); + when(data.getJigsawPieceLoader()).thenReturn(pieceLoader); + + IrisJigsawPool start = new IrisJigsawPool().setPieces(new KList<>()); + start.getPieces().add(new IrisJigsawPieceEntry("village/end", 2).setChance(0.25D)); + start.getPieces().add(new IrisJigsawPieceEntry("village/end", 7)); + start.getPieces().add(new IrisJigsawPieceEntry("village/east", 5)); + when(poolLoader.load("village/start")).thenReturn(start); + IrisJigsawPiece endPiece = planarPiece("village/end", IrisDirection.NORTH_NEGATIVE_Z) + .setRules(new IrisJigsawPieceRules() + .setMinimumDepth(1) + .setMaximumDepth(4) + .setMinimumPlacements(1) + .setMaximumPlacements(2) + .setTerminal(true)); + endPiece.getThemes().add("spruce"); + when(pieceLoader.load("village/end")).thenReturn(endPiece); + when(pieceLoader.load("village/east")).thenReturn(planarPiece( + "village/east", IrisDirection.EAST_POSITIVE_X)); + IrisStructure structure = new IrisStructure() + .setStartPool("village/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW); + + JigsawStudioVariantCatalog catalog = JigsawStudioGraphMapper.catalog( + data, structure, JigsawStudioMode.PLANAR_JIGSAW); + JigsawStudioVariant end = catalog.find("village/end").orElseThrow(); + JigsawStudioVariant east = catalog.find("village/east").orElseThrow(); + + assertEquals(2, end.memberships().size()); + assertEquals(new JigsawStudioPoolMembership("village/start", 0, 2, 0.25D), end.memberships().get(0)); + assertEquals(new JigsawStudioPoolMembership("village/start", 1, 7, 1D), end.memberships().get(1)); + assertEquals(new JigsawStudioPoolMembership("village/start", 2, 5, 1D), east.memberships().getFirst()); + assertEquals(List.of("spruce"), end.themes()); + assertEquals(new JigsawStudioPieceRules(1, 4, 1, 2, true), end.rules()); + assertEquals(3, east.sourceToCanonicalQuarterTurns()); + assertEquals(List.of(end, east), catalog.variants(JigsawPlanarArchetype.END)); + } + + @Test + @SuppressWarnings("unchecked") + public void ownedUnassignedPiecesRequireOwnedObjectForEditing() throws Exception { + Path root = temporaryFolder.newFolder("pack").toPath(); + IrisData data = mock(IrisData.class); + ResourceLoader poolLoader = mock(ResourceLoader.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + when(data.getDataFolder()).thenReturn(root.toFile()); + when(data.getJigsawPoolLoader()).thenReturn(poolLoader); + when(data.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(pieceLoader.load("village/orphan")).thenReturn(planarPiece( + "village/orphan", IrisDirection.NORTH_NEGATIVE_Z)); + when(pieceLoader.load("village/incomplete")).thenReturn(planarPiece( + "village/incomplete", IrisDirection.EAST_POSITIVE_X)); + + IrisStructure structure = new IrisStructure() + .setStartPool("village/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW); + structure.setLoadKey("village/demo"); + StructureKey key = new StructureKey("iris", "village/demo"); + String hash = "0".repeat(64); + StructureOwnershipManifest manifest = new StructureOwnershipManifest( + StructureOwnershipManifest.CURRENT_SCHEMA_VERSION, + key, + StructureSource.of(StructureSource.Kind.DATAPACK, key), + StructureBackend.IRIS_ASSEMBLY, + List.of(), + List.of(), + Map.of( + "jigsaw-pieces/village/orphan.json", hash, + "objects/village/orphan.iob", hash, + "jigsaw-pieces/village/incomplete.json", hash)); + Path manifestPath = root.resolve(manifest.relativePath()); + Files.createDirectories(manifestPath.getParent()); + Files.write(manifestPath, manifest.toJson()); + + JigsawStudioVariantCatalog catalog = JigsawStudioGraphMapper.catalog( + data, structure, JigsawStudioMode.PLANAR_JIGSAW); + JigsawStudioVariant orphan = catalog.find("village/orphan").orElseThrow(); + JigsawStudioVariant incomplete = catalog.find("village/incomplete").orElseThrow(); + + assertTrue(orphan.owned()); + assertFalse(orphan.assigned()); + assertFalse(incomplete.owned()); + assertFalse(incomplete.assigned()); + assertTrue(catalog.editableGraph()); + } + + @Test + @SuppressWarnings("unchecked") + public void managedDatapackManifestResourcesRemainReadOnly() throws Exception { + Path root = temporaryFolder.newFolder("managed-pack").toPath(); + IrisData data = mock(IrisData.class); + ResourceLoader poolLoader = mock(ResourceLoader.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + when(data.getDataFolder()).thenReturn(root.toFile()); + when(data.getJigsawPoolLoader()).thenReturn(poolLoader); + when(data.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(pieceLoader.load("village/managed")).thenReturn(planarPiece( + "village/managed", IrisDirection.NORTH_NEGATIVE_Z)); + + IrisStructure structure = new IrisStructure() + .setStartPool("village/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW); + structure.setLoadKey("village/managed"); + StructureKey key = new StructureKey("iris", "village/managed"); + String hash = "0".repeat(64); + String sourcePath = "data/example/worldgen/template_pool/village/start.json"; + StructureOwnershipManifest.Provenance provenance = new StructureOwnershipManifest.Provenance( + StructureOwnershipManifest.Origin.MANAGED_DATAPACK, + UUID.fromString("33333333-3333-3333-3333-333333333333").toString(), + hash, + hash, + 1L, + Map.of(sourcePath, hash), + Map.of(sourcePath, "jigsaw-pools/village/start.json"), + StructureOwnershipManifest.RollbackDisposition.DELETE_CREATED_IF_UNCHANGED); + StructureOwnershipManifest manifest = new StructureOwnershipManifest( + StructureOwnershipManifest.CURRENT_SCHEMA_VERSION, + key, + StructureSource.of(StructureSource.Kind.DATAPACK, key), + StructureBackend.IRIS_ASSEMBLY, + List.of(), + List.of(), + Map.of( + "jigsaw-pieces/village/managed.json", hash, + "objects/village/managed.iob", hash), + provenance); + Path manifestPath = root.resolve(manifest.relativePath()); + Files.createDirectories(manifestPath.getParent()); + Files.write(manifestPath, manifest.toJson()); + + JigsawStudioVariantCatalog catalog = JigsawStudioGraphMapper.catalog( + data, + structure, + JigsawStudioMode.PLANAR_JIGSAW); + JigsawStudioVariant managed = catalog.find("village/managed").orElseThrow(); + + assertFalse(managed.owned()); + assertFalse(managed.assigned()); + assertFalse(catalog.editableGraph()); + } + + @Test + @SuppressWarnings("unchecked") + public void spatialLayoutExpandsToFitLargestReachableObject() { + IrisData data = mock(IrisData.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(data.getObjectLoader()).thenReturn(objectLoader); + when(objectLoader.load("stronghold/tall-room")).thenReturn(new IrisObject(24, 32, 20)); + JigsawStudioVariant variant = new JigsawStudioVariant( + "stronghold/tall-room", + "stronghold/tall-room", + "", + Optional.of(new JigsawStudioCellDimensions(48, 72, 36)), + JigsawStudioMode.SPATIAL_JIGSAW, + Optional.empty(), + true, + false, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + + JigsawStudioCellDimensions dimensions = JigsawStudioGraphMapper.expandSpatialDimensions( + data, + new JigsawStudioVariantCatalog(List.of(variant)), + new JigsawStudioCellDimensions(16, 16, 16)); + + assertEquals(new JigsawStudioCellDimensions(24, 32, 24), dimensions); + } + + @Test + @SuppressWarnings("unchecked") + public void mapsAuthorLabelsAndCanonicalPerVariantDimensionsIndependently() { + IrisData data = mock(IrisData.class); + ResourceLoader poolLoader = mock(ResourceLoader.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(data.getJigsawPoolLoader()).thenReturn(poolLoader); + when(data.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(data.getObjectLoader()).thenReturn(objectLoader); + IrisJigsawPool start = new IrisJigsawPool().setPieces(new KList<>()); + start.getPieces().add(new IrisJigsawPieceEntry("village/east-gate", 1)); + when(poolLoader.load("village/start")).thenReturn(start); + IrisJigsawPiece gate = planarPiece("village/east-gate", IrisDirection.EAST_POSITIVE_X) + .setDisplayName("East Gatehouse"); + when(pieceLoader.load("village/east-gate")).thenReturn(gate); + when(objectLoader.load("village/east-gate")).thenReturn(new IrisObject(7, 3, 5)); + IrisStructure structure = new IrisStructure() + .setStartPool("village/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW); + for (IrisJigsawWorkcellArchetype archetype : IrisJigsawWorkcellArchetype.values()) { + boolean end = archetype == IrisJigsawWorkcellArchetype.END; + structure.getPlanarWorkcells().add(new IrisJigsawWorkcell( + end ? "Village Entrances" : "", + archetype, + end ? 12 : 16, + end ? 6 : 16, + end ? 10 : 16, + true)); + } + + JigsawStudioLayout layout = JigsawStudioGraphMapper.map(data, structure); + JigsawStudioBay workcell = layout.get(JigsawPlanarArchetype.END.stableId()); + JigsawStudioVariant variant = layout.variantCatalog().find("village/east-gate").orElseThrow(); + + assertEquals("End Cap", workcell.canonicalDisplayName()); + assertEquals("Village Entrances", workcell.displayName()); + assertEquals(new JigsawStudioCellDimensions(12, 6, 10), workcell.capacity()); + assertEquals("East Gatehouse", variant.resolvedDisplayName()); + assertEquals(new JigsawStudioCellDimensions(5, 3, 7), variant.dimensions().orElseThrow()); + } + + @Test + @SuppressWarnings("unchecked") + public void mapsSpatialWorkcellLabelWithoutChangingItsCanonicalRole() { + IrisData data = mock(IrisData.class); + when(data.getJigsawPoolLoader()).thenReturn(mock(ResourceLoader.class)); + when(data.getJigsawPieceLoader()).thenReturn(mock(ResourceLoader.class)); + IrisStructure structure = new IrisStructure() + .setStartPool("stronghold/missing") + .setMode(IrisJigsawMode.SPATIAL_JIGSAW) + .setSpatialWorkcellDisplayName("Stronghold Rooms"); + + JigsawStudioBay workcell = JigsawStudioGraphMapper.map(data, structure) + .get(JigsawStudioLayout.SPATIAL_WORKCELL_ID); + + assertEquals("Spatial", workcell.canonicalDisplayName()); + assertEquals("Stronghold Rooms", workcell.displayName()); + } + + @Test + @SuppressWarnings("unchecked") + public void planarMapPreservesIndependentEmptyAndDisabledWorkcellBounds() { + IrisData data = mock(IrisData.class); + when(data.getJigsawPoolLoader()).thenReturn(mock(ResourceLoader.class)); + when(data.getJigsawPieceLoader()).thenReturn(mock(ResourceLoader.class)); + IrisStructure structure = new IrisStructure() + .setStartPool("village/missing") + .setMode(IrisJigsawMode.PLANAR_JIGSAW); + for (IrisJigsawWorkcellArchetype archetype : IrisJigsawWorkcellArchetype.values()) { + int ordinal = archetype.ordinal(); + structure.getPlanarWorkcells().add(new IrisJigsawWorkcell( + "", + archetype, + 8 + ordinal, + 6 + ordinal, + 10 + ordinal, + archetype != IrisJigsawWorkcellArchetype.TEE)); + } + + JigsawStudioLayout layout = JigsawStudioGraphMapper.map(data, structure); + + assertEquals(new JigsawStudioCellDimensions(11, 9, 13), + layout.get("workcell/corner").bounds().dimensions()); + assertEquals(new JigsawStudioCellDimensions(12, 10, 14), + layout.get("workcell/tee").bounds().dimensions()); + assertFalse(layout.get("workcell/tee").enabled()); + assertTrue(layout.variants(layout.get("workcell/tee")).isEmpty()); + } + + private static IrisJigsawPiece planarPiece(String objectKey, IrisDirection direction) { + IrisJigsawPiece piece = new IrisJigsawPiece() + .setObject(objectKey) + .setConnectors(new KList<>()); + piece.getConnectors().add(connector(direction)); + return piece; + } + + private static IrisJigsawConnector connector(IrisDirection direction) { + return new IrisJigsawConnector().setDirection(direction); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayoutTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayoutTest.java new file mode 100644 index 000000000..ed406a769 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioLayoutTest.java @@ -0,0 +1,175 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +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 JigsawStudioLayoutTest { + private static final JigsawStudioCellDimensions CELL = new JigsawStudioCellDimensions(9, 6, 9); + + @Test + public void planarLayoutHasSixCanonicalWorkcellsInStableOrder() { + JigsawStudioVariant northEnd = planarVariant("village/end", JigsawPlanarTopology.NORTH_END); + JigsawStudioVariant eastEnd = planarVariant("village/east", JigsawPlanarTopology.EAST_END); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + CELL, + new JigsawStudioVariantCatalog(List.of(northEnd, eastEnd))); + + assertEquals(6, layout.bays().size()); + JigsawPlanarArchetype[] archetypes = JigsawPlanarArchetype.values(); + for (int index = 0; index < archetypes.length; index++) { + JigsawStudioBay workcell = layout.bays().get(index); + assertEquals(JigsawStudioBayKind.PLANAR_WORKCELL, workcell.kind()); + assertEquals(archetypes[index].stableId(), workcell.stableId()); + assertSame(archetypes[index], workcell.archetype().orElseThrow()); + assertSame(archetypes[index].canonicalTopology(), workcell.topology().orElseThrow()); + assertSame(workcell, layout.findAt( + workcell.bounds().originX(), + workcell.bounds().originY(), + workcell.bounds().originZ())); + } + assertEquals(List.of(northEnd, eastEnd), layout.variants(layout.get("workcell/end"))); + assertSame(northEnd, layout.defaultVariant(layout.get("workcell/end")).orElseThrow()); + assertTrue(layout.defaultVariant(layout.get("workcell/blank")).isEmpty()); + } + + @Test + public void planarWorkcellsUseDirectTwoBlockSpacing() { + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(16, 8, 16), + JigsawStudioVariantCatalog.empty()); + JigsawStudioBay blank = layout.get("workcell/blank"); + JigsawStudioBay end = layout.get("workcell/end"); + JigsawStudioBay corner = layout.get("workcell/corner"); + + assertEquals(18, end.bounds().originX() - blank.bounds().originX()); + assertEquals(18, corner.bounds().originZ() - blank.bounds().originZ()); + assertEquals(2, end.bounds().originX() - blank.bounds().maxX() - 1); + assertEquals(2, corner.bounds().originZ() - blank.bounds().maxZ() - 1); + assertEquals(3, layout.columns()); + assertEquals(2, layout.gap()); + } + + @Test + public void planarWorkcellsPackArbitraryPersistentBoundsWithoutOverlap() { + List specs = List.of( + new JigsawStudioWorkcellSpec(JigsawPlanarArchetype.BLANK, "", + new JigsawStudioCellDimensions(8, 4, 9), true), + new JigsawStudioWorkcellSpec(JigsawPlanarArchetype.END, "", + new JigsawStudioCellDimensions(20, 5, 7), true), + new JigsawStudioWorkcellSpec(JigsawPlanarArchetype.STRAIGHT, "", + new JigsawStudioCellDimensions(6, 6, 11), true), + new JigsawStudioWorkcellSpec(JigsawPlanarArchetype.CORNER, "", + new JigsawStudioCellDimensions(12, 7, 5), true), + new JigsawStudioWorkcellSpec(JigsawPlanarArchetype.TEE, "", + new JigsawStudioCellDimensions(7, 8, 15), false), + new JigsawStudioWorkcellSpec(JigsawPlanarArchetype.CROSS, "", + new JigsawStudioCellDimensions(10, 9, 6), true)); + JigsawStudioLayout layout = JigsawStudioLayout.createPlanar( + CELL, + specs, + JigsawStudioVariantCatalog.empty()); + + JigsawStudioBay blank = layout.get("workcell/blank"); + JigsawStudioBay end = layout.get("workcell/end"); + JigsawStudioBay straight = layout.get("workcell/straight"); + JigsawStudioBay corner = layout.get("workcell/corner"); + JigsawStudioBay tee = layout.get("workcell/tee"); + + assertEquals(14, end.bounds().originX() - blank.bounds().originX()); + assertEquals(36, straight.bounds().originX() - blank.bounds().originX()); + assertEquals(13, corner.bounds().originZ() - blank.bounds().originZ()); + assertEquals(new JigsawStudioCellDimensions(7, 8, 15), tee.bounds().dimensions()); + assertFalse(tee.enabled()); + assertTrue(blank.enabled()); + } + + @Test + public void spatialLayoutHasOneActiveWorkcell() { + JigsawStudioVariant hall = spatialVariant("stronghold/hall"); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.SPATIAL_JIGSAW, + CELL, + new JigsawStudioVariantCatalog(List.of(hall))); + + assertEquals(1, layout.bays().size()); + JigsawStudioBay workcell = layout.bays().getFirst(); + assertEquals(JigsawStudioLayout.SPATIAL_WORKCELL_ID, workcell.stableId()); + assertEquals(JigsawStudioBayKind.SPATIAL_WORKCELL, workcell.kind()); + assertTrue(workcell.archetype().isEmpty()); + assertTrue(workcell.topology().isEmpty()); + assertSame(hall, layout.defaultVariant(workcell).orElseThrow()); + assertNull(layout.findAt(-1, JigsawStudioLayout.FLOOR_Y + 1, -1)); + } + + @Test + public void controlChestIsOutsideEveryCaptureAndCage() { + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + CELL, + JigsawStudioVariantCatalog.empty()); + JigsawStudioControlPosition control = layout.controlPosition(); + + for (JigsawStudioBay workcell : layout.bays()) { + JigsawStudioBounds bounds = workcell.bounds(); + assertFalse(bounds.contains(control.worldX(), control.worldY(), control.worldZ())); + assertTrue(control.worldX() < bounds.originX() - 1 || control.worldX() > bounds.maxX() + 1 + || control.worldZ() < bounds.originZ() - 1 || control.worldZ() > bounds.maxZ() + 1); + } + } + + @Test + public void rejectsPathologicalCellAndCatalogSizes() { + assertThrows(IllegalArgumentException.class, + () -> new JigsawStudioCellDimensions(129, 16, 16)); + assertThrows(IllegalArgumentException.class, + () -> new JigsawStudioCellDimensions(128, 192, 128)); + + List variants = new ArrayList<>(); + for (int index = 0; index <= JigsawStudioLayout.MAX_VARIANTS; index++) { + variants.add(spatialVariant("stronghold/room-" + index)); + } + assertThrows(IllegalArgumentException.class, () -> new JigsawStudioVariantCatalog(variants)); + } + + private static JigsawStudioVariant planarVariant(String key, JigsawPlanarTopology topology) { + return new JigsawStudioVariant( + key, + key, + "", + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + JigsawStudioMode.PLANAR_JIGSAW, + Optional.of(topology), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + } + + private static JigsawStudioVariant spatialVariant(String key) { + return new JigsawStudioVariant( + key, + key, + "", + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + JigsawStudioMode.SPATIAL_JIGSAW, + Optional.empty(), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioManagedAuthoringGuardTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioManagedAuthoringGuardTest.java new file mode 100644 index 000000000..b07b5771d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioManagedAuthoringGuardTest.java @@ -0,0 +1,177 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class JigsawStudioManagedAuthoringGuardTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void graphEditorRejectsManagedDatapackOwnership() throws Exception { + Path packRoot = createManagedProject("managed/graph"); + + IOException lookupFailure = assertThrows(IOException.class, + () -> JigsawStudioGraphEditor.ownedPoolKeys(packRoot, "managed/graph")); + IOException editFailure = assertThrows(IOException.class, + () -> JigsawStudioGraphEditor.createPool( + packRoot, + "managed/graph", + "managed/graph/terminal", + "")); + + assertManagedGuidance(lookupFailure); + assertManagedGuidance(editFailure); + assertFalse(Files.exists(packRoot.resolve("jigsaw-pools/managed/graph/terminal.json"))); + } + + @Test + public void poolEditorRejectsManagedDatapackOwnershipWithoutChangingThePool() throws Exception { + Path packRoot = createManagedProject("managed/pool"); + Path poolPath = packRoot.resolve("jigsaw-pools/managed/pool/start.json"); + byte[] before = Files.readAllBytes(poolPath); + + IOException failure = assertThrows(IOException.class, + () -> JigsawStudioPoolEditor.updateWeight( + packRoot, + "managed/pool", + "managed/pool/start", + "managed/pool/start", + 9)); + + assertManagedGuidance(failure); + assertArrayEquals(before, Files.readAllBytes(poolPath)); + } + + @Test + public void structureEditorRejectsManagedDatapackOwnershipWithoutChangingRules() throws Exception { + Path packRoot = createManagedProject("managed/rules"); + Path structurePath = packRoot.resolve("structures/managed/rules.json"); + byte[] before = Files.readAllBytes(structurePath); + + IOException failure = assertThrows(IOException.class, + () -> JigsawStudioStructureEditor.updateLimits( + packRoot, + "managed/rules", + 12, + 6)); + + assertManagedGuidance(failure); + assertArrayEquals(before, Files.readAllBytes(structurePath)); + } + + @Test + public void centralAccessRuleMatchesManagedDatapackProvenanceOnly() throws Exception { + Path packRoot = createProject("managed/access"); + StructureOwnershipManifest created = readManifest(packRoot, "managed/access"); + + assertTrue(JigsawStudioAuthoringAccess.isEditable(created)); + + StructureOwnershipManifest managed = withManagedProvenance(created); + + assertFalse(JigsawStudioAuthoringAccess.isEditable(managed)); + assertManagedGuidance(assertThrows(IOException.class, + () -> JigsawStudioAuthoringAccess.requireEditable(managed))); + } + + @Test + public void projectDeletionRejectsManagedDatapackOwnership() throws Exception { + Path packRoot = createManagedProject("managed/deletion"); + + IOException failure = assertThrows( + IOException.class, + () -> JigsawStudioProjectDeletionService.inspect(packRoot, "managed/deletion")); + + assertManagedGuidance(failure); + assertTrue(Files.exists(packRoot.resolve("structures/managed/deletion.json"))); + } + + private Path createManagedProject(String structureKey) throws Exception { + Path packRoot = createProject(structureKey); + StructureOwnershipManifest manifest = readManifest(packRoot, structureKey); + StructureTransactionWriter writer = new StructureTransactionWriter(packRoot); + Files.write( + writer.ownershipManifestPath(StructureKey.parse(structureKey, "iris")), + withManagedProvenance(manifest).toJson()); + return packRoot; + } + + private Path createProject(String structureKey) throws Exception { + Path packRoot = temporaryFolder.newFolder(structureKey.replace('/', '-')).toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + structureKey, + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + StructureWriteResult result = JigsawStudioProjectCreator.create(packRoot, options); + assertTrue(result.successful()); + return packRoot; + } + + private static StructureOwnershipManifest readManifest( + Path packRoot, + String structureKey + ) throws IOException { + StructureTransactionWriter writer = new StructureTransactionWriter(packRoot); + Path manifestPath = writer.ownershipManifestPath(StructureKey.parse(structureKey, "iris")); + return StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath)); + } + + private static StructureOwnershipManifest withManagedProvenance( + StructureOwnershipManifest manifest + ) { + Map sourceHashes = new TreeMap<>(); + Map sourceMappings = new TreeMap<>(); + for (Map.Entry resource : manifest.resourceHashes().entrySet()) { + String sourcePath = "managed-source/" + resource.getKey(); + sourceHashes.put(sourcePath, resource.getValue()); + sourceMappings.put(sourcePath, resource.getKey()); + } + String planHash = StructureHash.sha256( + ("managed-plan:" + manifest.structure().value()).getBytes(StandardCharsets.UTF_8)); + String closureHash = StructureHash.sha256( + ("managed-closure:" + manifest.structure().value()).getBytes(StandardCharsets.UTF_8)); + StructureOwnershipManifest.Provenance provenance = new StructureOwnershipManifest.Provenance( + StructureOwnershipManifest.Origin.MANAGED_DATAPACK, + UUID.fromString("77777777-7777-7777-7777-777777777777").toString(), + planHash, + closureHash, + 1L, + sourceHashes, + sourceMappings, + StructureOwnershipManifest.RollbackDisposition.NONE); + return new StructureOwnershipManifest( + manifest.schemaVersion(), + manifest.structure(), + manifest.source(), + manifest.backend(), + manifest.capabilities(), + manifest.losses(), + manifest.resourceHashes(), + provenance); + } + + private static void assertManagedGuidance(IOException failure) { + assertTrue(failure.getMessage(), failure.getMessage().contains("managed by datapack ingest")); + assertTrue(failure.getMessage(), failure.getMessage().contains("adopt or clone")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPersistenceEditorsTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPersistenceEditorsTest.java new file mode 100644 index 000000000..fdeebf47e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioPersistenceEditorsTest.java @@ -0,0 +1,624 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.core.structure.authoring.StructureSource; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.util.common.math.IrisBlockVector; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +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 JigsawStudioPersistenceEditorsTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void createsCoherentThemeSetAndAppliesAtomicMetadataEditors() throws Exception { + Path packRoot = createPlanarProject("theme/test"); + + JigsawStudioPoolEditor.WeightUpdate weight = JigsawStudioPoolEditor.updateWeightAtIndex( + packRoot, + "theme/test", + "theme/test/pieces", + 0, + "theme/test/end", + 4); + JigsawStudioPoolEditor.ChanceUpdate chance = JigsawStudioPoolEditor.updateChanceAtIndex( + packRoot, + "theme/test", + "theme/test/pieces", + 0, + "theme/test/end", + 0.35D); + + assertTrue(weight.changed()); + assertTrue(chance.changed()); + + JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily( + packRoot, + "theme/test", + planarSources("theme/test"), + "variant-2"); + + assertTrue(creation.writeResult().successful()); + assertEquals(6, creation.pieceKeysByWorkcell().size()); + assertEquals( + "theme/test/variants/end/variant-2", + creation.pieceKeysByWorkcell().get(JigsawPlanarArchetype.END.stableId())); + JsonObject piecePool = readJson(packRoot.resolve("jigsaw-pools/theme/test/pieces.json")); + JsonArray entries = piecePool.getAsJsonArray("pieces"); + assertEquals("theme/test/end", entries.get(0).getAsJsonObject().get("piece").getAsString()); + assertEquals("theme/test/variants/end/variant-2", + entries.get(1).getAsJsonObject().get("piece").getAsString()); + assertEquals(4, entries.get(1).getAsJsonObject().get("weight").getAsInt()); + assertEquals(0.35D, entries.get(1).getAsJsonObject().get("chance").getAsDouble(), 0D); + JsonObject duplicatedEnd = readJson( + packRoot.resolve("jigsaw-pieces/theme/test/variants/end/variant-2.json")); + assertEquals(1, duplicatedEnd.getAsJsonArray("themes").size()); + assertEquals("variant-2", duplicatedEnd.getAsJsonArray("themes").get(0).getAsString()); + + assertTrue(JigsawStudioGraphEditor.updatePieceThemes( + packRoot, + "theme/test", + "theme/test/blank", + List.of()).successful()); + JigsawStudioPieceRules terminalRules = new JigsawStudioPieceRules(0, 30, 0, 0, true); + assertTrue(JigsawStudioGraphEditor.updatePieceRules( + packRoot, + "theme/test", + "theme/test/end", + terminalRules).successful()); + assertTrue(JigsawStudioGraphEditor.updatePieceRules( + packRoot, + "theme/test", + "theme/test/variants/end/variant-2", + terminalRules).successful()); + assertTrue(JigsawStudioStructureEditor.updateThemeSets( + packRoot, + "theme/test", + List.of( + new IrisJigsawThemeSet("variant-1", 2), + new IrisJigsawThemeSet("variant-2", 3))).successful()); + assertTrue(JigsawStudioStructureEditor.updateRequireCaps( + packRoot, + "theme/test", + true).successful()); + + JsonObject structure = readJson(packRoot.resolve("structures/theme/test.json")); + assertTrue(structure.get("requireCaps").getAsBoolean()); + assertEquals("FAIL_ASSEMBLY", structure.get("branchFailurePolicy").getAsString()); + assertEquals(3, structure.getAsJsonArray("themeSets").get(1) + .getAsJsonObject().get("weight").getAsInt()); + JsonObject savedRules = readJson(packRoot.resolve("jigsaw-pieces/theme/test/end.json")) + .getAsJsonObject("rules"); + assertTrue(savedRules.get("terminal").getAsBoolean()); + } + + @Test + public void portableBranchTerminationPolicySurvivesStructureEditors() throws Exception { + Path packRoot = temporaryFolder.newFolder("portable-policy").toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "portable/policy", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, + new JigsawStudioCellDimensions(16, 16, 16)); + assertTrue(JigsawStudioProjectCreator.create(packRoot, options).successful()); + + assertTrue(JigsawStudioStructureEditor.updateLimits( + packRoot, + "portable/policy", + 6, + 7).successful()); + assertTrue(JigsawStudioStructureEditor.updateWorkcellEnabled( + packRoot, + "portable/policy", + JigsawPlanarArchetype.BLANK, + false).successful()); + + JsonObject structure = readJson(packRoot.resolve("structures/portable/policy.json")); + assertEquals("TERMINATE_BRANCH", structure.get("branchFailurePolicy").getAsString()); + assertEquals(6, structure.get("maxDepth").getAsInt()); + assertEquals(7, structure.get("maxSizeChunks").getAsInt()); + } + + @Test + public void duplicateEndVariantCopiesPiecesAndCapsMembershipsExactly() throws Exception { + Path packRoot = createPlanarProject("variant/end"); + String sourcePiece = "variant/end/end"; + String targetPiece = "variant/end/variants/end/variant-2"; + assertTrue(JigsawStudioPoolEditor.updateWeightAtIndex( + packRoot, + "variant/end", + "variant/end/pieces", + 0, + sourcePiece, + 4).changed()); + assertTrue(JigsawStudioPoolEditor.updateChanceAtIndex( + packRoot, + "variant/end", + "variant/end/pieces", + 0, + sourcePiece, + 0.35D).changed()); + assertTrue(JigsawStudioPoolEditor.updateWeightAtIndex( + packRoot, + "variant/end", + "variant/end/caps", + 0, + sourcePiece, + 7).changed()); + assertTrue(JigsawStudioPoolEditor.updateChanceAtIndex( + packRoot, + "variant/end", + "variant/end/caps", + 0, + sourcePiece, + 0.8D).changed()); + + StructureWriteResult duplicated = JigsawStudioGraphEditor.duplicatePiece( + packRoot, + "variant/end", + sourcePiece, + targetPiece); + + assertTrue(duplicated.successful()); + assertMembershipCopy( + packRoot.resolve("jigsaw-pools/variant/end/pieces.json"), + sourcePiece, + targetPiece, + 4, + 0.35D); + assertMembershipCopy( + packRoot.resolve("jigsaw-pools/variant/end/caps.json"), + sourcePiece, + targetPiece, + 7, + 0.8D); + assertPieceMetadataCopy(packRoot, sourcePiece, targetPiece); + assertEquals(-1L, Files.mismatch( + packRoot.resolve("objects/" + sourcePiece + ".iob"), + packRoot.resolve("objects/" + targetPiece + ".iob"))); + } + + @Test + public void newCrossVariantCopiesStartAndPiecesMembershipsExactly() throws Exception { + Path packRoot = createPlanarProject("variant/cross"); + String sourcePiece = "variant/cross/cross"; + String targetPiece = "variant/cross/variants/cross/variant-2"; + assertTrue(JigsawStudioPoolEditor.updateWeightAtIndex( + packRoot, + "variant/cross", + "variant/cross/start", + 0, + sourcePiece, + 9).changed()); + assertTrue(JigsawStudioPoolEditor.updateChanceAtIndex( + packRoot, + "variant/cross", + "variant/cross/start", + 0, + sourcePiece, + 0.6D).changed()); + assertTrue(JigsawStudioPoolEditor.updateWeightAtIndex( + packRoot, + "variant/cross", + "variant/cross/pieces", + 4, + sourcePiece, + 5).changed()); + assertTrue(JigsawStudioPoolEditor.updateChanceAtIndex( + packRoot, + "variant/cross", + "variant/cross/pieces", + 4, + sourcePiece, + 0.45D).changed()); + + StructureWriteResult created = JigsawStudioGraphEditor.createBlankVariant( + packRoot, + "variant/cross", + sourcePiece, + targetPiece); + + assertTrue(created.successful()); + assertMembershipCopy( + packRoot.resolve("jigsaw-pools/variant/cross/start.json"), + sourcePiece, + targetPiece, + 9, + 0.6D); + assertMembershipCopy( + packRoot.resolve("jigsaw-pools/variant/cross/pieces.json"), + sourcePiece, + targetPiece, + 5, + 0.45D); + assertPieceMetadataCopy(packRoot, sourcePiece, targetPiece); + assertEquals(new IrisBlockVector(16, 16, 16), IrisObject.sampleSize( + packRoot.resolve("objects/" + targetPiece + ".iob").toFile())); + } + + @Test + public void deletesExactVariantMembershipsButProtectsFinalEnabledArchetype() throws Exception { + Path packRoot = createPlanarProject("delete/variant"); + JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily( + packRoot, + "delete/variant", + planarSources("delete/variant"), + "variant-2"); + String corner = creation.pieceKeysByWorkcell().get(JigsawPlanarArchetype.CORNER.stableId()); + + JigsawStudioGraphEditor.PieceDeletionResult deleted = + JigsawStudioGraphEditor.deletePieceVariant(packRoot, "delete/variant", corner); + + assertTrue(deleted.writeResult().successful()); + assertEquals(1, deleted.removedPoolMemberships()); + assertEquals(1, deleted.changedPools()); + assertEquals(1, deleted.removedPieceResources()); + assertEquals(1, deleted.removedObjectResources()); + assertFalse(Files.exists(packRoot.resolve("jigsaw-pieces/" + corner + ".json"))); + assertFalse(Files.exists(packRoot.resolve("objects/" + corner + ".iob"))); + + IOException failure = assertThrows( + IOException.class, + () -> JigsawStudioGraphEditor.deletePieceVariant( + packRoot, + "delete/variant", + "delete/variant/corner")); + assertTrue(failure.getMessage().contains("final variant for enabled planar workcell")); + } + + @Test + public void projectDeletionPlansBlockReferencesAndRejectStaleGraphs() throws Exception { + Path packRoot = createPlanarProject("delete/project"); + Path biome = packRoot.resolve("biomes/reference.json"); + Files.createDirectories(biome.getParent()); + Files.writeString( + biome, + "{\"structures\":[{\"structures\":[\"delete/project\"]}]}", + StandardCharsets.UTF_8); + + JigsawStudioProjectDeletionService.DeletionPlan blocked = + JigsawStudioProjectDeletionService.inspect(packRoot, "delete/project"); + + assertFalse(blocked.deletable()); + assertEquals("biomes/reference.json", blocked.blockers().getFirst().ownerPath()); + assertThrows( + IOException.class, + () -> JigsawStudioProjectDeletionService.delete(blocked)); + + Files.delete(biome); + JigsawStudioProjectDeletionService.DeletionPlan stale = + JigsawStudioProjectDeletionService.inspect(packRoot, "delete/project"); + assertTrue(stale.deletable()); + assertTrue(JigsawStudioStructureEditor.updateLimits( + packRoot, + "delete/project", + 9, + 8).successful()); + IOException staleFailure = assertThrows( + IOException.class, + () -> JigsawStudioProjectDeletionService.delete(stale)); + assertTrue(staleFailure.getMessage().contains("changed after deletion was inspected")); + + JigsawStudioProjectDeletionService.DeletionPlan current = + JigsawStudioProjectDeletionService.inspect(packRoot, "delete/project"); + JigsawStudioProjectDeletionService.ProjectDeletionResult deleted = + JigsawStudioProjectDeletionService.delete(current); + + assertTrue(deleted.manifestRemoved()); + assertTrue(deleted.removedResourceCount() > 0); + assertFalse(Files.exists(packRoot.resolve("structures/delete/project.json"))); + assertFalse(Files.exists(packRoot.resolve("jigsaw-pools/delete/project/start.json"))); + } + + @Test + public void projectDeletionRejectsCoordinatedReferenceWrittenAfterInspectionWithoutRemovingOwnedBytes() + throws Exception { + String structureKey = "delete/coordinated-race"; + Path packRoot = createPlanarProject(structureKey); + JigsawStudioProjectDeletionService.DeletionPlan plan = + JigsawStudioProjectDeletionService.inspect(packRoot, structureKey); + StructureTransactionWriter writer = new StructureTransactionWriter(packRoot); + Path manifestPath = writer.ownershipManifestPath(StructureKey.parse(structureKey, "iris")); + byte[] expectedManifest = Files.readAllBytes(manifestPath); + Map expectedResources = new LinkedHashMap<>(); + for (String relativePath : plan.expectedResourceHashes().keySet()) { + expectedResources.put(relativePath, Files.readAllBytes(packRoot.resolve(relativePath))); + } + + StructureKey referenceOwner = StructureKey.parse("iris:delete/reference-owner"); + StructureResourceBundle referenceBundle = StructureResourceBundle.builder(referenceOwner) + .source(StructureSource.of(StructureSource.Kind.IRIS, referenceOwner)) + .backend(StructureBackend.IRIS_ASSEMBLY) + .textResource( + "biomes/delete/late-reference.json", + "{\"structures\":[{\"structures\":[\"" + structureKey + "\"]}]}") + .build(); + StructureWriteResult competingWrite = writer.write(referenceBundle, StructureWriteMode.ADD_ONLY); + assertEquals(StructureWriteResult.Status.ADDED, competingWrite.status()); + + IOException failure = assertThrows( + IOException.class, + () -> JigsawStudioProjectDeletionService.delete(plan)); + + assertTrue(failure.getMessage().contains("gained 1 reverse references")); + assertArrayEquals(expectedManifest, Files.readAllBytes(manifestPath)); + for (Map.Entry resource : expectedResources.entrySet()) { + assertArrayEquals(resource.getValue(), Files.readAllBytes(packRoot.resolve(resource.getKey()))); + } + } + + @Test + public void projectDeletionThroughSymbolicPackRootFindsReverseReferences() throws Exception { + String structureKey = "delete/symbolic-blocked"; + Path packRoot = createPlanarProject(structureKey); + Path biome = packRoot.resolve("biomes/reference.json"); + Files.createDirectories(biome.getParent()); + Files.writeString( + biome, + "{\"structures\":[{\"structures\":[\"" + structureKey + "\"]}]}", + StandardCharsets.UTF_8); + Path linkedRoot = createSymbolicPackRoot(packRoot, "symbolic-blocked-pack"); + + JigsawStudioProjectDeletionService.DeletionPlan plan = + JigsawStudioProjectDeletionService.inspect(linkedRoot, structureKey); + + assertEquals(packRoot.toRealPath(), plan.packRoot()); + assertFalse(plan.deletable()); + assertEquals("biomes/reference.json", plan.blockers().getFirst().ownerPath()); + assertThrows(IOException.class, () -> JigsawStudioProjectDeletionService.delete(plan)); + assertTrue(Files.exists(packRoot.resolve("structures/" + structureKey + ".json"))); + } + + @Test + public void projectDeletionThroughSymbolicPackRootRemovesCompleteOwnedClosure() throws Exception { + String structureKey = "delete/symbolic-complete"; + Path packRoot = createPlanarProject(structureKey); + StructureTransactionWriter writer = new StructureTransactionWriter(packRoot); + Path manifestPath = writer.ownershipManifestPath(StructureKey.parse(structureKey, "iris")); + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson( + Files.readAllBytes(manifestPath)); + Path linkedRoot = createSymbolicPackRoot(packRoot, "symbolic-complete-pack"); + + JigsawStudioProjectDeletionService.DeletionPlan plan = + JigsawStudioProjectDeletionService.inspect(linkedRoot, structureKey); + JigsawStudioProjectDeletionService.ProjectDeletionResult deleted = + JigsawStudioProjectDeletionService.delete(plan); + + assertEquals(packRoot.toRealPath(), plan.packRoot()); + assertTrue(deleted.manifestRemoved()); + assertEquals(manifest.resourceHashes().size(), deleted.removedResourceCount()); + for (String relativePath : manifest.resourceHashes().keySet()) { + assertFalse(Files.exists(packRoot.resolve(relativePath))); + } + assertFalse(Files.exists(manifestPath)); + } + + @Test + public void chanceUpdateRejectsStaleMembershipIdentityAndInvalidBounds() throws Exception { + Path packRoot = createPlanarProject("chance/test"); + + assertThrows( + IllegalArgumentException.class, + () -> JigsawStudioPoolEditor.updateChanceAtIndex( + packRoot, + "chance/test", + "chance/test/pieces", + 0, + "chance/test/end", + Double.NaN)); + IOException stale = assertThrows( + IOException.class, + () -> JigsawStudioPoolEditor.updateChanceAtIndex( + packRoot, + "chance/test", + "chance/test/pieces", + 0, + "chance/test/straight", + 0.5D)); + assertTrue(stale.getMessage().contains("changed before the update")); + } + + @Test + public void createsSpatialThemeSetFromTheSingleSpatialWorkcellSource() throws Exception { + Path packRoot = temporaryFolder.newFolder("spatial-theme").toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "spatial/theme", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + assertTrue(JigsawStudioProjectCreator.create(packRoot, options).successful()); + + JigsawStudioGraphEditor.VariantFamilyCreation creation = JigsawStudioGraphEditor.duplicateActiveFamily( + packRoot, + "spatial/theme", + Map.of(JigsawStudioLayout.SPATIAL_WORKCELL_ID, "spatial/theme/start"), + "variant-2"); + + assertTrue(creation.writeResult().successful()); + assertEquals( + "spatial/theme/variants/spatial/variant-2", + creation.pieceKeysByWorkcell().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID)); + JsonObject pool = readJson(packRoot.resolve("jigsaw-pools/spatial/theme/start.json")); + assertEquals(2, pool.getAsJsonArray("pieces").size()); + } + + @Test + public void persistsVariantPlanarWorkcellAndSpatialWorkcellLabelsWithoutRewritingObjects() throws Exception { + Path planarRoot = createPlanarProject("labels/planar"); + Path planarObject = planarRoot.resolve("objects/labels/planar/end.iob"); + Path planarPool = planarRoot.resolve("jigsaw-pools/labels/planar/pieces.json"); + byte[] objectBefore = Files.readAllBytes(planarObject); + byte[] poolBefore = Files.readAllBytes(planarPool); + + assertTrue(JigsawStudioGraphEditor.updatePieceDisplayName( + planarRoot, + "labels/planar", + "labels/planar/end", + "Grand Longhouse").successful()); + assertTrue(JigsawStudioStructureEditor.updateWorkcellDisplayName( + planarRoot, + "labels/planar", + JigsawPlanarArchetype.END, + "Village Entrances").successful()); + + assertArrayEquals(objectBefore, Files.readAllBytes(planarObject)); + assertArrayEquals(poolBefore, Files.readAllBytes(planarPool)); + assertEquals("Grand Longhouse", readJson(planarRoot.resolve( + "jigsaw-pieces/labels/planar/end.json")).get("displayName").getAsString()); + JsonObject planarStructure = readJson(planarRoot.resolve("structures/labels/planar.json")); + assertEquals("Village Entrances", workcell( + planarStructure.getAsJsonArray("planarWorkcells"), + "END").get("displayName").getAsString()); + + Path spatialRoot = temporaryFolder.newFolder("labels-spatial").toPath(); + JigsawStudioProjectCreator.Options spatialOptions = new JigsawStudioProjectCreator.Options( + "labels/spatial", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + assertTrue(JigsawStudioProjectCreator.create(spatialRoot, spatialOptions).successful()); + Path spatialObject = spatialRoot.resolve("objects/labels/spatial/start.iob"); + byte[] spatialBefore = Files.readAllBytes(spatialObject); + + assertTrue(JigsawStudioStructureEditor.updateSpatialWorkcellDisplayName( + spatialRoot, + "labels/spatial", + "Stronghold Rooms").successful()); + + assertArrayEquals(spatialBefore, Files.readAllBytes(spatialObject)); + assertEquals("Stronghold Rooms", readJson(spatialRoot.resolve( + "structures/labels/spatial.json")).get("spatialWorkcellDisplayName").getAsString()); + } + + @Test + public void displayLabelsUseSixtyFourVisibleCodePointsAndRejectFormattingControls() { + String sixtyFourEmoji = "😀".repeat(64); + + assertEquals(sixtyFourEmoji, JigsawStudioGraphEditor.normalizeDisplayName(sixtyFourEmoji)); + assertEquals("Trimmed Name", JigsawStudioGraphEditor.normalizeDisplayName(" Trimmed Name ")); + assertThrows(IllegalArgumentException.class, + () -> JigsawStudioGraphEditor.normalizeDisplayName("😀".repeat(65))); + assertThrows(IllegalArgumentException.class, + () -> JigsawStudioGraphEditor.normalizeDisplayName("Line\nBreak")); + assertThrows(IllegalArgumentException.class, + () -> JigsawStudioGraphEditor.normalizeDisplayName("§aFormatted")); + } + + private Path createPlanarProject(String structureKey) throws Exception { + Path packRoot = temporaryFolder.newFolder(structureKey.replace('/', '-')).toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + structureKey, + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + StructureWriteResult result = JigsawStudioProjectCreator.create(packRoot, options); + assertTrue(result.successful()); + return packRoot; + } + + private Path createSymbolicPackRoot(Path packRoot, String linkName) throws IOException { + Path linkedRoot = temporaryFolder.getRoot().toPath().resolve(linkName); + try { + Files.createSymbolicLink(linkedRoot, packRoot); + } catch (IOException | UnsupportedOperationException exception) { + Assume.assumeNoException(exception); + } + return linkedRoot; + } + + private static Map planarSources(String structureKey) { + Map sources = new LinkedHashMap<>(); + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + sources.put( + archetype.stableId(), + structureKey + "/" + archetype.name().toLowerCase(Locale.ROOT)); + } + return sources; + } + + private static void assertMembershipCopy( + Path poolPath, + String sourcePiece, + String targetPiece, + int weight, + double chance + ) throws IOException { + JsonArray entries = readJson(poolPath).getAsJsonArray("pieces"); + JsonObject source = null; + JsonObject target = null; + for (JsonElement entry : entries) { + if (!entry.isJsonObject() || !entry.getAsJsonObject().has("piece")) { + continue; + } + JsonObject membership = entry.getAsJsonObject(); + if (sourcePiece.equals(membership.get("piece").getAsString())) { + source = membership; + } else if (targetPiece.equals(membership.get("piece").getAsString())) { + target = membership; + } + } + assertTrue(source != null); + assertTrue(target != null); + JsonObject expected = source.deepCopy(); + expected.addProperty("piece", targetPiece); + assertEquals(expected, target); + assertEquals(weight, target.get("weight").getAsInt()); + assertEquals(chance, target.get("chance").getAsDouble(), 0D); + } + + private static void assertPieceMetadataCopy( + Path packRoot, + String sourcePiece, + String targetPiece + ) throws IOException { + JsonObject expected = readJson(packRoot.resolve("jigsaw-pieces/" + sourcePiece + ".json")); + expected.addProperty("object", targetPiece); + JsonObject target = readJson(packRoot.resolve("jigsaw-pieces/" + targetPiece + ".json")); + assertTrue(expected.has("collidable")); + assertEquals(expected.get("collidable"), target.get("collidable")); + assertEquals(expected, target); + } + + private static JsonObject readJson(Path path) throws IOException { + return JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private static JsonObject workcell(JsonArray workcells, String archetype) { + for (JsonElement element : workcells) { + JsonObject workcell = element.getAsJsonObject(); + if (archetype.equals(workcell.get("archetype").getAsString())) { + return workcell; + } + } + throw new AssertionError("Missing workcell " + archetype); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreatorTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreatorTest.java new file mode 100644 index 000000000..afb6e6cc8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioProjectCreatorTest.java @@ -0,0 +1,497 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.core.structure.authoring.StructureResourceBundle; +import art.arcane.iris.engine.framework.StructureAssembler; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; +import art.arcane.iris.engine.framework.structure.StructureAssemblyStatus; +import art.arcane.iris.engine.framework.structure.StructureGraphCompilation; +import art.arcane.iris.engine.framework.structure.StructureResourceBundleGraphCompiler; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.volmlib.util.math.RNG; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class JigsawStudioProjectCreatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void createsCompletePlanarProjectAtomicallyWithoutOverwritingIt() throws Exception { + Path temporaryDirectory = temporaryFolder.getRoot().toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "settlement/test", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, + new JigsawStudioCellDimensions(24, 12, 18)); + + StructureResourceBundleGraphCompiler.requireViable(JigsawStudioProjectCreator.bundle(options)); + + StructureWriteResult created = JigsawStudioProjectCreator.create(temporaryDirectory, options); + + assertTrue(created.successful()); + assertTrue(Files.isRegularFile(temporaryDirectory.resolve("structures/settlement/test.json"))); + assertTrue(Files.isRegularFile(temporaryDirectory.resolve("jigsaw-pools/settlement/test/start.json"))); + assertTrue(Files.isRegularFile(temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"))); + assertTrue(Files.isRegularFile(temporaryDirectory.resolve("jigsaw-pools/settlement/test/caps.json"))); + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + String key = archetype.name().toLowerCase(); + assertTrue(Files.isRegularFile( + temporaryDirectory.resolve("jigsaw-pieces/settlement/test/" + key + ".json"))); + assertTrue(Files.isRegularFile( + temporaryDirectory.resolve("objects/settlement/test/" + key + ".iob"))); + } + + JsonObject structure = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("structures/settlement/test.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals("settlement/test/start", structure.get("startPool").getAsString()); + assertEquals("PLANAR_JIGSAW", structure.get("mode").getAsString()); + assertEquals("VANILLA_PORTABLE", structure.get("compatibility").getAsString()); + assertEquals("TERMINATE_BRANCH", structure.get("branchFailurePolicy").getAsString()); + assertEquals(24, structure.getAsJsonObject("cellSize").get("x").getAsInt()); + assertEquals(6, structure.getAsJsonArray("planarWorkcells").size()); + assertTrue(structure.getAsJsonArray("themeSets").isEmpty()); + + JsonObject startPool = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pools/settlement/test/start.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(1, startPool.getAsJsonArray("pieces").size()); + assertEquals("settlement/test/cross", startPool.getAsJsonArray("pieces").get(0) + .getAsJsonObject().get("piece").getAsString()); + JsonObject piecePool = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(5, piecePool.getAsJsonArray("pieces").size()); + assertEquals("settlement/test/caps", piecePool.get("fallback").getAsString()); + assertFalse(Files.readString( + temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"), + StandardCharsets.UTF_8).contains("settlement/test/blank")); + JsonObject capPool = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pools/settlement/test/caps.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject emptyCap = capPool.getAsJsonArray("pieces").get(1).getAsJsonObject(); + assertTrue(emptyCap.get("empty").getAsBoolean()); + assertFalse(emptyCap.has("piece")); + JsonObject cross = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pieces/settlement/test/cross.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(4, cross.getAsJsonArray("connectors").size()); + assertEquals("settlement/test/pieces", cross.getAsJsonArray("connectors").get(0) + .getAsJsonObject().get("pool").getAsString()); + assertTrue(cross.getAsJsonArray("themes").isEmpty()); + + StructureWriteResult duplicate = JigsawStudioProjectCreator.create(temporaryDirectory, options); + assertFalse(duplicate.successful()); + assertEquals(StructureWriteResult.Status.ADD_ONLY_CONFLICT, duplicate.status()); + + assertTrue(JigsawStudioStructureEditor.updateWorkcellDimensions( + temporaryDirectory, + "settlement/test", + JigsawPlanarArchetype.BLANK, + new JigsawStudioCellDimensions(32, 16, 20)).successful()); + assertTrue(JigsawStudioStructureEditor.updateWorkcellEnabled( + temporaryDirectory, + "settlement/test", + JigsawPlanarArchetype.BLANK, + false).successful()); + + IOException capacityFailure = assertThrows(IOException.class, () -> + JigsawStudioStructureEditor.updateWorkcellDimensions( + temporaryDirectory, + "settlement/test", + JigsawPlanarArchetype.END, + new JigsawStudioCellDimensions(8, 8, 8))); + assertTrue(capacityFailure.getMessage().contains("cannot contain")); + assertEquals(new IrisBlockVector(24, 12, 18), IrisObject.sampleSize( + temporaryDirectory.resolve("objects/settlement/test/end.iob").toFile())); + JsonObject editedStructure = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("structures/settlement/test.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals("TERMINATE_BRANCH", + editedStructure.get("branchFailurePolicy").getAsString()); + } + + @Test + public void rejectsNewPlanarCellsTooSmallForDistinctGlyphs() { + try { + new JigsawStudioProjectCreator.Options( + "settlement/tiny", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(2, 8, 2)); + fail("Expected undersized planar authoring cells to be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("at least 3")); + } + } + + @Test + public void defaultIrisPlanarProjectCanEnableMandatoryCaps() throws Exception { + Path temporaryDirectory = temporaryFolder.getRoot().toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "caps/default", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + + assertTrue(JigsawStudioProjectCreator.create(temporaryDirectory, options).successful()); + assertTrue(JigsawStudioStructureEditor.updateRequireCaps( + temporaryDirectory, + "caps/default", + true).successful()); + + JsonObject structure = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("structures/caps/default.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject end = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pieces/caps/default/end.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertTrue(structure.get("requireCaps").getAsBoolean()); + assertEquals("FAIL_ASSEMBLY", structure.get("branchFailurePolicy").getAsString()); + assertTrue(end.getAsJsonObject("rules").get("terminal").getAsBoolean()); + } + + @Test + public void defaultMandatoryCapsCompleteAtCompilerAndPreviewSeeds() throws Exception { + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "caps/seeds", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + StructureResourceBundle source = JigsawStudioProjectCreator.bundle(options); + StructureResourceBundle.Builder builder = StructureResourceBundle.builder(source.key()) + .source(source.source()) + .backend(source.backend()) + .capabilities(source.capabilities()) + .losses(source.losses()); + String structurePath = "structures/caps/seeds.json"; + for (StructureResourceBundle.Resource resource : source.resources().values()) { + byte[] content = resource.content(); + if (resource.relativePath().equals(structurePath)) { + JsonObject structure = JsonParser.parseString( + new String(content, StandardCharsets.UTF_8)).getAsJsonObject(); + structure.addProperty("requireCaps", true); + content = structure.toString().getBytes(StandardCharsets.UTF_8); + } + builder.resource(resource.relativePath(), content); + } + StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile( + builder.build()).getFirst(); + + for (long seed : List.of(0L, 1337L)) { + StructureAssemblyResult result = StructureAssembler.forCompilation( + compilation, + new IrisPosition(0, 64, 0)).assemble(new RNG(seed)); + + assertEquals(result.detail(), StructureAssemblyStatus.COMPLETE, result.status()); + } + } + + @Test + public void updatesOwnedPoolThroughWholeGraphTransaction() throws Exception { + Path temporaryDirectory = temporaryFolder.getRoot().toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "settlement/test", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + assertTrue(JigsawStudioProjectCreator.create(temporaryDirectory, options).successful()); + Path pool = temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"); + + StructureWriteResult terminalPool = JigsawStudioGraphEditor.createPool( + temporaryDirectory, + "settlement/test", + "settlement/test/terminal", + ""); + assertTrue(terminalPool.successful()); + assertTrue(Files.isRegularFile( + temporaryDirectory.resolve("jigsaw-pools/settlement/test/terminal.json"))); + + JigsawStudioPoolEditor.PoolUpdate fallback = JigsawStudioPoolEditor.updateFallback( + temporaryDirectory, + "settlement/test", + "settlement/test/start", + "settlement/test/terminal"); + assertTrue(fallback.changed()); + + JigsawStudioPoolEditor.WeightUpdate update = JigsawStudioPoolEditor.updateWeight( + temporaryDirectory, + "settlement/test", + "settlement/test/pieces", + "settlement/test/end", + 9); + + assertTrue(update.changed()); + assertEquals(1, update.changedEntries()); + assertNotNull(update.writeResult()); + assertTrue(update.writeResult().successful()); + JsonObject saved = JsonParser.parseString(Files.readString(pool, StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(9, saved.getAsJsonArray("pieces").get(0).getAsJsonObject() + .get("weight").getAsInt()); + + assertTrue(JigsawStudioGraphEditor.createPiece( + temporaryDirectory, + "settlement/test", + "settlement/test/pieces", + "settlement/test/side", + 2, + new JigsawStudioCellDimensions(16, 16, 16), + JigsawPlanarTopology.NORTH_EAST_CORNER).successful()); + JsonObject sidePiece = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pieces/settlement/test/side.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(2, sidePiece.getAsJsonArray("connectors").size()); + JsonObject northConnector = sidePiece.getAsJsonArray("connectors").get(0).getAsJsonObject(); + assertEquals("NORTH_NEGATIVE_Z", northConnector.get("direction").getAsString()); + assertEquals(8, northConnector.getAsJsonObject("position").get("x").getAsInt()); + assertEquals(8, northConnector.getAsJsonObject("position").get("y").getAsInt()); + assertEquals(0, northConnector.getAsJsonObject("position").get("z").getAsInt()); + assertEquals("minecraft:structure_void", northConnector.get("finalState").getAsString()); + assertTrue(JigsawStudioGraphEditor.updateConnectorChannel( + temporaryDirectory, + "settlement/test", + "settlement/test/side", + new IrisPosition(8, 8, 0), + "village/road").successful()); + JsonObject channeledPiece = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pieces/settlement/test/side.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals("village/road", channeledPiece.getAsJsonArray("connectors") + .get(0).getAsJsonObject().get("channel").getAsString()); + JigsawStudioPoolEditor.PoolUpdate removed = JigsawStudioPoolEditor.removePiece( + temporaryDirectory, + "settlement/test", + "settlement/test/pieces", + "settlement/test/side"); + assertTrue(removed.changed()); + JigsawStudioPoolEditor.PoolUpdate added = JigsawStudioPoolEditor.addPiece( + temporaryDirectory, + "settlement/test", + "settlement/test/pieces", + "settlement/test/side", + 4); + assertTrue(added.changed()); + JsonObject reloaded = JsonParser.parseString( + Files.readString(pool, StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(4, reloaded.getAsJsonArray("pieces").get(5).getAsJsonObject() + .get("weight").getAsInt()); + } + + @Test + public void createsOwnedBlankPieceObjectAndPoolEntryInOneTransaction() throws Exception { + Path temporaryDirectory = temporaryFolder.getRoot().toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "stronghold/test", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(12, 10, 14)); + assertTrue(JigsawStudioProjectCreator.create(temporaryDirectory, options).successful()); + + StructureWriteResult result = JigsawStudioGraphEditor.createPiece( + temporaryDirectory, + "stronghold/test", + "stronghold/test/start", + "stronghold/test/hall", + 3, + new JigsawStudioCellDimensions(12, 10, 14), + null); + + assertTrue(result.successful()); + assertTrue(Files.isRegularFile( + temporaryDirectory.resolve("jigsaw-pieces/stronghold/test/hall.json"))); + assertTrue(Files.isRegularFile( + temporaryDirectory.resolve("objects/stronghold/test/hall.iob"))); + assertTrue(JigsawStudioGraphEditor.ownsPiece( + temporaryDirectory, + "stronghold/test", + "stronghold/test/hall", + "stronghold/test/hall")); + assertTrue(JigsawStudioGraphEditor.updateRotatable( + temporaryDirectory, + "stronghold/test", + "stronghold/test/hall", + false).successful()); + JsonObject hallPiece = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pieces/stronghold/test/hall.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertFalse(hallPiece.get("rotatable").getAsBoolean()); + JsonObject pool = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pools/stronghold/test/start.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(2, pool.getAsJsonArray("pieces").size()); + assertEquals("stronghold/test/hall", pool.getAsJsonArray("pieces").get(1) + .getAsJsonObject().get("piece").getAsString()); + assertEquals(3, pool.getAsJsonArray("pieces").get(1) + .getAsJsonObject().get("weight").getAsInt()); + + StructureWriteResult resized = JigsawStudioStructureEditor.updateCellSize( + temporaryDirectory, + "stronghold/test", + new JigsawStudioCellDimensions(16, 12, 18)); + assertTrue(resized.successful()); + JsonObject structure = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("structures/stronghold/test.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(16, structure.getAsJsonObject("cellSize").get("x").getAsInt()); + + assertTrue(JigsawStudioGraphEditor.resizePieceObject( + temporaryDirectory, + "stronghold/test", + "stronghold/test/hall", + new JigsawStudioCellDimensions(16, 12, 18)).writeResult().successful()); + IrisBlockVector hallSize = IrisObject.sampleSize( + temporaryDirectory.resolve("objects/stronghold/test/hall.iob").toFile()); + IrisBlockVector startSize = IrisObject.sampleSize( + temporaryDirectory.resolve("objects/stronghold/test/start.iob").toFile()); + assertEquals(new IrisBlockVector(16, 12, 18), hallSize); + assertEquals(new IrisBlockVector(12, 10, 14), startSize); + + StructureWriteResult limited = JigsawStudioStructureEditor.updateLimits( + temporaryDirectory, + "stronghold/test", + 12, + 6); + assertTrue(limited.successful()); + JsonObject limitedStructure = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("structures/stronghold/test.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(12, limitedStructure.get("maxDepth").getAsInt()); + assertEquals(6, limitedStructure.get("maxSizeChunks").getAsInt()); + } + + @Test + public void resizesRotatedPlanarVariantToCanonicalDimensionsAndRelocatesSockets() throws Exception { + Path temporaryDirectory = temporaryFolder.getRoot().toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "settlement/rectangular", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(10, 6, 14)); + assertTrue(JigsawStudioProjectCreator.create(temporaryDirectory, options).successful()); + assertTrue(JigsawStudioGraphEditor.createPiece( + temporaryDirectory, + "settlement/rectangular", + "settlement/rectangular/pieces", + "settlement/rectangular/east-end", + 1, + new JigsawStudioCellDimensions(14, 6, 10), + JigsawPlanarTopology.EAST_END).successful()); + assertTrue(JigsawStudioStructureEditor.updateWorkcellDimensions( + temporaryDirectory, + "settlement/rectangular", + JigsawPlanarArchetype.END, + new JigsawStudioCellDimensions(12, 8, 18)).successful()); + + JigsawStudioGraphEditor.VariantResizeResult resized = JigsawStudioGraphEditor.resizePieceObject( + temporaryDirectory, + "settlement/rectangular", + "settlement/rectangular/east-end", + new JigsawStudioCellDimensions(12, 8, 18)); + + assertTrue(resized.writeResult().successful()); + assertEquals(new IrisBlockVector(18, 8, 12), IrisObject.sampleSize( + temporaryDirectory.resolve("objects/settlement/rectangular/east-end.iob").toFile())); + JsonObject piece = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pieces/settlement/rectangular/east-end.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject connector = piece.getAsJsonArray("connectors").get(0).getAsJsonObject(); + assertEquals(17, connector.getAsJsonObject("position").get("x").getAsInt()); + assertEquals(4, connector.getAsJsonObject("position").get("y").getAsInt()); + assertEquals(6, connector.getAsJsonObject("position").get("z").getAsInt()); + } + + @Test + public void duplicatesVariantsAndEditsOnePinnedPoolEntry() throws Exception { + Path temporaryDirectory = temporaryFolder.getRoot().toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + "settlement/test", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + assertTrue(JigsawStudioProjectCreator.create(temporaryDirectory, options).successful()); + + List pools = JigsawStudioGraphEditor.ownedPoolKeys( + temporaryDirectory, + "settlement/test"); + assertEquals(List.of("settlement/test/caps", "settlement/test/pieces", "settlement/test/start"), pools); + String firstKey = JigsawStudioGraphEditor.nextVariantKey( + temporaryDirectory, + "settlement/test", + "end"); + assertEquals("settlement/test/variants/end/variant-1", firstKey); + assertTrue(JigsawStudioGraphEditor.duplicatePiece( + temporaryDirectory, + "settlement/test", + "settlement/test/end", + firstKey).successful()); + assertEquals("settlement/test/variants/end/variant-2", + JigsawStudioGraphEditor.nextVariantKey( + temporaryDirectory, + "settlement/test", + "end")); + assertTrue(Files.mismatch( + temporaryDirectory.resolve("objects/settlement/test/end.iob"), + temporaryDirectory.resolve("objects/settlement/test/variants/end/variant-1.iob")) == -1L); + + JigsawStudioPoolEditor.WeightUpdate weight = JigsawStudioPoolEditor.updateWeightAtIndex( + temporaryDirectory, + "settlement/test", + "settlement/test/pieces", + 1, + firstKey, + 7); + assertTrue(weight.changed()); + assertEquals(1, weight.changedEntries()); + JsonObject pool = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(1, pool.getAsJsonArray("pieces").get(0).getAsJsonObject() + .get("weight").getAsInt()); + assertEquals(7, pool.getAsJsonArray("pieces").get(1).getAsJsonObject() + .get("weight").getAsInt()); + + try { + JigsawStudioPoolEditor.updateWeightAtIndex( + temporaryDirectory, + "settlement/test", + "settlement/test/pieces", + 1, + "settlement/test/end", + 8); + fail("Expected a stale pool-entry identity to be rejected"); + } catch (java.io.IOException expected) { + assertTrue(expected.getMessage().contains("changed before the update")); + } + + JigsawStudioPoolEditor.PoolUpdate removed = JigsawStudioPoolEditor.removeEntry( + temporaryDirectory, + "settlement/test", + "settlement/test/pieces", + 1, + firstKey); + assertTrue(removed.changed()); + JsonObject afterRemoval = JsonParser.parseString(Files.readString( + temporaryDirectory.resolve("jigsaw-pools/settlement/test/pieces.json"), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertEquals(5, afterRemoval.getAsJsonArray("pieces").size()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSessionTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSessionTest.java new file mode 100644 index 000000000..5d4ee232a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioSessionTest.java @@ -0,0 +1,289 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class JigsawStudioSessionTest { + private static final JigsawStudioCellDimensions CELL = new JigsawStudioCellDimensions(9, 6, 9); + + @Test + public void initializesOneActiveVariantPerPopulatedWorkcell() { + JigsawStudioVariant north = planarVariant("village/north", JigsawPlanarTopology.NORTH_END); + JigsawStudioVariant corner = planarVariant("village/corner", JigsawPlanarTopology.NORTH_EAST_CORNER); + JigsawStudioLayout layout = planarLayout(north, corner); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", layout); + + assertSame(north, session.activeVariant("workcell/end").orElseThrow()); + assertSame(corner, session.activeVariant("workcell/corner").orElseThrow()); + assertTrue(session.activeVariant("workcell/blank").isEmpty()); + assertTrue(session.selectBay("workcell/end")); + assertEquals("workcell/end", session.selectedBayId().orElseThrow()); + assertFalse(session.selectBay("topology/01")); + assertFalse(session.isDirty()); + } + + @Test + public void switchKeepsCommittedVariantUntilExpectedCompletion() { + JigsawStudioVariant north = planarVariant("village/north", JigsawPlanarTopology.NORTH_END); + JigsawStudioVariant east = planarVariant("village/east", JigsawPlanarTopology.EAST_END); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", planarLayout(north, east)); + long initialLoad = session.workcellSnapshot("workcell/end").loadGeneration(); + + assertEquals( + JigsawStudioSession.DirtyStatus.MARKED, + session.markWorkcellDirty("workcell/end").status()); + assertEquals( + JigsawStudioSession.SwitchStatus.DIRTY, + session.beginVariantSwitch("workcell/end", east.pieceKey(), false).status()); + JigsawStudioSession.SwitchStart start = session.beginVariantSwitch( + "workcell/end", east.pieceKey(), true); + JigsawStudioSession.VariantSwitchToken token = start.token().orElseThrow(); + + assertEquals(JigsawStudioSession.SwitchStatus.STARTED, start.status()); + assertSame(north, token.previousVariant().orElseThrow()); + assertSame(east, token.targetVariant()); + assertEquals(initialLoad, token.loadGeneration()); + assertSame(north, session.activeVariant("workcell/end").orElseThrow()); + assertTrue(session.isVariantSwitchCurrent(token)); + assertEquals( + JigsawStudioSession.DirtyStatus.SWITCH_IN_PROGRESS, + session.markWorkcellDirty("workcell/end").status()); + assertTrue(session.completeVariantSwitch(token)); + assertSame(east, session.activeVariant("workcell/end").orElseThrow()); + assertTrue(session.workcellSnapshot("workcell/end").loadGeneration() > initialLoad); + assertFalse(session.workcellSnapshot("workcell/end").dirty()); + assertFalse(session.isVariantSwitchCurrent(token)); + } + + @Test + public void saveClearsDirtyOnlyWhenIdentityStillMatches() { + JigsawStudioVariant end = planarVariant("village/end", JigsawPlanarTopology.NORTH_END); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", planarLayout(end)); + JigsawStudioSession.DirtyMark initialMark = session.markWorkcellDirty("workcell/end"); + assertEquals(JigsawStudioSession.DirtyStatus.MARKED, initialMark.status()); + assertTrue(initialMark.newlyDirty()); + JigsawStudioSession.SaveIdentity first = session.beginSave("workcell/end").identity().orElseThrow(); + + assertTrue(session.isSaveCurrent(first)); + JigsawStudioSession.DirtyMark laterMark = session.markWorkcellDirty("workcell/end"); + assertEquals(JigsawStudioSession.DirtyStatus.MARKED, laterMark.status()); + assertFalse(laterMark.newlyDirty()); + assertFalse(session.isSaveCurrent(first)); + assertFalse(session.markWorkcellSaved(first)); + assertTrue(session.workcellSnapshot("workcell/end").dirty()); + assertFalse(session.workcellSnapshot("workcell/end").saveInProgress()); + assertTrue(session.isDirtyCurrent(laterMark.identity().orElseThrow())); + + JigsawStudioSession.SaveIdentity second = session.beginSave("workcell/end").identity().orElseThrow(); + assertTrue(session.isSaveCurrent(second)); + assertTrue(session.markWorkcellSaved(second)); + assertFalse(session.workcellSnapshot("workcell/end").dirty()); + assertFalse(session.isDirty()); + } + + @Test + public void dirtyIdentityAdvancesForEveryAcceptedEdit() { + JigsawStudioVariant end = planarVariant("village/end", JigsawPlanarTopology.NORTH_END); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", planarLayout(end)); + + JigsawStudioSession.DirtyMark first = session.markWorkcellDirty("workcell/end"); + JigsawStudioSession.DirtyMark second = session.markWorkcellDirty("workcell/end"); + JigsawStudioSession.DirtyIdentity firstIdentity = first.identity().orElseThrow(); + JigsawStudioSession.DirtyIdentity secondIdentity = second.identity().orElseThrow(); + + assertTrue(first.newlyDirty()); + assertFalse(second.newlyDirty()); + assertFalse(session.isDirtyCurrent(firstIdentity)); + assertTrue(session.isDirtyCurrent(secondIdentity)); + assertTrue(secondIdentity.mutationGeneration() > firstIdentity.mutationGeneration()); + + JigsawStudioSession.SaveIdentity save = session.beginSave("workcell/end").identity().orElseThrow(); + assertTrue(session.markWorkcellSaved(save)); + assertFalse(session.isDirtyCurrent(secondIdentity)); + } + + @Test + public void dirtyMarkReportsWhyAnEditCannotBeTracked() { + JigsawStudioSession empty = new JigsawStudioSession("overworld", "village", planarLayout()); + + assertEquals( + JigsawStudioSession.DirtyStatus.UNKNOWN_WORKCELL, + empty.markWorkcellDirty("workcell/missing").status()); + JigsawStudioSession.DirtyMark noVariant = empty.markWorkcellDirty("workcell/end"); + assertEquals(JigsawStudioSession.DirtyStatus.NO_ACTIVE_VARIANT, noVariant.status()); + assertTrue(noVariant.identity().isEmpty()); + assertFalse(noVariant.newlyDirty()); + } + + @Test + public void saveAndSwitchReservationsExcludeEachOtherAndAbortSafely() { + JigsawStudioVariant north = planarVariant("village/north", JigsawPlanarTopology.NORTH_END); + JigsawStudioVariant east = planarVariant("village/east", JigsawPlanarTopology.EAST_END); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", planarLayout(north, east)); + JigsawStudioSession.SaveIdentity save = session.beginSave("workcell/end").identity().orElseThrow(); + + assertEquals( + JigsawStudioSession.SwitchStatus.SAVE_IN_PROGRESS, + session.beginVariantSwitch("workcell/end", east.pieceKey(), false).status()); + assertTrue(session.abortSave(save)); + JigsawStudioSession.VariantSwitchToken switchToken = session.beginVariantSwitch( + "workcell/end", east.pieceKey(), false).token().orElseThrow(); + assertEquals( + JigsawStudioSession.SaveStatus.SWITCH_IN_PROGRESS, + session.beginSave("workcell/end").status()); + assertTrue(session.abortVariantSwitch(switchToken)); + assertSame(north, session.activeVariant("workcell/end").orElseThrow()); + assertFalse(session.isVariantSwitchCurrent(switchToken)); + assertFalse(session.completeVariantSwitch(switchToken)); + } + + @Test + public void replacementOperationsRejectPreviouslyAbortedTokens() { + JigsawStudioVariant north = planarVariant("village/north", JigsawPlanarTopology.NORTH_END); + JigsawStudioVariant east = planarVariant("village/east", JigsawPlanarTopology.EAST_END); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", planarLayout(north, east)); + JigsawStudioSession.VariantSwitchToken firstSwitch = session.beginVariantSwitch( + "workcell/end", east.pieceKey(), false).token().orElseThrow(); + assertTrue(session.abortVariantSwitch(firstSwitch)); + JigsawStudioSession.VariantSwitchToken secondSwitch = session.beginVariantSwitch( + "workcell/end", east.pieceKey(), false).token().orElseThrow(); + + assertFalse(session.isVariantSwitchCurrent(firstSwitch)); + assertTrue(session.isVariantSwitchCurrent(secondSwitch)); + assertFalse(session.completeVariantSwitch(firstSwitch)); + assertTrue(session.abortVariantSwitch(secondSwitch)); + + JigsawStudioSession.SaveIdentity firstSave = session.beginSave("workcell/end").identity().orElseThrow(); + assertTrue(session.abortSave(firstSave)); + JigsawStudioSession.SaveIdentity secondSave = session.beginSave("workcell/end").identity().orElseThrow(); + + assertFalse(session.isSaveCurrent(firstSave)); + assertTrue(session.isSaveCurrent(secondSave)); + assertFalse(session.markWorkcellSaved(firstSave)); + assertTrue(session.abortSave(secondSave)); + } + + @Test + public void replacingCatalogRetainsOrFallsBackWithoutMovingWorkcells() { + JigsawStudioVariant north = planarVariant("village/north", JigsawPlanarTopology.NORTH_END); + JigsawStudioVariant east = planarVariant("village/east", JigsawPlanarTopology.EAST_END); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", planarLayout(north, east)); + JigsawStudioSession.VariantSwitchToken switchToken = session.beginVariantSwitch( + "workcell/end", east.pieceKey(), false).token().orElseThrow(); + assertTrue(session.completeVariantSwitch(switchToken)); + long selectedLoad = session.workcellSnapshot("workcell/end").loadGeneration(); + + assertTrue(session.replaceLayout(planarLayout(north, east))); + assertSame(east, session.activeVariant("workcell/end").orElseThrow()); + assertEquals(selectedLoad, session.workcellSnapshot("workcell/end").loadGeneration()); + + assertTrue(session.replaceLayout(planarLayout(north))); + assertSame(north, session.activeVariant("workcell/end").orElseThrow()); + assertTrue(session.workcellSnapshot("workcell/end").loadGeneration() > selectedLoad); + } + + @Test + public void replacingCatalogDoesNotMaterializeNewVariantsIntoPreviouslyEmptyWorkcells() { + JigsawStudioSession session = new JigsawStudioSession( + "overworld", + "village", + planarLayout()); + JigsawStudioVariant end = planarVariant("village/end", JigsawPlanarTopology.NORTH_END); + + assertTrue(session.replaceLayout(planarLayout(end))); + + assertTrue(session.activeVariant("workcell/end").isEmpty()); + assertEquals("", session.workcellSnapshot("workcell/end").activeVariantKey()); + } + + @Test + public void coherentFamilyRebindIsAtomicAcrossEveryWorkcell() { + JigsawStudioVariant endOne = planarVariant("village/end-one", JigsawPlanarTopology.NORTH_END); + JigsawStudioVariant endTwo = planarVariant("village/end-two", JigsawPlanarTopology.EAST_END); + JigsawStudioVariant cornerOne = planarVariant( + "village/corner-one", + JigsawPlanarTopology.NORTH_EAST_CORNER); + JigsawStudioVariant cornerTwo = planarVariant( + "village/corner-two", + JigsawPlanarTopology.SOUTH_WEST_CORNER); + JigsawStudioSession session = new JigsawStudioSession( + "overworld", + "village", + planarLayout(endOne, cornerOne)); + JigsawStudioLayout familyLayout = planarLayout(endOne, endTwo, cornerOne, cornerTwo); + + assertTrue(session.replaceLayoutAndRebind(familyLayout, Map.of( + "workcell/end", endTwo.pieceKey(), + "workcell/corner", cornerTwo.pieceKey()))); + assertSame(endTwo, session.activeVariant("workcell/end").orElseThrow()); + assertSame(cornerTwo, session.activeVariant("workcell/corner").orElseThrow()); + + assertThrows(IllegalArgumentException.class, () -> session.replaceLayoutAndRebind( + familyLayout, + Map.of( + "workcell/end", cornerOne.pieceKey(), + "workcell/corner", cornerTwo.pieceKey()))); + assertSame(endTwo, session.activeVariant("workcell/end").orElseThrow()); + assertSame(cornerTwo, session.activeVariant("workcell/corner").orElseThrow()); + } + + @Test + public void activeVariantCanReserveAnExactLiveReloadAfterResize() { + JigsawStudioVariant end = planarVariant("village/end", JigsawPlanarTopology.NORTH_END); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", planarLayout(end)); + long originalLoad = session.workcellSnapshot("workcell/end").loadGeneration(); + + JigsawStudioSession.SwitchStart reload = session.beginVariantReload("workcell/end"); + + assertEquals(JigsawStudioSession.SwitchStatus.STARTED, reload.status()); + assertEquals(end.pieceKey(), reload.token().orElseThrow().targetVariant().pieceKey()); + assertTrue(session.completeVariantSwitch(reload.token().orElseThrow())); + assertTrue(session.workcellSnapshot("workcell/end").loadGeneration() > originalLoad); + } + + @Test + public void reportsDirtyWorkcellsByStablePhysicalIdentity() { + JigsawStudioVariant end = planarVariant("village/end", JigsawPlanarTopology.NORTH_END); + JigsawStudioVariant corner = planarVariant("village/corner", JigsawPlanarTopology.NORTH_EAST_CORNER); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", planarLayout(end, corner)); + + assertEquals( + JigsawStudioSession.DirtyStatus.MARKED, + session.markWorkcellDirty("workcell/corner").status()); + assertEquals( + JigsawStudioSession.DirtyStatus.MARKED, + session.markWorkcellDirty("workcell/end").status()); + assertEquals(List.of("workcell/end", "workcell/corner"), session.dirtyWorkcellIds()); + } + + private static JigsawStudioLayout planarLayout(JigsawStudioVariant... variants) { + return JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + CELL, + new JigsawStudioVariantCatalog(List.of(variants))); + } + + private static JigsawStudioVariant planarVariant(String key, JigsawPlanarTopology topology) { + return new JigsawStudioVariant( + key, + key, + "", + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + JigsawStudioMode.PLANAR_JIGSAW, + Optional.of(topology), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of(new JigsawStudioPoolMembership("village/start", 0, 1, 1D))); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTrackerTest.java b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTrackerTest.java new file mode 100644 index 000000000..d7c0d043d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/runtime/jigsaw/JigsawStudioTripleSneakTrackerTest.java @@ -0,0 +1,108 @@ +package art.arcane.iris.core.runtime.jigsaw; + +import org.junit.Test; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class JigsawStudioTripleSneakTrackerTest { + private static final UUID PLAYER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID OTHER_PLAYER = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID WORLD = UUID.fromString("33333333-3333-3333-3333-333333333333"); + private static final UUID OTHER_WORLD = UUID.fromString("44444444-4444-4444-4444-444444444444"); + private static final UUID REQUEST = UUID.fromString("55555555-5555-5555-5555-555555555555"); + private static final UUID OTHER_REQUEST = UUID.fromString("66666666-6666-6666-6666-666666666666"); + + @Test + public void triggersOnThirdSneakAndResetsThePlayerSequence() { + JigsawStudioTripleSneakTracker tracker = new JigsawStudioTripleSneakTracker(100L); + + assertEquals( + JigsawStudioTripleSneakTracker.Progress.FIRST, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 10L)); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.SECOND, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 20L)); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.TRIGGERED, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 30L)); + assertEquals(0, tracker.trackedPlayers()); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.FIRST, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 40L)); + } + + @Test + public void acceptsTheExactWindowBoundaryAndResetsAfterIt() { + JigsawStudioTripleSneakTracker tracker = new JigsawStudioTripleSneakTracker(100L); + + assertEquals( + JigsawStudioTripleSneakTracker.Progress.FIRST, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 1_000L)); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.SECOND, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 1_050L)); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.TRIGGERED, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 1_100L)); + + tracker.recordSneak(PLAYER, WORLD, REQUEST, 2_000L); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.FIRST, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 2_101L)); + } + + @Test + public void changingWorldOrRequestStartsANewSequence() { + JigsawStudioTripleSneakTracker tracker = new JigsawStudioTripleSneakTracker(100L); + + tracker.recordSneak(PLAYER, WORLD, REQUEST, 10L); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.FIRST, + tracker.recordSneak(PLAYER, OTHER_WORLD, REQUEST, 20L)); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.FIRST, + tracker.recordSneak(PLAYER, OTHER_WORLD, OTHER_REQUEST, 30L)); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.SECOND, + tracker.recordSneak(PLAYER, OTHER_WORLD, OTHER_REQUEST, 40L)); + } + + @Test + public void movingBackwardInTimeStartsANewSequence() { + JigsawStudioTripleSneakTracker tracker = new JigsawStudioTripleSneakTracker(100L); + + tracker.recordSneak(PLAYER, WORLD, REQUEST, 50L); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.FIRST, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 49L)); + assertEquals( + JigsawStudioTripleSneakTracker.Progress.SECOND, + tracker.recordSneak(PLAYER, WORLD, REQUEST, 50L)); + } + + @Test + public void clearOperationsRemoveOnlyTheirIntendedSequences() { + JigsawStudioTripleSneakTracker tracker = new JigsawStudioTripleSneakTracker(100L); + tracker.recordSneak(PLAYER, WORLD, REQUEST, 10L); + tracker.recordSneak(OTHER_PLAYER, WORLD, OTHER_REQUEST, 10L); + + tracker.clearPlayer(PLAYER); + assertEquals(1, tracker.trackedPlayers()); + assertEquals(1, tracker.clearRequest(OTHER_REQUEST)); + assertEquals(0, tracker.trackedPlayers()); + + tracker.recordSneak(PLAYER, WORLD, REQUEST, 20L); + tracker.clearAll(); + assertEquals(0, tracker.trackedPlayers()); + } + + @Test + public void rejectsNonPositiveGestureWindows() { + assertThrows( + IllegalArgumentException.class, + () -> new JigsawStudioTripleSneakTracker(0L)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/BoardSVCJigsawContextTest.java b/core/src/test/java/art/arcane/iris/core/service/BoardSVCJigsawContextTest.java new file mode 100644 index 000000000..120618780 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/BoardSVCJigsawContextTest.java @@ -0,0 +1,195 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import org.junit.Test; + +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +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 BoardSVCJigsawContextTest { + @Test + public void rendersWorkcellIdentityWithoutModeOrOrientationData() { + JigsawStudioBoardContext context = context( + JigsawStudioMode.PLANAR_JIGSAW, + "Corner", + "mossy", + JigsawStudioBoardState.UNSAVED, + "Right-click the chest for variants" + ); + + List lines = BoardSVC.jigsawLines(context); + + assertEquals(List.of( + "&7&m-------------------", + "&dJigsaw Studio", + "&bStructure&7: village", + "&bWorkcell&7: Corner", + "&bVariant&7: mossy", + "&bState&7: Unsaved", + "&7&m-------------------", + "&eRight-click the chest for variants", + "&7&m-------------------" + ), lines); + String rendered = String.join("\n", lines).toLowerCase(Locale.ROOT); + assertFalse(rendered.contains("mode")); + assertFalse(rendered.contains("orientation")); + assertFalse(rendered.contains("mask")); + assertFalse(rendered.contains("north")); + assertFalse(rendered.contains("south")); + assertFalse(rendered.contains("east")); + assertFalse(rendered.contains("west")); + } + + @Test + public void rendersModeAndEntryPromptOutsideWorkcells() { + JigsawStudioBoardContext context = context( + JigsawStudioMode.SPATIAL_JIGSAW, + "", + "", + JigsawStudioBoardState.SAVED, + "Use the workcell chest to choose a variant" + ); + + assertEquals(List.of( + "&7&m-------------------", + "&dJigsaw Studio", + "&bStructure&7: village", + "&bMode&7: Spatial", + "&7&m-------------------", + "&eWalk into a workcell", + "&7Use the workcell chest to choose a variant", + "&7&m-------------------" + ), BoardSVC.jigsawLines(context)); + } + + @Test + public void customWorkcellLabelsRetainTheCanonicalSolverRole() { + JigsawStudioBoardContext context = new JigsawStudioBoardContext( + UUID.randomUUID(), + UUID.randomUUID(), + "village", + JigsawStudioMode.PLANAR_JIGSAW, + "End Cap", + "Village Entrances", + "Grand Longhouse", + JigsawStudioBoardState.SAVED, + "Triple-sneak for controls"); + + List lines = BoardSVC.jigsawLines(context); + + assertTrue(lines.contains("&bWorkcell&7: Village Entrances")); + assertTrue(lines.contains("&bRole&7: End Cap")); + assertTrue(lines.contains("&bVariant&7: Grand Longhouse")); + } + + @Test + public void escapesUntrustedBoardValues() { + assertEquals( + "&cFort‹red› keep", + BoardSVC.untrustedBoardValue("&cFort\u00a7a\nkeep") + ); + } + + @Test + public void equalImmutableContextDoesNotRenderAgain() { + UUID worldId = UUID.randomUUID(); + UUID requestId = UUID.randomUUID(); + JigsawStudioBoardContext previous = context(worldId, requestId, "mossy", JigsawStudioBoardState.SAVED); + JigsawStudioBoardContext equal = context(worldId, requestId, "mossy", JigsawStudioBoardState.SAVED); + JigsawStudioBoardContext changedVariant = context(worldId, requestId, "ruined", JigsawStudioBoardState.SAVED); + JigsawStudioBoardContext changedState = context(worldId, requestId, "mossy", JigsawStudioBoardState.UNSAVED); + + assertFalse(BoardSVC.shouldRenderJigsaw(previous, equal)); + assertTrue(BoardSVC.shouldRenderJigsaw(previous, changedVariant)); + assertTrue(BoardSVC.shouldRenderJigsaw(previous, changedState)); + assertTrue(BoardSVC.shouldRenderJigsaw(null, previous)); + } + + @Test + public void normalizesOptionalFieldsAndRejectsInvalidIdentity() { + JigsawStudioBoardContext context = new JigsawStudioBoardContext( + UUID.randomUUID(), + UUID.randomUUID(), + " village ", + JigsawStudioMode.PLANAR_JIGSAW, + null, + null, + null, + JigsawStudioBoardState.LOADING, + null + ); + + assertEquals("village", context.structureKey()); + assertEquals("", context.workcellName()); + assertEquals("", context.variantName()); + assertEquals("", context.controlHint()); + assertFalse(context.insideWorkcell()); + assertThrows(IllegalArgumentException.class, () -> new JigsawStudioBoardContext( + UUID.randomUUID(), + UUID.randomUUID(), + " ", + JigsawStudioMode.PLANAR_JIGSAW, + "", + "", + "", + JigsawStudioBoardState.LOADING, + "" + )); + assertThrows(IllegalArgumentException.class, () -> new JigsawStudioBoardContext( + UUID.randomUUID(), + UUID.randomUUID(), + "village", + JigsawStudioMode.PLANAR_JIGSAW, + "", + "", + "mossy", + JigsawStudioBoardState.LOADING, + "" + )); + } + + private JigsawStudioBoardContext context( + JigsawStudioMode mode, + String workcell, + String variant, + JigsawStudioBoardState state, + String hint + ) { + return new JigsawStudioBoardContext( + UUID.randomUUID(), + UUID.randomUUID(), + "village", + mode, + workcell, + workcell, + variant, + state, + hint + ); + } + + private JigsawStudioBoardContext context( + UUID worldId, + UUID requestId, + String variant, + JigsawStudioBoardState state + ) { + return new JigsawStudioBoardContext( + worldId, + requestId, + "village", + JigsawStudioMode.PLANAR_JIGSAW, + "Corner", + "Corner", + variant, + state, + "Right-click the chest for variants" + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/BoardSVCStudioEligibilityTest.java b/core/src/test/java/art/arcane/iris/core/service/BoardSVCStudioEligibilityTest.java index e7de27a7b..9b6ce5e2f 100644 --- a/core/src/test/java/art/arcane/iris/core/service/BoardSVCStudioEligibilityTest.java +++ b/core/src/test/java/art/arcane/iris/core/service/BoardSVCStudioEligibilityTest.java @@ -2,13 +2,11 @@ package art.arcane.iris.core.service; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import org.bukkit.scoreboard.Scoreboard; import org.junit.Test; import java.util.UUID; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -58,23 +56,6 @@ public class BoardSVCStudioEligibilityTest { assertTrue(service.isPlayerBoardEnabled(playerId)); } - @Test - public void restoresPreviousScoreboardWhileIrisStillOwnsThePlayer() { - Scoreboard previous = mock(Scoreboard.class); - Scoreboard iris = mock(Scoreboard.class); - - assertSame(previous, BoardSVC.selectScoreboardToRestore(iris, iris, previous)); - } - - @Test - public void preservesScoreboardAssignedAfterIris() { - Scoreboard previous = mock(Scoreboard.class); - Scoreboard iris = mock(Scoreboard.class); - Scoreboard replacement = mock(Scoreboard.class); - - assertSame(replacement, BoardSVC.selectScoreboardToRestore(replacement, iris, previous)); - } - private PlatformChunkGenerator generator(boolean studio, boolean closing, Engine engine) { PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); doReturn(studio).when(generator).isStudio(); diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRendererTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRendererTest.java new file mode 100644 index 000000000..0f2385d27 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioDisabledWorkcellRendererTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioWorkcellSpec; +import org.bukkit.entity.BlockDisplay; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +public class JigsawStudioDisabledWorkcellRendererTest { + @Test + public void disabledWorkcellProducesOneFullVolumeDescriptor() { + JigsawStudioLayout layout = layoutWithDisabled(JigsawPlanarArchetype.TEE); + + Map descriptors = + JigsawStudioDisabledWorkcellRenderer.descriptors(layout); + + assertEquals(1, descriptors.size()); + JigsawStudioDisabledWorkcellRenderer.Descriptor descriptor = + descriptors.get(JigsawPlanarArchetype.TEE.stableId()); + assertEquals(11, descriptor.width()); + assertEquals(5, descriptor.height()); + assertEquals(7, descriptor.depth()); + assertEquals(JigsawStudioLayout.FLOOR_Y + 1, descriptor.originY()); + } + + @Test + public void enabledWorkcellsNeverProduceRedGlassDescriptors() { + JigsawStudioLayout layout = layoutWithDisabled(null); + + Map descriptors = + JigsawStudioDisabledWorkcellRenderer.descriptors(layout); + + assertTrue(descriptors.isEmpty()); + assertFalse(layout.bays().isEmpty()); + } + + @Test + public void chunkUnloadDetachesOnlyDisplaysWhoseOriginsBelongToThatChunk() { + BlockDisplay target = mock(BlockDisplay.class); + BlockDisplay retained = mock(BlockDisplay.class); + JigsawStudioDisabledWorkcellRenderer.Descriptor targetDescriptor = + new JigsawStudioDisabledWorkcellRenderer.Descriptor("workcell/tee", 31, 65, -1, 3, 3, 3); + JigsawStudioDisabledWorkcellRenderer.Descriptor retainedDescriptor = + new JigsawStudioDisabledWorkcellRenderer.Descriptor("workcell/cross", 32, 65, -1, 3, 3, 3); + Map entities = new HashMap<>(Map.of( + targetDescriptor.workcellId(), target, + retainedDescriptor.workcellId(), retained)); + Map rendered = new HashMap<>(Map.of( + targetDescriptor.workcellId(), targetDescriptor, + retainedDescriptor.workcellId(), retainedDescriptor)); + + List removals = JigsawStudioDisabledWorkcellRenderer.detachChunkDisplays( + entities, rendered, 1, -1); + + assertEquals(List.of(target), removals); + assertFalse(entities.containsKey(targetDescriptor.workcellId())); + assertFalse(rendered.containsKey(targetDescriptor.workcellId())); + assertEquals(retained, entities.get(retainedDescriptor.workcellId())); + assertEquals(retainedDescriptor, rendered.get(retainedDescriptor.workcellId())); + verifyNoInteractions(target, retained); + } + + private static JigsawStudioLayout layoutWithDisabled(JigsawPlanarArchetype disabled) { + List specs = new ArrayList<>(); + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + JigsawStudioCellDimensions dimensions = archetype == JigsawPlanarArchetype.TEE + ? new JigsawStudioCellDimensions(11, 5, 7) + : new JigsawStudioCellDimensions(3, 3, 3); + specs.add(new JigsawStudioWorkcellSpec(archetype, "", dimensions, archetype != disabled)); + } + return JigsawStudioLayout.createPlanar( + new JigsawStudioCellDimensions(3, 3, 3), + specs, + JigsawStudioVariantCatalog.empty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioLifecycleTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioLifecycleTest.java new file mode 100644 index 000000000..b19fd99b3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioLifecycleTest.java @@ -0,0 +1,317 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KMap; +import org.bukkit.World; +import org.junit.After; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class JigsawStudioLifecycleTest { + private static final UUID OWNER = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID OTHER_OWNER = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + @After + public void clearLifecycle() { + JigsawStudioActivation.finishOpen(OWNER); + JigsawStudioActivation.finishOpen(OTHER_OWNER); + JigsawStudioActivation.deactivate("overworld"); + } + + @Test + public void openingLeaseAndActivationOwnerAreCentralized() { + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + assertFalse(JigsawStudioActivation.tryBeginOpen(OTHER_OWNER)); + JigsawStudioActivation.finishOpen(OTHER_OWNER); + assertEquals(OWNER, JigsawStudioActivation.openingOwnerId()); + + JigsawStudioActivation.Request request = activateOwnedStudio(); + + assertEquals(OWNER, request.ownerId()); + assertEquals(OWNER, JigsawStudioActivation.activeOwnerId()); + JigsawStudioActivation.finishOpen(OWNER); + assertNull(JigsawStudioActivation.openingOwnerId()); + assertFalse(JigsawStudioActivation.tryBeginOpen(OTHER_OWNER)); + } + + @Test + public void closeAuthorizationChecksOwnerDirtyStateAndSaveBarrierAtomically() { + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + JigsawStudioActivation.Request request = activateOwnedStudio(); + JigsawStudioActivation.finishOpen(OWNER); + JigsawStudioSession session = JigsawStudioActivation.getSession(request.requestId()); + JigsawStudioService service = new JigsawStudioService(); + + assertEquals( + JigsawStudioService.CloseStart.NOT_OWNER, + service.tryBeginClose(request.requestId(), OTHER_OWNER, false)); + assertTrue(service.closeProtectionFailure(request.requestId()).contains("owner-controlled")); + + assertEquals( + JigsawStudioSession.DirtyStatus.MARKED, + session.markWorkcellDirty(JigsawStudioLayout.SPATIAL_WORKCELL_ID).status()); + assertEquals( + JigsawStudioService.CloseStart.DIRTY, + service.tryBeginClose(request.requestId(), OWNER, false)); + assertTrue(service.closeProtectionFailure(request.requestId()).contains("autosave")); + + JigsawStudioSession.VariantSwitchToken switchToken = session.beginVariantSwitch( + JigsawStudioLayout.SPATIAL_WORKCELL_ID, + "stronghold/tower", + true).token().orElseThrow(); + assertEquals( + JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS, + service.tryBeginClose(request.requestId(), OWNER, true)); + assertTrue(service.closeProtectionFailure(request.requestId()).contains("loading a variant")); + assertTrue(session.abortVariantSwitch(switchToken)); + + assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(request.requestId())); + assertEquals( + JigsawStudioService.CloseStart.SAVE_IN_PROGRESS, + service.tryBeginClose(request.requestId(), OWNER, true)); + assertTrue(service.closeProtectionFailure(request.requestId()).contains("saving")); + + service.finishSave(request.requestId()); + assertEquals( + JigsawStudioService.CloseStart.STARTED, + service.tryBeginClose(request.requestId(), OWNER, true)); + assertNull(service.closeProtectionFailure(request.requestId())); + assertEquals(JigsawStudioService.SaveStart.CLOSING, service.tryBeginSave(request.requestId())); + } + + @Test + public void lateJigsawGuiMutationKeepsCloseBehindTheFinalSnapshotAndAutosaveBarriers() + throws ReflectiveOperationException { + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + JigsawStudioActivation.Request request = activateOwnedStudio(); + JigsawStudioActivation.finishOpen(OWNER); + JigsawStudioSession session = JigsawStudioActivation.getSession(request.requestId()); + JigsawStudioService service = new JigsawStudioService(); + KMap baseline = new KMap<>(); + baseline.put("name", "iris:start"); + KMap updated = new KMap<>(); + updated.put("name", "iris:hall"); + assertTrue(JigsawStudioService.tileSnapshotChanged(baseline, updated)); + + Class keyType = Class.forName(JigsawStudioService.class.getName() + "$JigsawTileWatchKey"); + Constructor constructor = keyType.getDeclaredConstructor( + UUID.class, UUID.class, int.class, int.class, int.class); + constructor.setAccessible(true); + Object key = constructor.newInstance(request.requestId(), UUID.randomUUID(), 1, 64, 1); + Class studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio"); + Constructor studioConstructor = studioType.getDeclaredConstructor( + UUID.class, + World.class, + Engine.class, + JigsawStudioGenerator.class, + ConcurrentHashMap.class, + Set.class, + AtomicLong.class); + studioConstructor.setAccessible(true); + World world = mock(World.class); + Object studio = studioConstructor.newInstance( + UUID.randomUUID(), + world, + mock(Engine.class), + mock(JigsawStudioGenerator.class), + new ConcurrentHashMap<>(), + ConcurrentHashMap.newKeySet(), + new AtomicLong()); + Class watchType = Class.forName(JigsawStudioService.class.getName() + "$JigsawTileWatch"); + Constructor watchConstructor = watchType.getDeclaredConstructor( + keyType, + studioType, + UUID.class, + String.class, + KMap.class, + AtomicBoolean.class, + AtomicBoolean.class); + watchConstructor.setAccessible(true); + Object watch = watchConstructor.newInstance( + key, + studio, + OWNER, + JigsawStudioLayout.SPATIAL_WORKCELL_ID, + baseline, + new AtomicBoolean(), + new AtomicBoolean()); + Field watchesField = JigsawStudioService.class.getDeclaredField("jigsawTileWatches"); + watchesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map watches = (Map) watchesField.get(service); + watches.put(key, watch); + + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(any(World.class), anyInt(), anyInt())) + .thenReturn(false); + scheduling.when(() -> J.runRegion( + any(World.class), anyInt(), anyInt(), any(Runnable.class))) + .thenReturn(false); + assertEquals( + JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS, + service.tryBeginClose(request.requestId(), OWNER, false)); + assertTrue(service.closeProtectionFailure(request.requestId()).contains("finalizing")); + assertEquals(1, watches.size()); + scheduling.verify(() -> J.s(any(Runnable.class), eq(5))); + } + + watches.clear(); + assertEquals( + JigsawStudioSession.DirtyStatus.MARKED, + session.markWorkcellDirty(JigsawStudioLayout.SPATIAL_WORKCELL_ID).status()); + assertEquals( + JigsawStudioService.CloseStart.DIRTY, + service.tryBeginClose(request.requestId(), OWNER, false)); + } + + @Test + public void exportLeaseSerializesSavesAndCloseAgainstThePinnedRequest() { + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + JigsawStudioActivation.Request request = activateOwnedStudio(); + JigsawStudioActivation.finishOpen(OWNER); + JigsawStudioService service = new JigsawStudioService(); + + assertEquals( + JigsawStudioService.ExportStart.NOT_OWNER, + service.tryBeginExport(request.requestId(), OTHER_OWNER)); + assertEquals( + JigsawStudioService.ExportStart.STARTED, + service.tryBeginExport(request.requestId(), OWNER)); + assertEquals( + JigsawStudioService.ExportStart.IN_PROGRESS, + service.tryBeginExport(request.requestId(), OWNER)); + assertEquals( + JigsawStudioService.SaveStart.EXPORT_OPERATION, + service.tryBeginSave(request.requestId())); + assertEquals( + JigsawStudioService.CloseStart.OPERATION_IN_PROGRESS, + service.tryBeginClose(request.requestId(), OWNER, false)); + assertTrue(service.closeProtectionFailure(request.requestId()).contains("exporting")); + + service.finishExport(request.requestId()); + assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(request.requestId())); + service.finishSave(request.requestId()); + } + + @Test + public void immediateSingleAndFamilyDuplicatesWaitForAutosaveThenRunExactlyOnce() { + assertEquals( + JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE, + JigsawStudioService.deferredDuplicationReadiness( + true, true, true, false, false)); + assertEquals( + JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_AUTOSAVE, + JigsawStudioService.deferredDuplicationReadiness( + true, true, true, true, false)); + assertEquals( + JigsawStudioService.DeferredDuplicationReadiness.WAITING_FOR_OPERATION, + JigsawStudioService.deferredDuplicationReadiness( + true, true, false, true, false)); + assertEquals( + JigsawStudioService.DeferredDuplicationReadiness.READY, + JigsawStudioService.deferredDuplicationReadiness( + true, true, false, false, false)); + assertEquals( + JigsawStudioService.DeferredDuplicationReadiness.STALE, + JigsawStudioService.deferredDuplicationReadiness( + true, false, false, false, false)); + } + + @Test + public void initialEvaluationWaitsForCommitAndSchedulesExactlyOnce() { + AtomicLong registrationBeforeCommit = new AtomicLong(); + assertFalse(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, false)); + assertEquals(0L, registrationBeforeCommit.get()); + assertTrue(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, true)); + assertEquals(1L, registrationBeforeCommit.get()); + assertFalse(JigsawStudioService.claimInitialEvaluation(registrationBeforeCommit, true)); + + AtomicLong registrationAfterCommit = new AtomicLong(); + assertTrue(JigsawStudioService.claimInitialEvaluation(registrationAfterCommit, true)); + assertEquals(1L, registrationAfterCommit.get()); + assertFalse(JigsawStudioService.claimInitialEvaluation(registrationAfterCommit, true)); + } + + @Test + public void ownerEnteringAWorkcellMakesItTheNextMenuSelection() { + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(16, 16, 16), + new JigsawStudioVariantCatalog(List.of())); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", layout); + JigsawStudioBay end = layout.get("workcell/end"); + JigsawStudioBay blank = layout.get("workcell/blank"); + + assertTrue(session.selectedBayId().isEmpty()); + assertTrue(JigsawStudioService.selectEnteredWorkcell(session, end, true)); + assertEquals(end.stableId(), session.selectedBayId().orElseThrow()); + assertFalse(JigsawStudioService.selectEnteredWorkcell(session, blank, false)); + assertEquals(end.stableId(), session.selectedBayId().orElseThrow()); + } + + private static JigsawStudioActivation.Request activateOwnedStudio() { + return JigsawStudioActivation.activate( + "overworld", + "stronghold", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16), + mock(IrisData.class), + JigsawStudioLayout.create( + JigsawStudioMode.SPATIAL_JIGSAW, + new JigsawStudioCellDimensions(16, 16, 16), + new JigsawStudioVariantCatalog(List.of( + spatialVariant("stronghold/hall"), + spatialVariant("stronghold/tower")))), + OWNER); + } + + private static JigsawStudioVariant spatialVariant(String key) { + return new JigsawStudioVariant( + key, + key, + "", + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + JigsawStudioMode.SPATIAL_JIGSAW, + Optional.empty(), + true, + true, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioMarkerParserTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioMarkerParserTest.java new file mode 100644 index 000000000..962bf681b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioMarkerParserTest.java @@ -0,0 +1,104 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.JigsawJoint; +import org.bukkit.block.Orientation; +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class JigsawStudioMarkerParserTest { + @Test + public void mapsEveryBukkitJigsawOrientation() { + assertDirections(Orientation.DOWN_EAST, IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.EAST_POSITIVE_X); + assertDirections(Orientation.DOWN_NORTH, IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.NORTH_NEGATIVE_Z); + assertDirections(Orientation.DOWN_SOUTH, IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.SOUTH_POSITIVE_Z); + assertDirections(Orientation.DOWN_WEST, IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.WEST_NEGATIVE_X); + assertDirections(Orientation.UP_EAST, IrisDirection.UP_POSITIVE_Y, IrisDirection.EAST_POSITIVE_X); + assertDirections(Orientation.UP_NORTH, IrisDirection.UP_POSITIVE_Y, IrisDirection.NORTH_NEGATIVE_Z); + assertDirections(Orientation.UP_SOUTH, IrisDirection.UP_POSITIVE_Y, IrisDirection.SOUTH_POSITIVE_Z); + assertDirections(Orientation.UP_WEST, IrisDirection.UP_POSITIVE_Y, IrisDirection.WEST_NEGATIVE_X); + assertDirections(Orientation.WEST_UP, IrisDirection.WEST_NEGATIVE_X, IrisDirection.UP_POSITIVE_Y); + assertDirections(Orientation.EAST_UP, IrisDirection.EAST_POSITIVE_X, IrisDirection.UP_POSITIVE_Y); + assertDirections(Orientation.NORTH_UP, IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.UP_POSITIVE_Y); + assertDirections(Orientation.SOUTH_UP, IrisDirection.SOUTH_POSITIVE_Z, IrisDirection.UP_POSITIVE_Y); + } + + @Test + public void parsesMarkerMetadataAndSignedPriorities() { + Map nbt = markerNbt(); + nbt.put("channel", "castle/door"); + nbt.put("selection_priority", -4); + nbt.put("placement_priority", 17L); + + IrisJigsawConnector connector = JigsawStudioMarkerParser.parse( + nbt, Orientation.NORTH_UP, 3, 5, 7); + + assertEquals(3, connector.getPosition().getX()); + assertEquals(5, connector.getPosition().getY()); + assertEquals(7, connector.getPosition().getZ()); + assertEquals(IrisDirection.NORTH_NEGATIVE_Z, connector.getDirection()); + assertEquals(IrisDirection.UP_POSITIVE_Y, connector.getTop()); + assertEquals("fort/pool", connector.getPool()); + assertEquals("iris:door", connector.getName()); + assertEquals("iris:door", connector.getTargetName()); + assertEquals("castle/door", connector.getChannel()); + assertEquals(JigsawJoint.ALIGNED, connector.getJoint()); + assertEquals("minecraft:stone", connector.getFinalState()); + assertEquals(-4, connector.getSelectionPriority()); + assertEquals(17, connector.getPlacementPriority()); + } + + @Test + public void defaultsMissingOptionalMetadata() { + IrisJigsawConnector connector = JigsawStudioMarkerParser.parse( + markerNbt(), Orientation.SOUTH_UP, 0, 0, 0); + + assertEquals("", connector.getChannel()); + assertEquals(0, connector.getSelectionPriority()); + assertEquals(0, connector.getPlacementPriority()); + } + + @Test + public void rejectsMissingRequiredMarkerMetadata() { + Map nbt = markerNbt(); + nbt.remove("pool"); + + assertThrows(IllegalArgumentException.class, + () -> JigsawStudioMarkerParser.parse(nbt, Orientation.NORTH_UP, 0, 0, 0)); + } + + @Test + public void rejectsNonStudioPoolNamespaces() { + Map nbt = markerNbt(); + nbt.put("pool", "minecraft:fort/pool"); + + assertThrows(IllegalArgumentException.class, + () -> JigsawStudioMarkerParser.parse(nbt, Orientation.NORTH_UP, 0, 0, 0)); + } + + private static Map markerNbt() { + Map nbt = new LinkedHashMap<>(); + nbt.put("name", "iris:door"); + nbt.put("target", "iris:door"); + nbt.put("pool", "iris:fort/pool"); + nbt.put("final_state", "minecraft:stone"); + nbt.put("joint", "aligned"); + return nbt; + } + + private static void assertDirections( + Orientation orientation, + IrisDirection expectedFront, + IrisDirection expectedTop + ) { + JigsawStudioMarkerParser.Directions directions = JigsawStudioMarkerParser.directions(orientation); + assertEquals(expectedFront, directions.front()); + assertEquals(expectedTop, directions.top()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioMenuControllerTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioMenuControllerTest.java new file mode 100644 index 000000000..789cb4b48 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioMenuControllerTest.java @@ -0,0 +1,555 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class JigsawStudioMenuControllerTest { + private static final UUID WORLD_ID = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID REQUEST_ID = UUID.fromString("77777777-7777-7777-7777-777777777777"); + private static final JigsawStudioPieceRules RULES = new JigsawStudioPieceRules(1, 12, 2, 8, false); + + @Test + public void pagesVariantsAndClampsStalePageIndexes() { + List values = new ArrayList<>(); + for (int index = 0; index < 57; index++) { + values.add(index); + } + + assertEquals(1, JigsawStudioMenuController.pageCount(0, 28)); + assertEquals(1, JigsawStudioMenuController.pageCount(28, 28)); + assertEquals(2, JigsawStudioMenuController.pageCount(29, 28)); + assertEquals(List.of(0, 1, 2), JigsawStudioMenuController.page(values, -10, 3)); + assertEquals(List.of(54, 55, 56), JigsawStudioMenuController.page(values, 999, 3)); + assertThrows(IllegalArgumentException.class, + () -> JigsawStudioMenuController.pageCount(1, 0)); + } + + @Test + public void freshUntouchedWorkcellsRenderAsAutosaved() { + JigsawStudioMenuState.Variant active = variant( + "pieces/corner", + true, + List.of(), + List.of()); + JigsawStudioMenuState.Workcell fresh = new JigsawStudioMenuState.Workcell( + "workcell/corner", + "L Junction", + "L Junction", + new JigsawStudioCellDimensions(16, 16, 16), + true, + active.pieceKey(), + false, + false, + false, + List.of(active)); + + assertEquals(ChatColor.GREEN + "Autosaved", JigsawStudioMenuController.workcellStatus(fresh)); + } + + @Test + public void activeEvaluationAndMinimumPlacementIconsAreUnambiguous() { + JigsawStudioMenuState.Variant active = variant( + "pieces/corner", + true, + List.of(), + List.of()); + + assertEquals(Material.JIGSAW, JigsawStudioMenuController.variantMaterial(active)); + assertEquals(Material.EMERALD, + JigsawStudioMenuController.evaluationMaterial(JigsawStudioEvaluationState.VALID)); + assertEquals(Material.TARGET, + JigsawStudioMenuController.RuleField.MINIMUM_PLACEMENTS.material()); + } + + @Test + public void exposesImmutableWorkcellVariantAndEvaluationState() { + List themes = new ArrayList<>(List.of("variant-1", "ruined")); + JigsawStudioGraphEvaluation graphEvaluation = new JigsawStudioGraphEvaluation( + REQUEST_ID, + 4L, + 1337L, + JigsawStudioEvaluationState.VALID, + "variant-1", + 19, + "Seed 1337 assembled 19 pieces", + JigsawStudioPreviewRenderer.PreviewBounds.empty()); + JigsawStudioMenuState.Evaluation evaluation = JigsawStudioMenuState.Evaluation.from(graphEvaluation); + JigsawStudioMenuState.Variant active = variant( + "pieces/corner_mossy", + true, + themes, + List.of(new JigsawStudioMenuState.Membership("pools/village", 3, 7, 0.35D))); + JigsawStudioMenuState.Workcell corner = new JigsawStudioMenuState.Workcell( + "workcell/corner", + "L Junction", + "Corner", + new JigsawStudioCellDimensions(24, 12, 18), + false, + active.pieceKey(), + true, + false, + false, + List.of(active)); + JigsawStudioMenuState state = state(evaluation, corner); + themes.add("late-theme"); + + assertSame(corner, state.selectedWorkcell()); + assertSame(corner, state.workcell(corner.stableId())); + assertSame(active, corner.activeVariant()); + assertEquals(new JigsawStudioCellDimensions(24, 12, 18), corner.capacity()); + assertFalse(corner.enabled()); + assertEquals(JigsawStudioEvaluationState.VALID, state.evaluation().state()); + assertEquals(4L, state.evaluation().generation()); + assertEquals("variant-1", state.evaluation().selectedTheme()); + assertEquals(19, state.evaluation().pieceCount()); + assertTrue(state.requireCaps()); + assertTrue(state.irisExtended()); + assertEquals(JigsawStudioCompatibilityTarget.IRIS_EXTENDED, state.compatibilityTarget()); + assertEquals(List.of(new JigsawStudioMenuState.ThemeSet("variant-1", 1)), state.themeSets()); + assertEquals(new JigsawStudioMenuState.ThemeSet("variant-1", 1), state.themeSet("variant-1")); + assertEquals(List.of("variant-1", "ruined"), active.themes()); + assertTrue(active.resizableToCapacity()); + assertEquals(RULES, active.rules()); + assertEquals(0.35D, active.memberships().getFirst().chance(), 0D); + } + + @Test + public void rejectsAmbiguousOrInvalidMenuIdentity() { + JigsawStudioMenuState.Variant active = variant("pieces/active", true, List.of(), List.of()); + + assertThrows(IllegalArgumentException.class, () -> new JigsawStudioMenuState.Workcell( + "workcell/corner", + "L Junction", + "Corner", + new JigsawStudioCellDimensions(16, 16, 16), + true, + "pieces/different", + false, + false, + false, + List.of(active))); + assertThrows(IllegalArgumentException.class, () -> new JigsawStudioMenuState( + WORLD_ID, + REQUEST_ID, + "village", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + false, + List.of(), + "workcell/missing", + JigsawStudioMenuState.Evaluation.pending(), + List.of(workcell(active)))); + assertThrows(IllegalArgumentException.class, () -> variant( + "pieces/duplicate-membership", + false, + List.of(), + List.of( + new JigsawStudioMenuState.Membership("pools/village", 1, 1, 1D), + new JigsawStudioMenuState.Membership("pools/village", 1, 2, 0.5D)))); + assertThrows(IllegalArgumentException.class, + () -> new JigsawStudioMenuState.Membership("pools/village", 0, 0, 1D)); + assertThrows(IllegalArgumentException.class, + () -> new JigsawStudioMenuState.Membership("pools/village", 0, 1, 1.01D)); + assertThrows(IllegalArgumentException.class, + () -> variant("pieces/blank-theme", false, List.of(" "), List.of())); + assertThrows(IllegalArgumentException.class, + () -> variant("pieces/duplicate-theme", false, List.of("variant-1", "variant-1"), List.of())); + assertThrows(IllegalArgumentException.class, + () -> new JigsawStudioMenuState.ThemeSet("variant-1", 0)); + assertThrows(IllegalArgumentException.class, + () -> new JigsawStudioMenuState.Evaluation( + JigsawStudioEvaluationState.PENDING, + -1L, + 1337L, + "", + 0, + "")); + } + + @Test + public void adjustsEachWorkcellAxisAtNormalAndShiftSteps() { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(24, 12, 18); + + assertEquals( + new JigsawStudioCellDimensions(25, 12, 18), + JigsawStudioMenuController.adjustedDimensions( + dimensions, + JigsawStudioMenuController.DimensionAxis.WIDTH, + 1).orElseThrow()); + assertEquals( + new JigsawStudioCellDimensions(24, 20, 18), + JigsawStudioMenuController.adjustedDimensions( + dimensions, + JigsawStudioMenuController.DimensionAxis.HEIGHT, + 8).orElseThrow()); + assertEquals( + new JigsawStudioCellDimensions(24, 12, 10), + JigsawStudioMenuController.adjustedDimensions( + dimensions, + JigsawStudioMenuController.DimensionAxis.DEPTH, + -8).orElseThrow()); + assertTrue(JigsawStudioMenuController.adjustedDimensions( + new JigsawStudioCellDimensions(1, 1, 1), + JigsawStudioMenuController.DimensionAxis.WIDTH, + -1).isEmpty()); + assertTrue(JigsawStudioMenuController.adjustedDimensions( + new JigsawStudioCellDimensions(128, 1, 1), + JigsawStudioMenuController.DimensionAxis.WIDTH, + 1).isEmpty()); + assertThrows(IllegalArgumentException.class, () -> JigsawStudioMenuController.adjustedDimensions( + dimensions, + JigsawStudioMenuController.DimensionAxis.WIDTH, + 0)); + } + + @Test + public void allocatesNumberedThemeSetsAndAdjustsPositiveWeights() { + List themeSets = List.of( + new JigsawStudioMenuState.ThemeSet("variant-1", 1), + new JigsawStudioMenuState.ThemeSet("seasonal", 4), + new JigsawStudioMenuState.ThemeSet("variant-2", 2)); + + assertEquals("variant-3", JigsawStudioMenuController.nextThemeSetKey(themeSets)); + assertEquals("variant-1", JigsawStudioMenuController.nextThemeSetKey(List.of())); + assertEquals(Integer.valueOf(12), JigsawStudioMenuController.adjustedPositiveValue(4, 8).orElseThrow()); + assertTrue(JigsawStudioMenuController.adjustedPositiveValue(4, -8).isEmpty()); + assertThrows(IllegalArgumentException.class, + () -> JigsawStudioMenuController.adjustedPositiveValue(4, 0)); + } + + @Test + public void editsPieceRuleFieldsWithinRuntimeBounds() { + assertEquals( + new JigsawStudioPieceRules(2, 12, 2, 8, false), + JigsawStudioMenuController.adjustedRules( + RULES, + JigsawStudioMenuController.RuleField.MINIMUM_DEPTH, + 1).orElseThrow()); + assertEquals( + new JigsawStudioPieceRules(1, 17, 2, 8, false), + JigsawStudioMenuController.adjustedRules( + RULES, + JigsawStudioMenuController.RuleField.MAXIMUM_DEPTH, + 5).orElseThrow()); + assertEquals( + new JigsawStudioPieceRules(1, 12, 3, 8, false), + JigsawStudioMenuController.adjustedRules( + RULES, + JigsawStudioMenuController.RuleField.MINIMUM_PLACEMENTS, + 1).orElseThrow()); + assertEquals( + new JigsawStudioPieceRules(1, 12, 2, 24, false), + JigsawStudioMenuController.adjustedRules( + RULES, + JigsawStudioMenuController.RuleField.MAXIMUM_PLACEMENTS, + 16).orElseThrow()); + assertEquals( + new JigsawStudioPieceRules(0, 30, 0, 0, false), + JigsawStudioMenuController.adjustedRules( + new JigsawStudioPieceRules(0, 30, 0, 512, false), + JigsawStudioMenuController.RuleField.MAXIMUM_PLACEMENTS, + 1).orElseThrow()); + assertEquals( + new JigsawStudioPieceRules(0, 30, 0, 512, false), + JigsawStudioMenuController.adjustedRules( + new JigsawStudioPieceRules(0, 30, 0, 0, false), + JigsawStudioMenuController.RuleField.MAXIMUM_PLACEMENTS, + -1).orElseThrow()); + assertTrue(JigsawStudioMenuController.adjustedRules( + RULES, + JigsawStudioMenuController.RuleField.MINIMUM_DEPTH, + 12).isEmpty()); + assertEquals( + new JigsawStudioPieceRules(1, 12, 2, 8, true), + JigsawStudioMenuController.withTerminal(RULES, true)); + } + + @Test + public void toolboxBindsCurrentRequestWorkcellVariantAndMembership() { + JigsawStudioMenuState.Membership membership = new JigsawStudioMenuState.Membership( + "pools/village", + 3, + 7, + 0.35D); + JigsawStudioMenuState.Variant active = variant( + "pieces/corner_mossy", + true, + List.of("variant-1"), + List.of(membership)); + JigsawStudioMenuState.Variant inactive = variant( + "pieces/corner_ruined", + false, + List.of("variant-1"), + List.of()); + JigsawStudioMenuState.Workcell corner = workcell(active, List.of(active, inactive)); + JigsawStudioMenuState state = state(JigsawStudioMenuState.Evaluation.pending(), corner); + + List tools = JigsawStudioMenuController.toolboxTools( + state, + corner); + JigsawStudioMenuController.ToolboxTool preview = tool(tools, JigsawStudioToolAction.PREVIEW_GRAPH, 0); + JigsawStudioMenuController.ToolboxTool chanceIncrease = tool( + tools, + JigsawStudioToolAction.ADJUST_VARIANT_CHANCE, + JigsawStudioMenuController.CHANCE_STEP_PERCENTAGE_POINTS); + JigsawStudioMenuController.ToolboxTool delete = tool( + tools, + JigsawStudioToolAction.DELETE_VARIANT, + 0); + JigsawStudioMenuController.ToolboxTool newThemeSet = tools.stream() + .filter(tool -> tool.payload().action() == JigsawStudioToolAction.DUPLICATE_FAMILY) + .findFirst() + .orElseThrow(); + JigsawStudioMenuController.ToolboxTool pieceRules = tools.stream() + .filter(tool -> tool.payload().action() == JigsawStudioToolAction.SET_PIECE_RULES) + .findFirst() + .orElseThrow(); + + assertEquals("Go to Preview", preview.displayName()); + assertEquals(REQUEST_ID, chanceIncrease.payload().requestId()); + assertEquals(corner.stableId(), chanceIncrease.payload().workcellId()); + assertEquals(active.pieceKey(), chanceIncrease.payload().pieceKey()); + assertEquals(membership.poolKey(), chanceIncrease.payload().poolKey()); + assertEquals(membership.entryIndex(), chanceIncrease.payload().entryIndex()); + assertEquals(inactive.pieceKey(), delete.payload().pieceKey()); + assertEquals("Duplicate All Enabled Cells as Family: variant-2", newThemeSet.displayName()); + assertEquals(active.pieceKey(), pieceRules.payload().pieceKey()); + assertTrue(tools.stream().noneMatch(tool -> tool.payload().action() == JigsawStudioToolAction.DELETE_VARIANT + && tool.payload().pieceKey().equals(active.pieceKey()))); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() == JigsawStudioToolAction.TOGGLE_WORKCELL)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() == JigsawStudioToolAction.RESIZE_WORKCELL)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() == JigsawStudioToolAction.RENAME_WORKCELL)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() == JigsawStudioToolAction.RENAME_VARIANT)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() == JigsawStudioToolAction.RESIZE_VARIANT)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.EXPAND_TO_CELL)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.TOGGLE_REQUIRE_CAPS)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.DELETE_PROJECT)); + } + + @Test + public void spatialToolboxStillProvidesSharedBoundsResize() { + JigsawStudioMenuState.Variant active = variant( + "pieces/room", + true, + List.of("variant-1"), + List.of()); + JigsawStudioMenuState.Workcell room = workcell(active); + JigsawStudioMenuState state = new JigsawStudioMenuState( + WORLD_ID, + REQUEST_ID, + "stronghold", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + false, + List.of(new JigsawStudioMenuState.ThemeSet("variant-1", 1)), + room.stableId(), + JigsawStudioMenuState.Evaluation.pending(), + List.of(room)); + + List tools = JigsawStudioMenuController.toolboxTools(state, room); + + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.RESIZE_WORKCELL)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.EXPAND_TO_CELL)); + assertTrue(tools.stream().noneMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.TOGGLE_WORKCELL)); + } + + @Test + public void vanillaPortableToolboxOmitsIrisOnlyMetadataActions() { + JigsawStudioMenuState.Variant active = variant( + "pieces/corner", + true, + List.of(), + List.of(new JigsawStudioMenuState.Membership("pools/village", 0, 1, 1D))); + JigsawStudioMenuState.Workcell corner = workcell(active); + JigsawStudioMenuState state = new JigsawStudioMenuState( + WORLD_ID, + REQUEST_ID, + "village", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, + true, + List.of(), + corner.stableId(), + JigsawStudioMenuState.Evaluation.pending(), + List.of(corner)); + + List tools = JigsawStudioMenuController.toolboxTools(state, corner); + + assertFalse(state.irisExtended()); + assertTrue(tools.stream().noneMatch(tool -> tool.payload().action() == JigsawStudioToolAction.SET_THEME)); + assertTrue(tools.stream().noneMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.SET_PIECE_RULES)); + assertTrue(tools.stream().noneMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.ADJUST_VARIANT_CHANCE)); + assertTrue(tools.stream().anyMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.ADJUST_VARIANT_WEIGHT)); + assertTrue(tools.stream().noneMatch(tool -> tool.payload().action() + == JigsawStudioToolAction.TOGGLE_REQUIRE_CAPS)); + } + + @Test + public void openMarshalsToThePlayerRegionBeforeReadingStudioState() { + Player player = mock(Player.class); + JigsawStudioMenuController.Actions actions = mock(JigsawStudioMenuController.Actions.class); + JigsawStudioMenuController controller = new JigsawStudioMenuController( + mock(JavaPlugin.class), + actions); + + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(player)).thenReturn(false); + scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenReturn(true); + + assertTrue(controller.open(player)); + verifyNoInteractions(actions); + } + } + + @Test + public void changedWorldClosesTheActiveMenuLease() throws ReflectiveOperationException { + UUID playerId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + UUID worldId = UUID.fromString("44444444-4444-4444-4444-444444444444"); + Player player = mock(Player.class); + World world = mock(World.class); + Location location = mock(Location.class); + PlayerChangedWorldEvent event = mock(PlayerChangedWorldEvent.class); + JigsawStudioMenuController menu = mock(JigsawStudioMenuController.class); + BoardSVC board = mock(BoardSVC.class); + JigsawStudioService service = new JigsawStudioService(); + BoardSVC previousBoard = IrisServices.getOrNull(BoardSVC.class); + + when(event.getPlayer()).thenReturn(player); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(location); + when(world.getUID()).thenReturn(worldId); + when(location.getWorld()).thenReturn(world); + + Field menuField = JigsawStudioService.class.getDeclaredField("menuController"); + menuField.setAccessible(true); + menuField.set(service, menu); + IrisServices.register(BoardSVC.class, board); + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(player)).thenReturn(true); + + service.onPlayerChangedWorld(event); + + verify(menu).close(player); + verify(board).clearJigsawContext(player); + } finally { + if (previousBoard == null) { + IrisServices.remove(BoardSVC.class); + } else { + IrisServices.register(BoardSVC.class, previousBoard); + } + } + } + + private JigsawStudioMenuState state( + JigsawStudioMenuState.Evaluation evaluation, + JigsawStudioMenuState.Workcell workcell + ) { + return new JigsawStudioMenuState( + WORLD_ID, + REQUEST_ID, + "village", + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + true, + List.of(new JigsawStudioMenuState.ThemeSet("variant-1", 1)), + workcell.stableId(), + evaluation, + List.of(workcell)); + } + + private JigsawStudioMenuState.Workcell workcell(JigsawStudioMenuState.Variant active) { + return workcell(active, List.of(active)); + } + + private JigsawStudioMenuState.Workcell workcell( + JigsawStudioMenuState.Variant active, + List variants + ) { + return new JigsawStudioMenuState.Workcell( + "workcell/corner", + "L Junction", + "Corner", + new JigsawStudioCellDimensions(24, 12, 18), + true, + active.pieceKey(), + true, + false, + false, + variants); + } + + private JigsawStudioMenuState.Variant variant( + String pieceKey, + boolean active, + List themes, + List memberships + ) { + return new JigsawStudioMenuState.Variant( + pieceKey, + pieceKey.substring(pieceKey.lastIndexOf('/') + 1), + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + active, + true, + true, + true, + active, + themes, + RULES, + memberships); + } + + private JigsawStudioMenuController.ToolboxTool tool( + List tools, + JigsawStudioToolAction action, + int amount + ) { + Optional match = tools.stream() + .filter(tool -> tool.payload().action() == action && tool.payload().amount() == amount) + .findFirst(); + return match.orElseThrow(); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioPreviewRendererTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioPreviewRendererTest.java new file mode 100644 index 000000000..34038b48c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioPreviewRendererTest.java @@ -0,0 +1,135 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.engine.framework.PlacedStructurePiece; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.spi.PlatformBlockState; +import org.bukkit.World; +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class JigsawStudioPreviewRendererTest { + @Test + public void planUsesPlacedPieceCenterAndExactBlockState() throws Exception { + PlatformBlockState stone = mock(PlatformBlockState.class); + when(stone.key()).thenReturn("minecraft:stone"); + IrisObject object = new IrisObject(3, 3, 3); + object.setUnsigned(2, 1, 0, stone); + PlacedStructurePiece piece = piece(object, 100, 70, -40, IrisObjectRotation.of(0, 0, 0)); + + JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(List.of(piece)); + + assertEquals(1, plan.blocks().size()); + assertEquals( + "minecraft:stone", + plan.blocks().get(new JigsawStudioPreviewRenderer.BlockPosition(101, 70, -41))); + assertEquals(99, plan.bounds().minimumX()); + assertEquals(101, plan.bounds().maximumX()); + assertEquals(-41, plan.bounds().minimumZ()); + assertEquals(-39, plan.bounds().maximumZ()); + } + + @Test + public void structureVoidRemovesAPreviouslyAuthoredPreviewCell() throws Exception { + PlatformBlockState stone = mock(PlatformBlockState.class); + when(stone.key()).thenReturn("minecraft:stone"); + PlatformBlockState structureVoid = mock(PlatformBlockState.class); + when(structureVoid.key()).thenReturn("minecraft:structure_void"); + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, stone); + object.setUnsigned(0, 0, 0, structureVoid); + + JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(List.of( + piece(object, 0, 65, 0, IrisObjectRotation.of(0, 0, 0)))); + + assertTrue(plan.blocks().isEmpty()); + assertFalse(plan.bounds().isEmpty()); + } + + @Test + public void emptyAssemblyProducesAnEmptyPlan() throws Exception { + JigsawStudioPreviewRenderer.PreviewPlan plan = JigsawStudioPreviewRenderer.plan(List.of()); + + assertTrue(plan.blocks().isEmpty()); + assertTrue(plan.bounds().isEmpty()); + } + + @Test + public void uncertainPositionIsReappliedWhenSuccessivePlansMatch() { + JigsawStudioPreviewRenderer.BlockPosition position = + new JigsawStudioPreviewRenderer.BlockPosition(4, 70, 9); + + assertTrue(JigsawStudioPreviewRenderer.requiresUpdate( + position, + Map.of(position, "minecraft:stone"), + Map.of(position, "minecraft:stone"), + Set.of(position))); + assertFalse(JigsawStudioPreviewRenderer.requiresUpdate( + position, + Map.of(position, "minecraft:stone"), + Map.of(position, "minecraft:stone"), + Set.of())); + } + + @Test + public void forgettingARequestDropsTrackingWithoutWorldCleanup() { + World world = mock(World.class); + UUID worldId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID requestId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + when(world.getUID()).thenReturn(worldId); + JigsawStudioPreviewRenderer renderer = new JigsawStudioPreviewRenderer(); + renderer.render( + world, + requestId, + 1L, + JigsawStudioPreviewRenderer.PreviewPlan.empty(), + ignored -> { + }); + assertTrue(renderer.bounds(requestId).isEmpty()); + clearInvocations(world); + + renderer.forgetRequest(requestId); + + assertNull(renderer.bounds(requestId)); + verifyNoInteractions(world); + } + + private static PlacedStructurePiece piece( + IrisObject object, + int x, + int y, + int z, + IrisObjectRotation rotation + ) { + int minimumX = x - object.getW() / 2; + int minimumY = y - object.getH() / 2; + int minimumZ = z - object.getD() / 2; + return new PlacedStructurePiece( + new IrisJigsawPiece().setObject("preview"), + object, + x, + y, + z, + rotation, + minimumX, + minimumY, + minimumZ, + minimumX + object.getW() - 1, + minimumY + object.getH() - 1, + minimumZ + object.getD() - 1); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssemblerTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssemblerTest.java new file mode 100644 index 000000000..8ae85e667 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioResourceBundleAssemblerTest.java @@ -0,0 +1,295 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.pack.StructurePackageClosure; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioProjectCreator; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; +import art.arcane.iris.core.structure.authoring.StructureWriteMode; +import art.arcane.iris.core.structure.authoring.StructureWriteOptions; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.JigsawJoint; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.UUID; + +import static org.junit.Assert.assertArrayEquals; +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 JigsawStudioResourceBundleAssemblerTest { + private static final Gson PRETTY_GSON = new GsonBuilder().setPrettyPrinting().create(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void assemblesAndAtomicallyOverwritesTheWholeOwnedGraph() throws Exception { + Path packRoot = createProject("fort"); + Path structurePath = packRoot.resolve("structures/fort.json"); + byte[] structureBefore = Files.readAllBytes(structurePath); + IrisJigsawConnector connector = connector(); + + JigsawStudioResourceBundleAssembler.Assembly assembly = + JigsawStudioResourceBundleAssembler.assemble( + packRoot, + "fort", + "fort/start", + emptyObject(), + List.of(connector), + false); + + assertEquals(4, assembly.bundle().resources().size()); + assertEquals("fort/start", assembly.objectKey()); + assertEquals(1, assembly.piece().getConnectors().size()); + StructureWriteResult result = new StructureTransactionWriter(packRoot) + .write(assembly.bundle(), StructureWriteMode.OVERWRITE); + assertTrue(result.successful()); + assertTrue(result.committed()); + + Path piecePath = packRoot.resolve("jigsaw-pieces/fort/start.json"); + IrisJigsawPiece persisted = new Gson().fromJson( + Files.readString(piecePath, StandardCharsets.UTF_8), IrisJigsawPiece.class); + assertEquals(1, persisted.getConnectors().size()); + assertEquals("fort/start", persisted.getConnectors().getFirst().getPool()); + assertArrayEquals(structureBefore, Files.readAllBytes(structurePath)); + StructurePackageClosure closure = StructurePackageClosure.collect(packRoot.toFile(), List.of("fort")); + assertTrue(closure.errors().toString(), closure.isValid()); + } + + @Test + public void assemblesAutosaveBundleFromDefaultPlanarProject() throws Exception { + String structureKey = "autosave/planar"; + Path packRoot = temporaryFolder.newFolder("planar-autosave").toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + structureKey, + JigsawStudioMode.PLANAR_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(16, 16, 16)); + assertTrue(JigsawStudioProjectCreator.create(packRoot, options).successful()); + String pieceKey = structureKey + "/end"; + IrisJigsawPiece piece = new Gson().fromJson( + Files.readString( + packRoot.resolve("jigsaw-pieces/" + pieceKey + ".json"), + StandardCharsets.UTF_8), + IrisJigsawPiece.class); + + JigsawStudioResourceBundleAssembler.Assembly assembly = + JigsawStudioResourceBundleAssembler.assemble( + packRoot, + structureKey, + pieceKey, + Files.readAllBytes(packRoot.resolve("objects/" + pieceKey + ".iob")), + List.copyOf(piece.getConnectors()), + false); + StructureWriteResult result = new StructureTransactionWriter(packRoot).write( + assembly.bundle(), + StructureWriteOptions.overwriteExpected(assembly.expectedManifestHash())); + + assertTrue(result.conflicts().toString(), result.successful()); + assertTrue(result.committed()); + StructurePackageClosure closure = StructurePackageClosure.collect( + packRoot.toFile(), + List.of(structureKey)); + assertTrue(closure.errors().toString(), closure.isValid()); + } + + @Test + public void capturePreservesAbsentDefaultsAndUnknownPieceMetadata() throws Exception { + Path packRoot = createProject("preserved"); + String pieceResource = "jigsaw-pieces/preserved/start.json"; + Path piecePath = packRoot.resolve(pieceResource); + JsonObject source = JsonParser.parseString( + Files.readString(piecePath, StandardCharsets.UTF_8)).getAsJsonObject(); + source.remove("collidable"); + JsonObject extension = new JsonObject(); + extension.addProperty("futureMode", "retained"); + source.add("extensionMetadata", extension); + byte[] sourceContent = (PRETTY_GSON.toJson(source) + "\n").getBytes(StandardCharsets.UTF_8); + Files.write(piecePath, sourceContent); + IrisJigsawPiece sourcePiece = new Gson().fromJson(source, IrisJigsawPiece.class); + JigsawStudioResourceBundleAssembler.Assembly unchangedAssembly = + JigsawStudioResourceBundleAssembler.assemble( + packRoot, + "preserved", + "preserved/start", + emptyObject(), + List.copyOf(sourcePiece.getConnectors()), + false); + assertArrayEquals( + sourceContent, + unchangedAssembly.bundle().resources().get(pieceResource).content()); + IrisJigsawConnector updated = connector() + .setPool("preserved/start") + .setName("iris:updated"); + + JigsawStudioResourceBundleAssembler.Assembly assembly = + JigsawStudioResourceBundleAssembler.assemble( + packRoot, + "preserved", + "preserved/start", + emptyObject(), + List.of(updated), + false); + + JsonObject captured = JsonParser.parseString(new String( + assembly.bundle().resources().get(pieceResource).content(), + StandardCharsets.UTF_8)).getAsJsonObject(); + assertFalse(captured.has("collidable")); + assertEquals(extension, captured.getAsJsonObject("extensionMetadata")); + assertEquals( + "iris:updated", + captured.getAsJsonArray("connectors") + .get(0) + .getAsJsonObject() + .get("name") + .getAsString()); + } + + @Test + public void refusesAProjectWithoutAnOwnershipManifest() throws Exception { + Path packRoot = createProject("unowned"); + StructureTransactionWriter writer = new StructureTransactionWriter(packRoot); + Files.delete(writer.ownershipManifestPath(new StructureKey("iris", "unowned"))); + + IOException exception = assertThrows(IOException.class, + () -> JigsawStudioResourceBundleAssembler.assemble( + packRoot, + "unowned", + "unowned/start", + emptyObject(), + List.of(connector()), + false)); + + assertTrue(exception.getMessage().contains("not Studio-owned")); + } + + @Test + public void refusesManagedDatapackOwnershipWithoutAssemblingAStudioSave() throws Exception { + Path packRoot = createProject("managed"); + Path objectPath = packRoot.resolve("objects/managed/start.iob"); + byte[] before = Files.readAllBytes(objectPath); + markManagedDatapack(packRoot, "managed"); + + IOException exception = assertThrows(IOException.class, + () -> JigsawStudioResourceBundleAssembler.assemble( + packRoot, + "managed", + "managed/start", + emptyObject(), + List.of(connector()), + false)); + + assertTrue(exception.getMessage(), exception.getMessage().contains("managed by datapack ingest")); + assertTrue(exception.getMessage(), exception.getMessage().contains("adopt or clone")); + assertArrayEquals(before, Files.readAllBytes(objectPath)); + } + + @Test + public void writerRejectsAResourceModifiedOutsideTheOwnershipTransaction() throws Exception { + Path packRoot = createProject("modified"); + Path piecePath = packRoot.resolve("jigsaw-pieces/modified/start.json"); + Files.writeString(piecePath, Files.readString(piecePath, StandardCharsets.UTF_8) + "\n"); + JigsawStudioResourceBundleAssembler.Assembly assembly = + JigsawStudioResourceBundleAssembler.assemble( + packRoot, + "modified", + "modified/start", + emptyObject(), + List.of(connector().setPool("modified/start")), + false); + + StructureWriteResult result = new StructureTransactionWriter(packRoot) + .write(assembly.bundle(), StructureWriteMode.OVERWRITE); + + assertFalse(result.successful()); + assertEquals(StructureWriteResult.Status.OWNERSHIP_CONFLICT, result.status()); + assertFalse(result.conflicts().isEmpty()); + } + + private Path createProject(String structureKey) throws Exception { + Path packRoot = temporaryFolder.newFolder(structureKey).toPath(); + JigsawStudioProjectCreator.Options options = new JigsawStudioProjectCreator.Options( + structureKey, + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + new JigsawStudioCellDimensions(4, 4, 4)); + StructureWriteResult result = JigsawStudioProjectCreator.create(packRoot, options); + assertTrue(result.successful()); + return packRoot; + } + + private static void markManagedDatapack(Path packRoot, String structureKey) throws IOException { + StructureTransactionWriter writer = new StructureTransactionWriter(packRoot); + Path manifestPath = writer.ownershipManifestPath(new StructureKey("iris", structureKey)); + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson( + Files.readAllBytes(manifestPath)); + Map mappings = new TreeMap<>(); + for (String relativePath : manifest.resourceHashes().keySet()) { + mappings.put(relativePath, relativePath); + } + StructureOwnershipManifest.Provenance provenance = new StructureOwnershipManifest.Provenance( + StructureOwnershipManifest.Origin.MANAGED_DATAPACK, + UUID.fromString("88888888-8888-8888-8888-888888888888").toString(), + "1".repeat(64), + "2".repeat(64), + 1L, + manifest.resourceHashes(), + mappings, + StructureOwnershipManifest.RollbackDisposition.NONE); + StructureOwnershipManifest managed = new StructureOwnershipManifest( + manifest.schemaVersion(), + manifest.structure(), + manifest.source(), + manifest.backend(), + manifest.capabilities(), + manifest.losses(), + manifest.resourceHashes(), + provenance); + Files.write(manifestPath, managed.toJson()); + } + + private static IrisJigsawConnector connector() { + return new IrisJigsawConnector() + .setPosition(new IrisPosition(0, 0, 0)) + .setDirection(IrisDirection.NORTH_NEGATIVE_Z) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("fort/start") + .setName("iris:door") + .setTargetName("iris:door") + .setJoint(JigsawJoint.ALIGNED) + .setFinalState("minecraft:air"); + } + + private static byte[] emptyObject() throws IOException { + IrisObject object = new IrisObject(4, 4, 4); + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + object.write(output); + return output.toByteArray(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioServiceCaptureTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioServiceCaptureTest.java new file mode 100644 index 000000000..a9791b756 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioServiceCaptureTest.java @@ -0,0 +1,1445 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBounds; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPoolMembership; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology; +import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.platform.studio.generators.JigsawStudioGenerator; +import art.arcane.iris.platform.bukkit.BukkitBlockState; +import art.arcane.iris.spi.IrisLogging; +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.iris.util.common.data.B; +import art.arcane.iris.util.common.math.IrisBlockVector; +import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Directional; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.BlockCookEvent; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BrewingStartEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockRedstoneEvent; +import org.bukkit.event.block.CrafterCraftEvent; +import org.bukkit.event.inventory.BrewEvent; +import org.bukkit.event.inventory.BrewingStandFuelEvent; +import org.bukkit.event.inventory.FurnaceBurnEvent; +import org.bukkit.event.inventory.FurnaceStartSmeltEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryMoveItemEvent; +import org.bukkit.event.inventory.InventoryPickupItemEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.WorldUnloadEvent; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertArrayEquals; +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.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class JigsawStudioServiceCaptureTest { + @Test + public void mappedGraphOwnershipControlsNewVariantsEvenWhenTheCatalogIsEmpty() { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + JigsawStudioLayout editable = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + new JigsawStudioVariantCatalog(List.of(), true)); + JigsawStudioLayout managed = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + new JigsawStudioVariantCatalog(List.of(), false)); + + assertTrue(JigsawStudioService.canCreateVariants(editable)); + assertFalse(JigsawStudioService.canCreateVariants(managed)); + } + + @Test + public void guiVariantCreationRequiresAnActiveOwnedAssignedSource() { + JigsawStudioVariant assigned = new JigsawStudioVariant( + "test/end", + "test/end", + "", + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + JigsawStudioMode.PLANAR_JIGSAW, + Optional.of(JigsawPlanarTopology.NORTH_END), + true, + true, + List.of("variant-1"), + new JigsawStudioPieceRules(0, 30, 0, 0, true), + List.of( + new JigsawStudioPoolMembership("test/pieces", 0, 4, 0.35D), + new JigsawStudioPoolMembership("test/caps", 0, 7, 0.8D))); + + assertEquals("", JigsawStudioService.variantCreationSourceFailure(assigned, false)); + assertEquals("", JigsawStudioService.variantCreationSourceFailure(assigned, true)); + assertTrue(JigsawStudioService.variantCreationSourceFailure(null, false) + .contains("piece create ")); + assertTrue(JigsawStudioService.variantCreationSourceFailure(planarVariant(true, true), false) + .contains("no owned pool membership")); + assertTrue(JigsawStudioService.variantCreationSourceFailure(planarVariant(true, false), true) + .contains("read-only variant")); + } + + @Test + public void recognizesVanillaNamespacedAndWorldEditMutationCommands() { + assertTrue(JigsawStudioService.isMutatingCommand("/fill 0 0 0 1 1 1 stone")); + assertTrue(JigsawStudioService.isMutatingCommand("minecraft:setblock 0 0 0 air")); + assertTrue(JigsawStudioService.isMutatingCommand("//paste -a")); + assertTrue(JigsawStudioService.isMutatingCommand("/execute as @s run setblock 0 0 0 stone")); + assertTrue(JigsawStudioService.isMutatingCommand("/function test:build")); + assertTrue(JigsawStudioService.isMutatingCommand("/data merge block 0 0 0 {}")); + assertTrue(JigsawStudioService.isMutatingCommand("/item replace block 0 0 0 container.0 with stone")); + assertFalse(JigsawStudioService.isMutatingCommand("/iris jigsaw status")); + assertFalse(JigsawStudioService.isMutatingCommand("/tp 0 80 0")); + } + + @Test + public void nonOwnerMutatingCommandsAreBlockedAtHighestPriority() throws NoSuchMethodException { + UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + assertTrue(JigsawStudioService.ownerMatches(ownerId, ownerId)); + assertTrue(JigsawStudioService.ownerMatches(null, otherId)); + assertFalse(JigsawStudioService.ownerMatches(ownerId, otherId)); + assertTrue(JigsawStudioService.blocksStudioEdit(ownerId, otherId)); + assertFalse(JigsawStudioService.blocksStudioEdit(ownerId, ownerId)); + assertFalse(JigsawStudioService.blocksStudioEdit(null, otherId)); + assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "//paste -a")); + assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, ownerId, "//paste -a")); + assertFalse(JigsawStudioService.blocksMutatingCommand(null, otherId, "//paste -a")); + assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/iris jigsaw status")); + assertFalse(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/msg owner hello")); + assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/execute run say bypass")); + assertTrue(JigsawStudioService.blocksMutatingCommand(ownerId, otherId, "/unknownplugin mutate")); + assertTrue(JigsawStudioService.blocksNonEditableWorkcellMutation( + true, "/fill 0 0 0 1 1 1 stone")); + assertTrue(JigsawStudioService.blocksNonEditableWorkcellMutation(true, "//paste -a")); + assertFalse(JigsawStudioService.blocksNonEditableWorkcellMutation( + true, "/iris jigsaw variant load test/end")); + assertFalse(JigsawStudioService.blocksNonEditableWorkcellMutation( + false, "/fill 0 0 0 1 1 1 stone")); + + Method method = JigsawStudioService.class.getMethod( + "onUnauthorizedPlayerCommand", + PlayerCommandPreprocessEvent.class); + EventHandler handler = method.getAnnotation(EventHandler.class); + assertNotNull(handler); + assertEquals(EventPriority.HIGHEST, handler.priority()); + assertTrue(handler.ignoreCancelled()); + } + + @Test + public void indirectMutationEventsRemainMonitorListeners() throws NoSuchMethodException { + assertMutationHandler("onPlayerInteract", PlayerInteractEvent.class); + assertMutationHandler("onBlockRedstone", BlockRedstoneEvent.class); + assertMutationHandler("onPistonExtend", BlockPistonExtendEvent.class); + assertMutationHandler("onPistonRetract", BlockPistonRetractEvent.class); + } + + @Test + public void studioContainerMutationPolicyProtectsOperationsOwnersAndAutomation() { + UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID otherId = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + assertFalse(JigsawStudioService.blocksStudioInventoryMutation( + false, false, true, true, ownerId, otherId)); + assertFalse(JigsawStudioService.blocksStudioInventoryMutation( + true, false, false, false, ownerId, ownerId)); + assertTrue(JigsawStudioService.blocksStudioInventoryMutation( + true, false, true, false, ownerId, ownerId)); + assertTrue(JigsawStudioService.blocksStudioInventoryMutation( + true, false, false, true, ownerId, ownerId)); + assertTrue(JigsawStudioService.blocksStudioInventoryMutation( + true, false, false, false, ownerId, otherId)); + assertFalse(JigsawStudioService.blocksStudioInventoryMutation( + true, false, false, false, ownerId, null)); + assertTrue(JigsawStudioService.blocksStudioInventoryMutation( + true, true, false, false, ownerId, null)); + assertTrue(JigsawStudioService.blocksStudioInventoryMutation( + true, true, false, false, ownerId, ownerId)); + } + + @Test + public void containerAndMachineMutationEventsHaveProtectionAndAutosaveContracts() + throws NoSuchMethodException { + assertHandler("onProtectedInventoryClick", InventoryClickEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedInventoryDrag", InventoryDragEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedInventoryMove", InventoryMoveItemEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedInventoryPickup", InventoryPickupItemEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedBlockCook", BlockCookEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedFurnaceBurn", FurnaceBurnEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedBrew", BrewEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedBrewingStandFuel", BrewingStandFuelEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedBlockDispense", BlockDispenseEvent.class, EventPriority.HIGHEST, true); + assertHandler("onProtectedCrafterCraft", CrafterCraftEvent.class, EventPriority.HIGHEST, true); + + assertMutationHandler("onBlockCook", BlockCookEvent.class); + assertMutationHandler("onFurnaceBurn", FurnaceBurnEvent.class); + assertHandler("onFurnaceStartSmelt", FurnaceStartSmeltEvent.class, EventPriority.MONITOR, false); + assertMutationHandler("onBrew", BrewEvent.class); + assertHandler("onBrewingStart", BrewingStartEvent.class, EventPriority.MONITOR, false); + assertMutationHandler("onBrewingStandFuel", BrewingStandFuelEvent.class); + assertMutationHandler("onBlockDispense", BlockDispenseEvent.class); + assertMutationHandler("onCrafterCraft", CrafterCraftEvent.class); + assertHandler("onWorldUnload", WorldUnloadEvent.class, EventPriority.HIGHEST, true); + } + + @Test + public void jigsawTileSnapshotDetectsLateVanillaGuiMutation() { + KMap baseline = new KMap<>(); + baseline.put("name", "iris:start"); + KMap unchanged = new KMap<>(); + unchanged.put("name", "iris:start"); + KMap changed = new KMap<>(); + changed.put("name", "iris:hall"); + KMap changedAgain = new KMap<>(); + changedAgain.put("name", "iris:cap"); + + assertFalse(JigsawStudioService.tileSnapshotChanged(baseline, unchanged)); + assertTrue(JigsawStudioService.tileSnapshotChanged(baseline, changed)); + assertEquals( + new JigsawStudioService.JigsawTilePollDecision(true, true), + JigsawStudioService.jigsawTilePollDecision(baseline, changed, false)); + assertEquals( + new JigsawStudioService.JigsawTilePollDecision(true, true), + JigsawStudioService.jigsawTilePollDecision(changed, changedAgain, false)); + assertEquals( + new JigsawStudioService.JigsawTilePollDecision(false, false), + JigsawStudioService.jigsawTilePollDecision(changedAgain, changedAgain, true)); + } + + @Test + public void persistentAutosaveFailuresLogOncePerDirtyIdentityAndUseCappedBackoff() { + UUID requestId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + IOException validationFailure = new IOException("closure rejected an empty piece entry"); + String validationDetail = "Jigsaw Studio save failed: closure rejected an empty piece entry"; + String validationContext = JigsawStudioService.autosaveFailureContext( + requestId, + "qa/profile_final", + "workcell/end", + "qa/profile_final/end", + validationDetail); + JigsawStudioService.AutosaveFailureState validationState = + new JigsawStudioService.AutosaveFailureState(); + StructureWriteResult writerResult = new StructureWriteResult( + StructureWriteResult.Status.OWNERSHIP_CONFLICT, + StructureWriteResult.Action.NONE, + List.of(StructureWriteResult.Conflict.at( + "jigsaw-pieces/qa/profile_final/end.json", + StructureWriteResult.ConflictReason.MODIFIED_RESOURCE)), + List.of(), + "", + Optional.empty()); + String writerDetail = JigsawStudioService.writeFailure(writerResult); + String writerContext = JigsawStudioService.autosaveFailureContext( + requestId, + "qa/profile_final", + "workcell/end", + "qa/profile_final/end", + writerDetail); + JigsawStudioService.AutosaveFailureState writerState = + new JigsawStudioService.AutosaveFailureState(); + + try (MockedStatic logging = mockStatic(IrisLogging.class)) { + JigsawStudioService.AutosaveFailureDecision firstValidation = + JigsawStudioService.recordPersistentAutosaveFailure( + validationState, + requestId, + "qa/profile_final", + "workcell/end", + "qa/profile_final/end", + validationDetail, + validationFailure); + JigsawStudioService.AutosaveFailureDecision secondValidation = + JigsawStudioService.recordPersistentAutosaveFailure( + validationState, + requestId, + "qa/profile_final", + "workcell/end", + "qa/profile_final/end", + validationDetail, + validationFailure); + List writerDelays = new ArrayList<>(); + for (int attempt = 0; attempt < 7; attempt++) { + writerDelays.add(JigsawStudioService.recordPersistentAutosaveFailure( + writerState, + requestId, + "qa/profile_final", + "workcell/end", + "qa/profile_final/end", + writerDetail, + null).retryTicks()); + } + + assertEquals(40, firstValidation.retryTicks()); + assertTrue(firstValidation.logFailure()); + assertEquals(80, secondValidation.retryTicks()); + assertFalse(secondValidation.logFailure()); + assertEquals(List.of(40, 80, 160, 320, 600, 600, 600), writerDelays); + logging.verify( + () -> IrisLogging.reportError(validationContext, validationFailure), + times(1)); + logging.verify(() -> IrisLogging.warn("%s", writerContext), times(1)); + } + + JigsawStudioService.AutosaveFailureDecision newIdentity = + new JigsawStudioService.AutosaveFailureState().recordPersistentFailure(); + assertEquals(40, newIdentity.retryTicks()); + assertTrue(newIdentity.logFailure()); + } + + @Test + public void siblingAutosaveResolvesTheCurrentBayAfterACommittedSaveReloadsLayout() + throws ReflectiveOperationException { + UUID requestId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + UUID worldId = UUID.fromString("44444444-4444-4444-4444-444444444444"); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + JigsawStudioVariant endVariant = planarVariant( + "test/end", + JigsawPlanarTopology.NORTH_END, + true, + true); + JigsawStudioVariant straightVariant = planarVariant( + "test/straight", + JigsawPlanarTopology.NORTH_SOUTH_STRAIGHT, + true, + true); + JigsawStudioVariantCatalog catalog = new JigsawStudioVariantCatalog( + List.of(endVariant, straightVariant)); + JigsawStudioLayout initialLayout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + catalog); + JigsawStudioSession session = new JigsawStudioSession( + "overworld", + "qa/profile_final", + initialLayout); + JigsawStudioBay initialEnd = initialLayout.get("workcell/end"); + JigsawStudioBay initialStraight = initialLayout.get("workcell/straight"); + JigsawStudioSession.DirtyIdentity endDirty = session.markWorkcellDirty(initialEnd.stableId()) + .identity() + .orElseThrow(); + JigsawStudioSession.DirtyIdentity straightDirty = session.markWorkcellDirty(initialStraight.stableId()) + .identity() + .orElseThrow(); + JigsawStudioActivation.Request request = mock(JigsawStudioActivation.Request.class); + when(request.requestId()).thenReturn(requestId); + when(request.packKey()).thenReturn("overworld"); + when(request.structureKey()).thenReturn("qa/profile_final"); + IrisData source = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader pieceLoader = mock(ResourceLoader.class); + @SuppressWarnings("unchecked") + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(request.source()).thenReturn(source); + when(source.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(source.getObjectLoader()).thenReturn(objectLoader); + when(pieceLoader.load("test/end", false)) + .thenReturn(new IrisJigsawPiece().setObject("test/end")); + when(pieceLoader.load("test/straight", false)) + .thenReturn(new IrisJigsawPiece().setObject("test/straight")); + when(objectLoader.load("test/end", false)).thenReturn(new IrisObject(16, 16, 16)); + when(objectLoader.load("test/straight", false)).thenReturn(new IrisObject(16, 16, 16)); + JigsawStudioGenerator generator = mock(JigsawStudioGenerator.class); + when(generator.getRequest()).thenReturn(request); + when(generator.getSession()).thenReturn(session); + when(generator.getLayout()).thenAnswer(invocation -> session.layout()); + World world = mock(World.class); + when(world.getUID()).thenReturn(worldId); + ConcurrentHashMap populations = new ConcurrentHashMap<>(); + JigsawStudioService.BayPopulation endPopulation = + new JigsawStudioService.BayPopulation(Set.of(0L), ""); + JigsawStudioService.BayPopulation straightPopulation = + new JigsawStudioService.BayPopulation(Set.of(0L), ""); + endPopulation.markFullyReady(); + straightPopulation.markFullyReady(); + populations.put(initialEnd.stableId(), endPopulation); + populations.put(initialStraight.stableId(), straightPopulation); + Class studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio"); + Constructor studioConstructor = studioType.getDeclaredConstructor( + UUID.class, + World.class, + Engine.class, + JigsawStudioGenerator.class, + ConcurrentHashMap.class, + Set.class, + AtomicLong.class); + studioConstructor.setAccessible(true); + Object studio = studioConstructor.newInstance( + worldId, + world, + mock(Engine.class), + generator, + populations, + ConcurrentHashMap.newKeySet(), + new AtomicLong()); + Method scheduleAutosave = JigsawStudioService.class.getDeclaredMethod( + "scheduleAutosave", + studioType, + JigsawStudioBay.class, + JigsawStudioSession.DirtyIdentity.class, + int.class); + scheduleAutosave.setAccessible(true); + JigsawStudioService service = new JigsawStudioService(); + Field studiosField = JigsawStudioService.class.getDeclaredField("studios"); + studiosField.setAccessible(true); + @SuppressWarnings("unchecked") + Map studios = (Map) studiosField.get(service); + studios.put(worldId, studio); + List delayedAutosaves = new ArrayList<>(); + + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic activation = mockStatic(JigsawStudioActivation.class)) { + activation.when(() -> JigsawStudioActivation.getRequest("overworld")).thenReturn(request); + scheduling.when(() -> J.runRegion( + any(World.class), + anyInt(), + anyInt(), + any(Runnable.class), + eq(40))) + .thenAnswer(invocation -> { + delayedAutosaves.add(invocation.getArgument(3, Runnable.class)); + return true; + }); + scheduling.when(() -> J.runRegion( + any(World.class), + anyInt(), + anyInt(), + any(Runnable.class))) + .thenReturn(true); + scheduleAutosave.invoke(service, studio, initialEnd, endDirty, 40); + scheduleAutosave.invoke(service, studio, initialStraight, straightDirty, 40); + assertEquals(2, delayedAutosaves.size()); + + JigsawStudioSession.SaveIdentity committedEnd = session.beginSave(initialEnd.stableId()) + .identity() + .orElseThrow(); + assertTrue(session.markWorkcellSaved(committedEnd)); + JigsawStudioLayout replacementLayout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + catalog); + assertTrue(session.replaceLayout(replacementLayout)); + JigsawStudioBay currentStraight = JigsawStudioService.resolveCurrentAutosaveBay( + session, + initialStraight.stableId()); + assertNotSame(initialStraight, currentStraight); + assertSame(replacementLayout.get(initialStraight.stableId()), currentStraight); + assertTrue(session.isDirtyCurrent(straightDirty)); + + delayedAutosaves.get(1).run(); + + assertTrue(session.workcellSnapshot(initialStraight.stableId()).saveInProgress()); + } + } + + @Test + public void saveNowRetainsAutosaveAcrossNotReadyInProgressAndSchedulerRejection() + throws ReflectiveOperationException { + UUID ownerId = UUID.fromString("11111111-1111-1111-1111-111111111111"); + UUID requestId = UUID.fromString("33333333-3333-3333-3333-333333333333"); + UUID worldId = UUID.fromString("44444444-4444-4444-4444-444444444444"); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + new JigsawStudioVariantCatalog(List.of(planarVariant(true, true)))); + JigsawStudioBay bay = layout.get("workcell/end"); + JigsawStudioSession session = new JigsawStudioSession("overworld", "village", layout); + JigsawStudioSession.DirtyIdentity identity = session.markWorkcellDirty(bay.stableId()) + .identity() + .orElseThrow(); + JigsawStudioActivation.Request request = mock(JigsawStudioActivation.Request.class); + when(request.requestId()).thenReturn(requestId); + when(request.ownerId()).thenReturn(ownerId); + IrisData source = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader pieceLoader = mock(ResourceLoader.class); + @SuppressWarnings("unchecked") + ResourceLoader objectLoader = mock(ResourceLoader.class); + IrisJigsawPiece piece = new IrisJigsawPiece().setObject("test/end"); + IrisObject object = new IrisObject(16, 16, 16); + when(request.source()).thenReturn(source); + when(source.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(source.getObjectLoader()).thenReturn(objectLoader); + when(pieceLoader.load("test/end", false)).thenReturn(piece); + when(objectLoader.load("test/end", false)).thenReturn(object); + JigsawStudioGenerator generator = mock(JigsawStudioGenerator.class); + when(generator.getRequest()).thenReturn(request); + when(generator.getSession()).thenReturn(session); + when(generator.getLayout()).thenReturn(layout); + when(generator.renderBay(any(JigsawStudioBay.class))) + .thenReturn(JigsawStudioGenerator.RenderedBay.empty(dimensions)); + World world = mock(World.class); + when(world.getUID()).thenReturn(worldId); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(ownerId); + when(player.getWorld()).thenReturn(world); + + JigsawStudioService service = new JigsawStudioService(); + ConcurrentHashMap populations = new ConcurrentHashMap<>(); + populations.put( + bay.stableId(), + new JigsawStudioService.BayPopulation(Set.of(0L), "workcell hydration failed")); + Class studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio"); + Constructor studioConstructor = studioType.getDeclaredConstructor( + UUID.class, + World.class, + Engine.class, + JigsawStudioGenerator.class, + ConcurrentHashMap.class, + Set.class, + AtomicLong.class); + studioConstructor.setAccessible(true); + Object studio = studioConstructor.newInstance( + worldId, + world, + mock(Engine.class), + generator, + populations, + ConcurrentHashMap.newKeySet(), + new AtomicLong()); + Field studiosField = JigsawStudioService.class.getDeclaredField("studios"); + studiosField.setAccessible(true); + @SuppressWarnings("unchecked") + Map studios = (Map) studiosField.get(service); + studios.put(worldId, studio); + + Class keyType = Class.forName(JigsawStudioService.class.getName() + "$AutosaveKey"); + Constructor keyConstructor = keyType.getDeclaredConstructor(UUID.class, String.class); + keyConstructor.setAccessible(true); + Object key = keyConstructor.newInstance(requestId, bay.stableId()); + Class ticketType = Class.forName(JigsawStudioService.class.getName() + "$AutosaveTicket"); + Constructor ticketConstructor = ticketType.getDeclaredConstructor( + keyType, + studioType, + JigsawStudioSession.DirtyIdentity.class, + AtomicBoolean.class, + AtomicBoolean.class); + ticketConstructor.setAccessible(true); + Object ticket = ticketConstructor.newInstance( + key, + studio, + identity, + new AtomicBoolean(false), + new AtomicBoolean(false)); + Field autosavesField = JigsawStudioService.class.getDeclaredField("autosaves"); + autosavesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map autosaves = (Map) autosavesField.get(service); + autosaves.put(key, ticket); + Method failureStateAccessor = ticketType.getDeclaredMethod("failureState"); + failureStateAccessor.setAccessible(true); + JigsawStudioService.AutosaveFailureState failureState = + (JigsawStudioService.AutosaveFailureState) failureStateAccessor.invoke(ticket); + assertEquals(40, failureState.recordPersistentFailure().retryTicks()); + + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.isOwnedByCurrentRegion(player)).thenReturn(true); + scheduling.when(() -> J.runRegion( + any(World.class), anyInt(), anyInt(), any(Runnable.class), anyInt())) + .thenReturn(false); + + assertFalse(service.flushAutosave(player, bay.stableId())); + assertEquals(1, autosaves.size()); + assertTrue(session.isDirtyCurrent(identity)); + + populations.put( + bay.stableId(), + new JigsawStudioService.BayPopulation(Set.of(0L), "")); + assertFalse(service.flushAutosave(player, bay.stableId())); + assertEquals(1, autosaves.size()); + assertTrue(session.isDirtyCurrent(identity)); + + JigsawStudioService.BayPopulation ready = + new JigsawStudioService.BayPopulation(Set.of(0L), ""); + ready.markFullyReady(); + populations.put(bay.stableId(), ready); + JigsawStudioSession.SaveIdentity inProgress = session.beginSave(bay.stableId()) + .identity() + .orElseThrow(); + assertFalse(service.flushAutosave(player, bay.stableId())); + assertEquals(1, autosaves.size()); + assertTrue(session.abortSave(inProgress)); + scheduling.verify(() -> J.s(any(Runnable.class), eq(5)), times(3)); + Object retainedTicket = autosaves.values().iterator().next(); + assertSame(failureState, failureStateAccessor.invoke(retainedTicket)); + assertEquals(80, failureState.recordPersistentFailure().retryTicks()); + } + } + + @Test + public void vanillaPortableRotationCannotBeDisabledFromTheControlMenu() { + JigsawStudioVariant rotatable = planarVariant(true, true); + JigsawStudioVariant fixed = planarVariant(false, true); + JigsawStudioVariant readOnly = planarVariant(true, false); + + assertFalse(JigsawStudioService.canToggleVariantRotation( + JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, rotatable)); + assertTrue(JigsawStudioService.canToggleVariantRotation( + JigsawStudioCompatibilityTarget.VANILLA_PORTABLE, fixed)); + assertTrue(JigsawStudioService.canToggleVariantRotation( + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, rotatable)); + assertFalse(JigsawStudioService.canToggleVariantRotation( + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, readOnly)); + } + + @Test + public void planarSaveRequiresTheCanonicalWorkcellConnectorOrientation() throws IOException { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + JigsawStudioVariantCatalog.empty()); + IrisJigsawConnector eastSource = connector().setDirection(IrisDirection.EAST_POSITIVE_X); + IrisJigsawConnector southDisplayed = connector().setDirection(IrisDirection.SOUTH_POSITIVE_Z); + + JigsawStudioService.requireWorkcellTopology( + layout.get("workcell/end"), + List.of(eastSource), + 3); + assertThrows(IOException.class, () -> JigsawStudioService.requireWorkcellTopology( + layout.get("workcell/end"), + List.of(southDisplayed), + 0)); + } + + @Test + public void rotatedConnectorFinalStatesReturnToStoredOrientation() throws IOException { + JigsawStudioBounds bounds = new JigsawStudioBounds( + 0, + 64, + 0, + new JigsawStudioCellDimensions(3, 1, 3)); + List areas = JigsawStudioService.chunkIntersections(bounds); + PlatformBlockState sourceState = BukkitBlockState.of(directionalBlockData(BlockFace.NORTH)); + for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) { + IrisObjectRotation displayRotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0); + PlatformBlockState displayedState = displayRotation.rotate(sourceState, 0, 0, 0); + IrisDirection displayedDirection = displayRotation.rotate(IrisDirection.NORTH_NEGATIVE_Z); + JigsawStudioService.CapturedConnector connector = new JigsawStudioService.CapturedConnector( + 1, + 0, + 1, + displayedDirection, + IrisDirection.UP_POSITIVE_Y, + "test/pool", + "door", + "door", + "", + JigsawJoint.ALIGNED, + displayedState.key(), + -3, + 7); + JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot( + areas.getFirst(), + List.of(), + List.of(connector)); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull( + displayedState.key(), false)).thenReturn(displayedState); + JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots( + bounds, + areas, + List.of(snapshot), + quarterTurns); + + assertEquals(sourceState.key(), capture.connectors().getFirst().getFinalState()); + assertEquals(-3, capture.connectors().getFirst().getSelectionPriority()); + assertEquals(7, capture.connectors().getFirst().getPlacementPriority()); + } + } + } + + @Test + public void noOpTeeAndCrossCapturePreservesCreatorOrderForSeededAssembly() throws IOException { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + List> authoredOrders = List.of( + List.of( + IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.EAST_POSITIVE_X, + IrisDirection.WEST_NEGATIVE_X), + List.of( + IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.EAST_POSITIVE_X, + IrisDirection.SOUTH_POSITIVE_Z, + IrisDirection.WEST_NEGATIVE_X)); + List> captureOrders = List.of( + List.of( + IrisDirection.WEST_NEGATIVE_X, + IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.EAST_POSITIVE_X), + List.of( + IrisDirection.WEST_NEGATIVE_X, + IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.SOUTH_POSITIVE_Z, + IrisDirection.EAST_POSITIVE_X)); + + for (int index = 0; index < authoredOrders.size(); index++) { + IrisJigsawPiece source = pieceWithPlanarConnectors(dimensions, authoredOrders.get(index)); + List captured = planarConnectors(dimensions, captureOrders.get(index)); + + List ordered = JigsawStudioService.preserveCapturedConnectorOrder( + source, + captured); + + assertEquals(authoredOrders.get(index), connectorDirections(ordered)); + } + } + + @Test + public void connectorMetadataEditsAtTheSamePositionKeepAuthoredOrder() throws IOException { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + IrisJigsawPiece source = pieceWithPlanarConnectors(dimensions, List.of( + IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.EAST_POSITIVE_X)); + IrisJigsawConnector capturedNorth = planarConnector( + dimensions, + IrisDirection.NORTH_NEGATIVE_Z) + .setPool("changed/pool") + .setName("changed:name") + .setTargetName("changed:target") + .setChannel("changed-channel") + .setJoint(JigsawJoint.ROLLABLE) + .setFinalState("minecraft:stone") + .setSelectionPriority(9) + .setPlacementPriority(-4); + IrisJigsawConnector capturedEast = planarConnector( + dimensions, + IrisDirection.EAST_POSITIVE_X); + + List ordered = JigsawStudioService.preserveCapturedConnectorOrder( + source, + List.of(capturedEast, capturedNorth)); + + assertSame(capturedNorth, ordered.getFirst()); + assertSame(capturedEast, ordered.getLast()); + assertEquals("changed/pool", ordered.getFirst().getPool()); + assertEquals(9, ordered.getFirst().getSelectionPriority()); + } + + @Test + public void removedConnectorsDisappearWithoutReorderingSurvivors() throws IOException { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + IrisJigsawPiece source = pieceWithPlanarConnectors(dimensions, List.of( + IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.EAST_POSITIVE_X, + IrisDirection.WEST_NEGATIVE_X)); + IrisJigsawConnector capturedWest = planarConnector( + dimensions, + IrisDirection.WEST_NEGATIVE_X); + IrisJigsawConnector capturedNorth = planarConnector( + dimensions, + IrisDirection.NORTH_NEGATIVE_Z); + + List ordered = JigsawStudioService.preserveCapturedConnectorOrder( + source, + List.of(capturedWest, capturedNorth)); + + assertEquals(List.of( + IrisDirection.NORTH_NEGATIVE_Z, + IrisDirection.WEST_NEGATIVE_X), connectorDirections(ordered)); + } + + @Test + public void newAndMovedConnectorsAppendInDeterministicSourcePositionOrder() throws IOException { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + IrisJigsawPiece source = pieceWithPlanarConnectors( + dimensions, + List.of(IrisDirection.NORTH_NEGATIVE_Z)); + IrisJigsawConnector capturedNorth = planarConnector( + dimensions, + IrisDirection.NORTH_NEGATIVE_Z); + IrisJigsawConnector highY = connectorAt(2, 5, 9); + IrisJigsawConnector highZ = connectorAt(2, 3, 10); + IrisJigsawConnector lowZ = connectorAt(2, 3, 4); + + List ordered = JigsawStudioService.preserveCapturedConnectorOrder( + source, + List.of(highY, capturedNorth, highZ, lowZ)); + + assertSame(capturedNorth, ordered.get(0)); + assertSame(lowZ, ordered.get(1)); + assertSame(highZ, ordered.get(2)); + assertSame(highY, ordered.get(3)); + } + + @Test + public void duplicateSourceOrCapturedConnectorPositionsAreRejected() { + IrisJigsawConnector first = connectorAt(1, 2, 3); + IrisJigsawConnector second = connectorAt(1, 2, 3) + .setDirection(IrisDirection.SOUTH_POSITIVE_Z); + IrisJigsawPiece duplicateSource = new IrisJigsawPiece().setConnectors(new KList<>()); + duplicateSource.getConnectors().add(first); + duplicateSource.getConnectors().add(second); + IrisJigsawPiece emptySource = new IrisJigsawPiece().setConnectors(new KList<>()); + + assertThrows(IOException.class, () -> JigsawStudioService.preserveCapturedConnectorOrder( + duplicateSource, + List.of(first))); + assertThrows(IOException.class, () -> JigsawStudioService.preserveCapturedConnectorOrder( + emptySource, + List.of(first, second))); + } + + @Test + public void rotatedCapturesMatchAuthoredOrderByInverseSourcePosition() throws IOException { + JigsawStudioBounds bounds = new JigsawStudioBounds( + 0, + 64, + 0, + new JigsawStudioCellDimensions(5, 1, 3)); + List areas = JigsawStudioService.chunkIntersections(bounds); + IrisJigsawConnector sourceFirst = connectorAt(0, 0, 1) + .setDirection(IrisDirection.NORTH_NEGATIVE_Z); + IrisJigsawConnector sourceSecond = connectorAt(2, 0, 2) + .setDirection(IrisDirection.EAST_POSITIVE_X); + IrisJigsawPiece source = new IrisJigsawPiece().setConnectors(new KList<>()); + source.getConnectors().add(sourceFirst); + source.getConnectors().add(sourceSecond); + PlatformBlockState sourceState = BukkitBlockState.of( + directionalBlockData(Material.OBSERVER, BlockFace.NORTH)); + + for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) { + IrisObjectRotation displayRotation = IrisObjectRotation.of(0, -90.0D * quarterTurns, 0); + PlatformBlockState displayedState = displayRotation.rotate(sourceState, 0, 0, 0); + JigsawStudioService.CapturedConnector displayedFirst = displayedConnector( + sourceFirst, + bounds.dimensions(), + quarterTurns, + displayRotation, + displayedState.key()); + JigsawStudioService.CapturedConnector displayedSecond = displayedConnector( + sourceSecond, + bounds.dimensions(), + quarterTurns, + displayRotation, + displayedState.key()); + JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot( + areas.getFirst(), + List.of(), + List.of(displayedSecond, displayedFirst)); + + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull(displayedState.key(), false)).thenReturn(displayedState); + JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots( + bounds, + areas, + List.of(snapshot), + quarterTurns); + List ordered = JigsawStudioService.preserveCapturedConnectorOrder( + source, + capture.connectors()); + + assertEquals(sourceFirst.getPosition(), ordered.getFirst().getPosition()); + assertEquals(sourceSecond.getPosition(), ordered.getLast().getPosition()); + } + } + } + + @Test + public void rotatedBlockEntityPayloadReturnsToItsStoredCoordinateUnchanged() throws Throwable { + JigsawStudioBounds bounds = new JigsawStudioBounds( + 0, + 64, + 0, + new JigsawStudioCellDimensions(5, 2, 3)); + List areas = JigsawStudioService.chunkIntersections(bounds); + KMap properties = new KMap<>(); + properties.put("CustomName", "QA Chest"); + properties.put("Lock", "iris:test"); + TileData tileData = new TileData("minecraft:chest", properties); + int[][] expectedPositions = { + {0, 0, 0}, + {1, 1, 3}, + {3, 1, 1}, + {1, 1, 1} + }; + for (int quarterTurns = 1; quarterTurns <= 3; quarterTurns++) { + BlockData displayedData = directionalBlockData(Material.CHEST, BlockFace.EAST); + PlatformBlockState displayedState = BukkitBlockState.of(displayedData); + JigsawStudioService.CapturedBlock capturedBlock = new JigsawStudioService.CapturedBlock( + 1, + 1, + 1, + displayedState, + tileData); + JigsawStudioService.ChunkSnapshot snapshot = new JigsawStudioService.ChunkSnapshot( + areas.getFirst(), + List.of(capturedBlock), + List.of()); + + JigsawStudioService.Capture capture = JigsawStudioService.aggregateSnapshots( + bounds, + areas, + List.of(snapshot), + quarterTurns); + IrisObject restored = readCapturedObject(capture.objectContent(), displayedState); + int[] expected = expectedPositions[quarterTurns]; + TileData restoredTile = restored.getStates().get(restored.getSigned( + expected[0], expected[1], expected[2])); + + assertTrue(capture.hasBlockEntities()); + assertNotNull(restoredTile); + assertEquals(tileData, restoredTile); + } + } + + @Test + public void rotatedTilePayloadDoesNotBlockMaterialization() { + KMap properties = new KMap<>(); + properties.put("CustomName", "QA Chest"); + TileData tileData = new TileData("minecraft:chest", properties); + PlatformBlockState state = BukkitBlockState.of( + directionalBlockData(Material.CHEST, BlockFace.NORTH)); + JigsawStudioGenerator.RenderedBay rendered = JigsawStudioGenerator.RenderedBay.valid( + new JigsawStudioCellDimensions(3, 2, 5), + List.of(new JigsawStudioGenerator.RenderedBlock(0, 0, 0, state, tileData)), + List.of()); + + assertEquals("", JigsawStudioService.validateMaterialization(rendered)); + } + + @Test + public void explicitAirFinalStateRemainsInTheCapturedObject() throws Throwable { + IrisObject object = new IrisObject(2, 2, 2); + BlockData air = blockData(Material.AIR, "minecraft:air"); + BlockData structureVoid = blockData(Material.STRUCTURE_VOID, "minecraft:structure_void"); + + JigsawStudioService.storeConnectorFinalState(object, 0, 0, 0, air); + JigsawStudioService.storeConnectorFinalState(object, 1, 0, 0, structureVoid); + + IrisBlockVector airPosition = object.getSigned(0, 0, 0); + IrisBlockVector structureVoidPosition = object.getSigned(1, 0, 0); + assertTrue(object.getBlocks().containsKey(airPosition)); + assertEquals("minecraft:air", object.getBlocks().get(airPosition).key()); + assertFalse(object.getBlocks().containsKey(structureVoidPosition)); + + assertAirRoundTrip(object); + } + + @Test + public void originalExplicitAirOutsideAConnectorSurvivesWhileNewAirRemainsAbsent() throws Throwable { + IrisObject source = new IrisObject(2, 2, 2); + BlockData air = blockData(Material.AIR, "minecraft:air"); + BlockData structureVoid = blockData(Material.STRUCTURE_VOID, "minecraft:structure_void"); + PlatformBlockState sourceAir = mock(PlatformBlockState.class); + when(sourceAir.isAir()).thenReturn(true); + when(sourceAir.key()).thenReturn("minecraft:air"); + source.setUnsigned(0, 0, 0, sourceAir); + + PlatformBlockState retained = JigsawStudioService.retainedSourceAir(source, 0, 0, 0, air); + assertNotNull(retained); + assertNull(JigsawStudioService.retainedSourceAir(source, 1, 0, 0, air)); + assertNull(JigsawStudioService.retainedSourceAir(source, 0, 0, 0, structureVoid)); + + IrisObject captured = new IrisObject(2, 2, 2); + captured.setUnsigned(0, 0, 0, retained); + assertAirRoundTrip(captured); + } + + private static void assertAirRoundTrip(IrisObject object) throws Throwable { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + object.write(output); + PlatformBlockState restoredAir = mock(PlatformBlockState.class); + when(restoredAir.key()).thenReturn("minecraft:air"); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(restoredAir); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + IrisObject restored = new IrisObject(); + restored.read(new ByteArrayInputStream(output.toByteArray())); + + assertTrue(restored.getBlocks().containsKey(restored.getSigned(0, 0, 0))); + assertEquals("minecraft:air", restored.getBlocks().get(restored.getSigned(0, 0, 0)).key()); + assertFalse(restored.getBlocks().containsKey(restored.getSigned(1, 0, 0))); + } finally { + IrisPlatforms.unbind(); + } + } + + @Test + public void markerHydrationUsesValidIdentifiersAndCaptureRestoresIrisKeysAndChannels() { + IrisJigsawConnector source = connector() + .setPool("fort/start") + .setName("door") + .setTargetName("door") + .setChannel("castle/door") + .setSelectionPriority(-4) + .setPlacementPriority(17); + IrisJigsawPiece piece = new IrisJigsawPiece().setConnectors(new KList<>()); + piece.getConnectors().add(source); + + KMap nbt = JigsawStudioService.markerNbt(source); + assertEquals("iris:fort/start", nbt.get("pool")); + assertEquals("iris:door", nbt.get("name")); + assertEquals(-4, nbt.get("selection_priority")); + assertEquals(17, nbt.get("placement_priority")); + assertFalse(nbt.containsKey("channel")); + + IrisJigsawConnector captured = connector() + .setPool("fort/start") + .setName("iris:door") + .setTargetName("iris:door"); + JigsawStudioService.restoreCapturedMetadata(captured, piece); + + assertEquals("fort/start", captured.getPool()); + assertEquals("door", captured.getName()); + assertEquals("door", captured.getTargetName()); + assertEquals("castle/door", captured.getChannel()); + } + + @Test + public void authoredBayReadinessRequiresEveryChunkToPopulateAndHydrate() { + JigsawStudioService.BayPopulation population = new JigsawStudioService.BayPopulation( + Set.of(11L, 12L), ""); + + assertFalse(population.readiness().ready()); + assertTrue(population.markGenerated(11L)); + assertTrue(population.needsApplication(11L)); + population.markApplied(11L); + assertTrue(population.needsVerification(11L)); + population.markHydrated(11L); + assertFalse(population.readiness().ready()); + + assertTrue(population.markGenerated(12L)); + population.markApplied(12L); + population.markHydrated(12L); + + JigsawStudioService.BayReadiness readiness = population.readiness(); + assertTrue(readiness.ready()); + assertEquals(2, readiness.requiredChunks()); + assertEquals(2, readiness.generatedChunks()); + assertEquals(2, readiness.hydratedChunks()); + } + + @Test + public void chunkLoadRecoversAGenerationSignalMissedDuringStudioRegistration() + throws ReflectiveOperationException { + UUID worldId = UUID.fromString("55555555-5555-5555-5555-555555555555"); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + JigsawStudioLayout layout = JigsawStudioLayout.create( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + new JigsawStudioVariantCatalog(List.of(planarVariant(true, true)))); + JigsawStudioBay blank = layout.get("workcell/blank"); + long chunkKey = ((long) 1 << 32) ^ 1L; + JigsawStudioService.BayPopulation population = + new JigsawStudioService.BayPopulation(Set.of(chunkKey), ""); + ConcurrentHashMap populations = new ConcurrentHashMap<>(); + populations.put(blank.stableId(), population); + + World world = mock(World.class); + when(world.getUID()).thenReturn(worldId); + JigsawStudioGenerator generator = mock(JigsawStudioGenerator.class); + when(generator.getLayout()).thenReturn(layout); + when(generator.renderBay(any(JigsawStudioBay.class))) + .thenReturn(JigsawStudioGenerator.RenderedBay.empty(dimensions)); + Class studioType = Class.forName(JigsawStudioService.class.getName() + "$ActiveStudio"); + Constructor studioConstructor = studioType.getDeclaredConstructor( + UUID.class, + World.class, + Engine.class, + JigsawStudioGenerator.class, + ConcurrentHashMap.class, + Set.class, + AtomicLong.class); + studioConstructor.setAccessible(true); + Object studio = studioConstructor.newInstance( + worldId, + world, + mock(Engine.class), + generator, + populations, + ConcurrentHashMap.newKeySet(), + new AtomicLong()); + JigsawStudioService service = new JigsawStudioService(); + Field studiosField = JigsawStudioService.class.getDeclaredField("studios"); + studiosField.setAccessible(true); + @SuppressWarnings("unchecked") + Map studios = (Map) studiosField.get(service); + studios.put(worldId, studio); + + Chunk chunk = mock(Chunk.class); + when(chunk.getX()).thenReturn(1); + when(chunk.getZ()).thenReturn(1); + ChunkLoadEvent event = mock(ChunkLoadEvent.class); + when(event.getWorld()).thenReturn(world); + when(event.getChunk()).thenReturn(chunk); + + service.onChunkLoad(event); + + assertTrue(population.needsApplication(chunkKey)); + assertEquals(1, population.readiness().generatedChunks()); + assertEquals(0, population.readiness().hydratedChunks()); + } + + @Test + public void hydrationFailurePermanentlyBlocksSaveReadiness() { + JigsawStudioService.BayPopulation population = new JigsawStudioService.BayPopulation( + Set.of(27L), ""); + population.markGenerated(27L); + population.markApplied(27L); + population.markHydrated(27L); + + assertTrue(population.readiness().ready()); + assertTrue(population.fail("tile NBT was not preserved")); + assertFalse(population.readiness().ready()); + assertEquals("tile NBT was not preserved", population.readiness().failure()); + assertFalse(population.fail("replacement failure")); + } + + @Test + public void chunkIntersectionsCoverNegativeAndPositiveCoordinatesExactlyOnce() { + JigsawStudioBounds bounds = new JigsawStudioBounds( + -5, + 64, + 9, + new JigsawStudioCellDimensions(25, 3, 20)); + + List areas = JigsawStudioService.chunkIntersections(bounds); + + assertEquals(List.of( + new JigsawStudioService.ChunkCaptureArea(-1, 0, 0, 5, 0, 7), + new JigsawStudioService.ChunkCaptureArea(-1, 1, 0, 5, 7, 20), + new JigsawStudioService.ChunkCaptureArea(0, 0, 5, 21, 0, 7), + new JigsawStudioService.ChunkCaptureArea(0, 1, 5, 21, 7, 20), + new JigsawStudioService.ChunkCaptureArea(1, 0, 21, 25, 0, 7), + new JigsawStudioService.ChunkCaptureArea(1, 1, 21, 25, 7, 20) + ), areas); + for (int x = 0; x < bounds.dimensions().width(); x++) { + for (int z = 0; z < bounds.dimensions().depth(); z++) { + int matches = 0; + for (JigsawStudioService.ChunkCaptureArea area : areas) { + if (area.contains(x, z)) { + matches++; + } + } + assertEquals(1, matches); + } + } + } + + @Test + public void chunkSnapshotsAggregateDeterministicallyAndRequireEveryIntersection() throws IOException { + JigsawStudioBounds bounds = new JigsawStudioBounds( + 8, + 64, + 0, + new JigsawStudioCellDimensions(20, 2, 1)); + List areas = JigsawStudioService.chunkIntersections(bounds); + PlatformBlockState stone = mock(PlatformBlockState.class); + when(stone.key()).thenReturn("minecraft:stone"); + JigsawStudioService.ChunkSnapshot first = new JigsawStudioService.ChunkSnapshot( + areas.getFirst(), + List.of(new JigsawStudioService.CapturedBlock(0, 0, 0, stone, null)), + List.of()); + JigsawStudioService.ChunkSnapshot second = new JigsawStudioService.ChunkSnapshot( + areas.getLast(), + List.of(new JigsawStudioService.CapturedBlock(8, 1, 0, stone, null)), + List.of()); + + JigsawStudioService.Capture forward = JigsawStudioService.aggregateSnapshots( + bounds, areas, List.of(first, second)); + JigsawStudioService.Capture reverse = JigsawStudioService.aggregateSnapshots( + bounds, areas, List.of(second, first)); + + assertArrayEquals(forward.objectContent(), reverse.objectContent()); + assertFalse(forward.hasBlockEntities()); + assertTrue(forward.connectors().isEmpty()); + assertThrows(IOException.class, + () -> JigsawStudioService.aggregateSnapshots(bounds, areas, List.of(first))); + } + + @Test + public void saveRegistrationIsRequestScoped() { + JigsawStudioService service = new JigsawStudioService(); + UUID firstRequest = UUID.fromString("44444444-4444-4444-4444-444444444444"); + UUID secondRequest = UUID.fromString("55555555-5555-5555-5555-555555555555"); + + assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(firstRequest)); + assertEquals(JigsawStudioService.SaveStart.IN_PROGRESS, service.tryBeginSave(firstRequest)); + assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(secondRequest)); + + service.finishSave(firstRequest); + assertEquals(JigsawStudioService.SaveStart.STARTED, service.tryBeginSave(firstRequest)); + service.finishSave(firstRequest); + service.finishSave(secondRequest); + } + + private static IrisJigsawConnector connector() { + return new IrisJigsawConnector() + .setPosition(new IrisPosition(0, 0, 0)) + .setDirection(IrisDirection.NORTH_NEGATIVE_Z) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("fort/start") + .setName("iris:door") + .setTargetName("iris:door") + .setJoint(JigsawJoint.ALIGNED) + .setFinalState("minecraft:air"); + } + + private static IrisJigsawConnector connectorAt(int x, int y, int z) { + return connector().setPosition(new IrisPosition(x, y, z)); + } + + private static IrisJigsawConnector planarConnector( + JigsawStudioCellDimensions dimensions, + IrisDirection direction + ) { + IrisPosition size = new IrisPosition( + dimensions.width(), + dimensions.height(), + dimensions.depth()); + return connector() + .setPosition(IrisJigsawConnector.canonicalPlanarPosition(size, direction)) + .setDirection(direction); + } + + private static List planarConnectors( + JigsawStudioCellDimensions dimensions, + List directions + ) { + List connectors = new ArrayList<>(directions.size()); + for (IrisDirection direction : directions) { + connectors.add(planarConnector(dimensions, direction)); + } + return connectors; + } + + private static IrisJigsawPiece pieceWithPlanarConnectors( + JigsawStudioCellDimensions dimensions, + List directions + ) { + IrisJigsawPiece piece = new IrisJigsawPiece().setConnectors(new KList<>()); + piece.getConnectors().addAll(planarConnectors(dimensions, directions)); + return piece; + } + + private static List connectorDirections(List connectors) { + List directions = new ArrayList<>(connectors.size()); + for (IrisJigsawConnector connector : connectors) { + directions.add(connector.getDirection()); + } + return directions; + } + + private static JigsawStudioService.CapturedConnector displayedConnector( + IrisJigsawConnector source, + JigsawStudioCellDimensions displayDimensions, + int displayRotationQuarterTurns, + IrisObjectRotation displayRotation, + String finalState + ) { + IrisPosition displayedPosition = displayedPosition( + source.getPosition(), + displayDimensions, + displayRotationQuarterTurns); + return new JigsawStudioService.CapturedConnector( + displayedPosition.getX(), + displayedPosition.getY(), + displayedPosition.getZ(), + displayRotation.rotate(source.getDirection()), + displayRotation.rotate(source.getTop()), + source.getPool(), + source.getName(), + source.getTargetName(), + source.getChannel(), + source.getJoint(), + finalState, + source.getSelectionPriority(), + source.getPlacementPriority()); + } + + private static IrisPosition displayedPosition( + IrisPosition source, + JigsawStudioCellDimensions displayDimensions, + int displayRotationQuarterTurns + ) { + int quarterTurns = Math.floorMod(displayRotationQuarterTurns, 4); + int sourceWidth = (quarterTurns & 1) == 0 + ? displayDimensions.width() + : displayDimensions.depth(); + int sourceDepth = (quarterTurns & 1) == 0 + ? displayDimensions.depth() + : displayDimensions.width(); + return switch (quarterTurns) { + case 0 -> source; + case 1 -> new IrisPosition(sourceDepth - 1 - source.getZ(), source.getY(), source.getX()); + case 2 -> new IrisPosition( + sourceWidth - 1 - source.getX(), + source.getY(), + sourceDepth - 1 - source.getZ()); + case 3 -> new IrisPosition(source.getZ(), source.getY(), sourceWidth - 1 - source.getX()); + default -> throw new IllegalStateException("Unreachable Jigsaw Studio test rotation"); + }; + } + + private static JigsawStudioVariant planarVariant(boolean rotatable, boolean owned) { + return planarVariant( + "test/end", + JigsawPlanarTopology.NORTH_END, + rotatable, + owned); + } + + private static JigsawStudioVariant planarVariant( + String pieceKey, + JigsawPlanarTopology topology, + boolean rotatable, + boolean owned + ) { + return new JigsawStudioVariant( + pieceKey, + pieceKey, + "", + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + JigsawStudioMode.PLANAR_JIGSAW, + Optional.of(topology), + rotatable, + owned, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + } + + private static Directional directionalBlockData(BlockFace initialFacing) { + return directionalBlockData(Material.OBSERVER, initialFacing); + } + + private static Directional directionalBlockData(Material material, BlockFace initialFacing) { + AtomicReference facing = new AtomicReference<>(initialFacing); + Directional data = mock(Directional.class); + when(data.getFacing()).thenAnswer(invocation -> facing.get()); + when(data.getFaces()).thenReturn(Set.of( + BlockFace.NORTH, + BlockFace.EAST, + BlockFace.SOUTH, + BlockFace.WEST)); + doAnswer(invocation -> { + facing.set(invocation.getArgument(0, BlockFace.class)); + return null; + }).when(data).setFacing(any(BlockFace.class)); + when(data.getMaterial()).thenReturn(material); + when(data.getAsString()).thenAnswer(invocation -> + "minecraft:" + material.name().toLowerCase(Locale.ROOT) + "[facing=" + + facing.get().name().toLowerCase(Locale.ROOT) + "]"); + when(data.clone()).thenAnswer(invocation -> directionalBlockData(material, facing.get())); + return data; + } + + private static IrisObject readCapturedObject( + byte[] content, + PlatformBlockState restoredState + ) throws Throwable { + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(restoredState); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + IrisObject restored = new IrisObject(); + restored.read(new ByteArrayInputStream(content)); + return restored; + } finally { + IrisPlatforms.unbind(); + } + } + + private static void assertMutationHandler(String methodName, Class eventType) throws NoSuchMethodException { + assertHandler(methodName, eventType, EventPriority.MONITOR, true); + } + + private static void assertHandler( + String methodName, + Class eventType, + EventPriority priority, + boolean ignoreCancelled + ) throws NoSuchMethodException { + Method method = JigsawStudioService.class.getMethod(methodName, eventType); + EventHandler annotation = method.getAnnotation(EventHandler.class); + assertNotNull(annotation); + assertEquals(priority, annotation.priority()); + assertEquals(ignoreCancelled, annotation.ignoreCancelled()); + } + + private static BlockData blockData(Material material, String state) { + BlockData blockData = mock(BlockData.class); + when(blockData.getMaterial()).thenReturn(material); + when(blockData.getAsString()).thenReturn(state); + return blockData; + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/JigsawStudioToolCodecTest.java b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioToolCodecTest.java new file mode 100644 index 000000000..329dce332 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/JigsawStudioToolCodecTest.java @@ -0,0 +1,196 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolAction; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioToolPayload; + +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.junit.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class JigsawStudioToolCodecTest { + private static final UUID REQUEST = UUID.fromString("77777777-7777-7777-7777-777777777777"); + + @Test + public void bindsACompleteNamedStickPayload() { + JigsawStudioToolCodec codec = new JigsawStudioToolCodec(); + ItemStack tool = mock(ItemStack.class); + ItemMeta meta = mock(ItemMeta.class); + PersistentDataContainer data = mock(PersistentDataContainer.class); + JigsawStudioToolPayload payload = JigsawStudioToolPayload.membership( + JigsawStudioToolAction.ADJUST_VARIANT_WEIGHT, + REQUEST, + "workcell/corner", + "village/corner_mossy", + "village/start", + 3, + -1); + when(tool.getType()).thenReturn(Material.STICK); + when(tool.getItemMeta()).thenReturn(meta); + when(tool.setItemMeta(meta)).thenReturn(true); + when(meta.getPersistentDataContainer()).thenReturn(data); + + codec.bind(tool, payload); + + verify(data).set( + JigsawStudioToolCodec.SCHEMA_KEY, + PersistentDataType.INTEGER, + JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION); + verify(data).set( + JigsawStudioToolCodec.ACTION_KEY, + PersistentDataType.STRING, + JigsawStudioToolAction.ADJUST_VARIANT_WEIGHT.name()); + verify(data).set( + JigsawStudioToolCodec.REQUEST_KEY, + PersistentDataType.STRING, + REQUEST.toString()); + verify(data).set( + JigsawStudioToolCodec.WORKCELL_KEY, + PersistentDataType.STRING, + "workcell/corner"); + verify(data).set( + JigsawStudioToolCodec.PIECE_KEY, + PersistentDataType.STRING, + "village/corner_mossy"); + verify(data).set( + JigsawStudioToolCodec.POOL_KEY, + PersistentDataType.STRING, + "village/start"); + verify(data).set(JigsawStudioToolCodec.ENTRY_INDEX_KEY, PersistentDataType.INTEGER, 3); + verify(data).set(JigsawStudioToolCodec.AMOUNT_KEY, PersistentDataType.INTEGER, -1); + verify(meta).setDisplayName(ChatColor.AQUA + "Jigsaw Studio: Adjust Variant Weight"); + verify(meta).setLore(argThat(lore -> lore.contains(ChatColor.GRAY + "Amount: -1") + && lore.contains(ChatColor.YELLOW + "Right-click to use"))); + verify(tool).setItemMeta(meta); + } + + @Test + public void decodesCurrentSchemaAndDefaultsOptionalFields() { + JigsawStudioToolCodec codec = new JigsawStudioToolCodec(); + PersistentDataContainer data = mock(PersistentDataContainer.class); + when(data.get(JigsawStudioToolCodec.SCHEMA_KEY, PersistentDataType.INTEGER)) + .thenReturn(JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION); + when(data.get(JigsawStudioToolCodec.ACTION_KEY, PersistentDataType.STRING)) + .thenReturn(JigsawStudioToolAction.PREVIEW_GRAPH.name()); + when(data.get(JigsawStudioToolCodec.REQUEST_KEY, PersistentDataType.STRING)) + .thenReturn(REQUEST.toString()); + + Optional decoded = codec.decode(data); + + assertTrue(decoded.isPresent()); + assertEquals( + JigsawStudioToolPayload.request(JigsawStudioToolAction.PREVIEW_GRAPH, REQUEST), + decoded.orElseThrow()); + } + + @Test + public void schemaTwoInvalidatesLegacyBoundTools() { + assertEquals(2, JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION); + JigsawStudioToolCodec codec = new JigsawStudioToolCodec(); + PersistentDataContainer legacy = mock(PersistentDataContainer.class); + when(legacy.get(JigsawStudioToolCodec.SCHEMA_KEY, PersistentDataType.INTEGER)).thenReturn(1); + when(legacy.get(JigsawStudioToolCodec.ACTION_KEY, PersistentDataType.STRING)) + .thenReturn(JigsawStudioToolAction.DUPLICATE_VARIANT.name()); + when(legacy.get(JigsawStudioToolCodec.REQUEST_KEY, PersistentDataType.STRING)) + .thenReturn(REQUEST.toString()); + + assertTrue(codec.decode(legacy).isEmpty()); + } + + @Test + public void rejectsMalformedOrUnsupportedPersistentBindings() { + JigsawStudioToolCodec codec = new JigsawStudioToolCodec(); + PersistentDataContainer unsupported = mock(PersistentDataContainer.class); + when(unsupported.get(JigsawStudioToolCodec.SCHEMA_KEY, PersistentDataType.INTEGER)).thenReturn(99); + when(unsupported.get(JigsawStudioToolCodec.ACTION_KEY, PersistentDataType.STRING)) + .thenReturn(JigsawStudioToolAction.SELECT_WORKCELL.name()); + when(unsupported.get(JigsawStudioToolCodec.REQUEST_KEY, PersistentDataType.STRING)) + .thenReturn(REQUEST.toString()); + assertTrue(codec.decode(unsupported).isEmpty()); + + PersistentDataContainer malformed = mock(PersistentDataContainer.class); + when(malformed.get(JigsawStudioToolCodec.SCHEMA_KEY, PersistentDataType.INTEGER)) + .thenReturn(JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION); + when(malformed.get(JigsawStudioToolCodec.ACTION_KEY, PersistentDataType.STRING)) + .thenReturn("NOT_AN_ACTION"); + when(malformed.get(JigsawStudioToolCodec.REQUEST_KEY, PersistentDataType.STRING)) + .thenReturn("not-a-uuid"); + assertTrue(codec.decode(malformed).isEmpty()); + } + + @Test + public void recognizesOnlyValidStickBindings() { + JigsawStudioToolCodec codec = new JigsawStudioToolCodec(); + ItemStack stone = mock(ItemStack.class); + when(stone.getType()).thenReturn(Material.STONE); + + assertFalse(codec.isTool(stone)); + assertThrows( + IllegalArgumentException.class, + () -> codec.bind( + stone, + JigsawStudioToolPayload.request(JigsawStudioToolAction.PREVIEW_GRAPH, REQUEST))); + } + + @Test + public void payloadValidatesSchemaIndexesAndFieldBounds() { + assertThrows( + IllegalArgumentException.class, + () -> new JigsawStudioToolPayload( + 0, + JigsawStudioToolAction.SELECT_WORKCELL, + REQUEST, + "", + "", + "", + JigsawStudioToolPayload.NO_ENTRY_INDEX, + 0)); + assertThrows( + IllegalArgumentException.class, + () -> new JigsawStudioToolPayload( + JigsawStudioToolPayload.CURRENT_SCHEMA_VERSION, + JigsawStudioToolAction.SELECT_WORKCELL, + REQUEST, + "", + "", + "", + -2, + 0)); + assertThrows( + IllegalArgumentException.class, + () -> JigsawStudioToolPayload.workcell( + JigsawStudioToolAction.SELECT_WORKCELL, + REQUEST, + "x".repeat(513))); + } + + @Test + public void marksOnlyDestructiveActionsForConfirmation() { + assertFalse(JigsawStudioToolAction.OPEN_MENU.destructive()); + assertFalse(JigsawStudioToolAction.TOGGLE_WORKCELL.destructive()); + assertFalse(JigsawStudioToolAction.RESIZE_WORKCELL.destructive()); + assertFalse(JigsawStudioToolAction.ADJUST_VARIANT_CHANCE.destructive()); + assertFalse(JigsawStudioToolAction.SET_THEME.destructive()); + assertFalse(JigsawStudioToolAction.SET_PIECE_RULES.destructive()); + assertFalse(JigsawStudioToolAction.TOGGLE_REQUIRE_CAPS.destructive()); + assertFalse(JigsawStudioToolAction.ADJUST_VARIANT_WEIGHT.destructive()); + assertTrue(JigsawStudioToolAction.UNLINK_MEMBERSHIP.destructive()); + assertTrue(JigsawStudioToolAction.DELETE_VARIANT.destructive()); + assertTrue(JigsawStudioToolAction.DELETE_PROJECT.destructive()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/StudioSVCJigsawProtectionTest.java b/core/src/test/java/art/arcane/iris/core/service/StudioSVCJigsawProtectionTest.java new file mode 100644 index 000000000..8dcaebf81 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/StudioSVCJigsawProtectionTest.java @@ -0,0 +1,124 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.runtime.StudioOpenCoordinator; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.util.common.plugin.VolmitPlugin; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class StudioSVCJigsawProtectionTest { + private static final UUID OWNER = UUID.fromString("33333333-3333-3333-3333-333333333333"); + + private JigsawStudioService jigsawService; + private MockedStatic bukkitPlatform; + + @Before + public void enableJigsawService() { + VolmitPlugin plugin = mock(VolmitPlugin.class); + bukkitPlatform = mockStatic(BukkitPlatform.class); + bukkitPlatform.when(BukkitPlatform::volmitPlugin).thenReturn(plugin); + jigsawService = new JigsawStudioService(); + jigsawService.onEnable(); + } + + @After + public void clearLifecycle() { + jigsawService.onDisable(); + JigsawStudioActivation.finishOpen(OWNER); + JigsawStudioActivation.deactivate("overworld"); + bukkitPlatform.close(); + } + + @Test + public void directStudioCloseRequiresJigsawCloseAuthorization() throws ReflectiveOperationException { + JigsawStudioActivation.Request request = activateOwnedStudio(); + IrisProject project = mock(IrisProject.class); + when(project.getName()).thenReturn("overworld"); + StudioSVC studio = new StudioSVC(); + setActiveProject(studio, project); + + CompletionException blocked = assertThrows(CompletionException.class, () -> studio.close().join()); + + assertTrue(blocked.getCause().getMessage().contains("owner-controlled")); + verify(project, never()).close(); + + assertEquals( + JigsawStudioService.CloseStart.STARTED, + jigsawService.tryBeginClose(request.requestId(), OWNER, false)); + StudioOpenCoordinator.StudioCloseResult closeResult = successfulClose(); + when(project.close()).thenReturn(CompletableFuture.completedFuture(closeResult)); + + assertEquals(closeResult, studio.close().join()); + assertNull(studio.getActiveProject()); + verify(project).close(); + } + + @Test + public void ordinaryStudioCloseBehaviorIsUnchanged() throws ReflectiveOperationException { + IrisProject project = mock(IrisProject.class); + when(project.getName()).thenReturn("ordinary"); + StudioOpenCoordinator.StudioCloseResult closeResult = successfulClose(); + when(project.close()).thenReturn(CompletableFuture.completedFuture(closeResult)); + StudioSVC studio = new StudioSVC(); + setActiveProject(studio, project); + + assertEquals(closeResult, studio.close().join()); + assertNull(studio.getActiveProject()); + verify(project).close(); + } + + private static JigsawStudioActivation.Request activateOwnedStudio() { + assertTrue(JigsawStudioActivation.tryBeginOpen(OWNER)); + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(16, 16, 16); + JigsawStudioActivation.Request request = JigsawStudioActivation.activate( + "overworld", + "stronghold", + JigsawStudioMode.SPATIAL_JIGSAW, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + dimensions, + mock(IrisData.class), + JigsawStudioLayout.create( + JigsawStudioMode.SPATIAL_JIGSAW, + dimensions, + JigsawStudioVariantCatalog.empty()), + OWNER); + JigsawStudioActivation.finishOpen(OWNER); + return request; + } + + private static StudioOpenCoordinator.StudioCloseResult successfulClose() { + return new StudioOpenCoordinator.StudioCloseResult(null, true, true, false, null); + } + + private static void setActiveProject(StudioSVC studio, IrisProject project) + throws ReflectiveOperationException { + Field field = StudioSVC.class.getDeclaredField("activeProject"); + field.setAccessible(true); + field.set(studio, project); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/BulkStructureImporterTemplateNameTest.java b/core/src/test/java/art/arcane/iris/core/structure/BulkStructureImporterTemplateNameTest.java index ef0caccff..a6fb0f17c 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/BulkStructureImporterTemplateNameTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/BulkStructureImporterTemplateNameTest.java @@ -108,6 +108,22 @@ public class BulkStructureImporterTemplateNameTest { assertEquals(3, report.imported()); assertEquals(1, report.skipped()); assertEquals(1, report.failed()); + assertFalse(report.retryRequired()); + } + + @Test + public void datapackReportRetainsRetryClassification() { + BulkStructureImporter.Report report = BulkStructureImporter.datapackReport( + 1, + 0, + 0, + 0, + 1, + Map.of(), + true + ); + + assertTrue(report.retryRequired()); } @Test @@ -118,6 +134,7 @@ public class BulkStructureImporterTemplateNameTest { assertEquals(0, report.imported()); assertEquals(0, report.skipped()); assertEquals(1, report.failed()); + assertTrue(report.retryRequired()); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/structure/StructureCaptureImporterTargetSelectionTest.java b/core/src/test/java/art/arcane/iris/core/structure/StructureCaptureImporterTargetSelectionTest.java new file mode 100644 index 000000000..fcbc6e90a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/StructureCaptureImporterTargetSelectionTest.java @@ -0,0 +1,73 @@ +package art.arcane.iris.core.structure; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class StructureCaptureImporterTargetSelectionTest { + @Test + public void onlyNonJigsawTemplateMissesBecomeCaptureCandidates() { + assertTrue(BulkStructureImporter.isCaptureCandidate( + "minecraft:igloo is not a jigsaw structure", + "No loadable structure NBT for key minecraft:igloo")); + assertFalse(BulkStructureImporter.isCaptureCandidate( + "Missing template pool towns_and_towers:broken/start", + "No loadable structure NBT for key towns_and_towers:broken")); + assertFalse(BulkStructureImporter.isCaptureCandidate( + "minecraft:desert_pyramid is not a jigsaw structure", + "Failed to load structure minecraft:desert_pyramid")); + } + + @Test + public void bulkSelectionUsesOnlyLiveTemplateMissCandidates() { + HashSet candidates = new HashSet<>(List.of( + "minecraft:swamp_hut", + "minecraft:igloo" + )); + BulkStructureImporter.Report report = new BulkStructureImporter.Report( + 4, 1, 2, 1, Map.of(), false, candidates); + + candidates.add("towns_and_towers:broken_jigsaw"); + + assertEquals( + List.of("minecraft:igloo", "minecraft:swamp_hut"), + List.copyOf(report.captureCandidates())); + assertThrows(UnsupportedOperationException.class, + () -> report.captureCandidates().add("minecraft:stronghold")); + assertEquals( + List.of("minecraft:igloo", "minecraft:swamp_hut"), + StructureCaptureImporter.selectCaptureKeys( + List.of( + "minecraft:village_plains", + "Minecraft:Swamp_Hut", + "minecraft:igloo", + "towns_and_towers:broken_jigsaw" + ), + report.captureCandidates())); + } + + @Test + public void standaloneSelectionRemainsUnfiltered() { + assertEquals( + List.of( + "minecraft:igloo", + "minecraft:village_plains", + "towns_and_towers:broken_jigsaw" + ), + StructureCaptureImporter.selectCaptureKeys( + List.of( + "towns_and_towers:broken_jigsaw", + "minecraft:village_plains", + "Minecraft:Igloo", + "minecraft:igloo" + ), + null)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/StructureImporterCaptureTest.java b/core/src/test/java/art/arcane/iris/core/structure/StructureImporterCaptureTest.java index 6f1822061..4299e4a02 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/StructureImporterCaptureTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/StructureImporterCaptureTest.java @@ -60,6 +60,7 @@ public class StructureImporterCaptureTest { assertEquals(4, captured.depth()); assertEquals(1, captured.structureMarkers()); assertEquals(0, captured.blocks()); + assertEquals(0, captured.nonAirBlocks()); assertTrue(captured.capabilities().contains(StructureCapability.BLOCKS)); assertFalse(captured.capabilities().contains(StructureCapability.CONNECTORS)); assertTrue(captured.losses().stream().anyMatch(loss -> @@ -67,4 +68,32 @@ public class StructureImporterCaptureTest { && loss.code().equals("structure_markers_resolved"))); assertFalse(captured.losses().stream().anyMatch(loss -> loss.code().equals("connectors_not_imported"))); } + + @Test + public void captureCountsStoredAirSeparatelyFromPhysicalBlocks() { + Structure structure = mock(Structure.class); + Palette palette = mock(Palette.class); + BlockState air = blockState(Material.AIR, "minecraft:air", 0); + BlockState stone = blockState(Material.STONE, "minecraft:stone", 1); + when(structure.getSize()).thenReturn(new BlockVector(2, 1, 1)); + when(structure.getPalettes()).thenReturn(List.of(palette)); + when(palette.getBlocks()).thenReturn(List.of(air, stone)); + + StructureImporter.CapturedStructure captured = StructureImporter.captureStructure(structure); + + assertEquals(2, captured.blocks()); + assertEquals(1, captured.nonAirBlocks()); + } + + private static BlockState blockState(Material material, String state, int x) { + BlockState block = mock(BlockState.class); + BlockData data = mock(BlockData.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + when(block.getBlockData()).thenReturn(data); + when(location.getBlockX()).thenReturn(x); + when(data.getMaterial()).thenReturn(material); + when(data.getAsString()).thenReturn(state); + return block; + } } diff --git a/core/src/test/java/art/arcane/iris/core/structure/StructureImporterModeTest.java b/core/src/test/java/art/arcane/iris/core/structure/StructureImporterModeTest.java index 95c7e11b3..198b8ce25 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/StructureImporterModeTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/StructureImporterModeTest.java @@ -70,4 +70,36 @@ public class StructureImporterModeTest { NamespacedKey key = NamespacedKey.fromString("nova_structures:temple/large"); assertEquals("nova_structures_temple_large", StructureImporter.deriveName(key)); } + + @Test + public void normalizesLegacySlabHalfProperties() { + assertEquals("minecraft:pale_oak_slab[type=top]", + StructureImporter.normalizeJigsawFinalState("minecraft:pale_oak_slab[half=top]")); + assertEquals("minecraft:pale_oak_slab[waterlogged=false,type=bottom]", + StructureImporter.normalizeJigsawFinalState( + "minecraft:pale_oak_slab[waterlogged=false,half=bottom]")); + } + + @Test + public void normalizesMisspelledPolishedBlackstone() { + assertEquals("minecraft:chiseled_polished_blackstone", + StructureImporter.normalizeJigsawFinalState("minecraft:chisled_polished_blackstone")); + assertEquals("minecraft:chiseled_polished_blackstone[axis=y]", + StructureImporter.normalizeJigsawFinalState( + "minecraft:chisled_polished_blackstone[axis=y]")); + } + + @Test + public void leavesNonSlabAndModernStatesUnchanged() { + assertEquals("minecraft:oak_stairs[half=top]", + StructureImporter.normalizeJigsawFinalState("minecraft:oak_stairs[half=top]")); + assertEquals("minecraft:pale_oak_slab[type=bottom]", + StructureImporter.normalizeJigsawFinalState("minecraft:pale_oak_slab[type=bottom]")); + assertEquals("minecraft:chisled_polished_blackstone_stairs", + StructureImporter.normalizeJigsawFinalState( + "minecraft:chisled_polished_blackstone_stairs")); + assertEquals("example:chisled_polished_blackstone", + StructureImporter.normalizeJigsawFinalState( + "example:chisled_polished_blackstone")); + } } diff --git a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java index e3f4722d0..22f7ab477 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterBundleTest.java @@ -28,6 +28,7 @@ import art.arcane.iris.core.structure.authoring.StructureSource; import art.arcane.iris.core.structure.authoring.StructureTransactionWriter; import art.arcane.iris.core.structure.authoring.StructureWriteMode; import art.arcane.iris.core.structure.authoring.StructureWriteResult; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -121,6 +122,8 @@ public class VillageImporterBundleTest { assertEquals(6, structure.get("maxDepth")); assertEquals(6, structure.get("maxSizeChunks")); + assertEquals(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH.name(), + structure.get("branchFailurePolicy")); } private StructureResourceBundle bundle(String objectContent) { diff --git a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterConnectorRoundTripTest.java b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterConnectorRoundTripTest.java new file mode 100644 index 000000000..012852d2a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterConnectorRoundTripTest.java @@ -0,0 +1,156 @@ +package art.arcane.iris.core.structure; + +import art.arcane.iris.core.structure.export.VanillaJigsawDatapackExporter; +import art.arcane.iris.core.structure.export.VanillaJigsawExportRequest; +import art.arcane.iris.core.structure.export.VanillaJigsawExportResult; +import art.arcane.iris.core.structure.export.VanillaJigsawExportSource; +import art.arcane.iris.engine.framework.structure.StructureGraphResolver; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; +import art.arcane.iris.engine.object.IrisJigsawCompatibility; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.ObjectPlaceMode; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import com.google.gson.Gson; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class VillageImporterConnectorRoundTripTest { + private static final String FINAL_STATE = + "minecraft:oak_stairs[facing=east,half=top,shape=straight,waterlogged=false]"; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void vanillaNbtMetadataSurvivesIrisPieceAndVanillaExport() throws Exception { + FakeJigsawBlockInfo jigsaw = new FakeJigsawBlockInfo(-29, -17); + FakeStructureBlockInfo info = new FakeStructureBlockInfo(new FakeCompoundTag(FINAL_STATE)); + VillageImporter.ConnectorMetadata metadata = VillageImporter.readConnectorMetadata(jigsaw, info); + Map importedConnector = VillageImporter.connectorJson( + 1, + 1, + 0, + "north", + "up", + "minecraft:terminal", + "roundtrip", + "minecraft:source", + "minecraft:door", + "ALIGNED", + metadata + ); + + Map importedPiece = new LinkedHashMap<>(); + importedPiece.put("object", "objects/start"); + importedPiece.put("connectors", List.of(importedConnector)); + importedPiece.put("rotatable", true); + IrisJigsawPiece piece = new Gson().fromJson(new Gson().toJson(importedPiece), IrisJigsawPiece.class); + IrisJigsawConnector connector = piece.getConnectors().getFirst(); + + assertEquals(FINAL_STATE, connector.getFinalState()); + assertEquals(-17, connector.getSelectionPriority()); + assertEquals(-29, connector.getPlacementPriority()); + + TestGraph graph = graph(piece, connector); + Path output = temporaryFolder.getRoot().toPath().resolve("roundtrip-pack"); + VanillaJigsawExportSource source = VanillaJigsawExportSource.forStructure( + "metadata", + graph.structure, + graph + ); + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export( + VanillaJigsawExportRequest.builder(source, output) + .namespace("roundtrip") + .resourcePath("metadata") + .build() + ); + + assertTrue(result.diagnostics().toString(), result.isSuccess()); + CompoundTag template = (CompoundTag) NBTUtil.read(output.resolve( + "data/roundtrip/structure/metadata/piece/pieces/start.nbt").toFile()).getTag(); + CompoundTag block = (CompoundTag) template.getListTag("blocks").get(0); + CompoundTag nbt = block.getCompoundTag("nbt"); + assertEquals(FINAL_STATE, nbt.getString("final_state")); + assertEquals(-17, nbt.getInt("selection_priority")); + assertEquals(-29, nbt.getInt("placement_priority")); + } + + private static TestGraph graph(IrisJigsawPiece piece, IrisJigsawConnector connector) { + TestGraph graph = new TestGraph(); + IrisObject object = new IrisObject(3, 3, 3); + PlatformBlockState finalState = mock(PlatformBlockState.class); + when(finalState.key()).thenReturn(connector.getFinalState()); + object.setUnsigned(1, 1, 0, finalState); + graph.objects.put("objects/start", object); + graph.pieces.put("pieces/start", piece); + graph.pools.put("pools/start", new IrisJigsawPool().setPieces(new KList<>( + new IrisJigsawPieceEntry("pieces/start", 1) + ))); + graph.pools.put("roundtrip/pool/minecraft/terminal", new IrisJigsawPool().setPieces(new KList<>( + new IrisJigsawPieceEntry().setEmpty(true).setWeight(1) + ))); + graph.structure = new IrisStructure() + .setStartPool("pools/start") + .setMaxDepth(4) + .setMaxSizeChunks(4) + .setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH) + .setCompatibility(IrisJigsawCompatibility.VANILLA_PORTABLE) + .setPlaceMode(ObjectPlaceMode.STRUCTURE_PIECE); + graph.structure.setLoadKey("metadata"); + return graph; + } + + private record FakeJigsawBlockInfo(int placementPriority, int selectionPriority) { + } + + private record FakeStructureBlockInfo(FakeCompoundTag nbt) { + } + + private record FakeCompoundTag(String finalState) { + public Optional getString(String key) { + return "final_state".equals(key) ? Optional.of(finalState) : Optional.empty(); + } + } + + private static final class TestGraph implements StructureGraphResolver { + private final Map pools = new LinkedHashMap<>(); + private final Map pieces = new LinkedHashMap<>(); + private final Map objects = new LinkedHashMap<>(); + private IrisStructure structure; + + @Override + public IrisJigsawPool loadPool(String key) { + return pools.get(key); + } + + @Override + public IrisJigsawPiece loadPiece(String key) { + return pieces.get(key); + } + + @Override + public IrisObject loadObject(String key) { + return objects.get(key); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterFailureLogTest.java b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterFailureLogTest.java index 9f443dcff..9b6681414 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterFailureLogTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterFailureLogTest.java @@ -13,7 +13,6 @@ import static org.junit.Assert.assertTrue; * instead of re-printing. */ public class VillageImporterFailureLogTest { - /** One throw site, mirroring a repeated in-loop failure (same class, message, and top frame). */ private static Throwable raise(boolean illegalArgument, String message) { return illegalArgument ? new IllegalArgumentException(message) : new IllegalStateException(message); } @@ -31,7 +30,7 @@ public class VillageImporterFailureLogTest { } @Test - public void distinctFailuresEachPrintOnce() { + public void distinctFailureMessagesAndCategoriesEachPrintOnce() { VillageImporter.resetFailureLogState(); assertTrue(VillageImporter.shouldPrintFullTrace(raise(true, "a"))); diff --git a/core/src/test/java/art/arcane/iris/core/structure/VillageImporterListPoolElementTest.java b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterListPoolElementTest.java new file mode 100644 index 000000000..60d9e3d6c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/VillageImporterListPoolElementTest.java @@ -0,0 +1,421 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.structure; + +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureLoss; +import art.arcane.iris.engine.object.IrisObject; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class VillageImporterListPoolElementTest { + @Test + public void plainTemplateElementRemainsUnchanged() throws Exception { + FakeSinglePoolElement single = new FakeSinglePoolElement("minecraft:village/plains/houses/plains_small_house_1"); + + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(single); + + assertSame(single, resolution.physicalElement()); + assertEquals("minecraft:village/plains/houses/plains_small_house_1", resolution.templateLocation()); + assertEquals(0, resolution.listLevels()); + assertEquals(0, resolution.omittedElements()); + } + + @Test + public void nestedListsRetainOnlyTheRecursivePrimaryTemplate() throws Exception { + FakeSinglePoolElement primary = new FakeSinglePoolElement("minecraft:pillager_outpost/watchtower"); + FakeSinglePoolElement innerOverlay = new FakeSinglePoolElement("test:inner_overlay"); + FakeSinglePoolElement outerOverlay = new FakeSinglePoolElement("test:outer_overlay"); + FakeListPoolElement inner = new FakeListPoolElement(List.of(primary, innerOverlay)); + FakeListPoolElement outer = new FakeListPoolElement(List.of(inner, outerOverlay)); + + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(outer); + + assertSame(primary, resolution.physicalElement()); + assertEquals("minecraft:pillager_outpost/watchtower", resolution.templateLocation()); + assertEquals(2, resolution.listLevels()); + assertEquals(2, resolution.omittedElements()); + } + + @Test + public void unsupportedPrimaryDoesNotFallThroughToLaterChildren() throws Exception { + FakeFeaturePoolElement feature = new FakeFeaturePoolElement(); + FakeSinglePoolElement fallback = new FakeSinglePoolElement("test:must_not_be_selected"); + FakeListPoolElement list = new FakeListPoolElement(List.of(feature, fallback)); + + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(list); + + assertSame(feature, resolution.physicalElement()); + assertNull(resolution.templateLocation()); + assertEquals(1, resolution.omittedElements()); + } + + @Test + public void emptyListResolvesWithoutIndexingAChild() throws Exception { + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement( + new FakeListPoolElement(List.of()) + ); + + assertNull(resolution.physicalElement()); + assertNull(resolution.templateLocation()); + assertEquals(1, resolution.listLevels()); + assertEquals(0, resolution.omittedElements()); + } + + @Test + public void outerChoiceProducesOneEntryWithItsOriginalWeight() throws Exception { + FakeListPoolElement towers = new FakeListPoolElement(List.of( + new FakeSinglePoolElement("minecraft:pillager_outpost/watchtower"), + new FakeSinglePoolElement("minecraft:pillager_outpost/watchtower_overgrown") + )); + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(towers); + + List> entries = List.of(VillageImporter.piecePoolEntry( + "qa/pillager_outpost/piece/minecraft/pillager_outpost/watchtower", + 7 + )); + + assertEquals(1, entries.size()); + assertEquals("minecraft:pillager_outpost/watchtower", resolution.templateLocation()); + assertEquals(7, entries.getFirst().get("weight")); + assertEquals(1, resolution.omittedElements()); + } + + @Test + public void omittedCompositeChildrenProduceExplicitFidelityLoss() throws Exception { + FakeListPoolElement towers = new FakeListPoolElement(List.of( + new FakeSinglePoolElement("minecraft:pillager_outpost/watchtower"), + new FakeSinglePoolElement("minecraft:pillager_outpost/watchtower_overgrown") + )); + VillageImporter.PoolElementResolution resolution = VillageImporter.resolvePoolElement(towers); + + StructureLoss loss = VillageImporter.listElementFallbackLoss( + resolution, + "minecraft:pillager_outpost/towers" + ); + + assertEquals(StructureCapability.LIST_ELEMENTS, loss.capability()); + assertEquals("list_pool_overlays_not_imported", loss.code()); + assertTrue(loss.detail().contains("minecraft:pillager_outpost/towers")); + assertTrue(loss.detail().contains("omitted 1 colocated element")); + } + + @Test + public void allAirTemplatesWithConnectorsRemainNonCollidablePhysicalScaffolds() { + List> connectors = List.of(Map.of("name", "test:connector")); + VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember( + "test:pool", + "test/pool", + false, + 2, + "test:fallback", + "test:scaffold", + "test:scaffold", + 3, + 0, + connectors); + Map scaffold = VillageImporter.pieceJson("test:scaffold", connectors, 0); + Map physical = VillageImporter.pieceJson("test:physical", connectors, 1); + + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, normalization.disposition()); + assertEquals("test:scaffold", normalization.poolEntry().get("piece")); + assertTrue(normalization.losses().isEmpty()); + assertEquals(false, scaffold.get("collidable")); + assertFalse(physical.containsKey("collidable")); + } + + @Test + public void connectorlessAllAirMemberNormalizesToExplicitEmptyWithFidelityLoss() { + VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember( + "dungeons_and_taverns:waystones", + "qa/pool/dungeons_and_taverns/waystones", + false, + 1, + "dungeons_and_taverns:waystones", + "dungeons_and_taverns:waystones/waystone_scaffold", + "qa/piece/dungeons_and_taverns/waystones/waystone_scaffold", + 5, + 0, + List.of()); + + assertEquals(VillageImporter.PoolMemberDisposition.EMPTY, normalization.disposition()); + assertEquals(true, normalization.poolEntry().get("empty")); + assertEquals(5, normalization.poolEntry().get("weight")); + assertEquals(1, normalization.losses().size()); + StructureLoss loss = normalization.losses().getFirst(); + assertEquals(StructureCapability.IRIS_PLACEMENT, loss.capability()); + assertEquals("connectorless_all_air_member_normalized_empty", loss.code()); + assertEquals("jigsaw-pools/qa/pool/dungeons_and_taverns/waystones.json", loss.affectedResource()); + assertTrue(loss.detail().contains("source self-fallback")); + } + + @Test + public void connectorlessNonAirMemberIsOmittedAsUnattachableWithFidelityLoss() { + VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember( + "test:orphan_pool", + "qa/pool/test/orphan_pool", + false, + 1, + null, + "test:orphan_house", + "qa/piece/test/orphan_house", + 7, + 42, + List.of()); + + assertEquals(VillageImporter.PoolMemberDisposition.OMITTED, normalization.disposition()); + assertTrue(normalization.poolEntry().isEmpty()); + assertEquals(1, normalization.losses().size()); + StructureLoss loss = normalization.losses().getFirst(); + assertEquals(StructureCapability.BLOCKS, loss.capability()); + assertEquals("connectorless_non_air_member_omitted", loss.code()); + assertTrue(loss.detail().contains("42 non-air block(s)")); + assertTrue(loss.detail().contains("no source fallback")); + assertTrue(loss.detail().contains("selection weights")); + assertTrue(loss.detail().contains("RNG consumption")); + assertEquals("jigsaw-pools/qa/pool/test/orphan_pool.json", loss.affectedResource()); + } + + @Test + public void connectorlessStartMemberRemainsPhysicalRegardlessOfAirContent() { + VillageImporter.PoolMemberNormalization allAir = VillageImporter.normalizePoolMember( + "test:start", + "qa/pool/test/start", + true, + 2, + "test:fallback", + "test:all_air_start", + "qa/piece/test/all_air_start", + 2, + 0, + List.of()); + VillageImporter.PoolMemberNormalization nonAir = VillageImporter.normalizePoolMember( + "test:start", + "qa/pool/test/start", + true, + 2, + "test:fallback", + "test:non_air_start", + "qa/piece/test/non_air_start", + 3, + 17, + List.of()); + + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, allAir.disposition()); + assertEquals("qa/piece/test/all_air_start", allAir.poolEntry().get("piece")); + assertTrue(allAir.losses().isEmpty()); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, nonAir.disposition()); + assertEquals("qa/piece/test/non_air_start", nonAir.poolEntry().get("piece")); + assertTrue(nonAir.losses().isEmpty()); + } + + @Test + public void mixedPoolConnectorlessAllAirMemberIsOmittedWithWeightAndRngDriftLoss() { + VillageImporter.PoolMemberNormalization normalization = VillageImporter.normalizePoolMember( + "test:mixed", + "qa/pool/test/mixed", + false, + 3, + "test:mixed", + "test:air_inert", + "qa/piece/test/air_inert", + 11, + 0, + List.of()); + + assertEquals(VillageImporter.PoolMemberDisposition.OMITTED, normalization.disposition()); + assertTrue(normalization.poolEntry().isEmpty()); + assertEquals(1, normalization.losses().size()); + StructureLoss loss = normalization.losses().getFirst(); + assertEquals(StructureCapability.IRIS_PLACEMENT, loss.capability()); + assertEquals("connectorless_all_air_mixed_member_omitted", loss.code()); + assertTrue(loss.detail().contains("mixed 3-member pool")); + assertTrue(loss.detail().contains("selection weights")); + assertTrue(loss.detail().contains("RNG consumption")); + } + + @Test + public void mixedDistinctFallbackRetainsEveryConnectorlessMemberAsPhysical() { + VillageImporter.PoolMemberNormalization allAir = VillageImporter.normalizePoolMember( + "test:mixed", + "qa/pool/test/mixed", + false, + 3, + "test:fallback", + "test:all_air_inert", + "qa/piece/test/all_air_inert", + 11, + 0, + List.of()); + VillageImporter.PoolMemberNormalization nonAir = VillageImporter.normalizePoolMember( + "test:mixed", + "qa/pool/test/mixed", + false, + 3, + "test:fallback", + "test:non_air_inert", + "qa/piece/test/non_air_inert", + 13, + 31, + List.of()); + + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, allAir.disposition()); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, nonAir.disposition()); + assertEquals("qa/piece/test/all_air_inert", allAir.poolEntry().get("piece")); + assertEquals("qa/piece/test/non_air_inert", nonAir.poolEntry().get("piece")); + assertTrue(allAir.losses().isEmpty()); + assertTrue(nonAir.losses().isEmpty()); + } + + @Test + public void singletonDistinctFallbackRetainsConnectorlessMembersAsPhysical() { + VillageImporter.PoolMemberNormalization allAir = VillageImporter.normalizePoolMember( + "test:primary", + "qa/pool/test/primary", + false, + 1, + "test:fallback", + "test:all_air_primary", + "qa/piece/test/all_air_primary", + 5, + 0, + List.of()); + VillageImporter.PoolMemberNormalization nonAir = VillageImporter.normalizePoolMember( + "test:primary", + "qa/pool/test/primary", + false, + 1, + "test:fallback", + "test:non_air_primary", + "qa/piece/test/non_air_primary", + 7, + 23, + List.of()); + VillageImporter.PoolMemberNormalization noFallbackAllAir = VillageImporter.normalizePoolMember( + "test:terminal", + "qa/pool/test/terminal", + false, + 1, + null, + "test:all_air_terminal", + "qa/piece/test/all_air_terminal", + 3, + 0, + List.of()); + VillageImporter.PoolMemberNormalization selfFallbackNonAir = VillageImporter.normalizePoolMember( + "test:self", + "qa/pool/test/self", + false, + 1, + "test:self", + "test:non_air_self", + "qa/piece/test/non_air_self", + 2, + 19, + List.of()); + + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, allAir.disposition()); + assertEquals("qa/piece/test/all_air_primary", allAir.poolEntry().get("piece")); + assertTrue(allAir.losses().isEmpty()); + assertEquals(VillageImporter.PoolMemberDisposition.PHYSICAL, nonAir.disposition()); + assertEquals("qa/piece/test/non_air_primary", nonAir.poolEntry().get("piece")); + assertTrue(nonAir.losses().isEmpty()); + assertEquals(VillageImporter.PoolMemberDisposition.EMPTY, noFallbackAllAir.disposition()); + assertEquals(VillageImporter.PoolMemberDisposition.OMITTED, selfFallbackNonAir.disposition()); + } + + @Test + public void omittedTemplateContributesNoBlocksOrCapturedCapabilities() { + VillageImporter.ImportedTemplate imported = new VillageImporter.ImportedTemplate( + new IrisObject(3, 3, 3), + 27, + 4, + List.of(), + List.of(StructureCapability.BLOCKS, StructureCapability.BLOCK_ENTITIES)); + VillageImporter.PoolMemberNormalization omitted = VillageImporter.normalizePoolMember( + "test:self", + "qa/pool/test/self", + false, + 2, + "test:self", + "test:omitted_tile", + "qa/piece/test/omitted_tile", + 1, + 4, + List.of()); + + assertEquals(VillageImporter.PoolMemberDisposition.OMITTED, omitted.disposition()); + assertEquals(0, imported.emittedBlocks(omitted)); + assertTrue(imported.emittedCapabilities(omitted).isEmpty()); + } + + private static final class FakeListPoolElement { + private final List elements; + + private FakeListPoolElement(List elements) { + this.elements = List.copyOf(elements); + } + + public List getElements() { + return elements; + } + } + + private static final class FakeSinglePoolElement { + private final FakeResourceLocation location; + + private FakeSinglePoolElement(String location) { + String[] parts = location.split(":", 2); + this.location = new FakeResourceLocation(parts[0], parts[1]); + } + + public FakeResourceLocation getTemplateLocation() { + return location; + } + } + + private static final class FakeFeaturePoolElement { + } + + private static final class FakeResourceLocation { + private final String namespace; + private final String path; + + private FakeResourceLocation(String namespace, String path) { + this.namespace = namespace; + this.path = path; + } + + public String getNamespace() { + return namespace; + } + + public String getPath() { + return path; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureOwnershipProvenanceTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureOwnershipProvenanceTest.java new file mode 100644 index 000000000..2b13520ca --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureOwnershipProvenanceTest.java @@ -0,0 +1,138 @@ +package art.arcane.iris.core.structure.authoring; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class StructureOwnershipProvenanceTest { + private static final StructureKey KEY = new StructureKey("iris", "village"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void legacyManifestWithoutProvenanceLoadsAsCreated() { + String resourceHash = StructureHash.sha256("object".getBytes(StandardCharsets.UTF_8)); + String json = "{" + + "\"schemaVersion\":1," + + "\"structure\":{\"namespace\":\"iris\",\"path\":\"village\"}," + + "\"source\":{\"kind\":\"IRIS\",\"key\":{\"namespace\":\"iris\",\"path\":\"village\"}," + + "\"version\":\"\",\"contentHash\":\"\"}," + + "\"backend\":\"IRIS_ASSEMBLY\"," + + "\"capabilities\":[\"BLOCKS\"]," + + "\"losses\":[]," + + "\"resourceHashes\":{\"objects/village.iob\":\"" + resourceHash + "\"}" + + "}"; + + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson( + json.getBytes(StandardCharsets.UTF_8)); + + assertEquals(StructureOwnershipManifest.Origin.CREATED, manifest.provenance().origin()); + assertTrue(manifest.provenance().sourceResourceHashes().isEmpty()); + } + + @Test + public void adoptedProvenanceSurvivesOrdinaryOwnedOverwrite() throws Exception { + Path root = temporaryFolder.newFolder("preserve").toPath(); + StructureResourceBundle first = bundle("object-v1", "structure-v1"); + Map hashes = Map.of( + "objects/village.iob", first.resources().get("objects/village.iob").contentHash(), + "structures/village.json", first.resources().get("structures/village.json").contentHash()); + StructureOwnershipManifest.Provenance provenance = new StructureOwnershipManifest.Provenance( + StructureOwnershipManifest.Origin.ADOPTED_CLONE, + UUID.randomUUID().toString(), + StructureHash.sha256("plan".getBytes(StandardCharsets.UTF_8)), + StructureHash.sha256("closure".getBytes(StandardCharsets.UTF_8)), + 1_754_740_800_000L, + hashes, + Map.of( + "objects/village.iob", "objects/village.iob", + "structures/village.json", "structures/village.json"), + StructureOwnershipManifest.RollbackDisposition.DELETE_CREATED_IF_UNCHANGED); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.writeVerified( + first, + StructureWriteOptions.addOnly(), + StructureTransactionReadSet.empty(), + provenance); + assertEquals(initial.failure().map(Throwable::toString).orElse(""), + StructureWriteResult.Status.ADDED, initial.status()); + + StructureWriteResult overwrite = writer.write(bundle("object-v2", "structure-v2"), StructureWriteMode.OVERWRITE); + + assertEquals(overwrite.failure().map(Throwable::toString).orElse(""), + StructureWriteResult.Status.OVERWRITTEN, overwrite.status()); + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson( + Files.readAllBytes(writer.ownershipManifestPath(KEY))); + assertEquals(provenance, manifest.provenance()); + } + + @Test + public void managedDatapackWriteCommitsManagedOriginWithTheManifest() throws Exception { + Path root = temporaryFolder.newFolder("managed").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + + StructureWriteResult result = writer.writeManagedDatapack( + datapackBundle("object", "structure"), + StructureWriteMode.ADD_ONLY + ); + + assertEquals(result.failure().map(Throwable::toString).orElse(""), + StructureWriteResult.Status.ADDED, result.status()); + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson( + Files.readAllBytes(writer.ownershipManifestPath(KEY))); + assertEquals(StructureOwnershipManifest.Origin.MANAGED_DATAPACK, manifest.provenance().origin()); + assertEquals(manifest.resourceHashes(), manifest.provenance().sourceResourceHashes()); + } + + @Test + public void managedDatapackOverwriteUpgradesMatchingOrdinaryImportProvenance() throws Exception { + Path root = temporaryFolder.newFolder("managed-upgrade").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + assertEquals(StructureWriteResult.Status.ADDED, + writer.write(datapackBundle("object-v1", "structure-v1"), StructureWriteMode.ADD_ONLY).status()); + + StructureWriteResult result = writer.writeManagedDatapack( + datapackBundle("object-v2", "structure-v2"), + StructureWriteMode.OVERWRITE + ); + + assertEquals(result.failure().map(Throwable::toString).orElse(""), + StructureWriteResult.Status.OVERWRITTEN, result.status()); + StructureOwnershipManifest manifest = StructureOwnershipManifest.fromJson( + Files.readAllBytes(writer.ownershipManifestPath(KEY))); + assertEquals(StructureOwnershipManifest.Origin.MANAGED_DATAPACK, manifest.provenance().origin()); + } + + private StructureResourceBundle bundle(String object, String structure) { + return StructureResourceBundle.builder(KEY) + .source(StructureSource.of(StructureSource.Kind.IRIS, KEY)) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capabilities(List.of(StructureCapability.BLOCKS, StructureCapability.CONNECTORS)) + .resource("objects/village.iob", object.getBytes(StandardCharsets.UTF_8)) + .textResource("structures/village.json", structure) + .build(); + } + + private StructureResourceBundle datapackBundle(String object, String structure) { + return StructureResourceBundle.builder(KEY) + .source(StructureSource.of( + StructureSource.Kind.DATAPACK, + new StructureKey("example", "village"))) + .backend(StructureBackend.IRIS_ASSEMBLY) + .capabilities(List.of(StructureCapability.BLOCKS, StructureCapability.CONNECTORS)) + .resource("objects/village.iob", object.getBytes(StandardCharsets.UTF_8)) + .textResource("structures/village.json", structure) + .build(); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionClaimTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionClaimTest.java new file mode 100644 index 000000000..a7709f65e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionClaimTest.java @@ -0,0 +1,114 @@ +package art.arcane.iris.core.structure.authoring; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class StructureTransactionClaimTest { + private static final StructureKey KEY = new StructureKey("iris", "village"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void failedManifestInstallLeavesExistingResourcesUnchangedAndUnowned() throws Exception { + Path root = temporaryFolder.newFolder("claim-rollback").toPath(); + Path resource = root.resolve("objects/village.iob"); + Files.createDirectories(resource.getParent()); + byte[] content = "existing".getBytes(StandardCharsets.UTF_8); + Files.write(resource, content); + String hash = StructureHash.sha256(content); + StructureOwnershipManifest manifest = new StructureOwnershipManifest( + StructureOwnershipManifest.CURRENT_SCHEMA_VERSION, + KEY, + StructureSource.of(StructureSource.Kind.IRIS, KEY), + StructureBackend.IRIS_ASSEMBLY, + List.of(StructureCapability.BLOCKS), + List.of(), + Map.of("objects/village.iob", hash)); + StructureTransactionReadSet readSet = StructureTransactionReadSet.builder() + .file("objects/village.iob", hash) + .absent(manifest.relativePath()) + .build(); + StructureTransactionWriter writer = new StructureTransactionWriter( + root, + new FailManifestMoveOperations()); + + StructureWriteResult result = writer.claimExisting(manifest, readSet); + + assertEquals(StructureWriteResult.Status.ROLLED_BACK, result.status()); + assertArrayEquals(content, Files.readAllBytes(resource)); + assertFalse(Files.exists(writer.ownershipManifestPath(KEY))); + } + + private static final class FailManifestMoveOperations implements StructureFileOperations { + private final NioStructureFileOperations delegate = new NioStructureFileOperations(); + private boolean failed; + + @Override + public boolean exists(Path path) { + return delegate.exists(path); + } + + @Override + public boolean isRegularFile(Path path) { + return delegate.isRegularFile(path); + } + + @Override + public byte[] readAllBytes(Path path) throws IOException { + return delegate.readAllBytes(path); + } + + @Override + public String sha256(Path path) throws IOException { + return delegate.sha256(path); + } + + @Override + public void createDirectories(Path path) throws IOException { + delegate.createDirectories(path); + } + + @Override + public void writeNew(Path path, byte[] content) throws IOException { + delegate.writeNew(path, content); + } + + @Override + public void move(Path source, Path target) throws IOException { + delegate.move(source, target); + } + + @Override + public void moveNew(Path source, Path target) throws IOException { + String portable = source.toString().replace('\\', '/'); + if (!failed && portable.endsWith("staged/ownership-manifest.json")) { + failed = true; + throw new IOException("Injected manifest install failure"); + } + delegate.moveNew(source, target); + } + + @Override + public void deleteIfExists(Path path) throws IOException { + delegate.deleteIfExists(path); + } + + @Override + public void deleteTree(Path root) throws IOException { + delegate.deleteTree(root); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java index 770d31bbe..52e695213 100644 --- a/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java +++ b/core/src/test/java/art/arcane/iris/core/structure/authoring/StructureTransactionWriterTest.java @@ -29,12 +29,14 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; import static org.junit.Assert.assertArrayEquals; 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 StructureTransactionWriterTest { @@ -120,6 +122,35 @@ public class StructureTransactionWriterTest { Files.readAllBytes(root.resolve("structures/temple.json"))); } + @Test + public void expectedManifestHashRejectsAStaleWholeGraphEdit() throws IOException { + Path root = temporaryFolder.newFolder("manifest-cas").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write( + bundle("object-v1", "structure-v1"), + StructureWriteMode.ADD_ONLY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + Path manifestPath = writer.ownershipManifestPath(TARGET_KEY); + String observedHash = StructureHash.sha256(Files.readAllBytes(manifestPath)); + + StructureWriteResult competing = writer.write( + bundle("object-v2", "structure-v2"), + StructureWriteMode.OVERWRITE); + assertEquals(failureMessage(competing), StructureWriteResult.Status.OVERWRITTEN, competing.status()); + + StructureWriteResult stale = writer.write( + bundle("object-stale", "structure-stale"), + StructureWriteOptions.overwriteExpected(observedHash)); + + assertEquals(StructureWriteResult.Status.OWNERSHIP_CONFLICT, stale.status()); + assertEquals(StructureWriteResult.ConflictReason.STALE_MANIFEST, + stale.conflicts().getFirst().reason()); + assertArrayEquals("object-v2".getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(root.resolve("objects/temple.iob"))); + assertArrayEquals("structure-v2".getBytes(StandardCharsets.UTF_8), + Files.readAllBytes(root.resolve("structures/temple.json"))); + } + @Test public void failedMultiFileInstallRollsBackEveryResourceAndManifest() throws IOException { Path root = temporaryFolder.newFolder("rollback").toPath(); @@ -496,12 +527,16 @@ public class StructureTransactionWriterTest { new StructureTransactionWriter.OwnedRemoval( alphaKey, StructureSource.Kind.DATAPACK, - alphaSource + alphaSource, + Optional.empty(), + Optional.empty() ), new StructureTransactionWriter.OwnedRemoval( zetaKey, StructureSource.Kind.DATAPACK, - zetaSource + zetaSource, + Optional.empty(), + Optional.empty() ) )); throw new AssertionError("Expected the later removal move to fail"); @@ -515,6 +550,77 @@ public class StructureTransactionWriterTest { assertTrue(Files.exists(writer.ownershipManifestPath(zetaKey))); } + @Test + public void preparedRemovalRejectsAStaleExpectedManifestHashBeforeMovingResources() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal-stale-manifest").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write( + bundle("object-v1", "structure-v1"), + StructureWriteMode.ADD_ONLY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + Path manifestPath = writer.ownershipManifestPath(TARGET_KEY); + String staleManifestHash = StructureHash.sha256(Files.readAllBytes(manifestPath)); + StructureWriteResult updated = writer.write( + bundle("object-v2", "structure-v2"), + StructureWriteOptions.overwriteExpected(staleManifestHash)); + assertEquals(failureMessage(updated), StructureWriteResult.Status.OVERWRITTEN, updated.status()); + + try { + writer.prepareOwnedRemovals(List.of(new StructureTransactionWriter.OwnedRemoval( + TARGET_KEY, + StructureSource.Kind.VANILLA, + SOURCE_KEY, + Optional.empty(), + Optional.of(staleManifestHash)))); + throw new AssertionError("Expected stale removal manifest hash to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("changed after removal was planned")); + } + + assertEquals("object-v2", Files.readString(root.resolve("objects/temple.iob"), StandardCharsets.UTF_8)); + assertEquals("structure-v2", Files.readString( + root.resolve("structures/temple.json"), + StandardCharsets.UTF_8)); + assertTrue(Files.exists(manifestPath)); + } + + @Test + public void lockedRemovalValidatorRejectsBeforeMovingOwnedResources() throws IOException { + Path root = temporaryFolder.newFolder("owned-removal-locked-validator").toPath(); + StructureTransactionWriter writer = new StructureTransactionWriter(root); + StructureWriteResult initial = writer.write( + bundle("object-v1", "structure-v1"), + StructureWriteMode.ADD_ONLY); + assertEquals(failureMessage(initial), StructureWriteResult.Status.ADDED, initial.status()); + Path objectPath = root.resolve("objects/temple.iob"); + Path structurePath = root.resolve("structures/temple.json"); + Path manifestPath = writer.ownershipManifestPath(TARGET_KEY); + byte[] expectedObject = Files.readAllBytes(objectPath); + byte[] expectedStructure = Files.readAllBytes(structurePath); + byte[] expectedManifest = Files.readAllBytes(manifestPath); + + IOException failure = assertThrows( + IOException.class, + () -> writer.prepareOwnedRemovals( + List.of(new StructureTransactionWriter.OwnedRemoval( + TARGET_KEY, + StructureSource.Kind.VANILLA, + SOURCE_KEY, + Optional.empty(), + Optional.of(StructureHash.sha256(expectedManifest)))), + () -> { + assertArrayEquals(expectedObject, Files.readAllBytes(objectPath)); + assertArrayEquals(expectedStructure, Files.readAllBytes(structurePath)); + assertArrayEquals(expectedManifest, Files.readAllBytes(manifestPath)); + throw new IOException("Injected locked removal validation failure"); + })); + + assertTrue(failure.getMessage().contains("Injected locked removal validation failure")); + assertArrayEquals(expectedObject, Files.readAllBytes(objectPath)); + assertArrayEquals(expectedStructure, Files.readAllBytes(structurePath)); + assertArrayEquals(expectedManifest, Files.readAllBytes(manifestPath)); + } + @Test public void preparedRemovalCanRollbackAfterCommitMarkerBeforeCoordinatorPublish() throws IOException { Path root = temporaryFolder.newFolder("owned-removal-coordinator-rollback").toPath(); @@ -525,7 +631,9 @@ public class StructureTransactionWriterTest { new StructureTransactionWriter.OwnedRemoval( TARGET_KEY, StructureSource.Kind.VANILLA, - SOURCE_KEY + SOURCE_KEY, + Optional.empty(), + Optional.empty() ) )); assertFalse(Files.exists(root.resolve("objects/temple.iob"))); @@ -548,7 +656,9 @@ public class StructureTransactionWriterTest { new StructureTransactionWriter.OwnedRemoval( TARGET_KEY, StructureSource.Kind.VANILLA, - SOURCE_KEY + SOURCE_KEY, + Optional.empty(), + Optional.empty() ) )); StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); @@ -572,7 +682,9 @@ public class StructureTransactionWriterTest { new StructureTransactionWriter.OwnedRemoval( TARGET_KEY, StructureSource.Kind.VANILLA, - SOURCE_KEY + SOURCE_KEY, + Optional.empty(), + Optional.empty() ) )); StructureTransactionWriter.PreparedRemovalToken token = removal.recoveryToken().orElseThrow(); diff --git a/core/src/test/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionServiceTest.java b/core/src/test/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionServiceTest.java new file mode 100644 index 000000000..d0345535c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/conversion/IrisStructureAdoptionServiceTest.java @@ -0,0 +1,376 @@ +package art.arcane.iris.core.structure.conversion; + +import art.arcane.iris.core.structure.authoring.StructureBackend; +import art.arcane.iris.core.structure.authoring.StructureCapability; +import art.arcane.iris.core.structure.authoring.StructureHash; +import art.arcane.iris.core.structure.authoring.StructureKey; +import art.arcane.iris.core.structure.authoring.StructureOwnershipManifest; +import art.arcane.iris.core.structure.authoring.StructureSource; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.UUID; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisStructureAdoptionServiceTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void exclusiveGraphIsClaimedWithoutRewritingResourceBytes() throws Exception { + Path root = temporaryFolder.newFolder("in-place").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + Path object = root.resolve("objects/village/house.iob"); + byte[] original = Files.readAllBytes(object); + FileTime originalTime = FileTime.fromMillis(1_000_000L); + Files.setLastModifiedTime(object, originalTime); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + + IrisStructureAdoptionPlan plan = service.inspect(IrisStructureAdoptionRequest.unowned("village")); + IrisStructureAdoptionResult result = service.apply(plan); + + assertEquals(IrisStructureAdoptionDisposition.IN_PLACE, plan.disposition()); + assertTrue(plan.summaryLines().toString(), plan.canApply()); + assertEquals(IrisStructureAdoptionResult.Status.APPLIED, result.status()); + assertArrayEquals(original, Files.readAllBytes(object)); + assertEquals(originalTime, Files.getLastModifiedTime(object)); + StructureOwnershipManifest manifest = readManifest(root, new StructureKey("iris", "village")); + assertEquals(StructureOwnershipManifest.Origin.ADOPTED_EXISTING, manifest.provenance().origin()); + assertEquals(StructureOwnershipManifest.RollbackDisposition.NONE, + manifest.provenance().rollbackDisposition()); + assertEquals(plan.planHash(), manifest.provenance().planHash()); + } + + @Test + public void changedSourceRejectsApplyWithoutInstallingOwnership() throws Exception { + Path root = temporaryFolder.newFolder("stale-source").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + IrisStructureAdoptionPlan plan = service.inspect(IrisStructureAdoptionRequest.unowned("village")); + Files.writeString(root.resolve("objects/village/house.iob"), "changed", StandardCharsets.UTF_8); + + IrisStructureAdoptionResult result = service.apply(plan); + + assertEquals(IrisStructureAdoptionResult.Status.STALE, result.status()); + assertFalse(Files.exists(manifestPath(root, new StructureKey("iris", "village")))); + } + + @Test + public void newStructureRootInvalidatesExclusiveClaimReadSet() throws Exception { + Path root = temporaryFolder.newFolder("stale-directory").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + IrisStructureAdoptionPlan plan = service.inspect(IrisStructureAdoptionRequest.unowned("village")); + write(root, "structures/new-root.json", "{\"startPool\":\"village/start\"}"); + + IrisStructureAdoptionResult result = service.apply(plan); + + assertEquals(IrisStructureAdoptionResult.Status.STALE, result.status()); + assertFalse(Files.exists(manifestPath(root, new StructureKey("iris", "village")))); + } + + @Test + public void sharedGraphUsesPrivateCloneAndRewritesEveryReference() throws Exception { + Path root = temporaryFolder.newFolder("clone").toPath(); + writeGraph(root, "village", "shared/start", "shared/house", "shared/house"); + write(root, "structures/village.json", + "{\"startPool\":\"shared/start\",\"branchFailurePolicy\":\"TERMINATE_BRANCH\"}"); + write(root, "structures/outpost.json", "{\"startPool\":\"shared/start\"}"); + byte[] sourceObject = Files.readAllBytes(root.resolve("objects/shared/house.iob")); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + + IrisStructureAdoptionPlan plan = service.inspect(IrisStructureAdoptionRequest.unowned("village")); + IrisStructureAdoptionResult result = service.apply(plan); + + assertEquals(IrisStructureAdoptionDisposition.CLONE_REQUIRED, plan.disposition()); + assertEquals(new StructureKey("iris", "village-studio"), plan.targetStructure()); + assertEquals(IrisStructureAdoptionResult.Status.APPLIED, result.status()); + Path targetStructure = root.resolve("structures/village-studio.json"); + JsonObject structure = JsonParser.parseString(Files.readString(targetStructure)).getAsJsonObject(); + assertEquals("village-studio/pool/shared/start", structure.get("startPool").getAsString()); + assertEquals("TERMINATE_BRANCH", structure.get("branchFailurePolicy").getAsString()); + Path targetPool = root.resolve("jigsaw-pools/village-studio/pool/shared/start.json"); + JsonObject pool = JsonParser.parseString(Files.readString(targetPool)).getAsJsonObject(); + assertEquals("village-studio/piece/shared/house", + pool.getAsJsonArray("pieces").get(0).getAsJsonObject().get("piece").getAsString()); + Path targetPiece = root.resolve("jigsaw-pieces/village-studio/piece/shared/house.json"); + JsonObject piece = JsonParser.parseString(Files.readString(targetPiece)).getAsJsonObject(); + assertEquals("village-studio/object/shared/house", piece.get("object").getAsString()); + assertArrayEquals(sourceObject, + Files.readAllBytes(root.resolve("objects/village-studio/object/shared/house.iob"))); + StructureOwnershipManifest manifest = readManifest(root, plan.targetStructure()); + assertEquals(StructureOwnershipManifest.Origin.ADOPTED_CLONE, manifest.provenance().origin()); + assertEquals(StructureOwnershipManifest.RollbackDisposition.DELETE_CREATED_IF_UNCHANGED, + manifest.provenance().rollbackDisposition()); + assertTrue(manifest.provenance().sourceToTargetPaths().containsKey("objects/shared/house.iob")); + } + + @Test + public void managedInputCannotBecomeAnInPlaceClaim() throws Exception { + Path root = temporaryFolder.newFolder("managed").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + IrisStructureAdoptionRequest request = new IrisStructureAdoptionRequest( + "village", + Optional.empty(), + IrisStructureAdoptionStrategy.AUTO, + IrisStructureAdoptionInputKind.MANAGED_DATAPACK); + + IrisStructureAdoptionPlan plan = service.inspect(request); + + assertEquals(IrisStructureAdoptionDisposition.CLONE_REQUIRED, plan.disposition()); + assertEquals(new StructureKey("iris", "village-studio"), plan.targetStructure()); + assertTrue(plan.diagnostics().stream().anyMatch(diagnostic -> + diagnostic.code() == IrisStructureAdoptionDiagnostic.Code.MANAGED_INPUT_REQUIRES_CLONE)); + } + + @Test + public void managedImportManifestIsVerifiedAndPrivateClonePreservesItsOrigin() throws Exception { + Path root = temporaryFolder.newFolder("managed-manifest").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + writeManagedManifest(root, "village"); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + IrisStructureAdoptionRequest request = new IrisStructureAdoptionRequest( + "village", + Optional.empty(), + IrisStructureAdoptionStrategy.AUTO, + IrisStructureAdoptionInputKind.MANAGED_DATAPACK); + + IrisStructureAdoptionPlan plan = service.inspect(request); + IrisStructureAdoptionResult result = service.apply(plan); + + assertEquals(IrisStructureAdoptionDisposition.CLONE_REQUIRED, plan.disposition()); + assertTrue(plan.summaryLines().toString(), plan.canApply()); + assertEquals(IrisStructureAdoptionResult.Status.APPLIED, result.status()); + StructureOwnershipManifest manifest = readManifest(root, plan.targetStructure()); + assertEquals(StructureOwnershipManifest.Origin.ADOPTED_MANAGED_CLONE, + manifest.provenance().origin()); + assertEquals(StructureOwnershipManifest.RollbackDisposition.DELETE_CREATED_IF_UNCHANGED, + manifest.provenance().rollbackDisposition()); + } + + @Test + public void ordinaryDatapackManifestCannotBeForgedIntoManagedInput() throws Exception { + Path root = temporaryFolder.newFolder("forged-managed").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + writeSourceManifest(root, "village", false); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + IrisStructureAdoptionRequest request = new IrisStructureAdoptionRequest( + "village", + Optional.empty(), + IrisStructureAdoptionStrategy.AUTO, + IrisStructureAdoptionInputKind.MANAGED_DATAPACK); + + IrisStructureAdoptionPlan plan = service.inspect(request); + + assertEquals(IrisStructureAdoptionDisposition.BLOCKED, plan.disposition()); + assertFalse(plan.canApply()); + assertTrue(plan.diagnostics().stream().anyMatch(diagnostic -> + diagnostic.code() == IrisStructureAdoptionDiagnostic.Code.SOURCE_ALREADY_OWNED)); + } + + @Test + public void unsafeCloneTargetPathReturnsBlockedDiagnostic() throws Exception { + Path root = temporaryFolder.newFolder("unsafe-target").toPath(); + Path outside = temporaryFolder.newFolder("outside-target").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + Files.createDirectories(root.resolve("objects")); + Files.createSymbolicLink(root.resolve("objects/copy"), outside); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + + IrisStructureAdoptionPlan plan = service.inspect(IrisStructureAdoptionRequest.cloneTo( + "village", + new StructureKey("iris", "copy"))); + + assertEquals(IrisStructureAdoptionDisposition.BLOCKED, plan.disposition()); + assertFalse(plan.canApply()); + assertTrue(plan.diagnostics().stream().anyMatch(diagnostic -> + diagnostic.code() == IrisStructureAdoptionDiagnostic.Code.TARGET_RESOURCE_UNSAFE)); + } + + @Test + public void cloneTargetAppearingAfterInspectionRejectsApply() throws Exception { + Path root = temporaryFolder.newFolder("stale-target").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + IrisStructureAdoptionPlan plan = service.inspect(IrisStructureAdoptionRequest.cloneTo( + "village", + new StructureKey("iris", "copy"))); + write(root, "structures/copy.json", "user-content"); + + IrisStructureAdoptionResult result = service.apply(plan); + + assertEquals(IrisStructureAdoptionResult.Status.STALE, result.status()); + assertEquals("user-content", Files.readString(root.resolve("structures/copy.json"))); + assertFalse(Files.exists(manifestPath(root, new StructureKey("iris", "copy")))); + } + + @Test + public void expiredPlanIsConsumedWithoutWriting() throws Exception { + Path root = temporaryFolder.newFolder("expiry").toPath(); + writeGraph(root, "village", "village/start", "village/house", "village/house"); + MutableClock clock = new MutableClock(Instant.parse("2026-08-09T12:00:00Z")); + IrisStructureAdoptionLimits limits = limits(Duration.ofSeconds(1L)); + IrisStructureAdoptionService service = new IrisStructureAdoptionService(root, limits, clock); + IrisStructureAdoptionPlan plan = service.inspect(IrisStructureAdoptionRequest.unowned("village")); + clock.set(Instant.parse("2026-08-09T12:00:02Z")); + + IrisStructureAdoptionResult result = service.apply(plan); + + assertEquals(IrisStructureAdoptionResult.Status.EXPIRED, result.status()); + assertFalse(Files.exists(manifestPath(root, new StructureKey("iris", "village")))); + } + + @Test + public void malformedGraphReturnsStructuredBlockedPlan() throws Exception { + Path root = temporaryFolder.newFolder("blocked").toPath(); + write(root, "structures/broken.json", "{\"startPool\":\"broken/missing\"}"); + IrisStructureAdoptionService service = service(root, Duration.ofMinutes(15L)); + + IrisStructureAdoptionPlan plan = service.inspect(IrisStructureAdoptionRequest.unowned("broken")); + + assertEquals(IrisStructureAdoptionDisposition.BLOCKED, plan.disposition()); + assertFalse(plan.canApply()); + assertTrue(plan.diagnostics().stream().anyMatch(diagnostic -> + diagnostic.code() == IrisStructureAdoptionDiagnostic.Code.SOURCE_GRAPH_INVALID)); + } + + private IrisStructureAdoptionService service(Path root, Duration ttl) { + return new IrisStructureAdoptionService( + root, + limits(ttl), + Clock.fixed(Instant.parse("2026-08-09T12:00:00Z"), ZoneOffset.UTC)); + } + + private IrisStructureAdoptionLimits limits(Duration ttl) { + return new IrisStructureAdoptionLimits( + 1_000, + 1024 * 1024, + 1024 * 1024, + 16L * 1024L * 1024L, + 1_000, + 100, + 100, + ttl); + } + + private void writeGraph( + Path root, + String structure, + String pool, + String piece, + String object + ) throws IOException { + write(root, "structures/" + structure + ".json", "{\"startPool\":\"" + pool + "\"}"); + write(root, "jigsaw-pools/" + pool + ".json", + "{\"pieces\":[{\"piece\":\"" + piece + "\",\"weight\":3}]}"); + write(root, "jigsaw-pieces/" + piece + ".json", + "{\"object\":\"" + object + "\",\"connectors\":[]}"); + write(root, "objects/" + object + ".iob", "object-" + object); + } + + private void writeManagedManifest(Path root, String structure) throws IOException { + writeSourceManifest(root, structure, true); + } + + private void writeSourceManifest(Path root, String structure, boolean managed) throws IOException { + TreeMap hashes = new TreeMap<>(); + List paths = List.of( + "structures/" + structure + ".json", + "jigsaw-pools/" + structure + "/start.json"); + for (String path : paths) { + hashes.put(path, StructureHash.sha256(Files.readAllBytes(root.resolve(path)))); + } + TreeMap mappings = new TreeMap<>(); + for (String path : paths) { + mappings.put(path, path); + } + StructureOwnershipManifest.Provenance provenance = managed + ? new StructureOwnershipManifest.Provenance( + StructureOwnershipManifest.Origin.MANAGED_DATAPACK, + UUID.randomUUID().toString(), + StructureHash.sha256("managed-plan".getBytes(StandardCharsets.UTF_8)), + StructureHash.sha256("managed-source".getBytes(StandardCharsets.UTF_8)), + 1_754_740_800_000L, + hashes, + mappings, + StructureOwnershipManifest.RollbackDisposition.NONE) + : StructureOwnershipManifest.Provenance.created(); + StructureOwnershipManifest manifest = new StructureOwnershipManifest( + StructureOwnershipManifest.CURRENT_SCHEMA_VERSION, + new StructureKey("iris", structure), + StructureSource.of( + StructureSource.Kind.VANILLA, + new StructureKey("minecraft", structure)), + StructureBackend.IRIS_ASSEMBLY, + List.of(StructureCapability.BLOCKS, StructureCapability.CONNECTORS), + List.of(), + Map.copyOf(hashes), + provenance); + Path manifestPath = manifestPath(root, manifest.structure()); + Files.createDirectories(manifestPath.getParent()); + Files.write(manifestPath, manifest.toJson()); + } + + private void write(Path root, String relativePath, String content) throws IOException { + Path target = root.resolve(relativePath); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } + + private StructureOwnershipManifest readManifest(Path root, StructureKey key) throws IOException { + return StructureOwnershipManifest.fromJson(Files.readAllBytes(manifestPath(root, key))); + } + + private Path manifestPath(Path root, StructureKey key) { + return root.resolve(StructureOwnershipManifest.relativePath(key)); + } + + private static final class MutableClock extends Clock { + private Instant instant; + + private MutableClock(Instant instant) { + this.instant = instant; + } + + private void set(Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporterTest.java b/core/src/test/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporterTest.java new file mode 100644 index 000000000..064027996 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/export/VanillaJigsawDatapackExporterTest.java @@ -0,0 +1,419 @@ +package art.arcane.iris.core.structure.export; + +import art.arcane.iris.engine.framework.structure.StructureGraphResolver; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; +import art.arcane.iris.engine.object.IrisJigsawCompatibility; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectReplace; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.ObjectPlaceMode; +import art.arcane.iris.engine.object.TileData; +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.collection.KList; +import art.arcane.volmlib.util.nbt.io.NBTUtil; +import art.arcane.volmlib.util.nbt.tag.CompoundTag; +import art.arcane.volmlib.util.nbt.tag.ListTag; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +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 VanillaJigsawDatapackExporterTest { + private static PlatformBlockState stone; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + stone = blockState("minecraft:stone"); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(stone); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void exportsComplete26_2DirectoryAndConnectorTemplate() throws Exception { + TestGraph graph = connectorGraph(); + Path output = temporaryFolder.getRoot().toPath().resolve("village-pack"); + VanillaJigsawExportRequest request = request(graph, output) + .namespace("studio") + .resourcePath("village/test") + .build(); + + VanillaJigsawDatapackExporter exporter = new VanillaJigsawDatapackExporter(); + VanillaJigsawExportValidation validation = exporter.validate(request); + assertTrue(validation.diagnostics().toString(), validation.isExportable()); + assertEquals(8, validation.plannedResources().size()); + assertFalse(Files.exists(output)); + + VanillaJigsawExportResult result = exporter.export(request); + + assertTrue(result.diagnostics().toString(), result.isSuccess()); + assertEquals(8, result.resources().size()); + assertTrue(Files.isRegularFile(output.resolve("pack.mcmeta"))); + assertTrue(Files.isRegularFile(output.resolve( + "data/studio/worldgen/template_pool/village/test/pool/pools/start.json"))); + assertTrue(Files.isRegularFile(output.resolve( + "data/studio/worldgen/processor_list/village/test/empty.json"))); + + JsonObject pack = json(output.resolve("pack.mcmeta")).getAsJsonObject("pack"); + assertEquals(107, pack.get("min_format").getAsJsonArray().get(0).getAsInt()); + assertEquals(1, pack.get("min_format").getAsJsonArray().get(1).getAsInt()); + assertEquals(107, pack.get("max_format").getAsInt()); + + JsonObject structure = json(output.resolve( + "data/studio/worldgen/structure/village/test.json")); + assertEquals("minecraft:jigsaw", structure.get("type").getAsString()); + assertEquals("#studio:village/test", structure.get("biomes").getAsString()); + assertEquals(64, structure.getAsJsonObject("max_distance_from_center") + .get("horizontal").getAsInt()); + assertEquals(4064, structure.getAsJsonObject("max_distance_from_center") + .get("vertical").getAsInt()); + assertEquals("studio:village/test/pool/pools/start", structure.get("start_pool").getAsString()); + + Path templatePath = output.resolve( + "data/studio/structure/village/test/piece/pieces/start.nbt"); + CompoundTag template = (CompoundTag) NBTUtil.read(templatePath.toFile()).getTag(); + assertEquals(4903, template.getInt("DataVersion")); + ListTag blocks = template.getListTag("blocks"); + assertEquals(1, blocks.size()); + CompoundTag block = (CompoundTag) blocks.get(0); + CompoundTag connector = block.getCompoundTag("nbt"); + assertNotNull(connector); + assertEquals("minecraft:jigsaw", connector.getString("id")); + assertEquals("studio:source", connector.getString("name")); + assertEquals("studio:door", connector.getString("target")); + assertEquals("studio:village/test/pool/pools/terminal", connector.getString("pool")); + assertEquals("minecraft:stone", connector.getString("final_state")); + assertEquals(-3, connector.getInt("selection_priority")); + assertEquals(-2, connector.getInt("placement_priority")); + + CompoundTag paletteEntry = (CompoundTag) template.getListTag("palette").get(0); + assertEquals("minecraft:jigsaw", paletteEntry.getString("Name")); + assertEquals("north_up", paletteEntry.getCompoundTag("Properties").getString("orientation")); + } + + @Test + public void exportsZipWithPackFilesAtArchiveRoot() throws Exception { + TestGraph graph = terminalGraph(); + Path output = temporaryFolder.getRoot().toPath().resolve("stronghold.zip"); + VanillaJigsawExportRequest request = request(graph, output) + .namespace("studio") + .resourcePath("stronghold") + .format(VanillaJigsawExportFormat.ZIP) + .build(); + + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export(request); + + assertTrue(result.diagnostics().toString(), result.isSuccess()); + assertTrue(Files.isRegularFile(output)); + try (ZipFile zip = new ZipFile(output.toFile())) { + assertNotNull(zip.getEntry("pack.mcmeta")); + assertNotNull(zip.getEntry("data/studio/worldgen/structure/stronghold.json")); + assertNotNull(zip.getEntry("data/studio/structure/stronghold/piece/pieces/start.nbt")); + Enumeration entries = zip.entries(); + List names = new ArrayList<>(); + while (entries.hasMoreElements()) { + names.add(entries.nextElement().getName()); + } + assertFalse(names.stream().anyMatch(name -> name.startsWith("stronghold/"))); + } + } + + @Test + public void preservesExplicitAirAndStructureVoidConnectorFinalStates() throws Exception { + TestGraph graph = terminalGraph(); + PlatformBlockState air = blockState("minecraft:air"); + IrisObject object = graph.objects.get("objects/start"); + object.setUnsigned(0, 1, 1, air); + IrisJigsawPiece piece = graph.pieces.get("pieces/start"); + piece.getConnectors().add(new IrisJigsawConnector() + .setPosition(new IrisPosition(0, 1, 1)) + .setDirection(IrisDirection.WEST_NEGATIVE_X) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("pools/terminal") + .setName("studio:air") + .setTargetName("studio:door") + .setFinalState("minecraft:air")); + piece.getConnectors().add(new IrisJigsawConnector() + .setPosition(new IrisPosition(2, 1, 1)) + .setDirection(IrisDirection.EAST_POSITIVE_X) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("pools/terminal") + .setName("studio:void") + .setTargetName("studio:door") + .setFinalState("minecraft:structure_void")); + graph.pools.put("pools/terminal", pool(emptyEntry(1))); + Path output = temporaryFolder.getRoot().toPath().resolve("final-state-pack"); + + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export( + request(graph, output).build()); + + assertTrue(result.diagnostics().toString(), result.isSuccess()); + CompoundTag template = (CompoundTag) NBTUtil.read(output.resolve( + "data/iris/structure/test_structure/piece/pieces/start.nbt").toFile()).getTag(); + ListTag blocks = template.getListTag("blocks"); + List finalStates = new ArrayList<>(); + for (Object value : blocks) { + CompoundTag block = (CompoundTag) value; + CompoundTag nbt = block.getCompoundTag("nbt"); + if (nbt != null && "minecraft:jigsaw".equals(nbt.getString("id"))) { + finalStates.add(nbt.getString("final_state")); + } + } + assertTrue(finalStates.contains("minecraft:air")); + assertTrue(finalStates.contains("minecraft:structure_void")); + } + + @Test + public void rejectsTilePayloadWithoutPublishingPartialOutput() { + TestGraph graph = terminalGraph(); + graph.objects.get("objects/start").setUnsignedTile(0, 0, 0, mock(TileData.class)); + Path output = temporaryFolder.getRoot().toPath().resolve("rejected-pack"); + + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export( + request(graph, output).build()); + + assertEquals(VanillaJigsawExportResult.Status.REJECTED, result.status()); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_TILE_DATA)); + assertFalse(Files.exists(output)); + assertTrue(result.resources().isEmpty()); + } + + @Test + public void rejectsStrictBranchFailurePolicyAsNonPortableGraphMetadata() { + TestGraph graph = terminalGraph(); + graph.structure.setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY); + Path output = temporaryFolder.getRoot().toPath().resolve("strict-policy-pack"); + + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export( + request(graph, output).build()); + + assertEquals(VanillaJigsawExportResult.Status.REJECTED, result.status()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> + diagnostic.code() == VanillaJigsawExportDiagnostic.Code.GRAPH_VALIDATION + && "NON_PORTABLE_METADATA".equals(diagnostic.resource()))); + assertFalse(Files.exists(output)); + } + + @Test + public void existingOutputIsPreservedUnlessReplacementIsExplicit() throws Exception { + TestGraph graph = terminalGraph(); + Path output = temporaryFolder.newFolder("existing-pack").toPath(); + Path sentinel = output.resolve("owner-data.txt"); + Files.writeString(sentinel, "preserve", StandardCharsets.UTF_8); + + VanillaJigsawExportResult rejected = new VanillaJigsawDatapackExporter().export( + request(graph, output).build()); + + assertEquals(VanillaJigsawExportResult.Status.REJECTED, rejected.status()); + assertTrue(hasCode(rejected, VanillaJigsawExportDiagnostic.Code.OUTPUT_EXISTS)); + assertEquals("preserve", Files.readString(sentinel, StandardCharsets.UTF_8)); + + VanillaJigsawExportResult replaced = new VanillaJigsawDatapackExporter().export( + request(graph, output).replaceExisting(true).build()); + + assertTrue(replaced.diagnostics().toString(), replaced.isSuccess()); + assertFalse(Files.exists(sentinel)); + assertTrue(Files.isRegularFile(output.resolve("pack.mcmeta"))); + } + + @Test + public void reportsEveryMaterialVanillaCompatibilityGateTogether() { + TestGraph graph = connectorGraph(); + graph.structure.setCompatibility(IrisJigsawCompatibility.IRIS_EXTENDED); + graph.structure.setPlaceMode(ObjectPlaceMode.CENTER_HEIGHT); + graph.structure.setMaxDepth(21); + graph.structure.setMaxSizeChunks(9); + graph.structure.getEdit().add(new IrisObjectReplace()); + graph.structure.getLoot().add("loot/test"); + graph.structure.getThemeSets().add(new IrisJigsawThemeSet("variant-1", 1)); + graph.structure.setRequireCaps(true); + graph.pieces.get("pieces/start") + .setRotatable(false) + .setCollidable(false) + .setRules(new IrisJigsawPieceRules().setTerminal(true)); + graph.pieces.get("pieces/start").getThemes().add("variant-1"); + graph.pieces.get("pieces/start").getConnectors().getFirst() + .setChannel("iris-only") + .setFinalState("minecraft:air"); + graph.pools.get("pools/start").setMandatoryFallback(true) + .getPieces().getFirst().setWeight(151).setChance(0.5D); + Path output = temporaryFolder.getRoot().toPath().resolve("incompatible-pack"); + + VanillaJigsawExportResult result = new VanillaJigsawDatapackExporter().export( + request(graph, output).build()); + + assertEquals(VanillaJigsawExportResult.Status.REJECTED, result.status()); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_COMPATIBILITY)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_PLACE_MODE)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_EDIT)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_LOOT)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_THEME_METADATA)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_CHANCE)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_PIECE_RULES)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_REQUIRED_CAPS)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DEPTH)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.INVALID_MAX_DISTANCE)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_FIXED_ROTATION)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_NON_COLLIDABLE_PIECE)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.UNSUPPORTED_CHANNEL)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.INVALID_POOL_WEIGHT)); + assertTrue(hasCode(result, VanillaJigsawExportDiagnostic.Code.INVALID_CONNECTOR_FINAL_STATE)); + assertFalse(Files.exists(output)); + } + + private VanillaJigsawExportRequest.Builder request(TestGraph graph, Path output) { + VanillaJigsawExportSource source = VanillaJigsawExportSource.forStructure( + "test_structure", + graph.structure, + graph); + return VanillaJigsawExportRequest.builder(source, output); + } + + private static TestGraph connectorGraph() { + TestGraph graph = new TestGraph(); + IrisObject object = new IrisObject(3, 3, 3); + object.setUnsigned(1, 1, 0, stone); + IrisJigsawConnector connector = new IrisJigsawConnector() + .setPosition(new IrisPosition(1, 1, 0)) + .setDirection(IrisDirection.NORTH_NEGATIVE_Z) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("pools/terminal") + .setName("studio:source") + .setTargetName("studio:door") + .setFinalState("minecraft:stone") + .setSelectionPriority(-3) + .setPlacementPriority(-2); + graph.objects.put("objects/start", object); + graph.pieces.put("pieces/start", piece("objects/start", connector)); + graph.pools.put("pools/start", pool(entry("pieces/start", 1))); + graph.pools.put("pools/terminal", pool(emptyEntry(1))); + graph.structure = structure("pools/start"); + return graph; + } + + private static TestGraph terminalGraph() { + TestGraph graph = new TestGraph(); + IrisObject object = new IrisObject(3, 3, 3); + object.setUnsigned(1, 1, 1, stone); + graph.objects.put("objects/start", object); + graph.pieces.put("pieces/start", piece("objects/start")); + graph.pools.put("pools/start", pool(entry("pieces/start", 1))); + graph.structure = structure("pools/start"); + return graph; + } + + private static IrisStructure structure(String startPool) { + IrisStructure structure = new IrisStructure(); + structure.setLoadKey("test_structure"); + structure.setStartPool(startPool); + structure.setMaxDepth(4); + structure.setMaxSizeChunks(4); + structure.setCompatibility(IrisJigsawCompatibility.VANILLA_PORTABLE); + structure.setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH); + return structure; + } + + private static IrisJigsawPiece piece(String object, IrisJigsawConnector... connectors) { + return new IrisJigsawPiece() + .setObject(object) + .setConnectors(new KList<>(connectors)) + .setRotatable(true); + } + + private static IrisJigsawPool pool(IrisJigsawPieceEntry... entries) { + return new IrisJigsawPool().setPieces(new KList<>(entries)); + } + + private static IrisJigsawPieceEntry entry(String piece, int weight) { + return new IrisJigsawPieceEntry().setPiece(piece).setWeight(weight); + } + + private static IrisJigsawPieceEntry emptyEntry(int weight) { + return new IrisJigsawPieceEntry().setEmpty(true).setWeight(weight); + } + + private static PlatformBlockState blockState(String key) { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(key); + when(state.namespace()).thenReturn(key.substring(0, key.indexOf(':'))); + return state; + } + + private static JsonObject json(Path path) throws IOException { + return JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private static boolean hasCode( + VanillaJigsawExportResult result, + VanillaJigsawExportDiagnostic.Code code + ) { + return result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.code() == code); + } + + private static final class TestGraph implements StructureGraphResolver { + private final Map pools = new LinkedHashMap<>(); + private final Map pieces = new LinkedHashMap<>(); + private final Map objects = new LinkedHashMap<>(); + private IrisStructure structure; + + @Override + public IrisJigsawPool loadPool(String key) { + return pools.get(key); + } + + @Override + public IrisJigsawPiece loadPiece(String key) { + return pieces.get(key); + } + + @Override + public IrisObject loadObject(String key) { + return objects.get(key); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorDatapackPreparationTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorDatapackPreparationTest.java new file mode 100644 index 000000000..9bd157019 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisCreatorDatapackPreparationTest.java @@ -0,0 +1,20 @@ +package art.arcane.iris.core.tools; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisCreatorDatapackPreparationTest { + @Test + public void nonStudioCreationAlwaysInstallsDatapacks() { + assertTrue(IrisCreator.DatapackPreparation.INSTALL_IF_CHANGED.requiresInstall(false)); + assertTrue(IrisCreator.DatapackPreparation.INSTALL_IF_CHANGED.requiresInstall(true)); + } + + @Test + public void studioCreationReusesOnlyAReadyLoadedRuntimeWithMatchingInputs() { + assertTrue(IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY.requiresInstall(false)); + assertFalse(IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY.requiresInstall(true)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapBarrierTest.java b/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapBarrierTest.java new file mode 100644 index 000000000..c9399cbaf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapBarrierTest.java @@ -0,0 +1,134 @@ +package art.arcane.iris.engine; + +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class NativeStructureBootstrapBarrierTest { + @Test + public void immediateCloseWaitsForExactRingCompletion() throws Exception { + NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier(); + CompletableFuture rings = new CompletableFuture<>(); + barrier.start(() -> { + }, () -> rings); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future close = executor.submit(() -> barrier.await("close")); + assertThrows(TimeoutException.class, () -> close.get(50L, TimeUnit.MILLISECONDS)); + rings.complete(null); + close.get(1L, TimeUnit.SECONDS); + assertFalse(barrier.isActive()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void immediateHotloadWaitsForExactRingCompletion() throws Exception { + NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier(); + CompletableFuture rings = new CompletableFuture<>(); + barrier.start(() -> { + }, () -> rings); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future hotload = executor.submit(() -> barrier.await("hotload")); + assertThrows(TimeoutException.class, () -> hotload.get(50L, TimeUnit.MILLISECONDS)); + rings.complete(null); + hotload.get(1L, TimeUnit.SECONDS); + assertFalse(barrier.isActive()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void asynchronousRingFailureFailsTransitionOnceBeforeAllowingRetry() { + NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier(); + CompletableFuture rings = new CompletableFuture<>(); + barrier.start(() -> { + }, () -> rings); + rings.completeExceptionally(new IllegalStateException("ring failure")); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> barrier.await("complex hotload")); + + assertTrue(failure.getMessage().contains("before complex hotload")); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertFalse(barrier.isActive()); + barrier.await("complex hotload retry"); + } + + @Test + public void synchronousStarterFailurePermanentlyPoisonsLifecycleTransitions() { + NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier(); + IllegalStateException starterFailure = new IllegalStateException("partial scheduling"); + + assertThrows(IllegalStateException.class, () -> barrier.start( + () -> { + }, + () -> { + throw starterFailure; + })); + + assertTrue(barrier.isActive()); + assertTrue(barrier.isPoisoned()); + IllegalStateException first = assertThrows( + IllegalStateException.class, + () -> barrier.await("close")); + IllegalStateException second = assertThrows( + IllegalStateException.class, + () -> barrier.await("close retry")); + assertTrue(first.getCause() == starterFailure); + assertTrue(second.getCause() == starterFailure); + assertThrows(IllegalStateException.class, () -> barrier.start( + () -> { + }, + () -> CompletableFuture.completedFuture(null))); + } + + @Test + public void failedClaimDoesNotPoisonAnUnstartedBootstrap() { + NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier(); + + assertThrows(IllegalStateException.class, () -> barrier.start( + () -> { + throw new IllegalStateException("cancelled"); + }, + () -> CompletableFuture.completedFuture(null))); + + assertFalse(barrier.isActive()); + assertFalse(barrier.isPoisoned()); + barrier.start(() -> { + }, () -> CompletableFuture.completedFuture(null)); + assertFalse(barrier.isActive()); + } + + @Test + public void secondRingBootstrapCannotReplaceAnUnfinishedOne() { + NativeStructureBootstrapBarrier barrier = new NativeStructureBootstrapBarrier(); + CompletableFuture first = new CompletableFuture<>(); + barrier.start(() -> { + }, () -> first); + + assertThrows(IllegalStateException.class, + () -> barrier.start( + () -> { + }, + () -> CompletableFuture.completedFuture(null))); + assertTrue(barrier.isActive()); + first.complete(null); + assertFalse(barrier.isActive()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapLifecycleContractTest.java b/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapLifecycleContractTest.java new file mode 100644 index 000000000..b3a0ed6a9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/NativeStructureBootstrapLifecycleContractTest.java @@ -0,0 +1,106 @@ +package art.arcane.iris.engine; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class NativeStructureBootstrapLifecycleContractTest { + @Test + public void closeWaitsBeforeLifecycleMutationAndGenerationSeal() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")); + int closeStart = source.indexOf("void close()"); + int closeEnd = source.indexOf("void cleanupFailedConstruction", closeStart); + String close = source.substring(closeStart, closeEnd); + + assertBefore(close, + "engine.awaitNativeStructureBootstrap(\"close\")", + "engine.lifecycleState = LifecycleState.CLOSING"); + assertBefore(close, + "engine.awaitNativeStructureBootstrap(\"close\")", + "sealAndAwait(\"close\""); + } + + @Test + public void fullAndComplexHotloadWaitBeforeLifecycleMutationAndGenerationSeal() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineHotloader.java")); + int complexStart = source.indexOf("void hotloadComplex()"); + int fullStart = source.indexOf("void hotloadSilently()", complexStart); + String complex = source.substring(complexStart, fullStart); + int fullEnd = source.indexOf("private void broadcastStudioHotload", fullStart); + String full = source.substring(fullStart, fullEnd); + + assertBefore(complex, + "engine.awaitNativeStructureBootstrap(\"complex hotload\")", + "engine.lifecycleState = LifecycleState.HOTLOADING"); + assertBefore(complex, + "engine.awaitNativeStructureBootstrap(\"complex hotload\")", + "engine.sealForTransition(\"complex hotload\""); + assertBefore(full, + "engine.awaitNativeStructureBootstrap(\"hotload\")", + "engine.lifecycleState = LifecycleState.HOTLOADING"); + assertBefore(full, + "engine.awaitNativeStructureBootstrap(\"hotload\")", + "engine.sealForTransition(\"hotload\""); + } + + @Test + public void ringBootstrapStartsUnderTheSameLifecycleLock() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/IrisEngine.java")); + int start = source.indexOf("public CompletableFuture startNativeStructureBootstrap("); + int end = source.indexOf("void awaitNativeStructureBootstrap", start); + String method = source.substring(start, end); + + assertBefore(method, "synchronized (lifecycleLock)", + "nativeStructureBootstrapBarrier.start(claim, starter)"); + assertBefore(method, "requireRunning(\"prepare native structure placements\")", + "nativeStructureBootstrapBarrier.start(claim, starter)"); + assertBefore(method, "nativeStructureBootstrapBarrier.start(claim, starter)", + "activation.run()"); + } + + @Test + public void studioVolumeQueriesStayOutsideCachesUntilTheBootstrapGateReleases() throws IOException { + String engineSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/IrisEngine.java")); + int queryStart = engineSource.indexOf( + "public KList getNativeStructureVolumes("); + int queryEnd = engineSource.indexOf( + "public void setNativeStructureVolumeQueriesEnabled", queryStart); + String query = engineSource.substring(queryStart, queryEnd); + int setterEnd = engineSource.indexOf("public IrisEngineData getEngineData()", queryEnd); + String setter = engineSource.substring(queryEnd, setterEnd); + String generatorSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java")); + int releaseStart = generatorSource.indexOf("public void endStudioEntryBootstrap()"); + int releaseEnd = generatorSource.indexOf( + "public boolean isStudioEntryBootstrapActive()", releaseStart); + String release = generatorSource.substring(releaseStart, releaseEnd); + + assertTrue(engineSource.contains( + "new AtomicBoolean(!requiredMode.studio())")); + assertBefore(query, + "!nativeStructureVolumeQueriesEnabled.get()", + "nativeStructureVolumeMemo.volumes("); + assertBefore(setter, + "nativeStructureVolumeMemo.clear()", + "nativeStructureVolumeQueriesEnabled.set(true)"); + assertBefore(release, + "irisEngine.setNativeStructureVolumeQueriesEnabled(shouldGenerateNativeStructures(", + "studioEntryBootstrapActive.set(false)"); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } +} 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 2940d95d1..a07d078ab 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 @@ -31,6 +31,7 @@ import art.arcane.iris.engine.object.IrisObject; 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.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructureCarveShape; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.IrisStructureTerrain; @@ -276,7 +277,7 @@ public class IrisStructureLocatorContractTest { when(data.load(IrisJigsawPiece.class, "test:target-piece", false)).thenReturn(targetPiece); when(data.load(IrisObject.class, "test:start-object", false)).thenReturn(startObject); when(data.load(IrisObject.class, "test:target-object", false)).thenReturn(targetObject); - assertTrue(StructureGraphCatalog.compile(data, structure).isAssemblyViable()); + assertFalse(StructureGraphCatalog.compile(data, structure).isAssemblyViable()); IrisStructurePlacement placement = new IrisStructurePlacement() .setNativeSuppression(NativeStructureSuppression.REPLACE_SOURCE); @@ -574,6 +575,49 @@ public class IrisStructureLocatorContractTest { assertNull(IrisStructureLocator.resolveVerticalShift(clippedSurface, placement, 300, -64, 319)); } + @Test + public void caveAssemblyBoundsAlignToTheRequestedAnchorFace() { + KList pieces = new KList<>(); + pieces.add(piece(-2, 10, -2, 2, 14, 2)); + IrisStructurePlacement placement = new IrisStructurePlacement(); + + placement.setAnchor(IrisStructureAnchorMode.CAVE_FLOOR); + KList floor = IrisStructureLocator.alignCavePieces(pieces, placement, 40); + assertEquals(40, floor.getFirst().getMinY()); + assertEquals(44, floor.getFirst().getMaxY()); + + placement.setAnchor(IrisStructureAnchorMode.CAVE_CEILING); + KList ceiling = IrisStructureLocator.alignCavePieces(pieces, placement, 40); + assertEquals(36, ceiling.getFirst().getMinY()); + assertEquals(40, ceiling.getFirst().getMaxY()); + + placement.setAnchor(IrisStructureAnchorMode.CAVE_CENTER); + KList center = IrisStructureLocator.alignCavePieces(pieces, placement, 40); + assertEquals(38, center.getFirst().getMinY()); + assertEquals(42, center.getFirst().getMaxY()); + } + + @Test + public void caveSearchabilityReservesVerticalBoundaryCells() { + Engine engine = mock(Engine.class); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + IrisStructurePlacement placement = new IrisStructurePlacement() + .setDistribution(StructureDistribution.DENSITY) + .setDensity(1D) + .setAnchor(IrisStructureAnchorMode.CAVE_FLOOR) + .setMinHeight(319) + .setMaxHeight(319); + + assertFalse(IrisStructureLocator.isSearchableDensityPlacement(engine, placement)); + placement.setMinHeight(318).setMaxHeight(318); + assertTrue(IrisStructureLocator.isSearchableDensityPlacement(engine, placement)); + + when(engine.getHeight()).thenReturn(2); + placement.setMinHeight(-2032).setMaxHeight(2032); + assertFalse(IrisStructureLocator.isSearchableDensityPlacement(engine, placement)); + } + @Test public void undergroundAssemblyIsShiftedBelowEveryTerrainColumn() { Engine engine = mock(Engine.class); 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 2bcb322c4..45a201202 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 @@ -3,6 +3,7 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisNativeStructure; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrainMode; @@ -156,6 +157,13 @@ public class NativeStructurePlacementPlannerTest { assertInvalid(mixed); } + @Test + public void nativeBackendRejectsEveryExplicitIrisAnchorMode() { + assertUnsupportedAnchor(nativePlacement().setAnchor(IrisStructureAnchorMode.SURFACE)); + assertUnsupportedAnchor(nativePlacement().setAnchor(IrisStructureAnchorMode.HEIGHT_BAND)); + assertUnsupportedAnchor(nativePlacement().setAnchor(IrisStructureAnchorMode.CAVE_FLOOR)); + } + private IrisStructurePlacement nativePlacement() { IrisStructurePlacement placement = new IrisStructurePlacement() .setDistribution(StructureDistribution.DENSITY) @@ -199,4 +207,13 @@ public class NativeStructurePlacementPlannerTest { assertTrue(expected.getMessage().contains("exactly one backend")); } } + + private void assertUnsupportedAnchor(IrisStructurePlacement placement) { + try { + NativeStructurePlacementPlanner.validateBackend(placement); + fail("Expected native structure anchor rejection"); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage().contains("do not support the Iris anchor field")); + } + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerTest.java index 5d11430b8..163e94fe2 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerTest.java @@ -19,11 +19,17 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; +import art.arcane.iris.engine.framework.structure.StructureAssemblyStatus; import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; @@ -32,15 +38,15 @@ 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.collection.KList; import art.arcane.volmlib.util.math.RNG; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +import java.util.List; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -79,12 +85,14 @@ public class StructureAssemblerTest { fixture.object("start-object", new IrisObject(1, 1, 1)); fixture.object("candidate-object", new IrisObject(1, 1, 1)); - KList fixed = fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(11L)); + StructureAssemblyResult fixed = fixture.assembler( + structure("start", 1), 0, 0, 0).assemble(new RNG(11L)); - assertNull(fixed); + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, fixed.status()); candidatePiece.setRotatable(true); - KList rotatable = fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(11L)); + List rotatable = completedPieces( + fixture.assembler(structure("start", 1), 0, 0, 0), new RNG(11L)); assertEquals(2, rotatable.size()); assertSame(candidatePiece, rotatable.get(1).getPiece()); @@ -109,10 +117,114 @@ public class StructureAssemblerTest { fixture.object("incompatible-object", new IrisObject(1, 1, 1)); fixture.object("fallback-object", new IrisObject(1, 1, 1)); - KList placed = fixture.assembler(structure("start", 2), 0, 0, 0).assemble(new RNG(29L)); + List placed = completedPieces( + fixture.assembler(structure("start", 2), 0, 0, 0), new RNG(29L)); assertEquals(2, placed.size()); assertSame(fallbackPiece, placed.get(1).getPiece()); + + List vanillaPolicyPlaced = completedPieces( + fixture.assembler(structure("start", 2) + .setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH), + 0, 0, 0), new RNG(29L)); + + assertEquals(2, vanillaPolicyPlaced.size()); + assertSame(fallbackPiece, vanillaPolicyPlaced.get(1).getPiece()); + } + + @Test + public void branchTerminationPolicyEndsOnlyAnUnmatchedOptionalBranch() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "target", "start", "door"), + connector(IrisDirection.SOUTH_POSITIVE_Z, "sibling", "start", "hall")); + IrisJigsawPiece incompatiblePiece = piece("incompatible-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "wrong", "")); + IrisJigsawPiece siblingPiece = piece("sibling-object", false, + connector(IrisDirection.NORTH_NEGATIVE_Z, "", "hall", "")); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", pool("", "incompatible-piece")); + fixture.pool("sibling", pool("", "sibling-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("incompatible-piece", incompatiblePiece); + fixture.piece("sibling-piece", siblingPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("incompatible-object", new IrisObject(1, 1, 1)); + fixture.object("sibling-object", new IrisObject(1, 1, 1)); + + StructureAssemblyResult strict = fixture.assembler( + structure("start", 2), 0, 0, 0).assemble(new RNG(17L)); + StructureAssemblyResult terminating = fixture.assembler( + structure("start", 2) + .setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH), + 0, 0, 0).assemble(new RNG(17L)); + + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, strict.status()); + assertEquals(StructureAssemblyStatus.COMPLETE, terminating.status()); + assertEquals(2, terminating.pieces().size()); + assertSame(startPiece, terminating.pieces().getFirst().getPiece()); + assertSame(siblingPiece, terminating.pieces().get(1).getPiece()); + } + + @Test + public void requiredFallbackOverridesBranchTerminationPolicy() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "target", "start", "door")); + IrisJigsawPiece capPiece = piece("cap-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "unused")); + capPiece.setRules(new IrisJigsawPieceRules().setTerminal(true)); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", pool("caps")); + fixture.pool("caps", pool("", "cap-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("cap-piece", capPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("cap-object", new IrisObject(64, 1, 1)); + IrisStructure structure = structure("start", 2) + .setMaxSizeChunks(1) + .setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH); + + StructureAssemblyResult structureRequired = fixture.assembler( + structure.setRequireCaps(true), 0, 0, 0).assemble(new RNG(19L)); + structure.setRequireCaps(false); + fixture.pool("target", pool("caps").setMandatoryFallback(true)); + StructureAssemblyResult poolRequired = fixture.assembler( + structure, 0, 0, 0).assemble(new RNG(19L)); + + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, structureRequired.status()); + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, poolRequired.status()); + } + + @Test + public void explicitEmptyAndEmptyPrimaryPoolsCutOffDirectFallback() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "target", "start", "door")); + IrisJigsawPiece fallbackPiece = piece("fallback-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + IrisJigsawPool explicitEmpty = pool("fallback"); + explicitEmpty.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true).setWeight(1)); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", explicitEmpty); + fixture.pool("fallback", pool("", "fallback-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("fallback-piece", fallbackPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("fallback-object", new IrisObject(1, 1, 1)); + IrisStructure structure = structure("start", 2) + .setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH); + + StructureAssemblyResult explicit = fixture.assembler( + structure, 0, 0, 0).assemble(firstChoiceRng()); + fixture.pool("target", pool("fallback")); + StructureAssemblyResult zeroEntry = fixture.assembler( + structure, 0, 0, 0).assemble(firstChoiceRng()); + + assertEquals(StructureAssemblyStatus.COMPLETE, explicit.status()); + assertEquals(1, explicit.pieces().size()); + assertEquals(StructureAssemblyStatus.COMPLETE, zeroEntry.status()); + assertEquals(1, zeroEntry.pieces().size()); } @Test @@ -132,11 +244,245 @@ public class StructureAssemblerTest { fixture.object("start-object", new IrisObject(1, 1, 1)); fixture.object("target-object", new IrisObject(1, 1, 1)); - assertNull(fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(13L))); + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, + fixture.assembler(structure("start", 1), 0, 0, 0) + .assemble(new RNG(13L)).status()); target.setTop(IrisDirection.UP_POSITIVE_Y); - KList placed = fixture.assembler(structure("start", 1), 0, 0, 0) - .assemble(new RNG(13L)); + List placed = completedPieces( + fixture.assembler(structure("start", 1), 0, 0, 0), new RNG(13L)); + assertEquals(2, placed.size()); + } + + @Test + public void planarCanonicalSocketsPlaceEveryNeighborOnTheExactCellGrid() { + Fixture fixture = new Fixture(); + IrisJigsawConnector northSource = connector( + IrisDirection.NORTH_NEGATIVE_Z, "north", "start", "door") + .setPosition(new IrisPosition(8, 4, 0)); + IrisJigsawConnector eastSource = connector( + IrisDirection.EAST_POSITIVE_X, "east", "start", "door") + .setPosition(new IrisPosition(15, 4, 8)); + IrisJigsawConnector southSource = connector( + IrisDirection.SOUTH_POSITIVE_Z, "south", "start", "door") + .setPosition(new IrisPosition(8, 4, 15)); + IrisJigsawConnector westSource = connector( + IrisDirection.WEST_NEGATIVE_X, "west", "start", "door") + .setPosition(new IrisPosition(0, 4, 8)); + IrisJigsawPiece startPiece = piece( + "start-object", false, northSource, eastSource, southSource, westSource); + IrisJigsawPiece northPiece = piece("north-object", false, + connector(IrisDirection.SOUTH_POSITIVE_Z, "", "door", "") + .setPosition(new IrisPosition(8, 4, 15))); + IrisJigsawPiece eastPiece = piece("east-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "") + .setPosition(new IrisPosition(0, 4, 8))); + IrisJigsawPiece southPiece = piece("south-object", false, + connector(IrisDirection.NORTH_NEGATIVE_Z, "", "door", "") + .setPosition(new IrisPosition(8, 4, 0))); + IrisJigsawPiece westPiece = piece("west-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "", "door", "") + .setPosition(new IrisPosition(15, 4, 8))); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("north", pool("", "north-piece")); + fixture.pool("east", pool("", "east-piece")); + fixture.pool("south", pool("", "south-piece")); + fixture.pool("west", pool("", "west-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("north-piece", northPiece); + fixture.piece("east-piece", eastPiece); + fixture.piece("south-piece", southPiece); + fixture.piece("west-piece", westPiece); + fixture.object("start-object", new IrisObject(16, 8, 16)); + fixture.object("north-object", new IrisObject(16, 8, 16)); + fixture.object("east-object", new IrisObject(16, 8, 16)); + fixture.object("south-object", new IrisObject(16, 8, 16)); + fixture.object("west-object", new IrisObject(16, 8, 16)); + IrisStructure structure = structure("start", 1) + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(16, 8, 16)); + + List placed = completedPieces( + fixture.assembler(structure, 32, 64, -48), new RNG(127L)); + PlacedStructurePiece start = requirePlacedPiece(placed, startPiece); + PlacedStructurePiece north = requirePlacedPiece(placed, northPiece); + PlacedStructurePiece east = requirePlacedPiece(placed, eastPiece); + PlacedStructurePiece south = requirePlacedPiece(placed, southPiece); + PlacedStructurePiece west = requirePlacedPiece(placed, westPiece); + + assertEquals(5, placed.size()); + assertEquals(32, north.getX()); + assertEquals(64, north.getY()); + assertEquals(-64, north.getZ()); + assertEquals(48, east.getX()); + assertEquals(64, east.getY()); + assertEquals(-48, east.getZ()); + assertEquals(32, south.getX()); + assertEquals(64, south.getY()); + assertEquals(-32, south.getZ()); + assertEquals(16, west.getX()); + assertEquals(64, west.getY()); + assertEquals(-48, west.getZ()); + assertEquals(north.getMaxZ() + 1, start.getMinZ()); + assertEquals(start.getMaxX() + 1, east.getMinX()); + assertEquals(start.getMaxZ() + 1, south.getMinZ()); + assertEquals(west.getMaxX() + 1, start.getMinX()); + } + + @Test + public void selectionPriorityProcessesHigherConnectorsFirstAndPreservesStableTies() { + Fixture fixture = new Fixture(); + IrisJigsawConnector eastSource = connector( + IrisDirection.EAST_POSITIVE_X, "east", "start", "door").setSelectionPriority(-20); + IrisJigsawConnector westSource = connector( + IrisDirection.WEST_NEGATIVE_X, "west", "start", "door").setSelectionPriority(-10); + IrisJigsawPiece startPiece = piece("start-object", false, eastSource, westSource); + IrisJigsawPiece eastPiece = piece("east-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + IrisJigsawPiece westPiece = piece("west-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "", "door", "")); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("east", pool("", "east-piece")); + fixture.pool("west", pool("", "west-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("east-piece", eastPiece); + fixture.piece("west-piece", westPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("east-object", new IrisObject(1, 1, 1)); + fixture.object("west-object", new IrisObject(1, 1, 1)); + IrisStructure structure = structure("start", 1); + + List prioritized = completedPieces( + fixture.assembler(structure, 0, 0, 0), new RNG(101L)); + + assertEquals(3, prioritized.size()); + assertSame(westPiece, prioritized.get(1).getPiece()); + assertSame(eastPiece, prioritized.get(2).getPiece()); + + eastSource.setSelectionPriority(-10); + List tied = completedPieces( + fixture.assembler(structure, 0, 0, 0), new RNG(101L)); + + assertSame(eastPiece, tied.get(1).getPiece()); + assertSame(westPiece, tied.get(2).getPiece()); + } + + @Test + public void selectionPriorityChoosesHigherCandidateConnectorAndPreservesStableTies() { + Fixture fixture = new Fixture(); + IrisJigsawPiece startPiece = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "target", "start", "door")); + IrisJigsawConnector low = connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "") + .setPosition(new IrisPosition(0, 0, 0)) + .setSelectionPriority(-20); + IrisJigsawConnector high = connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "") + .setPosition(new IrisPosition(0, 0, 2)) + .setSelectionPriority(-10); + IrisJigsawPiece targetPiece = piece("target-object", false, low, high); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", pool("", "target-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("target-piece", targetPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("target-object", new IrisObject(1, 1, 3)); + IrisStructure structure = structure("start", 1); + + List prioritized = completedPieces( + fixture.assembler(structure, 0, 0, 0), new RNG(103L)); + + assertEquals(-1, prioritized.get(1).getZ()); + + low.setSelectionPriority(-10); + List tied = completedPieces( + fixture.assembler(structure, 0, 0, 0), new RNG(103L)); + + assertEquals(1, tied.get(1).getZ()); + } + + @Test + public void placementPrioritySchedulesChildExpansionAfterTheCurrentPieceAndPreservesStableTies() { + Fixture fixture = new Fixture(); + IrisJigsawConnector eastSource = connector( + IrisDirection.EAST_POSITIVE_X, "east", "start", "door").setPlacementPriority(-20); + IrisJigsawConnector westSource = connector( + IrisDirection.WEST_NEGATIVE_X, "west", "start", "door").setPlacementPriority(-10); + IrisJigsawPiece startPiece = piece("start-object", false, eastSource, westSource); + IrisJigsawPiece eastPiece = piece("east-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", ""), + connector(IrisDirection.EAST_POSITIVE_X, "east-leaf", "east", "leaf")); + IrisJigsawPiece westPiece = piece("west-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "", "door", ""), + connector(IrisDirection.WEST_NEGATIVE_X, "west-leaf", "west", "leaf")); + IrisJigsawPiece eastLeaf = piece("east-leaf-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "leaf", "")); + IrisJigsawPiece westLeaf = piece("west-leaf-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "", "leaf", "")); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("east", pool("", "east-piece")); + fixture.pool("west", pool("", "west-piece")); + fixture.pool("east-leaf", pool("", "east-leaf-piece")); + fixture.pool("west-leaf", pool("", "west-leaf-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("east-piece", eastPiece); + fixture.piece("west-piece", westPiece); + fixture.piece("east-leaf-piece", eastLeaf); + fixture.piece("west-leaf-piece", westLeaf); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("east-object", new IrisObject(1, 1, 1)); + fixture.object("west-object", new IrisObject(1, 1, 1)); + fixture.object("east-leaf-object", new IrisObject(1, 1, 1)); + fixture.object("west-leaf-object", new IrisObject(1, 1, 1)); + IrisStructure structure = structure("start", 2); + + List prioritized = completedPieces( + fixture.assembler(structure, 0, 0, 0), new RNG(109L)); + + assertEquals(5, prioritized.size()); + assertSame(eastPiece, prioritized.get(1).getPiece()); + assertSame(westPiece, prioritized.get(2).getPiece()); + assertSame(westLeaf, prioritized.get(3).getPiece()); + assertSame(eastLeaf, prioritized.get(4).getPiece()); + + westSource.setPlacementPriority(-20); + List tied = completedPieces( + fixture.assembler(structure, 0, 0, 0), new RNG(109L)); + + assertSame(eastLeaf, tied.get(3).getPiece()); + assertSame(westLeaf, tied.get(4).getPiece()); + } + + @Test + public void connectorChannelsMustMatchExactly() { + Fixture fixture = new Fixture(); + IrisJigsawConnector source = connector( + IrisDirection.EAST_POSITIVE_X, "target", "start", "door").setChannel("road"); + IrisJigsawConnector target = connector( + IrisDirection.WEST_NEGATIVE_X, "", "door", "").setChannel("room"); + IrisJigsawPiece startPiece = piece("start-object", false, source); + IrisJigsawPiece targetPiece = piece("target-object", false, target); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", pool("", "target-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("target-piece", targetPiece); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("target-object", new IrisObject(1, 1, 1)); + IrisStructure structure = structure("start", 1); + + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, + fixture.assembler(structure, 0, 0, 0).assemble(new RNG(107L)).status()); + + target.setChannel("Road"); + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, + fixture.assembler(structure, 0, 0, 0).assemble(new RNG(107L)).status()); + + target.setChannel(" road"); + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, + fixture.assembler(structure, 0, 0, 0).assemble(new RNG(107L)).status()); + + target.setChannel("road"); + List placed = completedPieces( + fixture.assembler(structure, 0, 0, 0), new RNG(107L)); + assertEquals(2, placed.size()); } @@ -168,7 +514,8 @@ public class StructureAssemblerTest { IrisStructure structure = structure("start", 1); structure.setMaxSizeChunks(1); - KList placed = fixture.assembler(structure, 0, 0, 0).assemble(new RNG(41L)); + List placed = completedPieces( + fixture.assembler(structure, 0, 0, 0), new RNG(41L)); assertEquals(3, placed.size()); assertSame(branchPiece, placed.get(1).getPiece()); @@ -191,7 +538,9 @@ public class StructureAssemblerTest { fixture.object("start-object", new IrisObject(1, 1, 1)); fixture.object("incompatible-object", new IrisObject(1, 1, 1)); - assertNull(fixture.assembler(structure("start", 2), 0, 0, 0).assemble(new RNG(31L))); + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, + fixture.assembler(structure("start", 2), 0, 0, 0) + .assemble(new RNG(31L)).status()); } @Test @@ -202,14 +551,14 @@ public class StructureAssemblerTest { fixture.piece("start-piece", startPiece); fixture.object("start-object", new IrisObject(4, 2, 6)); - PlacedStructurePiece placed = fixture.assembler(structure("start", 1), 10, 20, 30) - .assemble(new RNG(53L)).getFirst(); + PlacedStructurePiece placed = completedPieces( + fixture.assembler(structure("start", 1), 10, 20, 30), new RNG(53L)).getFirst(); assertBounds(placed, 8, 19, 27, 11, 20, 32); } @Test - public void rotatedEvenSizedBoundsPreserveAsymmetricCentering() { + public void spatialRotatedEvenSizedBoundsPreserveExistingAsymmetricCentering() { Fixture fixture = new Fixture(); IrisJigsawPiece startPiece = piece("start-object", true); fixture.pool("start", pool("", "start-piece")); @@ -217,9 +566,68 @@ public class StructureAssemblerTest { fixture.object("start-object", new IrisObject(4, 2, 6)); StructureAssembler assembler = fixture.assembler(structure("start", 1), 10, 20, 30); - assertBounds(assembler.assemble(rotationIndex(1)).getFirst(), 7, 19, 29, 12, 20, 32); - assertBounds(assembler.assemble(rotationIndex(2)).getFirst(), 9, 19, 28, 12, 20, 33); - assertBounds(assembler.assemble(rotationIndex(3)).getFirst(), 8, 19, 28, 13, 20, 31); + assertBounds(completedPieces(assembler, rotationIndex(1)).getFirst(), 7, 19, 29, 12, 20, 32); + assertBounds(completedPieces(assembler, rotationIndex(2)).getFirst(), 9, 19, 28, 12, 20, 33); + assertBounds(completedPieces(assembler, rotationIndex(3)).getFirst(), 8, 19, 28, 13, 20, 31); + } + + @Test + public void planarEvenRectangleQuarterTurnsKeepBoundsAndSocketsOnOneGrid() { + for (int quarterTurn = 0; quarterTurn < 4; quarterTurn++) { + Fixture fixture = new Fixture(); + IrisJigsawConnector source = connector( + IrisDirection.NORTH_NEGATIVE_Z, "target", "start", "door") + .setPosition(new IrisPosition(2, 1, 0)) + .setJoint(JigsawJoint.ALIGNED); + IrisJigsawConnector target = connector( + IrisDirection.SOUTH_POSITIVE_Z, "", "door", "unused") + .setPosition(new IrisPosition(2, 1, 5)) + .setJoint(JigsawJoint.ALIGNED); + IrisJigsawPiece startPiece = piece("start-object", true, source); + IrisJigsawPiece targetPiece = piece("target-object", true, target); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", pool("", "target-piece")); + fixture.piece("start-piece", startPiece); + fixture.piece("target-piece", targetPiece); + fixture.object("start-object", new IrisObject(4, 2, 6)); + fixture.object("target-object", new IrisObject(4, 2, 6)); + IrisStructure structure = structure("start", 1) + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(4, 2, 6)); + + List pieces = completedPieces( + fixture.assembler(structure, 10, 20, 30), rotationIndex(quarterTurn)); + PlacedStructurePiece start = requirePlacedPiece(pieces, startPiece); + PlacedStructurePiece neighbor = requirePlacedPiece(pieces, targetPiece); + + switch (quarterTurn) { + case 0 -> { + assertBounds(start, 8, 19, 27, 11, 20, 32); + assertEquals(start.getMinZ(), neighbor.getMaxZ() + 1); + assertEquals(10, start.getX()); + assertEquals(30, start.getZ()); + } + case 1 -> { + assertBounds(start, 7, 19, 28, 12, 20, 31); + assertEquals(start.getMinX(), neighbor.getMaxX() + 1); + assertEquals(10, start.getX()); + assertEquals(29, start.getZ()); + } + case 2 -> { + assertBounds(start, 8, 19, 27, 11, 20, 32); + assertEquals(start.getMaxZ() + 1, neighbor.getMinZ()); + assertEquals(9, start.getX()); + assertEquals(29, start.getZ()); + } + case 3 -> { + assertBounds(start, 7, 19, 28, 12, 20, 31); + assertEquals(start.getMaxX() + 1, neighbor.getMinX()); + assertEquals(9, start.getX()); + assertEquals(30, start.getZ()); + } + default -> throw new AssertionError("Unexpected planar quarter turn"); + } + } } @Test @@ -233,6 +641,65 @@ public class StructureAssemblerTest { assertFalse(adjacent.intersects(origin)); } + @Test + public void collisionRequiresBothOverlappingPiecesToBeCollidable() { + StructureAssemblyResult bothCollidable = overlappingPair(true, true); + StructureAssemblyResult placedScaffold = overlappingPair(false, true); + StructureAssemblyResult candidateScaffold = overlappingPair(true, false); + + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, bothCollidable.status()); + assertEquals(1, bothCollidable.pieces().size()); + assertEquals(StructureAssemblyStatus.COMPLETE, placedScaffold.status()); + assertEquals(2, placedScaffold.pieces().size()); + assertEquals(StructureAssemblyStatus.COMPLETE, candidateScaffold.status()); + assertEquals(2, candidateScaffold.pieces().size()); + } + + @Test + public void pillagerOutpostAirScaffoldAllowsTheWatchtowerToOverlapItsNativeBounds() { + Fixture fixture = new Fixture(); + IrisJigsawConnector baseEntrance = connector( + IrisDirection.NORTH_NEGATIVE_Z, + "towers", + "minecraft:entrance", + "minecraft:entrance") + .setPosition(new IrisPosition(8, 1, 14)) + .setJoint(JigsawJoint.ALIGNED); + IrisJigsawConnector towerEntrance = connector( + IrisDirection.SOUTH_POSITIVE_Z, + "terminal", + "minecraft:entrance", + "minecraft:entrance") + .setPosition(new IrisPosition(7, 1, 13)) + .setJoint(JigsawJoint.ALIGNED); + IrisJigsawPiece basePlate = piece("base-plate-object", true, baseEntrance); + IrisJigsawPiece watchtower = piece("watchtower-object", true, towerEntrance); + fixture.pool("start", pool("terminal", "base-plate")); + fixture.pool("towers", pool("terminal", "watchtower")); + fixture.piece("base-plate", basePlate); + fixture.piece("watchtower", watchtower); + fixture.object("base-plate-object", new IrisObject(16, 30, 16)); + fixture.object("watchtower-object", new IrisObject(15, 21, 15)); + IrisStructure structure = structure("start", 1); + + StructureAssemblyResult collidable = fixture.assembler(structure, 0, 0, 0) + .assemble(firstChoiceRng()); + basePlate.setCollidable(false); + List placed = completedPieces( + fixture.assembler(structure, 0, 0, 0), firstChoiceRng()); + PlacedStructurePiece placedBase = requirePlacedPiece(placed, basePlate); + PlacedStructurePiece placedTower = requirePlacedPiece(placed, watchtower); + + assertEquals(StructureAssemblyStatus.COMPLETE, collidable.status()); + assertEquals(1, collidable.pieces().size()); + assertEquals(2, placed.size()); + assertTrue(placedBase.intersects(placedTower)); + assertEquals(-8, placedBase.getMinX()); + assertEquals(7, placedBase.getMaxX()); + assertEquals(-7, placedTower.getMinX()); + assertEquals(7, placedTower.getMaxX()); + } + @Test public void maximumWeightsCannotOverflowRuntimeSelection() { Fixture fixture = new Fixture(); @@ -248,8 +715,8 @@ public class StructureAssemblerTest { fixture.object("second-object", new IrisObject(1, 1, 1)); for (long seed = 0; seed < 32; seed++) { - KList placed = fixture.assembler(structure("start", 1), 0, 0, 0) - .assemble(new RNG(seed)); + List placed = completedPieces( + fixture.assembler(structure("start", 1), 0, 0, 0), new RNG(seed)); assertEquals(1, placed.size()); } } @@ -343,8 +810,8 @@ public class StructureAssemblerTest { fixture.piece("start-piece", startPiece); fixture.object("start-object", new IrisObject(1, 1, 1)); - KList placed = fixture.assembler(structure("start", 2), 0, 0, 0) - .assemble(new RNG(7L)); + List placed = completedPieces( + fixture.assembler(structure("start", 2), 0, 0, 0), new RNG(7L)); assertEquals(1, placed.size()); assertSame(startPiece, placed.getFirst().getPiece()); @@ -357,10 +824,11 @@ public class StructureAssemblerTest { start.getPieces().add(new IrisJigsawPieceEntry().setEmpty(true).setWeight(1)); fixture.pool("start", start); - KList placed = fixture.assembler(structure("start", 1), 0, 0, 0) + StructureAssemblyResult result = fixture.assembler(structure("start", 1), 0, 0, 0) .assemble(new RNG(9L)); - assertTrue(placed.isEmpty()); + assertEquals(StructureAssemblyStatus.INTENTIONAL_EMPTY, result.status()); + assertTrue(result.pieces().isEmpty()); } @Test @@ -378,6 +846,250 @@ public class StructureAssemblerTest { () -> fixture.assembler(overflowing, 0, 0, 0).assemble(new RNG(1L))); } + @Test + public void oneThemeIsSelectedForTheWholeAssembly() { + Fixture fixture = new Fixture(); + IrisJigsawPiece first = piece("first-object", false); + first.getThemes().add("variant-1"); + IrisJigsawPiece second = piece("second-object", false); + second.getThemes().add("variant-2"); + fixture.pool("start", pool("", "first-piece", "second-piece")); + fixture.piece("first-piece", first); + fixture.piece("second-piece", second); + fixture.object("first-object", new IrisObject(1, 1, 1)); + fixture.object("second-object", new IrisObject(1, 1, 1)); + IrisStructure structure = structure("start", 1); + structure.getThemeSets().add(new IrisJigsawThemeSet("variant-1", 1)); + structure.getThemeSets().add(new IrisJigsawThemeSet("variant-2", 1)); + + StructureAssemblyResult result = fixture.assembler(structure, 0, 0, 0) + .assemble(firstChoiceRng()); + + assertEquals(StructureAssemblyStatus.COMPLETE, result.status()); + assertEquals("variant-1", result.selectedTheme()); + assertSame(first, result.pieces().getFirst().getPiece()); + } + + @Test + public void unmetMinimumPlacementsTakePriorityWithinTheirPool() { + Fixture fixture = new Fixture(); + IrisJigsawPiece start = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "branch", "start", "door")); + IrisJigsawPiece required = piece("required-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + required.setRules(new IrisJigsawPieceRules().setMinimumPlacements(1)); + IrisJigsawPiece common = piece("common-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + fixture.pool("start", pool("", "start-piece")); + IrisJigsawPool branch = new IrisJigsawPool(); + branch.getPieces().add(new IrisJigsawPieceEntry("common-piece", 100)); + branch.getPieces().add(new IrisJigsawPieceEntry("required-piece", 1)); + fixture.pool("branch", branch); + fixture.piece("start-piece", start); + fixture.piece("required-piece", required); + fixture.piece("common-piece", common); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("required-object", new IrisObject(1, 1, 1)); + fixture.object("common-object", new IrisObject(1, 1, 1)); + + StructureAssemblyResult result = fixture.assembler(structure("start", 2), 0, 0, 0) + .assemble(new RNG(61L)); + + assertEquals(StructureAssemblyStatus.COMPLETE, result.status()); + assertSame(required, result.pieces().get(1).getPiece()); + } + + @Test + public void terminalPiecesNeverEnqueueTheirRemainingConnectors() { + Fixture fixture = new Fixture(); + IrisJigsawPiece terminal = piece("terminal-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "child", "start", "door")); + terminal.setRules(new IrisJigsawPieceRules().setTerminal(true)); + IrisJigsawPiece child = piece("child-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + fixture.pool("start", pool("", "terminal-piece")); + fixture.pool("child", pool("", "child-piece")); + fixture.piece("terminal-piece", terminal); + fixture.piece("child-piece", child); + fixture.object("terminal-object", new IrisObject(1, 1, 1)); + fixture.object("child-object", new IrisObject(1, 1, 1)); + + StructureAssemblyResult result = fixture.assembler(structure("start", 2), 0, 0, 0) + .assemble(new RNG(67L)); + + assertEquals(StructureAssemblyStatus.COMPLETE, result.status()); + assertEquals(1, result.pieces().size()); + } + + @Test + public void requiredCapsResolveThroughACompatibleTerminalFallback() { + Fixture fixture = new Fixture(); + IrisJigsawPiece start = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "branch", "start", "door")); + IrisJigsawPiece incompatible = piece("incompatible-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "wrong", "")); + IrisJigsawPiece cap = piece("cap-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "", "door", "")); + cap.setRules(new IrisJigsawPieceRules().setTerminal(true)); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("branch", pool("caps", "incompatible-piece")); + fixture.pool("caps", pool("", "cap-piece")); + fixture.piece("start-piece", start); + fixture.piece("incompatible-piece", incompatible); + fixture.piece("cap-piece", cap); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("incompatible-object", new IrisObject(1, 1, 1)); + fixture.object("cap-object", new IrisObject(1, 1, 1)); + IrisStructure structure = structure("start", 2).setRequireCaps(true); + + StructureAssemblyResult result = fixture.assembler(structure, 0, 0, 0) + .assemble(new RNG(71L)); + + assertEquals(StructureAssemblyStatus.COMPLETE, result.status()); + assertEquals(2, result.pieces().size()); + assertSame(cap, result.pieces().get(1).getPiece()); + } + + @Test + public void compatibleOpenConnectorsCloseAgainstAnAlreadyPlacedNeighbor() { + Fixture fixture = new Fixture(); + IrisJigsawPiece start = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "east", "start-east", "door"), + connector(IrisDirection.SOUTH_POSITIVE_Z, "south", "start-south", "door")); + IrisJigsawPiece east = piece("east-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "terminal", "door", "unused"), + connector(IrisDirection.SOUTH_POSITIVE_Z, "diagonal", "east-south", "door")); + IrisJigsawPiece south = piece("south-object", false, + connector(IrisDirection.NORTH_NEGATIVE_Z, "terminal", "door", "unused"), + connector(IrisDirection.EAST_POSITIVE_X, "diagonal", "south-east", "door")); + IrisJigsawPiece diagonal = piece("diagonal-object", false, + connector(IrisDirection.NORTH_NEGATIVE_Z, "loop", "door", "unused"), + connector(IrisDirection.WEST_NEGATIVE_X, "loop", "door", "unused")); + IrisJigsawPiece cap = piece("cap-object", true, + connector(IrisDirection.WEST_NEGATIVE_X, "terminal", "door", "unused"), + connector(IrisDirection.WEST_NEGATIVE_X, "terminal", "unused", "unused")); + cap.setRules(new IrisJigsawPieceRules().setTerminal(true)); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("east", pool("caps", "east-piece")); + fixture.pool("south", pool("caps", "south-piece")); + fixture.pool("diagonal", pool("caps", "diagonal-piece")); + fixture.pool("loop", pool("caps")); + fixture.pool("caps", pool("", "cap-piece")); + fixture.piece("start-piece", start); + fixture.piece("east-piece", east); + fixture.piece("south-piece", south); + fixture.piece("diagonal-piece", diagonal); + fixture.piece("cap-piece", cap); + fixture.object("start-object", new IrisObject(1, 1, 1)); + fixture.object("east-object", new IrisObject(1, 1, 1)); + fixture.object("south-object", new IrisObject(1, 1, 1)); + fixture.object("diagonal-object", new IrisObject(1, 1, 1)); + fixture.object("cap-object", new IrisObject(1, 1, 1)); + IrisStructure structure = structure("start", 2).setRequireCaps(true); + + List placed = completedPieces( + fixture.assembler(structure, 0, 0, 0), firstChoiceRng()); + + assertEquals(4, placed.size()); + assertFalse(placed.stream().anyMatch(piece -> piece.getPiece() == cap)); + } + + @Test + public void planarRequiredCapsRejectASecondOpenReservationForTheSameEmptyCell() { + Fixture fixture = new Fixture(); + IrisJigsawPiece start = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "east", "start-east", "door") + .setPosition(new IrisPosition(2, 0, 1)), + connector(IrisDirection.SOUTH_POSITIVE_Z, "south", "start-south", "door") + .setPosition(new IrisPosition(1, 0, 2))); + IrisJigsawPiece east = piece("east-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "terminal", "door", "unused") + .setPosition(new IrisPosition(0, 0, 1)), + connector(IrisDirection.SOUTH_POSITIVE_Z, "grow", "east-south", "door") + .setPosition(new IrisPosition(1, 0, 2))); + IrisJigsawPiece conflicting = piece("conflicting-object", false, + connector(IrisDirection.NORTH_NEGATIVE_Z, "terminal", "door", "unused") + .setPosition(new IrisPosition(1, 0, 0)), + connector(IrisDirection.EAST_POSITIVE_X, "grow", "south-east", "door") + .setPosition(new IrisPosition(2, 0, 1))); + IrisJigsawPiece compatible = piece("compatible-object", false, + connector(IrisDirection.NORTH_NEGATIVE_Z, "terminal", "door", "unused") + .setPosition(new IrisPosition(1, 0, 0))); + IrisJigsawPiece cap = piece("cap-object", true, + connector(IrisDirection.NORTH_NEGATIVE_Z, "terminal", "door", "unused") + .setPosition(new IrisPosition(1, 0, 0))); + cap.setRules(new IrisJigsawPieceRules().setTerminal(true)); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("east", pool("caps", "east-piece")); + fixture.pool("south", pool("caps", "conflicting-piece", "compatible-piece")); + fixture.pool("grow", pool("caps")); + fixture.pool("caps", pool("", "cap-piece")); + fixture.piece("start-piece", start); + fixture.piece("east-piece", east); + fixture.piece("conflicting-piece", conflicting); + fixture.piece("compatible-piece", compatible); + fixture.piece("cap-piece", cap); + fixture.object("start-object", new IrisObject(3, 1, 3)); + fixture.object("east-object", new IrisObject(3, 1, 3)); + fixture.object("conflicting-object", new IrisObject(3, 1, 3)); + fixture.object("compatible-object", new IrisObject(3, 1, 3)); + fixture.object("cap-object", new IrisObject(3, 1, 3)); + IrisStructure structure = structure("start", 2) + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(3, 1, 3)) + .setRequireCaps(true); + + List placed = completedPieces( + fixture.assembler(structure, 0, 0, 0), firstChoiceRng()); + + assertEquals(4, placed.size()); + assertFalse(placed.stream().anyMatch(piece -> piece.getPiece() == conflicting)); + assertTrue(placed.stream().anyMatch(piece -> piece.getPiece() == compatible)); + assertTrue(placed.stream().anyMatch(piece -> piece.getPiece() == cap)); + } + + @Test + public void startChanceCanIntentionallyProduceNoStructure() { + Fixture fixture = new Fixture(); + IrisJigsawPiece start = piece("start-object", false); + IrisJigsawPool startPool = pool("", "start-piece"); + startPool.getPieces().getFirst().setChance(0D); + fixture.pool("start", startPool); + fixture.piece("start-piece", start); + fixture.object("start-object", new IrisObject(1, 1, 1)); + + StructureAssemblyResult result = fixture.assembler(structure("start", 1), 0, 0, 0) + .assemble(new RNG(73L)); + + assertEquals(StructureAssemblyStatus.INTENTIONAL_EMPTY, result.status()); + assertTrue(result.pieces().isEmpty()); + } + + @Test + public void branchingGeometryReturnsATypedHardCapFailure() { + Fixture fixture = new Fixture(); + IrisJigsawConnector[] connectors = { + connector(IrisDirection.NORTH_NEGATIVE_Z, "branch", "door", "door"), + connector(IrisDirection.EAST_POSITIVE_X, "branch", "door", "door"), + connector(IrisDirection.SOUTH_POSITIVE_Z, "branch", "door", "door"), + connector(IrisDirection.WEST_NEGATIVE_X, "branch", "door", "door"), + connector(IrisDirection.UP_POSITIVE_Y, "branch", "door", "door"), + connector(IrisDirection.DOWN_NEGATIVE_Y, "branch", "door", "door") + }; + IrisJigsawPiece node = piece("node-object", false, connectors); + fixture.pool("start", pool("", "node-piece")); + fixture.pool("branch", pool("terminal", "node-piece")); + fixture.piece("node-piece", node); + fixture.object("node-object", new IrisObject(1, 1, 1)); + + StructureAssemblyResult result = fixture.assembler(structure("start", 10), 0, 0, 0) + .assemble(new RNG(83L)); + + assertEquals(StructureAssemblyStatus.HARD_CAP, result.status()); + assertEquals(512, result.pieces().size()); + assertFalse(result.hasOutput()); + } + private static IrisJigsawConnector connector(IrisDirection direction, String pool, String name, String targetName) { return new IrisJigsawConnector() .setPosition(new IrisPosition()) @@ -402,6 +1114,26 @@ public class StructureAssemblerTest { return pool; } + private static StructureAssemblyResult overlappingPair( + boolean placedCollidable, + boolean candidateCollidable + ) { + Fixture fixture = new Fixture(); + IrisJigsawPiece start = piece("start-object", false, + connector(IrisDirection.EAST_POSITIVE_X, "target", "start", "door")) + .setCollidable(placedCollidable); + IrisJigsawPiece candidate = piece("candidate-object", false, + connector(IrisDirection.WEST_NEGATIVE_X, "terminal", "door", "unused")) + .setCollidable(candidateCollidable); + fixture.pool("start", pool("", "start-piece")); + fixture.pool("target", pool("", "candidate-piece")); + fixture.piece("start-piece", start); + fixture.piece("candidate-piece", candidate); + fixture.object("start-object", new IrisObject(3, 3, 3)); + fixture.object("candidate-object", new IrisObject(3, 3, 3)); + return fixture.assembler(structure("start", 1), 0, 0, 0).assemble(new RNG(137L)); + } + private static IrisStructure structure(String startPool, int maxDepth) { return new IrisStructure().setStartPool(startPool).setMaxDepth(maxDepth).setMaxSizeChunks(8); } @@ -415,10 +1147,40 @@ public class StructureAssemblerTest { }; } + private static RNG firstChoiceRng() { + return new RNG(79L) { + @Override + public long nextLong(long bound) { + return 0L; + } + + @Override + public int i(int upperBound) { + return 0; + } + }; + } + private static PlacedStructurePiece bounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { return new PlacedStructurePiece(null, null, 0, 0, 0, null, minX, minY, minZ, maxX, maxY, maxZ); } + private static List completedPieces(StructureAssembler assembler, RNG rng) { + StructureAssemblyResult result = assembler.assemble(rng); + assertEquals(result.detail(), StructureAssemblyStatus.COMPLETE, result.status()); + return result.pieces(); + } + + private static PlacedStructurePiece requirePlacedPiece(List placed, + IrisJigsawPiece piece) { + for (PlacedStructurePiece placedPiece : placed) { + if (placedPiece.getPiece() == piece) { + return placedPiece; + } + } + throw new AssertionError("Expected jigsaw piece was not placed"); + } + private static void assertBounds(PlacedStructurePiece piece, int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { assertEquals(minX, piece.getMinX()); 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 new file mode 100644 index 000000000..7e0456a31 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java @@ -0,0 +1,195 @@ +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.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.function.IntPredicate; + +import static org.junit.Assert.assertArrayEquals; +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.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +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 StructureCaveAnchorResolverTest { + @Test + public void floorRequiresSolidBoundaryAndUpwardClearance() { + IntPredicate carved = carvedAt(10, 11, 12, 13); + + assertTrue(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_FLOOR, 10, 3)); + assertFalse(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_FLOOR, 11, 3)); + assertFalse(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_FLOOR, 10, 5)); + } + + @Test + public void ceilingRequiresSolidBoundaryAndDownwardClearance() { + IntPredicate carved = carvedAt(20, 21, 22, 23); + + assertTrue(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_CEILING, 23, 4)); + assertFalse(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_CEILING, 22, 4)); + } + + @Test + public void centerRequiresCarvedSpaceOnBothSides() { + IntPredicate carved = carvedAt(30, 31, 32, 33, 34); + + assertTrue(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_CENTER, 32, 5)); + assertFalse(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_CENTER, 31, 3)); + assertFalse(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_CENTER, 33, 3)); + assertFalse(StructureCaveAnchorResolver.matchesGeometry( + carvedAt(32, 33, 34), IrisStructureAnchorMode.CAVE_CENTER, 32, 3)); + } + + @Test + public void evenHeightCavernsHaveTwoValidCenterBlocks() { + IntPredicate carved = carvedAt(30, 31, 32, 33); + + assertTrue(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_CENTER, 31, 4)); + assertTrue(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_CENTER, 32, 4)); + assertFalse(StructureCaveAnchorResolver.matchesGeometry( + carved, IrisStructureAnchorMode.CAVE_CENTER, 30, 4)); + } + + @Test + public void anyRequiresClearanceAroundTheAlignedCenter() { + assertTrue(StructureCaveAnchorResolver.matchesGeometry( + carvedAt(39, 40, 41), IrisStructureAnchorMode.CAVE_ANY, 40, 3)); + assertTrue(StructureCaveAnchorResolver.matchesGeometry( + carvedAt(48, 49, 50, 51), IrisStructureAnchorMode.CAVE_ANY, 49, 4)); + assertFalse(StructureCaveAnchorResolver.matchesGeometry( + carvedAt(60, 61, 62), IrisStructureAnchorMode.CAVE_ANY, 60, 3)); + } + + @Test + public void deterministicColumnPlanDoesNotRepeatAttempts() { + int[] first = StructureCaveAnchorResolver.candidateColumnIndices(new RNG(928374L), 64); + int[] second = StructureCaveAnchorResolver.candidateColumnIndices(new RNG(928374L), 64); + Set unique = new HashSet<>(); + Arrays.stream(first).forEach(unique::add); + + assertArrayEquals(first, second); + assertEquals(64, first.length); + assertEquals(64, unique.size()); + assertTrue(unique.stream().allMatch(column -> column >= 0 && column < 256)); + assertEquals(64, StructureCaveAnchorResolver + .candidateColumnIndices(new RNG(1L), Integer.MAX_VALUE).length); + } + + @Test + public void worldHeightIsConvertedToMantleRelativeHeight() { + assertEquals(0, StructureCaveAnchorResolver.toMantleY(-64, -64)); + assertEquals(80, StructureCaveAnchorResolver.toMantleY(16, -64)); + assertEquals(383, StructureCaveAnchorResolver.toMantleY(319, -64)); + } + + @Test + public void biomeAllowlistMatchingIsTrimmedCaseInsensitiveAndNamespaceAware() { + assertTrue(StructureCaveAnchorResolver.matchesBiomeKey("crystal_caves", " Iris:CRYSTAL_CAVES ")); + assertTrue(StructureCaveAnchorResolver.matchesBiomeKey("iris:crystal_caves", "crystal_caves")); + assertFalse(StructureCaveAnchorResolver.matchesBiomeKey("iris:crystal_caves", "other:crystal_caves")); + assertFalse(StructureCaveAnchorResolver.matchesBiomeKey("crystal_caves", " ")); + } + + @Test + public void dryCaveUnderSubmergedSurfaceUsesAnchorCavernState() { + Engine engine = engineWithFloorAnchor(new MatterCavern(true, "", (byte) 0), 0); + + StructureCaveAnchorResolver.Anchor anchor = StructureCaveAnchorResolver.resolve( + engine, floorPlacement(false), 0, 0, new RNG(87234L)); + + assertNotNull(anchor); + assertEquals(-54, anchor.y()); + verify(engine, never()).getHeight(anyInt(), anyInt(), eq(true)); + } + + @Test + public void wetCaveUnderDrySurfaceIsRejectedAtTheAnchor() { + Engine engine = engineWithFloorAnchor(new MatterCavern(true, "", (byte) 1), 200); + + StructureCaveAnchorResolver.Anchor anchor = StructureCaveAnchorResolver.resolve( + engine, floorPlacement(false), 0, 0, new RNG(87234L)); + + assertNull(anchor); + verify(engine, never()).getHeight(anyInt(), anyInt(), eq(true)); + } + + @Test + public void dryPolicyHonorsExplicitLiquidsAndDefaultLavaHeight() { + MatterCavern air = new MatterCavern(true, "", (byte) 0); + MatterCavern water = new MatterCavern(true, "", (byte) 1); + MatterCavern lava = new MatterCavern(true, "", (byte) 2); + MatterCavern forcedAir = new MatterCavern(true, "", (byte) 3); + + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid(false, air, 8, 8)); + assertTrue(StructureCaveAnchorResolver.acceptsAnchorFluid(false, air, 9, 8)); + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid(false, water, 20, 8)); + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid(false, lava, 20, 8)); + assertTrue(StructureCaveAnchorResolver.acceptsAnchorFluid(false, forcedAir, 0, 8)); + assertTrue(StructureCaveAnchorResolver.acceptsAnchorFluid(true, water, 20, 8)); + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( + true, new MatterCavern(false, "", (byte) 0), 20, 8)); + } + + private static Engine engineWithFloorAnchor(MatterCavern anchorCavern, int surfaceHeight) { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + IrisDimension dimension = mock(IrisDimension.class); + MatterCavern carvedAir = new MatterCavern(true, "", (byte) 0); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(surfaceHeight); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getFluidHeight()).thenReturn(127); + when(dimension.getCaveLavaHeight()).thenReturn(8); + when(engine.getMantle().getMantle().get( + anyInt(), anyInt(), anyInt(), eq(MatterCavern.class))) + .thenAnswer(invocation -> { + int mantleY = invocation.getArgument(1); + if (mantleY == 10) { + return anchorCavern; + } + return mantleY == 11 || mantleY == 12 ? carvedAir : null; + }); + return engine; + } + + private static IrisStructurePlacement floorPlacement(boolean underwater) { + return new IrisStructurePlacement() + .setAnchor(IrisStructureAnchorMode.CAVE_FLOOR) + .setMinHeight(-54) + .setMaxHeight(-54) + .setCaveAnchorAttempts(1) + .setCaveAnchorScanStep(1) + .setCaveMinimumClearance(3) + .setUnderwater(underwater); + } + + private static IntPredicate carvedAt(Integer... values) { + Set carved = Set.of(values); + return carved::contains; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java index 93e1a73b7..e565de2e4 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.StructureDistribution; import org.junit.Test; @@ -255,6 +256,67 @@ public class StructurePlacementGridTest { assertNotEquals(firstSeed, secondSeed); } + @Test + public void legacyAnchorDoesNotMoveExistingPlacements() { + IrisStructurePlacement implicit = placement(StructureDistribution.RANDOM_SPREAD); + IrisStructurePlacement explicit = placement(StructureDistribution.RANDOM_SPREAD) + .setAnchor(IrisStructureAnchorMode.LEGACY); + + assertEquals(StructurePlacementGrid.placementIdentity(implicit), + StructurePlacementGrid.placementIdentity(explicit)); + assertEquals(StructurePlacementGrid.placementSalt(implicit), + StructurePlacementGrid.placementSalt(explicit)); + } + + @Test + public void caveAnchorConfigurationParticipatesInAnonymousIdentity() { + IrisStructurePlacement floor = placement(StructureDistribution.RANDOM_SPREAD) + .setAnchor(IrisStructureAnchorMode.CAVE_FLOOR); + IrisStructurePlacement ceiling = placement(StructureDistribution.RANDOM_SPREAD) + .setAnchor(IrisStructureAnchorMode.CAVE_CEILING); + + assertNotEquals(StructurePlacementGrid.placementIdentity(floor), + StructurePlacementGrid.placementIdentity(ceiling)); + assertNotEquals(StructurePlacementGrid.placementSalt(floor), + StructurePlacementGrid.placementSalt(ceiling)); + } + + @Test + public void unusedCaveSettingsDoNotMoveExplicitSurfacePlacements() { + IrisStructurePlacement first = placement(StructureDistribution.RANDOM_SPREAD) + .setAnchor(IrisStructureAnchorMode.SURFACE); + IrisStructurePlacement second = placement(StructureDistribution.RANDOM_SPREAD) + .setAnchor(IrisStructureAnchorMode.SURFACE) + .setCaveAnchorAttempts(64) + .setCaveAnchorScanStep(16) + .setCaveMinimumClearance(64); + second.getCaveBiomes().add("unused"); + + assertEquals(StructurePlacementGrid.placementIdentity(first), + StructurePlacementGrid.placementIdentity(second)); + assertEquals(StructurePlacementGrid.placementSalt(first), + StructurePlacementGrid.placementSalt(second)); + } + + @Test + public void caveBiomeAllowlistIdentityIgnoresOrderCaseWhitespaceAndDuplicates() { + IrisStructurePlacement first = placement(StructureDistribution.RANDOM_SPREAD) + .setAnchor(IrisStructureAnchorMode.CAVE_CENTER); + first.getCaveBiomes().add("crystal"); + first.getCaveBiomes().add("deep"); + IrisStructurePlacement second = placement(StructureDistribution.RANDOM_SPREAD) + .setAnchor(IrisStructureAnchorMode.CAVE_CENTER); + second.getCaveBiomes().add(" DEEP "); + second.getCaveBiomes().add("CRYSTAL"); + second.getCaveBiomes().add("crystal"); + second.getCaveBiomes().add(" "); + + assertEquals(StructurePlacementGrid.placementIdentity(first), + StructurePlacementGrid.placementIdentity(second)); + assertEquals(StructurePlacementGrid.placementSalt(first), + StructurePlacementGrid.placementSalt(second)); + } + @Test public void explicitPlacementIdentityKeepsRngStableAcrossSettingEdits() { IrisStructurePlacement first = placement(StructureDistribution.DENSITY); 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 new file mode 100644 index 000000000..b06a9f6c0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisStructureAnchorMode; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.volmlib.util.collection.KList; +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.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructurePlacementScopeTest { + @Test + public void caveBiomeContributesOnlyCaveAnchoredPlacements() { + IrisStructurePlacement surfacePlacement = new IrisStructurePlacement(); + IrisStructurePlacement cavePlacement = new IrisStructurePlacement() + .setAnchor(IrisStructureAnchorMode.CAVE_FLOOR); + IrisStructurePlacement invalidCaveScopePlacement = new IrisStructurePlacement() + .setAnchor(IrisStructureAnchorMode.SURFACE); + IrisStructurePlacement regionPlacement = new IrisStructurePlacement(); + IrisStructurePlacement dimensionPlacement = new IrisStructurePlacement(); + + IrisBiome surfaceBiome = new IrisBiome(); + surfaceBiome.getStructures().add(surfacePlacement); + IrisBiome caveBiome = new IrisBiome(); + caveBiome.getStructures().add(cavePlacement); + 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(); + when(engine.getDimension()).thenReturn(dimension); + when(complex.getTrueBiomeStream().get(8, 8)).thenReturn(surfaceBiome); + when(complex.getCaveBiomeStream().get(8, 8)).thenReturn(caveBiome); + when(complex.getRegionStream().get(8, 8)).thenReturn(region); + + KList placements = StructurePlacementScope.placementsAt(engine, 0, 0); + + assertEquals(4, placements.size()); + assertTrue(placements.contains(surfacePlacement)); + assertTrue(placements.contains(cavePlacement)); + assertTrue(placements.contains(regionPlacement)); + assertTrue(placements.contains(dimensionPlacement)); + assertFalse(placements.contains(invalidCaveScopePlacement)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelectionTest.java b/core/src/test/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelectionTest.java new file mode 100644 index 000000000..3ea36379d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/structure/JigsawPoolSelectionTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class JigsawPoolSelectionTest { + @Test + public void omittedAndSingleThemesDoNotAdvanceLegacyRngState() { + RNG omitted = new RNG(31L); + RNG omittedBaseline = new RNG(31L); + + assertEquals("", JigsawPoolSelection.selectTheme(new IrisStructure(), omitted)); + assertEquals(omittedBaseline.nextLong(), omitted.nextLong()); + + IrisStructure themed = new IrisStructure(); + themed.getThemeSets().add(new IrisJigsawThemeSet("variant-1", 1)); + RNG single = new RNG(47L); + RNG singleBaseline = new RNG(47L); + + assertEquals("variant-1", JigsawPoolSelection.selectTheme(themed, single)); + assertEquals(singleBaseline.nextLong(), single.nextLong()); + } + + @Test + public void defaultChanceDoesNotAdvanceLegacyRngState() { + RNG actual = new RNG(53L); + RNG baseline = new RNG(53L); + + assertTrue(JigsawPoolSelection.passesChance(new IrisJigsawPieceEntry(), actual)); + assertEquals(baseline.nextLong(), actual.nextLong()); + } + + @Test + public void chanceAndPieceRulesFormOneEligibilityGate() { + IrisJigsawPiece piece = new IrisJigsawPiece() + .setRules(new IrisJigsawPieceRules() + .setMinimumDepth(2) + .setMaximumDepth(4) + .setMaximumPlacements(2)); + piece.getThemes().add("variant-1"); + + assertFalse(JigsawPoolSelection.pieceEligible(piece, "variant-1", 1, 0)); + assertTrue(JigsawPoolSelection.pieceEligible(piece, "variant-1", 2, 0)); + assertTrue(JigsawPoolSelection.pieceEligible(piece, "variant-1", 4, 1)); + assertFalse(JigsawPoolSelection.pieceEligible(piece, "variant-1", 4, 2)); + assertFalse(JigsawPoolSelection.pieceEligible(piece, "variant-2", 2, 0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolverTest.java b/core/src/test/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolverTest.java new file mode 100644 index 000000000..75952216b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/structure/PlanarJigsawWorkcellResolverTest.java @@ -0,0 +1,75 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawWorkcell; +import art.arcane.iris.engine.object.IrisJigsawWorkcellArchetype; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +public class PlanarJigsawWorkcellResolverTest { + @Test + public void legacyCellSizeProducesSixEnabledRectangularWorkcells() { + IrisStructure structure = new IrisStructure().setCellSize(new IrisPosition(12, 7, 18)); + + Map workcells = + PlanarJigsawWorkcellResolver.resolve(structure); + + assertEquals(6, workcells.size()); + for (PlanarJigsawWorkcellResolver.ResolvedWorkcell workcell : workcells.values()) { + assertEquals(new IrisPosition(12, 7, 18), workcell.dimensions()); + assertEquals(true, workcell.enabled()); + } + } + + @Test + public void explicitWorkcellsRequireEveryUniqueArchetype() { + IrisStructure structure = new IrisStructure(); + structure.getPlanarWorkcells().add(new IrisJigsawWorkcell( + "", + IrisJigsawWorkcellArchetype.BLANK, + 16, + 16, + 16, + true)); + + IllegalArgumentException missing = assertThrows( + IllegalArgumentException.class, + () -> PlanarJigsawWorkcellResolver.resolve(structure)); + assertEquals("Planar jigsaw workcell END is missing", missing.getMessage()); + + structure.getPlanarWorkcells().add(new IrisJigsawWorkcell( + "", + IrisJigsawWorkcellArchetype.BLANK, + 8, + 8, + 8, + false)); + assertThrows(IllegalArgumentException.class, () -> PlanarJigsawWorkcellResolver.resolve(structure)); + } + + @Test + public void canonicalDimensionsSwapRectangularRotatedVariants() { + IrisJigsawPiece eastEnd = new IrisJigsawPiece(); + eastEnd.getConnectors().add(new IrisJigsawConnector().setDirection(IrisDirection.EAST_POSITIVE_X)); + + IrisPosition dimensions = PlanarJigsawWorkcellResolver.canonicalDimensions( + eastEnd, + new IrisObject(7, 5, 13)); + + assertEquals(new IrisPosition(13, 5, 7), dimensions); + assertEquals(IrisJigsawWorkcellArchetype.END, + IrisJigsawWorkcellArchetype.fromPiece(eastEnd)); + assertFalse(IrisJigsawWorkcellArchetype.fromPiece(eastEnd) + .sourceToCanonicalQuarterTurns(eastEnd) % 2 == 0); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResultTest.java b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResultTest.java new file mode 100644 index 000000000..603ecb5cf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureAssemblyResultTest.java @@ -0,0 +1,79 @@ +package art.arcane.iris.engine.framework.structure; + +import art.arcane.iris.engine.framework.PlacedStructurePiece; +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; +import static org.mockito.Mockito.mock; + +public class StructureAssemblyResultTest { + @Test + public void completeResultCarriesImmutableOutputAndNormalizedTheme() { + PlacedStructurePiece piece = mock(PlacedStructurePiece.class); + StructureAssemblyResult result = StructureAssemblyResult.complete( + List.of(piece), + " spruce "); + + assertEquals(StructureAssemblyStatus.COMPLETE, result.status()); + assertEquals(List.of(piece), result.pieces()); + assertEquals("spruce", result.selectedTheme()); + assertEquals("", result.detail()); + assertTrue(result.status().isComplete()); + assertFalse(result.status().isFailure()); + assertTrue(result.hasOutput()); + } + + @Test + public void intentionalEmptyIsCompleteWithoutOutput() { + StructureAssemblyResult result = StructureAssemblyResult.intentionalEmpty( + "desert", + "No start membership passed its chance gate"); + + assertEquals(StructureAssemblyStatus.INTENTIONAL_EMPTY, result.status()); + assertTrue(result.pieces().isEmpty()); + assertTrue(result.status().isComplete()); + assertFalse(result.hasOutput()); + } + + @Test + public void failedResultRetainsPartialPiecesForDiagnostics() { + PlacedStructurePiece piece = mock(PlacedStructurePiece.class); + StructureAssemblyResult result = StructureAssemblyResult.failed( + StructureAssemblyStatus.FAILED_UNCAPPED, + List.of(piece), + "spruce", + "Direct fallback could not place a terminal cap"); + + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, result.status()); + assertEquals(List.of(piece), result.pieces()); + assertTrue(result.status().isFailure()); + assertFalse(result.hasOutput()); + } + + @Test(expected = IllegalArgumentException.class) + public void completeResultRequiresOutput() { + StructureAssemblyResult.complete(List.of(), ""); + } + + @Test(expected = IllegalArgumentException.class) + public void failureRequiresDetail() { + StructureAssemblyResult.failed( + StructureAssemblyStatus.FAILED_RULES, + List.of(), + "", + " "); + } + + @Test(expected = IllegalArgumentException.class) + public void failureFactoryRejectsCompleteStatus() { + StructureAssemblyResult.failed( + StructureAssemblyStatus.COMPLETE, + List.of(), + "", + "invalid"); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilerTest.java index e74b60572..6339d0323 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureGraphCompilerTest.java @@ -1,10 +1,15 @@ package art.arcane.iris.engine.framework.structure; import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawBranchFailurePolicy; +import art.arcane.iris.engine.object.IrisJigsawCompatibility; import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawMode; import art.arcane.iris.engine.object.IrisJigsawPiece; import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPieceRules; import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisJigsawThemeSet; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisStructure; @@ -66,6 +71,8 @@ public class StructureGraphCompilerTest { assertEquals(first.getAssemblySamples(), second.getAssemblySamples()); for (StructureGraphAssemblySample sample : first.getAssemblySamples()) { assertEquals(List.of("pieces/start"), sample.outcome().pieceKeys()); + assertEquals(StructureAssemblyStatus.COMPLETE, sample.outcome().status()); + assertEquals("", sample.outcome().detail()); assertTrue(sample.outcome().isComplete()); } } @@ -124,6 +131,8 @@ public class StructureGraphCompilerTest { assertFalse(result.getGraph().getReachablePieces().contains("pieces/target")); assertFalse(result.isAssemblyViable()); for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, sample.outcome().status()); + assertTrue(sample.outcome().detail().contains("could not place a piece")); assertFalse(sample.outcome().isComplete()); } } @@ -143,6 +152,20 @@ public class StructureGraphCompilerTest { } } + @Test + public void connectorNamesUseTheRuntimeExactMatchingContract() { + InMemoryResolver resolver = connectorGraph(false, IrisDirection.SOUTH_POSITIVE_Z); + resolver.pieces.get("pieces/target").getConnectors().getFirst().setName(" door"); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertCodes(result, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + assertFalse(result.isAssemblyViable()); + } + @Test public void rejectsVerticalConnectorsThatYRotationCannotAlign() { InMemoryResolver resolver = connectorGraph(true, IrisDirection.UP_POSITIVE_Y); @@ -176,6 +199,383 @@ public class StructureGraphCompilerTest { assertTrue(rollable.isAssemblyViable()); } + @Test + public void channelsParticipateInMatchingAndPortableGraphsRejectThem() { + InMemoryResolver resolver = connectorGraph(false, IrisDirection.SOUTH_POSITIVE_Z); + IrisJigsawConnector source = resolver.pieces.get("pieces/start").getConnectors().getFirst(); + IrisJigsawConnector target = resolver.pieces.get("pieces/target").getConnectors().getFirst(); + source.setChannel("roads"); + target.setChannel("rooms"); + + StructureGraphCompilation mismatched = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertCodes(mismatched, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + + target.setChannel("Roads"); + StructureGraphCompilation mismatchedCase = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertCodes(mismatchedCase, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + + target.setChannel(" roads"); + StructureGraphCompilation mismatchedWhitespace = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertCodes(mismatchedWhitespace, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + + target.setChannel("roads"); + StructureGraphCompilation matched = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(matched.isAssemblyViable()); + + IrisStructure portable = structure("pools/start") + .setCompatibility(IrisJigsawCompatibility.VANILLA_PORTABLE) + .setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH); + StructureGraphCompilation rejectedPortable = StructureGraphCompiler.compile(portable, resolver); + + assertCodes(rejectedPortable, StructureGraphDiagnostic.Code.NON_PORTABLE_CONNECTOR); + assertTrue(rejectedPortable.hasErrors()); + } + + @Test + public void validatesPlanarCellSizeAndConnectorMetadata() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector connector = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/terminal", "source", "door") + .setFinalState("") + .setSelectionPriority(-1); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", piece("objects/start", connector)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/terminal", pool()); + IrisStructure structure = structure("pools/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(16, 0, 16)); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertCodes(result, + StructureGraphDiagnostic.Code.INVALID_PLANAR_CELL_SIZE, + StructureGraphDiagnostic.Code.INVALID_CONNECTOR_METADATA); + assertTrue(result.hasErrors()); + } + + @Test + public void acceptsRectangularPlanarCellSize() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/start", new IrisObject(16, 8, 8)); + resolver.pieces.put("pieces/start", piece("objects/start")); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + IrisStructure structure = structure("pools/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(16, 8, 8)); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertFalse(hasCode(result, StructureGraphDiagnostic.Code.INVALID_PLANAR_CELL_SIZE)); + assertTrue(result.isAssemblyViable()); + } + + @Test + public void allowsSmallerPlanarObjectsAndRejectsObjectsOutsideTheirWorkcell() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/start", new IrisObject(4, 3, 4)); + resolver.pieces.put("pieces/start", piece("objects/start")); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + IrisStructure structure = structure("pools/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(4, 3, 4)); + + StructureGraphCompilation valid = StructureGraphCompiler.compile(structure, resolver); + + assertFalse(hasCode(valid, StructureGraphDiagnostic.Code.INVALID_PLANAR_OBJECT_SIZE)); + + resolver.objects.put("objects/start", new IrisObject(4, 2, 4)); + StructureGraphCompilation smaller = StructureGraphCompiler.compile(structure, resolver); + + assertFalse(hasCode(smaller, StructureGraphDiagnostic.Code.INVALID_PLANAR_OBJECT_SIZE)); + + resolver.objects.put("objects/start", new IrisObject(5, 3, 4)); + StructureGraphCompilation oversized = StructureGraphCompiler.compile(structure, resolver); + + assertCodes(oversized, StructureGraphDiagnostic.Code.INVALID_PLANAR_OBJECT_SIZE); + assertTrue(oversized.getDiagnostics().stream().anyMatch( + diagnostic -> diagnostic.message().contains("does not fit"))); + } + + @Test + public void ignoresPlanarObjectSizeForPiecesThatCannotBeReached() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector source = connector( + new IrisPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/target", "source", "door"); + IrisJigsawConnector target = connector( + new IrisPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/target", "door", "unused"); + resolver.objects.put("objects/start", new IrisObject(4, 3, 4)); + resolver.objects.put("objects/target", new IrisObject(4, 2, 4)); + resolver.pieces.put("pieces/start", piece("objects/start", source).setRotatable(false)); + resolver.pieces.put("pieces/target", piece("objects/target", target).setRotatable(false)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/target", pool(entry("pieces/target", 1))); + IrisStructure structure = structure("pools/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(4, 3, 4)); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertFalse(result.getGraph().getReachablePieces().contains("pieces/target")); + assertFalse(hasCode(result, StructureGraphDiagnostic.Code.INVALID_PLANAR_OBJECT_SIZE)); + } + + @Test + public void acceptsAllCanonicalPlanarFaceCenterSockets() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector north = connector( + new IrisPosition(2, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/terminal", "north", "door"); + IrisJigsawConnector east = connector( + new IrisPosition(3, 1, 2), IrisDirection.EAST_POSITIVE_X, + "pools/terminal", "east", "door"); + IrisJigsawConnector south = connector( + new IrisPosition(2, 1, 3), IrisDirection.SOUTH_POSITIVE_Z, + "pools/terminal", "south", "door"); + IrisJigsawConnector west = connector( + new IrisPosition(0, 1, 2), IrisDirection.WEST_NEGATIVE_X, + "pools/terminal", "west", "door"); + resolver.objects.put("objects/start", new IrisObject(4, 3, 4)); + resolver.pieces.put("pieces/start", piece("objects/start", north, east, south, west)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/terminal", pool()); + IrisStructure structure = structure("pools/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(4, 3, 4)); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertTrue(result.getDiagnostics().isEmpty()); + assertTrue(result.isAssemblyViable()); + } + + @Test + public void rejectsOffsetVerticalAndTiltedPlanarConnectors() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector offset = connector( + new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, + "pools/terminal", "offset", "door"); + IrisJigsawConnector vertical = connector( + new IrisPosition(2, 1, 2), IrisDirection.UP_POSITIVE_Y, + "pools/terminal", "vertical", "door"); + IrisJigsawConnector tilted = connector( + new IrisPosition(2, 1, 3), IrisDirection.SOUTH_POSITIVE_Z, + "pools/terminal", "tilted", "door") + .setTop(IrisDirection.DOWN_NEGATIVE_Y); + resolver.objects.put("objects/start", new IrisObject(4, 3, 4)); + resolver.pieces.put("pieces/start", piece("objects/start", offset, vertical, tilted)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/terminal", pool()); + IrisStructure structure = structure("pools/start") + .setMode(IrisJigsawMode.PLANAR_JIGSAW) + .setCellSize(new IrisPosition(4, 3, 4)); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + List messages = new ArrayList<>(); + for (StructureGraphDiagnostic diagnostic : result.getDiagnostics()) { + if (diagnostic.code() == StructureGraphDiagnostic.Code.INVALID_PLANAR_CONNECTOR) { + messages.add(diagnostic.message()); + } + } + + assertEquals(3, messages.size()); + assertTrue(messages.stream().anyMatch(message -> message.contains("canonical NORTH_NEGATIVE_Z"))); + assertTrue(messages.stream().anyMatch(message -> message.contains("uses vertical direction"))); + assertTrue(messages.stream().anyMatch(message -> message.contains("keep connected cells on one Y level"))); + assertTrue(result.hasErrors()); + } + + @Test + public void spatialGraphsIgnoreThePlanarGeometryContract() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector vertical = connector( + new IrisPosition(1, 4, 1), IrisDirection.UP_POSITIVE_Y, + "pools/terminal", "vertical", "door") + .setTop(IrisDirection.DOWN_NEGATIVE_Y); + resolver.objects.put("objects/start", new IrisObject(2, 5, 3)); + resolver.pieces.put("pieces/start", piece("objects/start", vertical)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/terminal", pool()); + IrisStructure structure = structure("pools/start") + .setMode(IrisJigsawMode.SPATIAL_JIGSAW) + .setCellSize(new IrisPosition(16, 0, 8)); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertFalse(hasCode(result, StructureGraphDiagnostic.Code.INVALID_PLANAR_CELL_SIZE)); + assertFalse(hasCode(result, StructureGraphDiagnostic.Code.INVALID_PLANAR_OBJECT_SIZE)); + assertFalse(hasCode(result, StructureGraphDiagnostic.Code.INVALID_PLANAR_CONNECTOR)); + assertFalse(result.hasErrors()); + } + + @Test + public void deterministicSamplesHonorSelectionPriorityAndStableTies() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector east = connector( + new IrisPosition(2, 1, 1), IrisDirection.EAST_POSITIVE_X, + "pools/east", "source", "door").setSelectionPriority(-20); + IrisJigsawConnector west = connector( + new IrisPosition(0, 1, 1), IrisDirection.WEST_NEGATIVE_X, + "pools/west", "source", "door").setSelectionPriority(-10); + resolver.objects.put("objects/start", object()); + resolver.objects.put("objects/east", object()); + resolver.objects.put("objects/west", object()); + resolver.pieces.put("pieces/start", piece("objects/start", east, west)); + resolver.pieces.put("pieces/east", piece("objects/east", connector( + new IrisPosition(0, 1, 1), IrisDirection.WEST_NEGATIVE_X, + "pools/terminal", "door", "unused"))); + resolver.pieces.put("pieces/west", piece("objects/west", connector( + new IrisPosition(2, 1, 1), IrisDirection.EAST_POSITIVE_X, + "pools/terminal", "door", "unused"))); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/east", pool(entry("pieces/east", 1))); + resolver.pools.put("pools/west", pool(entry("pieces/west", 1))); + resolver.pools.put("pools/terminal", pool()); + IrisStructure structure = structure("pools/start"); + + StructureGraphCompilation prioritized = StructureGraphCompiler.compile(structure, resolver); + + for (StructureGraphAssemblySample sample : prioritized.getAssemblySamples()) { + assertEquals(List.of("pieces/start", "pieces/west", "pieces/east"), + sample.outcome().pieceKeys()); + } + + east.setSelectionPriority(-10); + StructureGraphCompilation tied = StructureGraphCompiler.compile(structure, resolver); + + for (StructureGraphAssemblySample sample : tied.getAssemblySamples()) { + assertEquals(List.of("pieces/start", "pieces/east", "pieces/west"), + sample.outcome().pieceKeys()); + } + } + + @Test + public void deterministicSamplesScheduleChildExpansionBySourcePlacementPriority() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector eastSource = connector( + new IrisPosition(2, 1, 1), IrisDirection.EAST_POSITIVE_X, + "pools/east", "source", "door").setPlacementPriority(-20); + IrisJigsawConnector westSource = connector( + new IrisPosition(0, 1, 1), IrisDirection.WEST_NEGATIVE_X, + "pools/west", "source", "door").setPlacementPriority(-10); + IrisJigsawConnector eastTarget = connector( + new IrisPosition(0, 1, 1), IrisDirection.WEST_NEGATIVE_X, + "pools/terminal", "door", "unused"); + IrisJigsawConnector eastBranch = connector( + new IrisPosition(2, 1, 1), IrisDirection.EAST_POSITIVE_X, + "pools/east-leaf", "east", "leaf"); + IrisJigsawConnector westTarget = connector( + new IrisPosition(2, 1, 1), IrisDirection.EAST_POSITIVE_X, + "pools/terminal", "door", "unused"); + IrisJigsawConnector westBranch = connector( + new IrisPosition(0, 1, 1), IrisDirection.WEST_NEGATIVE_X, + "pools/west-leaf", "west", "leaf"); + resolver.objects.put("objects/start", object()); + resolver.objects.put("objects/east", object()); + resolver.objects.put("objects/west", object()); + resolver.objects.put("objects/east-leaf", object()); + resolver.objects.put("objects/west-leaf", object()); + resolver.pieces.put("pieces/start", piece("objects/start", eastSource, westSource)); + resolver.pieces.put("pieces/east", piece("objects/east", eastTarget, eastBranch)); + resolver.pieces.put("pieces/west", piece("objects/west", westTarget, westBranch)); + resolver.pieces.put("pieces/east-leaf", piece("objects/east-leaf", connector( + new IrisPosition(0, 1, 1), IrisDirection.WEST_NEGATIVE_X, + "pools/terminal", "leaf", "unused"))); + resolver.pieces.put("pieces/west-leaf", piece("objects/west-leaf", connector( + new IrisPosition(2, 1, 1), IrisDirection.EAST_POSITIVE_X, + "pools/terminal", "leaf", "unused"))); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/east", pool(entry("pieces/east", 1))); + resolver.pools.put("pools/west", pool(entry("pieces/west", 1))); + resolver.pools.put("pools/east-leaf", pool(entry("pieces/east-leaf", 1))); + resolver.pools.put("pools/west-leaf", pool(entry("pieces/west-leaf", 1))); + resolver.pools.put("pools/terminal", pool()); + IrisStructure structure = structure("pools/start"); + + StructureGraphCompilation prioritized = StructureGraphCompiler.compile(structure, resolver); + + for (StructureGraphAssemblySample sample : prioritized.getAssemblySamples()) { + assertEquals(List.of( + "pieces/start", "pieces/east", "pieces/west", + "pieces/west-leaf", "pieces/east-leaf"), + sample.outcome().pieceKeys()); + } + + westSource.setPlacementPriority(-20); + StructureGraphCompilation tied = StructureGraphCompiler.compile(structure, resolver); + + for (StructureGraphAssemblySample sample : tied.getAssemblySamples()) { + assertEquals(List.of( + "pieces/start", "pieces/east", "pieces/west", + "pieces/east-leaf", "pieces/west-leaf"), + sample.outcome().pieceKeys()); + } + } + + @Test + public void deterministicSamplesUseSelectionPriorityForCandidateConnectors() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawConnector source = connector( + new IrisPosition(0, 1, 0), IrisDirection.EAST_POSITIVE_X, + "pools/target", "source", "door"); + IrisJigsawConnector low = connector( + new IrisPosition(0, 1, 0), IrisDirection.WEST_NEGATIVE_X, + "pools/low", "door", "leaf").setSelectionPriority(-20); + IrisJigsawConnector high = connector( + new IrisPosition(0, 1, 2), IrisDirection.WEST_NEGATIVE_X, + "pools/high", "door", "leaf").setSelectionPriority(-10); + resolver.objects.put("objects/start", new IrisObject(1, 3, 1)); + resolver.objects.put("objects/target", new IrisObject(1, 3, 3)); + resolver.objects.put("objects/low-leaf", new IrisObject(1, 3, 1)); + resolver.objects.put("objects/high-leaf", new IrisObject(1, 3, 1)); + resolver.pieces.put("pieces/start", piece("objects/start", source).setRotatable(false)); + resolver.pieces.put("pieces/target", piece("objects/target", low, high).setRotatable(false)); + resolver.pieces.put("pieces/low-leaf", piece("objects/low-leaf", connector( + new IrisPosition(0, 1, 0), IrisDirection.EAST_POSITIVE_X, + "pools/terminal", "leaf", "unused")).setRotatable(false)); + resolver.pieces.put("pieces/high-leaf", piece("objects/high-leaf", connector( + new IrisPosition(0, 1, 0), IrisDirection.EAST_POSITIVE_X, + "pools/terminal", "leaf", "unused")).setRotatable(false)); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + resolver.pools.put("pools/target", pool(entry("pieces/target", 1))); + resolver.pools.put("pools/low", pool(entry("pieces/low-leaf", 1))); + resolver.pools.put("pools/high", pool(entry("pieces/high-leaf", 1))); + resolver.pools.put("pools/terminal", pool()); + IrisStructure structure = structure("pools/start"); + + StructureGraphCompilation prioritized = StructureGraphCompiler.compile(structure, resolver); + + for (StructureGraphAssemblySample sample : prioritized.getAssemblySamples()) { + assertEquals(List.of("pieces/start", "pieces/target", "pieces/low-leaf"), + sample.outcome().pieceKeys()); + } + + low.setSelectionPriority(-10); + StructureGraphCompilation tied = StructureGraphCompiler.compile(structure, resolver); + + for (StructureGraphAssemblySample sample : tied.getAssemblySamples()) { + assertEquals(List.of("pieces/start", "pieces/target", "pieces/high-leaf"), + sample.outcome().pieceKeys()); + } + } + @Test public void deterministicSamplingUsesFallbackAtTheMaximumDepth() { InMemoryResolver resolver = new InMemoryResolver(); @@ -251,7 +651,8 @@ public class StructureGraphCompilerTest { assertFalse(result.isAssemblyViable()); for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { assertEquals(List.of("pieces/start"), sample.outcome().pieceKeys()); - assertEquals(1, sample.outcome().unresolvedConnectorCount()); + assertEquals(StructureAssemblyStatus.FAILED_UNCAPPED, sample.outcome().status()); + assertTrue(sample.outcome().detail().contains("could not place")); } } @@ -388,6 +789,24 @@ public class StructureGraphCompilerTest { } } + @Test + public void intentionalEmptyStartSamplesPreserveRuntimeStatusAndDetail() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.pools.put("pools/start", pool(emptyEntry(1))); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(result.isAssemblyViable()); + for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { + assertEquals(StructureAssemblyStatus.INTENTIONAL_EMPTY, sample.outcome().status()); + assertEquals( + "The selected start-pool membership intentionally produces no structure", + sample.outcome().detail()); + assertTrue(sample.outcome().intentionalEmpty()); + } + } + @Test public void rareEmptyStartPreventsGuaranteedReplacementOutputWithoutDependingOnSamples() { InMemoryResolver resolver = new InMemoryResolver(); @@ -439,7 +858,52 @@ public class StructureGraphCompilerTest { } @Test - public void defersTopologyPieceCapsToGeometryValidation() { + public void branchTerminationPolicyMakesUnmatchedOptionalBranchesViable() { + InMemoryResolver resolver = connectorGraph(false, IrisDirection.NORTH_NEGATIVE_Z); + IrisStructure strictStructure = structure("pools/start"); + IrisStructure terminatingStructure = structure("pools/start") + .setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH); + + StructureGraphCompilation strict = StructureGraphCompiler.compile(strictStructure, resolver); + StructureGraphCompilation terminating = StructureGraphCompiler.compile(terminatingStructure, resolver); + + assertCodes(strict, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + assertFalse(strict.isAssemblyViable()); + assertFalse(strict.guaranteesAssemblyOutput()); + assertCodes(terminating, + StructureGraphDiagnostic.Code.NO_COMPATIBLE_CONNECTOR, + StructureGraphDiagnostic.Code.UNREACHABLE_PIECE); + assertTrue(terminating.isAssemblyViable()); + assertTrue(terminating.guaranteesAssemblyOutput()); + for (StructureGraphAssemblySample sample : terminating.getAssemblySamples()) { + assertEquals(StructureAssemblyStatus.COMPLETE, sample.outcome().status()); + assertEquals(List.of("pieces/start"), sample.outcome().pieceKeys()); + } + } + + @Test + public void vanillaPortableGraphsRequireBranchTerminationPolicy() { + InMemoryResolver resolver = new InMemoryResolver(); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", piece("objects/start")); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + IrisStructure structure = structure("pools/start") + .setCompatibility(IrisJigsawCompatibility.VANILLA_PORTABLE); + + StructureGraphCompilation strict = StructureGraphCompiler.compile(structure, resolver); + structure.setBranchFailurePolicy(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH); + StructureGraphCompilation terminating = StructureGraphCompiler.compile(structure, resolver); + + assertCodes(strict, StructureGraphDiagnostic.Code.NON_PORTABLE_METADATA); + assertTrue(strict.hasErrors()); + assertFalse(hasCode(terminating, StructureGraphDiagnostic.Code.NON_PORTABLE_METADATA)); + assertTrue(terminating.isAssemblyViable()); + } + + @Test + public void authoritativeGeometrySamplingDoesNotReportTopologyOnlyPieceCaps() { InMemoryResolver resolver = new InMemoryResolver(); IrisJigsawConnector startSource = connector( new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, @@ -455,9 +919,9 @@ public class StructureGraphCompilerTest { "pools/recursive", "source", "door"); resolver.objects.put("objects/start", object()); resolver.objects.put("objects/recursive", object()); - resolver.pieces.put("pieces/start", piece("objects/start", startSource)); + resolver.pieces.put("pieces/start", piece("objects/start", startSource).setRotatable(false)); resolver.pieces.put("pieces/recursive", piece( - "objects/recursive", recursiveTarget, firstBranch, secondBranch)); + "objects/recursive", recursiveTarget, firstBranch, secondBranch).setRotatable(false)); resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); IrisJigsawPool recursive = pool(entry("pieces/recursive", 1)); recursive.setFallback("pools/empty"); @@ -465,24 +929,91 @@ public class StructureGraphCompilerTest { resolver.pools.put("pools/empty", pool()); IrisStructure structure = structure("pools/start"); structure.setMaxDepth(30); + structure.setMaxSizeChunks(8); StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); - assertCodes(result, StructureGraphDiagnostic.Code.ASSEMBLY_PIECE_CAP_REACHED); + assertFalse(hasCode(result, StructureGraphDiagnostic.Code.ASSEMBLY_PIECE_CAP_REACHED)); assertTrue(result.isAssemblyViable()); for (StructureGraphAssemblySample sample : result.getAssemblySamples()) { - assertTrue(sample.outcome().pieceCapReached()); - assertEquals(0, sample.outcome().unresolvedConnectorCount()); + assertFalse(sample.outcome().pieceCapReached()); + assertEquals(31, sample.outcome().pieceKeys().size()); + assertEquals(StructureAssemblyStatus.COMPLETE, sample.outcome().status()); } } + @Test + public void validatesChanceThemeAndRuleMetadataBeforeSampling() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawPiece start = piece("objects/start"); + start.getThemes().add("missing-theme"); + start.setRules(new IrisJigsawPieceRules().setMinimumDepth(4).setMaximumDepth(2)); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", start); + resolver.pools.put("pools/start", pool( + entry("pieces/start", 1).setChance(Double.NaN))); + IrisStructure structure = structure("pools/start"); + structure.getThemeSets().add(new IrisJigsawThemeSet("variant-1", 1)); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertCodes(result, + StructureGraphDiagnostic.Code.INVALID_CHANCE, + StructureGraphDiagnostic.Code.INVALID_THEME, + StructureGraphDiagnostic.Code.INVALID_PIECE_RULE); + assertTrue(result.getAssemblySamples().isEmpty()); + } + + @Test + public void rejectsADeclaredThemeWithoutAnEligibleStartPiece() { + InMemoryResolver resolver = new InMemoryResolver(); + IrisJigsawPiece start = piece("objects/start"); + start.getThemes().add("variant-1"); + resolver.objects.put("objects/start", object()); + resolver.pieces.put("pieces/start", start); + resolver.pools.put("pools/start", pool(entry("pieces/start", 1))); + IrisStructure structure = structure("pools/start"); + structure.getThemeSets().add(new IrisJigsawThemeSet("variant-1", 1)); + structure.getThemeSets().add(new IrisJigsawThemeSet("variant-2", 1)); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertCodes(result, StructureGraphDiagnostic.Code.UNSATISFIABLE_PIECE_RULE); + } + + @Test + public void terminalPiecesDoNotMakeTheirOutgoingPoolsReachable() { + InMemoryResolver resolver = connectorGraph(true, IrisDirection.SOUTH_POSITIVE_Z); + resolver.pieces.get("pieces/start").setRules( + new IrisJigsawPieceRules().setTerminal(true)); + + StructureGraphCompilation result = StructureGraphCompiler.compile( + structure("pools/start"), resolver); + + assertTrue(result.getGraph().getReachablePieces().contains("pieces/start")); + assertFalse(result.getGraph().getReachablePieces().contains("pieces/target")); + assertCodes(result, StructureGraphDiagnostic.Code.UNREACHABLE_POOL); + } + + @Test + public void requiredCapsNeedACompatibleTerminalInTheDirectFallback() { + InMemoryResolver resolver = connectorGraph(true, IrisDirection.SOUTH_POSITIVE_Z); + resolver.pools.get("pools/target").setFallback("pools/fallback"); + resolver.pools.put("pools/fallback", pool(entry("pieces/target", 1))); + IrisStructure structure = structure("pools/start").setRequireCaps(true); + + StructureGraphCompilation result = StructureGraphCompiler.compile(structure, resolver); + + assertCodes(result, StructureGraphDiagnostic.Code.NO_REQUIRED_TERMINAL); + } + private static InMemoryResolver connectorGraph(boolean rotatable, IrisDirection targetDirection) { InMemoryResolver resolver = new InMemoryResolver(); IrisJigsawConnector source = connector( new IrisPosition(1, 1, 0), IrisDirection.NORTH_NEGATIVE_Z, "pools/target", "source", "door"); IrisJigsawConnector target = connector( - new IrisPosition(1, 1, 0), targetDirection, + canonicalPosition(targetDirection), targetDirection, "pools/target", "door", "unrelated-target"); IrisJigsawPiece targetPiece = piece("objects/target", target); targetPiece.setRotatable(rotatable); @@ -510,6 +1041,17 @@ public class StructureGraphCompilerTest { return new IrisObject(3, 3, 3); } + private static IrisPosition canonicalPosition(IrisDirection direction) { + return switch (direction) { + case NORTH_NEGATIVE_Z -> new IrisPosition(1, 1, 0); + case EAST_POSITIVE_X -> new IrisPosition(2, 1, 1); + case SOUTH_POSITIVE_Z -> new IrisPosition(1, 1, 2); + case WEST_NEGATIVE_X -> new IrisPosition(0, 1, 1); + case UP_POSITIVE_Y -> new IrisPosition(1, 2, 1); + case DOWN_NEGATIVE_Y -> new IrisPosition(1, 0, 1); + }; + } + private static IrisJigsawPiece piece(String object, IrisJigsawConnector... connectors) { IrisJigsawPiece piece = new IrisJigsawPiece(); piece.setObject(object); @@ -553,6 +1095,15 @@ public class StructureGraphCompilerTest { } } + private static boolean hasCode(StructureGraphCompilation result, StructureGraphDiagnostic.Code code) { + for (StructureGraphDiagnostic diagnostic : result.getDiagnostics()) { + if (diagnostic.code() == code) { + return true; + } + } + return false; + } + private static final class InMemoryResolver implements StructureGraphResolver { private final Map pools = new LinkedHashMap<>(); private final Map pieces = new LinkedHashMap<>(); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompilerTest.java index 01d658c24..9e0146dab 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/structure/StructureResourceBundleGraphCompilerTest.java @@ -30,6 +30,7 @@ import java.io.IOException; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; public class StructureResourceBundleGraphCompilerTest { @Test @@ -74,7 +75,7 @@ public class StructureResourceBundleGraphCompilerTest { "{\"startPool\":\"start\",\"maxDepth\":1,\"maxSizeChunks\":1}") .build(); - assertThrows(IllegalArgumentException.class, + assertThrows(StructureGraphValidationException.class, () -> StructureResourceBundleGraphCompiler.requireViable(bundle)); } @@ -90,6 +91,88 @@ public class StructureResourceBundleGraphCompilerTest { StructureResourceBundleGraphCompiler.requireViable(bundle); } + @Test + public void sampledFailureReportsItsSeedStatusAndRuntimeDetailInsteadOfAWarning() throws IOException { + String sourceConnector = "{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"EAST_POSITIVE_X\",\"top\":\"UP_POSITIVE_Y\"," + + "\"pool\":\"target\",\"name\":\"source\",\"targetName\":\"door\"," + + "\"joint\":\"ROLLABLE\"}"; + StructureResourceBundle bundle = baseBundle() + .resource("objects/start.iob", object(1, 1, 1)) + .resource("objects/orphan.iob", object(1, 1, 1)) + .textResource("jigsaw-pieces/start.json", + "{\"object\":\"start\",\"connectors\":[" + sourceConnector + + "],\"rotatable\":false}") + .textResource("jigsaw-pieces/orphan.json", + "{\"object\":\"orphan\",\"connectors\":[],\"rotatable\":false}") + .textResource("jigsaw-pools/start.json", + "{\"pieces\":[{\"piece\":\"start\",\"weight\":1}]}") + .textResource("jigsaw-pools/target.json", + "{\"pieces\":[{\"piece\":\"orphan\",\"weight\":1}]}") + .textResource("structures/test.json", + "{\"startPool\":\"start\",\"maxDepth\":2,\"maxSizeChunks\":1}") + .build(); + + StructureGraphValidationException failure = assertThrows( + StructureGraphValidationException.class, + () -> StructureResourceBundleGraphCompiler.requireViable(bundle)); + + assertTrue(failure.getMessage(), failure.getMessage().contains( + "sampled assembly at seed 0 returned FAILED_UNCAPPED")); + assertTrue(failure.getMessage(), failure.getMessage().contains( + "Connector pool 'target' could not place a piece and has no direct fallback")); + } + + @Test + public void branchTerminationPolicyAcceptsTheSameUnmatchedOptionalBranch() throws IOException { + String sourceConnector = "{\"position\":{\"x\":0,\"y\":0,\"z\":0}," + + "\"direction\":\"EAST_POSITIVE_X\",\"top\":\"UP_POSITIVE_Y\"," + + "\"pool\":\"target\",\"name\":\"source\",\"targetName\":\"door\"," + + "\"joint\":\"ROLLABLE\"}"; + StructureResourceBundle bundle = baseBundle() + .resource("objects/start.iob", object(1, 1, 1)) + .resource("objects/orphan.iob", object(1, 1, 1)) + .textResource("jigsaw-pieces/start.json", + "{\"object\":\"start\",\"connectors\":[" + sourceConnector + + "],\"rotatable\":false}") + .textResource("jigsaw-pieces/orphan.json", + "{\"object\":\"orphan\",\"connectors\":[],\"rotatable\":false}") + .textResource("jigsaw-pools/start.json", + "{\"pieces\":[{\"piece\":\"start\",\"weight\":1}]}") + .textResource("jigsaw-pools/target.json", + "{\"pieces\":[{\"piece\":\"orphan\",\"weight\":1}]}") + .textResource("structures/test.json", + "{\"startPool\":\"start\",\"maxDepth\":2,\"maxSizeChunks\":1," + + "\"branchFailurePolicy\":\"TERMINATE_BRANCH\"}") + .build(); + + StructureResourceBundleGraphCompiler.requireViable(bundle); + + StructureGraphCompilation compilation = StructureResourceBundleGraphCompiler.compile(bundle).getFirst(); + assertTrue(compilation.isAssemblyViable()); + assertTrue(compilation.getAssemblySamples().stream() + .allMatch(sample -> sample.outcome().status() == StructureAssemblyStatus.COMPLETE)); + } + + @Test + public void structuralErrorDiagnosticRemainsAuthoritative() { + StructureResourceBundle bundle = baseBundle() + .textResource("jigsaw-pieces/start.json", + "{\"object\":\"missing\",\"connectors\":[],\"rotatable\":false}") + .textResource("jigsaw-pools/start.json", + "{\"pieces\":[{\"piece\":\"start\",\"weight\":1}]}") + .textResource("structures/test.json", + "{\"startPool\":\"start\",\"maxDepth\":1,\"maxSizeChunks\":1}") + .build(); + + StructureGraphValidationException failure = assertThrows( + StructureGraphValidationException.class, + () -> StructureResourceBundleGraphCompiler.requireViable(bundle)); + + assertTrue(failure.getMessage(), failure.getMessage().contains( + "Jigsaw piece 'start' references missing object 'missing'")); + } + @Test public void truncatedObjectFrameIsRejectedBeforePublish() throws IOException { StructureResourceBundle bundle = baseBundle() @@ -102,7 +185,7 @@ public class StructureResourceBundleGraphCompilerTest { "{\"startPool\":\"start\",\"maxDepth\":1,\"maxSizeChunks\":1}") .build(); - assertThrows(IllegalArgumentException.class, + assertThrows(StructureGraphValidationException.class, () -> StructureResourceBundleGraphCompiler.requireViable(bundle)); } 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 new file mode 100644 index 000000000..4a0b90ec1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java @@ -0,0 +1,95 @@ +package art.arcane.iris.engine.mantle; + +import art.arcane.iris.core.link.Identifier; +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.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InOrder; + +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.inOrder; +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 EngineMantleCleanupTest { + @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 coveredCleanupRetainsDeferredMaterializationSlices() { + CleanupFixture fixture = cleanupFixture(); + doReturn(true).when(fixture.engineMantle()).isCovered(7, -4); + + fixture.engineMantle().cleanupChunk(7, -4); + + verifyCleanup(fixture.chunk()); + } + + @Test + public void forcedCleanupRetainsDeferredMaterializationSlices() { + CleanupFixture fixture = cleanupFixture(); + + fixture.engineMantle().forceCleanupChunk(7, -4); + + verifyCleanup(fixture.chunk()); + } + + @SuppressWarnings("unchecked") + private CleanupFixture cleanupFixture() { + EngineMantle engineMantle = mock(EngineMantle.class, CALLS_REAL_METHODS); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(engineMantle.getMantle()).thenReturn(mantle); + when(mantle.getChunk(7, -4)).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> { + Runnable cleanup = invocation.getArgument(1); + cleanup.run(); + return null; + }).when(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any()); + return new CleanupFixture(engineMantle, chunk); + } + + private void verifyCleanup(MantleChunk chunk) { + verify(chunk).deleteSlices(PlatformBlockState.class); + verify(chunk).deleteSlices(MatterCavern.class); + verify(chunk, never()).deleteSlices(TileWrapper.class); + verify(chunk, never()).deleteSlices(Identifier.class); + InOrder order = inOrder(chunk); + order.verify(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any()); + order.verify(chunk).release(); + } + + private record CleanupFixture(EngineMantle engineMantle, MantleChunk chunk) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawModelMetadataTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawModelMetadataTest.java new file mode 100644 index 000000000..26556f703 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawModelMetadataTest.java @@ -0,0 +1,93 @@ +package art.arcane.iris.engine.object; + +import com.google.gson.Gson; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisJigsawModelMetadataTest { + @Test + public void defaultsPreserveSpatialExtendedGraphsAndVanillaConnectorMetadata() { + IrisStructure structure = new IrisStructure(); + IrisJigsawConnector connector = new IrisJigsawConnector(); + IrisJigsawPiece piece = new IrisJigsawPiece(); + + assertEquals(IrisJigsawMode.SPATIAL_JIGSAW, structure.resolvedMode()); + assertEquals(IrisJigsawCompatibility.IRIS_EXTENDED, structure.resolvedCompatibility()); + assertEquals(IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY, + structure.resolvedBranchFailurePolicy()); + assertEquals(new IrisPosition(16, 16, 16), structure.getCellSize()); + assertEquals("", connector.getChannel()); + assertEquals("minecraft:air", connector.getFinalState()); + assertEquals(0, connector.getSelectionPriority()); + assertEquals(0, connector.getPlacementPriority()); + assertTrue(piece.isCollidable()); + } + + @Test + public void nullTopologyMetadataResolvesToBackwardCompatibleDefaults() { + IrisStructure structure = new IrisStructure() + .setMode(null) + .setCompatibility(null) + .setBranchFailurePolicy(null); + + assertEquals(IrisJigsawMode.SPATIAL_JIGSAW, structure.resolvedMode()); + assertEquals(IrisJigsawCompatibility.IRIS_EXTENDED, structure.resolvedCompatibility()); + assertEquals(IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY, + structure.resolvedBranchFailurePolicy()); + } + + @Test + public void omittedJsonMetadataUsesBackwardCompatibleDefaults() { + Gson gson = new Gson(); + IrisStructure structure = gson.fromJson("{}", IrisStructure.class); + IrisJigsawConnector connector = gson.fromJson("{}", IrisJigsawConnector.class); + IrisJigsawPiece piece = gson.fromJson("{}", IrisJigsawPiece.class); + + assertEquals(IrisJigsawMode.SPATIAL_JIGSAW, structure.resolvedMode()); + assertEquals(IrisJigsawCompatibility.IRIS_EXTENDED, structure.resolvedCompatibility()); + assertEquals(IrisJigsawBranchFailurePolicy.FAIL_ASSEMBLY, + structure.resolvedBranchFailurePolicy()); + assertEquals(new IrisPosition(16, 16, 16), structure.getCellSize()); + assertEquals("", connector.getChannel()); + assertEquals("minecraft:air", connector.getFinalState()); + assertEquals(0, connector.getSelectionPriority()); + assertEquals(0, connector.getPlacementPriority()); + assertTrue(piece.isCollidable()); + } + + @Test + public void explicitBranchTerminationPolicySurvivesJsonLoading() { + IrisStructure structure = new Gson().fromJson( + "{\"branchFailurePolicy\":\"TERMINATE_BRANCH\"}", + IrisStructure.class + ); + + assertEquals(IrisJigsawBranchFailurePolicy.TERMINATE_BRANCH, + structure.resolvedBranchFailurePolicy()); + } + + @Test + public void connectorPrioritiesPreserveSignedVanillaValues() { + Gson gson = new Gson(); + IrisJigsawConnector connector = gson.fromJson( + "{\"selectionPriority\":-17,\"placementPriority\":-29}", + IrisJigsawConnector.class + ); + + assertEquals(-17, connector.getSelectionPriority()); + assertEquals(-29, connector.getPlacementPriority()); + } + + @Test + public void explicitNonCollidableMetadataSurvivesJsonLoading() { + IrisJigsawPiece piece = new Gson().fromJson( + "{\"collidable\":false}", + IrisJigsawPiece.class + ); + + assertFalse(piece.isCollidable()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawRulesModelTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawRulesModelTest.java new file mode 100644 index 000000000..f39257d1a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawRulesModelTest.java @@ -0,0 +1,147 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import com.google.gson.Gson; +import org.junit.Test; + +import java.lang.reflect.Field; + +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 class IrisJigsawRulesModelTest { + @Test + public void omittedMetadataPreservesUnthemedCertainOptionalDefaults() { + Gson gson = new Gson(); + IrisJigsawPiece piece = gson.fromJson("{}", IrisJigsawPiece.class); + IrisJigsawPieceEntry entry = gson.fromJson("{}", IrisJigsawPieceEntry.class); + IrisJigsawPool pool = gson.fromJson("{}", IrisJigsawPool.class); + IrisStructure structure = gson.fromJson("{}", IrisStructure.class); + IrisJigsawPieceRules rules = piece.resolvedRules(); + + assertTrue(piece.getThemes().isEmpty()); + assertEquals(0, rules.getMinimumDepth()); + assertEquals(30, rules.getMaximumDepth()); + assertEquals(0, rules.getMinimumPlacements()); + assertEquals(0, rules.getMaximumPlacements()); + assertFalse(rules.isTerminal()); + assertEquals(1D, entry.getChance(), 0D); + assertFalse(entry.requiresChanceRoll()); + assertFalse(pool.isMandatoryFallback()); + assertFalse(pool.requiresFallback(false)); + assertTrue(structure.getThemeSets().isEmpty()); + assertFalse(structure.isRequireCaps()); + } + + @Test + public void pieceThemesAreExactAndEmptyThemesAreUniversal() { + IrisJigsawPiece universal = new IrisJigsawPiece(); + IrisJigsawPiece themed = new IrisJigsawPiece(); + themed.getThemes().add("spruce"); + themed.getThemes().add("desert"); + + assertTrue(universal.supportsTheme("spruce")); + assertTrue(universal.supportsTheme("")); + assertTrue(themed.supportsTheme("spruce")); + assertTrue(themed.supportsTheme("desert")); + assertFalse(themed.supportsTheme("Spruce")); + assertFalse(themed.supportsTheme("")); + assertFalse(themed.supportsTheme(null)); + } + + @Test + public void chanceSeparatesCertainImpossibleAndRolledMemberships() { + IrisJigsawPieceEntry certain = new IrisJigsawPieceEntry().setChance(1D); + IrisJigsawPieceEntry impossible = new IrisJigsawPieceEntry().setChance(0D); + IrisJigsawPieceEntry rolled = new IrisJigsawPieceEntry().setChance(0.25D); + + assertFalse(certain.requiresChanceRoll()); + assertTrue(certain.passesChance(0.999D)); + assertFalse(impossible.requiresChanceRoll()); + assertFalse(impossible.passesChance(0D)); + assertTrue(rolled.requiresChanceRoll()); + assertTrue(rolled.passesChance(0.249D)); + assertFalse(rolled.passesChance(0.25D)); + } + + @Test(expected = IllegalArgumentException.class) + public void chanceRejectsRollAtUpperBoundary() { + new IrisJigsawPieceEntry().passesChance(1D); + } + + @Test + public void pieceRulesUseInclusiveDepthAndPrePlacementCounts() { + IrisJigsawPieceRules rules = new IrisJigsawPieceRules() + .setMinimumDepth(2) + .setMaximumDepth(5) + .setMinimumPlacements(2) + .setMaximumPlacements(3) + .setTerminal(true); + + assertFalse(rules.allowsDepth(1)); + assertTrue(rules.allowsDepth(2)); + assertTrue(rules.allowsDepth(5)); + assertFalse(rules.allowsDepth(6)); + assertTrue(rules.requiresMorePlacements(0)); + assertTrue(rules.requiresMorePlacements(1)); + assertFalse(rules.requiresMorePlacements(2)); + assertTrue(rules.allowsPlacement(2)); + assertFalse(rules.allowsPlacement(3)); + assertTrue(rules.isTerminal()); + } + + @Test + public void zeroMaximumPlacementsIsUnboundedWithinRuntimeSafetyCap() { + IrisJigsawPieceRules rules = new IrisJigsawPieceRules().setMaximumPlacements(0); + + assertTrue(rules.allowsPlacement(0)); + assertTrue(rules.allowsPlacement(512)); + assertFalse(rules.allowsPlacement(-1)); + } + + @Test + public void structureAndPoolMetadataExposeSchemaBounds() throws ReflectiveOperationException { + Field themeSets = IrisStructure.class.getDeclaredField("themeSets"); + Field themes = IrisJigsawPiece.class.getDeclaredField("themes"); + Field chance = IrisJigsawPieceEntry.class.getDeclaredField("chance"); + Field minimumPlacements = IrisJigsawPieceRules.class.getDeclaredField("minimumPlacements"); + Field maximumPlacements = IrisJigsawPieceRules.class.getDeclaredField("maximumPlacements"); + + ArrayType themeSetArray = themeSets.getAnnotation(ArrayType.class); + ArrayType themeArray = themes.getAnnotation(ArrayType.class); + assertNotNull(themeSetArray); + assertEquals(IrisJigsawThemeSet.class, themeSetArray.type()); + assertNotNull(themeArray); + assertEquals(String.class, themeArray.type()); + assertEquals(0D, chance.getAnnotation(MinNumber.class).value(), 0D); + assertEquals(1D, chance.getAnnotation(MaxNumber.class).value(), 0D); + assertEquals(0D, minimumPlacements.getAnnotation(MinNumber.class).value(), 0D); + assertEquals(512D, maximumPlacements.getAnnotation(MaxNumber.class).value(), 0D); + } + + @Test + public void mandatoryPoolFallbackCombinesWithStructurePolicy() { + IrisJigsawPool optional = new IrisJigsawPool(); + IrisJigsawPool mandatory = new IrisJigsawPool().setMandatoryFallback(true); + + assertFalse(optional.requiresFallback(false)); + assertTrue(optional.requiresFallback(true)); + assertTrue(mandatory.requiresFallback(false)); + assertTrue(mandatory.requiresFallback(true)); + } + + @Test + public void themeSetCarriesExactKeyAndPositiveDefaultWeight() { + IrisJigsawThemeSet defaults = new IrisJigsawThemeSet(); + IrisJigsawThemeSet configured = new IrisJigsawThemeSet("desert", 3); + + assertEquals("", defaults.getKey()); + assertEquals(1, defaults.getWeight()); + assertEquals("desert", configured.getKey()); + assertEquals(3, configured.getWeight()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java index 96626de79..caa8e6779 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java @@ -19,15 +19,21 @@ import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyString; 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 IrisObjectSurfaceSupportPlacementTest { private static final int SURFACE_Y = 80; private IrisData data; + private Engine engine; @Before public void bindPlatform() { @@ -41,9 +47,10 @@ public class IrisObjectSurfaceSupportPlacementTest { @SuppressWarnings("unchecked") ProceduralStream heightStream = mock(ProceduralStream.class); + when(heightStream.get(anyDouble(), anyDouble())).thenReturn((double) SURFACE_Y); IrisComplex complex = mock(IrisComplex.class); when(complex.getHeightStream()).thenReturn(heightStream); - Engine engine = mock(Engine.class); + engine = mock(Engine.class); when(engine.getHeight()).thenReturn(256); when(engine.getComplex()).thenReturn(complex); data = mock(IrisData.class); @@ -145,6 +152,59 @@ public class IrisObjectSurfaceSupportPlacementTest { assertTrue(place(placer, placement, SURFACE_Y) >= 0); } + @Test + public void explicitStructurePiecePlacesAfterItsStudioEngineCloses() { + when(data.getEngine()).thenReturn(null); + SurfacePlacer placer = new SurfacePlacer(); + IrisObjectPlacement placement = placement(ObjectPlaceMode.STRUCTURE_PIECE); + + assertTrue(place(placer, placement, SURFACE_Y) >= 0); + assertFalse(placer.written().isEmpty()); + verify(data, never()).getEngine(); + } + + @Test + public void targetWorldEngineSuppliesRequestedSlopeContext() { + when(data.getEngine()).thenReturn(null); + SurfacePlacer placer = new SurfacePlacer(engine); + IrisObjectPlacement placement = placement(ObjectPlaceMode.STRUCTURE_PIECE); + placement.setRotateTowardsSlope(true); + + assertTrue(place(placer, placement, SURFACE_Y) >= 0); + assertFalse(placer.written().isEmpty()); + verify(data, never()).getEngine(); + } + + @Test + public void forcePlaceBypassesSlopeConditionWithoutAnEngine() { + when(data.getEngine()).thenReturn(null); + SurfacePlacer placer = new SurfacePlacer(); + IrisObjectPlacement placement = placement(ObjectPlaceMode.STRUCTURE_PIECE); + placement.setSlopeCondition(new IrisSlopeClip().setMinimumSlope(100).setMaximumSlope(100)); + placement.setForcePlace(true); + + assertTrue(place(placer, placement, SURFACE_Y) >= 0); + assertFalse(placer.written().isEmpty()); + verify(data, never()).getEngine(); + } + + @Test + public void forcePlaceStillRequiresSlopeRotationContext() { + when(data.getEngine()).thenReturn(null); + SurfacePlacer placer = new SurfacePlacer(); + IrisObjectPlacement placement = placement(ObjectPlaceMode.STRUCTURE_PIECE); + placement.setSlopeCondition(new IrisSlopeClip().setMinimumSlope(100).setMaximumSlope(100)); + placement.setForcePlace(true); + placement.setRotateTowardsSlope(true); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> place(placer, placement, SURFACE_Y)); + + assertTrue(error.getMessage().contains("require an active Iris engine")); + assertTrue(placer.written().isEmpty()); + verify(data, never()).getEngine(); + } + @Test public void floatingIsNotGuarded() { SurfacePlacer placer = new SurfacePlacer(); @@ -168,6 +228,8 @@ public class IrisObjectSurfaceSupportPlacementTest { private IrisObject object() { PlatformBlockState solid = mock(PlatformBlockState.class); when(solid.isSolid()).thenReturn(true); + when(solid.key()).thenReturn("minecraft:stone"); + when(solid.materialKey()).thenReturn("minecraft:stone"); IrisObject object = new IrisObject(3, 3, 3); object.setUnsigned(1, 0, 1, solid); object.setUnsigned(1, 1, 1, solid); @@ -177,6 +239,15 @@ public class IrisObjectSurfaceSupportPlacementTest { private static final class SurfacePlacer implements IObjectPlacer { private final Set carved = new HashSet<>(); private final Map written = new HashMap<>(); + private final Engine engine; + + private SurfacePlacer() { + this(null); + } + + private SurfacePlacer(Engine engine) { + this.engine = engine; + } private void carve(int x, int y, int z) { carved.add(blockKey(x, y, z)); @@ -251,7 +322,7 @@ public class IrisObjectSurfaceSupportPlacementTest { @Override public Engine getEngine() { - return null; + return engine; } private static String blockKey(int x, int y, int z) { diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisStructureAnchorModeTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisStructureAnchorModeTest.java new file mode 100644 index 000000000..3b6331a10 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisStructureAnchorModeTest.java @@ -0,0 +1,33 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisStructureAnchorModeTest { + @Test + public void legacyPreservesUndergroundBehavior() { + IrisStructurePlacement surface = new IrisStructurePlacement(); + IrisStructurePlacement underground = new IrisStructurePlacement().setUnderground(true); + + assertEquals(IrisStructureAnchorMode.SURFACE, surface.resolvedAnchor()); + assertEquals(IrisStructureAnchorMode.HEIGHT_BAND, underground.resolvedAnchor()); + assertFalse(surface.isAnchoredUnderground()); + assertTrue(underground.isAnchoredUnderground()); + } + + @Test + public void explicitAnchorOverridesLegacyBoolean() { + IrisStructurePlacement floor = new IrisStructurePlacement() + .setUnderground(false) + .setAnchor(IrisStructureAnchorMode.CAVE_FLOOR); + IrisStructurePlacement surface = new IrisStructurePlacement() + .setUnderground(true) + .setAnchor(IrisStructureAnchorMode.SURFACE); + + assertTrue(floor.isAnchoredUnderground()); + assertFalse(surface.isAnchoredUnderground()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java new file mode 100644 index 000000000..f33fc52c6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorGenerationStageGateTest.java @@ -0,0 +1,412 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.engine.data.chunk.TerrainChunk; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.WrongEngineBroException; +import art.arcane.iris.engine.platform.studio.StudioGenerator; +import org.junit.Test; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CopyOnWriteArrayList; +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.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +public class BukkitChunkGeneratorGenerationStageGateTest { + @Test + public void activeStageDelaysExclusiveControl() throws Exception { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(2, closing::get); + BukkitChunkGenerator.GenerationStagePermit stage = gate.acquireStage("active"); + CountDownLatch exclusiveEntered = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future exclusive = executor.submit(() -> { + try { + gate.acquireExclusive(); + exclusiveEntered.countDown(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } finally { + if (exclusiveEntered.getCount() == 0L) { + gate.releaseExclusive(); + } + } + }); + + awaitQueueLength(gate, 1); + assertEquals(1L, exclusiveEntered.getCount()); + + stage.close(); + exclusive.get(2, TimeUnit.SECONDS); + + assertEquals(0L, exclusiveEntered.getCount()); + assertEquals(2, gate.availablePermits()); + } finally { + stage.close(); + executor.shutdownNow(); + } + } + + @Test + public void exclusiveWaiterRunsBeforeLaterGenerationStage() throws Exception { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(2, closing::get); + BukkitChunkGenerator.GenerationStagePermit activeStage = gate.acquireStage("active"); + CountDownLatch exclusiveEntered = new CountDownLatch(1); + CountDownLatch releaseExclusive = new CountDownLatch(1); + CountDownLatch laterStageAttempting = new CountDownLatch(1); + CountDownLatch laterStageEntered = new CountDownLatch(1); + List order = new CopyOnWriteArrayList<>(); + ExecutorService executor = Executors.newFixedThreadPool(2); + + try { + Future exclusive = executor.submit(() -> { + try { + gate.acquireExclusive(); + order.add("exclusive"); + exclusiveEntered.countDown(); + assertTrue(releaseExclusive.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } finally { + if (exclusiveEntered.getCount() == 0L) { + gate.releaseExclusive(); + } + } + }); + awaitQueueLength(gate, 1); + + Future laterStage = executor.submit(() -> { + laterStageAttempting.countDown(); + try (BukkitChunkGenerator.GenerationStagePermit ignored = gate.acquireStage("later")) { + order.add("stage"); + laterStageEntered.countDown(); + } + }); + assertTrue(laterStageAttempting.await(2, TimeUnit.SECONDS)); + awaitQueueLength(gate, 2); + + activeStage.close(); + assertTrue(exclusiveEntered.await(2, TimeUnit.SECONDS)); + assertEquals(1L, laterStageEntered.getCount()); + + releaseExclusive.countDown(); + exclusive.get(2, TimeUnit.SECONDS); + laterStage.get(2, TimeUnit.SECONDS); + + assertEquals(List.of("exclusive", "stage"), order); + assertEquals(2, gate.availablePermits()); + } finally { + releaseExclusive.countDown(); + activeStage.close(); + executor.shutdownNow(); + } + } + + @Test + public void generationStageCanBeReleasedTwiceFromAnotherThread() throws Exception { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, closing::get); + BukkitChunkGenerator.GenerationStagePermit stage = gate.acquireStage("async"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future release = executor.submit(() -> { + stage.close(); + stage.close(); + }); + release.get(2, TimeUnit.SECONDS); + + assertEquals(1, gate.availablePermits()); + gate.acquireExclusive(); + assertEquals(0, gate.availablePermits()); + gate.releaseExclusive(); + assertEquals(1, gate.availablePermits()); + } finally { + stage.close(); + executor.shutdownNow(); + } + } + + @Test + public void exclusiveGenerationStageDowngradesWithoutReleasingItsRetainedPermit() { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(3, closing::get); + BukkitChunkGenerator.GenerationStageExclusivePermit exclusive = + gate.acquireExclusiveStage("prepare"); + + assertEquals(0, gate.availablePermits()); + BukkitChunkGenerator.GenerationStagePermit stage = exclusive.downgradeToStage(); + + assertEquals(2, gate.availablePermits()); + exclusive.close(); + assertEquals(2, gate.availablePermits()); + stage.close(); + assertEquals(3, gate.availablePermits()); + } + + @Test + public void preparedGenerationRetriesChangedGeneratorWhileHoldingExclusiveAdmission() { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(2, closing::get); + PreparingStudioGenerator first = new PreparingStudioGenerator(gate, false); + PreparingStudioGenerator second = new PreparingStudioGenerator(gate, false); + AtomicInteger resolutions = new AtomicInteger(); + Supplier resolver = () -> resolutions.getAndIncrement() == 0 ? first : second; + Engine engine = mock(Engine.class); + + BukkitChunkGenerator.GenerationStagePermit stage = + BukkitChunkGenerator.acquirePreparedGenerationStage( + gate, + "prepared", + resolver, + engine, + 4, + 7); + + assertEquals(0, first.preparations()); + assertEquals(1, second.preparations()); + assertEquals(0, second.observedPermits()); + assertTrue(resolutions.get() >= 4); + assertEquals(1, gate.availablePermits()); + verifyNoInteractions(engine); + stage.close(); + assertEquals(2, gate.availablePermits()); + } + + @Test + public void failedPreparationReleasesEveryExclusivePermit() { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(2, closing::get); + PreparingStudioGenerator generator = new PreparingStudioGenerator(gate, true); + Engine engine = mock(Engine.class); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> BukkitChunkGenerator.acquirePreparedGenerationStage( + gate, + "prepared-failure", + () -> generator, + engine, + 2, + 3)); + + assertTrue(failure.getMessage().contains("could not prepare")); + assertTrue(failure.getCause() instanceof WrongEngineBroException); + assertEquals(1, generator.preparations()); + assertEquals(0, generator.observedPermits()); + assertEquals(2, gate.availablePermits()); + verifyNoInteractions(engine); + } + + @Test + public void defaultStudioGeneratorUsesOneOrdinaryGenerationPermit() { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(2, closing::get); + StudioGenerator generator = new DefaultStudioGenerator(); + Engine engine = mock(Engine.class); + + BukkitChunkGenerator.GenerationStagePermit stage = + BukkitChunkGenerator.acquirePreparedGenerationStage( + gate, + "ordinary", + () -> generator, + engine, + 0, + 0); + + assertEquals(1, gate.availablePermits()); + verifyNoInteractions(engine); + stage.close(); + assertEquals(2, gate.availablePermits()); + } + + @Test + public void exclusiveControlSuccessReleasesGateBeforeSynchronousCompletionDependent() throws Exception { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, closing::get); + CompletableFuture outward = new CompletableFuture<>(); + CountDownLatch dependentEntered = new CountDownLatch(1); + outward.thenRun(() -> { + try (BukkitChunkGenerator.GenerationStagePermit ignored = gate.acquireStage("success-dependent")) { + dependentEntered.countDown(); + } + }); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future operation = executor.submit(() -> + BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> { + }, outward)); + operation.get(2, TimeUnit.SECONDS); + + assertTrue(outward.isDone()); + assertEquals(0L, dependentEntered.getCount()); + assertEquals(1, gate.availablePermits()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void exclusiveControlFailureReleasesGateBeforeSynchronousCompletionDependent() throws Exception { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, closing::get); + CompletableFuture outward = new CompletableFuture<>(); + IllegalStateException expected = new IllegalStateException("exclusive failure"); + AtomicReference observed = new AtomicReference<>(); + CountDownLatch dependentEntered = new CountDownLatch(1); + outward.whenComplete((ignored, failure) -> { + try (BukkitChunkGenerator.GenerationStagePermit stage = gate.acquireStage("failure-dependent")) { + observed.set(failure); + dependentEntered.countDown(); + } + }); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future operation = executor.submit(() -> + BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> { + throw expected; + }, outward)); + operation.get(2, TimeUnit.SECONDS); + + CompletionException completion = assertThrows(CompletionException.class, outward::join); + assertSame(expected, completion.getCause()); + assertSame(expected, observed.get()); + assertEquals(0L, dependentEntered.getCount()); + assertEquals(1, gate.availablePermits()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void queuedStageIsRejectedAfterCloseBegins() throws Exception { + AtomicBoolean closing = new AtomicBoolean(false); + BukkitChunkGenerator.GenerationStageGate gate = + new BukkitChunkGenerator.GenerationStageGate(1, closing::get); + gate.acquireExclusive(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + + try { + Future stage = + executor.submit(() -> gate.acquireStage("queued")); + awaitQueueLength(gate, 1); + + closing.set(true); + gate.releaseExclusive(); + + ExecutionException failure = assertThrows( + ExecutionException.class, + () -> stage.get(2, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertTrue(failure.getCause().getMessage().contains("rejected while the generator is closing")); + assertEquals(1, gate.availablePermits()); + } finally { + if (gate.availablePermits() == 0) { + gate.releaseExclusive(); + } + executor.shutdownNow(); + } + } + + @Test + public void generationStageGateRequiresAtLeastOnePermit() { + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> new BukkitChunkGenerator.GenerationStageGate(0, () -> false)); + + assertTrue(failure.getMessage().contains("must be positive")); + } + + private static void awaitQueueLength( + BukkitChunkGenerator.GenerationStageGate gate, + int expected + ) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (gate.queueLength() < expected && System.nanoTime() < deadline) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + assertTrue("Expected at least " + expected + " queued gate threads", gate.queueLength() >= expected); + } + + private static final class DefaultStudioGenerator implements StudioGenerator { + @Override + public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) { + } + } + + private static final class PreparingStudioGenerator implements StudioGenerator { + private final BukkitChunkGenerator.GenerationStageGate gate; + private final boolean fail; + private final AtomicInteger preparations; + private int observedPermits; + + private PreparingStudioGenerator( + BukkitChunkGenerator.GenerationStageGate gate, + boolean fail + ) { + this.gate = gate; + this.fail = fail; + this.preparations = new AtomicInteger(); + this.observedPermits = -1; + } + + @Override + public boolean requiresPreSessionPreparation() { + return true; + } + + @Override + public void prepareChunkBeforeSession(Engine engine, int x, int z) throws WrongEngineBroException { + preparations.incrementAndGet(); + observedPermits = gate.availablePermits(); + if (fail) { + throw new WrongEngineBroException("prepared failure"); + } + } + + @Override + public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) { + } + + private int preparations() { + return preparations.get(); + } + + private int observedPermits() { + return observedPermits; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java new file mode 100644 index 000000000..244da2c8c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitializationModeTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.engine.IrisEngine; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class BukkitChunkGeneratorInitializationModeTest { + @Test + public void runtimeAndOrdinaryStudioWarmGenerationCaches() { + IrisEngine.InitializationMode runtime = + BukkitChunkGenerator.selectInitializationMode(false, false); + IrisEngine.InitializationMode studio = + BukkitChunkGenerator.selectInitializationMode(true, false); + + assertEquals(IrisEngine.InitializationMode.RUNTIME, runtime); + assertFalse(runtime.studio()); + assertTrue(runtime.warmGenerationCaches()); + assertEquals(IrisEngine.InitializationMode.STUDIO, studio); + assertTrue(studio.studio()); + assertTrue(studio.warmGenerationCaches()); + } + + @Test + public void activeJigsawStudioSkipsGenerationCacheWarm() { + IrisEngine.InitializationMode mode = + BukkitChunkGenerator.selectInitializationMode(true, true); + + assertEquals(IrisEngine.InitializationMode.JIGSAW_STUDIO, mode); + assertTrue(mode.studio()); + assertFalse(mode.warmGenerationCaches()); + } + + @Test + public void jigsawBootstrapAndInitializationFailureSkipNativeStructureGeneration() { + assertFalse(BukkitChunkGenerator.shouldGenerateNativeStructures(true, false, false)); + assertFalse(BukkitChunkGenerator.shouldGenerateNativeStructures(true, true, false)); + assertFalse(BukkitChunkGenerator.shouldGenerateNativeStructures(false, true, false)); + assertFalse(BukkitChunkGenerator.shouldGenerateNativeStructures(false, false, true)); + assertTrue(BukkitChunkGenerator.shouldGenerateNativeStructures(false, false, false)); + } + + @Test + public void onlyOrdinaryOpenStudioRunsThePackHotloader() { + assertFalse(BukkitChunkGenerator.shouldRunStudioHotload(false, false, false)); + assertFalse(BukkitChunkGenerator.shouldRunStudioHotload(true, true, false)); + assertFalse(BukkitChunkGenerator.shouldRunStudioHotload(true, false, true)); + assertTrue(BukkitChunkGenerator.shouldRunStudioHotload(true, false, false)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java new file mode 100644 index 000000000..25d7bb5bb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java @@ -0,0 +1,118 @@ +package art.arcane.iris.engine.platform; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; +import art.arcane.volmlib.util.mantle.runtime.MantleHooks; +import art.arcane.volmlib.util.matter.Matter; +import org.bukkit.Chunk; +import org.junit.Test; +import org.mockito.InOrder; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +public class EngineBukkitOpsDeferredMaterializationTest { + @Test + @SuppressWarnings("unchecked") + public void tileSliceIsDeletedAfterIteration() { + MantleChunk mantleChunk = mock(MantleChunk.class); + + EngineBukkitOps.materializeTiles(mock(Engine.class), mock(Chunk.class), mantleChunk); + + InOrder order = inOrder(mantleChunk); + order.verify(mantleChunk).iterate(eq(TileWrapper.class), any()); + order.verify(mantleChunk).deleteSlices(TileWrapper.class); + } + + @Test + @SuppressWarnings("unchecked") + public void failedTileIterationRetainsSlice() { + MantleChunk mantleChunk = mock(MantleChunk.class); + IllegalStateException failure = new IllegalStateException("tile failure"); + doThrow(failure).when(mantleChunk).iterate(eq(TileWrapper.class), any()); + + assertThrows(IllegalStateException.class, + () -> EngineBukkitOps.materializeTiles(mock(Engine.class), mock(Chunk.class), mantleChunk)); + + verify(mantleChunk, never()).deleteSlices(TileWrapper.class); + } + + @Test + @SuppressWarnings("unchecked") + public void customSliceIsDeletedAfterIteration() { + MantleChunk mantleChunk = mock(MantleChunk.class); + + EngineBukkitOps.materializeCustomBlocks(mock(Engine.class), mock(Chunk.class), mantleChunk); + + InOrder order = inOrder(mantleChunk); + order.verify(mantleChunk).iterate(eq(Identifier.class), any()); + order.verify(mantleChunk).deleteSlices(Identifier.class); + } + + @Test + @SuppressWarnings("unchecked") + public void failedCustomPassRetrySkipsCompletedTilePass() { + MantleDataAdapter adapter = mock(MantleDataAdapter.class); + MantleChunk chunk = new MantleChunk<>(1, 0, 0, adapter, MantleHooks.NONE); + AtomicInteger tileRuns = new AtomicInteger(); + AtomicInteger customRuns = new AtomicInteger(); + AtomicInteger updateRuns = new AtomicInteger(); + Runnable tileTask = tileRuns::incrementAndGet; + Runnable customTask = () -> { + if (customRuns.incrementAndGet() == 1) { + throw new IllegalStateException("custom failure"); + } + }; + Runnable updateTask = updateRuns::incrementAndGet; + + assertThrows(IllegalStateException.class, + () -> EngineBukkitOps.runMaterializationPasses(chunk, tileTask, customTask, updateTask)); + + assertTrue(chunk.isFlagged(MantleFlag.TILE)); + assertFalse(chunk.isFlagged(MantleFlag.CUSTOM)); + assertFalse(chunk.isFlagged(MantleFlag.UPDATE)); + assertFalse(chunk.isFlagged(MantleFlag.ETCHED)); + + EngineBukkitOps.runMaterializationPasses(chunk, tileTask, customTask, updateTask); + + assertTrue(chunk.isFlagged(MantleFlag.TILE)); + assertTrue(chunk.isFlagged(MantleFlag.CUSTOM)); + assertTrue(chunk.isFlagged(MantleFlag.UPDATE)); + assertTrue(chunk.isFlagged(MantleFlag.ETCHED)); + assertEquals(1, tileRuns.get()); + assertEquals(2, customRuns.get()); + assertEquals(1, updateRuns.get()); + } + + @Test + @SuppressWarnings("unchecked") + public void failedCustomIterationRetainsSlice() { + MantleChunk mantleChunk = mock(MantleChunk.class); + doThrow(new IllegalStateException("custom failure")) + .when(mantleChunk).iterate(eq(Identifier.class), any()); + + assertThrows(IllegalStateException.class, + () -> EngineBukkitOps.materializeCustomBlocks( + mock(Engine.class), + mock(Chunk.class), + mantleChunk + )); + + verify(mantleChunk, never()).deleteSlices(Identifier.class); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGeneratorPreparationTest.java b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGeneratorPreparationTest.java new file mode 100644 index 000000000..7d5a9275b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/BiomeBuffetGeneratorPreparationTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.engine.platform.studio.generators; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BiomeBuffetGeneratorPreparationTest { + @Test + public void focusHotloadPreparationIsOptedInAndIdempotent() { + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisBiome biome = mock(IrisBiome.class); + PlatformBlockState barrier = mock(PlatformBlockState.class); + AtomicReference focus = new AtomicReference<>(""); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getAllBiomes(engine)).thenReturn(new KList<>(biome)); + when(dimension.getFocus()).thenAnswer(invocation -> focus.get()); + when(dimension.setFocus(anyString())).thenAnswer(invocation -> { + focus.set(invocation.getArgument(0, String.class)); + return dimension; + }); + when(biome.getLoadKey()).thenReturn("prepared_biome"); + + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getState("BARRIER")).thenReturn(barrier); + BiomeBuffetGenerator generator = new BiomeBuffetGenerator(engine, 1); + + assertTrue(generator.requiresPreSessionPreparation()); + generator.prepareChunkBeforeSession(engine, 0, 0); + generator.prepareChunkBeforeSession(engine, 0, 0); + generator.prepareChunkBeforeSession(engine, 2, 0); + } + + verify(dimension, times(1)).setFocus("prepared_biome"); + verify(engine, times(1)).hotloadComplex(); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGeneratorTest.java b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGeneratorTest.java new file mode 100644 index 000000000..12522f43c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/platform/studio/generators/JigsawStudioGeneratorTest.java @@ -0,0 +1,583 @@ +package art.arcane.iris.engine.platform.studio.generators; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarArchetype; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarDirection; +import art.arcane.iris.core.runtime.jigsaw.JigsawPlanarTopology; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioActivation; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioBay; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCellDimensions; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioCompatibilityTarget; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioControlPosition; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioLayout; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioMode; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioPieceRules; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioSession; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariant; +import art.arcane.iris.core.runtime.jigsaw.JigsawStudioVariantCatalog; +import art.arcane.iris.engine.data.chunk.TerrainChunk; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectRotation; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import org.bukkit.block.data.BlockData; +import org.bukkit.generator.ChunkGenerator.ChunkData; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class JigsawStudioGeneratorTest { + @Test + public void serviceRegistrationIsPublishedAfterTheRegistrationFinishes() throws Exception { + GeneratorFixture fixture = fixture( + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(3, 3, 3), + JigsawStudioVariantCatalog.empty()); + CountDownLatch registrationStarted = new CountDownLatch(1); + CountDownLatch releaseRegistration = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + CountDownLatch secondFinished = new CountDownLatch(1); + AtomicInteger registrations = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> fixture.generator().publishServiceRegistration(() -> { + registrations.incrementAndGet(); + registrationStarted.countDown(); + await(releaseRegistration); + })); + assertTrue(registrationStarted.await(5, TimeUnit.SECONDS)); + Future second = executor.submit(() -> { + secondStarted.countDown(); + fixture.generator().publishServiceRegistration(registrations::incrementAndGet); + secondFinished.countDown(); + }); + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + assertFalse(secondFinished.await(250, TimeUnit.MILLISECONDS)); + + releaseRegistration.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + + assertEquals(1, registrations.get()); + assertEquals(0L, secondFinished.getCount()); + } finally { + releaseRegistration.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + public void paintsCanonicalWoolGlyphsBelowCaptureAndOneControlChest() { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(7, 5, 7); + GeneratorFixture fixture = fixture( + JigsawStudioMode.PLANAR_JIGSAW, + dimensions, + JigsawStudioVariantCatalog.empty()); + JigsawStudioBay corner = fixture.layout().get("workcell/corner"); + int glyphY = corner.bounds().originY() - 1; + int centerX = dimensions.width() / 2; + int centerZ = dimensions.depth() / 2; + + assertSame(fixture.connectorCap(), stateAt( + fixture.generator(), + corner.bounds().originX() + centerX, + glyphY, + corner.bounds().originZ())); + assertSame(fixture.connectorCap(), stateAt( + fixture.generator(), + corner.bounds().maxX(), + glyphY, + corner.bounds().originZ() + centerZ)); + assertSame(fixture.topologyPath(), stateAt( + fixture.generator(), + corner.bounds().originX() + centerX, + glyphY, + corner.bounds().originZ() + 1)); + assertSame(fixture.topologyPath(), stateAt( + fixture.generator(), + corner.bounds().originX() + centerX + 1, + glyphY, + corner.bounds().originZ() + centerZ)); + assertSame(fixture.topologyBase(), stateAt( + fixture.generator(), + corner.bounds().originX(), + glyphY, + corner.bounds().originZ())); + assertFalse(fixture.topologyBase() == stateAt( + fixture.generator(), + corner.bounds().originX() + centerX, + corner.bounds().originY(), + corner.bounds().originZ() + centerZ)); + + JigsawStudioBay blank = fixture.layout().get("workcell/blank"); + assertSame(fixture.topologyBase(), stateAt( + fixture.generator(), + blank.bounds().originX() + centerX, + blank.bounds().originY() - 1, + blank.bounds().originZ() + centerZ)); + JigsawStudioControlPosition control = fixture.layout().controlPosition(); + assertSame(fixture.controlChest(), stateAt( + fixture.generator(), control.worldX(), control.worldY(), control.worldZ())); + } + + @Test + public void glyphMathHandlesAllArchetypesAndSmallEvenAndOddCells() { + int[] sizes = {1, 2, 3, 4, 7, 16}; + for (int size : sizes) { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(size, 1, size); + int center = size / 2; + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + JigsawPlanarTopology topology = archetype.canonicalTopology(); + boolean centerPath = JigsawStudioGenerator.isTopologyPath( + topology, center, center, center, center); + assertEquals(archetype != JigsawPlanarArchetype.BLANK, centerPath); + if (topology.connects(JigsawPlanarDirection.NORTH)) { + assertTrue(JigsawStudioGenerator.isConnectorCap( + topology, dimensions, center, 0, center, center)); + } + if (topology.connects(JigsawPlanarDirection.EAST)) { + assertTrue(JigsawStudioGenerator.isConnectorCap( + topology, dimensions, size - 1, center, center, center)); + } + } + } + } + + @Test + public void connectorCapsOnlyOccupyConnectedFaceCenters() { + int[] sizes = {3, 4, 7, 16}; + for (int size : sizes) { + JigsawStudioCellDimensions dimensions = new JigsawStudioCellDimensions(size, 1, size); + int center = size / 2; + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + JigsawPlanarTopology topology = archetype.canonicalTopology(); + for (int z = 0; z < size; z++) { + for (int x = 0; x < size; x++) { + boolean expected = topology.connects(JigsawPlanarDirection.NORTH) + && x == center && z == 0 + || topology.connects(JigsawPlanarDirection.EAST) + && x == size - 1 && z == center + || topology.connects(JigsawPlanarDirection.SOUTH) + && x == center && z == size - 1 + || topology.connects(JigsawPlanarDirection.WEST) + && x == 0 && z == center; + assertEquals(expected, JigsawStudioGenerator.isConnectorCap( + topology, dimensions, x, z, center, center)); + } + } + } + } + } + + @Test + public void canonicalFiveByFivePathsMatchEveryArchetype() { + Map> expected = Map.of( + JigsawPlanarArchetype.BLANK, List.of(".....", ".....", ".....", ".....", "....."), + JigsawPlanarArchetype.END, List.of("..#..", "..#..", "..#..", ".....", "....."), + JigsawPlanarArchetype.STRAIGHT, List.of("..#..", "..#..", "..#..", "..#..", "..#.."), + JigsawPlanarArchetype.CORNER, List.of("..#..", "..#..", "..###", ".....", "....."), + JigsawPlanarArchetype.TEE, List.of("..#..", "..#..", "#####", ".....", "....."), + JigsawPlanarArchetype.CROSS, List.of("..#..", "..#..", "#####", "..#..", "..#..")); + + for (JigsawPlanarArchetype archetype : JigsawPlanarArchetype.values()) { + List rows = expected.get(archetype); + for (int z = 0; z < 5; z++) { + for (int x = 0; x < 5; x++) { + boolean path = JigsawStudioGenerator.isTopologyPath( + archetype.canonicalTopology(), x, z, 2, 2); + assertEquals(rows.get(z).charAt(x) == '#', path); + } + } + } + } + + @Test + public void clipsLargeGlyphPaintingToOneChunkFootprint() { + GeneratorFixture fixture = fixture( + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(128, 1, 128), + JigsawStudioVariantCatalog.empty()); + JigsawStudioBay cross = fixture.layout().get("workcell/cross"); + int chunkX = (cross.bounds().originX() + 32) >> 4; + int chunkZ = (cross.bounds().originZ() + 32) >> 4; + RecordingTerrainChunk chunk = new RecordingTerrainChunk(-64, 320); + + fixture.generator().paintChunk(chunk, chunkX, chunkZ); + + int glyphBlocks = chunk.countState(fixture.topologyBase()) + + chunk.countState(fixture.topologyPath()) + + chunk.countState(fixture.connectorCap()); + assertEquals(256, glyphBlocks); + } + + @Test + @SuppressWarnings("unchecked") + public void rendersSourceVariantInCanonicalOrientationAndRotatesFinalState() { + IrisData source = mock(IrisData.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(source.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(source.getObjectLoader()).thenReturn(objectLoader); + IrisJigsawConnector connector = new IrisJigsawConnector() + .setPosition(new IrisPosition(2, 1, 1)) + .setDirection(IrisDirection.EAST_POSITIVE_X) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("village/start") + .setName("door") + .setTargetName("door") + .setJoint(JigsawJoint.ALIGNED) + .setFinalState("minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]"); + IrisJigsawPiece piece = new IrisJigsawPiece() + .setObject("village/east") + .setConnectors(new KList<>()); + piece.getConnectors().add(connector); + IrisObject object = new IrisObject(3, 3, 3); + PlatformBlockState sourceBlock = mock(PlatformBlockState.class); + when(sourceBlock.nativeHandle()).thenReturn(mock(BlockData.class)); + KMap tileProperties = new KMap<>(); + tileProperties.put("CustomName", "QA Chest"); + TileData sourceTile = new TileData("minecraft:chest", tileProperties); + object.setUnsigned(0, 0, 0, sourceBlock); + object.setUnsignedTile(0, 0, 0, sourceTile); + when(pieceLoader.load("village/east", false)).thenReturn(piece); + when(objectLoader.load("village/east", false)).thenReturn(object); + JigsawStudioVariant variant = planarVariant("village/east", JigsawPlanarTopology.EAST_END); + GeneratorFixture fixture = fixture( + source, + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(3, 3, 3), + new JigsawStudioVariantCatalog(List.of(variant))); + + JigsawStudioGenerator.RenderedBay rendered = fixture.generator().renderVariant( + fixture.layout().get("workcell/end"), variant); + + assertTrue(rendered.valid()); + JigsawStudioGenerator.RenderedConnector rotated = rendered.connectors().getFirst(); + assertEquals(1, rotated.x()); + assertEquals(0, rotated.z()); + assertEquals(IrisDirection.NORTH_NEGATIVE_Z, rotated.connector().getDirection()); + assertEquals("north_up", rotated.orientation()); + assertTrue(rotated.connector().getFinalState().contains("facing=north")); + JigsawStudioGenerator.RenderedBlock rotatedBlock = rendered.blocks().getFirst(); + assertEquals(sourceTile, rotatedBlock.tileData()); + assertNotSame(sourceTile, rotatedBlock.tileData()); + } + + @Test + @SuppressWarnings("unchecked") + public void invalidRotatedFinalStateFailsRenderPrecisely() { + IrisData source = mock(IrisData.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(source.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(source.getObjectLoader()).thenReturn(objectLoader); + IrisJigsawConnector connector = new IrisJigsawConnector() + .setPosition(new IrisPosition(2, 1, 1)) + .setDirection(IrisDirection.EAST_POSITIVE_X) + .setTop(IrisDirection.UP_POSITIVE_Y) + .setPool("village/start") + .setName("door") + .setTargetName("door") + .setJoint(JigsawJoint.ALIGNED) + .setFinalState("minecraft:not_a_real_block"); + IrisJigsawPiece piece = new IrisJigsawPiece() + .setObject("village/east") + .setConnectors(new KList<>()); + piece.getConnectors().add(connector); + when(pieceLoader.load("village/east", false)).thenReturn(piece); + when(objectLoader.load("village/east", false)).thenReturn(new IrisObject(3, 3, 3)); + JigsawStudioVariant variant = planarVariant("village/east", JigsawPlanarTopology.EAST_END); + GeneratorFixture fixture = fixture( + source, + JigsawStudioMode.PLANAR_JIGSAW, + new JigsawStudioCellDimensions(3, 3, 3), + new JigsawStudioVariantCatalog(List.of(variant))); + + JigsawStudioGenerator.RenderedBay rendered = fixture.generator().renderVariant( + fixture.layout().get("workcell/end"), variant); + + assertFalse(rendered.valid()); + assertTrue(rendered.failure().contains("final state 'minecraft:not_a_real_block' cannot be parsed and rotated")); + } + + @Test + @SuppressWarnings("unchecked") + public void renderCacheIncludesLoadGenerationAndSupportsExplicitInvalidation() { + IrisData source = mock(IrisData.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + ResourceLoader objectLoader = mock(ResourceLoader.class); + when(source.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(source.getObjectLoader()).thenReturn(objectLoader); + IrisJigsawPiece piece = new IrisJigsawPiece() + .setObject("stronghold/hall") + .setConnectors(new KList<>()); + when(pieceLoader.load("stronghold/hall", false)).thenReturn(piece); + when(objectLoader.load("stronghold/hall", false)).thenReturn(new IrisObject(3, 3, 3)); + JigsawStudioVariant variant = new JigsawStudioVariant( + "stronghold/hall", + "stronghold/hall", + "", + Optional.of(new JigsawStudioCellDimensions(3, 3, 3)), + JigsawStudioMode.SPATIAL_JIGSAW, + Optional.empty(), + true, + false, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + GeneratorFixture fixture = fixture( + source, + JigsawStudioMode.SPATIAL_JIGSAW, + new JigsawStudioCellDimensions(3, 3, 3), + new JigsawStudioVariantCatalog(List.of(variant))); + JigsawStudioBay workcell = fixture.layout().get(JigsawStudioLayout.SPATIAL_WORKCELL_ID); + + JigsawStudioGenerator.RenderedBay first = fixture.generator().renderVariant(workcell, variant, 4L); + assertSame(first, fixture.generator().renderVariant(workcell, variant, 4L)); + JigsawStudioGenerator.RenderedBay nextGeneration = fixture.generator().renderVariant(workcell, variant, 5L); + assertNotSame(first, nextGeneration); + fixture.generator().invalidateRender(workcell.stableId(), variant.pieceKey()); + assertNotSame(nextGeneration, fixture.generator().renderVariant(workcell, variant, 5L)); + } + + @Test + public void mapsAllVanillaJigsawOrientations() { + assertEquals("north_up", JigsawStudioGenerator.orientation( + IrisDirection.NORTH_NEGATIVE_Z, IrisDirection.UP_POSITIVE_Y)); + assertEquals("up_east", JigsawStudioGenerator.orientation( + IrisDirection.UP_POSITIVE_Y, IrisDirection.EAST_POSITIVE_X)); + assertEquals("down_west", JigsawStudioGenerator.orientation( + IrisDirection.DOWN_NEGATIVE_Y, IrisDirection.WEST_NEGATIVE_X)); + } + + private static PlatformBlockState stateAt( + JigsawStudioGenerator generator, + int worldX, + int worldY, + int worldZ + ) { + int chunkX = worldX >> 4; + int chunkZ = worldZ >> 4; + RecordingTerrainChunk chunk = new RecordingTerrainChunk(-64, 320); + generator.paintChunk(chunk, chunkX, chunkZ); + return chunk.getBlockData(worldX - (chunkX << 4), worldY, worldZ - (chunkZ << 4)); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for Jigsaw Studio registration"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for Jigsaw Studio registration", exception); + } + } + + private static GeneratorFixture fixture( + JigsawStudioMode mode, + JigsawStudioCellDimensions dimensions, + JigsawStudioVariantCatalog catalog + ) { + return fixture(mock(IrisData.class), mode, dimensions, catalog); + } + + private static GeneratorFixture fixture( + IrisData source, + JigsawStudioMode mode, + JigsawStudioCellDimensions dimensions, + JigsawStudioVariantCatalog catalog + ) { + Engine engine = mock(Engine.class); + UUID requestId = UUID.randomUUID(); + JigsawStudioLayout layout = JigsawStudioLayout.create(mode, dimensions, catalog); + JigsawStudioActivation.Request request = new JigsawStudioActivation.Request( + requestId, + "overworld", + "test/structure", + mode, + JigsawStudioCompatibilityTarget.IRIS_EXTENDED, + dimensions, + source, + null); + JigsawStudioSession session = new JigsawStudioSession( + requestId, + "overworld", + "test/structure", + layout); + PlatformBlockState lightFloor = mock(PlatformBlockState.class); + PlatformBlockState darkFloor = mock(PlatformBlockState.class); + PlatformBlockState frame = mock(PlatformBlockState.class); + PlatformBlockState topologyBase = mock(PlatformBlockState.class); + PlatformBlockState topologyPath = mock(PlatformBlockState.class); + PlatformBlockState connectorCap = mock(PlatformBlockState.class); + PlatformBlockState invalidMarker = mock(PlatformBlockState.class); + PlatformBlockState controlChest = mock(PlatformBlockState.class); + JigsawStudioGenerator generator = new JigsawStudioGenerator( + engine, + request, + session, + lightFloor, + darkFloor, + frame, + topologyBase, + topologyPath, + connectorCap, + invalidMarker, + controlChest, + JigsawStudioGeneratorTest::renderFinalState); + return new GeneratorFixture( + generator, + layout, + topologyBase, + topologyPath, + connectorCap, + controlChest); + } + + private static JigsawStudioVariant planarVariant(String key, JigsawPlanarTopology topology) { + return new JigsawStudioVariant( + key, + key, + "", + Optional.of(new JigsawStudioCellDimensions(16, 16, 16)), + JigsawStudioMode.PLANAR_JIGSAW, + Optional.of(topology), + true, + false, + List.of(), + new JigsawStudioPieceRules(0, 30, 0, 0, false), + List.of()); + } + + private static String renderFinalState( + String finalState, + IrisObjectRotation rotation, + int quarterTurns + ) { + if (finalState.equals("minecraft:not_a_real_block")) { + return null; + } + if (Math.floorMod(quarterTurns, 4) == 3) { + return finalState.replace("facing=east", "facing=north"); + } + return finalState; + } + + private record GeneratorFixture( + JigsawStudioGenerator generator, + JigsawStudioLayout layout, + PlatformBlockState topologyBase, + PlatformBlockState topologyPath, + PlatformBlockState connectorCap, + PlatformBlockState controlChest + ) { + } + + private static final class RecordingTerrainChunk implements TerrainChunk { + private final int minHeight; + private final int maxHeight; + private final Map blocks = new HashMap<>(); + + private RecordingTerrainChunk(int minHeight, int maxHeight) { + this.minHeight = minHeight; + this.maxHeight = maxHeight; + } + + @Override + public PlatformBiome getBiome(int x, int y, int z) { + return null; + } + + @Override + public void setBiome(int x, int y, int z, PlatformBiome biome) { + } + + @Override + public int getMinHeight() { + return minHeight; + } + + @Override + public int getMaxHeight() { + return maxHeight; + } + + @Override + public void setBlock(int x, int y, int z, PlatformBlockState blockData) { + blocks.put(key(x, y, z), blockData); + } + + @Override + public void setRegion( + int xMin, + int yMin, + int zMin, + int xMax, + int yMax, + int zMax, + PlatformBlockState blockData + ) { + for (int x = xMin; x < xMax; x++) { + for (int y = yMin; y < yMax; y++) { + for (int z = zMin; z < zMax; z++) { + setBlock(x, y, z, blockData); + } + } + } + } + + @Override + public PlatformBlockState getBlockData(int x, int y, int z) { + return blocks.get(key(x, y, z)); + } + + @Override + public ChunkData getChunkData() { + return null; + } + + private int countState(PlatformBlockState state) { + int count = 0; + for (PlatformBlockState block : blocks.values()) { + if (block == state) { + count++; + } + } + return count; + } + + private String key(int x, int y, int z) { + return x + ":" + y + ":" + z; + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java index 28c6d6ada..3cb5e4f76 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java @@ -183,7 +183,7 @@ public final class GenerationProbe { .maxHeight(dimension.getMaxHeight()) .build(); EngineTarget target = new EngineTarget(world, dimension, data); - engine = new IrisEngine(target, false); + engine = new IrisEngine(target, IrisEngine.InitializationMode.RUNTIME); } catch (Throwable e) { System.out.println("[genprobe] FAIL: engine construction threw before generation could start"); e.printStackTrace(System.out);