From 7f5ce70f491c95b0b3397ad4a40606575bcc41eb Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 6 Oct 2026 20:51:28 -0400 Subject: [PATCH] Improve generation and lifecycle safety - Reduce terrain, noise, hydrology, and decorator sampling overhead. - Reduce object transaction allocations while preserving rollback behavior. - Optimize structure placement and cave anchor sampling. - Parallelize Studio cache warmup and protect world entry and hotload recovery. - Validate pack fingerprints and publish pack replacements atomically. - Notify modded operators about outdated packs without synchronizing pack data. - Harden world creation, loading, evacuation, and teleport failure recovery. - Coalesce queued region flushes while preserving chunk save ordering. - Remove obsolete mantle, pack, and cave planning code. - Add regression tests and reusable gameplay fixtures. --- .../art/arcane/iris/command/CommandPack.java | 82 +- .../world/IrisWorldGeneratorResolver.java | 21 +- .../gameplay/decorator-pack/biomes/flat.json | 47 + .../dimensions/decorator-fixture.json | 28 + .../decorator-pack/generators/flat.json | 15 + .../gameplay/decorator-pack/regions/flat.json | 12 + .../plugin/src/test/gameplay/decorators.mjs | 40 + .../iris/fixture/NativeLifecycleFixture.java | 72 +- .../native-terrain-pack/biomes/flat.json | 9 + .../dimensions/native-terrain.json | 2 +- .../test/gameplay/noise-pack/biomes/flat.json | 32 + .../noise-pack/dimensions/noise-fixture.json | 28 + .../noise-pack/expressions/terrain.json | 3 + .../gameplay/noise-pack/generators/flat.json | 22 + .../gameplay/noise-pack/regions/flat.json | 12 + .../bukkit/plugin/src/test/gameplay/noise.mjs | 48 ++ .../gameplay/structure-pack/biomes/flat.json | 45 + .../dimensions/structure-fixture.json | 66 ++ .../structure-pack/generators/flat.json | 15 + .../structure-pack/jigsaw-pieces/buried.json | 8 + .../structure-pack/jigsaw-pieces/surface.json | 8 + .../structure-pack/jigsaw-pools/buried.json | 8 + .../structure-pack/jigsaw-pools/surface.json | 8 + .../structure-pack/objects/buried.iob | Bin 0 -> 1076 bytes .../structure-pack/objects/object.iob | Bin 0 -> 188 bytes .../structure-pack/objects/surface.iob | Bin 0 -> 453 bytes .../gameplay/structure-pack/regions/flat.json | 12 + .../structure-pack/structures/buried.json | 6 + .../structure-pack/structures/surface.json | 6 + .../plugin/src/test/gameplay/structures.mjs | 52 ++ .../plugin/src/test/gameplay/studio-open.mjs | 74 ++ .../src/test/gameplay/world-lifecycle.mjs | 98 +++ .../CommandPackValidationPublicationTest.java | 126 +++ .../iris/modded/ModdedDecoratorHooks.java | 2 +- .../iris/modded/ModdedEngineBootstrap.java | 2 + .../art/arcane/iris/modded/ModdedGenPool.java | 44 +- .../arcane/iris/modded/ModdedIrisSplash.java | 9 - .../iris/modded/ModdedPrimaryWorldRouter.java | 85 +- .../iris/modded/ModdedProtocolHandler.java | 5 + .../art/arcane/iris/modded/ModdedStartup.java | 109 ++- .../modded/command/IrisModdedCommands.java | 7 + .../modded/command/ModdedPackCommands.java | 6 +- .../service/ModdedPackUpdateService.java | 137 +++ .../service/ModdedStudioHotloadService.java | 43 +- .../ModdedClientPackageIsolationTest.java | 31 + .../iris/modded/ModdedDecoratorHooksTest.java | 44 + .../arcane/iris/modded/ModdedGenPoolTest.java | 78 ++ .../ModdedPackValidationPublicationTest.java | 143 ++++ .../modded/ModdedPrimaryWorldRouterTest.java | 44 + .../ModdedStartupReinjectFailureTest.java | 5 + .../IrisModdedCommandsTeleportTest.java | 3 + .../service/ModdedPackUpdateServiceTest.java | 209 +++++ .../ModdedStudioHotloadServiceTest.java | 57 ++ .../modded/NativeTeleportSafetyTest.java | 142 +++ core/build.gradle | 2 - .../splash/IrisSplashPackScanner.java | 2 +- .../iris/generation/biome/IrisBiome.java | 10 +- .../generation/biome/IrisBiomeGenLinks.java | 74 +- .../biome/IrisBiomeLayerGenerator.java | 2 +- .../cache/ConcurrentClockCache.java | 92 +- .../cave/IrisCaveProfileSampler.java | 21 +- .../generation/decoration/DecoratorCore.java | 59 +- .../generation/decoration/IrisDecorator.java | 18 +- .../decoration/IrisProceduralBlocks.java | 10 +- .../decoration/IrisShoreLineDecorator.java | 7 +- .../hydrology/CaveCandidateBuilder.java | 4 - .../hydrology/HydrologyOutletPlanner.java | 6 +- .../hydrology/HydrologySampledGrid.java | 19 + .../hydrology/HydrologySourcePlanner.java | 2 +- .../hydrology/cave/CaveBoundaryResult.java | 18 - .../hydrology/cave/CaveComponentResult.java | 18 - .../hydrology/cave/CaveGrottoResult.java | 18 - .../hydrology/cave/CavePathResult.java | 13 - .../cave/HydrologyCaveChamberPlanner.java | 809 ------------------ .../cave/HydrologyCaveContainmentPlanner.java | 133 --- .../cave/HydrologyCaveGrottoShape.java | 21 - .../cave/HydrologyCavePlannerSettings.java | 61 -- .../IrisHydrologyRoutingTerrainSampler.java | 4 +- .../surface/ErosionFieldCompiler.java | 49 +- .../iris/generation/mantle/MantleWriter.java | 676 +-------------- .../mantle/ObjectContinuationPersistence.java | 5 + .../mantle/ObjectDestinationTransaction.java | 58 +- .../mantle/StructureFoundationPlanner.java | 4 +- .../mantle/StructurePieceColumns.java | 9 +- .../generation/mantle/TerrainMatterView.java | 14 +- .../iris/generation/noise/IrisExpression.java | 77 +- .../generation/noise/IrisGeneratorStyle.java | 2 +- .../runtime/DimensionTerrainContext.java | 8 +- .../runtime/GenerationCacheWarmer.java | 99 ++- .../iris/generation/runtime/IrisComplex.java | 4 +- .../iris/generation/runtime/MeteredCache.java | 4 - .../generation/runtime/PreservationSVC.java | 6 - .../stage/IrisDimensionStackActuator.java | 26 +- .../stage/IrisFloatingChildBiomeModifier.java | 27 +- .../stage/IrisTerrainNormalActuator.java | 4 +- .../stream/CachedDoubleStream2D.java | 6 - .../generation/stream/CachedStream2D.java | 6 - .../generation/stream/CachedStream3D.java | 5 - .../stream/ContextInjectingStream.java | 12 +- .../generation/stream/GenerationStreams.java | 16 - .../stream/ProvisionalSampling.java | 52 +- .../generation/terrain/Terrain3DRuntime.java | 26 +- .../iris/pack/AtomicDirectoryPublisher.java | 54 +- .../java/art/arcane/iris/pack/IrisPack.java | 384 --------- .../arcane/iris/pack/IrisPackRepository.java | 137 --- .../iris/pack/PackBiomeLayerValidator.java | 10 +- .../iris/pack/PackDimensionValidator.java | 71 +- .../art/arcane/iris/pack/PackDownloader.java | 57 +- .../arcane/iris/pack/PackFingerprints.java | 305 +++++++ .../arcane/iris/pack/PackResourceCleanup.java | 30 +- .../arcane/iris/pack/PackValidationCache.java | 3 +- .../iris/pack/PackValidationRegistry.java | 21 +- .../art/arcane/iris/pack/PackValidator.java | 6 +- .../pack/datapack/ServerConfigurator.java | 291 +------ .../pack/loading/ImageResourceLoader.java | 2 +- .../arcane/iris/pack/loading/IrisData.java | 29 +- .../loading/MatterObjectResourceLoader.java | 2 +- .../pack/loading/ObjectResourceLoader.java | 2 +- .../iris/pack/loading/ResourceLoader.java | 8 +- .../generation/BukkitChunkGenerator.java | 14 +- .../object/IrisAxisRotationClamp.java | 7 +- .../iris/structure/object/IrisObjectIO.java | 265 ++++-- .../object/IrisObjectPlacementRunner.java | 16 +- .../object/IrisObjectTransforms.java | 20 +- .../StructureCaveAnchorResolver.java | 25 +- .../art/arcane/iris/studio/StudioSVC.java | 40 +- .../studio/object/StudioOpenCoordinator.java | 24 +- .../art/arcane/iris/world/IrisCreator.java | 58 +- .../art/arcane/iris/world/IrisToolbelt.java | 4 +- .../arcane/iris/world/IrisWorldStorage.java | 17 +- .../WorldUnloadBoundaryRegistry.java | 9 +- .../iris/world/pregen/AsyncPregenMethod.java | 7 +- .../iris/world/pregen/PregenChunkFlush.java | 62 ++ .../runtime/WorldRuntimeControlService.java | 14 +- .../art/arcane/iris/world/task/jobs/Job.java | 13 +- .../splash/IrisSplashPackScannerTest.java | 28 + .../biome/FloatingIslandLockedLayersTest.java | 123 +++ .../biome/IrisBiomeGenLinksTest.java | 48 ++ .../biome/IrisBiomeLockedLayerTest.java | 22 +- .../cache/ConcurrentClockCacheTest.java | 52 ++ .../cave/IrisCaveProfileSamplerTest.java | 117 +++ .../decoration/DecoratorCoreTest.java | 37 + .../DecoratorStackSamplingTest.java | 98 +++ .../IrisDecoratorPaletteSelectionTest.java | 88 ++ .../IrisPlacementWaterloggingTest.java | 25 + .../IrisShoreLineDecoratorTest.java | 29 + .../HydrologyCaveTerminalAlignmentTest.java | 14 +- .../HydrologySurfaceBudgetCacheTest.java | 143 ++++ .../HydrologyCaveContainmentPlannerTest.java | 665 +------------- .../cave/HydrologyCaveIterationOrderTest.java | 3 - ...risHydrologyRoutingTerrainSamplerTest.java | 22 + .../surface/ErosionFieldCompilerTest.java | 5 + .../MantleHydrologyCaveVoxelViewTest.java | 38 +- ...MantleWriterNeighborhoodBenchmarkTest.java | 125 --- .../mantle/MantleWriterNeighborhoodTest.java | 184 ---- .../MantleWriterPreObjectJournalTest.java | 240 +++++- .../ObjectDestinationTransactionTest.java | 24 +- .../mantle/StructureCarvingFootprintTest.java | 56 ++ .../StructureFoundationPlannerTest.java | 23 + .../mantle/TerrainMatterViewTest.java | 49 ++ .../noise/IrisExpressionCoordinateTest.java | 46 + .../noise/IrisExpressionScratchTest.java | 185 ++++ .../noise/IrisGeneratorStyleOverrideTest.java | 72 ++ .../DimensionTerrain3DCompositionTest.java | 73 +- .../GenerationCacheWarmerParallelTest.java | 126 +++ .../stream/ProvisionalSamplingTest.java | 55 ++ .../Terrain3DDensityPlaneReuseTest.java | 152 ++++ .../pack/AtomicDirectoryPublisherTest.java | 101 +++ .../pack/PackBiomeLayerValidatorTest.java | 11 + .../arcane/iris/pack/PackDownloaderTest.java | 30 + .../PackFingerprintPlatformIsolationTest.java | 98 +++ .../iris/pack/PackResourceCleanupTest.java | 88 ++ .../PackValidatorDimensionHeightTest.java | 57 ++ .../iris/pack/PackValidatorReadOnlyTest.java | 31 + ...erConfiguratorDatapackFingerprintTest.java | 106 ++- .../loading/IrisDataAuthoringCacheTest.java | 50 +- .../schema/SchemaBuilderIdentityTest.java | 2 +- ...ukkitChunkGeneratorSpawnHydrologyTest.java | 79 ++ .../protocol/IrisProtocolServerTest.java | 33 + .../IrisAxisRotationClampPurityTest.java | 42 + .../IrisObjectIoReadValidationTest.java | 293 +++++++ .../object/IrisObjectIoWriteLimitsTest.java | 234 +++++ ...isObjectPlacementRunnerRegressionTest.java | 49 ++ .../object/IrisObjectTransformsTileTest.java | 32 + .../StructureCaveAnchorColumnScanTest.java | 166 ++++ .../StructureCaveAnchorResolverTest.java | 55 ++ .../studio/StudioSVCWorldPackPublishTest.java | 86 +- .../object/StudioCloseRecoveryTest.java | 5 + .../world/IrisCreatorInitialSpawnTest.java | 20 + .../iris/world/IrisCreatorRollbackTest.java | 89 ++ .../iris/world/IrisCreatorTeleportTest.java | 42 + .../world/IrisToolbeltEvacuationTest.java | 56 ++ .../WorldUnloadBoundaryRegistryTest.java | 16 + .../AsyncPregenServerSaveOwnershipTest.java | 58 +- .../world/pregen/PregenChunkFlushTest.java | 164 ++++ ...rldRuntimeControlServiceSafeEntryTest.java | 34 + ...orldRuntimeControlServiceTeleportTest.java | 51 ++ 197 files changed, 7560 insertions(+), 4420 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/gameplay/decorator-pack/biomes/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/decorator-pack/dimensions/decorator-fixture.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/decorator-pack/generators/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/decorator-pack/regions/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/decorators.mjs create mode 100644 adapters/bukkit/plugin/src/test/gameplay/noise-pack/biomes/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/noise-pack/dimensions/noise-fixture.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/noise-pack/expressions/terrain.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/noise-pack/generators/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/noise-pack/regions/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/noise.mjs create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/biomes/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/dimensions/structure-fixture.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/generators/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pieces/buried.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pieces/surface.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pools/buried.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pools/surface.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/objects/buried.iob create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/objects/object.iob create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/objects/surface.iob create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/regions/flat.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/structures/buried.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structure-pack/structures/surface.json create mode 100644 adapters/bukkit/plugin/src/test/gameplay/structures.mjs create mode 100644 adapters/bukkit/plugin/src/test/gameplay/studio-open.mjs create mode 100644 adapters/bukkit/plugin/src/test/gameplay/world-lifecycle.mjs create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandPackValidationPublicationTest.java create mode 100644 adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPackUpdateService.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDecoratorHooksTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenPoolTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java create mode 100644 adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeTeleportSafetyTest.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveBoundaryResult.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveComponentResult.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveGrottoResult.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/cave/CavePathResult.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveChamberPlanner.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveGrottoShape.java delete mode 100644 core/src/main/java/art/arcane/iris/pack/IrisPack.java delete mode 100644 core/src/main/java/art/arcane/iris/pack/IrisPackRepository.java create mode 100644 core/src/main/java/art/arcane/iris/pack/PackFingerprints.java create mode 100644 core/src/main/java/art/arcane/iris/world/pregen/PregenChunkFlush.java create mode 100644 core/src/test/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScannerTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/biome/FloatingIslandLockedLayersTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeGenLinksTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/cave/IrisCaveProfileSamplerTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/decoration/DecoratorStackSamplingTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorPaletteSelectionTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBudgetCacheTest.java delete mode 100644 core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterNeighborhoodBenchmarkTest.java delete mode 100644 core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterNeighborhoodTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionCoordinateTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionScratchTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorStyleOverrideTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/GenerationCacheWarmerParallelTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DDensityPlaneReuseTest.java create mode 100644 core/src/test/java/art/arcane/iris/pack/PackFingerprintPlatformIsolationTest.java create mode 100644 core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoReadValidationTest.java create mode 100644 core/src/test/java/art/arcane/iris/structure/placement/StructureCaveAnchorColumnScanTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/IrisCreatorRollbackTest.java create mode 100644 core/src/test/java/art/arcane/iris/world/pregen/PregenChunkFlushTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandPack.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandPack.java index 601b4a115..6bacceda0 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandPack.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandPack.java @@ -18,6 +18,8 @@ package art.arcane.iris.command; +import art.arcane.iris.pack.PackFingerprints; + import art.arcane.iris.Iris; import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.PackDirectoryResolver; @@ -174,7 +176,6 @@ public class CommandPack implements DirectorExecutor { s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack)))); return; } - PackValidationRegistry.remove(packFolder.getName()); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath())))); reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED); return; @@ -230,7 +231,6 @@ public class CommandPack implements DirectorExecutor { s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack)))); return; } - PackValidationRegistry.remove(packFolder.getName()); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath())))); reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED); return; @@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor { return persisted == null || persisted.isBlank() ? "unknown" : persisted; } - private PackValidationResult runValidate(VolmitSender s, File packFolder) { + PackValidationResult runValidate(VolmitSender s, File packFolder) { + PackValidationRegistry.ValidationTicket ticket = + PackValidationRegistry.tryBeginValidation(packFolder.toPath()); + PackValidationResult result; + String contentFingerprint = ""; + String contextFingerprint = ""; try { - PackValidationResult result = PackValidator.validate(packFolder); - PackValidationRegistry.publish(result); - reportResult(s, result); - return result; + if (ticket == null) { + throw new IllegalStateException("Pack content is currently being changed."); + } + contentFingerprint = PackFingerprints.computePackTreeFingerprint(packFolder); + contextFingerprint = PackValidationCache.contextFingerprint(); + result = PackValidator.validate(packFolder); + if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packFolder)) + || !contextFingerprint.equals(PackValidationCache.contextFingerprint())) { + throw new IllegalStateException("Pack content or validation context changed during validation."); + } } catch (Throwable e) { Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e); String detail = e.getMessage() == null || e.getMessage().isBlank() ? e.getClass().getSimpleName() : e.getMessage(); - PackValidationResult result = new PackValidationResult( + result = new PackValidationResult( packFolder.getName(), List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail), List.of(), System.currentTimeMillis()); - PackValidationRegistry.publish(result); - reportResult(s, result); - return result; + contentFingerprint = ""; + contextFingerprint = ""; } + if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) { + PackValidationRegistry.publish(result); + } else { + result = new PackValidationResult(packFolder.getName(), + List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis()); + } + reportResult(s, result); + return result; } private void persistValidationCache(File packsRoot) { - List packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); - List results = new ArrayList<>(packDirectories.size()); - for (File packDirectory : packDirectories) { - PackValidationResult result = PackValidationRegistry.get(packDirectory.getName()); - if (result == null) { - // The boot cache only loads when it covers every pack, so a partial write is - // pointless - but say so instead of silently skipping the persist forever. - Iris.warn("Pack validation cache not written: \"" + packDirectory.getName() - + "\" has no registered result. Run /iris pack validate all to repopulate it."); + try { + String contextFingerprint = PackValidationCache.contextFingerprint(); + PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot); + List packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); + List results = new ArrayList<>(packDirectories.size()); + for (File packDirectory : packDirectories) { + PackValidationResult result = PackValidationRegistry.getMatching( + packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName())); + if (result == null) { + Iris.warn("Pack validation cache not written: \"" + packDirectory.getName() + + "\" has no validation matching its current content. Run /iris pack validate to refresh it."); + return; + } + results.add(result); + } + if (!snapshot.content().equals(PackFingerprints.computePackContentSnapshot(packsRoot).content()) + || !contextFingerprint.equals(PackValidationCache.contextFingerprint())) { + Iris.warn("Pack validation cache not written because pack content or validation context changed."); return; } - results.add(result); - } - try { + for (int index = 0; index < packDirectories.size(); index++) { + File packDirectory = packDirectories.get(index); + if (PackValidationRegistry.getMatching(packDirectory.toPath(), + snapshot.packContents().get(packDirectory.getName())) != results.get(index)) { + Iris.warn("Pack validation cache not written because validation changed during persistence."); + return; + } + } PackValidationCache.save( Iris.instance.getDataFile("cache", "pack-validation.json").toPath(), - PackValidationCache.contentFingerprint(packsRoot), - PackValidationCache.contextFingerprint(), + snapshot.content(), + contextFingerprint, results); } catch (IOException | RuntimeException e) { Iris.reportError("Could not persist refreshed pack-validation results", e); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java index 9fe77c6a3..1081dfbb2 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java @@ -18,8 +18,9 @@ package art.arcane.iris.world; +import art.arcane.iris.pack.PackFingerprints; + import art.arcane.iris.configuration.IrisSettings; -import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.Iris; import art.arcane.iris.world.lifecycle.WorldLifecycleStaging; @@ -184,12 +185,12 @@ public final class IrisWorldGeneratorResolver { } List packNames = packDirs.stream().map(File::getName).sorted().toList(); Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath(); - ServerConfigurator.PackContentSnapshot contentSnapshot = - new ServerConfigurator.PackContentSnapshot("", Map.of()); + PackFingerprints.PackContentSnapshot contentSnapshot = + new PackFingerprints.PackContentSnapshot("", Map.of()); String contextFingerprint = ""; Optional> cached = Optional.empty(); try { - contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot); + contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot); contextFingerprint = PackValidationCache.contextFingerprint(); if (!externalContentChanged) { cached = PackValidationCache.load( @@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver { private static FreshValidation validateStablePacks( File packsRoot, List initialPackDirs, - ServerConfigurator.PackContentSnapshot initialSnapshot + PackFingerprints.PackContentSnapshot initialSnapshot ) { List packDirs = initialPackDirs; - ServerConfigurator.PackContentSnapshot before = initialSnapshot; + PackFingerprints.PackContentSnapshot before = initialSnapshot; for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) { List packNames = packDirs.stream().map(File::getName).sorted().toList(); List results = validatePacks(packDirs); - ServerConfigurator.PackContentSnapshot after; + PackFingerprints.PackContentSnapshot after; try { - after = ServerConfigurator.computePackContentSnapshot(packsRoot); + after = PackFingerprints.computePackContentSnapshot(packsRoot); } catch (RuntimeException exception) { Iris.reportError("Could not verify Iris pack bytes after validation", exception); - after = new ServerConfigurator.PackContentSnapshot("", Map.of()); + after = new PackFingerprints.PackContentSnapshot("", Map.of()); } List afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); List afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList(); @@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver { private record FreshValidation( List packDirs, - ServerConfigurator.PackContentSnapshot contentSnapshot, + PackFingerprints.PackContentSnapshot contentSnapshot, List results, boolean stable ) { diff --git a/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/biomes/flat.json b/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/biomes/flat.json new file mode 100644 index 000000000..d1a004363 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/biomes/flat.json @@ -0,0 +1,47 @@ +{ + "name": "Flat", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [ + { + "block": "minecraft:lime_wool" + } + ] + }, + { + "minHeight": 384, + "maxHeight": 384, + "palette": [ + { + "block": "minecraft:green_concrete" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 96, + "max": 96 + } + ], + "decorators": [ + { + "palette": [ + { + "block": "minecraft:oak_stairs", + "data": { + "half": "bottom", + "facing": "north" + } + } + ], + "chance": 1, + "forcePlace": true + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/dimensions/decorator-fixture.json b/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/dimensions/decorator-fixture.json new file mode 100644 index 000000000..fdd945815 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/dimensions/decorator-fixture.json @@ -0,0 +1,28 @@ +{ + "name": "Decorator Fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "flat" + ], + "focusRegion": "flat", + "focus": "flat", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { + "min": -64, + "max": 320 + }, + "logicalHeight": 384, + "fluidHeight": 0, + "bedrock": false, + "rockPalette": { + "palette": [ + { + "block": "minecraft:red_concrete" + } + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/generators/flat.json b/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/generators/flat.json new file mode 100644 index 000000000..7cd98b811 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/generators/flat.json @@ -0,0 +1,15 @@ +{ + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "seed": 310, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/regions/flat.json b/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/regions/flat.json new file mode 100644 index 000000000..d6fff925f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/decorator-pack/regions/flat.json @@ -0,0 +1,12 @@ +{ + "name": "Flat", + "landBiomes": [ + "flat" + ], + "seaBiomes": [ + "flat" + ], + "shoreBiomes": [ + "flat" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/decorators.mjs b/adapters/bukkit/plugin/src/test/gameplay/decorators.mjs new file mode 100644 index 000000000..c0f49c1de --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/decorators.mjs @@ -0,0 +1,40 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +export default { + name: 'iris-decorators', + description: 'Verify authored stair decorations occupy one block and retain their half state.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Decorator generation requires an isolated instance') + await context.step('create a world with authored stair decorations', async () => { + const response = await context.command('/iris create name=decorator_probe type=decorator-fixture seed=78264193', + /Successfully created your world|Failed to create/, 180000) + context.expect(response.includes('Successfully created your world'), response) + }) + await context.step('client sees single bottom-half stair decorations', async () => { + let checked = 0 + await context.waitUntil(() => { + checked = 0 + const center = context.bot.entity.position.floored() + for (let x = -6; x <= 6; x++) { + for (let z = -6; z <= 6; z++) { + for (let y = -4; y <= 1; y++) { + const position = center.offset(x, y, z) + if (context.bot.blockAt(position)?.name !== 'lime_wool') continue + const decoration = context.bot.blockAt(position.offset(0, 1, 0)) + const above = context.bot.blockAt(position.offset(0, 2, 0)) + if (!decoration || !above) return false + if (decoration.name !== 'oak_stairs' || decoration.getProperties().half !== 'bottom' + || above.name !== 'air') return false + checked++ + } + } + } + return checked >= 100 + }, { label: 'At least 100 authored stair columns with empty space above', timeoutMs: 60000, intervalMs: 250 }) + context.expect(checked >= 100, 'Client verified generated decorator columns', { checked }) + context.report.decoratorColumns = checked + }) + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/NativeLifecycleFixture.java b/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/NativeLifecycleFixture.java index 3851d2289..da21e7187 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/NativeLifecycleFixture.java +++ b/adapters/bukkit/plugin/src/test/gameplay/native-lifecycle-fixture/src/art/arcane/iris/fixture/NativeLifecycleFixture.java @@ -4,6 +4,13 @@ import art.arcane.volmlib.nativelib.NativeAdapters; import art.arcane.iris.world.runtime.RuntimeInjection; import art.arcane.iris.world.IrisCreator; import art.arcane.iris.world.IrisToolbelt; +import art.arcane.iris.world.IrisWorldStorage; +import art.arcane.iris.world.WorldCreatorCompat; +import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator; +import art.arcane.iris.platform.bootstrap.ServerProperties; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.Material; import org.bukkit.block.Biome; import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess; @@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender; import org.bukkit.generator.ChunkGenerator; import org.bukkit.plugin.java.JavaPlugin; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.util.concurrent.CompletableFuture; import java.util.List; import java.util.logging.Level; @@ -26,6 +37,7 @@ import java.util.logging.Level; public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution { private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe"); private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe"); + private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe"); private static final long WORLD_SEED = 78264193L; private NativeWorldRuntime runtime; @@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { if (arguments.length != 1) { - sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload"); + sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry"); return true; } try { @@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun case "terrain-create" -> createTerrain(sender); case "terrain-check" -> checkTerrain(sender); case "terrain-unload" -> unloadTerrain(sender); + case "lifecycle-status" -> lifecycleStatus(sender); + case "lifecycle-entry" -> lifecycleEntry(sender); case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available() + " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null)); default -> throw new IllegalArgumentException("Unknown lifecycle operation"); @@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun return true; } + private void lifecycleStatus(CommandSender sender) throws Exception { + World world = Bukkit.getWorld(COMMAND_KEY); + Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath(); + Path levelRoot = IrisWorldStorage.levelRoot().toPath(); + Path registry = levelRoot.resolve("iris/worlds.json"); + boolean registered = Files.exists(registry) + && Files.readString(registry).contains("\"" + COMMAND_KEY + "\""); + String startupName = IrisWorldStorage.configuredWorldName(COMMAND_KEY, levelRoot.getFileName().toString()); + YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML); + boolean configured = configuration.getConfigurationSection("worlds") != null + && configuration.getConfigurationSection("worlds").contains(startupName); + File quarantineRoot = levelRoot.resolve("dimensions/iris").toFile(); + File[] quarantines = quarantineRoot.listFiles((directory, name) -> name.startsWith(".iris-delete-")); + int quarantineCount = quarantines == null ? 0 : quarantines.length; + Path queueFile = Bukkit.getPluginManager().getPlugin("Iris").getDataFolder().toPath() + .resolve("pending-world-deletes.txt"); + List queue = Files.exists(queueFile) ? Files.readAllLines(queueFile) : List.of(); + boolean queued = quarantineCount > 0; + if (quarantines != null) { + for (File quarantine : quarantines) { + queued &= queue.contains(quarantine.getName()); + } + } + boolean disk = Files.exists(storage, LinkOption.NOFOLLOW_LINKS); + String cleanup = disk ? "present" : quarantineCount == 0 ? "deleted" : queued ? "queued" : "unsafe"; + boolean idle = true; + LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get(); + for (LifecycleOperationCoordinator.Domain domain : LifecycleOperationCoordinator.Domain.values()) { + idle &= coordinator.active(domain).isEmpty(); + } + sender.sendMessage("IRIS_LIFECYCLE loaded=" + (world != null) + " registered=" + registered + + " configured=" + configured + " disk=" + disk + " quarantines=" + quarantineCount + + " cleanup=" + cleanup + " idle=" + idle); + } + + private void lifecycleEntry(CommandSender sender) { + if (!(sender instanceof Player player)) { + throw new IllegalArgumentException("Lifecycle entry requires a player"); + } + World world = Bukkit.getWorld(COMMAND_KEY); + if (world == null || player.getWorld() != world || world.getSeed() != WORLD_SEED + || !IrisToolbelt.isIrisWorld(world)) { + throw new IllegalStateException("Player did not enter the requested seeded Iris world"); + } + Location feet = player.getLocation(); + Material floor = world.getBlockAt(feet.getBlockX(), feet.getBlockY() - 1, feet.getBlockZ()).getType(); + if (floor != Material.LIME_WOOL || !floor.isSolid() + || !world.getBlockAt(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()).isPassable() + || !world.getBlockAt(feet.getBlockX(), feet.getBlockY() + 1, feet.getBlockZ()).isPassable()) { + throw new IllegalStateException("Lifecycle player entry lacks a safe fixture floor"); + } + sender.sendMessage("IRIS_LIFECYCLE_ENTRY world=" + COMMAND_KEY + " seed=" + world.getSeed() + + " floor=lime_wool safe=true"); + } + private void create(CommandSender sender) throws ReflectiveOperationException { if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) { throw new IllegalStateException("Native runtime unavailable or probe world already loaded"); @@ -124,6 +193,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun int y = world.getHighestBlockYAt(x, z); if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL || world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE + || world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE || world.getBiome(x, y, z) != Biome.PLAINS) { throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z + ": " + world.getBlockAt(x, y, z).getType()); diff --git a/adapters/bukkit/plugin/src/test/gameplay/native-terrain-pack/biomes/flat.json b/adapters/bukkit/plugin/src/test/gameplay/native-terrain-pack/biomes/flat.json index ecc940922..ba493aa72 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/native-terrain-pack/biomes/flat.json +++ b/adapters/bukkit/plugin/src/test/gameplay/native-terrain-pack/biomes/flat.json @@ -11,6 +11,15 @@ "block": "minecraft:lime_wool" } ] + }, + { + "minHeight": 384, + "maxHeight": 384, + "palette": [ + { + "block": "minecraft:green_concrete" + } + ] } ], "generators": [ diff --git a/adapters/bukkit/plugin/src/test/gameplay/native-terrain-pack/dimensions/native-terrain.json b/adapters/bukkit/plugin/src/test/gameplay/native-terrain-pack/dimensions/native-terrain.json index 22f965b36..aa81aaa1b 100644 --- a/adapters/bukkit/plugin/src/test/gameplay/native-terrain-pack/dimensions/native-terrain.json +++ b/adapters/bukkit/plugin/src/test/gameplay/native-terrain-pack/dimensions/native-terrain.json @@ -21,7 +21,7 @@ "rockPalette": { "palette": [ { - "block": "minecraft:green_concrete" + "block": "minecraft:red_concrete" } ] } diff --git a/adapters/bukkit/plugin/src/test/gameplay/noise-pack/biomes/flat.json b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/biomes/flat.json new file mode 100644 index 000000000..816cc6c63 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/biomes/flat.json @@ -0,0 +1,32 @@ +{ + "name": "Flat", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [ + { + "block": "minecraft:lime_wool" + } + ] + }, + { + "minHeight": 384, + "maxHeight": 384, + "palette": [ + { + "block": "minecraft:green_concrete" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 64, + "max": 96 + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/noise-pack/dimensions/noise-fixture.json b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/dimensions/noise-fixture.json new file mode 100644 index 000000000..39b0f3267 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/dimensions/noise-fixture.json @@ -0,0 +1,28 @@ +{ + "name": "Noise Sources", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "flat" + ], + "focusRegion": "flat", + "focus": "flat", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { + "min": -64, + "max": 320 + }, + "logicalHeight": 384, + "fluidHeight": 0, + "bedrock": false, + "rockPalette": { + "palette": [ + { + "block": "minecraft:red_concrete" + } + ] + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/noise-pack/expressions/terrain.json b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/expressions/terrain.json new file mode 100644 index 000000000..7516b362b --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/expressions/terrain.json @@ -0,0 +1,3 @@ +{ + "expression": "0.5 + sin(x * 0.15) * 0.25 + cos(y * 0.13) * 0.25" +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/noise-pack/generators/flat.json b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/generators/flat.json new file mode 100644 index 000000000..41d0570af --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/generators/flat.json @@ -0,0 +1,22 @@ +{ + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "seed": 310, + "composite": [ + { + "seed": 310, + "style": { + "expression": "terrain" + } + }, + { + "seed": 311, + "style": { + "style": "CLOVER_HERMITE", + "zoom": 0.1 + } + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/noise-pack/regions/flat.json b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/regions/flat.json new file mode 100644 index 000000000..d6fff925f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/noise-pack/regions/flat.json @@ -0,0 +1,12 @@ +{ + "name": "Flat", + "landBiomes": [ + "flat" + ], + "seaBiomes": [ + "flat" + ], + "shoreBiomes": [ + "flat" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/noise.mjs b/adapters/bukkit/plugin/src/test/gameplay/noise.mjs new file mode 100644 index 000000000..b988e353d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/noise.mjs @@ -0,0 +1,48 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +export default { + name: 'iris-noise', + description: 'Verify expression and interpolated noise produce varied, bounded terrain visible to a player.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Noise generation requires an isolated instance') + await context.step('create a world with expression and Clover noise', async () => { + const response = await context.command('/iris create name=noise_probe type=noise-fixture seed=78264193', + /Successfully created your world|Failed to create/, 180000) + context.expect(response.includes('Successfully created your world'), response) + }) + await context.step('client observes bounded terrain with varying heights', async () => { + let columns = 0 + let minimum = Infinity + let maximum = -Infinity + await context.waitUntil(() => { + columns = 0 + minimum = Infinity + maximum = -Infinity + const center = context.bot.entity.position.floored() + for (let x = -8; x <= 8; x++) { + for (let z = -8; z <= 8; z++) { + let surface = null + for (let y = 60; y <= 100; y++) { + const position = center.offset(x, y - center.y, z) + const block = context.bot.blockAt(position) + if (!block) return false + if (block.name === 'lime_wool') { + if (surface !== null || context.bot.blockAt(position.offset(0, 1, 0))?.name !== 'air') return false + surface = y + } + } + if (surface === null || surface < 64 || surface > 96) return false + columns++ + minimum = Math.min(minimum, surface) + maximum = Math.max(maximum, surface) + } + } + return columns === 289 && maximum - minimum >= 3 + }, { label: '289 noise columns with bounded, varied surfaces', timeoutMs: 60000, intervalMs: 250 }) + context.expect(columns === 289 && maximum - minimum >= 3, 'Client verified noise terrain', { columns, minimum, maximum }) + context.report.noiseTerrain = { columns, minimum, maximum } + }) + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/biomes/flat.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/biomes/flat.json new file mode 100644 index 000000000..bec624cc5 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/biomes/flat.json @@ -0,0 +1,45 @@ +{ + "name": "Flat", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + { + "minHeight": 1, + "maxHeight": 1, + "palette": [ + { + "block": "minecraft:lime_wool" + } + ] + }, + { + "minHeight": 384, + "maxHeight": 384, + "palette": [ + { + "block": "minecraft:stone" + } + ] + } + ], + "generators": [ + { + "generator": "flat", + "min": 64, + "max": 64 + } + ], + "objects": [ + { + "place": [ + "object" + ], + "chance": 1, + "density": 4, + "mode": "CENTER_HEIGHT", + "rotation": { + "enabled": false + } + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/dimensions/structure-fixture.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/dimensions/structure-fixture.json new file mode 100644 index 000000000..54f12a3e2 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/dimensions/structure-fixture.json @@ -0,0 +1,66 @@ +{ + "name": "Structure Fixture", + "version": 1, + "mode": { + "type": "OVERWORLD" + }, + "regions": [ + "flat" + ], + "focusRegion": "flat", + "focus": "flat", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": { + "min": -64, + "max": 320 + }, + "logicalHeight": 384, + "fluidHeight": 0, + "bedrock": false, + "rockPalette": { + "palette": [ + { + "block": "minecraft:stone" + } + ] + }, + "structures": [ + { + "structures": [ + "surface" + ], + "placementId": "surface", + "distribution": "DENSITY", + "density": 1, + "anchor": "SURFACE", + "terrain": { + "mode": "PRESERVE" + }, + "stilt": { + "maxDepth": 16, + "palette": { + "palette": [ + { + "block": "minecraft:diamond_block" + } + ] + } + } + }, + { + "structures": [ + "buried" + ], + "placementId": "buried", + "distribution": "DENSITY", + "density": 1, + "anchor": "HEIGHT_BAND", + "minHeight": 24, + "maxHeight": 24, + "terrain": { + "mode": "BORE" + } + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/generators/flat.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/generators/flat.json new file mode 100644 index 000000000..7cd98b811 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/generators/flat.json @@ -0,0 +1,15 @@ +{ + "interpolator": { + "function": "NONE", + "horizontalScale": 1 + }, + "seed": 310, + "composite": [ + { + "seed": 310, + "style": { + "style": "FLAT" + } + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pieces/buried.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pieces/buried.json new file mode 100644 index 000000000..bff099644 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pieces/buried.json @@ -0,0 +1,8 @@ +{ + "object": "buried", + "connectors": [], + "rotatable": false, + "rules": { + "terminal": true + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pieces/surface.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pieces/surface.json new file mode 100644 index 000000000..6eab87e84 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pieces/surface.json @@ -0,0 +1,8 @@ +{ + "object": "surface", + "connectors": [], + "rotatable": false, + "rules": { + "terminal": true + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pools/buried.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pools/buried.json new file mode 100644 index 000000000..2ff07dc97 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pools/buried.json @@ -0,0 +1,8 @@ +{ + "pieces": [ + { + "piece": "buried", + "weight": 1 + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pools/surface.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pools/surface.json new file mode 100644 index 000000000..571936a9b --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/jigsaw-pools/surface.json @@ -0,0 +1,8 @@ +{ + "pieces": [ + { + "piece": "surface", + "weight": 1 + } + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/objects/buried.iob b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/objects/buried.iob new file mode 100644 index 0000000000000000000000000000000000000000..5225b184726c2eb790c3ee89517355f915b5d43f GIT binary patch literal 1076 zcmYk4u};H45JbrHx8eIZ%;RZnch`f%H0%4n<;<@&>k?Jo zaqCKZ>r&jZgws3u;udw*cbjDnD!Fh9!ga-VsmH0guDGtazPP?PeQmBUt~aOY%%Sx4 zw59kUrY{dO`f2J7U8>HG&51DwOP;Q!<`jjCk1%lfh=z*~-A>hu58sw+#|LpagQI(T zCh|1v4TZZpbWhWEstKaz{3QkL!yCz3rkRWj&2C0Xs0n-rmP+0~B0IQKJ5&!@I literal 0 HcmV?d00001 diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/objects/surface.iob b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/objects/surface.iob new file mode 100644 index 0000000000000000000000000000000000000000..d74e9937faf2f99b93f168fd1f6ac1716ea175f5 GIT binary patch literal 453 zcmXxfu?~Vz3`XGsE++1-yn>FpxiPu98bnNppb7Z;g8%8QB)4A*IRwmXZU3{W#%?Mv z`?5Npj+WVW=w59z){oDj9on((@7G)ZZk`Hu%hy&q=FZ`g)N{YDS0wYWkaKiMhjh|A zq(eHSTcSfcq?6tu9nzsXUH+Y}I#(U~M0LKabLq0^kPhjjW|EGb4(amfkPhjjW|EGb U4(W>MkPhjjW|EGb4%7eH56W9%wg3PC literal 0 HcmV?d00001 diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/regions/flat.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/regions/flat.json new file mode 100644 index 000000000..d6fff925f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/regions/flat.json @@ -0,0 +1,12 @@ +{ + "name": "Flat", + "landBiomes": [ + "flat" + ], + "seaBiomes": [ + "flat" + ], + "shoreBiomes": [ + "flat" + ] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/structures/buried.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/structures/buried.json new file mode 100644 index 000000000..c695be737 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/structures/buried.json @@ -0,0 +1,6 @@ +{ + "startPool": "buried", + "maxDepth": 1, + "maxSizeChunks": 1, + "placeMode": "STRUCTURE_PIECE" +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structure-pack/structures/surface.json b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/structures/surface.json new file mode 100644 index 000000000..4af2c8150 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structure-pack/structures/surface.json @@ -0,0 +1,6 @@ +{ + "startPool": "surface", + "maxDepth": 1, + "maxSizeChunks": 1, + "placeMode": "STRUCTURE_PIECE" +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/structures.mjs b/adapters/bukkit/plugin/src/test/gameplay/structures.mjs new file mode 100644 index 000000000..d60d04c18 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/structures.mjs @@ -0,0 +1,52 @@ +import { readFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import path from 'node:path' + +export default { + name: 'iris-structures', + description: 'Verify surface foundations, buried rooms, and waterlogged object blocks in generated chunks.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Structure generation requires an isolated instance') + const world = context.options.command ?? 'structure_probe' + context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier') + await context.step('create the structure fixture world', async () => { + const response = await context.command(`/iris create name=${world} type=structure-fixture seed=78264193`, + /World ready|Failed to create/, 180000) + context.expect(response.includes('World ready'), response) + }) + await context.step('observe structures and objects above and below terrain', async () => { + await context.waitUntil(() => { + if (Math.abs(context.bot.entity.position.x) > 32 || Math.abs(context.bot.entity.position.z) > 32) return false + for (let x = -16; x < 32; x += 16) { + for (let z = -16; z < 32; z += 16) { + for (let y = 16; y <= 80; y += 16) { + if (!context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))) return false + } + } + } + return true + }, { label: 'Complete structure fixture chunks', timeoutMs: 60000, intervalMs: 100 }) + const digest = createHash('sha256') + const counts = { emerald_block: 0, diamond_block: 0, blue_concrete: 0, gold_block: 0, oak_slab: 0 } + for (let x = -16; x < 32; x++) { + for (let z = -16; z < 32; z++) { + for (let y = 16; y <= 80; y++) { + const block = context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z)) + context.expect(block !== null, 'Every sampled structure block must be loaded') + digest.update(`${block.stateId}\n`) + if (Object.hasOwn(counts, block.name)) counts[block.name]++ + if (block.name === 'emerald_block') context.expect(y > 64, 'Surface platform must be above terrain') + if (block.name === 'blue_concrete') context.expect(y < 40, 'Buried room must remain underground') + if (block.name === 'oak_slab') context.expect(block.getProperties().waterlogged === false, + 'Object slabs placed on dry ground must clear waterlogging') + } + } + } + for (const [block, count] of Object.entries(counts)) { + context.expect(count > 0, `Generated fixture must contain ${block}`, { count }) + } + context.report.structures = { counts, hash: digest.digest('hex') } + }) + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/studio-open.mjs b/adapters/bukkit/plugin/src/test/gameplay/studio-open.mjs new file mode 100644 index 000000000..d1cc346d0 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/studio-open.mjs @@ -0,0 +1,74 @@ +import { readFile, stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import path from 'node:path' + +export default { + name: 'iris-studio-open', + description: 'Measure repeated Studio entry and verify visible terrain, spectator mode, and world cleanup.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Studio entry requires an isolated instance') + const match = /^([a-z0-9_-]+):([1-4])$/.exec(context.options.command ?? 'overworld:2') + context.expect(match, 'Expected pack:cycles with one to four cycles') + context.report.studioOpen = [] + for (let cycle = 0; cycle < Number(match[2]); cycle++) { + await context.step(`open and close Studio ${cycle + 1}`, async () => { + const before = new Set((await context.observe()).worlds.map(world => world.id)) + const start = Date.now() + let world + let center + await context.command(`/iris studio open ${match[1]} seed=1337`) + await context.waitUntil(async () => { + const observation = await context.observe() + const player = observation.players.find(entry => entry.username === context.bot.username) + world = observation.worlds.find(entry => entry.id === player?.world) + if (!world || before.has(world.id) || !/iris-[0-9a-f-]{36}$/.test(world.name)) return false + center = context.bot.entity.position.floored() + return world.loadedChunks > 0 && context.bot.blockAt(center) !== null && context.bot.game.gameMode === 'spectator' + }, { label: 'Studio arrival with terrain and spectator mode', timeoutMs: 180000, intervalMs: 100 }) + const arrivalMs = Date.now() - start + context.expect(Number.isInteger(context.bot.game.minY) && Number.isInteger(context.bot.game.height), + 'Studio dimension must report its vertical range') + await context.waitUntil(() => { + for (let x = -1; x <= 1; x++) { + for (let z = -1; z <= 1; z++) { + for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y += 16) { + if (context.bot.blockAt(center.offset(x, y - center.y, z)) === null) return false + } + } + } + return true + }, { label: 'Complete neighboring Studio columns', timeoutMs: 60000, intervalMs: 100 }) + const digest = createHash('sha256') + let solid = 0 + for (let x = -1; x <= 1; x++) { + for (let z = -1; z <= 1; z++) { + for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y++) { + const block = context.bot.blockAt(center.offset(x, y - center.y, z)) + context.expect(block !== null, 'Studio entry column must be available') + digest.update(`${block.name}\n`) + if (!['air', 'cave_air', 'void_air', 'water', 'lava'].includes(block.name)) solid++ + } + } + } + context.expect(solid > 0, 'Studio entry must contain solid terrain') + context.report.studioOpen.push({ cycle, arrivalMs, world: world.name, x: center.x, z: center.z, + terrainHash: digest.digest('hex'), solid }) + const closed = await context.command('/iris studio close', /^Studio closed\.$|Studio close failed:/, 180000) + context.expect(closed === 'Studio closed.', closed) + await context.waitUntil(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id), + { label: 'Studio world unload', timeoutMs: 90000, intervalMs: 100 }) + const transientName = /iris-[0-9a-f-]{36}$/.exec(world.name)[0] + const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8') + const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world' + const directory = path.join(context.server.directory, level, 'dimensions', 'iris', transientName) + await context.waitUntil(async () => { + try { await stat(directory); return false } catch (error) { + if (error.code === 'ENOENT') return true + throw error + } + }, { label: 'Studio directory cleanup', timeoutMs: 90000, intervalMs: 100 }) + }) + } + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/world-lifecycle.mjs b/adapters/bukkit/plugin/src/test/gameplay/world-lifecycle.mjs new file mode 100644 index 000000000..e8e2b5a9a --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/world-lifecycle.mjs @@ -0,0 +1,98 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +const worldName = 'lifecycle_probe' +const worldKey = `iris:${worldName}` +const storageName = `world_iris_${worldName}` + +export default { + name: 'iris-world-lifecycle', + description: 'Create, enter, evacuate, reload, and remove an Iris world through the player commands.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'World lifecycle requires an isolated instance') + const wait = (predicate, label, timeoutMs = 180000) => context.waitUntil(predicate, { + label, timeoutMs, intervalMs: 250 + }) + const playerWorld = async () => { + const observation = await context.observe() + const player = observation.players.find(entry => entry.username === context.bot.username) + return observation.worlds.find(entry => entry.id === player?.world)?.name + } + const status = async () => { + const response = await context.command('/irislifecycle lifecycle-status', /IRIS_LIFECYCLE |NATIVE_FAILED/, 10000) + context.expect(!response.includes('NATIVE_FAILED'), response) + return response + } + const settledStatus = async expected => { + const response = await status() + return response.includes(expected) && response.includes('idle=true') + } + const assertEvacuated = async label => { + let destination + await wait(async () => { + destination = await playerWorld() + return Boolean(destination && destination !== storageName) + }, label, 30000) + context.expect(destination && destination !== storageName, label, { destination }) + } + const assertSafeEntry = async () => { + await wait(async () => (await playerWorld()) === storageName, 'Player entered the lifecycle world') + await wait(() => { + const feet = context.bot.entity.position.floored() + const floor = context.bot.blockAt(feet.offset(0, -1, 0)) + const body = context.bot.blockAt(feet) + const head = context.bot.blockAt(feet.offset(0, 1, 0)) + return context.bot.entity.onGround && floor?.name === 'lime_wool' + && body?.boundingBox === 'empty' && head?.boundingBox === 'empty' + }, 'Client sees solid terrain below unobstructed player entry', 30000) + const response = await context.command('/irislifecycle lifecycle-entry', /IRIS_LIFECYCLE_ENTRY|NATIVE_FAILED/, 10000) + context.expect(response.includes(`IRIS_LIFECYCLE_ENTRY world=${worldKey} seed=78264193 floor=lime_wool safe=true`), response) + } + await context.step('confirm disposable world is absent', async () => { + const response = await status() + context.expect(response.includes('loaded=false registered=false configured=false disk=false quarantines=0 cleanup=deleted idle=true'), response) + await context.command('/minecraft:gamemode survival @s') + await wait(() => context.bot.game.gameMode === 'survival', 'Survival mode', 10000) + }) + await context.step('create through Iris and automatically enter safe terrain', async () => { + await context.command(`/iris create name=${worldName} type=native-terrain seed=78264193`) + await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'Created world registration and storage') + await assertSafeEntry() + }) + await context.step('unload while the player is inside and verify evacuation', async () => { + context.expect((await playerWorld()) === storageName, 'Player must be inside the world before unload') + await context.command(`/iris unloadWorld ${worldKey}`) + await wait(async () => await settledStatus('loaded=false registered=true configured=true disk=true'), 'World unloaded with storage preserved') + await assertEvacuated('Unload evacuated the player to a loaded world') + }) + await context.step('reload the saved world and enter it again', async () => { + await context.command(`/iris loadWorld ${worldName}`) + await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'World reloaded') + await context.command(`/iris tp ${worldKey}`) + await assertSafeEntry() + }) + await context.step('remove the occupied world and verify disk cleanup', async () => { + await context.command(`/iris remove world=${worldName} delete=true`) + let finalStatus + await wait(async () => { + finalStatus = await status() + return finalStatus.includes('loaded=false registered=false configured=false disk=false') + && /cleanup=(deleted|queued)\b/.test(finalStatus) && finalStatus.includes('idle=true') + }, 'Removal detached registrations and deleted or queued quarantine') + await assertEvacuated('Removal evacuated the player to a loaded world') + context.report.worldLifecycle = { world: worldKey, cleanup: /cleanup=(deleted|queued)\b/.exec(finalStatus)[1] } + }) + if (process.env.IRIS_QA_PACK_URL) { + await context.step('download and overwrite the fixture pack through Iris', async () => { + const response = await context.command( + `/iris download link=${process.env.IRIS_QA_PACK_URL} overwrite=true`, + /Iris pack 'native-terrain' installed|Iris pack download failed\./, + 120000 + ) + context.expect(response.includes("Iris pack 'native-terrain' installed"), response) + }) + } + + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandPackValidationPublicationTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandPackValidationPublicationTest.java new file mode 100644 index 000000000..a2e39ee53 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/command/CommandPackValidationPublicationTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.command; + +import art.arcane.iris.pack.PackFingerprints; + +import art.arcane.iris.Iris; +import art.arcane.iris.pack.PackValidationRegistry; +import art.arcane.iris.pack.PackValidationCache; +import art.arcane.iris.pack.PackValidationResult; +import art.arcane.iris.pack.PackValidator; +import art.arcane.iris.platform.bukkit.plugin.VolmitSender; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.File; +import java.nio.file.Files; +import java.util.List; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +public class CommandPackValidationPublicationTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @After + public void clearValidation() { + PackValidationRegistry.clear(); + } + + @Test + public void changedCachedPackCannotBePersistedAsFreshValidation() throws Exception { + File packs = temporaryFolder.newFolder("packs"); + File root = new File(packs, "terrain"); + Files.createDirectories(root.toPath()); + File content = new File(root, "dimension.json"); + Files.writeString(content.toPath(), "original"); + File cacheFile = new File(temporaryFolder.getRoot(), "validation-cache.json"); + Iris previousPlugin = Iris.instance; + Iris plugin = mock(Iris.class); + Iris.instance = plugin; + when(plugin.getDataFile("cache", "pack-validation.json")).thenReturn(cacheFile); + try (MockedStatic iris = mockStatic(Iris.class); + MockedStatic cache = mockStatic(PackValidationCache.class, CALLS_REAL_METHODS)) { + cache.when(PackValidationCache::contextFingerprint).thenReturn("context"); + PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L); + PackValidationRegistry.publish(valid); + PackValidationRegistry.publish(root.toPath(), valid, + PackFingerprints.computePackTreeFingerprint(root), "context"); + Files.writeString(content.toPath(), "changed"); + Method persist = CommandPack.class.getDeclaredMethod("persistValidationCache", File.class); + persist.setAccessible(true); + + persist.invoke(new CommandPack(), packs); + + assertFalse(cacheFile.exists()); + Files.writeString(content.toPath(), "original"); + + persist.invoke(new CommandPack(), packs); + + assertTrue(cacheFile.exists()); + assertTrue(PackValidationCache.load(cacheFile.toPath(), + PackFingerprints.computePackContentSnapshot(packs).content(), "context", + List.of(root.getName())).orElseThrow().getFirst().isLoadable()); + } finally { + Iris.instance = previousPlugin; + } + } + + @Test + public void commandPublishesCanonicalRootValidation() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(root)).thenReturn(valid); + + assertSame(valid, new CommandPack().runValidate(mock(VolmitSender.class), root)); + + assertSame(valid, PackValidationRegistry.get(root.toPath())); + assertSame(valid, PackValidationRegistry.get(root.getName())); + } + } + + @Test + public void mutationDuringValidationCannotRepublishStaleResult() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + try (MockedStatic validator = mockStatic(PackValidator.class); + MockedStatic iris = mockStatic(Iris.class)) { + validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> { + try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) { + return new PackValidationResult(root.getName(), List.of(), List.of(), 1L); + } + }); + + assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable()); + assertNull(PackValidationRegistry.get(root.toPath())); + assertNull(PackValidationRegistry.get(root.getName())); + } + } + + @Test + public void externalEditDuringValidationPublishesBlockedResult() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + File content = new File(root, "dimension.json"); + Files.writeString(content.toPath(), "original"); + try (MockedStatic validator = mockStatic(PackValidator.class); + MockedStatic iris = mockStatic(Iris.class)) { + validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> { + Files.writeString(content.toPath(), "changed"); + return new PackValidationResult(root.getName(), List.of(), List.of(), 1L); + }); + + assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable()); + assertFalse(PackValidationRegistry.get(root.toPath()).isLoadable()); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java index 70ba9c6b2..5208b1a09 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java @@ -50,7 +50,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi int xx = rX + dx; int yy = y + dy; int zz = rZ + dz; - return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight() + return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy >= hunk.getHeight() ? null : hunk.get(xx, yy, zz); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 0936c651e..5ccf22f50 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -70,6 +70,7 @@ import art.arcane.iris.modded.service.ModdedEngineMaintenanceService; import art.arcane.iris.modded.service.ModdedEntitySpawnService; import art.arcane.iris.modded.service.ModdedLogFilterService; import art.arcane.iris.modded.service.ModdedPreservationService; +import art.arcane.iris.modded.service.ModdedPackUpdateService; import art.arcane.iris.modded.service.ModdedSettingsHotloadService; import art.arcane.iris.modded.service.ModdedStudioHotloadService; import art.arcane.iris.modded.service.ModdedTreeFellerService; @@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap { createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService()); createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); + createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService()); ModdedStudioHotloadService studioHotloadService = createdServices.register( ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java index f2375ed1d..45e189eab 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java @@ -27,7 +27,6 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; /** * Owns the Iris generation pool and the single decision of whether this loader already runs chunk @@ -43,7 +42,7 @@ public final class ModdedGenPool { private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L; private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger(); private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem(); - private static final AtomicReference GEN_POOL = new AtomicReference<>(createGenPool()); + private static ExecutorService generationPool = createGenPool(); private ModdedGenPool() { } @@ -63,39 +62,32 @@ public final class ModdedGenPool { return CHUNK_SYSTEM.description(); } - static ExecutorService pool() { - ExecutorService pool = GEN_POOL.get(); - if (pool != null && !pool.isShutdown()) { - return pool; - } - start(); - ExecutorService restarted = GEN_POOL.get(); - if (restarted == null) { + static synchronized ExecutorService pool() { + if (generationPool.isShutdown()) { throw new RejectedExecutionException("Iris gen pool is shut down"); } - return restarted; + return generationPool; } - static void start() { - while (true) { - ExecutorService current = GEN_POOL.get(); - if (current != null && !current.isShutdown()) { - return; - } - ExecutorService created = createGenPool(); - if (GEN_POOL.compareAndSet(current, created)) { - return; - } - created.shutdownNow(); + static synchronized void start() { + if (!generationPool.isShutdown()) { + return; } + if (!generationPool.isTerminated()) { + throw new IllegalStateException("Iris gen pool cannot restart while prior workers are still active"); + } + generationPool = createGenPool(); } static void shutdown() { - ExecutorService pool = GEN_POOL.getAndSet(null); - if (pool == null) { - return; + ExecutorService pool; + synchronized (ModdedGenPool.class) { + pool = generationPool; + if (pool.isTerminated()) { + return; + } + pool.shutdown(); } - pool.shutdown(); try { if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) { return; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java index 0179fc25e..98aa655e6 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedIrisSplash.java @@ -20,13 +20,11 @@ package art.arcane.iris.modded; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; -import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.diagnostics.splash.IrisSplashComposer; import art.arcane.iris.diagnostics.splash.IrisSplashRenderer; import art.arcane.iris.spi.IrisLogging; -import java.io.File; public final class ModdedIrisSplash { @@ -34,18 +32,11 @@ public final class ModdedIrisSplash { } public static void print(NativeModdedLoader loader) { - printPacks(loader); if (isLogoEnabled()) { printLogo(loader); } } - private static void printPacks(NativeModdedLoader loader) { - File packFolder = IrisPlatforms.get().packsFolderNoCreate(); - IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError) - .thenAccept(lines -> lines.forEach(IrisLogging::info)); - } - private static void printLogo(NativeModdedLoader loader) { String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion(); String[] splash = IrisSplashRenderer.renderPlain(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java index 861f73b37..460652935 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java @@ -27,16 +27,15 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; public final class ModdedPrimaryWorldRouter { private static final int TICK_INTERVAL = 20; - private static final Set routed = ConcurrentHashMap.newKeySet(); - private static final Set inFlight = ConcurrentHashMap.newKeySet(); + private static final ConcurrentMap> routes = new ConcurrentHashMap<>(); private static int tickCounter = 0; private static volatile String loginRefusal; private static volatile String absentPrimary; @@ -45,8 +44,12 @@ public final class ModdedPrimaryWorldRouter { } public static void clear() { - routed.clear(); - inFlight.clear(); + routes.forEach((id, route) -> { + if (routes.remove(id, route)) { + route.cancel(false); + } + }); + tickCounter = 0; loginRefusal = null; absentPrimary = null; } @@ -57,8 +60,10 @@ public final class ModdedPrimaryWorldRouter { */ public static void forget(UUID player) { if (player != null) { - routed.remove(player); - inFlight.remove(player); + CompletableFuture route = routes.remove(player); + if (route != null) { + route.cancel(false); + } } } @@ -110,36 +115,52 @@ public final class ModdedPrimaryWorldRouter { return; } - server.forEachPlayer(player -> { - UUID id = player.id(); - if (routed.contains(id) || !inFlight.add(id)) { - return; + server.forEachPlayer(player -> routePlayer(server, player, overworld, target, primary)); + } + + private static void routePlayer(NativeModdedServer server, NativeProtocolPlayer player, + NativeWorld overworld, NativeWorld target, String primary) { + UUID id = player.id(); + if (routes.containsKey(id)) { + return; + } + CompletableFuture route = new CompletableFuture<>(); + if (routes.putIfAbsent(id, route) != null) { + return; + } + route.whenComplete((success, failure) -> { + if (!Boolean.TRUE.equals(success)) { + routes.remove(id, route); } - if (!player.isInWorld(overworld)) { - inFlight.remove(id); - routed.add(id); - return; - } - try { - CompletableFuture teleport = NativeWorldTeleport.teleport(player, - new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(), - ModdedTeleportDeadline.fromNow())); - teleport.whenComplete((success, failure) -> { - inFlight.remove(id); - if (Boolean.TRUE.equals(success) && failure == null) { - routed.add(id); - return; - } - if (failure != null) { + }); + if (!player.isInWorld(overworld)) { + route.complete(true); + return; + } + try { + CompletableFuture teleport = NativeWorldTeleport.teleport(player, + new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(), + ModdedTeleportDeadline.fromNow())); + route.whenComplete((success, failure) -> { + if (!Boolean.TRUE.equals(success) && !teleport.isDone()) { + teleport.cancel(false); + } + }); + teleport.whenComplete((success, failure) -> { + if (failure != null) { + if (route.completeExceptionally(failure)) { ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, failure); } - }); - } catch (Throwable e) { - inFlight.remove(id); - ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e); + } else { + route.complete(Boolean.TRUE.equals(success)); + } + }); + } catch (Throwable failure) { + if (route.completeExceptionally(failure)) { + ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, failure); } - }); + } } /** diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java index f7219ff51..52655ea74 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java @@ -31,6 +31,7 @@ import art.arcane.iris.platform.protocol.IrisVisionRequestService; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.protocol.IrisProtocol; +import art.arcane.iris.modded.service.ModdedPackUpdateService; import java.util.Objects; import java.util.UUID; @@ -128,6 +129,10 @@ public final class ModdedProtocolHandler { } String sessionId = sessionId(player); ModdedStartup.warnStartupFailuresTo(player); + ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class); + if (packUpdates != null) { + packUpdates.notifyPlayer(player); + } IrisSessionRegistry current = registry; ModdedProtocolTransport currentTransport = transport; if (current == null || currentTransport == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 1f530cbbf..0cd0ef925 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -18,10 +18,13 @@ package art.arcane.iris.modded; +import art.arcane.iris.pack.PackFingerprints; + import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDownloader; import art.arcane.iris.pack.PackValidationRegistry; +import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidator; import art.arcane.iris.modded.command.ModdedPackCommands; @@ -31,15 +34,11 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import java.io.File; -import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Stream; public final class ModdedStartup { private static final int COMPAT_BOOT_KEY_CAP = 3; @@ -60,36 +59,9 @@ public final class ModdedStartup { if (!PREPARED.compareAndSet(false, true)) { return; } - reportLegacyPacksDirectory(); validateAllPacks(); } - /** - * Older builds mkdir'd (and stale guidance sometimes populated) config/iris/packs, but modded - * packs live under config/irisworldgen/packs. Never auto-move user content: warn loudly when - * the legacy directory holds packs, and quietly remove it when it is empty. - */ - private static void reportLegacyPacksDirectory() { - try { - File legacy = ModdedEngineBootstrap.loader().configDir().resolve("iris").resolve("packs").toFile(); - if (!legacy.isDirectory()) { - return; - } - if (!PackDirectoryResolver.listVisiblePackDirectories(legacy).isEmpty()) { - File real = art.arcane.iris.spi.IrisPlatforms.get().packsFolderNoCreate(); - ModdedIrisLog.warn("Iris found packs under the legacy directory {} - modded packs load from {} only. Move them there.", - legacy.getAbsolutePath(), real.getAbsolutePath()); - return; - } - String[] entries = legacy.list(); - if (entries == null || entries.length == 0) { - legacy.delete(); - } - } catch (Throwable e) { - ModdedIrisLog.debug("Iris legacy packs directory check failed", e); - } - } - /** * Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler: * ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this @@ -140,8 +112,7 @@ public final class ModdedStartup { } for (File packDir : packDirs) { try { - PackValidationResult result = PackValidator.validate(packDir); - PackValidationRegistry.publish(result); + PackValidationResult result = validatePack(packDir); String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; String compatSummary = PackValidator.compatSummary(result, minecraftVersion); String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary; @@ -184,16 +155,15 @@ public final class ModdedStartup { throw new BrokenPackException(pack, List.of( "Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + ".")); } - PackValidationResult cached = PackValidationRegistry.get(pack); - if (cached != null && cached.getValidatedAtMillis() >= newestModificationMillis(packDir.toPath())) { - // prepareForStartup already validated this pack and nothing in it changed since; re-validating per - // persistent dimension at boot costs a full pack parse each time. - return PackValidationRegistry.requireLoadable(pack); - } + Path packRoot = packDir.toPath(); try { - PackValidationResult result = PackValidator.validate(packDir); - PackValidationRegistry.publish(result); - return PackValidationRegistry.requireLoadable(pack); + PackValidationResult cached = PackValidationRegistry.getMatching( + packRoot, PackFingerprints.computePackTreeFingerprint(packDir)); + if (cached != null) { + return PackValidationRegistry.requireLoadable(packRoot); + } + validatePack(packDir); + return PackValidationRegistry.requireLoadable(packRoot); } catch (BrokenPackException e) { throw e; } catch (Throwable e) { @@ -212,6 +182,42 @@ public final class ModdedStartup { } } + public static PackValidationResult validatePack(File packDir) { + Path packRoot = packDir.toPath(); + PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(packRoot); + if (ticket == null) { + throw new BrokenPackException(packDir.getName(), List.of("Pack content is currently being changed.")); + } + String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packDir); + String contextFingerprint = PackValidationCache.contextFingerprint(); + PackValidationResult result; + try { + result = PackValidator.validate(packDir); + } catch (Throwable failure) { + ModdedIrisLog.error("Iris pack validation failed for '{}'", packDir.getName(), failure); + String detail = failure.getMessage(); + if (detail == null || detail.isBlank()) { + detail = failure.getClass().getSimpleName(); + } + result = new PackValidationResult( + packDir.getName(), + List.of("Pack validation failed with " + failure.getClass().getSimpleName() + ": " + detail), + List.of(), + System.currentTimeMillis()); + } + if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packDir)) + || !contextFingerprint.equals(PackValidationCache.contextFingerprint())) { + PackValidationRegistry.remove(packRoot); + PackValidationRegistry.remove(packDir.getName()); + throw new BrokenPackException(packDir.getName(), List.of("Pack content or validation context changed during validation.")); + } + if (!PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) { + throw new BrokenPackException(packDir.getName(), List.of("Pack content changed during validation.")); + } + PackValidationRegistry.publish(result); + return result; + } + static void reinjectPersistentDimensions(NativeModdedServer server) { List dimensions = ModdedDimensionRegistryStore.loadForStartup(server); @@ -266,23 +272,6 @@ public final class ModdedStartup { + GenerationRefusalNotice.summary(failure)); } - private static long newestModificationMillis(Path root) { - long newest = 0L; - try (Stream walk = Files.walk(root)) { - for (Path path : (Iterable) walk::iterator) { - BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class); - long modified = attributes.lastModifiedTime().toMillis(); - if (modified > newest) { - newest = modified; - } - } - } catch (IOException | RuntimeException unreadable) { - ModdedIrisLog.debug("Iris could not stat {} for validation reuse; revalidating", root, unreadable); - return Long.MAX_VALUE; - } - return newest; - } - /** * True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime * dimension that is not loaded yet is still on its way. @@ -296,7 +285,7 @@ public final class ModdedStartup { * the console. Tell the operators who can actually act on it when they join. */ public static void warnStartupFailuresTo(NativeProtocolPlayer player) { - if (player == null || !player.isGameMaster()) { + if (player == null || (!player.isGameMaster() && !player.isServerOwner())) { return; } for (String failure : DIMENSION_FAILURES) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 1be62bae4..562c5fb53 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -33,6 +33,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.service.ModdedPackUpdateService; import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.iris.modded.ModdedScheduler; @@ -445,6 +446,12 @@ public final class IrisModdedCommands { ); String completionMessage = downloadCompletionMessage(result); if (result != null) { + if (result.changed()) { + ModdedPackUpdateService updates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class); + if (updates != null) { + dispatchDownloadFeedback(source, updates::refresh); + } + } if (completionMessage != null) { dispatchDownloadFeedback(source, () -> ok(source, completionMessage)); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java index f0046bae8..80baf8706 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java @@ -18,6 +18,8 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.modded.ModdedStartup; + import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.spi.IrisPlatforms; @@ -25,7 +27,6 @@ import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackResourceCleanup; import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationResult; -import art.arcane.iris.pack.PackValidator; import art.arcane.iris.modded.ModdedEngineBootstrap; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; @@ -138,8 +139,7 @@ public final class ModdedPackCommands { int broken = 0; for (File target : targets) { try { - PackValidationResult result = PackValidator.validate(target); - PackValidationRegistry.publish(result); + PackValidationResult result = ModdedStartup.validatePack(target); if (!result.isLoadable()) { broken++; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPackUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPackUpdateService.java new file mode 100644 index 000000000..056f5ec36 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedPackUpdateService.java @@ -0,0 +1,137 @@ +package art.arcane.iris.modded.service; + +import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner; +import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata; +import art.arcane.iris.pack.BuiltInPackUpdates; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Function; + +public final class ModdedPackUpdateService implements ModdedTickableService { + private final Function> checker; + private final Set notified = new HashSet<>(); + private CompletableFuture pending; + private List notices = List.of(); + private File packsFolder; + private boolean enabled; + private boolean started; + + public ModdedPackUpdateService() { + this(ModdedPackUpdateService::checkPacks); + } + + ModdedPackUpdateService(Function> checker) { + this.checker = checker; + } + + @Override + public void onEnable() { + enabled = true; + } + + @Override + public void onDisable() { + enabled = false; + if (pending != null) { + pending.cancel(true); + pending = null; + } + started = false; + notices = List.of(); + notified.clear(); + packsFolder = null; + } + + @Override + public void onServerTick(NativeModdedServer server) { + if (!enabled || server == null || !server.hasPlayerList()) { + return; + } + if (!started) { + started = true; + packsFolder = IrisPlatforms.get().packsFolderNoCreate().getAbsoluteFile(); + pending = checker.apply(packsFolder); + } + if (pending == null || !pending.isDone()) { + return; + } + CompletableFuture completed = pending; + pending = null; + try { + publish(completed.join()); + server.forEachPlayer(this::notifyPlayer); + } catch (CompletionException error) { + IrisLogging.reportError("Iris pack update check failed", error); + } + } + + public void refresh() { + if (!enabled) { + return; + } + if (pending != null) { + pending.cancel(true); + pending = null; + } + started = false; + notices = List.of(); + notified.clear(); + } + + public void notifyPlayer(NativeProtocolPlayer player) { + if (!enabled || notices.isEmpty() || player == null || !player.connected() + || (!player.isGameMaster() && !player.isServerOwner()) || !notified.add(player.id())) { + return; + } + for (String notice : notices) { + player.sendMessage(notice); + } + } + + private static CompletableFuture checkPacks(File folder) { + return CompletableFuture.supplyAsync(() -> IrisSplashPackScanner.collect(folder, IrisLogging::reportError)) + .thenCompose(packs -> BuiltInPackUpdates.check(packs.stream().map(SplashPackMetadata::name).toList()) + .thenApply(updates -> new PackCheck(packs, updates))); + } + + private void publish(PackCheck result) { + List updates = new ArrayList<>(); + IrisLogging.info("Iris packs: " + packsFolder); + for (SplashPackMetadata pack : result.packs()) { + BuiltInPackUpdates.Update update = result.updates().get(pack.name()); + String version = pack.version().matches("[0-9]+") ? "v" + pack.version() : "version unknown"; + IrisLogging.info(" " + pack.name() + " " + version + + (update == null ? "" : update.suffix(pack.version()))); + if (update != null && update.newerThan(pack.version())) { + updates.add("Iris " + pack.name() + " release metadata is outdated: installed v" + pack.version() + + ", latest built-in release v" + update.version() + "."); + updates.add("Back up and review custom edits in " + new File(packsFolder, pack.name()) + + "; close Studio, then update with /iris download pack=" + pack.name() + " overwrite=true."); + updates.add("Existing worlds retain their recorded production pack; this updates the authoring pack for new worlds."); + } + } + notices = List.copyOf(updates); + for (String notice : notices) { + IrisLogging.warn(notice); + } + } + + record PackCheck(List packs, Map updates) { + PackCheck { + packs = List.copyOf(packs); + updates = Map.copyOf(updates); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java index db6916eee..8c041c9a2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java @@ -57,6 +57,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks { @@ -64,6 +65,8 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, private static final long POLL_MILLIS = 250L; private static final long CHECK_LATCH_MILLIS = 1_000L; private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L; + private static final long SHUTDOWN_TIMEOUT_SECONDS = 30L; + private static final long INTERRUPT_DRAIN_TIMEOUT_SECONDS = 5L; private final ConcurrentHashMap watches = new ConcurrentHashMap<>(); private volatile ExecutorService executor; @@ -81,8 +84,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, @Override public void onEnable() { - if (executor != null) { - return; + ExecutorService current = executor; + if (current != null && !current.isTerminated()) { + if (!current.isShutdown()) { + return; + } + throw new IllegalStateException("Iris Studio hotload cannot restart while prior workers are still active"); } executor = Executors.newSingleThreadExecutor((Runnable task) -> { Thread thread = new Thread(task, "Iris Studio Hotload"); @@ -96,10 +103,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, @Override public void onDisable() { ExecutorService active = executor; - executor = null; - if (active != null) { - active.shutdownNow(); + if (!shutdownAndDrain(active)) { + throw new IllegalStateException("Iris Studio hotload worker remained active during service shutdown"); } + executor = null; for (Watch watch : watches.values()) { watch.close(); } @@ -197,7 +204,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, @Override public void onServerTick(NativeModdedServer server) { ExecutorService active = executor; - if (active == null) { + if (active == null || active.isShutdown()) { return; } long now = System.currentTimeMillis(); @@ -284,6 +291,30 @@ public final class ModdedStudioHotloadService implements ModdedTickableService, && !isPregeneratorActive(engine); } + private static boolean shutdownAndDrain(ExecutorService active) { + if (active == null) { + return true; + } + active.shutdown(); + try { + if (active.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + return true; + } + active.shutdownNow(); + if (active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + return true; + } + ModdedIrisLog.error("Iris Studio hotload worker did not stop after shutdownNow", + new IllegalStateException("Iris Studio hotload worker is still active")); + return false; + } catch (InterruptedException exception) { + active.shutdownNow(); + Thread.currentThread().interrupt(); + ModdedIrisLog.error("Interrupted while draining Iris Studio hotload", exception); + return false; + } + } + private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) { if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) { return true; diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedClientPackageIsolationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedClientPackageIsolationTest.java index c9fbd2942..f719be3e9 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedClientPackageIsolationTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedClientPackageIsolationTest.java @@ -39,6 +39,37 @@ public class ModdedClientPackageIsolationTest { "art/arcane/iris/nativegen"); private static final int MINIMUM_SCANNED = 50; + @Test + public void clientPresentationCannotAccessPackOrWorldPersistence() throws IOException, URISyntaxException { + Path root = classesRoot(); + Path directory = root.resolve(CLIENT_IRIS); + List forbidden = List.of( + "art/arcane/iris/pack/", + "art/arcane/iris/world/storage/", + "art/arcane/iris/world/history/", + "art/arcane/iris/generation/runtime/", + "art/arcane/iris/configuration/", + "java/nio/file/", + "java/nio/channels/FileChannel", + "java/io/File", + "java/io/RandomAccessFile"); + List violations = new ArrayList<>(); + int scanned = 0; + try (Stream walk = Files.walk(directory)) { + for (Path classFile : walk.filter(ModdedClientPackageIsolationTest::isClassFile).toList()) { + scanned++; + String bytecode = readAsLatin1(classFile); + for (String dependency : forbidden) { + if (bytecode.contains(dependency)) { + violations.add(root.relativize(classFile) + " -> " + dependency); + } + } + } + } + assertTrue("client presentation classes were not scanned", scanned >= 20); + assertEquals("client presentation can access persistent generation data: " + violations, List.of(), violations); + } + @Test public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException { Path root = classesRoot(); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDecoratorHooksTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDecoratorHooksTest.java new file mode 100644 index 000000000..c9bb86add --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDecoratorHooksTest.java @@ -0,0 +1,44 @@ +package art.arcane.iris.modded; + +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockFaces; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import org.junit.Test; +import org.mockito.MockedStatic; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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 ModdedDecoratorHooksTest { + @Test + public void faceFixingExcludesTheUpperBoundaryWhileKeepingTheLastRowAccessible() { + NativeBlockState state = mock(NativeBlockState.class); + NativeBlockState lastRow = mock(NativeBlockState.class); + Hunk hunk = mock(Hunk.class); + when(hunk.getHeight()).thenReturn(4); + when(hunk.get(1, 3, 2)).thenReturn(lastRow); + when(hunk.get(1, 4, 2)).thenThrow(new IndexOutOfBoundsException("upper boundary")); + try (MockedStatic faces = mockStatic(NativeBlockFaces.class)) { + faces.when(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class))) + .thenAnswer(invocation -> { + NativeBlockFaces.Neighbors neighbors = invocation.getArgument(1); + assertNull(neighbors.secondary(0, 1, 0)); + assertSame(lastRow, neighbors.secondary(0, 0, 0)); + return state; + }); + + assertSame(state, new ModdedDecoratorHooks().fixFaces(state, hunk, 1, 2, 99, 3, 108, null)); + + faces.verify(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class))); + } + verify(hunk, never()).get(1, 4, 2); + verify(hunk).get(1, 3, 2); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenPoolTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenPoolTest.java new file mode 100644 index 000000000..42d85b54f --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenPoolTest.java @@ -0,0 +1,78 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; + +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.junit.Assert.fail; + +public class ModdedGenPoolTest { + @Test + public void executorLookupDoesNotRestartStoppedPool() throws Exception { + ModdedGenPool.start(); + ExecutorService original = ModdedGenPool.pool(); + ModdedGenPool.shutdown(); + try { + ModdedGenPool.pool(); + fail("Stopped generation pool must reject executor lookup"); + } catch (RejectedExecutionException expected) { + assertTrue(original.isShutdown()); + } + ModdedGenPool.start(); + try { + assertNotSame(original, ModdedGenPool.pool()); + } finally { + ModdedGenPool.shutdown(); + } + } + + @Test + public void restartWaitsForPreviousWorkersToTerminate() throws Exception { + ModdedGenPool.start(); + ExecutorService original = ModdedGenPool.pool(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + original.execute(() -> { + entered.countDown(); + boolean released = false; + while (!released) { + try { + release.await(); + released = true; + } catch (InterruptedException ignored) { + Thread.interrupted(); + } + } + }); + assertTrue(entered.await(5L, TimeUnit.SECONDS)); + try { + Thread.currentThread().interrupt(); + ModdedGenPool.shutdown(); + assertTrue(Thread.interrupted()); + assertFalse(original.isTerminated()); + try { + ModdedGenPool.start(); + fail("Generation pool must retain workers until they terminate"); + } catch (IllegalStateException expected) { + assertFalse(original.isTerminated()); + } + } finally { + Thread.interrupted(); + release.countDown(); + assertTrue(original.awaitTermination(5L, TimeUnit.SECONDS)); + ModdedGenPool.start(); + assertNotSame(original, ModdedGenPool.pool()); + ExecutorService restarted = ModdedGenPool.pool(); + ModdedGenPool.start(); + assertSame(restarted, ModdedGenPool.pool()); + ModdedGenPool.shutdown(); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java new file mode 100644 index 000000000..c32b08189 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPackValidationPublicationTest.java @@ -0,0 +1,143 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.modded.command.ModdedPackCommands; +import art.arcane.iris.pack.BrokenPackException; +import art.arcane.iris.pack.PackValidationRegistry; +import art.arcane.iris.pack.PackValidationResult; +import art.arcane.iris.pack.PackValidator; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mockStatic; + +public class ModdedPackValidationPublicationTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @After + public void clearValidation() { + PackValidationRegistry.clear(); + } + + @Test + public void validationPublishesTheCanonicalRootAndPackName() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + File equivalent = new File(root, "."); + PackValidationResult result = loadable(root); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(equivalent)).thenReturn(result); + + assertSame(result, ModdedStartup.validatePack(equivalent)); + + assertSame(result, PackValidationRegistry.requireLoadable(root.toPath())); + assertSame(result, PackValidationRegistry.requireLoadable(root.getName())); + } + } + + @Test + public void rootInvalidationCannotReusePackNameValidation() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + PackValidationRegistry.publish(loadable(root)); + PackValidationResult replacement = loadable(root); + try (MockedStatic validator = mockStatic(PackValidator.class); + MockedStatic commands = mockStatic(ModdedPackCommands.class)) { + commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile()); + validator.when(() -> PackValidator.validate(root)).thenReturn(replacement); + + assertSame(replacement, ModdedStartup.requirePackForWorldCreation(root.getName())); + + validator.verify(() -> PackValidator.validate(root)); + assertSame(replacement, PackValidationRegistry.requireLoadable(root.toPath())); + } + } + + @Test + public void changedContentWithPreservedTimestampCannotReuseValidation() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + File content = new File(root, "dimension.json"); + Files.writeString(content.toPath(), "valid"); + PackValidationResult original = loadable(root); + PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(), + System.currentTimeMillis()); + try (MockedStatic validator = mockStatic(PackValidator.class); + MockedStatic commands = mockStatic(ModdedPackCommands.class)) { + commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile()); + validator.when(() -> PackValidator.validate(root)).thenReturn(original, broken); + ModdedStartup.validatePack(root); + Files.writeString(content.toPath(), "wrong"); + Files.setLastModifiedTime(content.toPath(), FileTime.fromMillis(1L)); + Files.setLastModifiedTime(root.toPath(), FileTime.fromMillis(1L)); + + assertThrows(BrokenPackException.class, + () -> ModdedStartup.requirePackForWorldCreation(root.getName())); + } + } + + @Test + public void externalEditDuringValidationCannotPublishMixedSnapshot() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + File content = new File(root, "dimension.json"); + Files.writeString(content.toPath(), "original"); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> { + Files.writeString(content.toPath(), "changed"); + return loadable(root); + }); + + assertThrows(BrokenPackException.class, () -> ModdedStartup.validatePack(root)); + assertNull(PackValidationRegistry.get(root.toPath())); + } + } + + @Test + public void mutationDuringValidationPreventsStalePublication() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> { + try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) { + return loadable(root); + } + }); + + try { + ModdedStartup.validatePack(root); + fail("Changed pack must reject stale validation"); + } catch (BrokenPackException expected) { + assertNull(PackValidationRegistry.get(root.toPath())); + assertNull(PackValidationRegistry.get(root.getName())); + } + } + } + + @Test + public void validationFailurePublishesBlockedRoot() throws Exception { + File root = temporaryFolder.newFolder("terrain"); + try (MockedStatic validator = mockStatic(PackValidator.class); + MockedStatic log = mockStatic(ModdedIrisLog.class)) { + validator.when(() -> PackValidator.validate(root)).thenThrow(new IllegalStateException("Invalid terrain")); + + PackValidationResult result = ModdedStartup.validatePack(root); + + assertFalse(result.isLoadable()); + assertSame(result, PackValidationRegistry.get(root.toPath())); + assertSame(result, PackValidationRegistry.get(root.getName())); + } + } + + private static PackValidationResult loadable(File root) { + return new PackValidationResult(root.getName(), List.of(), List.of(), System.currentTimeMillis()); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java index 72c128b6b..e1b3c8026 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedPrimaryWorldRouterTest.java @@ -4,7 +4,10 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; import org.junit.After; +import org.junit.BeforeClass; import org.junit.Test; import org.mockito.MockedStatic; import org.mockito.invocation.InvocationOnMock; @@ -38,6 +41,12 @@ public class ModdedPrimaryWorldRouterTest { private static final int TICK_INTERVAL = 20; private static final String PRIMARY = "iris:primary"; + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + @After public void clearRouter() { ModdedPrimaryWorldRouter.clear(); @@ -233,6 +242,41 @@ public class ModdedPrimaryWorldRouterTest { return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L); } + @Test + public void disconnectCancelsOldRoutingWithoutAffectingReconnectedPlayer() { + NativeModdedServer server = mock(NativeModdedServer.class); + NativeWorld overworld = mock(NativeWorld.class); + NativeWorld primary = mock(NativeWorld.class); + when(overworld.nativeHandle()).thenReturn(new Object()); + when(primary.nativeHandle()).thenReturn(new Object()); + when(server.overworld()).thenReturn(overworld); + NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); + UUID id = UUID.randomUUID(); + when(player.id()).thenReturn(id); + when(player.isInWorld(overworld)).thenReturn(true); + onlinePlayers(server, player); + CompletableFuture oldRoute = new CompletableFuture<>(); + CompletableFuture newRoute = new CompletableFuture<>(); + ModdedModConfig config = config(true, PRIMARY); + try (MockedStatic configs = mockStatic(ModdedModConfig.class); + MockedStatic manager = mockStatic(ModdedDimensionManager.class); + MockedStatic teleport = mockStatic(NativeWorldTeleport.class)) { + configs.when(ModdedModConfig::get).thenReturn(config); + manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary); + teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenReturn(oldRoute, newRoute); + evaluate(server); + ModdedPrimaryWorldRouter.forget(id); + assertTrue(oldRoute.isCancelled()); + evaluate(server); + oldRoute.complete(true); + evaluate(server); + teleport.verify(() -> NativeWorldTeleport.teleport(any(), any()), times(2)); + assertFalse(newRoute.isDone()); + ModdedPrimaryWorldRouter.clear(); + assertTrue(newRoute.isCancelled()); + } + } + private static void evaluate(NativeModdedServer server) { for (int tick = 0; tick < TICK_INTERVAL; tick++) { ModdedPrimaryWorldRouter.tick(server); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java index 15dc419a4..d81288a87 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStartupReinjectFailureTest.java @@ -57,6 +57,11 @@ public class ModdedStartupReinjectFailureTest { NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); ModdedStartup.warnStartupFailuresTo(operator); ModdedStartup.warnStartupFailuresTo(player); + NativeProtocolPlayer owner = mock(NativeProtocolPlayer.class); + when(owner.isServerOwner()).thenReturn(true); + ModdedStartup.warnStartupFailuresTo(owner); + verify(owner).sendMessage(argThat((String message) -> message.contains("iris:moon") + && message.contains("generation history is unusable"))); verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon") && message.contains("generation history is unusable"))); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java index a6abc2183..68f37ca26 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandsTeleportTest.java @@ -21,6 +21,8 @@ import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.Level; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.Pose; import org.junit.BeforeClass; import org.junit.Test; import org.mockito.MockedStatic; @@ -98,6 +100,7 @@ public class IrisModdedCommandsTeleportTest { when(server.getLevel(dimension)).thenReturn(serverLevel); ServerPlayer serverPlayer = mock(ServerPlayer.class); when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID()); + when(serverPlayer.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F)); NativeWorld level = mock(NativeWorld.class); when(level.nativeHandle()).thenReturn(serverLevel); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java new file mode 100644 index 000000000..3c6c116b1 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedPackUpdateServiceTest.java @@ -0,0 +1,209 @@ +package art.arcane.iris.modded.service; + +import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata; +import art.arcane.iris.pack.BuiltInPackUpdates; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.LevelBasedPermissionSet; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedPackUpdateServiceTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void pendingCheckNotifiesOnlineAndLaterOperatorsOnceOnServerTick() { + CompletableFuture pending = new CompletableFuture<>(); + ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> pending); + NativeProtocolPlayer online = player(true, false); + NativeModdedServer server = server(online); + File root = new File("config/irisworldgen/packs").getAbsoluteFile(); + try (MockedStatic platforms = platform(root)) { + service.onEnable(); + service.onServerTick(server); + service.notifyPlayer(online); + verify(online, never()).sendMessage(anyString()); + pending.complete(outdated()); + verify(online, never()).sendMessage(anyString()); + service.onServerTick(server); + service.onServerTick(server); + service.notifyPlayer(online); + verify(online, times(1)).sendMessage(contains("installed v1, latest built-in release v2")); + verify(online, times(1)).sendMessage(contains(new File(root, "overworld").toString())); + verify(online, times(1)).sendMessage(contains("/iris download pack=overworld overwrite=true")); + verify(online, never()).sendMessage(contains("plugins/")); + NativeProtocolPlayer later = player(true, false); + service.notifyPlayer(later); + verify(later).sendMessage(contains("release metadata is outdated")); + service.onDisable(); + } + } + + @Test + public void integratedOwnerWithoutCheatsReceivesNoticeAndRemoteOrdinaryPlayersDoNot() { + NativeProtocolPlayer owner = player(false, true); + NativeProtocolPlayer remote = player(false, false); + ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(outdated())); + try (MockedStatic platforms = platform(new File("config/irisworldgen/packs"))) { + service.onEnable(); + service.onServerTick(server(owner)); + verify(owner).sendMessage(contains("release metadata is outdated")); + service.notifyPlayer(remote); + verify(remote, never()).sendMessage(anyString()); + service.onDisable(); + } + } + + @Test + public void disableCancelsCheckAndRestartDoesNotPublishOldNotices() { + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + AtomicInteger checks = new AtomicInteger(); + ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0 ? first : second); + NativeProtocolPlayer operator = player(true, false); + NativeModdedServer server = server(operator); + try (MockedStatic platforms = platform(new File("config/irisworldgen/packs"))) { + service.onEnable(); + service.onServerTick(server); + service.onDisable(); + assertTrue(first.isCancelled()); + assertFalse(first.complete(outdated())); + service.onEnable(); + service.onServerTick(server); + second.complete(new ModdedPackUpdateService.PackCheck(List.of(), Map.of())); + service.onServerTick(server); + verify(operator, never()).sendMessage(anyString()); + assertEquals(2, checks.get()); + service.onDisable(); + } + } + + @Test + public void currentCustomUnknownAndUnavailableVersionsNeverBecomeOutdated() { + ModdedPackUpdateService.PackCheck result = new ModdedPackUpdateService.PackCheck(List.of( + new SplashPackMetadata("overworld", "unknown"), + new SplashPackMetadata("underworld", "3"), + new SplashPackMetadata("custom", "1")), Map.of( + "overworld", new BuiltInPackUpdates.Update("2"), + "underworld", new BuiltInPackUpdates.Update("2"))); + ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(result)); + NativeProtocolPlayer operator = player(true, false); + try (MockedStatic platforms = platform(new File("config/irisworldgen/packs"))) { + service.onEnable(); + service.onServerTick(server(operator)); + verify(operator, never()).sendMessage(anyString()); + service.onDisable(); + } + } + + @Test + public void successfulAuthoringUpdateClearsOldNoticesAndRescansInstalledVersion() { + CompletableFuture current = new CompletableFuture<>(); + AtomicInteger checks = new AtomicInteger(); + ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0 + ? CompletableFuture.completedFuture(outdated()) : current); + NativeProtocolPlayer operator = player(true, false); + NativeModdedServer server = server(operator); + try (MockedStatic platforms = platform(new File("config/irisworldgen/packs"))) { + service.onEnable(); + service.onServerTick(server); + verify(operator).sendMessage(contains("release metadata is outdated")); + service.refresh(); + NativeProtocolPlayer later = player(true, false); + service.notifyPlayer(later); + verify(later, never()).sendMessage(anyString()); + service.onServerTick(server); + current.complete(new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "2")), + Map.of("overworld", new BuiltInPackUpdates.Update("2")))); + service.onServerTick(server); + service.notifyPlayer(later); + verify(later, never()).sendMessage(anyString()); + assertEquals(2, checks.get()); + service.onDisable(); + } + } + + @Test + public void nativeOwnerCheckDoesNotGrantGameMasterPermissions() { + ServerPlayer handle = mock(ServerPlayer.class); + ServerLevel level = mock(ServerLevel.class); + MinecraftServer server = mock(MinecraftServer.class); + when(handle.level()).thenReturn(level); + when(level.getServer()).thenReturn(server); + when(handle.permissions()).thenReturn(LevelBasedPermissionSet.ALL); + NativeProtocolPlayer player = NativeProtocolPlayer.fromHandle(handle); + when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(true); + assertTrue(player.isServerOwner()); + assertFalse(player.isGameMaster()); + when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(false); + assertFalse(player.isServerOwner()); + assertFalse(player.isGameMaster()); + } + + private static ModdedPackUpdateService.PackCheck outdated() { + return new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "1")), + Map.of("overworld", new BuiltInPackUpdates.Update("2"))); + } + + private static MockedStatic platform(File root) { + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.packsFolderNoCreate()).thenReturn(root); + MockedStatic platforms = mockStatic(IrisPlatforms.class); + platforms.when(IrisPlatforms::get).thenReturn(platform); + return platforms; + } + + private static NativeProtocolPlayer player(boolean operator, boolean owner) { + NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); + when(player.id()).thenReturn(UUID.randomUUID()); + when(player.connected()).thenReturn(true); + when(player.isGameMaster()).thenReturn(operator); + when(player.isServerOwner()).thenReturn(owner); + return player; + } + + @SuppressWarnings("unchecked") + private static NativeModdedServer server(NativeProtocolPlayer player) { + NativeModdedServer server = mock(NativeModdedServer.class); + when(server.hasPlayerList()).thenReturn(true); + doAnswer(invocation -> { + invocation.getArgument(0, Consumer.class).accept(player); + return null; + }).when(server).forEachPlayer(any()); + return server; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java index a5b6a8c25..a7e8d31a5 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java @@ -4,14 +4,65 @@ import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.modded.ModdedScheduler; import art.arcane.volmlib.util.collection.KList; import org.junit.Test; +import org.mockito.InOrder; +import java.lang.reflect.Field; import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class ModdedStudioHotloadServiceTest { + @Test + public void shutdownDrainsWorkerBeforeAllowingRestart() throws Exception { + ModdedStudioHotloadService service = new ModdedStudioHotloadService(); + ExecutorService executor = mock(ExecutorService.class); + setExecutor(service, executor); + when(executor.awaitTermination(30L, TimeUnit.SECONDS)).thenReturn(false); + when(executor.awaitTermination(5L, TimeUnit.SECONDS)).thenReturn(true); + + service.onDisable(); + + InOrder order = inOrder(executor); + order.verify(executor).shutdown(); + order.verify(executor).awaitTermination(30L, TimeUnit.SECONDS); + order.verify(executor).shutdownNow(); + order.verify(executor).awaitTermination(5L, TimeUnit.SECONDS); + service.onEnable(); + service.onDisable(); + } + + @Test + public void undrainedWorkerPreventsRestart() throws Exception { + ModdedStudioHotloadService service = new ModdedStudioHotloadService(); + ExecutorService executor = mock(ExecutorService.class); + setExecutor(service, executor); + when(executor.isShutdown()).thenReturn(true); + try { + service.onDisable(); + fail("Hotload shutdown must report a worker that did not terminate"); + } catch (IllegalStateException expected) { + verify(executor).shutdownNow(); + } + try { + service.onEnable(); + fail("Hotload must not start another worker before the previous worker terminates"); + } catch (IllegalStateException expected) { + assertFalse(executor.isTerminated()); + } + when(executor.isTerminated()).thenReturn(true); + service.onEnable(); + service.onDisable(); + } + @Test public void detectsDatapackImportsAcrossLoadedDimensions() { IrisDimension empty = new IrisDimension(); @@ -45,4 +96,10 @@ public class ModdedStudioHotloadServiceTest { scheduler.shutdown(); } } + private static void setExecutor(ModdedStudioHotloadService service, ExecutorService executor) throws Exception { + Field field = ModdedStudioHotloadService.class.getDeclaredField("executor"); + field.setAccessible(true); + field.set(service, executor); + } + } diff --git a/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeTeleportSafetyTest.java b/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeTeleportSafetyTest.java new file mode 100644 index 000000000..09e305c6e --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/volmlib/nativelib/minecraft26_2/modded/NativeTeleportSafetyTest.java @@ -0,0 +1,142 @@ +package art.arcane.volmlib.nativelib.minecraft26_2.modded; + +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class NativeTeleportSafetyTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void rejectsTallAndHazardousSupportsAndAcceptsSolidFloor() { + ServerPlayer player = player(); + ServerLevel level = level(); + for (BlockState support : new BlockState[]{Blocks.OAK_FENCE.defaultBlockState(), + Blocks.MAGMA_BLOCK.defaultBlockState(), Blocks.CACTUS.defaultBlockState()}) { + setSupport(level, support); + assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D)); + } + setSupport(level, Blocks.STONE.defaultBlockState()); + assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D)); + } + + @Test + public void fractionalPositionUsesWholePlayerBodyAndRequiresNeighborChunk() { + ServerPlayer player = player(); + ServerLevel level = level(); + setSupport(level, Blocks.STONE.defaultBlockState()); + when(level.noCollision(any(), any(AABB.class))).thenAnswer(invocation -> { + AABB body = invocation.getArgument(1); + return body.maxX <= 16D; + }); + assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.5D, 4, 8.5D)); + assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D)); + when(level.noCollision(any(), any(AABB.class))).thenReturn(true); + when(level.getChunkSource().hasChunk(1, 0)).thenReturn(false); + assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D)); + EntityDimensions dimensions = player.getDimensions(Pose.STANDING); + assertEquals(0, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.5D, 4D, 8.5D), new ChunkPos(0, 0))); + assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.9D, 4D, 8.5D), new ChunkPos(0, 0))); + assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(-16.1D, 4D, -8.5D), new ChunkPos(-2, -1))); + } + + @Test + public void scaledStandingBodyStaysInsideActualWorldHeight() { + ServerLevel level = level(); + setSupport(level, Blocks.STONE.defaultBlockState()); + ServerPlayer player = player(); + when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 13F)); + assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D)); + when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 12F)); + assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D)); + assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 0, 8.5D)); + } + + @Test + public void reconnectDuringChunkWarmupDoesNotTeleportReplacementSession() { + MinecraftServer server = mock(MinecraftServer.class, RETURNS_DEEP_STUBS); + when(server.isSameThread()).thenReturn(true); + ServerLevel level = level(); + ResourceKey key = ResourceKey.create(Registries.DIMENSION, Identifier.parse("iris:test")); + when(level.getServer()).thenReturn(server); + when(level.dimension()).thenReturn(key); + when(server.getLevel(key)).thenReturn(level); + ServerChunkCache chunks = level.getChunkSource(); + when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(false); + CompletableFuture loaded = new CompletableFuture<>(); + doReturn(loaded).when(chunks).addTicketAndLoadWithRadius(any(), any(), anyInt()); + ServerPlayer original = player(); + UUID id = UUID.randomUUID(); + when(original.getUUID()).thenReturn(id); + when(server.getPlayerList().getPlayer(id)).thenReturn(original); + NativeWorld world = mock(NativeWorld.class); + when(world.nativeHandle()).thenReturn(level); + CompletableFuture result = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(original), + new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), world, 8.5D, 4D, 8.5D, 0L)); + assertFalse(result.isDone()); + ServerPlayer replacement = player(); + when(server.getPlayerList().getPlayer(id)).thenReturn(replacement); + loaded.complete(null); + assertFalse(result.join()); + verify(chunks).removeTicketWithRadius(any(), any(), anyInt()); + } + + private ServerPlayer player() { + ServerPlayer player = mock(ServerPlayer.class); + when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F)); + return player; + } + + private ServerLevel level() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunks = mock(ServerChunkCache.class); + when(level.getChunkSource()).thenReturn(chunks); + when(level.getMinY()).thenReturn(0); + when(level.getMaxY()).thenReturn(16); + when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(true); + when(level.noCollision(any(), any(AABB.class))).thenReturn(true); + return level; + } + + private void setSupport(ServerLevel level, BlockState support) { + when(level.getBlockState(any(BlockPos.class))).thenAnswer(invocation -> { + BlockPos position = invocation.getArgument(0); + return position.getY() == 3 ? support : Blocks.AIR.defaultBlockState(); + }); + } +} diff --git a/core/build.gradle b/core/build.gradle index e5e809caf..dc649addd 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -379,8 +379,6 @@ List unusedVolmLibClasses = [ 'art/arcane/volmlib/util/bukkit/Events', 'art/arcane/volmlib/util/json/SingleCollectionTypeFactory', 'art/arcane/volmlib/util/collection/StateList', - 'art/arcane/iris/pack/IrisPack', - 'art/arcane/iris/pack/IrisPackRepository', 'art/arcane/iris/world/task/jobs/DownloadJob', 'art/arcane/iris/world/task/jobs/JobCollection', 'art/arcane/iris/world/task/jobs/ParallelQueueJob', diff --git a/core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java b/core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java index fe69865fe..8b8ec918b 100644 --- a/core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java +++ b/core/src/main/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScanner.java @@ -54,7 +54,7 @@ public final class IrisSplashPackScanner { JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); JsonElement version = json.get("version"); if (version == null || !version.isJsonPrimitive()) { - return null; + return new SplashPackMetadata(dimName, "unknown"); } return new SplashPackMetadata(dimName, version.getAsString()); diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java index eebe8ca94..9f7c9b7d7 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiome.java @@ -96,8 +96,6 @@ public class IrisBiome extends IrisRegistrant implements Rarity { private static final int CHILDREN_SEED_SALT = 2137; private final transient AtomicCache> genCache = new AtomicCache<>(); - private final transient AtomicCache> genCacheMax = new AtomicCache<>(); - private final transient AtomicCache> genCacheMin = new AtomicCache<>(); private final transient AtomicCache> surfaceObjectsCache = new AtomicCache<>(); private final transient AtomicCache> carveObjectsCache = new AtomicCache<>(); private final transient AtomicCache cacheColor = new AtomicCache<>(); @@ -415,12 +413,12 @@ public class IrisBiome extends IrisRegistrant implements Rarity { return getCustomDerivitives() != null && getCustomDerivitives().isNotEmpty(); } - public double getGenLinkMax(String loadKey, Engine engine) { - return IrisBiomeGenLinks.getGenLinkMax(this, loadKey, engine); + public double getGenLinkMax(String loadKey) { + return IrisBiomeGenLinks.getGenLinkMax(this, loadKey); } - public double getGenLinkMin(String loadKey, Engine engine) { - return IrisBiomeGenLinks.getGenLinkMin(this, loadKey, engine); + public double getGenLinkMin(String loadKey) { + return IrisBiomeGenLinks.getGenLinkMin(this, loadKey); } public IrisBiomeGeneratorLink getGenLink(String loadKey) { diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeGenLinks.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeGenLinks.java index 22e200948..84412cd91 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeGenLinks.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeGenLinks.java @@ -19,7 +19,6 @@ package art.arcane.iris.generation.biome; import art.arcane.volmlib.util.cache.AtomicCache; -import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.util.collection.KMap; /** @@ -31,24 +30,14 @@ final class IrisBiomeGenLinks { private IrisBiomeGenLinks() { } - static double getGenLinkMax(IrisBiome biome, String loadKey, Engine engine) { - if (loadKey == null || loadKey.isBlank()) { - return 0; - } - - Integer v = maxIndex(biome).get(loadKey); - - return v == null ? 0 : v; + static double getGenLinkMax(IrisBiome biome, String loadKey) { + IrisBiomeGeneratorLink link = getGenLink(biome, loadKey); + return link == null ? 0 : link.getMax(); } - static double getGenLinkMin(IrisBiome biome, String loadKey, Engine engine) { - if (loadKey == null || loadKey.isBlank()) { - return 0; - } - - Integer v = minIndex(biome).get(loadKey); - - return v == null ? 0 : v; + static double getGenLinkMin(IrisBiome biome, String loadKey) { + IrisBiomeGeneratorLink link = getGenLink(biome, loadKey); + return link == null ? 0 : link.getMin(); } static IrisBiomeGeneratorLink getGenLink(IrisBiome biome, String loadKey) { @@ -59,57 +48,6 @@ final class IrisBiomeGenLinks { return linkIndex(biome).get(loadKey); } - private static KMap maxIndex(IrisBiome biome) { - AtomicCache> cache = biome.getGenCacheMax(); - KMap cached = cache.getIfPresent(); - - if (cached != null) { - return cached; - } - - return cache.aquire(() -> - { - KMap l = new KMap<>(); - - for (IrisBiomeGeneratorLink i : biome.getGenerators()) { - String generatorKey = i.getGenerator(); - if (generatorKey == null || generatorKey.isBlank()) { - continue; - } - - l.put(generatorKey, i.getMax()); - - } - - return l; - }); - } - - private static KMap minIndex(IrisBiome biome) { - AtomicCache> cache = biome.getGenCacheMin(); - KMap cached = cache.getIfPresent(); - - if (cached != null) { - return cached; - } - - return cache.aquire(() -> - { - KMap l = new KMap<>(); - - for (IrisBiomeGeneratorLink i : biome.getGenerators()) { - String generatorKey = i.getGenerator(); - if (generatorKey == null || generatorKey.isBlank()) { - continue; - } - - l.put(generatorKey, i.getMin()); - } - - return l; - }); - } - private static KMap linkIndex(IrisBiome biome) { AtomicCache> cache = biome.getGenCache(); KMap cached = cache.getIfPresent(); diff --git a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java index e871bc6b4..85c0fa754 100644 --- a/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/biome/IrisBiomeLayerGenerator.java @@ -225,7 +225,7 @@ final class IrisBiomeLayerGenerator { int sampledDepth = Math.min(maxDepth, cycleLength); for (int depth = 0; depth < sampledDepth; depth++) { - int position = Math.floorMod(512L - height - depth, cycleLength); + int position = Math.floorMod(512L - height + depth, cycleLength); int layerIndex = findLayer(boundaries, position); IrisBiomePaletteLayer layer = layers.get(layerIndex); int offset = position - boundaries[layerIndex]; diff --git a/core/src/main/java/art/arcane/iris/generation/cache/ConcurrentClockCache.java b/core/src/main/java/art/arcane/iris/generation/cache/ConcurrentClockCache.java index a999fbb97..403891c28 100644 --- a/core/src/main/java/art/arcane/iris/generation/cache/ConcurrentClockCache.java +++ b/core/src/main/java/art/arcane/iris/generation/cache/ConcurrentClockCache.java @@ -14,27 +14,31 @@ import java.util.function.Consumer; public final class ConcurrentClockCache { private static final int WAYS = 4; - private final AtomicReferenceArray> slots; + private final AtomicReferenceArray> slots; private final long sets; + private final int capacity; public ConcurrentClockCache(int capacity) { if (capacity <= 0) { throw new IllegalArgumentException("Cache capacity must be positive"); } int setCount = Math.ceilDiv(capacity, WAYS); + this.capacity = Math.multiplyExact(setCount, WAYS); sets = setCount; - slots = new AtomicReferenceArray<>(Math.multiplyExact(setCount, WAYS)); + slots = new AtomicReferenceArray<>(setCount); } public int capacity() { - return slots.length(); + return capacity; } public V get(long key) { - long hash = CacheKey.mix(key); - int base = base(hash); + CacheSet snapshot = slots.getAcquire(index(CacheKey.mix(key))); + if (snapshot == null) { + return null; + } for (int way = 0; way < WAYS; way++) { - Entry entry = slots.getAcquire(base + way); + Entry entry = snapshot.get(way); if (entry != null && entry.key == key) { entry.touch(); return entry.value; @@ -46,12 +50,23 @@ public final class ConcurrentClockCache { public V putIfAbsent(long key, V value) { Objects.requireNonNull(value, "Cached value"); long hash = CacheKey.mix(key); - int base = base(hash); + int index = index(hash); int hand = (int) (hash >>> 60) & (WAYS - 1); + Entry created = null; while (true) { + CacheSet snapshot = slots.getAcquire(index); + if (snapshot == null) { + if (created == null) { + created = new Entry<>(key, value); + } + if (slots.compareAndSet(index, null, new CacheSet<>(created, null, null, null))) { + return value; + } + continue; + } int empty = -1; for (int way = 0; way < WAYS; way++) { - Entry entry = slots.getAcquire(base + way); + Entry entry = snapshot.get(way); if (entry == null) { if (empty < 0) { empty = way; @@ -61,30 +76,23 @@ public final class ConcurrentClockCache { return entry.value; } } - Entry created = new Entry<>(key, value); - if (empty >= 0) { - if (slots.compareAndSet(base + empty, null, created)) { - return value; - } - continue; - } - int victim = -1; - Entry victimEntry = null; + int victim = empty; for (int step = 0; step < WAYS * 2 && victim < 0; step++) { int way = (hand + step) & (WAYS - 1); - Entry entry = slots.getAcquire(base + way); - if (entry == null || !entry.referenced) { + Entry entry = snapshot.get(way); + if (!entry.referenced) { victim = way; - victimEntry = entry; } else { entry.referenced = false; } } if (victim < 0) { victim = hand; - victimEntry = slots.getAcquire(base + victim); } - if (slots.compareAndSet(base + victim, victimEntry, created)) { + if (created == null) { + created = new Entry<>(key, value); + } + if (slots.compareAndSet(index, snapshot, snapshot.with(victim, created))) { return value; } } @@ -92,9 +100,15 @@ public final class ConcurrentClockCache { public void forEach(Consumer action) { for (int index = 0; index < slots.length(); index++) { - Entry entry = slots.getAcquire(index); - if (entry != null) { - action.accept(entry.value); + CacheSet snapshot = slots.getAcquire(index); + if (snapshot == null) { + continue; + } + for (int way = 0; way < WAYS; way++) { + Entry entry = snapshot.get(way); + if (entry != null) { + action.accept(entry.value); + } } } } @@ -105,14 +119,36 @@ public final class ConcurrentClockCache { } } - private int base(long hash) { - return (int) (((hash & 0xffffffffL) * sets) >>> 32) * WAYS; + private int index(long hash) { + return (int) (((hash & 0xffffffffL) * sets) >>> 32); + } + + private record CacheSet(Entry first, Entry second, Entry third, Entry fourth) { + private Entry get(int way) { + return switch (way) { + case 0 -> first; + case 1 -> second; + case 2 -> third; + case 3 -> fourth; + default -> throw new IndexOutOfBoundsException(way); + }; + } + + private CacheSet with(int way, Entry entry) { + return switch (way) { + case 0 -> new CacheSet<>(entry, second, third, fourth); + case 1 -> new CacheSet<>(first, entry, third, fourth); + case 2 -> new CacheSet<>(first, second, entry, fourth); + case 3 -> new CacheSet<>(first, second, third, entry); + default -> throw new IndexOutOfBoundsException(way); + }; + } } private static final class Entry { private final long key; private final V value; - private boolean referenced; + private volatile boolean referenced; private Entry(long key, V value) { this.key = key; diff --git a/core/src/main/java/art/arcane/iris/generation/cave/IrisCaveProfileSampler.java b/core/src/main/java/art/arcane/iris/generation/cave/IrisCaveProfileSampler.java index d78b5cbbc..a78b2250e 100644 --- a/core/src/main/java/art/arcane/iris/generation/cave/IrisCaveProfileSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/cave/IrisCaveProfileSampler.java @@ -19,6 +19,8 @@ package art.arcane.iris.generation.cave; import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.volmlib.util.math.M; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; @@ -31,12 +33,15 @@ import java.util.List; public final class IrisCaveProfileSampler { private static final double FLOATING_THRESHOLD_BIAS_SCALE = 0.2D; - private final IrisData data; private final IrisCaveProfile profile; private final CNG baseDensity; private final CNG detailDensity; private final CNG warpDensity; - private final RNG thresholdRng; + private final CNG thresholdDensity; + private final double thresholdMin; + private final double thresholdMax; + private final double constantThreshold; + private final boolean thresholdHasNoise; private final ModuleState[] modules; private final double inverseNormalization; private final double baseWeight; @@ -47,14 +52,19 @@ public final class IrisCaveProfileSampler { private final boolean hasModules; public IrisCaveProfileSampler(Engine engine, IrisCaveProfile profile) { - this.data = engine.getData(); + IrisData data = engine.getData(); this.profile = profile; List moduleStates = new ArrayList<>(); RNG baseRng = new RNG(engine.getSeedManager().getCarve()); this.baseDensity = profile.getBaseDensityStyle().create(baseRng.nextParallelRNG(934_447), data); this.detailDensity = profile.getDetailDensityStyle().create(baseRng.nextParallelRNG(612_991), data); this.warpDensity = profile.getWarpStyle().create(baseRng.nextParallelRNG(770_713), data); - this.thresholdRng = baseRng.nextParallelRNG(489_112); + IrisStyledRange threshold = profile.getDensityThreshold(); + thresholdMin = threshold.getMin(); + thresholdMax = threshold.getMax(); + thresholdHasNoise = thresholdMin != thresholdMax && !threshold.getStyle().isFlat(); + thresholdDensity = thresholdHasNoise ? threshold.getStyle().create(baseRng.nextParallelRNG(489_112), data) : null; + constantThreshold = thresholdMin == thresholdMax ? thresholdMin : M.lerp(thresholdMin, thresholdMax, 0.5D); this.baseWeight = profile.getBaseWeight(); this.detailWeight = profile.getDetailWeight(); this.warpStrength = profile.getWarpStrength(); @@ -82,7 +92,8 @@ public final class IrisCaveProfileSampler { return false; } - double threshold = profile.getDensityThreshold().get(thresholdRng, x, z, data) - profile.getThresholdBias(); + double threshold = (thresholdHasNoise ? thresholdDensity.fitDouble(thresholdMin, thresholdMax, x, z) + : constantThreshold) - profile.getThresholdBias(); threshold += floatingThresholdBias(floatingCarveThreshold); return sampleDensity(x, y, z) <= threshold; } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java b/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java index 255f75846..e57e58ebe 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/DecoratorCore.java @@ -98,14 +98,6 @@ final class DecoratorCore { return picked; } - private static String topHalfValue(String half) { - return half.equals("upper") || half.equals("lower") ? "upper" : "top"; - } - - private static String bottomHalfValue(String half) { - return half.equals("upper") || half.equals("lower") ? "lower" : "bottom"; - } - static void placeSurfaceSingle(IrisDecorator decorator, int x, int z, int realX, int height, int realZ, Hunk data, RNG rng, IrisData irisData, @@ -150,7 +142,7 @@ final class DecoratorCore { } String half = bd == null ? null : IrisProceduralBlocks.propertyValue(bd, "half"); - if (half != null) { + if ("upper".equals(half) || "lower".equals(half)) { int lowerY = height + 1; int upperY = height + 2; if (!canPlaceTwoBlockPlant(data, x, z, lowerY, upperY, caveSkipFluid)) { @@ -158,9 +150,9 @@ final class DecoratorCore { } try { - NativeBlockState upper = bd.withProperty("half", topHalfValue(half)); + NativeBlockState upper = bd.withProperty("half", "upper"); NativeBlockState lower = fixFacesForHunk( - bd.withProperty("half", bottomHalfValue(half)), + bd.withProperty("half", "lower"), data, x, z, realX, lowerY, realZ, mantle); data.set(x, lowerY, z, lower); data.set(x, upperY, z, IrisProceduralBlocks.normalizeWaterlogging(upper, data.get(x, upperY, z), true)); @@ -228,6 +220,8 @@ final class DecoratorCore { int stack = computeStack(decorator, rng, realX, realZ, irisData, effectiveMax); int placed = 0; boolean hasSpikes = false; + NativeBlockState sampledBlock = null; + boolean sampledTop = false; for (int i = 0; i < stack; i++) { int y = height + 1 + i; if (y >= data.getHeight()) { @@ -239,9 +233,14 @@ final class DecoratorCore { break; } double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); - NativeBlockState block = threshold >= decorator.getTopThreshold() - ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) - : decorator.pickBlockData(rng, irisData, realX, realZ); + boolean top = threshold >= decorator.getTopThreshold(); + if (sampledBlock == null || top != sampledTop) { + sampledBlock = top + ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) + : decorator.pickBlockData(rng, irisData, realX, realZ); + sampledTop = top; + } + NativeBlockState block = sampledBlock; if (block == null) { break; } @@ -282,6 +281,8 @@ final class DecoratorCore { int stack = computeStack(decorator, rng, realX, realZ, irisData, max); int placed = 0; boolean hasSpikes = false; + NativeBlockState sampledBlock = null; + boolean sampledTop = false; for (int i = 0; i < stack; i++) { int y = height - i; if (y < 0 || y < minHeight) { @@ -293,9 +294,14 @@ final class DecoratorCore { break; } double threshold = stack == 1 ? 1.0 : ((double) i) / (stack - 1); - NativeBlockState block = threshold >= decorator.getTopThreshold() - ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) - : decorator.pickBlockData(rng, irisData, realX, realZ); + boolean top = threshold >= decorator.getTopThreshold(); + if (sampledBlock == null || top != sampledTop) { + sampledBlock = top + ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) + : decorator.pickBlockData(rng, irisData, realX, realZ); + sampledTop = top; + } + NativeBlockState block = sampledBlock; if (block == null) { break; } @@ -346,7 +352,7 @@ final class DecoratorCore { } String half = IrisProceduralBlocks.propertyValue(bd, "half"); - if (half != null) { + if ("upper".equals(half) || "lower".equals(half)) { int lowerY = height + 1; int upperY = height + 2; if (max <= 2 || !canPlaceTwoBlockPlant(data, xf, zf, lowerY, upperY, false)) { @@ -354,8 +360,8 @@ final class DecoratorCore { } try { - NativeBlockState upper = bd.withProperty("half", topHalfValue(half)); - NativeBlockState lower = bd.withProperty("half", bottomHalfValue(half)); + NativeBlockState upper = bd.withProperty("half", "upper"); + NativeBlockState lower = bd.withProperty("half", "lower"); data.set(xf, lowerY, zf, IrisProceduralBlocks.normalizeWaterlogging(lower, data.get(xf, lowerY, zf), true)); data.set(xf, upperY, zf, IrisProceduralBlocks.normalizeWaterlogging(upper, data.get(xf, upperY, zf), true)); } catch (Throwable e) { @@ -386,15 +392,22 @@ final class DecoratorCore { int placed = 0; boolean hasSpikes = false; + NativeBlockState sampledBlock = null; + boolean sampledTop = false; for (int i = 0; i < stack; i++) { int h = height + 1 + i; if (h >= height + max || h >= data.getHeight()) { break; } double threshold = stack == 1 ? 0.0 : ((double) i) / (stack - 1); - NativeBlockState bd = threshold >= decorator.getTopThreshold() - ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) - : decorator.pickBlockData(rng, irisData, realX, realZ); + boolean top = threshold >= decorator.getTopThreshold(); + if (sampledBlock == null || top != sampledTop) { + sampledBlock = top + ? decorator.pickBlockDataTop(rng, irisData, realX, realZ) + : decorator.pickBlockData(rng, irisData, realX, realZ); + sampledTop = top; + } + NativeBlockState bd = sampledBlock; if (bd == null) { break; } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisDecorator.java index 759665e8a..44f4da55e 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisDecorator.java @@ -193,20 +193,22 @@ public class IrisDecorator { } public NativeBlockState getBlockData100(IrisBiome b, RNG rng, double x, double y, double z, IrisData data) { - if (getBlockData(data).isEmpty()) { + KList palette = getBlockData(data); + if (palette.isEmpty()) { IrisLogging.warnOnce("decorator-empty:" + b.getName(), "Empty Block Data for " + b.getName()); return null; } - if (getBlockData(data).size() == 1) { - return getBlockData(data).get(0); + if (palette.size() == 1) { + return palette.get(0); } - return getVarianceGenerator(rng, data).fit(getBlockData(data), z, y, x); //X and Z must be switched + return getVarianceGenerator(rng, data).fit(palette, z, y, x); //X and Z must be switched } public NativeBlockState getBlockDataForTop(IrisBiome b, RNG rng, double x, double y, double z, IrisData data) { - if (getBlockDataTops(data).isEmpty()) { + KList topPalette = getBlockDataTops(data); + if (topPalette.isEmpty()) { return getBlockData100(b, rng, x, y, z, data); } @@ -214,11 +216,11 @@ public class IrisDecorator { double zz = z / style.getZoom(); if (getGenerator(rng, data).fitDouble(0D, 1D, xx, zz) <= chance) { //Exclude y from here - if (getBlockData(data).size() == 1) { - return getBlockDataTops(data).get(0); + if (topPalette.size() == 1) { + return topPalette.get(0); } - return getVarianceGenerator(rng, data).fit(getBlockDataTops(data), z, y, x); //X and Z must be switched + return getVarianceGenerator(rng, data).fit(topPalette, z, y, x); //X and Z must be switched } return null; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisProceduralBlocks.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisProceduralBlocks.java index f7ec8d7dd..2c0d5cc9d 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisProceduralBlocks.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisProceduralBlocks.java @@ -146,13 +146,17 @@ public final class IrisProceduralBlocks { } public static NativeBlockState normalizeWaterlogging(NativeBlockState state, NativeBlockState existing, boolean enabled) { - if (state == null || !hasProperty(state, "waterlogged")) { + if (state == null) { + return null; + } + String current = propertyValue(state, "waterlogged"); + if (current == null) { return state; } - boolean authored = "true".equals(propertyValue(state, "waterlogged")); + boolean authored = "true".equals(current); boolean submerged = existing != null && (existing.isWater() || "true".equals(propertyValue(existing, "waterlogged"))); String value = submerged && (enabled || authored) ? "true" : "false"; - return value.equals(propertyValue(state, "waterlogged")) ? state : state.withProperty("waterlogged", value); + return value.equals(current) ? state : state.withProperty("waterlogged", value); } public static IrisObject assemble(Map blocks) { diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisShoreLineDecorator.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisShoreLineDecorator.java index d85cace71..1c5493f3e 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisShoreLineDecorator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisShoreLineDecorator.java @@ -111,12 +111,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { return; } - int stack = decorator.getHeight(partRNG, realX, realZ, getData()); - if (decorator.isScaleStack()) { - stack = (int) Math.ceil((double) (max - height) * ((double) stack / 100)); - } else { - stack = Math.min(max - height, stack); - } + int stack = DecoratorCore.computeStack(decorator, partRNG, realX, realZ, getData(), max - height); if (stack == 1) { int targetY = height + 1; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/CaveCandidateBuilder.java b/core/src/main/java/art/arcane/iris/generation/hydrology/CaveCandidateBuilder.java index bec394c2f..282637a8c 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/CaveCandidateBuilder.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/CaveCandidateBuilder.java @@ -5,7 +5,6 @@ import art.arcane.iris.generation.hydrology.cave.CavePositionIndex; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveFluidPolicy; -import art.arcane.iris.generation.hydrology.cave.HydrologyCaveGrottoShape; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode; import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlannerSettings; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource; @@ -170,11 +169,8 @@ final class CaveCandidateBuilder { volume, 1, 1, - 1, - 1, dryHeadroom, HydrologyCaveFluidPolicy.REJECT_EXISTING, - HydrologyCaveGrottoShape.ELLIPSOID, horizontalRadius, maximumDepth ); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOutletPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOutletPlanner.java index 444a9b65f..d31cb098f 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOutletPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyOutletPlanner.java @@ -114,7 +114,7 @@ final class HydrologyOutletPlanner { return selectedOcean; } if (surface) { - HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); + HydrologySurfaceBudgets budgets = grid.surfaceBudgets(planner.settings.surface().sources()); if (budgets.overridden()) { oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true)); } @@ -243,7 +243,7 @@ final class HydrologyOutletPlanner { } sortSurfaceFallbackOutletCandidates(grid, oceanCandidates); sortSurfaceFallbackOutletCandidates(grid, inlandCandidates); - HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); + HydrologySurfaceBudgets budgets = grid.surfaceBudgets(planner.settings.surface().sources()); if (budgets.overridden()) { HydrologyTileKey key = HydrologyTileKey.fromBlock(grid.ownerMinimumX(), grid.ownerMinimumZ(), grid.ownerSize()); oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true)); @@ -279,7 +279,7 @@ final class HydrologyOutletPlanner { List limitSurfaceOutlets(HydrologyTileKey key, HydrologySampledGrid grid, List candidates, boolean coastal) { - HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources()); + HydrologySurfaceBudgets budgets = grid.surfaceBudgets(planner.settings.surface().sources()); if (!budgets.overridden()) { return coastal ? limitOutletsByType(candidates, planner.settings.outlets().maximumCoastalPerTile()) : limitOutlets(candidates, planner.settings.outlets().maximumPerTile()); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySampledGrid.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySampledGrid.java index 3f778e85e..de428b665 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySampledGrid.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySampledGrid.java @@ -12,6 +12,7 @@ final class HydrologySampledGrid { private final int spacing; private final List nodes; private volatile HydrologyDrainageGraph drainage; + private volatile SurfaceBudgetSnapshot surfaceBudgets; HydrologySampledGrid(int minimumX, int minimumZ, int ownerMinimumX, int ownerMinimumZ, int ownerSize, int width, int spacing, List nodes) { @@ -77,6 +78,21 @@ final class HydrologySampledGrid { return nodes.get(index); } + HydrologySurfaceBudgets surfaceBudgets(HydrologyPlannerSettings.Source sources) { + SurfaceBudgetSnapshot current = surfaceBudgets; + if (current != null && current.sources().equals(sources)) { + return current.budgets(); + } + synchronized (this) { + current = surfaceBudgets; + if (current == null || !current.sources().equals(sources)) { + current = new SurfaceBudgetSnapshot(sources, HydrologySurfaceBudgets.sample(this, sources)); + surfaceBudgets = current; + } + return current.budgets(); + } + } + HydrologyGridNode nodeAt(int gridX, int gridZ) { if (gridX < 0 || gridZ < 0 || gridX >= width || gridZ >= width) { return null; @@ -102,4 +118,7 @@ final class HydrologySampledGrid { return x >= ownerMinimumX && x < ownerMinimumX + ownerSize && z >= ownerMinimumZ && z < ownerMinimumZ + ownerSize; } + + private record SurfaceBudgetSnapshot(HydrologyPlannerSettings.Source sources, HydrologySurfaceBudgets budgets) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java index 34c0d3719..3f44416c4 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java @@ -219,7 +219,7 @@ final class HydrologySourcePlanner { return SourceSelection.empty(surface); } long sourceSalt = surface ? SURFACE_SOURCE_SALT : UNDERGROUND_SOURCE_SALT; - HydrologySurfaceBudgets surfaceBudgets = surface ? HydrologySurfaceBudgets.sample(grid, sourceSettings) : null; + HydrologySurfaceBudgets surfaceBudgets = surface ? grid.surfaceBudgets(sourceSettings) : null; ArrayList candidates = new ArrayList<>(); boolean hasRequiredCandidate = false; for (HydrologyGridNode node : grid.nodes()) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveBoundaryResult.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveBoundaryResult.java deleted file mode 100644 index da0002728..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveBoundaryResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package art.arcane.iris.generation.hydrology.cave; - -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; - -record CaveBoundaryResult(Set sealGuards, HydrologyCaveRejection rejection) { - static CaveBoundaryResult accepted(Set sealGuards) { - return new CaveBoundaryResult( - Collections.unmodifiableSet(new LinkedHashSet<>(sealGuards)), - HydrologyCaveRejection.NONE - ); - } - - static CaveBoundaryResult rejected(HydrologyCaveRejection rejection) { - return new CaveBoundaryResult(Set.of(), rejection); - } -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveComponentResult.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveComponentResult.java deleted file mode 100644 index 9c66dfd24..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveComponentResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package art.arcane.iris.generation.hydrology.cave; - -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; - -record CaveComponentResult(Set positions, HydrologyCaveRejection rejection) { - static CaveComponentResult accepted(Set positions) { - return new CaveComponentResult( - Collections.unmodifiableSet(new LinkedHashSet<>(positions)), - HydrologyCaveRejection.NONE - ); - } - - static CaveComponentResult rejected(HydrologyCaveRejection rejection) { - return new CaveComponentResult(Set.of(), rejection); - } -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveGrottoResult.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveGrottoResult.java deleted file mode 100644 index aa902bc2a..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CaveGrottoResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package art.arcane.iris.generation.hydrology.cave; - -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; - -record CaveGrottoResult(Set positions, HydrologyCaveRejection rejection) { - static CaveGrottoResult accepted(Set positions) { - return new CaveGrottoResult( - Collections.unmodifiableSet(new LinkedHashSet<>(positions)), - HydrologyCaveRejection.NONE - ); - } - - static CaveGrottoResult rejected(HydrologyCaveRejection rejection) { - return new CaveGrottoResult(Set.of(), rejection); - } -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CavePathResult.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CavePathResult.java deleted file mode 100644 index c6911d1db..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/CavePathResult.java +++ /dev/null @@ -1,13 +0,0 @@ -package art.arcane.iris.generation.hydrology.cave; - -import java.util.List; - -record CavePathResult(List positions, HydrologyCaveRejection rejection) { - static CavePathResult accepted(List positions) { - return new CavePathResult(List.copyOf(positions), HydrologyCaveRejection.NONE); - } - - static CavePathResult rejected(HydrologyCaveRejection rejection) { - return new CavePathResult(List.of(), rejection); - } -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveChamberPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveChamberPlanner.java deleted file mode 100644 index edaf6ed7f..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveChamberPlanner.java +++ /dev/null @@ -1,809 +0,0 @@ -package art.arcane.iris.generation.hydrology.cave; - -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collection; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import java.util.Set; - -final class HydrologyCaveChamberPlanner { - private final HydrologyCaveContainmentPlanner planner; - - HydrologyCaveChamberPlanner(HydrologyCaveContainmentPlanner planner) { - this.planner = planner; - } - - HydrologyCavePlan planGrottoOrClosedComponent( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List throat - ) { - CaveVoxel targetVoxel = planner.voxelAt(view, source.target()); - if (planner.isFluidReachable(targetVoxel, settings)) { - return planClosedComponent(view, source, settings, throat); - } - if (targetVoxel == CaveVoxel.LAVA) { - return planner.rejected(source, HydrologyCaveRejection.LAVA_CONTACT); - } - if (targetVoxel == CaveVoxel.INCOMPATIBLE_FLUID) { - return planner.rejected(source, HydrologyCaveRejection.INCOMPATIBLE_FLUID); - } - return planGeneratedGrotto(view, source, settings, throat); - } - - HydrologyCavePlan planWaterfallPool( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List throat - ) { - if (!view.isOpenToSurface(source.target())) { - return planGrottoOrClosedComponent(view, source, settings, throat); - } - HydrologyCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat); - if (dryThroatRejection != HydrologyCaveRejection.NONE) { - return planner.rejected(source, dryThroatRejection); - } - HydrologyCaveRejection shaftRejection = validateWaterfallShaft(view, source, settings, throat); - if (shaftRejection != HydrologyCaveRejection.NONE) { - return planner.rejected(source, shaftRejection); - } - CaveVoxel targetVoxel = planner.voxelAt(view, source.target()); - if (!planner.isFluidReachable(targetVoxel, settings)) { - return planner.rejected(source, planner.rejectionForTarget(targetVoxel, settings)); - } - - Map actions = new LinkedHashMap<>(); - addThroatActions(actions, throat, source); - addSealGuards(view, source, actions); - return planner.accepted(view, source, actions); - } - - HydrologyCavePlan planClosedComponent( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List throat - ) { - HydrologyCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat); - if (dryThroatRejection != HydrologyCaveRejection.NONE) { - return planner.rejected(source, dryThroatRejection); - } - HydrologyCaveRejection waterfallRejection = validateWaterfallShaft(view, source, settings, throat); - if (waterfallRejection != HydrologyCaveRejection.NONE) { - return planner.rejected(source, waterfallRejection); - } - - CaveVoxel targetVoxel = planner.voxelAt(view, source.target()); - if (!planner.isFluidReachable(targetVoxel, settings)) { - return planner.rejected(source, planner.rejectionForTarget(targetVoxel, settings)); - } - - CaveComponentResult component = resolveClosedComponent(view, source, settings, throat); - if (component.rejection() != HydrologyCaveRejection.NONE) { - return planner.rejected(source, component.rejection()); - } - - Map actions = new LinkedHashMap<>(); - addThroatActions(actions, throat, source); - for (CavePosition position : component.positions()) { - actions.put(position, HydrologyCaveAction.WET_SOURCE); - } - addSealGuards(view, source, actions); - return planner.accepted(view, source, actions); - } - - HydrologyCaveRejection validateDryThroatContacts( - CaveVoxelView view, - HydrologyCaveSource source, - List throat - ) { - Set throatPositions = Set.copyOf(throat); - for (CavePosition position : throat) { - if (position.y() <= source.waterHeadY()) { - continue; - } - for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return HydrologyCaveRejection.WORLD_BOUNDARY; - } - CaveVoxel voxel = planner.voxelAt(view, neighbor); - if (voxel == CaveVoxel.LAVA) { - return HydrologyCaveRejection.LAVA_CONTACT; - } - if (voxel == CaveVoxel.COMPATIBLE_FLUID) { - return HydrologyCaveRejection.EXISTING_FLUID; - } - if (voxel == CaveVoxel.INCOMPATIBLE_FLUID) { - return HydrologyCaveRejection.INCOMPATIBLE_FLUID; - } - } - } - return HydrologyCaveRejection.NONE; - } - - HydrologyCaveRejection validateWaterfallShaft( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List throat - ) { - if (source.mode() != HydrologyCaveMode.WATERFALL_POOL) { - return HydrologyCaveRejection.NONE; - } - - Set throatPositions = Set.copyOf(throat); - for (CavePosition position : throat) { - if (position.y() <= source.waterHeadY()) { - continue; - } - for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return HydrologyCaveRejection.WORLD_BOUNDARY; - } - HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, neighbor); - if (boundsRejection != HydrologyCaveRejection.NONE) { - return boundsRejection; - } - CaveVoxel voxel = planner.voxelAt(view, neighbor); - HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings); - if (hazard != HydrologyCaveRejection.NONE) { - return hazard; - } - if (voxel != CaveVoxel.SOLID) { - return HydrologyCaveRejection.WATERFALL_SHAFT_OPEN; - } - } - } - return HydrologyCaveRejection.NONE; - } - - HydrologyCavePlan planGeneratedGrotto( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List throat - ) { - CaveGrottoResult grotto = buildGrotto(source, settings); - if (grotto.rejection() != HydrologyCaveRejection.NONE) { - return planner.rejected(source, grotto.rejection()); - } - Set chamber = grotto.positions(); - - Set carve = new LinkedHashSet<>(chamber.size() + throat.size()); - carve.addAll(chamber); - carve.addAll(throat); - HydrologyCaveRejection carveRejection = validateGeneratedCarve(view, source, settings, carve); - if (carveRejection != HydrologyCaveRejection.NONE) { - return planner.rejected(source, carveRejection); - } - - CaveBoundaryResult boundary = validateGeneratedBoundary(view, source, settings, carve); - if (boundary.rejection() != HydrologyCaveRejection.NONE) { - return planner.rejected(source, boundary.rejection()); - } - - Map actions = new LinkedHashMap<>(); - addChamberActions(actions, chamber, source.waterHeadY()); - addThroatActions(actions, throat, source); - for (CavePosition position : boundary.sealGuards()) { - actions.put(position, HydrologyCaveAction.SEAL_GUARD); - } - return planner.accepted(view, source, actions); - } - - HydrologyCavePlan planDeepPool( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List throat - ) { - CaveGrottoResult grotto = buildGrotto(source, settings); - if (grotto.rejection() != HydrologyCaveRejection.NONE) { - return planner.rejected(source, grotto.rejection()); - } - Set chamber = grotto.positions(); - Set carve = new LinkedHashSet<>(chamber.size() + throat.size()); - carve.addAll(chamber); - carve.addAll(throat); - - HydrologyCaveRejection carveRejection = validateDeepPoolCarve(view, source, settings, carve); - if (carveRejection != HydrologyCaveRejection.NONE) { - return planner.rejected(source, carveRejection); - } - CaveBoundaryResult boundary = validateDeepPoolBoundary(view, source, settings, carve); - if (boundary.rejection() != HydrologyCaveRejection.NONE) { - return planner.rejected(source, boundary.rejection()); - } - - Map actions = new LinkedHashMap<>(); - addChamberActions(actions, chamber, source.waterHeadY()); - addThroatActions(actions, throat, source); - for (CavePosition position : boundary.sealGuards()) { - actions.put(position, HydrologyCaveAction.SEAL_GUARD); - } - return planner.accepted(view, source, actions); - } - - HydrologyCaveRejection validateDeepPoolCarve( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - Set carve - ) { - for (CavePosition position : carve) { - if (!view.isInWorld(position)) { - return HydrologyCaveRejection.WORLD_BOUNDARY; - } - HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position); - if (boundsRejection != HydrologyCaveRejection.NONE) { - return boundsRejection; - } - CaveVoxel voxel = planner.voxelAt(view, position); - HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings); - if (hazard != HydrologyCaveRejection.NONE) { - return hazard; - } - if (voxel == CaveVoxel.SOLID) { - continue; - } - if (position.y() > source.waterHeadY() - && voxel == CaveVoxel.CAVE_AIR - && !view.isOpenToSurface(position)) { - continue; - } - return HydrologyCaveRejection.GROTTO_INTERSECTION; - } - return HydrologyCaveRejection.NONE; - } - - CaveBoundaryResult validateDeepPoolBoundary( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - Set carve - ) { - Set guards = new LinkedHashSet<>(); - for (CavePosition position : carve) { - for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (carve.contains(neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return CaveBoundaryResult.rejected(HydrologyCaveRejection.WORLD_BOUNDARY); - } - HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, neighbor); - if (boundsRejection != HydrologyCaveRejection.NONE) { - return CaveBoundaryResult.rejected(boundsRejection); - } - CaveVoxel voxel = planner.voxelAt(view, neighbor); - HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings); - if (hazard != HydrologyCaveRejection.NONE) { - return CaveBoundaryResult.rejected(hazard); - } - if (voxel == CaveVoxel.SOLID) { - guards.add(neighbor); - continue; - } - if (neighbor.y() > source.waterHeadY() - && voxel == CaveVoxel.CAVE_AIR - && !view.isOpenToSurface(neighbor)) { - continue; - } - return CaveBoundaryResult.rejected(HydrologyCaveRejection.GROTTO_SHELL_OPEN); - } - } - return CaveBoundaryResult.accepted(guards); - } - - CaveComponentResult resolveClosedComponent( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List throat - ) { - Queue queue = new ArrayDeque<>(); - Set queued = new LinkedHashSet<>(); - int visitedCount = 0; - - queue.add(source.target()); - queued.add(source.target()); - HydrologyCaveRejection seedRejection = addThroatContacts(view, source, settings, throat, queue, queued); - if (seedRejection != HydrologyCaveRejection.NONE) { - return CaveComponentResult.rejected(seedRejection); - } - - while (!queue.isEmpty()) { - CavePosition position = queue.remove(); - HydrologyCaveRejection positionRejection = validateReachablePosition(view, source, settings, position); - if (positionRejection != HydrologyCaveRejection.NONE) { - return CaveComponentResult.rejected(positionRejection); - } - if (++visitedCount > settings.maxFloodVolume()) { - return CaveComponentResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); - } - - for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - HydrologyCaveRejection neighborRejection = inspectReachableNeighbor( - view, - source, - settings, - neighbor, - queue, - queued - ); - if (neighborRejection != HydrologyCaveRejection.NONE) { - return CaveComponentResult.rejected(neighborRejection); - } - } - } - - return CaveComponentResult.accepted(queued); - } - - HydrologyCaveRejection addThroatContacts( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List throat, - Queue queue, - Set queued - ) { - for (CavePosition position : throat) { - if (position.y() > source.waterHeadY()) { - continue; - } - for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - HydrologyCaveRejection rejection = inspectReachableNeighbor( - view, - source, - settings, - neighbor, - queue, - queued - ); - if (rejection != HydrologyCaveRejection.NONE) { - return rejection; - } - } - } - return HydrologyCaveRejection.NONE; - } - - HydrologyCaveRejection inspectReachableNeighbor( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - CavePosition position, - Queue queue, - Set queued - ) { - if (position.y() > source.waterHeadY()) { - return inspectAboveHeadNeighbor(view, source, position); - } - if (!view.isInWorld(position)) { - return HydrologyCaveRejection.WORLD_BOUNDARY; - } - - CaveVoxel voxel = planner.voxelAt(view, position); - HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings); - if (hazard != HydrologyCaveRejection.NONE) { - return hazard; - } - if (!planner.isFluidReachable(voxel, settings)) { - return HydrologyCaveRejection.NONE; - } - - HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position); - if (boundsRejection != HydrologyCaveRejection.NONE) { - return boundsRejection; - } - if (queued.add(position)) { - queue.add(position); - } - return HydrologyCaveRejection.NONE; - } - - HydrologyCaveRejection inspectAboveHeadNeighbor( - CaveVoxelView view, - HydrologyCaveSource source, - CavePosition position - ) { - if (isInletOpening(source, position) || !view.isInWorld(position)) { - return HydrologyCaveRejection.NONE; - } - CaveVoxel voxel = planner.voxelAt(view, position); - return switch (voxel) { - case LAVA -> HydrologyCaveRejection.LAVA_CONTACT; - case COMPATIBLE_FLUID -> HydrologyCaveRejection.EXISTING_FLUID; - case INCOMPATIBLE_FLUID -> HydrologyCaveRejection.INCOMPATIBLE_FLUID; - default -> HydrologyCaveRejection.NONE; - }; - } - - HydrologyCaveRejection validateReachablePosition( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - CavePosition position - ) { - if (!view.isInWorld(position)) { - return HydrologyCaveRejection.WORLD_BOUNDARY; - } - HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position); - if (boundsRejection != HydrologyCaveRejection.NONE) { - return boundsRejection; - } - if (view.isOpenToSurface(position)) { - return HydrologyCaveRejection.OPEN_SURFACE; - } - CaveVoxel voxel = planner.voxelAt(view, position); - HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings); - if (hazard != HydrologyCaveRejection.NONE) { - return hazard; - } - return planner.isFluidReachable(voxel, settings) - ? HydrologyCaveRejection.NONE - : HydrologyCaveRejection.NO_CAVE_TARGET; - } - - CavePathResult buildThroat( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings - ) { - CavePosition entry = source.entry(); - CavePosition target = source.target(); - int deltaX = target.x() - entry.x(); - int deltaY = target.y() - entry.y(); - int deltaZ = target.z() - entry.z(); - int movesX = Math.abs(deltaX); - int movesY = Math.abs(deltaY); - int movesZ = Math.abs(deltaZ); - int length = movesX + movesY + movesZ; - if (length > settings.maxThroatLength()) { - return CavePathResult.rejected(HydrologyCaveRejection.THROAT_LIMIT); - } - - int stepX = Integer.signum(deltaX); - int stepY = Integer.signum(deltaY); - int stepZ = Integer.signum(deltaZ); - int usedX = 0; - int usedY = 0; - int usedZ = 0; - CavePosition current = entry; - List positions = new ArrayList<>(length + 1); - - while (true) { - HydrologyCaveRejection positionRejection = validateThroatPosition(view, source, settings, current); - if (positionRejection != HydrologyCaveRejection.NONE) { - return CavePathResult.rejected(positionRejection); - } - positions.add(current); - if (current.equals(target)) { - return expandThroat(view, source, settings, positions); - } - - int axis = selectNextAxis(source.sourceId(), movesX, movesY, movesZ, usedX, usedY, usedZ); - if (axis == 0) { - current = current.offset(stepX, 0, 0); - usedX++; - } else if (axis == 1) { - current = current.offset(0, stepY, 0); - usedY++; - } else { - current = current.offset(0, 0, stepZ); - usedZ++; - } - } - } - - CavePathResult expandThroat( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - List centerline - ) { - int radius = settings.throatRadius(); - int extent = radius - 1; - int radiusSquared = radius * radius; - Set expanded = new LinkedHashSet<>(); - for (CavePosition center : centerline) { - for (int dx = -extent; dx <= extent; dx++) { - for (int dy = -extent; dy <= extent; dy++) { - for (int dz = -extent; dz <= extent; dz++) { - if ((dx * dx) + (dy * dy) + (dz * dz) >= radiusSquared) { - continue; - } - CavePosition position = center.offset(dx, dy, dz); - if (position.y() > source.entry().y()) { - continue; - } - HydrologyCaveRejection rejection = validateThroatPosition(view, source, settings, position); - if (rejection != HydrologyCaveRejection.NONE) { - return CavePathResult.rejected(rejection); - } - expanded.add(position); - if (expanded.size() > settings.maxFloodVolume()) { - return CavePathResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); - } - } - } - } - } - return CavePathResult.accepted(List.copyOf(expanded)); - } - - int selectNextAxis( - long sourceId, - int movesX, - int movesY, - int movesZ, - int usedX, - int usedY, - int usedZ - ) { - double scoreX = nextAxisScore(movesX, usedX); - double scoreY = nextAxisScore(movesY, usedY); - double scoreZ = nextAxisScore(movesZ, usedZ); - double minimum = Math.min(scoreX, Math.min(scoreY, scoreZ)); - int tieOffset = Math.floorMod(sourceId, 3); - for (int offset = 0; offset < 3; offset++) { - int axis = (tieOffset + offset) % 3; - double score = axis == 0 ? scoreX : axis == 1 ? scoreY : scoreZ; - if (score == minimum) { - return axis; - } - } - throw new IllegalStateException("No remaining throat axis"); - } - - double nextAxisScore(int moves, int used) { - if (used >= moves) { - return Double.POSITIVE_INFINITY; - } - return ((2D * used) + 1D) / moves; - } - - HydrologyCaveRejection validateThroatPosition( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - CavePosition position - ) { - if (!view.isInWorld(position)) { - return HydrologyCaveRejection.WORLD_BOUNDARY; - } - HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position); - if (boundsRejection != HydrologyCaveRejection.NONE) { - return boundsRejection; - } - return planner.rejectionForHazard(planner.voxelAt(view, position), settings); - } - - CaveGrottoResult buildGrotto(HydrologyCaveSource source, HydrologyCavePlannerSettings settings) { - int horizontalRadius = settings.grottoHorizontalRadius(); - int verticalRadius = settings.grottoVerticalRadius(); - Set candidates = new LinkedHashSet<>(); - - for (int dx = -horizontalRadius; dx <= horizontalRadius; dx++) { - for (int dy = -verticalRadius; dy <= verticalRadius; dy++) { - for (int dz = -horizontalRadius; dz <= horizontalRadius; dz++) { - if (settings.grottoShape().contains(source, settings, dx, dy, dz)) { - candidates.add(source.target().offset(dx, dy, dz)); - if (candidates.size() > settings.maxFloodVolume()) { - return CaveGrottoResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); - } - } - } - } - } - candidates.add(source.target()); - for (int offset = 1; offset <= settings.dryHeadroom(); offset++) { - CavePosition headroom = new CavePosition( - source.target().x(), source.waterHeadY() + offset, source.target().z()); - if (Math.abs(headroom.y() - source.target().y()) > verticalRadius) { - return CaveGrottoResult.rejected(HydrologyCaveRejection.DRY_HEADROOM_LIMIT); - } - candidates.add(headroom); - if (candidates.size() > settings.maxFloodVolume()) { - return CaveGrottoResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); - } - } - return CaveGrottoResult.accepted(connectedGrotto(source.target(), candidates)); - } - - Set connectedGrotto(CavePosition target, Set candidates) { - Queue queue = new ArrayDeque<>(); - Set connected = new LinkedHashSet<>(); - queue.add(target); - connected.add(target); - while (!queue.isEmpty()) { - CavePosition position = queue.remove(); - for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (candidates.contains(neighbor) && connected.add(neighbor)) { - queue.add(neighbor); - } - } - } - return connected; - } - - HydrologyCaveRejection validateGeneratedCarve( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - Set carve - ) { - for (CavePosition position : carve) { - if (!view.isInWorld(position)) { - return HydrologyCaveRejection.WORLD_BOUNDARY; - } - HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, position); - if (boundsRejection != HydrologyCaveRejection.NONE) { - return boundsRejection; - } - CaveVoxel voxel = planner.voxelAt(view, position); - if (isGeneratedInletCarve(view, source, settings, position, voxel)) { - continue; - } - HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings); - if (hazard != HydrologyCaveRejection.NONE) { - return hazard; - } - if (voxel != CaveVoxel.SOLID) { - return HydrologyCaveRejection.GROTTO_INTERSECTION; - } - } - return HydrologyCaveRejection.NONE; - } - - CaveBoundaryResult validateGeneratedBoundary( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - Set carve - ) { - Set guards = new LinkedHashSet<>(); - for (CavePosition position : carve) { - for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (carve.contains(neighbor)) { - continue; - } - if (isInletOpening(source, neighbor) - || isGeneratedInletOpening(view, source, settings, neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return CaveBoundaryResult.rejected(HydrologyCaveRejection.WORLD_BOUNDARY); - } - HydrologyCaveRejection boundsRejection = planner.validateBounds(source, settings, neighbor); - if (boundsRejection != HydrologyCaveRejection.NONE) { - return CaveBoundaryResult.rejected(boundsRejection); - } - CaveVoxel voxel = planner.voxelAt(view, neighbor); - HydrologyCaveRejection hazard = planner.rejectionForHazard(voxel, settings); - if (hazard != HydrologyCaveRejection.NONE) { - return CaveBoundaryResult.rejected(hazard); - } - if (voxel != CaveVoxel.SOLID) { - return CaveBoundaryResult.rejected(HydrologyCaveRejection.GROTTO_SHELL_OPEN); - } - guards.add(neighbor); - } - } - return CaveBoundaryResult.accepted(guards); - } - - void addChamberActions( - Map actions, - Collection positions, - int waterHeadY - ) { - for (CavePosition position : positions) { - HydrologyCaveAction action = position.y() <= waterHeadY - ? HydrologyCaveAction.WET_SOURCE - : HydrologyCaveAction.DRY_AIR; - actions.put(position, action); - } - } - - void addThroatActions( - Map actions, - Collection throat, - HydrologyCaveSource source - ) { - for (CavePosition position : throat) { - HydrologyCaveAction action; - if (position.y() <= source.waterHeadY()) { - action = HydrologyCaveAction.WET_SOURCE; - } else if (source.mode() == HydrologyCaveMode.WATERFALL_POOL - || source.mode() == HydrologyCaveMode.GENERATED_GROTTO) { - action = HydrologyCaveAction.FALLING_FLUID; - } else { - action = HydrologyCaveAction.DRY_AIR; - } - actions.put(position, action); - } - } - - void addSealGuards( - CaveVoxelView view, - HydrologyCaveSource source, - Map actions - ) { - Set guards = new LinkedHashSet<>(); - for (CavePosition position : List.copyOf(actions.keySet())) { - for (CavePosition direction : HydrologyCaveContainmentPlanner.DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (actions.containsKey(neighbor) - || isInletOpening(source, neighbor) - || !view.isInWorld(neighbor)) { - continue; - } - if (planner.voxelAt(view, neighbor) == CaveVoxel.SOLID) { - guards.add(neighbor); - } - } - } - for (CavePosition guard : guards) { - actions.put(guard, HydrologyCaveAction.SEAL_GUARD); - } - } - - boolean isInletOpening(HydrologyCaveSource source, CavePosition position) { - return position.equals(source.entry().offset(0, 1, 0)); - } - - boolean isGeneratedInletCarve( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - CavePosition position, - CaveVoxel voxel - ) { - if (voxel != CaveVoxel.CAVE_AIR && voxel != CaveVoxel.COMPATIBLE_FLUID) { - return false; - } - int extent = Math.max(0, settings.throatRadius() - 1); - long deltaX = (long) position.x() - source.entry().x(); - long deltaZ = (long) position.z() - source.entry().z(); - return position.y() >= source.entry().y() - extent - && position.y() <= source.entry().y() - && deltaX * deltaX + deltaZ * deltaZ <= (long) extent * extent - && view.isOpenToSurface(position); - } - - boolean isGeneratedInletOpening( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - CavePosition position - ) { - int radius = Math.max(1, settings.throatRadius()); - long deltaX = (long) position.x() - source.entry().x(); - long deltaZ = (long) position.z() - source.entry().z(); - return position.y() >= source.entry().y() - && position.y() <= source.entry().y() + 1 - && deltaX * deltaX + deltaZ * deltaZ < (long) radius * radius - && view.isOpenToSurface(position); - } -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveContainmentPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveContainmentPlanner.java index c35eac5da..27e9ac995 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveContainmentPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveContainmentPlanner.java @@ -29,91 +29,6 @@ public final class HydrologyCaveContainmentPlanner { static final Comparator SOURCE_PRIORITY = HydrologyCaveConflictPolicy.sourcePriority(); - final HydrologyCaveChamberPlanner chambers; - - public HydrologyCaveContainmentPlanner() { - this.chambers = new HydrologyCaveChamberPlanner(this); - } - - public HydrologyCavePlan plan( - CaveVoxelView view, - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings - ) { - Objects.requireNonNull(view); - Objects.requireNonNull(source); - Objects.requireNonNull(settings); - - HydrologyCaveRejection sourceRejection = validateSource(source); - if (sourceRejection != HydrologyCaveRejection.NONE) { - return rejected(source, sourceRejection); - } - - CavePathResult throat = chambers.buildThroat(view, source, settings); - if (throat.rejection() != HydrologyCaveRejection.NONE) { - return rejected(source, throat.rejection()); - } - - return switch (source.mode()) { - case CLOSED_COMPONENT -> chambers.planClosedComponent(view, source, settings, throat.positions()); - case GENERATED_GROTTO -> chambers.planGeneratedGrotto(view, source, settings, throat.positions()); - case GROTTO_OR_CLOSED_COMPONENT -> chambers.planGrottoOrClosedComponent( - view, - source, - settings, - throat.positions() - ); - case WATERFALL_POOL -> chambers.planWaterfallPool( - view, - source, - settings, - throat.positions() - ); - case DEEP_POOL -> chambers.planDeepPool(view, source, settings, throat.positions()); - }; - } - - public HydrologyCavePlanningResult planAll( - CaveVoxelView view, - Collection sources, - HydrologyCavePlannerSettings settings - ) { - Objects.requireNonNull(view); - Objects.requireNonNull(sources); - Objects.requireNonNull(settings); - - List orderedSources = new ArrayList<>(sources); - orderedSources.sort(SOURCE_PRIORITY); - List plans = new ArrayList<>(orderedSources.size()); - Map combinedActions = new LinkedHashMap<>(); - Map claimedBy = new HashMap<>(); - Map combinedPreconditions = new LinkedHashMap<>(); - - for (HydrologyCaveSource source : orderedSources) { - HydrologyCavePlan candidate = plan(view, source, settings); - if (!candidate.accepted()) { - plans.add(candidate); - continue; - } - OptionalLong winnerSourceId = findWinningSourceId( - candidate.actions().keySet(), - claimedBy - ); - if (winnerSourceId.isPresent()) { - plans.add(rejectedOverlap(source, winnerSourceId.getAsLong())); - } else { - plans.add(candidate); - combinedActions.putAll(candidate.actions()); - combinedPreconditions.putAll(candidate.baselinePreconditions()); - } - for (CavePosition position : candidate.actions().keySet()) { - claimedBy.putIfAbsent(position, source); - } - } - - return new HydrologyCavePlanningResult(plans, combinedActions, combinedPreconditions); - } - public HydrologyCavePlanningResult validateAll( CaveVoxelView view, Collection candidates @@ -698,14 +613,6 @@ public final class HydrologyCaveContainmentPlanner { return HydrologyCaveRejection.NONE; } - HydrologyCaveRejection rejectionForTarget( - CaveVoxel voxel, - HydrologyCavePlannerSettings settings - ) { - HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); - return hazard == HydrologyCaveRejection.NONE ? HydrologyCaveRejection.NO_CAVE_TARGET : hazard; - } - HydrologyCaveRejection rejectionForHazard( CaveVoxel voxel, HydrologyCavePlannerSettings settings @@ -722,50 +629,10 @@ public final class HydrologyCaveContainmentPlanner { }; } - boolean isFluidReachable(CaveVoxel voxel, HydrologyCavePlannerSettings settings) { - return voxel == CaveVoxel.CAVE_AIR - || (voxel == CaveVoxel.COMPATIBLE_FLUID - && settings.existingFluidPolicy() != HydrologyCaveFluidPolicy.REJECT_EXISTING) - || (voxel == CaveVoxel.INCOMPATIBLE_FLUID - && settings.existingFluidPolicy() == HydrologyCaveFluidPolicy.REPLACE_CONTAINED); - } - CaveVoxel voxelAt(CaveVoxelView view, CavePosition position) { return Objects.requireNonNull(view.voxelAt(position)); } - OptionalLong findWinningSourceId( - Set positions, - Map claimedBy - ) { - HydrologyCaveSource winner = null; - for (CavePosition position : positions) { - HydrologyCaveSource contender = claimedBy.get(position); - if (contender == null) { - continue; - } - if (winner == null || SOURCE_PRIORITY.compare(contender, winner) < 0) { - winner = contender; - } - } - return winner == null ? OptionalLong.empty() : OptionalLong.of(winner.sourceId()); - } - - HydrologyCavePlan accepted( - CaveVoxelView view, - HydrologyCaveSource source, - Map actions - ) { - Map preconditions = new LinkedHashMap<>(actions.size()); - for (CavePosition position : actions.keySet()) { - preconditions.put( - position, - new CaveVoxelPrecondition(voxelAt(view, position), view.isOpenToSurface(position)) - ); - } - return accepted(source, actions, preconditions); - } - HydrologyCavePlan accepted( HydrologyCaveSource source, Map actions, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveGrottoShape.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveGrottoShape.java deleted file mode 100644 index 91296e39a..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveGrottoShape.java +++ /dev/null @@ -1,21 +0,0 @@ -package art.arcane.iris.generation.hydrology.cave; - -@FunctionalInterface -public interface HydrologyCaveGrottoShape { - HydrologyCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> { - double horizontalRadius = settings.grottoHorizontalRadius(); - double verticalRadius = settings.grottoVerticalRadius(); - double normalized = ((double) dx * dx / (horizontalRadius * horizontalRadius)) - + ((double) dy * dy / (verticalRadius * verticalRadius)) - + ((double) dz * dz / (horizontalRadius * horizontalRadius)); - return normalized <= 1D; - }; - - boolean contains( - HydrologyCaveSource source, - HydrologyCavePlannerSettings settings, - int offsetX, - int offsetY, - int offsetZ - ); -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlannerSettings.java b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlannerSettings.java index cbdde8dca..125672fd3 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlannerSettings.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/cave/HydrologyCavePlannerSettings.java @@ -6,72 +6,17 @@ public record HydrologyCavePlannerSettings( int maxHorizontalRadius, int maxDepth, int maxFloodVolume, - int maxThroatLength, - int throatRadius, int grottoHorizontalRadius, int grottoVerticalRadius, int dryHeadroom, HydrologyCaveFluidPolicy existingFluidPolicy, - HydrologyCaveGrottoShape grottoShape, int maxClosedComponentHorizontalRadius, int maxClosedComponentDepth ) { public static final int MAXIMUM_PLANNED_MUTATIONS = 262_144; - public HydrologyCavePlannerSettings( - int maxHorizontalRadius, - int maxDepth, - int maxFloodVolume, - int maxThroatLength, - int throatRadius, - int grottoHorizontalRadius, - int grottoVerticalRadius, - int dryHeadroom, - HydrologyCaveFluidPolicy existingFluidPolicy, - HydrologyCaveGrottoShape grottoShape - ) { - this( - maxHorizontalRadius, - maxDepth, - maxFloodVolume, - maxThroatLength, - throatRadius, - grottoHorizontalRadius, - grottoVerticalRadius, - dryHeadroom, - existingFluidPolicy, - grottoShape, - maxHorizontalRadius, - maxDepth - ); - } - - public HydrologyCavePlannerSettings( - int maxHorizontalRadius, - int maxDepth, - int maxFloodVolume, - int maxThroatLength, - int grottoHorizontalRadius, - int grottoVerticalRadius, - HydrologyCaveFluidPolicy existingFluidPolicy - ) { - this( - maxHorizontalRadius, - maxDepth, - maxFloodVolume, - maxThroatLength, - 1, - grottoHorizontalRadius, - grottoVerticalRadius, - 0, - existingFluidPolicy, - HydrologyCaveGrottoShape.ELLIPSOID - ); - } - public HydrologyCavePlannerSettings { Objects.requireNonNull(existingFluidPolicy); - Objects.requireNonNull(grottoShape); if (maxHorizontalRadius < 1) { throw new IllegalArgumentException("maxHorizontalRadius must be positive"); } @@ -81,12 +26,6 @@ public record HydrologyCavePlannerSettings( if (maxFloodVolume < 1) { throw new IllegalArgumentException("maxFloodVolume must be positive"); } - if (maxThroatLength < 1) { - throw new IllegalArgumentException("maxThroatLength must be positive"); - } - if (throatRadius < 1) { - throw new IllegalArgumentException("throatRadius must be positive"); - } if (grottoHorizontalRadius < 1) { throw new IllegalArgumentException("grottoHorizontalRadius must be positive"); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java index e1a6b26c3..1ff641808 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -376,7 +376,7 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai private double naturalHeight(CacheStripe stripe, long packed, int blockX, int blockZ) { synchronized (stripe) { double cached = stripe.naturalHeights.getAndMoveToLast(packed); - if (!Double.isNaN(cached) || stripe.naturalHeights.containsKey(packed)) { + if (!Double.isNaN(cached)) { return cached; } TerrainBasis cachedBasis = stripe.bases.getAndMoveToLast(packed); @@ -392,7 +392,7 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai } synchronized (stripe) { double existing = stripe.naturalHeights.getAndMoveToLast(packed); - if (!Double.isNaN(existing) || stripe.naturalHeights.containsKey(packed)) { + if (!Double.isNaN(existing)) { return existing; } stripe.naturalHeights.putAndMoveToLast(packed, sampled); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java index 97d32935f..deea5519d 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java @@ -9,7 +9,8 @@ import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; import art.arcane.iris.generation.hydrology.RiverFootprint; import art.arcane.iris.generation.hydrology.IrisRiverBedProfile; import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle; -import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap; +import it.unimi.dsi.fastutil.doubles.DoubleArrayList; +import it.unimi.dsi.fastutil.ints.IntArrayList; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import it.unimi.dsi.fastutil.longs.LongArrayList; @@ -149,8 +150,9 @@ public final class ErosionFieldCompiler { boolean[] basin = prepared.basin(); double[] bendOffsets = prepared.bendOffsets(); double[][] blendWidth = prepared.blendWidth(); - Long2IntOpenHashMap nearest = geometry.nearest; - Long2DoubleOpenHashMap distance = geometry.distance; + Long2IntOpenHashMap indices = geometry.indices; + IntArrayList stations = geometry.stations; + DoubleArrayList distance = geometry.distance; if (!geometry.prepared) { for (int station = 0; station < count; station++) { int radius = prepared.radii()[station]; @@ -172,14 +174,22 @@ public final class ErosionFieldCompiler { continue; } long key = RiverFootprint.pack(cellX, cellZ); - int existing = nearest.get(key); + int index = indices.get(key); + int existing = index < 0 ? -1 : stations.getInt(index); + double existingDistance = index < 0 ? 0D : distance.getDouble(index); // A cell on a station's cross-section row ties between the two segments meeting // there; the later station owns it so the row uses the head its own ring bounded. if (existing < 0 - || cellDistance < distance.get(key) - EPSILON - || Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) { - nearest.put(key, station); - distance.put(key, cellDistance); + || cellDistance < existingDistance - EPSILON + || Math.abs(cellDistance - existingDistance) <= EPSILON && station > existing) { + if (index < 0) { + indices.put(key, stations.size()); + stations.add(station); + distance.add(cellDistance); + } else { + stations.set(index, station); + distance.set(index, cellDistance); + } } } } @@ -189,17 +199,18 @@ public final class ErosionFieldCompiler { int oceanStart = oceanStart(centerline, valley, terminal); int inletStart = prepared.inletStart(); Long2ObjectOpenHashMap columns = new Long2ObjectOpenHashMap<>(wetOnly - ? Math.min(nearest.size(), Math.max(16, count * 8)) : nearest.size()); + ? Math.min(indices.size(), Math.max(16, count * 8)) : indices.size()); LongArrayList wetKeys = new LongArrayList(); - LongIterator candidates = nearest.keySet().iterator(); - long[] coordinates = new long[Math.min(nearest.size(), HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE)]; + LongIterator candidates = indices.keySet().iterator(); + long[] coordinates = new long[Math.min(indices.size(), HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE)]; double[] outlines = new double[coordinates.length]; while (candidates.hasNext()) { int pageSize = 0; while (candidates.hasNext() && pageSize < coordinates.length) { long key = candidates.nextLong(); - int station = nearest.get(key); - double cellDistance = distance.get(key); + int geometryIndex = indices.get(key); + int station = stations.getInt(geometryIndex); + double cellDistance = distance.getDouble(geometryIndex); int cellX = RiverFootprint.unpackX(key); int cellZ = RiverFootprint.unpackZ(key); int head = valley.head()[station]; @@ -231,8 +242,9 @@ public final class ErosionFieldCompiler { HydrologyTerrainSample[] samples = sampler.sampleBatch(coordinates, pageSize); for (int index = 0; index < pageSize; index++) { long key = coordinates[index]; - int station = nearest.get(key); - double cellDistance = distance.get(key); + int geometryIndex = indices.get(key); + int station = stations.getInt(geometryIndex); + double cellDistance = distance.getDouble(geometryIndex); int cellX = RiverFootprint.unpackX(key); int cellZ = RiverFootprint.unpackZ(key); int head = valley.head()[station]; @@ -898,12 +910,13 @@ public final class ErosionFieldCompiler { } private static final class RasterGeometry { - private final Long2IntOpenHashMap nearest = new Long2IntOpenHashMap(); - private final Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap(); + private final Long2IntOpenHashMap indices = new Long2IntOpenHashMap(); + private final IntArrayList stations = new IntArrayList(); + private final DoubleArrayList distance = new DoubleArrayList(); private boolean prepared; private RasterGeometry() { - nearest.defaultReturnValue(-1); + indices.defaultReturnValue(-1); } } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java index 6b2afdf5a..bb94e272f 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleWriter.java @@ -22,36 +22,27 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.iris.world.storage.matter.PreObjectMatterCell; -import com.google.common.collect.ImmutableList; -import it.unimi.dsi.fastutil.longs.LongOpenHashSet; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.integration.Identifier; import art.arcane.iris.world.WorldMaintenance; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; -import art.arcane.iris.generation.runtime.GenerationFailures; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.terrain.Terrain3DColumn; import art.arcane.iris.world.history.TransitionGenerationPlan; -import art.arcane.iris.generation.noise.IrisGeneratorStyle; -import art.arcane.iris.pack.value.IrisPosition; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.generation.context.ChunkContext; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.subterrain.SubterrainCell; import art.arcane.iris.generation.subterrain.SubterrainRasterizer; -import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.documentation.ChunkCoordinates; -import art.arcane.volmlib.util.function.Function3; 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.math.RNG; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.IrisMatter; import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterSlice; -import art.arcane.volmlib.util.noise.CNG; import art.arcane.iris.world.task.J; import lombok.Data; import lombok.AccessLevel; @@ -61,9 +52,7 @@ import lombok.Setter; import lombok.ToString; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.block.B; -import org.bukkit.util.Vector; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.function.Supplier; import it.unimi.dsi.fastutil.longs.Long2ObjectMap; @@ -71,7 +60,6 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import java.util.Map; import java.util.List; import java.util.Objects; -import java.util.Set; import java.util.concurrent.atomic.AtomicReferenceArray; import static art.arcane.iris.generation.mantle.EngineMantle.AIR; @@ -215,122 +203,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } } - static Set getBallooned(Set vset, double radius) { - Set returnset = new HashSet<>(); - int ceilrad = (int) Math.ceil(radius); - double r2 = Math.pow(radius, 2); - - for (IrisPosition v : vset) { - int tipx = v.getX(); - int tipy = v.getY(); - int tipz = v.getZ(); - - for (int loopx = tipx - ceilrad; loopx <= tipx + ceilrad; loopx++) { - for (int loopy = tipy - ceilrad; loopy <= tipy + ceilrad; loopy++) { - for (int loopz = tipz - ceilrad; loopz <= tipz + ceilrad; loopz++) { - if (hypot(loopx - tipx, loopy - tipy, loopz - tipz) <= r2) { - returnset.add(new IrisPosition(loopx, loopy, loopz)); - } - } - } - } - } - return returnset; - } - - static Set getHollowed(Set vset) { - PositionLookup lookup = PositionLookup.of(vset); - Set returnset = new KSet<>(); - for (IrisPosition v : vset) { - int x = v.getX(); - int y = v.getY(); - int z = v.getZ(); - if (!(lookup.contains(x + 1, y, z) - && lookup.contains(x - 1, y, z) - && lookup.contains(x, y + 1, z) - && lookup.contains(x, y - 1, z) - && lookup.contains(x, y, z + 1) - && lookup.contains(x, y, z - 1))) { - returnset.add(v); - } - } - return returnset; - } - - private static double hypot(double x, double y, double z) { - return Math.pow(x, 2) + Math.pow(y, 2) + Math.pow(z, 2); - } - - record PositionLookup(LongOpenHashSet keys, Set positions, int originX, int originY, int originZ) { - private static final int AXIS_BITS = 21; - private static final long AXIS_LIMIT = 1L << AXIS_BITS; - - static PositionLookup of(Set positions) { - if (positions.isEmpty()) { - return new PositionLookup(new LongOpenHashSet(), positions, 0, 0, 0); - } - - int minX = Integer.MAX_VALUE; - int minY = Integer.MAX_VALUE; - int minZ = Integer.MAX_VALUE; - int maxX = Integer.MIN_VALUE; - int maxY = Integer.MIN_VALUE; - int maxZ = Integer.MIN_VALUE; - for (IrisPosition position : positions) { - minX = Math.min(minX, position.getX()); - minY = Math.min(minY, position.getY()); - minZ = Math.min(minZ, position.getZ()); - maxX = Math.max(maxX, position.getX()); - maxY = Math.max(maxY, position.getY()); - maxZ = Math.max(maxZ, position.getZ()); - } - - if ((long) maxX - minX >= AXIS_LIMIT - 2L - || (long) maxY - minY >= AXIS_LIMIT - 2L - || (long) maxZ - minZ >= AXIS_LIMIT - 2L) { - return new PositionLookup(null, positions, 0, 0, 0); - } - - LongOpenHashSet keys = new LongOpenHashSet(positions.size()); - for (IrisPosition position : positions) { - keys.add(key(position.getX() - minX, position.getY() - minY, position.getZ() - minZ)); - } - return new PositionLookup(keys, positions, minX, minY, minZ); - } - - boolean contains(int x, int y, int z) { - if (keys == null) { - return positions.contains(new IrisPosition(x, y, z)); - } - - long dx = (long) x - originX; - long dy = (long) y - originY; - long dz = (long) z - originZ; - if ((dx | dy | dz) < 0L || dx >= AXIS_LIMIT || dy >= AXIS_LIMIT || dz >= AXIS_LIMIT) { - return false; - } - return keys.contains(key(dx, dy, dz)); - } - - private static long key(long x, long y, long z) { - return (x << (AXIS_BITS * 2)) | (y << AXIS_BITS) | z; - } - } - - private static double lengthSq(double x, double y, double z) { - return (x * x) + (y * y) + (z * z); - } - - private static double lengthSq(double x, double z) { - return (x * x) + (z * z); - } - - public void setDataWarped(int x, int y, int z, T t, RNG rng, IrisData data, IrisGeneratorStyle style) { - setData((int) Math.round(style.warp(rng, data, x, x, y, -z)), - (int) Math.round(style.warp(rng, data, y, z, -x, y)), - (int) Math.round(style.warp(rng, data, z, -y, z, x)), t); - } - public ObjectPlacementCapture captureObjectPlacement(ObjectContinuationBundle.PlacementKey key) { if (objectPlacementCapture.get() != null) { throw new IllegalStateException("Object placement capture is already active"); @@ -376,6 +248,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return; } if (t instanceof NativeBlockState) { + clearTile(matter, x, y, z); clearDeferredPlacement(matter, x, y, z); } Class sliceType = t instanceof NativeBlockState ? NativeBlockState.class : matter.getClass(t); @@ -671,69 +544,21 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return carvedColumn; } - public boolean restorePrerequisiteData(int x, int y, int z, Class type) { + void restoreData(int x, int y, int z, Class type, Object value) { Objects.requireNonNull(type, "type"); - if (!allowsWrite(x, z) - || y < 0 - || y >= mantle.getWorldHeight() - || !isPreObjectType(type)) { - return false; - } - - MantleChunk chunk = acquireChunk(x >> 4, z >> 4); - int section = y >> 4; - if (chunk == null) { - return false; - } - - synchronized (chunk) { - if (!chunk.exists(section)) { - return false; - } - Matter matter = chunk.get(section); - PreObjectMatterCell cell = preObjectCell(matter, x, y, z); - if (cell == null || !cell.captures(type)) { - return false; - } - restoreRaw(matter, x, y, z, type, cell.original(type)); - recordObjectValue(x, y, z, type, cell.original(type)); - return true; - } - } - - public boolean restorePrerequisiteCell(int x, int y, int z) { if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) { - return false; + return; } - MantleChunk chunk = acquireChunk(x >> 4, z >> 4); - int section = y >> 4; if (chunk == null) { - return false; + throw new IllegalStateException("Object rollback exceeded its prepared Mantle radius at " + + x + "," + y + "," + z); } - synchronized (chunk) { - if (!chunk.exists(section)) { - return false; + Matter matter = chunk.get(y >> 4); + if (matter != null) { + restoreRaw(matter, x, y, z, type, value); } - Matter matter = chunk.get(section); - PreObjectMatterCell cell = preObjectCell(matter, x, y, z); - if (cell == null) { - return false; - } - if (cell.blockCaptured()) { - restoreRaw(matter, x, y, z, NativeBlockState.class, cell.block()); - recordObjectValue(x, y, z, NativeBlockState.class, cell.block()); - } - if (cell.stringCaptured()) { - restoreRaw(matter, x, y, z, String.class, cell.string()); - recordObjectValue(x, y, z, String.class, cell.string()); - } - if (cell.cavernCaptured()) { - restoreRaw(matter, x, y, z, MatterCavern.class, cell.cavern()); - recordObjectValue(x, y, z, MatterCavern.class, cell.cavern()); - } - return true; } } @@ -778,6 +603,13 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return hydrology != null && hydrology.protectsPlacement(); } + private static void clearTile(Matter matter, int x, int y, int z) { + MatterSlice slice = matter.getSlice(TileWrapper.class); + if (slice != null) { + slice.set(x & 15, y & 15, z & 15, null); + } + } + private static void clearDeferredPlacement(Matter matter, int x, int y, int z) { MatterSlice slice = matter.getSlice(Identifier.class); if (slice != null) { @@ -803,6 +635,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) { return; } + clearTile(matter, x, y, z); MatterSlice blockSlice = matter.slice(NativeBlockState.class); MatterSlice identifierSlice = matter.slice(Identifier.class); capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class); @@ -1102,479 +935,6 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return getEngineMantle().getEngine(); } - /** - * Set a sphere into the mantle - * - * @param cx the center x - * @param cy the center y - * @param cz the center z - * @param radius the radius of this sphere - * @param fill should it be filled? or just the outer shell? - * @param data the data to set - * @param the type of data to apply to the mantle - */ - public void setSphere(int cx, int cy, int cz, double radius, boolean fill, T data) { - setElipsoid(cx, cy, cz, radius, radius, radius, fill, data); - } - - public void setElipsoid(int cx, int cy, int cz, double rx, double ry, double rz, boolean fill, T data) { - setElipsoidFunction(cx, cy, cz, rx, ry, rz, fill, (a, b, c) -> data); - } - - public void setElipsoidWarped(int cx, int cy, int cz, double rx, double ry, double rz, boolean fill, T data, RNG rng, IrisData idata, IrisGeneratorStyle style) { - setElipsoidFunctionWarped(cx, cy, cz, rx, ry, rz, fill, (a, b, c) -> data, rng, idata, style); - } - - /** - * Set an elipsoid into the mantle - * - * @param cx the center x - * @param cy the center y - * @param cz the center z - * @param rx the x radius - * @param ry the y radius - * @param rz the z radius - * @param fill should it be filled or just the outer shell? - * @param data the data to set - * @param the type of data to apply to the mantle - */ - public void setElipsoidFunction(int cx, int cy, int cz, double rx, double ry, double rz, boolean fill, Function3 data) { - rx += 0.5; - ry += 0.5; - rz += 0.5; - final double invRadiusX = 1 / rx; - final double invRadiusY = 1 / ry; - final double invRadiusZ = 1 / rz; - final int ceilRadiusX = (int) Math.ceil(rx); - final int ceilRadiusY = (int) Math.ceil(ry); - final int ceilRadiusZ = (int) Math.ceil(rz); - double nextXn = 0; - - forX: - for (int x = 0; x <= ceilRadiusX; ++x) { - final double xn = nextXn; - nextXn = (x + 1) * invRadiusX; - double nextYn = 0; - forY: - for (int y = 0; y <= ceilRadiusY; ++y) { - final double yn = nextYn; - nextYn = (y + 1) * invRadiusY; - double nextZn = 0; - for (int z = 0; z <= ceilRadiusZ; ++z) { - final double zn = nextZn; - nextZn = (z + 1) * invRadiusZ; - - double distanceSq = lengthSq(xn, yn, zn); - if (distanceSq > 1) { - if (z == 0) { - if (y == 0) { - break forX; - } - break forY; - } - break; - } - - if (!fill) { - if (lengthSq(nextXn, yn, zn) <= 1 && lengthSq(xn, nextYn, zn) <= 1 && lengthSq(xn, yn, nextZn) <= 1) { - continue; - } - } - - setData(x + cx, y + cy, z + cz, data.apply(x + cx, y + cy, z + cz)); - setData(-x + cx, y + cy, z + cz, data.apply(-x + cx, y + cy, z + cz)); - setData(x + cx, -y + cy, z + cz, data.apply(x + cx, -y + cy, z + cz)); - setData(x + cx, y + cy, -z + cz, data.apply(x + cx, y + cy, -z + cz)); - setData(-x + cx, y + cy, -z + cz, data.apply(-x + cx, y + cy, -z + cz)); - setData(-x + cx, -y + cy, z + cz, data.apply(-x + cx, -y + cy, z + cz)); - setData(x + cx, -y + cy, -z + cz, data.apply(x + cx, -y + cy, -z + cz)); - setData(-x + cx, y + cy, -z + cz, data.apply(-x + cx, y + cy, -z + cz)); - setData(-x + cx, -y + cy, -z + cz, data.apply(-x + cx, -y + cy, -z + cz)); - } - } - } - } - - public void setElipsoidFunctionWarped(int cx, int cy, int cz, double rx, double ry, double rz, boolean fill, Function3 data, RNG rng, IrisData idata, IrisGeneratorStyle style) { - rx += 0.5; - ry += 0.5; - rz += 0.5; - final double invRadiusX = 1 / rx; - final double invRadiusY = 1 / ry; - final double invRadiusZ = 1 / rz; - final int ceilRadiusX = (int) Math.ceil(rx); - final int ceilRadiusY = (int) Math.ceil(ry); - final int ceilRadiusZ = (int) Math.ceil(rz); - double nextXn = 0; - - forX: - for (int x = 0; x <= ceilRadiusX; ++x) { - final double xn = nextXn; - nextXn = (x + 1) * invRadiusX; - double nextYn = 0; - forY: - for (int y = 0; y <= ceilRadiusY; ++y) { - final double yn = nextYn; - nextYn = (y + 1) * invRadiusY; - double nextZn = 0; - for (int z = 0; z <= ceilRadiusZ; ++z) { - final double zn = nextZn; - nextZn = (z + 1) * invRadiusZ; - - double distanceSq = lengthSq(xn, yn, zn); - if (distanceSq > 1) { - if (z == 0) { - if (y == 0) { - break forX; - } - break forY; - } - break; - } - - if (!fill) { - if (lengthSq(nextXn, yn, zn) <= 1 && lengthSq(xn, nextYn, zn) <= 1 && lengthSq(xn, yn, nextZn) <= 1) { - continue; - } - } - - setDataWarped(x + cx, y + cy, z + cz, data.apply(x + cx, y + cy, z + cz), rng, idata, style); - setDataWarped(-x + cx, y + cy, z + cz, data.apply(-x + cx, y + cy, z + cz), rng, idata, style); - setDataWarped(x + cx, -y + cy, z + cz, data.apply(x + cx, -y + cy, z + cz), rng, idata, style); - setDataWarped(x + cx, y + cy, -z + cz, data.apply(x + cx, y + cy, -z + cz), rng, idata, style); - setDataWarped(-x + cx, y + cy, -z + cz, data.apply(-x + cx, y + cy, -z + cz), rng, idata, style); - setDataWarped(-x + cx, -y + cy, z + cz, data.apply(-x + cx, -y + cy, z + cz), rng, idata, style); - setDataWarped(x + cx, -y + cy, -z + cz, data.apply(x + cx, -y + cy, -z + cz), rng, idata, style); - setDataWarped(-x + cx, y + cy, -z + cz, data.apply(-x + cx, y + cy, -z + cz), rng, idata, style); - setDataWarped(-x + cx, -y + cy, -z + cz, data.apply(-x + cx, -y + cy, -z + cz), rng, idata, style); - } - } - } - } - - /** - * Set a cuboid of data in the mantle - * - * @param x1 the min x - * @param y1 the min y - * @param z1 the min z - * @param x2 the max x - * @param y2 the max y - * @param z2 the max z - * @param data the data to set - * @param the type of data to apply to the mantle - */ - public void setCuboid(int x1, int y1, int z1, int x2, int y2, int z2, T data) { - int j, k; - - for (int i = x1; i <= x2; i++) { - for (j = x1; j <= x2; j++) { - for (k = x1; k <= x2; k++) { - setData(i, j, k, data); - } - } - } - } - - /** - * Set a pyramid of data in the mantle - * - * @param cx the center x - * @param cy the base y - * @param cz the center z - * @param data the data to set - * @param size the size of the pyramid (width of base & height) - * @param filled should it be filled or hollow - * @param the type of data to apply to the mantle - */ - @SuppressWarnings("ConstantConditions") - public void setPyramid(int cx, int cy, int cz, T data, int size, boolean filled) { - int height = size; - - for (int y = 0; y <= height; ++y) { - size--; - for (int x = 0; x <= size; ++x) { - for (int z = 0; z <= size; ++z) { - if ((filled && z <= size && x <= size) || z == size || x == size) { - setData(x + cx, y + cy, z + cz, data); - setData(-x + cx, y + cy, z + cz, data); - setData(x + cx, y + cy, -z + cz, data); - setData(-x + cx, y + cy, -z + cz, data); - } - } - } - } - } - - /** - * Set a 3d line - * - * @param a the first point - * @param b the second point - * @param radius the radius - * @param filled hollow or filled? - * @param data the data - * @param the type of data to apply to the mantle - */ - public void setLine(IrisPosition a, IrisPosition b, double radius, boolean filled, T data) { - setLine(ImmutableList.of(a, b), radius, filled, data); - } - - public void setLine(List vectors, double radius, boolean filled, T data) { - setLineConsumer(vectors, radius, filled, (_x, _y, _z) -> data); - } - - /** - * Set lines for points - * - * @param vectors the points - * @param radius the radius - * @param filled hollow or filled? - * @param data the data to set - * @param the type of data to apply to the mantle - */ - public void setLineConsumer(List vectors, double radius, boolean filled, Function3 data) { - Set vset = cleanup(vectors); - vset = getBallooned(vset, radius); - - if (!filled) { - vset = getHollowed(vset); - } - - setConsumer(vset, data); - } - - /** - * Set lines for points - * - * @param vectors the points - * @param radius the radius - * @param filled hollow or filled? - * @param data the data to set - * @param the type of data to apply to the mantle - */ - public void setNoiseMasked(List vectors, double radius, double threshold, CNG shape, Set masks, boolean filled, Function3 data) { - Set vset = cleanup(vectors); - vset = masks == null ? getBallooned(vset, radius) : getMasked(vset, masks, radius); - vset.removeIf(p -> shape.noise(p.getX(), p.getY(), p.getZ()) < threshold); - - if (!filled) { - vset = getHollowed(vset); - } - - setConsumer(vset, data); - } - - static Set getMasked(Set vectors, Set masks, double radius) { - Set vset = new KSet<>(); - int ceil = (int) Math.ceil(radius); - double r2 = Math.pow(radius, 2); - PositionLookup maskLookup = PositionLookup.of(masks); - - for (IrisPosition v : vectors) { - int tipX = v.getX(); - int tipY = v.getY(); - int tipZ = v.getZ(); - - for (int x = -ceil; x <= ceil; x++) { - for (int y = -ceil; y <= ceil; y++) { - for (int z = -ceil; z <= ceil; z++) { - if (hypot(x, y, z) > r2 || !maskLookup.contains(x, y, z)) - continue; - vset.add(new IrisPosition(tipX + x, tipY + y, tipZ + z)); - } - } - } - } - - return vset; - } - - private static Set cleanup(List vectors) { - Set vset = new KSet<>(); - - for (int i = 0; vectors.size() != 0 && i < vectors.size() - 1; i++) { - IrisPosition pos1 = vectors.get(i); - IrisPosition pos2 = vectors.get(i + 1); - int x1 = pos1.getX(); - int y1 = pos1.getY(); - int z1 = pos1.getZ(); - int x2 = pos2.getX(); - int y2 = pos2.getY(); - int z2 = pos2.getZ(); - int tipx = x1; - int tipy = y1; - int tipz = z1; - int dx = Math.abs(x2 - x1); - int dy = Math.abs(y2 - y1); - int dz = Math.abs(z2 - z1); - - if (dx + dy + dz == 0) { - vset.add(new IrisPosition(tipx, tipy, tipz)); - continue; - } - - int dMax = Math.max(Math.max(dx, dy), dz); - if (dMax == dx) { - for (int domstep = 0; domstep <= dx; domstep++) { - tipx = x1 + domstep * (x2 - x1 > 0 ? 1 : -1); - tipy = (int) Math.round(y1 + domstep * ((double) dy) / ((double) dx) * (y2 - y1 > 0 ? 1 : -1)); - tipz = (int) Math.round(z1 + domstep * ((double) dz) / ((double) dx) * (z2 - z1 > 0 ? 1 : -1)); - - vset.add(new IrisPosition(tipx, tipy, tipz)); - } - } else if (dMax == dy) { - for (int domstep = 0; domstep <= dy; domstep++) { - tipy = y1 + domstep * (y2 - y1 > 0 ? 1 : -1); - tipx = (int) Math.round(x1 + domstep * ((double) dx) / ((double) dy) * (x2 - x1 > 0 ? 1 : -1)); - tipz = (int) Math.round(z1 + domstep * ((double) dz) / ((double) dy) * (z2 - z1 > 0 ? 1 : -1)); - - vset.add(new IrisPosition(tipx, tipy, tipz)); - } - } else /* if (dMax == dz) */ { - for (int domstep = 0; domstep <= dz; domstep++) { - tipz = z1 + domstep * (z2 - z1 > 0 ? 1 : -1); - tipy = (int) Math.round(y1 + domstep * ((double) dy) / ((double) dz) * (y2 - y1 > 0 ? 1 : -1)); - tipx = (int) Math.round(x1 + domstep * ((double) dx) / ((double) dz) * (x2 - x1 > 0 ? 1 : -1)); - - vset.add(new IrisPosition(tipx, tipy, tipz)); - } - } - } - - return vset; - } - - /** - * Set a cylinder in the mantle - * - * @param cx the center x - * @param cy the base y - * @param cz the center z - * @param data the data to set - * @param radius the radius - * @param height the height of the cyl - * @param filled filled or not - */ - public void setCylinder(int cx, int cy, int cz, T data, double radius, int height, boolean filled) { - setCylinder(cx, cy, cz, data, radius, radius, height, filled); - } - - /** - * Set a cylinder in the mantle - * - * @param cx the center x - * @param cy the base y - * @param cz the center z - * @param data the data to set - * @param radiusX the x radius - * @param radiusZ the z radius - * @param height the height of this cyl - * @param filled filled or hollow? - */ - public void setCylinder(int cx, int cy, int cz, T data, double radiusX, double radiusZ, int height, boolean filled) { - int affected = 0; - radiusX += 0.5; - radiusZ += 0.5; - - if (height == 0) { - return; - } else if (height < 0) { - height = -height; - cy = cy - height; - } - - if (cy < 0) { - cy = 0; - } else if (cy + height - 1 > getMantle().getWorldHeight()) { - height = getMantle().getWorldHeight() - cy + 1; - } - - final double invRadiusX = 1 / radiusX; - final double invRadiusZ = 1 / radiusZ; - final int ceilRadiusX = (int) Math.ceil(radiusX); - final int ceilRadiusZ = (int) Math.ceil(radiusZ); - double nextXn = 0; - - forX: - for (int x = 0; x <= ceilRadiusX; ++x) { - final double xn = nextXn; - nextXn = (x + 1) * invRadiusX; - double nextZn = 0; - for (int z = 0; z <= ceilRadiusZ; ++z) { - final double zn = nextZn; - nextZn = (z + 1) * invRadiusZ; - double distanceSq = lengthSq(xn, zn); - - if (distanceSq > 1) { - if (z == 0) { - break forX; - } - - break; - } - - if (!filled) { - if (lengthSq(nextXn, zn) <= 1 && lengthSq(xn, nextZn) <= 1) { - continue; - } - } - - for (int y = 0; y < height; ++y) { - setData(cx + x, cy + y, cz + z, data); - setData(cx + -x, cy + y, cz + z, data); - setData(cx + x, cy + y, cz + -z, data); - setData(cx + -x, cy + y, cz + -z, data); - } - } - } - } - - public void set(IrisPosition pos, T data) { - try { - setData(pos.getX(), pos.getY(), pos.getZ(), data); - } catch (Throwable e) { - GenerationFailures.rethrowEngineFailure(e); - // Reached per position while an object writes past the edge of the writer window, which is - // how a large object is clipped. Nothing here is actionable from outside the engine. - IrisLogging.debug("No set? " + data.toString() + " for " + pos.toString()); - } - } - - public void set(List positions, T data) { - for (IrisPosition i : positions) { - set(i, data); - } - } - - public void set(Set positions, T data) { - for (IrisPosition i : positions) { - set(i, data); - } - } - - public void setConsumer(Set positions, Function3 data) { - for (IrisPosition i : positions) { - set(i, data.apply(i.getX(), i.getY(), i.getZ())); - } - } - - public boolean isWithin(Vector pos) { - return isWithin(pos.getBlockX(), pos.getBlockY(), pos.getBlockZ()); - } - - public boolean isWithin(int x, int y, int z) { - int cx = x >> 4; - int cz = z >> 4; - - if (y < 0 || y >= mantle.getWorldHeight()) { - return false; - } - - return cx >= this.x - radius && cx <= this.x + radius - && cz >= this.z - radius && cz <= this.z + radius; - } - @Override public void close() { for (int index = 0; index < window.length(); index++) { @@ -1689,6 +1049,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { maximumX = Math.max(maximumX, x); maximumZ = Math.max(maximumZ, z); if (type == NativeBlockState.class) { + MatterSlice tiles = fragment.getSlice(TileWrapper.class); + if (tiles != null) { + tiles.set(x & 15, y, z & 15, null); + } fragment.slice(Identifier.class).set(x & 15, y, z & 15, null); fragment.slice(type).set(x & 15, y, z & 15, value == null ? AIR.get() : value); } else { diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java index cff0b068b..680e06d05 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectContinuationPersistence.java @@ -8,6 +8,7 @@ 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.MatterSlice; import java.util.List; import java.util.Map; @@ -51,6 +52,10 @@ public final class ObjectContinuationPersistence { public static void put(Matter payload, int x, int y, int z, Object value) { if (value instanceof NativeBlockState state) { + MatterSlice tiles = payload.getSlice(TileWrapper.class); + if (tiles != null) { + tiles.set(x, y, z, null); + } String customKey = state.deferredPlacementKey(); NativeBlockState base = state.placementBaseState(); boolean custom = state.isCustom() && customKey != null && base != null; diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java index 8fa049015..a6970a7de 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java @@ -99,12 +99,19 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } captureOriginal(originals, mutation.key()); if (mutation.key().type() == NativeBlockState.class) { - captureOriginal(originals, new DataKey( - mutation.key().x(), - mutation.key().y(), - mutation.key().z(), - Identifier.class - )); + TileWrapper tile = writer.getDataIfPresent( + mutation.key().x(), mutation.key().y(), mutation.key().z(), TileWrapper.class); + if (tile != null) { + originals.putIfAbsent(new DataKey( + mutation.key().x(), mutation.key().y(), mutation.key().z(), TileWrapper.class), tile); + } + Identifier identifier = writer.getDataIfPresent( + mutation.key().x(), mutation.key().y(), mutation.key().z(), Identifier.class); + if (identifier != null || mutation instanceof CustomBlockMutation) { + originals.putIfAbsent(new DataKey( + mutation.key().x(), mutation.key().y(), mutation.key().z(), Identifier.class), + identifier == null ? CLEARED : identifier); + } } mutation.apply(writer); } @@ -148,6 +155,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { Identifier identifier = Identifier.fromString(placementKey); OverlayCell cell = writableCell(x, y, z); cell.block = baseState; + cell.tile = CLEARED; cell.identifier = identifier; mutations.add(replayed != null ? replayed : new CustomBlockMutation(new DataKey(x, y, z, NativeBlockState.class), state)); @@ -243,6 +251,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { OverlayCell cell = writableCell(x, y, z); if (data instanceof NativeBlockState) { cell.block = data; + cell.tile = CLEARED; cell.identifier = CLEARED; } else { cell.put(type, data); @@ -325,15 +334,15 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { return cell; } - private Object prerequisiteOrCleared(int x, int y, int z, Class type) { - Object value = writer.getPrerequisiteDataIfPresent(x, y, z, type); + private Object publishedOrCleared(int x, int y, int z, Class type) { + Object value = writer.getDataIfPresent(x, y, z, type); return value == null ? CLEARED : value; } private void captureOriginal(LinkedHashMap originals, DataKey key) { originals.computeIfAbsent( key, - candidate -> prerequisiteOrCleared( + candidate -> publishedOrCleared( candidate.x(), candidate.y(), candidate.z(), @@ -383,27 +392,16 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { private void rollback(LinkedHashMap originals, Throwable failure) { ArrayList> entries = new ArrayList<>(originals.entrySet()); - entries.sort((first, second) -> Boolean.compare( - second.getKey().type() == NativeBlockState.class, - first.getKey().type() == NativeBlockState.class - )); - for (Map.Entry entry : entries) { + for (int index = entries.size() - 1; index >= 0; index--) { + Map.Entry entry = entries.get(index); DataKey key = entry.getKey(); try { - if (key.type() == NativeBlockState.class - && writer.restorePrerequisiteCell(key.x(), key.y(), key.z())) { - continue; - } - if (writer.restorePrerequisiteData(key.x(), key.y(), key.z(), key.type())) { - continue; - } - writer.clearData(key.x(), key.y(), key.z(), key.type()); - Object original = entry.getValue(); - if (original != CLEARED) { - writer.setData(key.x(), key.y(), key.z(), original); - } + writer.restoreData(key.x(), key.y(), key.z(), key.type(), + entry.getValue() == CLEARED ? null : entry.getValue()); } catch (Throwable rollbackFailure) { - failure.addSuppressed(rollbackFailure); + if (rollbackFailure != failure) { + failure.addSuppressed(rollbackFailure); + } } } } @@ -413,6 +411,7 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { private Object hydrology; private Object cavern; private Object identifier; + private Object tile; private Object string; private Object treeMaterial; private Map, Object> other; @@ -430,6 +429,9 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { if (type == Identifier.class) { return identifier; } + if (type == TileWrapper.class) { + return tile; + } if (type == String.class) { return string; } @@ -446,6 +448,8 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { cavern = value; } else if (type == Identifier.class) { identifier = value; + } else if (type == TileWrapper.class) { + tile = value; } else if (type == String.class) { string = value; } else if (type == TreeBlockMaterial.class) { diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/StructureFoundationPlanner.java b/core/src/main/java/art/arcane/iris/generation/mantle/StructureFoundationPlanner.java index 765c095bc..c4a346778 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/StructureFoundationPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/StructureFoundationPlanner.java @@ -19,9 +19,7 @@ final class StructureFoundationPlanner { return; } long columnKey = pack(x, z); - if (!columns.containsKey(columnKey) || y < columns.get(columnKey)) { - columns.put(columnKey, y); - } + columns.mergeInt(columnKey, y, Math::min); } static int findGroundY(int foundationY, int maxDepth, int minimumY, IntPredicate solidAtY) { diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/StructurePieceColumns.java b/core/src/main/java/art/arcane/iris/generation/mantle/StructurePieceColumns.java index 09453617e..a435b51e2 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/StructurePieceColumns.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/StructurePieceColumns.java @@ -4,6 +4,8 @@ import art.arcane.iris.structure.placement.PlacedStructurePiece; import art.arcane.iris.structure.object.IrisObject; import art.arcane.iris.structure.object.IrisObjectRotation; import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.block.VectorMap; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.geometry.IrisBlockVector; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.structure.StructureCarvingFootprint; @@ -36,11 +38,12 @@ final class StructurePieceColumns { if (object == null || rotation == null || object.getBlocks() == null) { continue; } - for (IrisBlockVector local : object.getBlocks().keys()) { - if (local == null || B.isAir(object.getBlocks().get(local))) { + VectorMap.Cursor cursor = object.getBlocks().cursor(); + while (cursor.next()) { + if (B.isAir(cursor.value())) { continue; } - IrisBlockVector rotated = rotation.rotate(local.clone()); + IrisBlockVector rotated = rotation.rotate(cursor.key()); long worldX = (long) piece.getX() + rotated.getBlockX(); long worldY = (long) piece.getY() + rotated.getBlockY(); long worldZ = (long) piece.getZ() + rotated.getBlockZ(); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/TerrainMatterView.java b/core/src/main/java/art/arcane/iris/generation/mantle/TerrainMatterView.java index f77f9c009..adc54a791 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/TerrainMatterView.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/TerrainMatterView.java @@ -22,13 +22,15 @@ public final class TerrainMatterView { } public static T get(Mantle mantle, int x, int y, int z, Class type) { - if (!hasJournal(type)) { - return mantle.get(x, y, z, type); + Objects.requireNonNull(type, "Terrain matter type"); + if (y < 0 || y >= mantle.getWorldHeight()) { + return null; } - MantleChunk chunk = mantle.getChunk(x >> 4, z >> 4); - synchronized (chunk) { - PreObjectMatterCell cell = mantle.get(x, y, z, PreObjectMatterCell.class); - return cell != null && cell.captures(type) ? cell.original(type) : mantle.get(x, y, z, type); + MantleChunk chunk = mantle.useChunk(x >> 4, z >> 4); + try { + return get(chunk, x, y, z, type); + } finally { + chunk.release(); } } diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisExpression.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisExpression.java index 8830d1f46..eb96d30f0 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisExpression.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisExpression.java @@ -49,6 +49,9 @@ import lombok.experimental.Accessors; @Data @EqualsAndHashCode(callSuper = false) public class IrisExpression extends IrisRegistrant { + private static final int MAX_RETAINED_EVALUATION_DEPTH = 8; + private static final int MAX_RETAINED_ARGUMENTS = 256; + @ArrayType(type = IrisExpressionLoad.class, min = 1) @Description("Variables to use in this expression") private KList variables = new KList<>(); @@ -66,6 +69,10 @@ public class IrisExpression extends IrisRegistrant { @Setter(AccessLevel.NONE) private final transient LazyBoundedCache> streams = new LazyBoundedCache<>(8); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient ThreadLocal evaluationScratch = ThreadLocal.withInitial(EvaluationScratch::new); + private Expression expression() { return expressionCache.aquire(() -> { Scope scope = new Scope(); // Create variable scope. This scope can hold both constants and invocation variables. @@ -108,31 +115,37 @@ public class IrisExpression extends IrisRegistrant { } public double evaluate(RNG rng, double x, double z) { - double[] g = new double[3 + getVariables().size()]; - int m = 0; - for (IrisExpressionLoad i : getVariables()) { - g[m++] = i.getValue(rng, getLoader(), x, z); + EvaluationScratch scratch = evaluationScratch.get(); + double[] arguments = scratch.acquire(3 + getVariables().size()); + try { + int index = 0; + for (IrisExpressionLoad variable : getVariables()) { + arguments[index++] = variable.getValue(rng, getLoader(), x, z); + } + arguments[index++] = x; + arguments[index++] = z; + arguments[index] = -1; + return expression().evaluate(new FunctionContext(rng), arguments); + } finally { + scratch.release(); } - - g[m++] = x; - g[m++] = z; - g[m] = -1; - - return expression().evaluate(new FunctionContext(rng), g); } public double evaluate(RNG rng, double x, double y, double z) { - double[] g = new double[3 + getVariables().size()]; - int m = 0; - for (IrisExpressionLoad i : getVariables()) { - g[m++] = i.getValue(rng, getLoader(), x, y, z); + EvaluationScratch scratch = evaluationScratch.get(); + double[] arguments = scratch.acquire(3 + getVariables().size()); + try { + int index = 0; + for (IrisExpressionLoad variable : getVariables()) { + arguments[index++] = variable.getValue(rng, getLoader(), x, y, z); + } + arguments[index++] = x; + arguments[index++] = y; + arguments[index] = z; + return expression().evaluate(new FunctionContext(rng), arguments); + } finally { + scratch.release(); } - - g[m++] = x; - g[m++] = y; - g[m] = z; - - return expression().evaluate(new FunctionContext(rng), g); } @Override @@ -144,4 +157,28 @@ public class IrisExpression extends IrisRegistrant { public String getTypeName() { return "Expression"; } + + private static final class EvaluationScratch { + private final double[][] retained = new double[MAX_RETAINED_EVALUATION_DEPTH][]; + private int depth; + + private double[] acquire(int length) { + double[] arguments; + if (depth >= retained.length || length > MAX_RETAINED_ARGUMENTS) { + arguments = new double[length]; + } else { + arguments = retained[depth]; + if (arguments == null || arguments.length != length) { + arguments = new double[length]; + retained[depth] = arguments; + } + } + depth++; + return arguments; + } + + private void release() { + depth--; + } + } } diff --git a/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java b/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java index 8f5ea4261..317924b3a 100644 --- a/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java +++ b/core/src/main/java/art/arcane/iris/generation/noise/IrisGeneratorStyle.java @@ -258,7 +258,7 @@ public class IrisGeneratorStyle { @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean isFlat() { - return style == null || style.equals(NoiseStyle.FLAT); + return expression == null && imageMap == null && (style == null || style.equals(NoiseStyle.FLAT)); } public double getMaxFractureDistance() { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java b/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java index 9cd82c8cc..3d4490d30 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/DimensionTerrainContext.java @@ -210,8 +210,8 @@ public final class DimensionTerrainContext implements DataProvider { if (key == null || key.isBlank()) { continue; } - maximum += biome.getGenLinkMax(key, engine); - minimum += biome.getGenLinkMin(key, engine); + maximum += biome.getGenLinkMax(key); + minimum += biome.getGenLinkMin(key); } interpolatorBounds.put(biome, new NoiseBounds(minimum, maximum)); } @@ -329,8 +329,8 @@ public final class DimensionTerrainContext implements DataProvider { if (key == null || key.isBlank()) { continue; } - maximum += sampledBiome.getGenLinkMax(key, engine); - minimum += sampledBiome.getGenLinkMin(key, engine); + maximum += sampledBiome.getGenLinkMax(key); + minimum += sampledBiome.getGenLinkMin(key); } return new NoiseBounds(minimum, maximum); } catch (Throwable e) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java index c616a23af..8f7217046 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/GenerationCacheWarmer.java @@ -32,7 +32,14 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.RNG; +import java.util.ArrayList; import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; public final class GenerationCacheWarmer { private GenerationCacheWarmer() { @@ -43,6 +50,7 @@ public final class GenerationCacheWarmer { IrisData data = engine.getData(); RNG root = new RNG(engine.getSeedManager().getComponent() + 7777L); int[] counter = {0}; + List proceduralTasks = new ArrayList<>(); KList biomes = engine.getAllBiomes(); biomes.sort(Comparator.comparing(IrisBiome::getLoadKey)); @@ -50,7 +58,7 @@ public final class GenerationCacheWarmer { warmPlacements(biome.getObjects(), root, counter, data, engine); warmDecorators(biome.getDecorators(), engine.getSeedManager().getComponent(), counter, data); warmOres(biome.getOres(), engine.getSeedManager().getTerrain(), counter, data); - warmProcedural(biome.getProceduralObjects(), root, counter, data); + collectProcedural(biome.getProceduralObjects(), root, counter, proceduralTasks); } KList regions = engine.getDimension().getAllRegions(engine); @@ -58,10 +66,11 @@ public final class GenerationCacheWarmer { for (IrisRegion region : regions) { warmPlacements(region.getObjects(), root, counter, data, engine); warmOres(region.getOres(), engine.getSeedManager().getTerrain(), counter, data); - warmProcedural(region.getProceduralObjects(), root, counter, data); + collectProcedural(region.getProceduralObjects(), root, counter, proceduralTasks); } warmOres(engine.getDimension().getOres(), engine.getSeedManager().getTerrain(), counter, data); + warmProcedural(proceduralTasks, data); IrisLogging.debug("[IrisEngine timing] cache warm " + counter[0] + " configs=" + (M.ms() - start) + "ms"); } @@ -110,15 +119,93 @@ public final class GenerationCacheWarmer { } } - private static void warmProcedural(IrisProceduralObjects procedural, RNG root, int[] counter, IrisData data) { + private static void collectProcedural(IrisProceduralObjects procedural, RNG root, int[] counter, + List tasks) { if (procedural == null) { return; } for (IrisProceduralPlacement placement : procedural.getAllPlacements()) { - if (placement == null) { - continue; + if (placement != null) { + tasks.add(new ProceduralWarmTask(placement, root.nextParallelRNG(counter[0]++))); } - placement.getVariantObject(data, root.nextParallelRNG(counter[0]++), null); } } + + static void warmProcedural(List tasks, IrisData data) { + if (tasks.isEmpty()) { + return; + } + IdentityHashMap> identities = new IdentityHashMap<>(); + List> groups = new ArrayList<>(); + for (ProceduralWarmTask task : tasks) { + List group = identities.get(task.placement()); + if (group == null) { + group = new ArrayList<>(); + identities.put(task.placement(), group); + groups.add(group); + } + group.add(task); + } + int workers = Math.min(groups.size(), Math.min(4, Runtime.getRuntime().availableProcessors())); + if (workers == 1) { + for (ProceduralWarmTask task : tasks) { + task.placement().getVariantObject(data, task.rng(), null); + } + return; + } + Throwable failure = null; + boolean interrupted = false; + try (ExecutorService executor = Executors.newFixedThreadPool(workers, runnable -> { + Thread thread = new Thread(runnable, "Iris-Procedural-Warm"); + thread.setDaemon(true); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + })) { + List> pending = new ArrayList<>(groups.size()); + for (List group : groups) { + pending.add(executor.submit(() -> warmGroup(group, data))); + } + for (Future future : pending) { + boolean complete = false; + while (!complete) { + try { + future.get(); + complete = true; + } catch (InterruptedException exception) { + interrupted = true; + } catch (ExecutionException exception) { + Throwable cause = exception.getCause(); + if (failure == null) { + failure = cause; + } else if (failure != cause) { + failure.addSuppressed(cause); + } + complete = true; + } + } + } + } finally { + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof RuntimeException exception) { + throw exception; + } + if (failure != null) { + throw new IllegalStateException("Procedural generation cache warming failed.", failure); + } + } + + private static void warmGroup(List group, IrisData data) { + for (ProceduralWarmTask task : group) { + task.placement().getVariantObject(data, task.rng(), null); + } + } + + record ProceduralWarmTask(IrisProceduralPlacement placement, RNG rng) { + } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index e87cc0092..74deeedc8 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -1955,8 +1955,8 @@ public class IrisComplex implements DataProvider { continue; } - max += biome.getGenLinkMax(key, engine); - min += biome.getGenLinkMin(key, engine); + max += biome.getGenLinkMax(key); + min += biome.getGenLinkMin(key); } return new GeneratorBounds(min, max); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/MeteredCache.java b/core/src/main/java/art/arcane/iris/generation/runtime/MeteredCache.java index 15936fb22..afdf6c1c1 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/MeteredCache.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/MeteredCache.java @@ -18,13 +18,9 @@ package art.arcane.iris.generation.runtime; -import art.arcane.volmlib.util.data.KCache; - public interface MeteredCache { long getSize(); - KCache getRawCache(); - long getMaxSize(); default double getUsage() { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/PreservationSVC.java b/core/src/main/java/art/arcane/iris/generation/runtime/PreservationSVC.java index 5e61a8f6f..261e0d5fc 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/PreservationSVC.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/PreservationSVC.java @@ -20,7 +20,6 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.pack.loading.IrisData; -import art.arcane.volmlib.util.data.KCache; import art.arcane.volmlib.util.format.Form; import art.arcane.iris.platform.bukkit.plugin.IrisService; import art.arcane.volmlib.util.scheduling.Looper; @@ -33,7 +32,6 @@ import java.util.Objects; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; import java.util.stream.Stream; public class PreservationSVC implements IrisService, PreservationRegistry { @@ -188,10 +186,6 @@ public class PreservationSVC implements IrisService, PreservationRegistry { caches.add(new WeakReference<>(cache)); } - public List> caches() { - return cacheStream().map(MeteredCache::getRawCache).collect(Collectors.toList()); - } - @Unmodifiable public List getCaches() { return cacheStream().toList(); diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisDimensionStackActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisDimensionStackActuator.java index ca5e95543..8a71c7f36 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisDimensionStackActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisDimensionStackActuator.java @@ -192,7 +192,8 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator surfaceBlocks = biome == null || layer.terrainColumn() != null ? null @@ -207,6 +208,8 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator ceilingBlocks = null; + int terrainSpan = layer.terrainColumn() == null ? -1 : layer.terrainColumn().spanCount() - 1; KList seaBlocks = biome == null || fluidDepth == 0 ? null : biome.generateSeaLayers(worldX, worldZ, rng, fluidDepth, data); @@ -239,13 +242,32 @@ public final class IrisDimensionStackActuator extends EngineAssignedActuator= 0) { + while (terrainSpan >= 0 && sourceY < layer.terrainColumn().ceiling(terrainSpan)) { + terrainSpan--; + } + int ceilingY = layer.terrainColumn().ceiling(terrainSpan); + int ceilingDepth = sourceY - ceilingY; + if (terrainSpan > 0 && depth >= 2 && ceilingDepth < 2) { + if (ceilingBlocks == null && biome != null) { + ceilingBlocks = biome.generateCeilingLayers(dimension, worldX, worldZ, rng, + 2, ceilingY, data, null); + } + writeBlock(output, metadata, localX, y, localZ, + ceilingBlocks != null && ceilingBlocks.hasIndex(ceilingDepth) + ? ceilingBlocks.get(ceilingDepth) : layer.rockBlock()); + continue; + } + } if (surfaceBlocks == null && biome != null) { surfaceBlocks = biome.generateLayersWithSlope(dimension, worldX, worldZ, rng, - sourceSurfaceY, sourceSurfaceY, data, terrainContext.getSurfaceSlopeStream(sourceSurfaceY)); + sourceSurfaceY + 1 - bedrockDepth, sourceSurfaceY, data, + terrainContext.getSurfaceSlopeStream(sourceSurfaceY)); } writeBlock(output, metadata, localX, y, localZ, surfaceBlocks != null && surfaceBlocks.hasIndex(depth) diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java index 1020a9c91..99d23c2ee 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisFloatingChildBiomeModifier.java @@ -178,15 +178,24 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier topBlocks = target == null ? null : target.generateLayers(dimension, wx, wz, layerRng, paletteDepth, paletteDepth, data, complex); + boolean physicalDepth = usesPhysicalDepth(target, entry); + int topPaletteDepth = physicalDepth ? Math.max(paletteDepth, surfaceY - baseY + 1) : paletteDepth; + KList topBlocks = target == null ? null : target.generateLayers(dimension, wx, wz, layerRng, topPaletteDepth, surfaceY, data, complex); if (topBlocks == null || topBlocks.isEmpty()) { - topBlocks = parent.generateLayers(dimension, wx, wz, layerRng, paletteDepth, paletteDepth, data, complex); + physicalDepth = usesPhysicalDepth(parent, entry); + topPaletteDepth = physicalDepth ? Math.max(paletteDepth, surfaceY - baseY + 1) : paletteDepth; + topBlocks = parent.generateLayers(dimension, wx, wz, layerRng, topPaletteDepth, surfaceY, data, complex); } KList bottomBlocks = generateBottomPaletteLayers(entry, dimension, wx, wz, layerRng, paletteDepth, data, complex); - return new PaletteContext(topBlocks, bottomBlocks, B.getState("minecraft:stone")); + return new PaletteContext(topBlocks, bottomBlocks, B.getState("minecraft:stone"), surfaceY, physicalDepth); + } + + private static boolean usesPhysicalDepth(IrisBiome biome, IrisFloatingChildBiomes entry) { + return biome != null && biome.isLockLayers() && (entry == null || entry.getBottomPaletteMode() == null + || entry.getBottomPaletteMode() == FloatingBottomPaletteMode.DEPTH); } static RNG paletteRng(RNG worldRng, IrisFloatingChildBiomes entry) { @@ -198,11 +207,15 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier topBlocks; private final KList bottomBlocks; private final NativeBlockState fallbackSolid; + private final int surfaceY; + private final boolean physicalDepth; - private PaletteContext(KList topBlocks, KList bottomBlocks, NativeBlockState fallbackSolid) { + private PaletteContext(KList topBlocks, KList bottomBlocks, NativeBlockState fallbackSolid, int surfaceY, boolean physicalDepth) { this.topBlocks = topBlocks; this.bottomBlocks = bottomBlocks; this.fallbackSolid = fallbackSolid; + this.surfaceY = surfaceY; + this.physicalDepth = physicalDepth; } } @@ -251,12 +264,12 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier 0 ? layerCeilingY + 2 : 0); @@ -436,7 +436,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator implements ProceduralStream, MeteredCache, ChunkFillableStream2D, ChunkFillableDoubleStream2D { private final ProceduralStream stream; @@ -56,11 +55,6 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu return cache.getSize(); } - @Override - public KCache getRawCache() { - return null; - } - @Override public long getMaxSize() { return cache.getMaxSize(); diff --git a/core/src/main/java/art/arcane/iris/generation/stream/CachedStream2D.java b/core/src/main/java/art/arcane/iris/generation/stream/CachedStream2D.java index 36a6a66cb..40aac2964 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/CachedStream2D.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/CachedStream2D.java @@ -24,7 +24,6 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.MeteredCache; import art.arcane.volmlib.util.cache.ChunkCache2D; import art.arcane.volmlib.util.cache.WorldCache2D; -import art.arcane.volmlib.util.data.KCache; import art.arcane.volmlib.util.stream.BasicStream; import art.arcane.volmlib.util.stream.ProceduralStream; public class CachedStream2D extends BasicStream implements ProceduralStream, MeteredCache, ChunkFillableStream2D { @@ -70,11 +69,6 @@ public class CachedStream2D extends BasicStream implements ProceduralStrea return cache.getSize(); } - @Override - public KCache getRawCache() { - return null; - } - @Override public long getMaxSize() { return cache.getMaxSize(); diff --git a/core/src/main/java/art/arcane/iris/generation/stream/CachedStream3D.java b/core/src/main/java/art/arcane/iris/generation/stream/CachedStream3D.java index 64a6a0757..998338fdc 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/CachedStream3D.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/CachedStream3D.java @@ -64,11 +64,6 @@ public class CachedStream3D extends BasicStream implements ProceduralStrea return cache.getSize(); } - @Override - public KCache getRawCache() { - return cache; - } - @Override public long getMaxSize() { return cache.getMaxSize(); diff --git a/core/src/main/java/art/arcane/iris/generation/stream/ContextInjectingStream.java b/core/src/main/java/art/arcane/iris/generation/stream/ContextInjectingStream.java index 422102d76..0f760289f 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/ContextInjectingStream.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/ContextInjectingStream.java @@ -6,17 +6,15 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.util.function.Function3; import art.arcane.volmlib.util.stream.BasicStream; import art.arcane.volmlib.util.stream.ProceduralStream; + +import java.util.Objects; public class ContextInjectingStream extends BasicStream { private final Engine engine; private final Function3 contextAccessor; - public ContextInjectingStream(ProceduralStream stream, Function3 contextAccessor) { - this(stream, null, contextAccessor); - } - public ContextInjectingStream(ProceduralStream stream, Engine engine, Function3 contextAccessor) { super(stream); - this.engine = engine; + this.engine = Objects.requireNonNull(engine); this.contextAccessor = contextAccessor; } @@ -30,9 +28,9 @@ public class ContextInjectingStream extends BasicStream { int blockZ = (int) z; if (chunkContext != null - && (engine == null || context.getEngine() == engine) + && context.getEngine() == engine && context.getGenerationSessionId() == chunkContext.getGenerationSessionId() - && (engine == null || context.getGenerationSessionId() == engine.getGenerationSessionId()) + && context.getGenerationSessionId() == engine.getGenerationSessionId() && blockX >> 4 == chunkContext.getX() >> 4 && blockZ >> 4 == chunkContext.getZ() >> 4) { T t = contextAccessor.apply(chunkContext, blockX & 15, blockZ & 15); diff --git a/core/src/main/java/art/arcane/iris/generation/stream/GenerationStreams.java b/core/src/main/java/art/arcane/iris/generation/stream/GenerationStreams.java index 03bf1c56c..1faa5234e 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/GenerationStreams.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/GenerationStreams.java @@ -1,13 +1,9 @@ package art.arcane.iris.generation.stream; -import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.Engine; -import art.arcane.iris.generation.noise.IrisStyledRange; import art.arcane.iris.generation.context.ChunkContext; import art.arcane.volmlib.util.function.Function3; -import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.stream.ProceduralStream; -import art.arcane.volmlib.util.stream.interpolation.Interpolated; public final class GenerationStreams { private GenerationStreams() { @@ -25,19 +21,7 @@ public final class GenerationStreams { return new CachedStream3D(name, engine, stream, maxSize); } - public static ProceduralStream contextInjecting(ProceduralStream stream, Function3 contextAccessor) { - //return stream; - return new ContextInjectingStream<>(stream, contextAccessor); - } - public static ProceduralStream contextInjecting(ProceduralStream stream, Engine engine, Function3 contextAccessor) { return new ContextInjectingStream<>(stream, engine, contextAccessor); } - - public static ProceduralStream style(ProceduralStream stream, RNG rng, IrisStyledRange range, IrisData data) { - return ProceduralStream.of((x, z) -> { - double d = stream.getDouble(x, z); - return range.get(rng, d, -d, data); - }, Interpolated.DOUBLE); - } } diff --git a/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java b/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java index 07d1a3f21..0d912b67f 100644 --- a/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java +++ b/core/src/main/java/art/arcane/iris/generation/stream/ProvisionalSampling.java @@ -18,7 +18,7 @@ public final class ProvisionalSampling { /** Records that the calling thread just answered from inputs that are not final. */ public static void mark() { - TRACKERS.get().provisional = true; + TRACKERS.get().revision++; } /** @@ -27,15 +27,9 @@ public final class ProvisionalSampling { */ public static T memoizable(ProceduralStream stream, int x, int z) { Tracker tracker = TRACKERS.get(); - boolean outer = tracker.begin(); - T value; - boolean provisional; - try { - value = stream.get(x, z); - } finally { - provisional = tracker.end(outer); - } - if (provisional) { + long revision = tracker.revision; + T value = stream.get(x, z); + if (tracker.revision != revision) { throw new Unmemoizable(value); } return value; @@ -43,15 +37,9 @@ public final class ProvisionalSampling { public static double memoizableDouble(ProceduralStream stream, int x, int z) { Tracker tracker = TRACKERS.get(); - boolean outer = tracker.begin(); - double value; - boolean provisional; - try { - value = stream.getDouble(x, z); - } finally { - provisional = tracker.end(outer); - } - if (provisional) { + long revision = tracker.revision; + double value = stream.getDouble(x, z); + if (tracker.revision != revision) { throw new Unmemoizable(value); } return value; @@ -59,34 +47,16 @@ public final class ProvisionalSampling { public static T memoizable(Supplier resolver) { Tracker tracker = TRACKERS.get(); - boolean outer = tracker.begin(); - T value; - boolean provisional; - try { - value = resolver.get(); - } finally { - provisional = tracker.end(outer); - } - if (provisional) { + long revision = tracker.revision; + T value = resolver.get(); + if (tracker.revision != revision) { throw new Unmemoizable(value); } return value; } private static final class Tracker { - private boolean provisional; - - private boolean begin() { - boolean outer = provisional; - provisional = false; - return outer; - } - - private boolean end(boolean outer) { - boolean inner = provisional; - provisional = outer || inner; - return inner; - } + private long revision; } /** Carries a provisional value past a memoizing cache so the cache stores nothing. */ diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java index 5998da6ec..c419b7bf1 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java @@ -145,25 +145,26 @@ public final class Terrain3DRuntime { int count = 1; boundaries[0] = 0; boolean previousSolid = true; - for (int gridY = Math.floorDiv(minimum, STEP) * STEP; gridY <= maximum; gridY += STEP) { - NodeSample lowerNW = northWest.sample(gridY); - NodeSample lowerNE = northEast.sample(gridY); - NodeSample lowerSW = southWest.sample(gridY); - NodeSample lowerSE = southEast.sample(gridY); + int firstGridY = Math.floorDiv(minimum, STEP) * STEP; + NodeSample lowerNW = northWest.sample(firstGridY); + NodeSample lowerNE = northEast.sample(firstGridY); + NodeSample lowerSW = southWest.sample(firstGridY); + NodeSample lowerSE = southEast.sample(firstGridY); + double lowerDisplacement = interpolate(lowerNW.displacement, lowerNE.displacement, + lowerSW.displacement, lowerSE.displacement, dx, dz); + double lowerCrack = interpolate(lowerNW.crackDistance, lowerNE.crackDistance, + lowerSW.crackDistance, lowerSE.crackDistance, dx, dz); + double lowerDepth = interpolate(lowerNW.crackDepth, lowerNE.crackDepth, + lowerSW.crackDepth, lowerSE.crackDepth, dx, dz); + for (int gridY = firstGridY; gridY <= maximum; gridY += STEP) { NodeSample upperNW = northWest.sample(gridY + STEP); NodeSample upperNE = northEast.sample(gridY + STEP); NodeSample upperSW = southWest.sample(gridY + STEP); NodeSample upperSE = southEast.sample(gridY + STEP); - double lowerDisplacement = interpolate(lowerNW.displacement, lowerNE.displacement, - lowerSW.displacement, lowerSE.displacement, dx, dz); double upperDisplacement = interpolate(upperNW.displacement, upperNE.displacement, upperSW.displacement, upperSE.displacement, dx, dz); - double lowerCrack = interpolate(lowerNW.crackDistance, lowerNE.crackDistance, - lowerSW.crackDistance, lowerSE.crackDistance, dx, dz); double upperCrack = interpolate(upperNW.crackDistance, upperNE.crackDistance, upperSW.crackDistance, upperSE.crackDistance, dx, dz); - double lowerDepth = interpolate(lowerNW.crackDepth, lowerNE.crackDepth, - lowerSW.crackDepth, lowerSE.crackDepth, dx, dz); double upperDepth = interpolate(upperNW.crackDepth, upperNE.crackDepth, upperSW.crackDepth, upperSE.crackDepth, dx, dz); int lastY = Math.min(maximum, gridY + STEP - 1); @@ -185,6 +186,9 @@ public final class Terrain3DRuntime { previousSolid = solid; } } + lowerDisplacement = upperDisplacement; + lowerCrack = upperCrack; + lowerDepth = upperDepth; } if (previousSolid) { if (count == boundaries.length) { diff --git a/core/src/main/java/art/arcane/iris/pack/AtomicDirectoryPublisher.java b/core/src/main/java/art/arcane/iris/pack/AtomicDirectoryPublisher.java index ac68f37bf..9e94453eb 100644 --- a/core/src/main/java/art/arcane/iris/pack/AtomicDirectoryPublisher.java +++ b/core/src/main/java/art/arcane/iris/pack/AtomicDirectoryPublisher.java @@ -2,16 +2,24 @@ package art.arcane.iris.pack; import java.io.IOException; import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryNotEmptyException; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.Comparator; +import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.stream.Stream; public final class AtomicDirectoryPublisher { + private static final int MAXIMUM_METADATA_DELETION_RETRIES = 3; + private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini"); + private static final String APPLE_DOUBLE_PREFIX = "._"; + private AtomicDirectoryPublisher() { } @@ -139,8 +147,52 @@ public final class AtomicDirectoryPublisher { } try (Stream stream = Files.walk(path)) { for (Path entry : stream.sorted(Comparator.reverseOrder()).toList()) { - Files.deleteIfExists(entry); + deleteEntry(entry); } } } + + public static boolean isOperatingSystemMetadata(Path entry) { + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + Path name = entry.getFileName(); + if (name == null) { + return false; + } + String fileName = name.toString(); + return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX); + } + + private static void deleteEntry(Path entry) throws IOException { + for (int attempt = 0; ; attempt++) { + try { + Files.deleteIfExists(entry); + return; + } catch (DirectoryNotEmptyException notEmpty) { + if (attempt >= MAXIMUM_METADATA_DELETION_RETRIES || !deleteResidualMetadata(entry)) { + throw notEmpty; + } + } + } + } + + private static boolean deleteResidualMetadata(Path directory) throws IOException { + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + List entries; + try (Stream stream = Files.list(directory)) { + entries = stream.toList(); + } + for (Path entry : entries) { + if (!isOperatingSystemMetadata(entry)) { + return false; + } + } + for (Path entry : entries) { + Files.deleteIfExists(entry); + } + return true; + } } diff --git a/core/src/main/java/art/arcane/iris/pack/IrisPack.java b/core/src/main/java/art/arcane/iris/pack/IrisPack.java deleted file mode 100644 index 62e1c9ae5..000000000 --- a/core/src/main/java/art/arcane/iris/pack/IrisPack.java +++ /dev/null @@ -1,384 +0,0 @@ -/* - * 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.pack; - -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.pack.loading.IrisData; -import art.arcane.iris.pack.loading.ResourceLoader; -import art.arcane.iris.studio.StudioSVC; -import art.arcane.iris.generation.terrain.IrisDimension; -import art.arcane.iris.world.IrisWorld; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.exceptions.IrisException; -import art.arcane.volmlib.util.format.Form; -import art.arcane.volmlib.util.io.IO; -import art.arcane.volmlib.util.json.JSONArray; -import art.arcane.volmlib.util.json.JSONObject; -import art.arcane.iris.platform.bukkit.plugin.VolmitSender; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; -import lombok.Data; -import org.bukkit.World; -import org.zeroturnaround.zip.commons.FileUtils; - -import java.io.File; -import java.io.IOException; -import java.net.MalformedURLException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; - -/** - * Represents an Iris pack that exists - */ -@Data -public class IrisPack { - private final File folder; - private final IrisData data; - - /** - * Create an iris pack backed by a data folder - * the data folder is assumed to be in the Iris/packs/NAME folder - * - * @param name the name - */ - public IrisPack(String name) { - this(packsPack(name)); - } - - /** - * Create an iris pack backed by a data folder - * - * @param folder the folder of the pack. Must be a directory - */ - public IrisPack(File folder) { - this.folder = folder; - - if (!folder.exists()) { - throw new RuntimeException("Cannot open Pack " + folder.getPath() + " (directory doesnt exist)"); - } - - if (!folder.isDirectory()) { - throw new RuntimeException("Cannot open Pack " + folder.getPath() + " (not a directory)"); - } - - this.data = IrisData.get(folder); - } - - /** - * Create a new pack from the input url - * - * @param sender the sender - * @param url the url, or name, or really anything see IrisPackRepository.from(String) - * @return the iris pack - * @throws IrisException fails - */ - public static Future from(VolmitSender sender, String url) throws IrisException { - IrisPackRepository repo = IrisPackRepository.from(url); - if (repo == null) { - throw new IrisException("Null Repo"); - } - - try { - return from(sender, repo); - } catch (MalformedURLException e) { - throw new IrisException("Malformed URL " + e.getMessage()); - } - } - - /** - * Create a pack from a repo - * - * @param sender the sender - * @param repo the repo - * @return the pack - * @throws MalformedURLException shit happens - */ - public static Future from(VolmitSender sender, IrisPackRepository repo) throws MalformedURLException { - CompletableFuture pack = new CompletableFuture<>(); - repo.install(sender, () -> { - pack.complete(new IrisPack(repo.getRepo())); - }); - return pack; - } - - /** - * Create a blank pack with a given name - * - * @param name the name of the pack - * @return the pack - * @throws IrisException if the pack already exists or another error - */ - public static IrisPack blank(String name) throws IrisException { - File f = packsPack(name); - - if (f.exists()) { - throw new IrisException("Already exists"); - } - - File fd = new File(f, "dimensions/" + name + ".json"); - fd.getParentFile().mkdirs(); - try { - IO.writeAll(fd, "{\n" + - " \"name\": \"" + Form.capitalize(name) + "\",\n" + - " \"version\": 1\n" + - "}\n"); - } catch (IOException e) { - throw new IrisException(e.getMessage(), e); - } - - IrisPack pack = new IrisPack(f); - pack.updateWorkspace(); - return pack; - } - - /** - * Get a packs pack folder for a name. Such that overworld would resolve as Iris/packs/overworld - * - * @param name the name - * @return the file path - */ - public static File packsPack(String name) { - return IrisPlatforms.get().packsFolderNoCreate(name); - } - - private static KList collectFiles(File f, String fileExtension) { - KList l = new KList<>(); - - if (f.isDirectory()) { - for (File i : f.listFiles()) { - l.addAll(collectFiles(i, fileExtension)); - } - } else if (f.getName().endsWith("." + fileExtension)) { - l.add(f); - } - - return l; - } - - /** - * Delete this pack. This invalidates this pack and you should - * probably no longer use this instance after deleting this pack - */ - public void delete() { - IO.delete(folder); - folder.delete(); - } - - /** - * Get the name of this pack - * - * @return the pack name - */ - public String getName() { - return folder.getName(); - } - - /** - * Get the file path of the workspace file - * - * @return the workspace file path - */ - public File getWorkspaceFile() { - return new File(getFolder(), getName() + ".code-workspace"); - } - - /** - * Update the workspace file - * - * @return true if it was updated - */ - public boolean updateWorkspace() { - getFolder().mkdirs(); - File ws = getWorkspaceFile(); - - try { - PrecisionStopwatch p = PrecisionStopwatch.start(); - IrisLogging.debug("Building Workspace: " + ws.getPath()); - JSONObject j = generateWorkspaceConfig(); - IO.writeAll(ws, j.toString(4)); - p.end(); - IrisLogging.debug("Building Workspace: " + ws.getPath() + " took " + Form.duration(p.getMilliseconds(), 2)); - return true; - } catch (Throwable e) { - IrisLogging.reportError(e); - IrisLogging.warn("Pack invalid: " + ws.getAbsolutePath() + " Re-creating. You may loose some vs-code workspace settings! But not your actual project!"); - ws.delete(); - try { - IO.writeAll(ws, generateWorkspaceConfig()); - } catch (IOException e1) { - IrisLogging.reportError(e1); - } - } - - return false; - } - - /** - * Install this pack into a world - * - * @param world the world to install into (world/iris/pack) - * @return the installed pack - */ - public IrisPack install(World world) throws IrisException { - return install(new File(world.getWorldFolder(), "iris/pack")); - } - - /** - * Install this pack into a world - * - * @param world the world to install into (world/iris/pack) - * @return the installed pack - */ - public IrisPack install(IrisWorld world) throws IrisException { - return install(new File(world.worldFolder(), "iris/pack")); - } - - /** - * Install this pack into a world - * - * @param folder the folder to install this pack into - * @return the installed pack - */ - public IrisPack install(File folder) throws IrisException { - if (folder.exists()) { - throw new IrisException("Cannot install new pack because the folder " + folder.getName() + " already exists!"); - } - - folder.mkdirs(); - - try { - FileUtils.copyDirectory(getFolder(), folder); - } catch (IOException e) { - IrisLogging.reportError(e); - } - - return new IrisPack(folder); - } - - /** - * Create a new pack using this pack as a template. The new pack will be renamed & have a renamed dimension - * to match it. - * - * @param newName the new pack name - * @return the new IrisPack - */ - public IrisPack install(String newName) throws IrisException { - File newPack = packsPack(newName); - - if (newPack.exists()) { - throw new IrisException("Cannot install new pack because the folder " + newName + " already exists!"); - } - - try { - FileUtils.copyDirectory(getFolder(), newPack); - } catch (IOException e) { - IrisLogging.reportError(e); - } - - IrisData data = IrisData.get(newPack); - IrisDimension dim = data.getDimensionLoader().load(getDimensionKey()); - data.dump(); - File from = dim.getLoadFile(); - File to = new File(from.getParentFile(), newName + ".json"); - try { - FileUtils.moveFile(from, to); - new File(newPack, getWorkspaceFile().getName()).delete(); - } catch (Throwable e) { - throw new IrisException(e); - } - - IrisPack pack = new IrisPack(newPack); - pack.updateWorkspace(); - - return pack; - } - - /** - * The dimension's assumed loadkey - * - * @return getName() - */ - public String getDimensionKey() { - return getName(); - } - - /** - * Get the main dimension object - * - * @return the dimension (folder name as dim key) - */ - public IrisDimension getDimension() { - return getData().getDimensionLoader().load(getDimensionKey()); - } - - /** - * Find all files in this pack with the given extension - * - * @param fileExtension the extension - * @return the list of files - */ - public KList collectFiles(String fileExtension) { - return collectFiles(getFolder(), fileExtension); - } - - private JSONObject generateWorkspaceConfig() { - JSONObject ws = new JSONObject(); - JSONArray folders = new JSONArray(); - JSONObject folder = new JSONObject(); - folder.put("path", "."); - folders.put(folder); - ws.put("folders", folders); - JSONObject settings = new JSONObject(); - settings.put("workbench.colorTheme", "Monokai"); - settings.put("workbench.preferredDarkColorTheme", "Solarized Dark"); - settings.put("workbench.tips.enabled", false); - settings.put("workbench.tree.indent", 24); - settings.put("files.autoSave", "onFocusChange"); - JSONObject jc = new JSONObject(); - jc.put("editor.autoIndent", "brackets"); - jc.put("editor.acceptSuggestionOnEnter", "smart"); - jc.put("editor.cursorSmoothCaretAnimation", true); - jc.put("editor.dragAndDrop", false); - jc.put("files.trimTrailingWhitespace", true); - jc.put("diffEditor.ignoreTrimWhitespace", true); - jc.put("files.trimFinalNewlines", true); - jc.put("editor.suggest.showKeywords", false); - jc.put("editor.suggest.showSnippets", false); - jc.put("editor.suggest.showWords", false); - JSONObject st = new JSONObject(); - st.put("strings", true); - jc.put("editor.quickSuggestions", st); - jc.put("editor.suggest.insertMode", "replace"); - settings.put("[json]", jc); - settings.put("json.maxItemsComputed", 30000); - JSONArray schemas = new JSONArray(); - IrisData dm = IrisData.get(getFolder()); - - for (ResourceLoader r : dm.getLoaders().v()) { - if (r.supportsSchemas()) { - schemas.put(r.buildSchema()); - } - } - - settings.put("json.schemas", schemas); - ws.put("settings", settings); - - return ws; - } -} diff --git a/core/src/main/java/art/arcane/iris/pack/IrisPackRepository.java b/core/src/main/java/art/arcane/iris/pack/IrisPackRepository.java deleted file mode 100644 index 45f367d82..000000000 --- a/core/src/main/java/art/arcane/iris/pack/IrisPackRepository.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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.pack; - -import art.arcane.iris.localization.IrisLanguage; -import art.arcane.iris.localization.RuntimeUiMessages; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.studio.StudioSVC; -import art.arcane.volmlib.util.format.Form; -import art.arcane.iris.platform.bukkit.plugin.VolmitSender; -import art.arcane.iris.world.task.jobs.DownloadJob; -import art.arcane.iris.world.task.jobs.JobCollection; -import art.arcane.iris.world.task.jobs.SingleJob; -import lombok.Builder; -import lombok.Data; -import org.zeroturnaround.zip.ZipUtil; -import org.zeroturnaround.zip.commons.FileUtils; - -import java.io.File; -import java.io.IOException; -import java.net.MalformedURLException; -import java.util.UUID; - -@Data -@Builder -public class IrisPackRepository { - private static final String DEFAULT_BRANCH = "stable"; - - @Builder.Default - private String user = "IrisDimensions"; - - @Builder.Default - private String repo = "overworld"; - - @Builder.Default - private String branch = DEFAULT_BRANCH; - - @Builder.Default - private String tag = ""; - - /** - * - */ - public static IrisPackRepository from(String g) { - // https://github.com/IrisDimensions/overworld - if (g.startsWith("https://github.com/")) { - String sub = g.split("\\Qgithub.com/\\E")[1]; - IrisPackRepository r = IrisPackRepository.builder() - .user(sub.split("\\Q/\\E")[0]) - .repo(sub.split("\\Q/\\E")[1]).build(); - - if (g.contains("/tree/")) { - r.setBranch(g.split("/tree/")[1]); - } - - return r; - } else if (g.contains("/")) { - String[] f = g.split("\\Q/\\E"); - - if (f.length == 1) { - return from(g); - } else if (f.length == 2) { - return IrisPackRepository.builder() - .user(f[0]) - .repo(f[1]) - .build(); - } else if (f.length >= 3) { - IrisPackRepository r = IrisPackRepository.builder() - .user(f[0]) - .repo(f[1]) - .build(); - - if (f[2].startsWith("#")) { - r.setTag(f[2].substring(1)); - } else { - r.setBranch(f[2]); - } - - return r; - } - } else { - return IrisPackRepository.builder() - .user("IrisDimensions") - .repo(g) - .branch(DEFAULT_BRANCH) - .build(); - } - - return null; - } - - public String toURL() { - if (!tag.trim().isEmpty()) { - return "https://codeload.github.com/" + user + "/" + repo + "/zip/refs/tags/" + tag; - } - - return "https://codeload.github.com/" + user + "/" + repo + "/zip/refs/heads/" + branch; - } - - public void install(VolmitSender sender, Runnable whenComplete) throws MalformedURLException { - File pack = IrisPlatforms.get().packsFolderNoCreate(getRepo()); - - if (!pack.exists()) { - File dl = new File(IrisPlatforms.get().dataFolder("cache", "temp"), "dltk-" + UUID.randomUUID() + ".zip"); - File work = new File(IrisPlatforms.get().dataFolder("cache", "temp"), "extk-" + UUID.randomUUID()); - new JobCollection(Form.capitalize(getRepo()), - new DownloadJob(toURL(), pack), - new SingleJob(IrisLanguage.text(RuntimeUiMessages.JOB_EXTRACTING), () -> ZipUtil.unpack(dl, work)), - new SingleJob(IrisLanguage.text(RuntimeUiMessages.JOB_INSTALLING), () -> { - try { - FileUtils.copyDirectory(work.listFiles()[0], pack); - } catch (IOException e) { - IrisLogging.reportError("Failed to install Iris pack into " + pack + ".", e); - } - })).execute(sender, whenComplete); - } else { - sender.sendMessage(IrisLanguage.text(RuntimeUiMessages.PACK_ALREADY_EXISTS)); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/pack/PackBiomeLayerValidator.java b/core/src/main/java/art/arcane/iris/pack/PackBiomeLayerValidator.java index c41de4438..22f7709e5 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackBiomeLayerValidator.java +++ b/core/src/main/java/art/arcane/iris/pack/PackBiomeLayerValidator.java @@ -108,13 +108,21 @@ final class PackBiomeLayerValidator { private static void validateLayerArray(JSONObject biome, String field, String biomeKey, List blockingErrors) { - if (!biome.has(field) || biome.isNull(field)) { + if (!biome.has(field)) { return; } JSONArray array = biome.optJSONArray(field); if (array == null) { blockingErrors.add("Biome '" + biomeKey + "' " + field + " must be an array."); + return; + } + for (int index = 0; index < array.length(); index++) { + Object layer = array.opt(index); + if (!(layer instanceof JSONObject) && !(layer instanceof String)) { + blockingErrors.add("Biome '" + biomeKey + "' " + field + "[" + index + + "] must be an object or snippet reference."); + } } } } diff --git a/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java b/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java index be0d7200f..573761c00 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java +++ b/core/src/main/java/art/arcane/iris/pack/PackDimensionValidator.java @@ -63,6 +63,7 @@ final class PackDimensionValidator { validateStaticObjects(packFolder, dimensionKey, dimJson, blockingErrors); validateObjectScaleFactor(dimensionKey, dimJson, blockingErrors); validateDimensionStack(packFolder, dimensionKey, dimJson, dimensionKeys, blockingErrors); + validateUpperDimension(dimensionKey, dimJson, dimensionKeys, blockingErrors); JSONArray regionsArray = dimJson.optJSONArray("regions"); if (regionsArray == null || regionsArray.length() == 0) { @@ -107,6 +108,21 @@ final class PackDimensionValidator { } } + private static void validateUpperDimension(String dimensionKey, JSONObject dimension, Set dimensionKeys, + List errors) { + if (!dimension.has("upperDimension") || dimension.isNull("upperDimension")) { + return; + } + Object value = dimension.opt("upperDimension"); + if (!(value instanceof String key)) { + errors.add("Dimension '" + dimensionKey + "' upperDimension must be a dimension key."); + return; + } + if (!key.isEmpty() && !key.equalsIgnoreCase("none") && !dimensionKeys.contains(key)) { + errors.add("Dimension '" + dimensionKey + "' upperDimension references missing dimension '" + key + "'."); + } + } + static void validateObjectScaleFactor(String dimensionKey, JSONObject dimension, List errors) { validateFiniteNumber(dimension, "allObjectScaleFactor", IrisObjectScale.MINIMUM_FACTOR, IrisObjectScale.MAXIMUM_FACTOR, "Dimension '" + dimensionKey + "'", errors); @@ -385,15 +401,31 @@ final class PackDimensionValidator { * initializers (min 16, max 32); logicalHeight absent means 256. */ static void validateDimensionHeights(File packFolder, String dimensionKey, JSONObject dimJson, List blockingErrors) { - JSONObject range = resolveDimensionHeight(packFolder, dimJson); - if (range == null && dimJson.has("dimensionHeight") && !dimJson.isNull("dimensionHeight")) { - if (!(dimJson.opt("dimensionHeight") instanceof String)) { - blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight must be an object or a range snippet reference."); - } - // Unresolvable snippet references are reported by the content-key machinery. + if (dimJson.has("dimensionHeight") && dimJson.isNull("dimensionHeight")) { + blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight must be an object or a range snippet reference."); return; } + JSONObject range = resolveDimensionHeight(packFolder, dimJson); + if (range == null && dimJson.has("dimensionHeight")) { + blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight must be an object or a resolvable range snippet reference."); + return; + } + if (range != null) { + boolean validMin = validateHeightNumber(range, "min", dimensionKey, blockingErrors); + boolean validMax = validateHeightNumber(range, "max", dimensionKey, blockingErrors); + if (!validMin || !validMax) { + return; + } + } + if (dimJson.has("logicalHeight") && !dimJson.isNull("logicalHeight")) { + double logicalHeightValue = dimJson.optDouble("logicalHeight", Double.NaN); + if (!Double.isFinite(logicalHeightValue) || logicalHeightValue != Math.rint(logicalHeightValue) + || logicalHeightValue < Integer.MIN_VALUE || logicalHeightValue > Integer.MAX_VALUE) { + blockingErrors.add("Dimension '" + dimensionKey + "' logicalHeight must be an integer."); + return; + } + } int minY; int maxY; if (range == null) { @@ -404,7 +436,7 @@ final class PackDimensionValidator { maxY = (int) range.optDouble("max", 32D); } int height = maxY - minY; - int logicalHeight = dimJson.optInt("logicalHeight", 256); + int logicalHeight = (int) dimJson.optDouble("logicalHeight", 256D); if (height < IrisDimensionType.MIN_HEIGHT || height > IrisDimensionType.MAX_HEIGHT) { blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight span (max - min) is " + height @@ -507,6 +539,31 @@ final class PackDimensionValidator { return Double.toString(value); } + private static boolean validateHeightNumber(JSONObject range, String field, String dimensionKey, List errors) { + if (!range.has(field) || range.isNull(field)) { + return true; + } + Object value = range.opt(field); + double number; + try { + if (value instanceof Number numeric) { + number = numeric.doubleValue(); + } else if (value instanceof String text) { + number = Double.parseDouble(text); + } else { + throw new NumberFormatException(); + } + } catch (NumberFormatException exception) { + errors.add("Dimension '" + dimensionKey + "' dimensionHeight." + field + " must be a finite number."); + return false; + } + if (!Double.isFinite(number)) { + errors.add("Dimension '" + dimensionKey + "' dimensionHeight." + field + " must be a finite number."); + return false; + } + return true; + } + private static JSONObject resolveDimensionHeight(File packFolder, JSONObject dimJson) { if (!dimJson.has("dimensionHeight") || dimJson.isNull("dimensionHeight")) { return null; diff --git a/core/src/main/java/art/arcane/iris/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/pack/PackDownloader.java index 35707b114..9e776e113 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackDownloader.java +++ b/core/src/main/java/art/arcane/iris/pack/PackDownloader.java @@ -59,6 +59,7 @@ import java.util.regex.Pattern; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import java.util.zip.ZipFile; public final class PackDownloader { private static final String DEFAULT_OVERWORLD_PACK = "overworld"; @@ -641,33 +642,36 @@ public final class PackDownloader { ); return null; } - if (cancellation != null) { - cancellation.beginPublication(); - } - IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close); - IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close); Path retainedBackup = null; - if (forceOverwrite && Files.exists(target)) { - Path backupRoot = packsRoot.resolve(".backups"); - if (Files.isSymbolicLink(backupRoot)) { - throw new IOException("Pack backup folder is an unsafe symbolic link: " + backupRoot); + try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(target)) { + PackValidationRegistry.remove(prepared.key()); + if (cancellation != null) { + cancellation.beginPublication(); } - Files.createDirectories(backupRoot); - retainedBackup = backupRoot.resolve(prepared.key() + "-" + UUID.randomUUID()); - } - try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) { - if (retainedBackup != null) { - publication.retainBackup(retainedBackup); + IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close); + IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close); + if (forceOverwrite && Files.exists(target)) { + Path backupRoot = packsRoot.resolve(".backups"); + if (Files.isSymbolicLink(backupRoot)) { + throw new IOException("Pack backup folder is an unsafe symbolic link: " + backupRoot); + } + Files.createDirectories(backupRoot); + retainedBackup = backupRoot.resolve(prepared.key() + "-" + UUID.randomUUID()); } - publication.commit(); - if (retainedBackup == null) { - try { - publication.cleanupBackup(); - } catch (IOException exception) { - IrisLogging.reportError( - "Pack '" + prepared.key() + "' was published, but its transaction backup could not be cleaned.", - exception - ); + try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) { + if (retainedBackup != null) { + publication.retainBackup(retainedBackup); + } + publication.commit(); + if (retainedBackup == null) { + try { + publication.cleanupBackup(); + } catch (IOException exception) { + IrisLogging.reportError( + "Pack '" + prepared.key() + "' was published, but its transaction backup could not be cleaned.", + exception + ); + } } } } @@ -763,6 +767,11 @@ public final class PackDownloader { if (Files.size(source) > safety.maxArchiveBytes()) { throw new IOException("Pack archive exceeds the compressed size limit."); } + try (ZipFile directory = new ZipFile(source.toFile())) { + if (directory.size() > safety.maxEntries()) { + throw new IOException("Pack archive contains too many entries."); + } + } if (Files.exists(root, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { throw new IOException("Pack extraction target is not a directory: " + root); } diff --git a/core/src/main/java/art/arcane/iris/pack/PackFingerprints.java b/core/src/main/java/art/arcane/iris/pack/PackFingerprints.java new file mode 100644 index 000000000..7d8bd310e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/pack/PackFingerprints.java @@ -0,0 +1,305 @@ +package art.arcane.iris.pack; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +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.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +public final class PackFingerprints { + private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace"; + private static final int FINGERPRINT_BUFFER_BYTES = 64 * 1024; + + private PackFingerprints() { + } + + public static String computePackMetadataDigest(File packsDir) { + Path root = resolveFingerprintRoot(packsDir); + if (root == null) { + return ""; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + List entries = collectFingerprintEntries(root.toRealPath()); + entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); + for (FingerprintEntry entry : entries) { + byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8); + updateDigestInt(digest, relativePath.length); + digest.update(relativePath); + updateDigestLong(digest, entry.size()); + updateDigestLong(digest, entry.lastModifiedMillis()); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (IOException exception) { + throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + public static String computePackFingerprint(File packsDir) { + return computePackContentSnapshot(packsDir).content(); + } + + public static PackContentSnapshot computePackContentSnapshot(File packsDir) { + Path root = resolveFingerprintRoot(packsDir); + if (root == null) { + return new PackContentSnapshot("", Map.of()); + } + try { + Path resolvedRoot = root.toRealPath(); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + Map packDigests = new LinkedHashMap<>(); + List entries = collectFingerprintEntries(resolvedRoot); + entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); + byte[] buffer = new byte[FINGERPRINT_BUFFER_BYTES]; + for (FingerprintEntry entry : entries) { + MessageDigest packDigest = entry.packName() == null + ? null + : packDigests.computeIfAbsent(entry.packName(), ignored -> newSha256Digest()); + updateFingerprintEntry(digest, entry.relativePath(), entry.size()); + if (packDigest != null) { + updateFingerprintEntry(packDigest, entry.packRelativePath(), entry.size()); + } + long readBytes = 0L; + try (InputStream input = Files.newInputStream( + entry.source(), + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS)) { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + if (packDigest != null) { + packDigest.update(buffer, 0, read); + } + readBytes += read; + } + } + } + if (readBytes != entry.size()) { + throw new IOException("Iris pack changed while fingerprinting: " + entry.source()); + } + } + Map packContents = new LinkedHashMap<>(); + for (Map.Entry entry : packDigests.entrySet()) { + packContents.put(entry.getKey(), HexFormat.of().formatHex(entry.getValue().digest())); + } + return new PackContentSnapshot( + HexFormat.of().formatHex(digest.digest()), + Map.copyOf(packContents)); + } catch (IOException exception) { + throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + public static String computePackTreeFingerprint(File packDir) { + Path root = resolveFingerprintRoot(packDir); + if (root == null) { + return ""; + } + try { + List entries = new ArrayList<>(); + collectFingerprintTree(root.toRealPath(), "", null, entries); + entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[FINGERPRINT_BUFFER_BYTES]; + for (FingerprintEntry entry : entries) { + updateFingerprintEntry(digest, entry.relativePath(), entry.size()); + long readBytes = 0L; + try (InputStream input = Files.newInputStream( + entry.source(), + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS)) { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + readBytes += read; + } + } + } + if (readBytes != entry.size()) { + throw new IOException("Iris pack changed while fingerprinting: " + entry.source()); + } + } + return HexFormat.of().formatHex(digest.digest()); + } catch (IOException exception) { + throw new UncheckedIOException("Unable to fingerprint Iris pack at " + root, exception); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 not available", exception); + } + } + + private static MessageDigest newSha256Digest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 not available", exception); + } + } + + private static void updateFingerprintEntry(MessageDigest digest, String relativePath, long size) { + byte[] relativeBytes = relativePath.getBytes(StandardCharsets.UTF_8); + updateDigestInt(digest, relativeBytes.length); + digest.update(relativeBytes); + updateDigestLong(digest, size); + } + + private static Path resolveFingerprintRoot(File packsDir) { + if (packsDir == null) { + return null; + } + Path root = packsDir.toPath().toAbsolutePath().normalize(); + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.isDirectory(root)) { + if (Files.isSymbolicLink(root)) { + throw new IllegalArgumentException("Iris packs root target is missing or unsafe: " + root); + } + return null; + } + return root; + } + + private static List collectFingerprintEntries(Path root) throws IOException { + List entries = new ArrayList<>(); + try (Stream children = Files.list(root)) { + for (Path child : children.toList()) { + String childName = child.getFileName().toString(); + if (PackDirectoryResolver.isHiddenName(childName) || isNonContentPackFile(childName)) { + continue; + } + if (Files.isSymbolicLink(child)) { + if (!Files.isDirectory(child)) { + throw new IOException("Iris pack fingerprint rejected symbolic link: " + child); + } + PackDirectoryResolver.requireSafePackTree(child.toFile()); + collectFingerprintTree(child.toRealPath(), childName, childName, entries); + } else if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { + collectFingerprintTree(child, childName, childName, entries); + } else if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)) { + BasicFileAttributes attributes = Files.readAttributes( + child, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); + entries.add(new FingerprintEntry( + child, + childName, + null, + null, + attributes.size(), + attributes.lastModifiedTime().toMillis())); + } else { + throw new IOException("Iris pack fingerprint rejected unsupported entry: " + child); + } + } + } + return entries; + } + + private static void collectFingerprintTree( + Path treeRoot, + String logicalRoot, + String packName, + List entries + ) throws IOException { + Files.walkFileTree(treeRoot, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory( + Path directory, + BasicFileAttributes attributes + ) { + if (!directory.equals(treeRoot) + && treeRoot.relativize(directory).getNameCount() == 1 + && PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { + String fileName = file.getFileName().toString(); + if ((treeRoot.relativize(file).getNameCount() == 1 + && PackDirectoryResolver.isHiddenName(fileName)) + || isNonContentPackFile(fileName)) { + return FileVisitResult.CONTINUE; + } + if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { + throw new IOException("Iris pack fingerprint rejected symbolic link: " + file); + } + if (!attributes.isRegularFile()) { + throw new IOException("Iris pack fingerprint rejected unsupported entry: " + file); + } + String relative = treeRoot.relativize(file).toString().replace(File.separatorChar, '/'); + String logicalRelative = logicalRoot.isEmpty() ? relative : logicalRoot + "/" + relative; + entries.add(new FingerprintEntry( + file, + logicalRelative, + packName, + packName == null ? null : relative, + attributes.size(), + attributes.lastModifiedTime().toMillis())); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + throw new IOException("Unable to inspect Iris pack entry: " + file, failure); + } + }); + } + + private static boolean isNonContentPackFile(String name) { + return name != null && (name.equals(".DS_Store") || name.endsWith(CODE_WORKSPACE_SUFFIX)); + } + + private record FingerprintEntry( + Path source, + String relativePath, + String packName, + String packRelativePath, + long size, + long lastModifiedMillis + ) { + } + + private static void updateDigestInt(MessageDigest digest, int value) { + for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { + digest.update((byte) (value >>> shift)); + } + } + + private static void updateDigestLong(MessageDigest digest, long value) { + for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { + digest.update((byte) (value >>> shift)); + } + } + + public record PackContentSnapshot(String content, Map packContents) { + public PackContentSnapshot { + content = Objects.requireNonNullElse(content, ""); + packContents = Map.copyOf(Objects.requireNonNullElse(packContents, Map.of())); + } + } + +} diff --git a/core/src/main/java/art/arcane/iris/pack/PackResourceCleanup.java b/core/src/main/java/art/arcane/iris/pack/PackResourceCleanup.java index a575e6150..5081df4fc 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackResourceCleanup.java +++ b/core/src/main/java/art/arcane/iris/pack/PackResourceCleanup.java @@ -75,11 +75,15 @@ public final class PackResourceCleanup { synchronized (packLock(lockPath)) { try { Path packRoot = requirePackRoot(packFolder); - CleanupScan scan = scanCleanup(packRoot); - if (scan.paths().isEmpty()) { - return new ApplyResult(null, List.of(), null); + try (PackValidationRegistry.RootMutation mutation = + PackValidationRegistry.beginRootMutation(packRoot)) { + CleanupScan scan = scanCleanup(packRoot); + if (scan.paths().isEmpty()) { + return new ApplyResult(null, List.of(), null); + } + PackValidationRegistry.remove(packFolder.getName()); + return quarantine(packRoot, scan.paths()); } - return quarantine(packRoot, scan.paths()); } catch (IOException | RuntimeException e) { return new ApplyResult(null, List.of(), errorMessage("Unable to quarantine pack resources", e)); } @@ -110,14 +114,18 @@ public final class PackResourceCleanup { synchronized (packLock(lockPath)) { try { Path packRoot = requirePackRoot(packFolder); - RestoreScan scan = scanRestore(packRoot); - if (scan.dump() == null) { - return new RestoreResult(null, List.of(), List.of(), null); + try (PackValidationRegistry.RootMutation mutation = + PackValidationRegistry.beginRootMutation(packRoot)) { + RestoreScan scan = scanRestore(packRoot); + if (scan.dump() == null) { + return new RestoreResult(null, List.of(), List.of(), null); + } + if (!scan.conflicts().isEmpty()) { + return new RestoreResult(scan.dumpPath(), List.of(), scan.conflicts(), null); + } + PackValidationRegistry.remove(packFolder.getName()); + return restore(packRoot, scan); } - if (!scan.conflicts().isEmpty()) { - return new RestoreResult(scan.dumpPath(), List.of(), scan.conflicts(), null); - } - return restore(packRoot, scan); } catch (IOException | RuntimeException e) { return new RestoreResult(null, List.of(), List.of(), errorMessage("Unable to restore quarantined resources", e)); } diff --git a/core/src/main/java/art/arcane/iris/pack/PackValidationCache.java b/core/src/main/java/art/arcane/iris/pack/PackValidationCache.java index 81da1a065..17d6cf23b 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackValidationCache.java +++ b/core/src/main/java/art/arcane/iris/pack/PackValidationCache.java @@ -1,6 +1,5 @@ package art.arcane.iris.pack; -import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.validation.CompatAction; import art.arcane.iris.pack.validation.CompatFinding; import art.arcane.iris.pack.validation.CompatRegistry; @@ -41,7 +40,7 @@ public final class PackValidationCache { } public static String contentFingerprint(File packsRoot) { - return ServerConfigurator.computePackFingerprint(packsRoot); + return PackFingerprints.computePackFingerprint(packsRoot); } public static String contextFingerprint() { diff --git a/core/src/main/java/art/arcane/iris/pack/PackValidationRegistry.java b/core/src/main/java/art/arcane/iris/pack/PackValidationRegistry.java index b22cac7c1..851d3a25d 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackValidationRegistry.java +++ b/core/src/main/java/art/arcane/iris/pack/PackValidationRegistry.java @@ -115,6 +115,11 @@ public final class PackValidationRegistry { } public static boolean publishIfCurrent(ValidationTicket ticket, PackValidationResult result) { + return publishIfCurrent(ticket, result, "", ""); + } + + public static boolean publishIfCurrent(ValidationTicket ticket, PackValidationResult result, + String contentFingerprint, String contextFingerprint) { if (ticket == null || result == null) { return false; } @@ -129,7 +134,9 @@ public final class PackValidationRegistry { return new RootState( current.generation(), false, - new RootValidation(result, "", "")); + new RootValidation(result, + Objects.requireNonNull(contentFingerprint, "Content fingerprint"), + Objects.requireNonNull(contextFingerprint, "Context fingerprint"))); }); return published.get(); } @@ -321,6 +328,18 @@ public final class PackValidationRegistry { ""); } + public synchronized void stageValidatedSnapshot( + PackValidationResult result, + String contentFingerprint, + String contextFingerprint + ) { + requireOpen(); + pendingValidation = new RootValidation( + Objects.requireNonNull(result, "Pack validation result"), + Objects.requireNonNull(contentFingerprint, "Content fingerprint"), + Objects.requireNonNull(contextFingerprint, "Context fingerprint")); + } + public synchronized void commit() { requireOpen(); if (pendingValidation == null) { diff --git a/core/src/main/java/art/arcane/iris/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/pack/PackValidator.java index ef510e75a..881a350cc 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/pack/PackValidator.java @@ -41,6 +41,7 @@ import java.util.Collections; import java.util.IdentityHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; @@ -205,12 +206,15 @@ public final class PackValidator { return findings; } catch (Throwable e) { IrisLogging.reportError("Version-content gating failed for pack '" + packFolder.getName() + "'", e); + blockingErrors.add("Version-content gating failed with " + e.getClass().getSimpleName() + + ": " + Objects.requireNonNullElse(e.getMessage(), "No failure detail.")); return List.of(); } finally { if (data != null) { try { data.close(); - } catch (Throwable ignored) { + } catch (Throwable closeFailure) { + IrisLogging.reportError("Failed to close validation data for pack '" + packFolder.getName() + "'", closeFailure); } } } diff --git a/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java index 62740ea9d..ca8463da6 100644 --- a/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/pack/datapack/ServerConfigurator.java @@ -37,6 +37,7 @@ import art.arcane.iris.pack.datapack.IDataFixer; import art.arcane.iris.pack.AtomicDirectoryPublisher; import art.arcane.iris.pack.DefaultPackBootstrapProvisioner; import art.arcane.iris.pack.PackDirectoryResolver; +import art.arcane.iris.pack.PackFingerprints; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiomeCustom; @@ -57,27 +58,16 @@ import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.io.UncheckedIOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.nio.channels.FileChannel; import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.FileVisitResult; import java.nio.file.Files; -import java.nio.file.LinkOption; import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; -import java.nio.file.attribute.BasicFileAttributes; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.ArrayList; -import java.util.Comparator; -import java.util.HexFormat; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -89,14 +79,11 @@ import java.util.stream.Stream; import art.arcane.iris.localization.BukkitRuntimeMessages; import art.arcane.iris.localization.IrisLanguage; -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"; static final String POST_COMPILE_RESTART_WARNING = "Iris installed updated datapack registry entries; " + "restart the server before creating worlds or opening Studios."; - private static final int FINGERPRINT_BUFFER_BYTES = 64 * 1024; private static volatile boolean loadedDatapackRuntimeReady; private static volatile String loadedDatapackCompilerInputFingerprint = ""; private static volatile Map loadedDatapackRegistryRequirements = Map.of(); @@ -667,166 +654,14 @@ public class ServerConfigurator { } static PackFingerprint resolvePackFingerprint(File packsDir, String cachedMetadata, String cachedContent) { - String metadata = computePackMetadataDigest(packsDir); + String metadata = PackFingerprints.computePackMetadataDigest(packsDir); if (!metadata.isEmpty() && metadata.equals(cachedMetadata) && cachedContent != null && !cachedContent.isEmpty()) { return new PackFingerprint(metadata, cachedContent); } - return new PackFingerprint(metadata, computePackFingerprint(packsDir)); - } - - public static String computePackMetadataDigest(File packsDir) { - Path root = resolveFingerprintRoot(packsDir); - if (root == null) { - return ""; - } - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - List entries = collectFingerprintEntries(root.toRealPath()); - entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); - for (FingerprintEntry entry : entries) { - byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8); - updateDigestInt(digest, relativePath.length); - digest.update(relativePath); - updateDigestLong(digest, entry.size()); - updateDigestLong(digest, entry.lastModifiedMillis()); - } - return HexFormat.of().formatHex(digest.digest()); - } catch (IOException exception) { - throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } - } - - public static String computePackFingerprint(File packsDir) { - return computePackContentSnapshot(packsDir).content(); - } - - public static PackContentSnapshot computePackContentSnapshot(File packsDir) { - Path root = resolveFingerprintRoot(packsDir); - if (root == null) { - return new PackContentSnapshot("", Map.of()); - } - try { - Path resolvedRoot = root.toRealPath(); - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - Map packDigests = new LinkedHashMap<>(); - List entries = collectFingerprintEntries(resolvedRoot); - entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); - byte[] buffer = new byte[FINGERPRINT_BUFFER_BYTES]; - for (FingerprintEntry entry : entries) { - MessageDigest packDigest = entry.packName() == null - ? null - : packDigests.computeIfAbsent(entry.packName(), ignored -> newSha256Digest()); - updateFingerprintEntry(digest, entry.relativePath(), entry.size()); - if (packDigest != null) { - updateFingerprintEntry(packDigest, entry.packRelativePath(), entry.size()); - } - long readBytes = 0L; - try (InputStream input = Files.newInputStream( - entry.source(), - StandardOpenOption.READ, - LinkOption.NOFOLLOW_LINKS)) { - int read; - while ((read = input.read(buffer)) >= 0) { - if (read > 0) { - digest.update(buffer, 0, read); - if (packDigest != null) { - packDigest.update(buffer, 0, read); - } - readBytes += read; - } - } - } - if (readBytes != entry.size()) { - throw new IOException("Iris pack changed while fingerprinting: " + entry.source()); - } - } - Map packContents = new LinkedHashMap<>(); - for (Map.Entry entry : packDigests.entrySet()) { - packContents.put(entry.getKey(), HexFormat.of().formatHex(entry.getValue().digest())); - } - return new PackContentSnapshot( - HexFormat.of().formatHex(digest.digest()), - Map.copyOf(packContents)); - } catch (IOException exception) { - throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } - } - - public static String computePackTreeFingerprint(File packDir) { - Path root = resolveFingerprintRoot(packDir); - if (root == null) { - return ""; - } - try { - List entries = new ArrayList<>(); - collectFingerprintTree(root.toRealPath(), "", null, entries); - entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] buffer = new byte[FINGERPRINT_BUFFER_BYTES]; - for (FingerprintEntry entry : entries) { - updateFingerprintEntry(digest, entry.relativePath(), entry.size()); - long readBytes = 0L; - try (InputStream input = Files.newInputStream( - entry.source(), - StandardOpenOption.READ, - LinkOption.NOFOLLOW_LINKS)) { - int read; - while ((read = input.read(buffer)) >= 0) { - if (read > 0) { - digest.update(buffer, 0, read); - readBytes += read; - } - } - } - if (readBytes != entry.size()) { - throw new IOException("Iris pack changed while fingerprinting: " + entry.source()); - } - } - return HexFormat.of().formatHex(digest.digest()); - } catch (IOException exception) { - throw new UncheckedIOException("Unable to fingerprint Iris pack at " + root, exception); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 not available", exception); - } - } - - private static MessageDigest newSha256Digest() { - try { - return MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 not available", exception); - } - } - - private static void updateFingerprintEntry(MessageDigest digest, String relativePath, long size) { - byte[] relativeBytes = relativePath.getBytes(StandardCharsets.UTF_8); - updateDigestInt(digest, relativeBytes.length); - digest.update(relativeBytes); - updateDigestLong(digest, size); - } - - private static Path resolveFingerprintRoot(File packsDir) { - if (packsDir == null) { - return null; - } - Path root = packsDir.toPath().toAbsolutePath().normalize(); - if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { - return null; - } - if (!Files.isDirectory(root)) { - if (Files.isSymbolicLink(root)) { - throw new IllegalArgumentException("Iris packs root target is missing or unsafe: " + root); - } - return null; - } - return root; + return new PackFingerprint(metadata, PackFingerprints.computePackFingerprint(packsDir)); } private static FingerprintCache readFingerprintCache(Path cacheFile) { @@ -894,132 +729,12 @@ public class ServerConfigurator { } } - private static List collectFingerprintEntries(Path root) throws IOException { - List entries = new ArrayList<>(); - try (Stream children = Files.list(root)) { - for (Path child : children.toList()) { - String childName = child.getFileName().toString(); - if (PackDirectoryResolver.isHiddenName(childName) || isNonContentPackFile(childName)) { - continue; - } - if (Files.isSymbolicLink(child)) { - if (!Files.isDirectory(child)) { - throw new IOException("Iris pack fingerprint rejected symbolic link: " + child); - } - PackDirectoryResolver.requireSafePackTree(child.toFile()); - collectFingerprintTree(child.toRealPath(), childName, childName, entries); - } else if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) { - collectFingerprintTree(child, childName, childName, entries); - } else if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)) { - BasicFileAttributes attributes = Files.readAttributes( - child, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - entries.add(new FingerprintEntry( - child, - childName, - null, - null, - attributes.size(), - attributes.lastModifiedTime().toMillis())); - } else { - throw new IOException("Iris pack fingerprint rejected unsupported entry: " + child); - } - } - } - return entries; - } - - private static void collectFingerprintTree( - Path treeRoot, - String logicalRoot, - String packName, - List entries - ) throws IOException { - Files.walkFileTree(treeRoot, new SimpleFileVisitor<>() { - @Override - public FileVisitResult preVisitDirectory( - Path directory, - BasicFileAttributes attributes - ) { - if (!directory.equals(treeRoot) - && treeRoot.relativize(directory).getNameCount() == 1 - && PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) { - return FileVisitResult.SKIP_SUBTREE; - } - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException { - String fileName = file.getFileName().toString(); - if ((treeRoot.relativize(file).getNameCount() == 1 - && PackDirectoryResolver.isHiddenName(fileName)) - || isNonContentPackFile(fileName)) { - return FileVisitResult.CONTINUE; - } - if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) { - throw new IOException("Iris pack fingerprint rejected symbolic link: " + file); - } - if (!attributes.isRegularFile()) { - throw new IOException("Iris pack fingerprint rejected unsupported entry: " + file); - } - String relative = treeRoot.relativize(file).toString().replace(File.separatorChar, '/'); - String logicalRelative = logicalRoot.isEmpty() ? relative : logicalRoot + "/" + relative; - entries.add(new FingerprintEntry( - file, - logicalRelative, - packName, - packName == null ? null : relative, - attributes.size(), - attributes.lastModifiedTime().toMillis())); - return FileVisitResult.CONTINUE; - } - - @Override - public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { - throw new IOException("Unable to inspect Iris pack entry: " + file, failure); - } - }); - } - - private static boolean isNonContentPackFile(String name) { - return name != null && (name.equals(".DS_Store") || name.endsWith(CODE_WORKSPACE_SUFFIX)); - } - - private record FingerprintEntry( - Path source, - String relativePath, - String packName, - String packRelativePath, - long size, - long lastModifiedMillis - ) { - } - record PackFingerprint(String metadata, String content) { } - public record PackContentSnapshot(String content, Map packContents) { - public PackContentSnapshot { - content = Objects.requireNonNullElse(content, ""); - packContents = Map.copyOf(Objects.requireNonNullElse(packContents, Map.of())); - } - } - private record FingerprintCache(String content, String metadata) { } - private static void updateDigestInt(MessageDigest digest, int value) { - for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { - digest.update((byte) (value >>> shift)); - } - } - - private static void updateDigestLong(MessageDigest digest, long value) { - for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { - digest.update((byte) (value >>> shift)); - } - } - public static File resolveDatapacksFolder(File worldFolder) { File rootFolder = resolveWorldRootFolder(worldFolder); return new File(rootFolder, "datapacks"); diff --git a/core/src/main/java/art/arcane/iris/pack/loading/ImageResourceLoader.java b/core/src/main/java/art/arcane/iris/pack/loading/ImageResourceLoader.java index e5905df7f..e43dafeee 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/ImageResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/ImageResourceLoader.java @@ -142,7 +142,7 @@ public class ImageResourceLoader extends ResourceLoader { for (File file : listedFiles) { if (file.isFile() && file.getName().endsWith(".png")) { - m.add(prefix + file.getName().replace(".png", "")); + m.add(prefix + file.getName().substring(0, file.getName().length() - ".png".length())); } else if (file.isDirectory()) { getPNGFiles(file, prefix + file.getName() + "/", m, visitedDirectories); } diff --git a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java index 614c765d5..23fb8fff5 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java @@ -867,29 +867,18 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public String toLoadKey(File f) { - if (f.getPath().startsWith(getDataFolder().getPath())) { - String[] full = f.getPath().split("\\Q" + File.separator + "\\E"); - String[] df = getDataFolder().getPath().split("\\Q" + File.separator + "\\E"); - StringBuilder g = new StringBuilder(); - boolean m = true; - for (int i = 0; i < full.length; i++) { - if (i >= df.length) { - if (m) { - m = false; - continue; - } - - g.append("/").append(full[i]); - } + Path root = getDataFolder().toPath().toAbsolutePath().normalize(); + Path resource = f.toPath().toAbsolutePath().normalize(); + if (resource.startsWith(root)) { + Path relative = root.relativize(resource); + if (relative.getNameCount() > 1) { + String key = relative.subpath(1, relative.getNameCount()).toString().replace(File.separatorChar, '/'); + int extension = key.lastIndexOf('.'); + return extension > key.lastIndexOf('/') ? key.substring(0, extension) : key; } - - return g.substring(1).split("\\Q.\\E")[0]; - } else { - IrisLogging.error("Forign file from loader " + f.getPath() + " (loader realm: " + getDataFolder().getPath() + ")"); } IrisLogging.error("Failed to load " + f.getPath() + " (loader realm: " + getDataFolder().getPath() + ")"); - return null; } @@ -976,7 +965,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { .filter(s -> s.endsWith(".json")) .map(s -> s.substring(absPath.length() + 1)) .map(s -> s.replace("\\", "/")) - .map(s -> s.split("\\Q.\\E")[0]) + .map(s -> s.substring(0, s.length() - ".json".length())) .forEach(s -> l.add("snippet/" + s)); } catch (Throwable e) { IrisLogging.reportError("Failed to scan Iris snippets in " + snippetFolder + ".", e); diff --git a/core/src/main/java/art/arcane/iris/pack/loading/MatterObjectResourceLoader.java b/core/src/main/java/art/arcane/iris/pack/loading/MatterObjectResourceLoader.java index c15ee8c5e..2c5195678 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/MatterObjectResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/MatterObjectResourceLoader.java @@ -83,7 +83,7 @@ public class MatterObjectResourceLoader extends ResourceLoader for (File file : listedFiles) { if (file.isFile() && file.getName().endsWith(".mat")) { - m.add(prefix + file.getName().replace(".mat", "")); + m.add(prefix + file.getName().substring(0, file.getName().length() - ".mat".length())); } else if (file.isDirectory()) { findMatFiles(file, prefix + file.getName() + "/", m, visitedDirectories); } diff --git a/core/src/main/java/art/arcane/iris/pack/loading/ObjectResourceLoader.java b/core/src/main/java/art/arcane/iris/pack/loading/ObjectResourceLoader.java index 54a594f45..c11320af1 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/ObjectResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/ObjectResourceLoader.java @@ -106,7 +106,7 @@ public class ObjectResourceLoader extends ResourceLoader { String name = skipDirName ? "" : dir.getName() + "/"; for (File f : listedFiles) { if (f.isFile() && f.getName().endsWith(ext)) { - paths.add(name + f.getName().replaceAll("\\Q" + ext + "\\E", "")); + paths.add(name + f.getName().substring(0, f.getName().length() - ext.length())); } else if (f.isDirectory()) { getFiles(f, ext, false, visitedDirectories).forEach(e -> paths.add(name + e)); } diff --git a/core/src/main/java/art/arcane/iris/pack/loading/ResourceLoader.java b/core/src/main/java/art/arcane/iris/pack/loading/ResourceLoader.java index cc78f09a1..826585f06 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/ResourceLoader.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/ResourceLoader.java @@ -397,7 +397,8 @@ public class ResourceLoader implements MeteredCache { HashSet m = new HashSet<>(); for (File i : files) { for (File j : matchAllFiles(i, (f) -> f.getName().endsWith(".json"))) { - m.add(i.toURI().relativize(j.toURI()).getPath().replace(".json", "")); + String path = i.toURI().relativize(j.toURI()).getPath(); + m.add(path.substring(0, path.length() - ".json".length())); } } @@ -767,11 +768,6 @@ public class ResourceLoader implements MeteredCache { return loadCache.getSize(); } - @Override - public KCache getRawCache() { - return loadCache; - } - @Override public long getMaxSize() { return loadCache.getMaxSize(); diff --git a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java index 343999d8b..95614dfc4 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java @@ -831,7 +831,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override protected long loop() { Engine activeEngine = engine; - if (activeEngine instanceof IrisEngine irisEngine + if (initialEntryPending || activeEngine instanceof IrisEngine irisEngine && irisEngine.isGenerationCacheWarmPending()) { return HOTLOAD_LOOP_DELAY_MS; } @@ -995,8 +995,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public synchronized void beginInitialEntry(boolean playerEntry) { - if (studio || closing || engine != null) { - throw new IllegalStateException("Initial world entry must begin before a normal generator initializes."); + if (closing || engine != null) { + throw new IllegalStateException("Initial world entry must begin before the generator initializes."); } initialEntryPending = true; initialPlayerEntryPending = playerEntry; @@ -1071,7 +1071,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public void hotload() { - if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { return; } @@ -1079,18 +1079,18 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private void hotloadFromWatcher() { - if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { return; } CompletableFuture pending = new CompletableFuture<>(); watcherHotload.set(pending); try { - if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive) + if (initialEntryPending || !shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive) || Thread.currentThread().isInterrupted()) { pending.cancel(true); } else { J.a(() -> completeExclusiveControlFuture(loadLock, () -> { - if (shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { + if (!initialEntryPending && shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) { getEngine().hotload(); } }, pending)); diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisAxisRotationClamp.java b/core/src/main/java/art/arcane/iris/structure/object/IrisAxisRotationClamp.java index 88e3f89f6..63a609790 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisAxisRotationClamp.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisAxisRotationClamp.java @@ -60,7 +60,7 @@ public class IrisAxisRotationClamp { @DependsOn({"min", "max"}) @MinNumber(0) @MaxNumber(360) - @Description("Iris spins the axis but not freely. For example an interval of 90 would mean 4 possible angles (right angles) degrees. \nSetting this to 0 means totally free rotation.\n\nNote that a lot of structures can have issues with non 90 degree intervals because the minecraft block resolution is so low.") + @Description("Iris spins the axis but not freely. For example an interval of 90 would mean 4 possible angles (right angles) degrees. \nA zero interval resolves to a one-degree rotation step. Unlimited rotation also resolves positive intervals below 1 to one degree.\n\nNote that a lot of structures can have issues with non 90 degree intervals because the minecraft block resolution is so low.") private double interval = 0; public void minMax(double fd) { @@ -79,7 +79,7 @@ public class IrisAxisRotationClamp { public double getRadians(int rng) { if (forceLock) { - return Math.toRadians(Math.ceil(Math.abs((max % 360D)))); + return Math.toRadians(max); } if (isUnlimited()) { @@ -91,6 +91,7 @@ public class IrisAxisRotationClamp { return Math.toRadians(max); } - return Math.toRadians(M.clip((interval * (Math.ceil(Math.abs((rng % 360D) / interval)))) % 360D, Math.min(min, max), Math.max(min, max))); + double resolvedInterval = interval <= 0 ? 1 : interval; + return Math.toRadians(M.clip((resolvedInterval * (Math.ceil(Math.abs((rng % 360D) / resolvedInterval)))) % 360D, Math.min(min, max), Math.max(min, max))); } } diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectIO.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectIO.java index 3878660d9..ef3190663 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectIO.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectIO.java @@ -28,7 +28,6 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.generation.block.B; import art.arcane.iris.generation.geometry.IrisBlockVector; -import art.arcane.volmlib.util.math.Vector3i; import art.arcane.iris.platform.bukkit.plugin.VolmitSender; import art.arcane.iris.world.task.jobs.Job; import art.arcane.volmlib.util.collection.KList; @@ -42,12 +41,22 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicReference; /** @@ -128,79 +137,108 @@ public final class IrisObjectIO { } static void readLegacy(IrisObject self, InputStream in) throws IOException { - self.surfaceSupportOffsets.reset(); - self.floatingFootprint.reset(); - DataInputStream din = new DataInputStream(in); - self.w = din.readInt(); - self.h = din.readInt(); - self.d = din.readInt(); - self.center = new Vector3i(self.w / 2, self.h / 2, self.d / 2); - int s = din.readInt(); - - for (int i = 0; i < s; i++) { - IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()); - NativeBlockState data = resolvePaletteState(self, din.readUTF()); - if (isExcludedObjectBlock(data)) { - continue; + DataInputStream input = new DataInputStream(in); + IrisObject parsed = readDimensions(input); + int blocks = requireNonnegativeCount(input.readInt(), "block"); + for (int index = 0; index < blocks; index++) { + IrisBlockVector position = new IrisBlockVector(input.readShort(), input.readShort(), input.readShort()); + NativeBlockState state = resolvePaletteState(self, input.readUTF()); + if (!isExcludedObjectBlock(state)) { + parsed.blocks.put(position, state); } - self.blocks.put(pos, data); } - if (din.available() == 0) - return; - - try { - int size = din.readInt(); - - for (int i = 0; i < size; i++) { - readTile(self, din); + int firstByte = input.read(); + if (firstByte != -1) { + int tiles = requireNonnegativeCount((firstByte << 24) + | (input.readUnsignedByte() << 16) + | (input.readUnsignedByte() << 8) + | input.readUnsignedByte(), "tile"); + for (int index = 0; index < tiles; index++) { + readTile(parsed, input); } - } catch (Throwable e) { - IrisLogging.reportError(e); } + publishRead(self, parsed); } - static void read(IrisObject self, InputStream in) throws Throwable { - self.surfaceSupportOffsets.reset(); - self.floatingFootprint.reset(); - DataInputStream din = new DataInputStream(in); - self.w = din.readInt(); - self.h = din.readInt(); - self.d = din.readInt(); - if (!din.readUTF().equals("Iris V2 IOB;")) { - throw new HeaderException(); + static void read(IrisObject self, InputStream in) throws IOException { + DataInputStream input = new DataInputStream(in); + IrisObject parsed = readDimensions(input); + if (!V2_HEADER.equals(input.readUTF())) { + throw new IOException("Invalid object header"); } - self.center = new Vector3i(self.w / 2, self.h / 2, self.d / 2); - int s = din.readShort(); - int i; + int paletteSize = requireNonnegativeCount(input.readShort(), "palette"); KList palette = new KList<>(); - - for (i = 0; i < s; i++) { - palette.add(din.readUTF()); + for (int index = 0; index < paletteSize; index++) { + palette.add(input.readUTF()); } // Resolve the palette once: B.getState per BLOCK was a registry lookup times the // block count (tens of thousands) instead of times the palette size (hundreds). NativeBlockState[] resolved = new NativeBlockState[palette.size()]; - for (i = 0; i < resolved.length; i++) { - resolved[i] = resolvePaletteState(self, palette.get(i)); + for (int index = 0; index < resolved.length; index++) { + resolved[index] = resolvePaletteState(self, palette.get(index)); } - s = din.readInt(); - - for (i = 0; i < s; i++) { - IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()); - NativeBlockState data = resolved[din.readShort()]; - if (isExcludedObjectBlock(data)) { - continue; + int blocks = requireNonnegativeCount(input.readInt(), "block"); + for (int index = 0; index < blocks; index++) { + IrisBlockVector position = new IrisBlockVector(input.readShort(), input.readShort(), input.readShort()); + int paletteIndex = input.readShort(); + if (paletteIndex < 0 || paletteIndex >= resolved.length) { + throw new IOException("Invalid object palette index " + paletteIndex); + } + NativeBlockState state = resolved[paletteIndex]; + if (!isExcludedObjectBlock(state)) { + parsed.blocks.put(position, state); } - self.blocks.put(pos, data); } - s = din.readInt(); + int tiles = requireNonnegativeCount(input.readInt(), "tile"); + for (int index = 0; index < tiles; index++) { + readTile(parsed, input); + } + publishRead(self, parsed); + } - for (i = 0; i < s; i++) { - readTile(self, din); + private static IrisObject readDimensions(DataInputStream input) throws IOException { + int width = input.readInt(); + int height = input.readInt(); + int depth = input.readInt(); + requireValidDimensions(width, height, depth); + return new IrisObject(width, height, depth); + } + + private static void requireValidDimensions(int width, int height, int depth) throws IOException { + if (width < 1 || height < 1 || depth < 1) { + throw new IOException("Invalid object dimensions " + width + "x" + height + "x" + depth); + } + } + + private static int requireNonnegativeCount(int count, String kind) throws IOException { + if (count < 0) { + throw new IOException("Invalid object " + kind + " count " + count); + } + return count; + } + + private static void publishRead(IrisObject self, IrisObject parsed) { + // A mid-file failure must not leave parsed entries merged into the previous object contents. + self.writeLock.lock(); + try { + self.w = parsed.w; + self.h = parsed.h; + self.d = parsed.d; + self.center = parsed.center; + self.shrinkOffset = parsed.shrinkOffset; + self.blocks = parsed.blocks; + self.states = parsed.states; + self.smartBored = false; + self.smartBoreVariant = null; + self.aabb.reset(); + self.surfaceSupportOffsets.reset(); + self.floatingFootprint.reset(); + } finally { + self.writeLock.unlock(); } } @@ -213,21 +251,30 @@ public final class IrisObjectIO { } static void read(IrisObject self, File file) throws IOException { - try (var fin = new BufferedInputStream(new FileInputStream(file))) { - read(self, fin); - } catch (Throwable e) { - if (!(e instanceof HeaderException)) - IrisLogging.reportError(e); - // The V2 parse populates blocks/states incrementally; a mid-file failure must not - // leave those entries to be merged with the legacy parse of the same file. - self.blocks.clear(); - self.states.clear(); - try (var fin = new BufferedInputStream(new FileInputStream(file))) { - readLegacy(self, fin); + try (BufferedInputStream input = new BufferedInputStream(new FileInputStream(file))) { + if (hasV2Header(input)) { + read(self, input); + } else { + readLegacy(self, input); } } } + private static boolean hasV2Header(BufferedInputStream input) throws IOException { + input.mark(3 * Integer.BYTES + Short.BYTES + V2_HEADER.length()); + try { + DataInputStream header = new DataInputStream(input); + header.readInt(); + header.readInt(); + header.readInt(); + int length = header.readUnsignedShort(); + return length == V2_HEADER.length() + && V2_HEADER.equals(new String(header.readNBytes(length), StandardCharsets.US_ASCII)); + } finally { + input.reset(); + } + } + /** * Palette key to state. A key the server does not have goes through the pack's dimension {@code blockFallbacks} * before the plain lookup, so a declared fallback actually reaches the world instead of becoming air. Present @@ -276,15 +323,16 @@ public final class IrisObjectIO { * write path now rejects them with a descriptive error before any byte is written. */ static void validateWritable(IrisObject self) throws IOException { + requireValidDimensions(self.w, self.h, self.d); Palette palette = buildPalette(self); if (palette.size() > MAX_PALETTE_ENTRIES) { throw new IOException("Object '" + self.getLoadKey() + "' has " + palette.size() + " distinct block states; the .iob format supports at most " + MAX_PALETTE_ENTRIES + "."); } - for (var entry : self.blocks) { + for (Map.Entry entry : self.blocks) { requireShortCoordinates(self, "block", entry.getKey()); } - for (var entry : self.states) { + for (Map.Entry entry : self.states) { requireShortCoordinates(self, "tile", entry.getKey()); } } @@ -345,9 +393,8 @@ public final class IrisObjectIO { } private static void writeValidated(IrisObject self, OutputStream o, VolmitSender sender) throws IOException { - AtomicReference ref = new AtomicReference<>(); - CountDownLatch latch = new CountDownLatch(1); - new Job() { + AtomicReference ref = new AtomicReference<>(); + CompletableFuture completion = new Job() { private volatile int total = self.blocks.size() * 3 + self.states.size(); private volatile int c = 0; @@ -385,10 +432,8 @@ public final class IrisObjectIO { writeState(dos, entry); ++c; } - } catch (IOException e) { - ref.set(e); - } finally { - latch.countDown(); + } catch (Throwable failure) { + ref.set(failure); } } @@ -407,13 +452,26 @@ public final class IrisObjectIO { }.execute(sender, true, () -> {}); try { - latch.await(); + completion.get(); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IOException("Interrupted while writing object", interrupted); + } catch (ExecutionException rejected) { + ref.compareAndSet(null, rejected.getCause()); + } + Throwable failure = ref.get(); + if (failure instanceof IOException ioFailure) { + throw ioFailure; + } + if (failure instanceof RuntimeException runtimeFailure) { + throw runtimeFailure; + } + if (failure instanceof Error error) { + throw error; + } + if (failure != null) { + throw new IOException("Object save work failed", failure); } - if (ref.get() != null) - throw ref.get(); } private static void writeHeader(IrisObject self, DataOutputStream output) throws IOException { @@ -446,12 +504,9 @@ public final class IrisObjectIO { return; } - // Validate before opening the stream: FileOutputStream truncates, and a rejected - // object must leave the existing .iob untouched. + // Validate before staging output: a rejected object must leave the existing .iob untouched. validateWritable(self); - try (FileOutputStream out = new FileOutputStream(file)) { - writeValidated(self, out); - } + writeAtomically(file, out -> writeValidated(self, out)); } static void write(IrisObject self, File file, VolmitSender sender) throws IOException { @@ -460,15 +515,49 @@ public final class IrisObjectIO { } validateWritable(self); - try (FileOutputStream out = new FileOutputStream(file)) { - writeValidated(self, out, sender); + writeAtomically(file, out -> writeValidated(self, out, sender)); + } + + private static void writeAtomically(File file, ObjectWriter writer) throws IOException { + Path target = resolveWriteTarget(file); + Path parent = target.getParent(); + boolean posix = Files.getFileStore(parent).supportsFileAttributeView(PosixFileAttributeView.class); + Set permissions = posix && Files.exists(target) + ? Files.getPosixFilePermissions(target) : null; + Path temporary = posix + ? Files.createTempFile(parent, ".iris-object-", ".tmp", + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-rw-rw-"))) + : Files.createTempFile(parent, ".iris-object-", ".tmp"); + try { + if (permissions != null) { + Files.setPosixFilePermissions(temporary, permissions); + } + try (FileOutputStream output = new FileOutputStream(temporary.toFile())) { + writer.write(output); + } + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); } } - private static class HeaderException extends IOException { - public HeaderException() { - super("Invalid Header"); + private static Path resolveWriteTarget(File file) throws IOException { + Path target = file.toPath().toAbsolutePath(); + target = target.getParent().toRealPath().resolve(target.getFileName()); + Set visited = new HashSet<>(); + while (Files.isSymbolicLink(target)) { + if (!visited.add(target)) { + throw new IOException("Object destination contains a symbolic-link cycle: " + file); + } + Path link = Files.readSymbolicLink(target); + target = link.isAbsolute() ? link : target.getParent().resolve(link); + target = target.getParent().toRealPath().resolve(target.getFileName()); } + return target; + } + + private interface ObjectWriter { + void write(OutputStream output) throws IOException; } private static final class Palette { diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java index 541eed333..1819361ea 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementRunner.java @@ -622,7 +622,7 @@ final class IrisObjectPlacementRunner { continue; } - data = IrisProceduralBlocks.normalizeWaterlogging(data, placer.get(xx, yy, zz), waterlogCandidate); + data = normalizePlacedWaterlogging(data, placer, xx, yy, zz, waterlogCandidate); if (!rawStructurePiece && B.isVineBlock(data)) { data = attachVineFaces(placer, data, xx, yy, zz); @@ -822,7 +822,7 @@ final class IrisObjectPlacementRunner { } } } - placer.set(xx, j, zz, IrisProceduralBlocks.normalizeWaterlogging(d, placer.get(xx, j, zz), waterlogCandidate)); + placer.set(xx, j, zz, normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); } } else { int scan = 0; @@ -844,7 +844,7 @@ final class IrisObjectPlacementRunner { } } } - placer.set(xx, j, zz, IrisProceduralBlocks.normalizeWaterlogging(d, placer.get(xx, j, zz), waterlogCandidate)); + placer.set(xx, j, zz, normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); } } continue; @@ -892,7 +892,7 @@ final class IrisObjectPlacementRunner { if (B.isVineBlock(d)) { d = attachVineFaces(placer, d, xx, j, zz); } - placer.set(xx, j, zz, IrisProceduralBlocks.normalizeWaterlogging(d, placer.get(xx, j, zz), waterlogCandidate)); + placer.set(xx, j, zz, normalizePlacedWaterlogging(d, placer, xx, j, zz, waterlogCandidate)); } } @@ -930,6 +930,14 @@ final class IrisObjectPlacementRunner { return !wouldReplace && (rawStructurePiece || !air); } + private static NativeBlockState normalizePlacedWaterlogging(NativeBlockState state, IObjectPlacer placer, + int x, int y, int z, boolean enabled) { + if (state == null || !IrisProceduralBlocks.hasProperty(state, "waterlogged")) { + return state; + } + return IrisProceduralBlocks.normalizeWaterlogging(state, placer.get(x, y, z), enabled); + } + private KList transformedPaintSupport(SpinKernel spin, IrisBlockVector translateOffset, CNG warp, double half, int x, int y, int z) { KList offsets = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java index 444abbb35..ebd07b42c 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java @@ -33,12 +33,10 @@ import art.arcane.volmlib.util.interpolation.Interpolation3D; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; -import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; /** * Geometric transforms for {@link IrisObject}: rotation, scaling and the interpolated upscalers. @@ -57,22 +55,20 @@ final class IrisObjectTransforms { self.writeLock.lock(); try { VectorMap d = new VectorMap<>(); - Set omitted = new HashSet<>(); + VectorMap dx = new VectorMap<>(); for (Map.Entry entry : self.blocks) { NativeBlockState rotated = r.rotate(entry.getValue(), spinx, spiny, spinz); if (rotated == null) { - omitted.add(entry.getKey()); continue; } - d.put(r.rotate(entry.getKey(), spinx, spiny, spinz), rotated); - } - - VectorMap dx = new VectorMap<>(); - - for (Map.Entry entry : self.states) { - if (!omitted.contains(entry.getKey())) { - dx.put(r.rotate(entry.getKey(), spinx, spiny, spinz), entry.getValue()); + IrisBlockVector destination = r.rotate(entry.getKey(), spinx, spiny, spinz); + d.put(destination, rotated); + TileData tile = self.states.get(entry.getKey()); + if (tile == null) { + dx.remove(destination); + } else { + dx.put(destination, tile); } } diff --git a/core/src/main/java/art/arcane/iris/structure/placement/StructureCaveAnchorResolver.java b/core/src/main/java/art/arcane/iris/structure/placement/StructureCaveAnchorResolver.java index fe16c072b..4f282fd0d 100644 --- a/core/src/main/java/art/arcane/iris/structure/placement/StructureCaveAnchorResolver.java +++ b/core/src/main/java/art/arcane/iris/structure/placement/StructureCaveAnchorResolver.java @@ -91,9 +91,10 @@ public final class StructureCaveAnchorResolver { } List anchors = new ArrayList<>(); int step = Math.max(1, scanStep); + IntPredicate carved = y -> isCarved(engine, blockX, y, blockZ); for (int worldY = minimumY; worldY <= maximumY; worldY += step) { int mantleY = toMantleY(worldY, engine.getMinHeight()); - if (!matchesGeometry(engine, mode, blockX, mantleY, blockZ, clearance)) { + if (!matchesGeometry(carved, mode, mantleY, clearance)) { continue; } if (!matchesAnchorFluid(engine, placement, blockX, mantleY, blockZ)) { @@ -172,22 +173,6 @@ public final class StructureCaveAnchorResolver { } } - 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, @@ -289,7 +274,11 @@ public final class StructureCaveAnchorResolver { int blockZ ) { HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); - MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ); + if (hydrology != null && hydrology.protectsPlacement()) { + return false; + } + MatterCavern cavern = engine.getMantle().getMantle() + .get(blockX, mantleY, blockZ, MatterCavern.class); return acceptsAnchorFluid( placement.isUnderwater(), cavern, diff --git a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java index 9ebc47227..22a3ce07d 100644 --- a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java @@ -18,6 +18,8 @@ package art.arcane.iris.studio; +import art.arcane.iris.pack.PackFingerprints; + import art.arcane.iris.studio.jigsaw.JigsawStudioService; import art.arcane.iris.spi.IrisLogging; @@ -37,6 +39,7 @@ import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDownloadExecution; import art.arcane.iris.pack.PackDownloader; import art.arcane.iris.pack.PackValidationRegistry; +import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidator; import art.arcane.iris.studio.workspace.IrisProject; @@ -400,7 +403,7 @@ public class StudioSVC implements IrisService { } static IrisDimension loadInstalledDimension(Path packRoot, Path validatedSource, String dimensionKey) throws IOException { - String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(packRoot.toFile()); + String copiedFingerprint = PackFingerprints.computePackTreeFingerprint(packRoot.toFile()); PackValidationResult validation = validatePublishedPack(packRoot, validatedSource, copiedFingerprint); if (!validation.isLoadable()) { throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); @@ -468,7 +471,7 @@ public class StudioSVC implements IrisService { requireSafePublicationTarget(target, replaceExisting); publication = AtomicDirectoryPublisher.publish(stage, target); stage = null; - String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile()); + String copiedFingerprint = PackFingerprints.computePackTreeFingerprint(target.toFile()); PackValidationResult publishedValidation = validatePublishedPack(target, source, copiedFingerprint, validationMutation); if (!publishedValidation.isLoadable()) { @@ -559,17 +562,40 @@ public class StudioSVC implements IrisService { static PackValidationResult validatePublishedPack(Path packRoot) { try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(packRoot)) { - PackValidationResult result = PackValidator.validate(packRoot.toFile()); - mutation.stage(result); - mutation.commit(); - return PackValidationRegistry.requireLoadable(packRoot); + return validateAndPublishSnapshot(packRoot, + PackFingerprints.computePackTreeFingerprint(packRoot.toFile()), mutation); } } + private static PackValidationResult validatePublishedPack(Path packRoot, String contentFingerprint) { + try (PackValidationRegistry.RootMutation mutation = + PackValidationRegistry.beginRootMutation(packRoot)) { + return validateAndPublishSnapshot(packRoot, contentFingerprint, mutation); + } + } + + private static PackValidationResult validateAndPublishSnapshot( + Path packRoot, + String contentFingerprint, + PackValidationRegistry.RootMutation mutation + ) { + String contextFingerprint = PackValidationCache.contextFingerprint(); + PackValidationResult result = PackValidator.validate(packRoot.toFile()); + if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packRoot.toFile()))) { + throw new IllegalStateException("Iris pack changed during semantic validation: " + packRoot); + } + if (!contextFingerprint.equals(PackValidationCache.contextFingerprint())) { + throw new IllegalStateException("Iris validation registry context changed during semantic validation: " + packRoot); + } + mutation.stageValidatedSnapshot(result, contentFingerprint, contextFingerprint); + mutation.commit(); + return PackValidationRegistry.requireLoadable(packRoot); + } + static PackValidationResult validateGenerationCandidate(Path packRoot, String contentFingerprint) { PackValidationResult validation = PackValidationRegistry.getMatching(packRoot, contentFingerprint); if (validation == null) { - return validatePublishedPack(packRoot); + return validatePublishedPack(packRoot, contentFingerprint); } if (!validation.isLoadable()) { throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); diff --git a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java index 95985aeac..c3cd6daf1 100644 --- a/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/studio/object/StudioOpenCoordinator.java @@ -247,8 +247,18 @@ public final class StudioOpenCoordinator { IrisLogging.debug("Studio open: " + world.getName() + " ready in " + elapsedMillis(openStart) + "ms"); + provider.completeInitialEntry(); future.complete(new StudioOpenResult(world, entryLocation)); } catch (Throwable e) { + if (provider != null) { + try { + provider.completeInitialEntry(); + } catch (Throwable completionFailure) { + if (completionFailure != e) { + e.addSuppressed(completionFailure); + } + } + } if (entryBootstrap != null) { entryBootstrap.handle((ignored, failure) -> null).join(); } @@ -440,12 +450,18 @@ public final class StudioOpenCoordinator { )); } return closeFuture.whenComplete((result, throwable) -> { - if (throwable == null && result.failureCause() == null) { + Throwable failure = throwable != null ? unwrapFailure(throwable) + : result == null ? new IllegalStateException("Studio cleanup completed without a result.") + : result.failureCause(); + if (failure == null) { lease.close(); } else { - IrisLogging.error("Studio cleanup remains incomplete for \"" + operationTarget - + "\". Further Iris world operations are blocked. Resolve the reported failure " - + "and restart the server manually before opening another world."); + IrisLogging.reportError("Studio cleanup remains incomplete for \"" + operationTarget + + "\" (unloadCompletedLive=" + (result != null && result.unloadCompletedLive()) + + ", folderDeletionCompletedLive=" + (result != null && result.folderDeletionCompletedLive()) + + ", startupCleanupQueued=" + (result != null && result.startupCleanupQueued()) + + "). Further Iris world operations are blocked. Resolve the reported failure " + + "and restart the server manually before opening another world.", failure); } }).copy(); } diff --git a/core/src/main/java/art/arcane/iris/world/IrisCreator.java b/core/src/main/java/art/arcane/iris/world/IrisCreator.java index 2040c60a9..69e8ef12c 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/world/IrisCreator.java @@ -71,6 +71,7 @@ import java.nio.file.Files; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; @@ -78,6 +79,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.IntSupplier; @@ -244,6 +246,7 @@ public class IrisCreator { World world = null; boolean bukkitRegistered = false; PlatformChunkGenerator stagedGenerator = null; + boolean studioEntryTransferred = false; try { reportStudioProgress(0.08D, "resolve_dimension"); reportStudioProgress(0.16D, "prepare_world_pack"); @@ -309,7 +312,7 @@ public class IrisCreator { throw new IrisException("Access is null. Something bad happened."); } stagedGenerator = access; - if (!studio && !benchmark) { + if (!benchmark) { access.beginInitialEntry(sender != null && sender.isPlayer()); } AtomicInteger createProgressTask = startCreateProgressReporter(access, done, creationReporter); @@ -386,7 +389,9 @@ public class IrisCreator { } reportCreationProgress(creationReporter, 0.92D, "teleport_player"); awaitSenderTeleport(world); - access.completeInitialEntry(); + if (!studio) { + access.completeInitialEntry(); + } if (pregen != null) { CompletableFuture ff = new CompletableFuture<>(); @@ -415,6 +420,7 @@ public class IrisCreator { } } reportCreationProgress(creationReporter, 0.99D, "finalize"); + studioEntryTransferred = studio; return world; } catch (Throwable failure) { rollbackWorldCreation(worldKey, world, stagedGenerator, storageRoot, bukkitRegistered, failure); @@ -423,9 +429,13 @@ public class IrisCreator { } throw new IrisException("Failed to create world \"" + name + "\".", failure); } finally { - if (stagedGenerator != null) { - stagedGenerator.completeInitialEntry(); - } + completeCreationEntry(stagedGenerator, studioEntryTransferred); + } + } + + static void completeCreationEntry(PlatformChunkGenerator generator, boolean studioEntryTransferred) { + if (generator != null && !studioEntryTransferred) { + generator.completeInitialEntry(); } } @@ -452,15 +462,40 @@ public class IrisCreator { int chunkX = entryAnchor.getBlockX() >> 4; int chunkZ = entryAnchor.getBlockZ() >> 4; - return requiredRuntime.requestChunkAsync(requiredWorld, chunkX, chunkZ, true) - .thenCompose(chunk -> requiredRuntime.resolveSafeEntry(requiredWorld, entryAnchor)) + CompletableFuture result = new CompletableFuture<>(); + AtomicReference> activeTeleport = new AtomicReference<>(); + result.whenComplete((ignored, failure) -> { + CompletableFuture teleport = activeTeleport.get(); + if (result.isCancelled() && teleport != null) { + teleport.cancel(false); + } + }); + requiredRuntime.requestChunkAsync(requiredWorld, chunkX, chunkZ, true) + .thenCompose(chunk -> result.isCancelled() + ? CompletableFuture.failedFuture(new CancellationException()) + : requiredRuntime.resolveSafeEntry(requiredWorld, entryAnchor)) .thenCompose(safeEntry -> { + if (result.isCancelled()) { + return CompletableFuture.failedFuture(new CancellationException()); + } if (safeEntry == null) { return CompletableFuture.failedFuture(new IllegalStateException( "Unable to resolve a safe entry for world \"" + requiredWorld.getName() + "\".")); } - return requiredRuntime.teleport(requiredPlayer, safeEntry); + CompletableFuture teleport = requiredRuntime.teleport(requiredPlayer, safeEntry); + activeTeleport.set(teleport); + if (result.isCancelled()) { + teleport.cancel(false); + } + return teleport; + }).whenComplete((teleported, failure) -> { + if (failure == null) { + result.complete(teleported); + } else { + result.completeExceptionally(unwrapFailure(failure)); + } }); + return result; } private void awaitSenderTeleport(World world) { @@ -752,7 +787,12 @@ public class IrisCreator { try { stagedGenerator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (Throwable rollbackFailure) { - failure.addSuppressed(unwrapFailure(rollbackFailure)); + Throwable cause = unwrapFailure(rollbackFailure); + failure.addSuppressed(cause); + safeToDelete = false; + if (cause instanceof TimeoutException) { + ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\"."); + } } } if (activeWorld != null) { diff --git a/core/src/main/java/art/arcane/iris/world/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/world/IrisToolbelt.java index ee2433be6..800829ad4 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/world/IrisToolbelt.java @@ -524,8 +524,8 @@ public class IrisToolbelt { } }; try { - if (!J.runEntity(player, teleportTask)) { - teleportTask.run(); + if (!J.runEntity(player, teleportTask, 0, () -> evacuation.complete(false))) { + evacuation.complete(false); } } catch (Throwable failure) { evacuation.completeExceptionally(failure); diff --git a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java index 4ffc516bb..8b04a2bfa 100644 --- a/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/world/IrisWorldStorage.java @@ -1,6 +1,7 @@ package art.arcane.iris.world; import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.pack.AtomicDirectoryPublisher; import art.arcane.volmlib.util.bukkit.WorldIdentity; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; @@ -18,14 +19,11 @@ import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.Properties; -import java.util.Set; import java.util.stream.Stream; public final class IrisWorldStorage { private static final String IRIS_NAMESPACE = "iris"; private static final String DEFAULT_LEVEL_NAME = "world"; - private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini"); - private static final String APPLE_DOUBLE_PREFIX = "._"; /** * Server#getLevelDirectory is Paper-API-only. Once a call throws NoSuchMethodError (plain @@ -352,23 +350,12 @@ public final class IrisWorldStorage { return true; } try (Stream entries = Files.list(irisRoot)) { - return entries.anyMatch(entry -> !isOperatingSystemMetadata(entry)); + return entries.anyMatch(entry -> !AtomicDirectoryPublisher.isOperatingSystemMetadata(entry)); } catch (IOException unreadable) { return true; } } - private static boolean isOperatingSystemMetadata(Path entry) { - if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { - return false; - } - Path name = entry.getFileName(); - if (name == null) { - return false; - } - String fileName = name.toString(); - return OS_METADATA_FILES.contains(fileName) || fileName.startsWith(APPLE_DOUBLE_PREFIX); - } /** * True when the level root holds at least one Iris-managed world directory. Used by the failure paths diff --git a/core/src/main/java/art/arcane/iris/world/lifecycle/WorldUnloadBoundaryRegistry.java b/core/src/main/java/art/arcane/iris/world/lifecycle/WorldUnloadBoundaryRegistry.java index 4670c01ec..2cc577e91 100644 --- a/core/src/main/java/art/arcane/iris/world/lifecycle/WorldUnloadBoundaryRegistry.java +++ b/core/src/main/java/art/arcane/iris/world/lifecycle/WorldUnloadBoundaryRegistry.java @@ -4,6 +4,7 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; public final class WorldUnloadBoundaryRegistry { private static final ConcurrentHashMap ACTIVE = new ConcurrentHashMap<>(); @@ -13,7 +14,7 @@ public final class WorldUnloadBoundaryRegistry { static Boundary begin(String worldIdentity) { String requiredIdentity = Objects.requireNonNull(worldIdentity, "world identity"); - Boundary boundary = new Boundary(requiredIdentity, new CompletableFuture<>()); + Boundary boundary = new Boundary(requiredIdentity, new CompletableFuture<>(), new AtomicBoolean()); Boundary existing = ACTIVE.putIfAbsent(requiredIdentity, boundary); if (existing != null) { throw new IllegalStateException("World unload is already active for " + requiredIdentity + "."); @@ -22,8 +23,8 @@ public final class WorldUnloadBoundaryRegistry { } public static CompletionStage claim(String worldIdentity) { - Boundary boundary = ACTIVE.remove(Objects.requireNonNull(worldIdentity, "world identity")); - return boundary == null ? null : boundary.completion(); + Boundary boundary = ACTIVE.get(Objects.requireNonNull(worldIdentity, "world identity")); + return boundary == null || !boundary.claimed().compareAndSet(false, true) ? null : boundary.completion(); } static void complete(Boundary boundary, Boolean unloaded, Throwable failure) { @@ -36,6 +37,6 @@ public final class WorldUnloadBoundaryRegistry { boundary.completion().completeExceptionally(WorldLifecycleSupport.unwrap(failure)); } - record Boundary(String worldIdentity, CompletableFuture completion) { + record Boundary(String worldIdentity, CompletableFuture completion, AtomicBoolean claimed) { } } diff --git a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java index 78045cf87..b508d30da 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java @@ -88,7 +88,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final String chunkAccessMode; private final ChunkRequestExecutor executor; private final Executor slowRequestExecutor; - private final Executor chunkIoExecutor; + private final PregenChunkFlush chunkIoFlush; private final PregenSerialWorker chunkFlush; private final PregenSerialWorker mantleCleanup; private final PregenAdmissionGate admission; @@ -179,7 +179,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { // Moonrise flush parks until the level's pending IO settles. Filling the pool with parked // flushes left generation waiting on plate loads that could never run. this.chunkFlush = new PregenSerialWorker("Iris Pregen Chunk Flush", world.getName()); - this.chunkIoExecutor = chunkFlush.executor(); + this.chunkIoFlush = new PregenChunkFlush(chunkFlush.executor(), () -> INMS.get().flushChunkIO(world)); this.mantleCleanup = new PregenSerialWorker("Iris Pregen Mantle Cleanup", world.getName()); this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs(); this.urgent = false; @@ -427,8 +427,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { } private CompletableFuture flushChunkIOAsync() { - INMSBinding binding = INMS.get(); - return CompletableFuture.runAsync(() -> binding.flushChunkIO(world), chunkIoExecutor); + return chunkIoFlush.request(); } private CompletableFuture trackEviction(CompletableFuture eviction) { diff --git a/core/src/main/java/art/arcane/iris/world/pregen/PregenChunkFlush.java b/core/src/main/java/art/arcane/iris/world/pregen/PregenChunkFlush.java new file mode 100644 index 000000000..ee8617032 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/world/pregen/PregenChunkFlush.java @@ -0,0 +1,62 @@ +package art.arcane.iris.world.pregen; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +final class PregenChunkFlush { + private final Executor executor; + private final Runnable flush; + private CompletableFuture pending; + private boolean scheduled; + + PregenChunkFlush(Executor executor, Runnable flush) { + this.executor = Objects.requireNonNull(executor); + this.flush = Objects.requireNonNull(flush); + } + + CompletableFuture request() { + CompletableFuture requested; + boolean submit; + synchronized (this) { + if (pending == null) { + pending = new CompletableFuture<>(); + } + requested = pending; + submit = !scheduled; + scheduled = true; + } + if (submit) { + try { + executor.execute(this::drain); + } catch (RuntimeException | Error failure) { + synchronized (this) { + pending = null; + scheduled = false; + } + requested.completeExceptionally(failure); + } + } + return requested.copy(); + } + + private void drain() { + while (true) { + CompletableFuture batch; + synchronized (this) { + batch = pending; + if (batch == null) { + scheduled = false; + return; + } + pending = null; + } + try { + flush.run(); + batch.complete(null); + } catch (Throwable failure) { + batch.completeExceptionally(failure); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/world/runtime/WorldRuntimeControlService.java b/core/src/main/java/art/arcane/iris/world/runtime/WorldRuntimeControlService.java index 8f22c500c..3c95a27ee 100644 --- a/core/src/main/java/art/arcane/iris/world/runtime/WorldRuntimeControlService.java +++ b/core/src/main/java/art/arcane/iris/world/runtime/WorldRuntimeControlService.java @@ -303,12 +303,15 @@ public final class WorldRuntimeControlService { return CompletableFuture.completedFuture(null); } - int chunkX = source.getBlockX() >> 4; - int chunkZ = source.getBlockZ() >> 4; + Location destination = source.clone(); + int chunkX = destination.getBlockX() >> 4; + int chunkZ = destination.getBlockZ() >> 4; CompletableFuture future = new CompletableFuture<>(); boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { try { - future.complete(findTopSafeLocationWithTicket(world, source, chunkX, chunkZ)); + if (!future.isDone()) { + future.complete(findTopSafeLocationWithTicket(world, destination, chunkX, chunkZ)); + } } catch (Throwable t) { future.completeExceptionally(t); } @@ -369,6 +372,7 @@ public final class WorldRuntimeControlService { return CompletableFuture.completedFuture(false); } Objects.requireNonNull(teleporter, "teleporter"); + Location destination = location.clone(); CompletableFuture future = new CompletableFuture<>(); GameModeRestore modeRestore = new GameModeRestore(player); @@ -395,7 +399,7 @@ public final class WorldRuntimeControlService { return; } } - CompletableFuture teleportFuture = teleporter.teleport(player, location); + CompletableFuture teleportFuture = teleporter.teleport(player, destination); if (teleportFuture == null) { future.complete(false); return; @@ -425,7 +429,7 @@ public final class WorldRuntimeControlService { } catch (Throwable t) { future.completeExceptionally(t); } - }); + }, 0, () -> future.complete(false)); if (!scheduled) { future.completeExceptionally(new IllegalStateException("Failed to schedule teleport for " + player.getName() + ".")); } diff --git a/core/src/main/java/art/arcane/iris/world/task/jobs/Job.java b/core/src/main/java/art/arcane/iris/world/task/jobs/Job.java index 4fe936de1..9a3f1623c 100644 --- a/core/src/main/java/art/arcane/iris/world/task/jobs/Job.java +++ b/core/src/main/java/art/arcane/iris/world/task/jobs/Job.java @@ -54,19 +54,19 @@ public interface Job { } - default void execute(VolmitSender sender) { - execute(sender, () -> { + default CompletableFuture execute(VolmitSender sender) { + return execute(sender, () -> { }); } - default void execute(VolmitSender sender, Runnable whenComplete) { - execute(sender, false, whenComplete); + default CompletableFuture execute(VolmitSender sender, Runnable whenComplete) { + return execute(sender, false, whenComplete); } - default void execute(VolmitSender sender, boolean silentMsg, Runnable whenComplete) { + default CompletableFuture execute(VolmitSender sender, boolean silentMsg, Runnable whenComplete) { PrecisionStopwatch p = PrecisionStopwatch.start(); - CompletableFuture f = J.afut(this::execute); + CompletableFuture f = J.afut(this::execute); int c = J.ar(() -> { if (sender.isPlayer()) { sender.sendProgress(getProgress(), getName()); @@ -90,5 +90,6 @@ public interface Job { } whenComplete.run(); }); + return f; } } diff --git a/core/src/test/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScannerTest.java b/core/src/test/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScannerTest.java new file mode 100644 index 000000000..dec52100a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/diagnostics/splash/IrisSplashPackScannerTest.java @@ -0,0 +1,28 @@ +package art.arcane.iris.diagnostics.splash; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class IrisSplashPackScannerTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void missingVersionRemainsVisibleWithUnknownVersion() throws Exception { + File pack = temporary.newFolder("overworld"); + File dimensions = new File(pack, "dimensions"); + Files.createDirectories(dimensions.toPath()); + Files.writeString(new File(dimensions, "overworld.json").toPath(), "{}"); + IrisSplashPackScanner.SplashPackMetadata metadata = IrisSplashPackScanner.read(pack, null); + assertNotNull(metadata); + assertEquals("unknown", metadata.version()); + assertEquals(1, IrisSplashPackScanner.collect(temporary.getRoot(), null).size()); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/biome/FloatingIslandLockedLayersTest.java b/core/src/test/java/art/arcane/iris/generation/biome/FloatingIslandLockedLayersTest.java new file mode 100644 index 000000000..c1536223d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/biome/FloatingIslandLockedLayersTest.java @@ -0,0 +1,123 @@ +package art.arcane.iris.generation.biome; + +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.stage.IrisFloatingChildBiomeModifier; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisSlopeClip; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.noise.CNG; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.Arrays; + +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +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.mockStatic; +import static org.mockito.Mockito.when; + +public class FloatingIslandLockedLayersTest { + @Test + public void lockedBandsFollowIslandAltitudeInsteadOfThickness() { + NativeBlockState first = mock(NativeBlockState.class); + NativeBlockState second = mock(NativeBlockState.class); + NativeBlockState third = mock(NativeBlockState.class); + IrisFloatingChildBiomes entry = new IrisFloatingChildBiomes().setBiome(""); + IrisBiome parent = new IrisBiome().setLockLayers(true).setLockLayersMax(16) + .setLayers(new KList<>(layer(first), layer(second), layer(third))) + .setFloatingChildBiomes(new KList<>(entry)); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(engine.getDimension()).thenReturn(new IrisDimension()); + when(engine.getSubterrainCell(anyInt(), anyInt(), anyInt())).thenReturn(null); + when(engine.getComplex().allowsNewDiscreteContentAt(anyInt(), anyInt())).thenReturn(false); + FloatingIslandBoundarySampler boundary = mock(FloatingIslandBoundarySampler.class); + when(boundary.parent(anyInt(), anyInt())).thenReturn(parent); + ChunkContext context = mock(ChunkContext.class); + when(context.getFloatingIslandBoundarySampler()).thenReturn(boundary); + FloatingIslandSample thin = sample(entry, 18, 3); + FloatingIslandSample thick = sample(entry, 17, 4); + FloatingIslandSample raised = sample(entry, 19, 3); + boolean[] gapMask = {true, false, false, false, false, false, false, false, true, false, true}; + FloatingIslandSample gapped = FloatingIslandSample.constructForTest(entry, 10, 11, 10, 3, gapMask, null); + IrisFloatingChildBiomes mirroredEntry = new IrisFloatingChildBiomes().setBiome("") + .setBottomPaletteMode(FloatingBottomPaletteMode.MIRROR_TOP); + FloatingIslandSample mirrored = FloatingIslandSample.constructForTest(mirroredEntry, 10, 11, 10, 3, gapMask, null); + NativeBlockState customBlock = mock(NativeBlockState.class); + IrisFloatingChildBiomes customEntry = new IrisFloatingChildBiomes().setBiome("") + .setBottomPaletteMode(FloatingBottomPaletteMode.CUSTOM).setBottomPalette(new KList<>(layer(customBlock))); + FloatingIslandSample custom = FloatingIslandSample.constructForTest(customEntry, 10, 11, 10, 3, gapMask, null); + IrisFloatingChildBiomes ordinaryEntry = new IrisFloatingChildBiomes().setBiome(""); + IrisBiome ordinaryParent = new IrisBiome().setLayers(new KList<>(layer(first), layer(second), layer(third))) + .setFloatingChildBiomes(new KList<>(ordinaryEntry)); + when(boundary.parent(-10, -16)).thenReturn(ordinaryParent); + FloatingIslandSample ordinary = FloatingIslandSample.constructForTest(ordinaryEntry, 10, 11, 10, 3, gapMask, null); + Hunk output = Hunk.newArrayHunk(16, 64, 16); + try (MockedStatic samples = mockStatic(FloatingIslandSample.class); + MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getState("minecraft:stone")).thenReturn(first); + samples.when(() -> FloatingIslandSample.sampleMemoized(eq(parent), eq(-16), eq(-16), + anyInt(), anyLong(), any(), eq(engine), eq(boundary))).thenReturn(thin); + samples.when(() -> FloatingIslandSample.sampleMemoized(eq(parent), eq(-15), eq(-16), + anyInt(), anyLong(), any(), eq(engine), eq(boundary))).thenReturn(thick); + samples.when(() -> FloatingIslandSample.sampleMemoized(eq(parent), eq(-14), eq(-16), + anyInt(), anyLong(), any(), eq(engine), eq(boundary))).thenReturn(raised); + samples.when(() -> FloatingIslandSample.sampleMemoized(eq(parent), eq(-13), eq(-16), + anyInt(), anyLong(), any(), eq(engine), eq(boundary))).thenReturn(gapped); + samples.when(() -> FloatingIslandSample.sampleMemoized(eq(parent), eq(-12), eq(-16), + anyInt(), anyLong(), any(), eq(engine), eq(boundary))).thenReturn(mirrored); + samples.when(() -> FloatingIslandSample.sampleMemoized(eq(parent), eq(-11), eq(-16), + anyInt(), anyLong(), any(), eq(engine), eq(boundary))).thenReturn(custom); + samples.when(() -> FloatingIslandSample.sampleMemoized(eq(ordinaryParent), eq(-10), eq(-16), + anyInt(), anyLong(), any(), eq(engine), eq(boundary))).thenReturn(ordinary); + new IrisFloatingChildBiomeModifier(engine).onModify(-16, -16, output, false, context); + } + assertSame(first, output.get(0, 20, 0)); + assertSame(first, output.get(1, 20, 0)); + assertSame(third, output.get(2, 21, 0)); + assertSame(first, output.get(3, 20, 0)); + assertSame(third, output.get(3, 18, 0)); + assertSame(second, output.get(3, 10, 0)); + assertSame(first, output.get(4, 10, 0)); + assertSame(second, output.get(4, 18, 0)); + assertSame(customBlock, output.get(5, 10, 0)); + assertSame(second, output.get(5, 18, 0)); + assertSame(second, output.get(6, 18, 0)); + assertSame(third, output.get(6, 10, 0)); + for (int y = 18; y <= 20; y++) { + assertSame(output.get(0, y, 0), output.get(1, y, 0)); + if (y >= 19) { + assertSame(output.get(0, y, 0), output.get(2, y, 0)); + } + } + } + + private FloatingIslandSample sample(IrisFloatingChildBiomes entry, int baseY, int thickness) { + boolean[] mask = new boolean[thickness]; + Arrays.fill(mask, true); + return FloatingIslandSample.constructForTest(entry, baseY, thickness, thickness - 1, thickness, mask, null); + } + + private IrisBiomePaletteLayer layer(NativeBlockState block) { + IrisBiomePaletteLayer layer = mock(IrisBiomePaletteLayer.class); + CNG height = mock(CNG.class); + when(layer.getZoom()).thenReturn(1D); + when(layer.getMinHeight()).thenReturn(1); + when(layer.getMaxHeight()).thenReturn(1); + when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip()); + when(layer.getHeightGenerator(any(), any())).thenReturn(height); + when(height.fit(anyInt(), anyInt(), anyDouble(), anyDouble())).thenReturn(1); + when(layer.get(any(), anyInt(), anyDouble(), anyDouble(), anyDouble(), any())).thenReturn(block); + when(layer.get(any(), anyDouble(), anyDouble(), anyDouble(), any())).thenReturn(block); + return layer; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeGenLinksTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeGenLinksTest.java new file mode 100644 index 000000000..eb9e5e907 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeGenLinksTest.java @@ -0,0 +1,48 @@ +package art.arcane.iris.generation.biome; + +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +public class IrisBiomeGenLinksTest { + @Test + public void duplicateGeneratorKeysUseTheLastLinkForAllQueries() { + IrisBiomeGeneratorLink first = link("terrain", -20, 30); + IrisBiomeGeneratorLink last = link("terrain", -40, 60); + IrisBiome biome = new IrisBiome().setGenerators(new KList<>(first, last)); + + assertEquals(60D, biome.getGenLinkMax("terrain"), 0D); + assertEquals(-40D, biome.getGenLinkMin("terrain"), 0D); + assertSame(last, biome.getGenLink("terrain")); + } + + @Test + public void independentKeysPreserveNegativeBounds() { + IrisBiome biome = new IrisBiome().setGenerators(new KList<>( + link("ocean", -40, -10), link("land", 5, 80))); + + assertEquals(-40D, biome.getGenLinkMin("ocean"), 0D); + assertEquals(-10D, biome.getGenLinkMax("ocean"), 0D); + assertEquals(5D, biome.getGenLinkMin("land"), 0D); + assertEquals(80D, biome.getGenLinkMax("land"), 0D); + } + + @Test + public void missingAndBlankKeysHaveNoBoundsOrLink() { + IrisBiome biome = new IrisBiome().setGenerators(new KList<>( + link(null, -40, 60), link(" ", -20, 30), link("terrain", 5, 80))); + + for (String key : new String[]{null, "", " ", "missing"}) { + assertNull(biome.getGenLink(key)); + assertEquals(0D, biome.getGenLinkMin(key), 0D); + assertEquals(0D, biome.getGenLinkMax(key), 0D); + } + } + + private static IrisBiomeGeneratorLink link(String key, int minimum, int maximum) { + return new IrisBiomeGeneratorLink().setGenerator(key).setMin(minimum).setMax(maximum); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLockedLayerTest.java b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLockedLayerTest.java index 0e608e0d7..84b6bb029 100644 --- a/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLockedLayerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/biome/IrisBiomeLockedLayerTest.java @@ -38,7 +38,7 @@ public class IrisBiomeLockedLayerTest { IrisBiomeCeilingLayerTest.layer(third, 1))); List cycle = List.of(first, second, third); int[] heights = {1, 509, 512, 513, 1000, Integer.MAX_VALUE}; - int[][] expectedCycles = {{1, 0, 2}, {0, 2, 1}, {0, 2, 1}, {2, 1, 0}, {1, 0, 2}, {1, 0, 2}}; + int[][] expectedCycles = {{1, 2, 0}, {0, 1, 2}, {0, 1, 2}, {2, 0, 1}, {1, 2, 0}, {1, 2, 0}}; for (int index = 0; index < heights.length; index++) { KList blocks = biome.generateLockedLayers( 13, -27, new RNG(37), 8, heights[index], null, null); @@ -49,6 +49,24 @@ public class IrisBiomeLockedLayerTest { } } + @Test + public void sharedWorldHeightHasTheSameBandUnderDifferentSurfaceHeights() { + NativeBlockState first = mock(NativeBlockState.class); + NativeBlockState second = mock(NativeBlockState.class); + NativeBlockState third = mock(NativeBlockState.class); + IrisBiome biome = new IrisBiome().setLockLayers(true).setLockLayersMax(16).setLayers(new KList<>( + IrisBiomeCeilingLayerTest.layer(first, 1), + IrisBiomeCeilingLayerTest.layer(second, 1), + IrisBiomeCeilingLayerTest.layer(third, 1))); + for (int surface : new int[]{-17, 20, 512, 1000}) { + KList lower = biome.generateLockedLayers(13, -27, new RNG(37), 8, surface, null, null); + KList higher = biome.generateLockedLayers(13, -27, new RNG(37), 9, surface + 1, null, null); + for (int depth = 0; depth < lower.size(); depth++) { + assertEquals(lower.get(depth), higher.get(depth + 1)); + } + } + } + @Test public void sampledBandsMatchExpandedVariableMixedPalettes() { NativeBlockState first = mock(NativeBlockState.class); @@ -171,7 +189,7 @@ public class IrisBiomeLockedLayerTest { } if (!expanded.isEmpty()) { for (int depth = 0; depth < Math.min(maxDepth, biome.getLockLayersMax()); depth++) { - result.add(expanded.get(Math.floorMod(512L - height - depth, expanded.size()))); + result.add(expanded.get(Math.floorMod(512L - (height - (long) depth), expanded.size()))); } } return result; diff --git a/core/src/test/java/art/arcane/iris/generation/cache/ConcurrentClockCacheTest.java b/core/src/test/java/art/arcane/iris/generation/cache/ConcurrentClockCacheTest.java index 276396f0d..dc50ff6d6 100644 --- a/core/src/test/java/art/arcane/iris/generation/cache/ConcurrentClockCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/cache/ConcurrentClockCacheTest.java @@ -4,6 +4,7 @@ import org.junit.Test; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -54,6 +55,57 @@ public class ConcurrentClockCacheTest { assertEquals(Long.valueOf(-1L), cache.get(-1L)); } + @Test + public void concurrentWritersAgreeWhenSetRequiresEviction() throws Exception { + ConcurrentClockCache cache = new ConcurrentClockCache<>(4); + CyclicBarrier barrier = new CyclicBarrier(3); + Object[] published = new Object[2]; + int rounds = 100_000; + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + List> writers = new ArrayList<>(2); + for (int writer = 0; writer < 2; writer++) { + int index = writer; + writers.add(executor.submit(() -> { + for (int round = 0; round < rounds; round++) { + barrier.await(10, TimeUnit.SECONDS); + published[index] = cache.putIfAbsent(999L, new Object()); + barrier.await(10, TimeUnit.SECONDS); + } + return null; + })); + } + int disagreements = 0; + int duplicatePublications = 0; + for (int round = 0; round < rounds; round++) { + cache.clear(); + for (long key = 0; key < 4; key++) { + cache.putIfAbsent(key, new Object()); + cache.get(key); + } + barrier.await(10, TimeUnit.SECONDS); + barrier.await(10, TimeUnit.SECONDS); + if (published[0] != published[1]) { + disagreements++; + } + AtomicInteger retainedPublications = new AtomicInteger(); + cache.forEach(value -> { + if (value == published[0] || value == published[1]) { + retainedPublications.incrementAndGet(); + } + }); + if (retainedPublications.get() != 1) { + duplicatePublications++; + } + } + for (Future writer : writers) { + writer.get(10, TimeUnit.SECONDS); + } + assertEquals(0, disagreements); + assertEquals(0, duplicatePublications); + assertSame(published[0], cache.get(999L)); + } + } + @Test public void concurrentWritersAgreeOnOneValuePerKey() throws Exception { ConcurrentClockCache cache = new ConcurrentClockCache<>(1 << 12); diff --git a/core/src/test/java/art/arcane/iris/generation/cave/IrisCaveProfileSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/cave/IrisCaveProfileSamplerTest.java new file mode 100644 index 000000000..3eeac84a4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/cave/IrisCaveProfileSamplerTest.java @@ -0,0 +1,117 @@ +package art.arcane.iris.generation.cave; + +import art.arcane.iris.generation.noise.IrisGeneratorStyle; +import art.arcane.iris.generation.noise.NoiseStyle; +import art.arcane.iris.generation.noise.IrisStyledRange; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.M; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IrisCaveProfileSamplerTest { + @Test + public void resolvesThresholdNoiseOnceWhilePreservingCoordinateDecisions() { + IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + CNG thresholdNoise = mock(CNG.class); + when(style.create(any(), isNull())).thenReturn(thresholdNoise); + when(thresholdNoise.fitDouble(anyDouble(), anyDouble(), anyDouble(), anyDouble())) + .thenAnswer(invocation -> (double) invocation.getArgument(2) * 0.003D + + (double) invocation.getArgument(3) * 0.005D); + IrisCaveProfile profile = profile(new IrisStyledRange().setMin(-0.4D).setMax(0.7D).setStyle(style)); + IrisCaveProfileSampler sampler = new IrisCaveProfileSampler(engine(), profile); + for (int x = -19; x <= 19; x++) { + for (int y = -3; y <= 11; y++) { + int z = -x; + double density = density(x, y, z); + double threshold = x * 0.003D + z * 0.005D - profile.getThresholdBias(); + threshold += (1D - 0.75D) * 0.2D; + assertEquals(density <= threshold, sampler.shouldCarve(x, y, z, 0.75D)); + } + } + verify(style, times(1)).create(any(), isNull()); + verify(thresholdNoise, times(585)).fitDouble(anyDouble(), anyDouble(), anyDouble(), anyDouble()); + } + + @Test + public void realSeededThresholdMatchesStyledRangeAtSignedCoordinates() { + IrisStyledRange range = new IrisStyledRange().setMin(-0.4D).setMax(0.7D) + .setStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX)); + IrisCaveProfile profile = profile(range); + IrisCaveProfileSampler sampler = new IrisCaveProfileSampler(engine(), profile); + RNG thresholdRng = new RNG(47L).nextParallelRNG(489_112); + int[] coordinates = {-1009, -33, -1, 0, 17, 513}; + double[] floatingThresholds = {-0.5D, 0D, 0.25D, 0.75D, 1D, 1.5D}; + for (int x : coordinates) { + for (int z : coordinates) { + for (int y = -60; y <= 60; y += 3) { + for (double floatingThreshold : floatingThresholds) { + double threshold = range.get(thresholdRng, x, z, null) - profile.getThresholdBias(); + threshold += (1D - Math.max(0D, Math.min(1D, floatingThreshold))) * 0.2D; + assertEquals("x=" + x + " y=" + y + " z=" + z + " floating=" + floatingThreshold, + density(x, y, z) <= threshold, sampler.shouldCarve(x, y, z, floatingThreshold)); + } + } + } + } + } + + @Test + public void constantAndFlatThresholdsNeverResolveNoise() { + for (boolean equalBounds : new boolean[]{false, true}) { + IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + when(style.isFlat()).thenReturn(true); + IrisStyledRange range = new IrisStyledRange().setMin(-0.4D) + .setMax(equalBounds ? -0.4D : 0.7D).setStyle(style); + IrisCaveProfile profile = profile(range); + IrisCaveProfileSampler sampler = new IrisCaveProfileSampler(engine(), profile); + double threshold = (equalBounds ? -0.4D : M.lerp(-0.4D, 0.7D, 0.5D)) - profile.getThresholdBias(); + for (int y = -60; y <= 60; y++) { + assertEquals(density(-17, y, 31) <= threshold, sampler.shouldCarve(-17, y, 31, 1D)); + } + verify(style, never()).create(any(), any()); + } + } + + private Engine engine() { + Engine engine = mock(Engine.class); + SeedManager seeds = mock(SeedManager.class); + when(seeds.getCarve()).thenReturn(47L); + when(engine.getSeedManager()).thenReturn(seeds); + return engine; + } + + private IrisCaveProfile profile(IrisStyledRange threshold) { + CNG densityNoise = mock(CNG.class); + when(densityNoise.noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble())).thenAnswer(invocation -> + density(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2))); + IrisGeneratorStyle densityStyle = mock(IrisGeneratorStyle.class); + when(densityStyle.create(any(), isNull())).thenReturn(densityNoise); + IrisCaveProfile profile = mock(IrisCaveProfile.class); + when(profile.getBaseDensityStyle()).thenReturn(densityStyle); + when(profile.getDetailDensityStyle()).thenReturn(densityStyle); + when(profile.getWarpStyle()).thenReturn(densityStyle); + when(profile.getDensityThreshold()).thenReturn(threshold); + when(profile.getThresholdBias()).thenReturn(0.03D); + when(profile.getBaseWeight()).thenReturn(1D); + when(profile.getModules()).thenReturn(new KList<>()); + when(profile.isEnabled()).thenReturn(true); + return profile; + } + + private double density(double x, double y, double z) { + return x * 0.003D + y * 0.01D + z * 0.005D; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/DecoratorCoreTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/DecoratorCoreTest.java index 71e734b3b..1063af9b3 100644 --- a/core/src/test/java/art/arcane/iris/generation/decoration/DecoratorCoreTest.java +++ b/core/src/test/java/art/arcane/iris/generation/decoration/DecoratorCoreTest.java @@ -808,6 +808,43 @@ public class DecoratorCoreTest { return state; } + @Test + public void surfaceStairsAndTrapdoorsUseTheirAuthoredHalfInOneVoxel() { + assertSingleVoxelHalfPlacement(false); + } + + @Test + public void floatingStairsAndTrapdoorsUseTheirAuthoredHalfInOneVoxel() { + assertSingleVoxelHalfPlacement(true); + } + + private void assertSingleVoxelHalfPlacement(boolean floating) { + for (String key : new String[]{"minecraft:oak_stairs[half=bottom]", "minecraft:oak_trapdoor[half=top]"}) { + IrisDecorator decorator = mock(IrisDecorator.class); + IrisData data = mock(IrisData.class); + NativeBlockState support = sturdyState(); + NativeBlockState air = airState(); + NativeBlockState occupied = mock(NativeBlockState.class); + NativeBlockState block = mock(NativeBlockState.class); + when(block.key()).thenReturn(key); + when(block.canPlaceOnto(support)).thenReturn(true); + when(decorator.pickBlockData(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(block); + Hunk output = Hunk.newArrayHunk(1, 4, 1); + output.set(0, 0, 0, support); + output.set(0, 1, 0, air); + output.set(0, 2, 0, occupied); + + if (floating) { + DecoratorCore.placeFloatingSimple(decorator, 0, 0, 0, 0, 0, 3, output, new RNG(1L), data, null); + } else { + DecoratorCore.placeSurfaceSingle(decorator, 0, 0, 0, 0, 0, output, new RNG(1L), data, false, false, null); + } + + assertSame(key, block, output.get(0, 1, 0)); + assertSame(key, occupied, output.get(0, 2, 0)); + } + } + private NativeBlockState tallPlantState() { return tallPlantState(mock(NativeBlockState.class), mock(NativeBlockState.class)); } diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/DecoratorStackSamplingTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/DecoratorStackSamplingTest.java new file mode 100644 index 000000000..e5b43f76a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/decoration/DecoratorStackSamplingTest.java @@ -0,0 +1,98 @@ +package art.arcane.iris.generation.decoration; + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import org.bukkit.block.BlockSupport; +import org.bukkit.block.data.BlockData; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class DecoratorStackSamplingTest { + @Test + public void coordinateNoiseIsSampledOncePerBodyAndTopWithIdenticalVoxelAndRngOutput() { + for (long seed : new long[]{1L, 78264193L, -517L}) { + for (int height : new int[]{1, 2, 8, 30}) { + for (int direction = 0; direction < 3; direction++) { + for (int coordinate : new int[]{-37, 0, 13}) { + assertEquivalentStack(seed, height, direction, coordinate); + } + } + } + } + } + + private static void assertEquivalentStack(long seed, int height, int direction, int coordinate) { + IrisData data = mock(IrisData.class); + RNG rng = new RNG(seed); + RNG controlRng = new RNG(seed); + CNG variance = new CNG(new RNG(seed + 11L)); + CNG controlVariance = new CNG(new RNG(seed + 11L)); + NativeBlockState[] body = {block("minecraft:stone"), block("minecraft:dirt")}; + NativeBlockState[] top = {block("minecraft:granite"), block("minecraft:andesite")}; + IrisDecorator decorator = spy(new IrisDecorator().setForcePlace(true) + .setStackMin(height).setStackMax(height).setTopThreshold(0.4D)); + doReturn(body).when(decorator).getBlockDataArray(data); + doReturn(top).when(decorator).getBlockDataTopsArray(data); + doReturn(variance).when(decorator).getVarianceGenerator(rng, data); + NativeBlockState air = block("minecraft:air"); + doReturn(true).when(air).isAir(); + NativeBlockState support = block("minecraft:stone"); + BlockData supportHandle = mock(BlockData.class); + doReturn(true).when(support).isSolid(); + doReturn(supportHandle).when(support).nativeHandle(); + when(supportHandle.isFaceSturdy(any(), eq(BlockSupport.FULL))).thenReturn(true); + Hunk output = Hunk.newArrayHunk(1, height + 2, 1); + for (int y = 0; y < output.getHeight(); y++) { + output.set(0, y, 0, air); + } + int supportY = direction == 1 ? height + 1 : 0; + output.set(0, supportY, 0, support); + DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + int z = coordinate * 3 - 7; + switch (direction) { + case 0 -> DecoratorCore.placeStackUp(decorator, 0, 0, coordinate, z, 0, height, + output, rng, data, opts); + case 1 -> DecoratorCore.placeStackDown(decorator, 0, 0, coordinate, z, height, 0, + output, rng, data, height, opts, null); + case 2 -> assertEquals(height, DecoratorCore.placeFloatingStacked(decorator, 0, 0, + coordinate, z, 0, height + 1, output, rng, data, null)); + default -> throw new IllegalArgumentException("Unknown stack direction"); + } + boolean usedBody = false; + boolean usedTop = false; + for (int i = 0; i < height; i++) { + double threshold = height == 1 ? direction == 2 ? 0D : 1D : (double) i / (height - 1); + boolean topSegment = threshold >= decorator.getTopThreshold(); + NativeBlockState[] palette = topSegment ? top : body; + NativeBlockState expected = palette[Math.abs(controlVariance.fit(0, palette.length - 1, z, coordinate))]; + int y = direction == 1 ? height - i : i + 1; + assertSame("seed=" + seed + ", direction=" + direction + ", y=" + y, expected, output.get(0, y, 0)); + usedBody |= !topSegment; + usedTop |= topSegment; + } + assertSame(support, output.get(0, supportY, 0)); + verify(decorator, times(usedBody ? 1 : 0)).pickBlockData(rng, data, coordinate, z); + verify(decorator, times(usedTop ? 1 : 0)).pickBlockDataTop(rng, data, coordinate, z); + assertEquals(controlRng.nextLong(), rng.nextLong()); + } + + private static NativeBlockState block(String key) { + NativeBlockState state = mock(NativeBlockState.class); + doReturn(key).when(state).key(); + when(state.canPlaceOnto(any())).thenReturn(true); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorPaletteSelectionTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorPaletteSelectionTest.java new file mode 100644 index 000000000..0afbba5bf --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/decoration/IrisDecoratorPaletteSelectionTest.java @@ -0,0 +1,88 @@ +package art.arcane.iris.generation.decoration; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.noise.CNG; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class IrisDecoratorPaletteSelectionTest { + @Test + public void multipleTopEntriesVaryEvenWhenTheBodyPaletteIsASingleBlock() { + IrisData data = mock(IrisData.class); + IrisBiome biome = mock(IrisBiome.class); + IrisDecorator decorator = spy(new IrisDecorator().setChance(1D)); + NativeBlockState body = mock(NativeBlockState.class); + NativeBlockState first = mock(NativeBlockState.class); + NativeBlockState second = mock(NativeBlockState.class); + KList tops = new KList<>(first, second); + CNG chance = mock(CNG.class); + CNG variance = mock(CNG.class); + RNG rng = new RNG(37L); + doReturn(new KList<>(body)).when(decorator).getBlockData(data); + doReturn(tops).when(decorator).getBlockDataTops(data); + doReturn(chance).when(decorator).getGenerator(rng, data); + doReturn(variance).when(decorator).getVarianceGenerator(rng, data); + doReturn(second).when(variance).fit(tops, 23D, 9D, 17D); + doReturn(first).when(variance).fit(tops, 24D, 9D, 17D); + + assertSame(second, decorator.getBlockDataForTop(biome, rng, 17D, 9D, 23D, data)); + assertSame(first, decorator.getBlockDataForTop(biome, rng, 17D, 9D, 24D, data)); + verify(variance).fit(tops, 23D, 9D, 17D); + verify(variance).fit(tops, 24D, 9D, 17D); + } + + @Test + public void aSingleTopEntryDoesNotBuildOrSampleTheVarianceGenerator() { + IrisData data = mock(IrisData.class); + IrisBiome biome = mock(IrisBiome.class); + IrisDecorator decorator = spy(new IrisDecorator().setChance(1D)); + NativeBlockState firstBody = mock(NativeBlockState.class); + NativeBlockState secondBody = mock(NativeBlockState.class); + NativeBlockState top = mock(NativeBlockState.class); + RNG rng = new RNG(37L); + doReturn(new KList<>(firstBody, secondBody)).when(decorator).getBlockData(data); + doReturn(new KList<>(top)).when(decorator).getBlockDataTops(data); + doReturn(mock(CNG.class)).when(decorator).getGenerator(rng, data); + + assertSame(top, decorator.getBlockDataForTop(biome, rng, 17D, 9D, 23D, data)); + verify(decorator, never()).getVarianceGenerator(rng, data); + verify(decorator, times(1)).getBlockDataTops(data); + } + + @Test + public void bodySamplingResolvesPaletteOnceAndPreservesCoordinatesAndRngConsumption() { + IrisData data = mock(IrisData.class); + IrisBiome biome = mock(IrisBiome.class); + IrisDecorator decorator = spy(new IrisDecorator()); + NativeBlockState first = mock(NativeBlockState.class); + NativeBlockState second = mock(NativeBlockState.class); + KList palette = new KList<>(first, second); + CNG variance = new CNG(new RNG(841L)); + RNG rng = new RNG(37L); + RNG control = new RNG(37L); + doReturn(palette).when(decorator).getBlockData(data); + doReturn(variance).when(decorator).getVarianceGenerator(rng, data); + + for (int x = -8; x < 8; x++) { + for (int y = 0; y < 4; y++) { + double z = x * 3D - 2D; + NativeBlockState expected = variance.fit(palette, z, y, x); + assertSame(expected, decorator.getBlockData100(biome, rng, x, y, z, data)); + } + } + verify(decorator, times(64)).getBlockData(data); + assertEquals(control.nextLong(), rng.nextLong()); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/IrisPlacementWaterloggingTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/IrisPlacementWaterloggingTest.java index abce341b2..37ed3efb8 100644 --- a/core/src/test/java/art/arcane/iris/generation/decoration/IrisPlacementWaterloggingTest.java +++ b/core/src/test/java/art/arcane/iris/generation/decoration/IrisPlacementWaterloggingTest.java @@ -4,8 +4,11 @@ import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.volmlib.util.hunk.Hunk; import org.junit.Test; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class IrisPlacementWaterloggingTest { @@ -36,6 +39,28 @@ public class IrisPlacementWaterloggingTest { assertSame(dry, DecoratorCore.fixFacesForHunk(wet, hunk, 3, 5, 3, 29, 5, null)); } + @Test + public void currentWaterloggedValueIsReadOnceAndUnchangedStatesAreReused() { + NativeBlockState dry = state("minecraft:oak_slab[type=bottom,waterlogged=false]"); + assertSame(dry, IrisProceduralBlocks.normalizeWaterlogging(dry, null, true)); + verify(dry, times(1)).key(); + NativeBlockState stone = state("minecraft:stone"); + assertSame(stone, IrisProceduralBlocks.normalizeWaterlogging(stone, null, true)); + verify(stone, times(1)).key(); + assertNull(IrisProceduralBlocks.normalizeWaterlogging(null, null, true)); + } + + @Test + public void waterloggedNeighborsSubmergeAuthoredAndEnabledStates() { + NativeBlockState dry = state("minecraft:oak_slab[type=bottom,waterlogged=false]"); + NativeBlockState wet = state("minecraft:oak_slab[type=bottom,waterlogged=true]"); + NativeBlockState neighbor = state("minecraft:oak_stairs[facing=east,waterlogged=true]"); + when(dry.withProperty("waterlogged", "true")).thenReturn(wet); + assertSame(wet, IrisProceduralBlocks.normalizeWaterlogging(dry, neighbor, true)); + assertSame(dry, IrisProceduralBlocks.normalizeWaterlogging(dry, neighbor, false)); + assertSame(wet, IrisProceduralBlocks.normalizeWaterlogging(wet, neighbor, false)); + } + private NativeBlockState state(String key) { NativeBlockState state = mock(NativeBlockState.class); when(state.key()).thenReturn(key); diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/IrisShoreLineDecoratorTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/IrisShoreLineDecoratorTest.java index a8f24f7fa..a6d61fa3a 100644 --- a/core/src/test/java/art/arcane/iris/generation/decoration/IrisShoreLineDecoratorTest.java +++ b/core/src/test/java/art/arcane/iris/generation/decoration/IrisShoreLineDecoratorTest.java @@ -273,6 +273,35 @@ public class IrisShoreLineDecoratorTest { return new Fixture(new IrisShoreLineDecorator(engine), data, biome, decorator, decorant); } + @Test + public void percentageShorelineStackRespectsTheAbsoluteMaximumAndKeepsItsTop() { + Fixture fixture = createFixture(false); + NativeBlockState support = sturdyState(); + NativeBlockState top = mock(NativeBlockState.class); + when(fixture.decorant.canPlaceOnto(support)).thenReturn(true); + when(fixture.decorator.isStacking()).thenReturn(true); + when(fixture.decorator.isScaleStack()).thenReturn(true); + when(fixture.decorator.getAbsoluteMaxStack()).thenReturn(3); + when(fixture.decorator.getHeight(any(), anyDouble(), anyDouble(), eq(fixture.data))).thenReturn(100); + when(fixture.decorator.getTopThreshold()).thenReturn(0.9D); + when(fixture.decorator.getBlockDataForTop(eq(fixture.biome), any(), anyDouble(), anyDouble(), + anyDouble(), eq(fixture.data))).thenReturn(top); + Hunk output = Hunk.newArrayHunk(1, 64, 1); + for (int y = FLUID_HEIGHT + 1; y < output.getHeight(); y++) { + output.set(0, y, 0, airState()); + } + NativeBlockState beyondMaximum = output.get(0, FLUID_HEIGHT + 4, 0); + output.set(0, FLUID_HEIGHT, 0, support); + + fixture.shoreline.decorateAcceptedShore(0, 0, 0, 0, output, fixture.biome, + FLUID_HEIGHT, output.getHeight()); + + assertSame(fixture.decorant, output.get(0, FLUID_HEIGHT + 1, 0)); + assertSame(fixture.decorant, output.get(0, FLUID_HEIGHT + 2, 0)); + assertSame(top, output.get(0, FLUID_HEIGHT + 3, 0)); + assertSame(beyondMaximum, output.get(0, FLUID_HEIGHT + 4, 0)); + } + private Hunk output(NativeBlockState support, NativeBlockState target) { Hunk output = Hunk.newArrayHunk(1, FLUID_HEIGHT + 3, 1); output.set(0, FLUID_HEIGHT, 0, support); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCaveTerminalAlignmentTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCaveTerminalAlignmentTest.java index de72855b0..5b4061e64 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCaveTerminalAlignmentTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCaveTerminalAlignmentTest.java @@ -7,7 +7,6 @@ import art.arcane.iris.generation.hydrology.cave.CaveVoxelView; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveFluidPolicy; -import art.arcane.iris.generation.hydrology.cave.HydrologyCaveGrottoShape; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode; import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlannerSettings; @@ -165,8 +164,17 @@ public class HydrologyCaveTerminalAlignmentTest { actions.put(SHARED, sharedAction); return new HydrologyCaveCandidate( new HydrologyCaveSource(id, SHARED, SHARED, 20, HydrologyCaveMode.GENERATED_GROTTO), - "water", new HydrologyCavePlannerSettings(8, 8, 32, 1, 1, 1, 1, 0, - HydrologyCaveFluidPolicy.REJECT_EXISTING, HydrologyCaveGrottoShape.ELLIPSOID, 8, 8), + "water", new HydrologyCavePlannerSettings( + 8, + 8, + 32, + 1, + 1, + 0, + HydrologyCaveFluidPolicy.REJECT_EXISTING, + 8, + 8 + ), false, actions, Set.of(own)); } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBudgetCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBudgetCacheTest.java new file mode 100644 index 000000000..742b61f55 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBudgetCacheTest.java @@ -0,0 +1,143 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +public class HydrologySurfaceBudgetCacheTest { + @Test + public void repeatedBudgetReadsScanOnceAndPreserveReferenceAreaOrderAndCounts() { + HydrologySampledGrid grid = spy(grid()); + HydrologyPlannerSettings.Source sources = sources(40); + HydrologySurfaceBudgets reference = HydrologySurfaceBudgets.sample(grid, sources); + clearInvocations(grid); + + HydrologySurfaceBudgets cached = grid.surfaceBudgets(sources); + for (int call = 0; call < 16; call++) { + assertSame(cached, grid.surfaceBudgets(sources(40))); + } + + verify(grid, times(1)).nodes(); + assertBudgetsEqual(reference, cached); + assertEquals(List.of("west", "east"), cached.areas().stream().map(area -> area.policy.areaKey()).toList()); + assertEquals(8, cached.areas().getFirst().landCells); + assertEquals(8, cached.areas().getFirst().sourceCells); + assertEquals(7, cached.areas().getLast().landCells); + assertEquals(7, cached.areas().getLast().sourceCells); + } + + @Test + public void changingSourceElevationReplacesOnlyThisGridsSnapshot() { + HydrologySampledGrid grid = grid(); + HydrologySampledGrid other = grid(); + HydrologySurfaceBudgets initial = grid.surfaceBudgets(sources(40)); + HydrologySurfaceBudgets otherInitial = other.surfaceBudgets(sources(40)); + HydrologySurfaceBudgets elevated = grid.surfaceBudgets(sources(80)); + + assertNotSame(initial, elevated); + assertBudgetsEqual(HydrologySurfaceBudgets.sample(grid, sources(80)), elevated); + assertEquals(0, elevated.areas().getFirst().sourceCells); + assertEquals(7, elevated.areas().getLast().sourceCells); + assertSame(otherInitial, other.surfaceBudgets(sources(40))); + assertBudgetsEqual(initial, grid.surfaceBudgets(sources(40))); + } + + @Test(timeout = 10000) + public void concurrentConsumersPublishOneCompleteBudgetSnapshot() throws Exception { + HydrologySampledGrid grid = spy(grid()); + HydrologyPlannerSettings.Source sources = sources(40); + HydrologySurfaceBudgets reference = HydrologySurfaceBudgets.sample(grid, sources); + clearInvocations(grid); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + List> tasks = new ArrayList<>(); + for (int call = 0; call < 32; call++) { + tasks.add(executor.submit(() -> grid.surfaceBudgets(sources))); + } + HydrologySurfaceBudgets first = tasks.getFirst().get(); + for (Future task : tasks) { + assertSame(first, task.get()); + } + assertBudgetsEqual(reference, first); + verify(grid, times(1)).nodes(); + } finally { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + } + + @Test + public void seededSourceAndOutletQuotasMatchUncachedBudgets() { + HydrologySampledGrid grid = grid(); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologySurfaceBudgets reference = HydrologySurfaceBudgets.sample(grid, settings.surface().sources()); + HydrologySurfaceBudgets cached = grid.surfaceBudgets(settings.surface().sources()); + for (long seed : new long[]{0L, 17L, -31L, Long.MIN_VALUE, Long.MAX_VALUE}) { + HydrologyPlanner planner = new HydrologyPlanner(seed, settings, + (x, z) -> HydrologyTerrainSample.openLand(90, 0D, "land")); + for (HydrologyTileKey tile : List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(-3, 5))) { + for (HydrologySurfaceBudgets.Area area : reference.areas()) { + HydrologySurfaceBudgets.Area actual = cached.area(area.policy); + for (int outlets = 0; outlets <= 4; outlets++) { + assertEquals(reference.sourceTarget(area, outlets, planner, tile), + cached.sourceTarget(actual, outlets, planner, tile)); + } + assertEquals(reference.outletTarget(area, true, planner, tile), + cached.outletTarget(actual, true, planner, tile)); + assertEquals(reference.outletTarget(area, false, planner, tile), + cached.outletTarget(actual, false, planner, tile)); + } + } + } + } + + private static void assertBudgetsEqual(HydrologySurfaceBudgets expected, HydrologySurfaceBudgets actual) { + assertEquals(expected.overridden(), actual.overridden()); + assertEquals(expected.areas().size(), actual.areas().size()); + for (int index = 0; index < expected.areas().size(); index++) { + HydrologySurfaceBudgets.Area expectedArea = expected.areas().get(index); + HydrologySurfaceBudgets.Area actualArea = actual.areas().get(index); + assertEquals(expectedArea.policy, actualArea.policy); + assertEquals(expectedArea.landCells, actualArea.landCells); + assertEquals(expectedArea.sourceCells, actualArea.sourceCells); + assertSame(actualArea, actual.area(expectedArea.policy)); + } + } + + private static HydrologyPlannerSettings.Source sources(int minimumElevation) { + return new HydrologyPlannerSettings.Source(true, 3D, minimumElevation, 0, 64, 160); + } + + private static HydrologySampledGrid grid() { + ArrayList nodes = new ArrayList<>(); + int width = 6; + for (int gridZ = 0; gridZ < width; gridZ++) { + for (int gridX = 0; gridX < width; gridX++) { + int x = -32 + gridX * 32; + int z = -32 + gridZ * 32; + SurfaceRiverPolicy policy = new SurfaceRiverPolicy(x < 64 ? "west" : "east", 8D, 160, + 1, 3, 4, gridZ % 2 == 0 ? 128 : 256, null); + HydrologyTerrainSample terrain = gridX == 3 && gridZ == 3 + ? HydrologyTerrainSample.ocean(30, "land") + : HydrologyTerrainSample.openLand(x < 64 ? 60 : 90, 0D, "land"); + nodes.add(new HydrologyGridNode(nodes.size(), gridX, gridZ, x, z, nodes.size(), + terrain.withSurfacePolicy(policy))); + } + } + return new HydrologySampledGrid(-32, -32, 0, 0, 128, width, 32, nodes); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveContainmentPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveContainmentPlannerTest.java index a658e4420..2817bfcff 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveContainmentPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveContainmentPlannerTest.java @@ -4,12 +4,10 @@ import art.arcane.iris.generation.hydrology.HydrologyCaveVoxelViewFactory; import art.arcane.iris.generation.hydrology.HydrologyObservedPlannedSurface; import org.junit.Test; -import java.util.ArrayDeque; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Queue; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -20,15 +18,6 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class HydrologyCaveContainmentPlannerTest { - private static final List DIRECTIONS = List.of( - new CavePosition(1, 0, 0), - new CavePosition(-1, 0, 0), - new CavePosition(0, 1, 0), - new CavePosition(0, -1, 0), - new CavePosition(0, 0, 1), - new CavePosition(0, 0, -1) - ); - @Test public void cavePositionHashSeparatesAxisExtrusions() { assertNotEquals(new CavePosition(0, 31, 0).hashCode(), new CavePosition(1, 0, 0).hashCode()); @@ -37,584 +26,14 @@ public class HydrologyCaveContainmentPlannerTest { private final HydrologyCaveContainmentPlanner planner = new HydrologyCaveContainmentPlanner(); - @Test - public void closedComponentProducesConnectedThroatPoolAndGuards() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(1, 6, 0)); - HydrologyCaveSource source = source(14L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); - - HydrologyCavePlan plan = planner.plan(view, source, settings()); - - assertTrue(plan.accepted()); - assertEquals(HydrologyCaveAction.DRY_AIR, plan.actions().get(position(0, 12, 0))); - assertEquals(HydrologyCaveAction.DRY_AIR, plan.actions().get(position(0, 11, 0))); - assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); - assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(1, 6, 0))); - assertTrue(plan.actions().containsValue(HydrologyCaveAction.SEAL_GUARD)); - assertMutationConnected(plan, source.entry(), position(1, 6, 0)); - assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); - assertEquals(CaveVoxel.CAVE_AIR, plan.baselinePreconditions().get(position(1, 6, 0)).voxel()); - assertEquals(CaveVoxel.SOLID, plan.baselinePreconditions().get(position(0, 12, 0)).voxel()); - } - - @Test - public void compatibleFluidFollowsExplicitPolicy() { - TestVoxelView view = new TestVoxelView(); - CavePosition target = position(0, 7, 0); - view.set(CaveVoxel.COMPATIBLE_FLUID, target); - HydrologyCaveSource source = source(1L, 10, HydrologyCaveMode.CLOSED_COMPONENT, target); - - HydrologyCavePlan allowed = planner.plan(view, source, settings(HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE)); - HydrologyCavePlan rejected = planner.plan(view, source, settings(HydrologyCaveFluidPolicy.REJECT_EXISTING)); - - assertTrue(allowed.accepted()); - assertEquals(HydrologyCaveAction.WET_SOURCE, allowed.actions().get(target)); - assertEquals(HydrologyCaveRejection.EXISTING_FLUID, rejected.rejection()); - assertTrue(rejected.actions().isEmpty()); - } - - @Test - public void replacementPolicyIsDistinctAndStillRejectsLava() { - CavePosition target = position(0, 7, 0); - TestVoxelView compatibleView = new TestVoxelView(); - compatibleView.set(CaveVoxel.COMPATIBLE_FLUID, target); - TestVoxelView incompatibleView = new TestVoxelView(); - incompatibleView.set(CaveVoxel.INCOMPATIBLE_FLUID, target); - TestVoxelView lavaView = new TestVoxelView(); - lavaView.set(CaveVoxel.LAVA, target); - HydrologyCaveSource source = source(101L, 10, HydrologyCaveMode.CLOSED_COMPONENT, target); - - HydrologyCavePlan rejectedCompatible = planner.plan( - compatibleView, - source, - settings(HydrologyCaveFluidPolicy.REJECT_EXISTING) - ); - HydrologyCavePlan allowedCompatible = planner.plan( - compatibleView, - source, - settings(HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE) - ); - HydrologyCavePlan rejectedIncompatible = planner.plan( - incompatibleView, - source, - settings(HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE) - ); - HydrologyCavePlan replacedIncompatible = planner.plan( - incompatibleView, - source, - settings(HydrologyCaveFluidPolicy.REPLACE_CONTAINED) - ); - HydrologyCavePlan rejectedLava = planner.plan( - lavaView, - source, - settings(HydrologyCaveFluidPolicy.REPLACE_CONTAINED) - ); - - assertEquals(HydrologyCaveRejection.EXISTING_FLUID, rejectedCompatible.rejection()); - assertTrue(allowedCompatible.accepted()); - assertEquals(HydrologyCaveRejection.INCOMPATIBLE_FLUID, rejectedIncompatible.rejection()); - assertTrue(replacedIncompatible.accepted()); - assertEquals(HydrologyCaveAction.WET_SOURCE, replacedIncompatible.actions().get(target)); - assertEquals(HydrologyCaveRejection.LAVA_CONTACT, rejectedLava.rejection()); - } - - @Test - public void throatRadiusExpandsTheConnectedBoreFootprint() { - TestVoxelView thinView = new TestVoxelView(); - TestVoxelView thickView = new TestVoxelView(); - CavePosition target = position(0, 7, 0); - thinView.set(CaveVoxel.CAVE_AIR, target); - thickView.set(CaveVoxel.CAVE_AIR, target); - HydrologyCaveSource source = source(102L, 10, HydrologyCaveMode.CLOSED_COMPONENT, target); - HydrologyCavePlannerSettings thinSettings = detailedSettings( - 1, - 0, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - HydrologyCavePlannerSettings thickSettings = detailedSettings( - 3, - 0, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - HydrologyCavePlan thin = planner.plan(thinView, source, thinSettings); - HydrologyCavePlan thick = planner.plan(thickView, source, thickSettings); - - assertTrue(thin.accepted()); - assertTrue(thick.accepted()); - assertFalse(thin.actions().containsKey(position(2, 10, 0))); - assertEquals(HydrologyCaveAction.WET_SOURCE, thick.actions().get(position(2, 10, 0))); - assertTrue(mutationCount(thick) > mutationCount(thin)); - } - - @Test - public void generatedGrottoRetainsConfiguredDryHeadroom() { - TestVoxelView view = new TestVoxelView(); - HydrologyCaveSource source = source(103L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)); - HydrologyCavePlannerSettings acceptedSettings = detailedSettings( - 1, - 2, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - HydrologyCavePlannerSettings rejectedSettings = detailedSettings( - 1, - 5, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - HydrologyCavePlan accepted = planner.plan(view, source, acceptedSettings); - HydrologyCavePlan rejected = planner.plan(view, source, rejectedSettings); - - assertTrue(accepted.accepted()); - assertEquals(HydrologyCaveAction.FALLING_FLUID, accepted.actions().get(position(0, 11, 0))); - assertEquals(HydrologyCaveAction.FALLING_FLUID, accepted.actions().get(position(0, 12, 0))); - assertEquals(HydrologyCaveRejection.DRY_HEADROOM_LIMIT, rejected.rejection()); - } - - @Test - public void configuredGrottoPredicateChangesFootprintDeterministically() { - TestVoxelView view = new TestVoxelView(); - HydrologyCaveSource source = source(104L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)); - HydrologyCavePlannerSettings ellipsoid = detailedSettings( - 1, - 0, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - HydrologyCaveGrottoShape compactShape = (candidate, settings, dx, dy, dz) -> { - double horizontal = settings.grottoHorizontalRadius(); - double vertical = settings.grottoVerticalRadius(); - double normalized = (dx * dx / (horizontal * horizontal)) - + (dy * dy / (vertical * vertical)) - + (dz * dz / (horizontal * horizontal)); - return normalized <= 0.5D; - }; - HydrologyCavePlannerSettings compact = detailedSettings( - 1, - 0, - compactShape, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - HydrologyCavePlan full = planner.plan(view, source, ellipsoid); - HydrologyCavePlan firstCompact = planner.plan(view, source, compact); - HydrologyCavePlan secondCompact = planner.plan(view, source, compact); - - assertTrue(full.accepted()); - assertTrue(firstCompact.accepted()); - assertEquals(firstCompact, secondCompact); - assertTrue(mutationCount(full) > mutationCount(firstCompact)); - } - - @Test - public void radiusLimitRejectsWholeComponentWithoutClipping() { - TestVoxelView view = new TestVoxelView(); - for (int x = 0; x <= 3; x++) { - view.set(CaveVoxel.CAVE_AIR, position(x, 7, 0)); - } - HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings( - 2, - 20, - 64, - 32, - 1, - 1, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - HydrologyCavePlan plan = planner.plan( - view, - source(2L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), - settings - ); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.RADIUS_LIMIT); - } - - @Test - public void depthLimitRejectsWholeComponentWithoutClipping() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 8, 0), position(0, 7, 0)); - HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings( - 8, - 4, - 64, - 32, - 1, - 1, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - HydrologyCavePlan plan = planner.plan( - view, - source(3L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 8, 0)), - settings - ); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.DEPTH_LIMIT); - } - - @Test - public void volumeLimitRejectsWholeComponentWithoutClipping() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); - HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings( - 8, - 20, - 2, - 32, - 1, - 1, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - HydrologyCavePlan plan = planner.plan( - view, - source(4L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), - settings - ); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.VOLUME_LIMIT); - } - - @Test - public void worldBoundaryRejectsWholeComponent() { - TestVoxelView view = new TestVoxelView(-16, 0, 0, 32, -16, 16); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - - HydrologyCavePlan plan = planner.plan( - view, - source(5L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), - settings() - ); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.WORLD_BOUNDARY); - } - - @Test - public void openSurfaceRejectsWholeComponent() { - TestVoxelView view = new TestVoxelView(); - CavePosition target = position(0, 7, 0); - view.set(CaveVoxel.CAVE_AIR, target); - view.openToSurface(target); - - HydrologyCavePlan plan = planner.plan( - view, - source(6L, 10, HydrologyCaveMode.CLOSED_COMPONENT, target), - settings() - ); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.OPEN_SURFACE); - } - - @Test - public void lavaAndIncompatibleFluidContactsRejectWholeComponent() { - TestVoxelView lavaView = new TestVoxelView(); - lavaView.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - lavaView.set(CaveVoxel.LAVA, position(1, 7, 0)); - TestVoxelView fluidView = new TestVoxelView(); - fluidView.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - fluidView.set(CaveVoxel.INCOMPATIBLE_FLUID, position(1, 7, 0)); - HydrologyCaveSource source = source(7L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); - - HydrologyCavePlan lavaPlan = planner.plan(lavaView, source, settings()); - HydrologyCavePlan fluidPlan = planner.plan(fluidView, source, settings()); - - assertRejectedWithoutPublication(lavaPlan, HydrologyCaveRejection.LAVA_CONTACT); - assertRejectedWithoutPublication(fluidPlan, HydrologyCaveRejection.INCOMPATIBLE_FLUID); - } - - @Test - public void fallingHazardAbovePoolRejectsWholeComponent() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - view.set(CaveVoxel.LAVA, position(1, 11, 0)); - - HydrologyCavePlan plan = planner.plan( - view, - source(15L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), - settings() - ); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.LAVA_CONTACT); - } - - @Test - public void generatedGrottoHasBoundedWetDryAndGuardActions() { - TestVoxelView view = new TestVoxelView(); - HydrologyCaveSource source = source(8L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)); - - HydrologyCavePlan plan = planner.plan(view, source, settings()); - - assertTrue(plan.accepted()); - assertEquals(HydrologyCaveAction.FALLING_FLUID, plan.actions().get(position(0, 12, 0))); - assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(0, 8, 0))); - assertTrue(plan.actions().containsValue(HydrologyCaveAction.SEAL_GUARD)); - assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); - assertFalse(plan.actions().containsKey(position(0, 13, 0))); - assertMutationConnected(plan, source.entry(), source.target()); - } - - @Test - public void generatedGrottoAcceptsItsCompleteSurfaceMouth() { - TestVoxelView view = new TestVoxelView(); - CavePosition[] wetMouth = { - position(-1, 12, 0), - position(1, 12, 0), - position(0, 12, -1), - position(0, 12, 1) - }; - CavePosition[] dryMouth = { - position(-1, 13, 0), - position(1, 13, 0), - position(0, 13, -1), - position(0, 13, 1) - }; - view.set(CaveVoxel.COMPATIBLE_FLUID, wetMouth); - view.set(CaveVoxel.CAVE_AIR, dryMouth); - for (CavePosition position : wetMouth) { - view.openToSurface(position); - } - for (CavePosition position : dryMouth) { - view.openToSurface(position); - } - HydrologyCaveSource source = source(108L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)); - - HydrologyCavePlan plan = planner.plan(view, source, detailedSettings( - 2, - 2, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - )); - - assertTrue(plan.rejection().toString(), plan.accepted()); - for (CavePosition position : wetMouth) { - assertEquals(HydrologyCaveAction.FALLING_FLUID, plan.actions().get(position)); - } - } - - @Test - public void generatedGrottoRejectsAnOpenShell() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(3, 8, 0)); - - HydrologyCavePlan plan = planner.plan( - view, - source(9L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)), - settings() - ); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.GROTTO_SHELL_OPEN); - } - - @Test - public void deepPoolOpensOnlyAboveItsContainedFluidHead() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 11, 0), position(0, 13, 0)); - HydrologyCaveSource source = new HydrologyCaveSource( - 109L, - position(0, 10, 0), - position(0, 8, 0), - 10, - HydrologyCaveMode.DEEP_POOL - ); - - HydrologyCavePlan plan = planner.plan(view, source, detailedSettings( - 1, - 2, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.REJECT_EXISTING - )); - - assertTrue(plan.rejection().toString(), plan.accepted()); - assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); - assertEquals(HydrologyCaveAction.DRY_AIR, plan.actions().get(position(0, 11, 0))); - assertFalse(plan.actions().containsKey(position(0, 13, 0))); - } - - @Test - public void deepPoolRejectsCaveLeakAtOrBelowItsFluidHead() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(5, 8, 0)); - HydrologyCaveSource source = new HydrologyCaveSource( - 110L, - position(0, 10, 0), - position(0, 8, 0), - 10, - HydrologyCaveMode.DEEP_POOL - ); - - HydrologyCavePlan plan = planner.plan(view, source, detailedSettings( - 1, - 2, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.REJECT_EXISTING - )); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.GROTTO_SHELL_OPEN); - } - - @Test - public void combinedModeUsesClosedCaveOrGeneratedGrottoFromBaseline() { - TestVoxelView caveView = new TestVoxelView(); - CavePosition target = position(0, 8, 0); - caveView.set(CaveVoxel.CAVE_AIR, target); - TestVoxelView solidView = new TestVoxelView(); - HydrologyCaveSource source = source(10L, 10, HydrologyCaveMode.GROTTO_OR_CLOSED_COMPONENT, target); - - HydrologyCavePlan cavePlan = planner.plan(caveView, source, settings()); - HydrologyCavePlan grottoPlan = planner.plan(solidView, source, settings()); - - assertTrue(cavePlan.accepted()); - assertTrue(grottoPlan.accepted()); - assertEquals(CaveVoxel.CAVE_AIR, cavePlan.baselinePreconditions().get(target).voxel()); - assertEquals(CaveVoxel.SOLID, grottoPlan.baselinePreconditions().get(target).voxel()); - } - - @Test - public void waterfallKeepsColumnDistinctFromSourcePoolAndDryAir() { - TestVoxelView view = new TestVoxelView(); - HydrologyCaveSource source = source(11L, 10, HydrologyCaveMode.WATERFALL_POOL, position(0, 8, 0)); - - HydrologyCavePlan plan = planner.plan(view, source, settings()); - - assertTrue(plan.accepted()); - assertEquals(HydrologyCaveAction.FALLING_FLUID, plan.actions().get(position(0, 12, 0))); - assertEquals(HydrologyCaveAction.FALLING_FLUID, plan.actions().get(position(0, 11, 0))); - assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); - assertFalse(plan.actions().containsValue(HydrologyCaveAction.DRY_AIR)); - } - - @Test - public void waterfallRejectsAnUnsealedFallingColumn() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 8, 0), position(1, 11, 0)); - HydrologyCaveSource source = source(13L, 10, HydrologyCaveMode.WATERFALL_POOL, position(0, 8, 0)); - - HydrologyCavePlan plan = planner.plan(view, source, settings()); - - assertRejectedWithoutPublication(plan, HydrologyCaveRejection.WATERFALL_SHAFT_OPEN); - } - - @Test - public void overlapArbitrationIsOrderIndependentAndNamesWinner() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); - HydrologyCaveSource lowerHead = new HydrologyCaveSource( - 1L, - position(2, 12, 0), - position(2, 7, 0), - 10, - HydrologyCaveMode.CLOSED_COMPONENT - ); - HydrologyCaveSource higherHead = new HydrologyCaveSource( - 20L, - position(0, 12, 0), - position(0, 7, 0), - 11, - HydrologyCaveMode.CLOSED_COMPONENT - ); - - HydrologyCavePlanningResult forward = planner.planAll(view, List.of(lowerHead, higherHead), settings()); - HydrologyCavePlanningResult reverse = planner.planAll(view, List.of(higherHead, lowerHead), settings()); - - assertEquals(forward, reverse); - assertEquals(higherHead, forward.plans().get(0).source()); - assertTrue(forward.plans().get(0).accepted()); - assertEquals(HydrologyCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); - assertEquals(20L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); - assertEquals(forward.actions().keySet(), forward.baselinePreconditions().keySet()); - } - - @Test - public void equalHeadOverlapUsesLowestStableSourceId() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); - HydrologyCaveSource largerId = new HydrologyCaveSource( - 20L, - position(0, 12, 0), - position(0, 7, 0), - 10, - HydrologyCaveMode.CLOSED_COMPONENT - ); - HydrologyCaveSource smallerId = new HydrologyCaveSource( - 1L, - position(2, 12, 0), - position(2, 7, 0), - 10, - HydrologyCaveMode.CLOSED_COMPONENT - ); - - HydrologyCavePlanningResult result = planner.planAll(view, List.of(largerId, smallerId), settings()); - - assertEquals(smallerId, result.plans().get(0).source()); - assertTrue(result.plans().get(0).accepted()); - assertEquals(1L, result.plans().get(1).arbitrationWinnerSourceId().getAsLong()); - } - - @Test - public void arbitrationUsesDirectHigherPriorityPlansEvenWhenTheyAreRejected() { - TestVoxelView view = new TestVoxelView(); - view.set( - CaveVoxel.CAVE_AIR, - position(0, 7, 0), - position(2, 7, 0), - position(4, 7, 0) - ); - HydrologyCaveSource lowest = new HydrologyCaveSource( - 30L, - position(0, 12, 0), - position(0, 7, 0), - 10, - HydrologyCaveMode.CLOSED_COMPONENT - ); - HydrologyCaveSource middle = new HydrologyCaveSource( - 20L, - position(2, 12, 0), - position(2, 7, 0), - 11, - HydrologyCaveMode.CLOSED_COMPONENT - ); - HydrologyCaveSource highest = new HydrologyCaveSource( - 10L, - position(4, 12, 0), - position(4, 7, 0), - 12, - HydrologyCaveMode.CLOSED_COMPONENT - ); - - HydrologyCavePlanningResult forward = planner.planAll( - view, - List.of(lowest, middle, highest), - settings() - ); - HydrologyCavePlanningResult reverse = planner.planAll( - view, - List.of(highest, middle, lowest), - settings() - ); - - assertEquals(forward, reverse); - assertTrue(forward.plans().get(0).accepted()); - assertEquals(HydrologyCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); - assertEquals(10L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); - assertEquals(HydrologyCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(2).rejection()); - assertEquals(20L, forward.plans().get(2).arbitrationWinnerSourceId().getAsLong()); - assertFalse(forward.actions().containsKey(position(0, 7, 0))); - assertTrue(forward.actions().containsKey(position(4, 7, 0))); - } - @Test public void planOutputsAreImmutable() { TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - HydrologyCaveSource source = source(12L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); - HydrologyCavePlanningResult result = planner.planAll(view, List.of(source), settings()); + HydrologyCaveCandidate candidate = candidate(12L, 10, "water", + Map.of(position(0, 10, 0), HydrologyCaveAction.WET_SOURCE), settings()); + HydrologyCavePlanningResult result = planner.validateAll(view, List.of(candidate)); HydrologyCavePlan plan = result.plans().get(0); + assertTrue(plan.accepted()); assertThrows( UnsupportedOperationException.class, @@ -656,10 +75,12 @@ public class HydrologyCaveContainmentPlannerTest { 12, 20, 1, - 32, 2, 2, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + 0, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE, + 12, + 20 ); HydrologyCaveCandidate overflow = candidate( 202L, @@ -754,10 +175,12 @@ public class HydrologyCaveContainmentPlannerTest { 12, 20, 1, - 32, 2, 2, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + 0, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE, + 12, + 20 ); HydrologyCaveCandidate candidate = candidate( 205L, @@ -1068,12 +491,7 @@ public class HydrologyCaveContainmentPlannerTest { 10, "water", Map.of(dryHeadroom, HydrologyCaveAction.DRY_AIR), - detailedSettings( - 2, - 2, - HydrologyCaveGrottoShape.ELLIPSOID, - HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE - ) + detailedSettings(2, HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE) ) ); @@ -1152,40 +570,6 @@ public class HydrologyCaveContainmentPlannerTest { assertEquals(HydrologyCaveAction.SEAL_GUARD, forward.actions().get(secondPosition)); } - private void assertMutationConnected(HydrologyCavePlan plan, CavePosition start, CavePosition expected) { - Set mutations = new HashSet<>(); - for (Map.Entry entry : plan.actions().entrySet()) { - if (entry.getValue() != HydrologyCaveAction.SEAL_GUARD) { - mutations.add(entry.getKey()); - } - } - Queue queue = new ArrayDeque<>(); - Set visited = new HashSet<>(); - queue.add(start); - visited.add(start); - while (!queue.isEmpty()) { - CavePosition position = queue.remove(); - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (mutations.contains(neighbor) && visited.add(neighbor)) { - queue.add(neighbor); - } - } - } - assertTrue(visited.contains(expected)); - assertEquals(mutations, visited); - } - - private int mutationCount(HydrologyCavePlan plan) { - int count = 0; - for (HydrologyCaveAction action : plan.actions().values()) { - if (action != HydrologyCaveAction.SEAL_GUARD) { - count++; - } - } - return count; - } - private void assertRejectedWithoutPublication(HydrologyCavePlan plan, HydrologyCaveRejection rejection) { assertFalse(plan.accepted()); assertEquals(rejection, plan.rejection()); @@ -1193,10 +577,6 @@ public class HydrologyCaveContainmentPlannerTest { assertTrue(plan.baselinePreconditions().isEmpty()); } - private HydrologyCaveSource source(long sourceId, int waterHeadY, HydrologyCaveMode mode, CavePosition target) { - return new HydrologyCaveSource(sourceId, position(0, 12, 0), target, waterHeadY, mode); - } - private HydrologyCaveCandidate candidate( long sourceId, int waterHeadY, @@ -1226,26 +606,33 @@ public class HydrologyCaveContainmentPlannerTest { } private HydrologyCavePlannerSettings settings(HydrologyCaveFluidPolicy policy) { - return new HydrologyCavePlannerSettings(12, 20, 64, 32, 2, 2, policy); + return new HydrologyCavePlannerSettings( + 12, + 20, + 64, + 2, + 2, + 0, + policy, + 12, + 20 + ); } private HydrologyCavePlannerSettings detailedSettings( - int throatRadius, int dryHeadroom, - HydrologyCaveGrottoShape shape, HydrologyCaveFluidPolicy policy ) { return new HydrologyCavePlannerSettings( 12, 20, 2048, - 32, - throatRadius, 4, 4, dryHeadroom, policy, - shape + 12, + 20 ); } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveIterationOrderTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveIterationOrderTest.java index 3eb6c1f42..150897509 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveIterationOrderTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/cave/HydrologyCaveIterationOrderTest.java @@ -136,13 +136,10 @@ public final class HydrologyCaveIterationOrderTest { 8, 16, 64, - 16, - 1, 2, 2, 4, HydrologyCaveFluidPolicy.REJECT_EXISTING, - HydrologyCaveGrottoShape.ELLIPSOID, 8, 16 ); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java index c093fb46a..e8db7a54f 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -389,6 +389,28 @@ public class IrisHydrologyRoutingTerrainSamplerTest { assertEquals(2, heightCalls.get()); } + @Test + public void nonFiniteHeightsAreRetriedUntilFiniteAndRetainedWithoutBasisLoading() { + double[] heights = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, 87D}; + AtomicInteger heightCalls = new AtomicInteger(); + AtomicInteger basisCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources((x, z, height) -> { + basisCalls.incrementAndGet(); + return basis(x, z, height); + }, (x, z) -> heights[heightCalls.getAndIncrement()], (x, z) -> false, 63), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16)); + for (int index = 0; index < 3; index++) { + assertThrows(IllegalArgumentException.class, () -> sampler.sampleLandHeight(0, 0)); + assertEquals(0, sampler.naturalHeightCacheSize()); + } + assertEquals(87D, sampler.sampleLandHeight(0, 0), 0D); + assertEquals(87D, sampler.sampleLandHeight(0, 0), 0D); + assertEquals(4, heightCalls.get()); + assertEquals(3, basisCalls.get()); + assertEquals(1, sampler.naturalHeightCacheSize()); + } + @Test public void adjacentGridsShareBasesAndProduceIdenticalOverlap() { AtomicInteger calls = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java index 0b6364574..ac8e1a3f4 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java @@ -146,15 +146,19 @@ public class ErosionFieldCompilerTest { public void rasterBatchesPreserveExactDemandAndColumnOrder() { Map serialCalls = new HashMap<>(); Map batchCalls = new HashMap<>(); + List serialOrder = new ArrayList<>(); + List batchOrder = new ArrayList<>(); AtomicInteger batches = new AtomicInteger(); HydrologyTerrainSampler serial = (x, z) -> { serialCalls.merge(RiverFootprint.pack(x, z), 1, Integer::sum); + serialOrder.add(RiverFootprint.pack(x, z)); return HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 7), 0D, "land"); }; HydrologyTerrainSampler batched = new HydrologyTerrainSampler() { @Override public HydrologyTerrainSample sample(int x, int z) { batchCalls.merge(RiverFootprint.pack(x, z), 1, Integer::sum); + batchOrder.add(RiverFootprint.pack(x, z)); return HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 7), 0D, "land"); } @@ -176,6 +180,7 @@ public class ErosionFieldCompilerTest { assertEquals(expected.uncontainedWetCells(), actual.uncontainedWetCells()); assertEquals(expected.bankExcavation(), actual.bankExcavation()); assertEquals(serialCalls, batchCalls); + assertEquals(serialOrder, batchOrder); assertTrue(batches.get() > 1); } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyCaveVoxelViewTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyCaveVoxelViewTest.java index 9c1b81112..8ac017d24 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyCaveVoxelViewTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleHydrologyCaveVoxelViewTest.java @@ -15,6 +15,7 @@ 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 art.arcane.volmlib.util.matter.MatterSlice; import org.junit.Test; import java.util.ArrayList; @@ -26,6 +27,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; @@ -40,7 +42,7 @@ public class MantleHydrologyCaveVoxelViewTest { @SuppressWarnings("unchecked") public void authoritativeChunkFlagControlsWhetherCarvingInputIsRequired() { Mantle mantle = mock(Mantle.class); - doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt()); + configurePinnedReads(mantle); when(mantle.hasFlag(2, -3, ReservedFlag.CARVED)).thenReturn(true); assertFalse(MantleHydrologyCaveVoxelView.requiresCarvingInput(mantle, 2, -3)); @@ -54,7 +56,7 @@ public class MantleHydrologyCaveVoxelViewTest { @SuppressWarnings("unchecked") public void mapsCarvedAndStoredMatterAndLoadsEachChunkOnce() { Mantle mantle = mock(Mantle.class); - doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt()); + configurePinnedReads(mantle); MatterCavern air = new MatterCavern(true, "", (byte) 0); MatterCavern water = new MatterCavern(true, "", (byte) 1); MatterCavern lava = new MatterCavern(true, "", (byte) 2); @@ -100,7 +102,7 @@ public class MantleHydrologyCaveVoxelViewTest { @SuppressWarnings("unchecked") public void carvedVerticalShaftIsOpenToSurface() { Mantle mantle = mock(Mantle.class); - doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt()); + configurePinnedReads(mantle); MatterCavern air = new MatterCavern(true, "", (byte) 0); doAnswer(invocation -> { int x = invocation.getArgument(0); @@ -126,7 +128,7 @@ public class MantleHydrologyCaveVoxelViewTest { @SuppressWarnings("unchecked") public void terrainUndercutsRemainOpenWithoutCaveMatter() { Mantle mantle = mock(Mantle.class); - doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt()); + configurePinnedReads(mantle); MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView(mantle, 128, new MantleHydrologyCaveVoxelView.TerrainSources((x, z) -> new MantleHydrologyCaveVoxelView.TerrainColumn(80, false), (x, y, z) -> y <= 40 || y >= 65 && y <= 80, @@ -212,8 +214,34 @@ public class MantleHydrologyCaveVoxelViewTest { verify(complex, never()).sampleHydrologyColumn(anyDouble(), anyDouble()); } + @SuppressWarnings("unchecked") + private static void configurePinnedReads(Mantle mantle) { + when(mantle.getWorldHeight()).thenReturn(128); + when(mantle.useChunk(anyInt(), anyInt())).thenAnswer(chunkInvocation -> { + int chunkX = chunkInvocation.getArgument(0); + int chunkZ = chunkInvocation.getArgument(1); + MantleChunk chunk = mock(MantleChunk.class); + when(chunk.exists(anyInt())).thenReturn(true); + when(chunk.get(anyInt())).thenAnswer(sectionInvocation -> { + int section = sectionInvocation.getArgument(0); + Matter matter = mock(Matter.class); + when(matter.getSlice(any())).thenAnswer(sliceInvocation -> { + Class type = sliceInvocation.getArgument(0); + MatterSlice slice = mock(MatterSlice.class); + when(slice.get(anyInt(), anyInt(), anyInt())).thenAnswer(cellInvocation -> + mantle.get((chunkX << 4) + (int) cellInvocation.getArgument(0), + (section << 4) + (int) cellInvocation.getArgument(1), + (chunkZ << 4) + (int) cellInvocation.getArgument(2), type)); + return slice; + }); + return matter; + }); + return chunk; + }); + } + private static Engine configuredEngine(Mantle mantle) { - doReturn(mock(MantleChunk.class)).when(mantle).getChunk(anyInt(), anyInt()); + configurePinnedReads(mantle); when(mantle.getWorldHeight()).thenReturn(128); when(mantle.hasFlag(anyInt(), anyInt(), eq(ReservedFlag.CARVED))).thenReturn(true); Engine engine = mock(Engine.class); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterNeighborhoodBenchmarkTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterNeighborhoodBenchmarkTest.java deleted file mode 100644 index 13ee3a937..000000000 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterNeighborhoodBenchmarkTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package art.arcane.iris.generation.mantle; - -import art.arcane.iris.pack.value.IrisPosition; -import org.junit.Assume; -import org.junit.Test; - -import java.lang.management.ManagementFactory; -import java.lang.management.ThreadMXBean; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.function.Supplier; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class MantleWriterNeighborhoodBenchmarkTest { - private static final int WARMUP = 400; - private static final int ROUNDS = 40; - private static final int PASSES = 6; - private static final double RADIUS = 5.0D; - - @Test - public void hollowingAllocatesLessThanThePositionProbeImplementation() { - Set ballooned = MantleWriter.getBallooned(line(), RADIUS); - assertTrue("benchmark input is too small to be meaningful: " + ballooned.size(), ballooned.size() > 4_000); - - Comparison comparison = compare( - () -> MantleWriter.getHollowed(ballooned), - () -> MantleWriterNeighborhoodTest.referenceHollowed(ballooned)); - - System.out.println("[bench] getHollowed voxels=" + ballooned.size() - + " primitiveKeys=" + comparison.candidate() - + " positionProbes=" + comparison.reference()); - - assertEquals(comparison.reference().result(), comparison.candidate().result()); - - Assume.assumeTrue("per-thread allocation counters are unavailable on this JVM", - comparison.candidate().bytes() >= 0L); - assertTrue("primitive-keyed hollowing allocated " + comparison.candidate().bytes() - + " bytes against " + comparison.reference().bytes() + " for the position-probe implementation", - comparison.candidate().bytes() < comparison.reference().bytes()); - } - - @Test - public void ballooningMatchesTheVarargsDistanceImplementationExactly() { - Set seeds = line(); - - Comparison comparison = compare( - () -> MantleWriter.getBallooned(seeds, RADIUS), - () -> MantleWriterNeighborhoodTest.referenceBallooned(seeds, RADIUS)); - - System.out.println("[bench] getBallooned seeds=" + seeds.size() - + " fixedArityDistance=" + comparison.candidate() - + " varargsDistance=" + comparison.reference()); - - assertEquals(comparison.reference().result(), comparison.candidate().result()); - } - - private static Set line() { - Set positions = new LinkedHashSet<>(); - for (int step = 0; step <= 48; step++) { - positions.add(new IrisPosition(step, 60 + (step % 5), step / 2)); - } - return positions; - } - - private static Comparison compare(Supplier> candidate, Supplier> reference) { - for (int round = 0; round < WARMUP; round++) { - candidate.get(); - reference.get(); - } - - Measurement bestCandidate = null; - Measurement bestReference = null; - for (int pass = 0; pass < PASSES; pass++) { - bestCandidate = best(bestCandidate, measure(candidate)); - bestReference = best(bestReference, measure(reference)); - } - - return new Comparison(bestCandidate, bestReference); - } - - private static Measurement best(Measurement current, Measurement measured) { - if (current == null) { - return measured; - } - - return new Measurement( - measured.result(), - current.bytes() < 0L || measured.bytes() < 0L ? -1L : Math.min(current.bytes(), measured.bytes()), - Math.min(current.nanos(), measured.nanos())); - } - - private static Measurement measure(Supplier> work) { - Set result = null; - long startBytes = allocatedBytes(); - long startNanos = System.nanoTime(); - for (int round = 0; round < ROUNDS; round++) { - result = work.get(); - } - long elapsedNanos = System.nanoTime() - startNanos; - long endBytes = allocatedBytes(); - long bytes = startBytes < 0L || endBytes < 0L ? -1L : (endBytes - startBytes) / ROUNDS; - - return new Measurement(result, bytes, elapsedNanos / ROUNDS); - } - - private static long allocatedBytes() { - ThreadMXBean threads = ManagementFactory.getThreadMXBean(); - if (!(threads instanceof com.sun.management.ThreadMXBean hotspot) || !hotspot.isThreadAllocatedMemoryEnabled()) { - return -1L; - } - return hotspot.getThreadAllocatedBytes(Thread.currentThread().threadId()); - } - - private record Comparison(Measurement candidate, Measurement reference) { - } - - private record Measurement(Set result, long bytes, long nanos) { - @Override - public String toString() { - return "{bytes/op=" + bytes + ", ns/op=" + nanos + ", voxels=" + result.size() + "}"; - } - } -} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterNeighborhoodTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterNeighborhoodTest.java deleted file mode 100644 index feacdca27..000000000 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterNeighborhoodTest.java +++ /dev/null @@ -1,184 +0,0 @@ -package art.arcane.iris.generation.mantle; - -import art.arcane.iris.pack.value.IrisPosition; -import org.junit.Test; - -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class MantleWriterNeighborhoodTest { - @Test - public void balloonedVoxelsMatchTheReferenceSphereForFractionalRadii() { - for (double radius : new double[]{0.0D, 0.5D, 1.0D, 1.5D, 2.0D, 3.7D, 5.0D}) { - Set seeds = line(-3, 60, 7, 4, 63, 9); - assertEquals("radius " + radius, referenceBallooned(seeds, radius), MantleWriter.getBallooned(seeds, radius)); - } - } - - @Test - public void hollowedVoxelsMatchTheReferenceSurfaceExtraction() { - Set ballooned = MantleWriter.getBallooned(line(0, 40, 0, 6, 44, 3), 4.0D); - - assertEquals(referenceHollowed(ballooned), MantleWriter.getHollowed(ballooned)); - } - - @Test - public void hollowingASolidBlockKeepsOnlyItsShell() { - Set solid = new LinkedHashSet<>(); - for (int x = 0; x < 5; x++) { - for (int y = 0; y < 5; y++) { - for (int z = 0; z < 5; z++) { - solid.add(new IrisPosition(x, y, z)); - } - } - } - - Set hollow = MantleWriter.getHollowed(solid); - - assertEquals(98, hollow.size()); - assertFalse(hollow.contains(new IrisPosition(2, 2, 2))); - assertTrue(hollow.contains(new IrisPosition(0, 2, 2))); - } - - @Test - public void aSetTooWideToPackFallsBackToPositionEqualityWithoutChangingTheResult() { - Set spread = new LinkedHashSet<>(); - spread.add(new IrisPosition(-20_000_000, 60, 0)); - spread.add(new IrisPosition(20_000_000, 60, 0)); - spread.add(new IrisPosition(20_000_001, 60, 0)); - - assertEquals(referenceHollowed(spread), MantleWriter.getHollowed(spread)); - assertEquals(spread, MantleWriter.getHollowed(spread)); - } - - @Test - public void maskedVoxelsMatchTheReferenceMaskExpansion() { - Set seeds = line(0, 70, 0, 3, 70, 4); - Set masks = new LinkedHashSet<>(); - masks.add(new IrisPosition(0, 0, 0)); - masks.add(new IrisPosition(1, 0, 0)); - masks.add(new IrisPosition(0, 1, 0)); - masks.add(new IrisPosition(-1, 0, 1)); - masks.add(new IrisPosition(0, 0, -2)); - - assertEquals(referenceMasked(seeds, masks, 3.0D), MantleWriter.getMasked(seeds, masks, 3.0D)); - } - - @Test - public void anEmptyMaskProducesNoVoxels() { - assertTrue(MantleWriter.getMasked(line(0, 70, 0, 2, 70, 2), new LinkedHashSet<>(), 2.0D).isEmpty()); - } - - @Test - public void aPositionLookupReportsMembershipOutsideItsBoundingBoxAsAbsent() { - Set positions = new LinkedHashSet<>(); - positions.add(new IrisPosition(10, 20, 30)); - MantleWriter.PositionLookup lookup = MantleWriter.PositionLookup.of(positions); - - assertTrue(lookup.contains(10, 20, 30)); - assertFalse(lookup.contains(9, 20, 30)); - assertFalse(lookup.contains(10, 20, 31)); - assertFalse(lookup.contains(Integer.MIN_VALUE, 20, 30)); - assertFalse(lookup.contains(Integer.MAX_VALUE, 20, 30)); - } - - @Test - public void anEmptyPositionLookupContainsNothing() { - MantleWriter.PositionLookup lookup = MantleWriter.PositionLookup.of(new LinkedHashSet<>()); - - assertFalse(lookup.contains(0, 0, 0)); - assertFalse(lookup.contains(-1, -1, -1)); - } - - private static Set line(int x1, int y1, int z1, int x2, int y2, int z2) { - Set positions = new LinkedHashSet<>(); - int steps = Math.max(Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1)), Math.abs(z2 - z1)); - for (int step = 0; step <= steps; step++) { - double t = steps == 0 ? 0D : (double) step / steps; - positions.add(new IrisPosition( - (int) Math.round(x1 + t * (x2 - x1)), - (int) Math.round(y1 + t * (y2 - y1)), - (int) Math.round(z1 + t * (z2 - z1)))); - } - return positions; - } - - static Set referenceBallooned(Set vset, double radius) { - Set returnset = new HashSet<>(); - int ceilrad = (int) Math.ceil(radius); - double r2 = Math.pow(radius, 2); - - for (IrisPosition v : vset) { - int tipx = v.getX(); - int tipy = v.getY(); - int tipz = v.getZ(); - - for (int loopx = tipx - ceilrad; loopx <= tipx + ceilrad; loopx++) { - for (int loopy = tipy - ceilrad; loopy <= tipy + ceilrad; loopy++) { - for (int loopz = tipz - ceilrad; loopz <= tipz + ceilrad; loopz++) { - if (referenceHypot(loopx - tipx, loopy - tipy, loopz - tipz) <= r2) { - returnset.add(new IrisPosition(loopx, loopy, loopz)); - } - } - } - } - } - return returnset; - } - - static Set referenceHollowed(Set vset) { - Set returnset = new LinkedHashSet<>(); - for (IrisPosition v : vset) { - double x = v.getX(); - double y = v.getY(); - double z = v.getZ(); - if (!(vset.contains(new IrisPosition(x + 1, y, z)) - && vset.contains(new IrisPosition(x - 1, y, z)) - && vset.contains(new IrisPosition(x, y + 1, z)) - && vset.contains(new IrisPosition(x, y - 1, z)) - && vset.contains(new IrisPosition(x, y, z + 1)) - && vset.contains(new IrisPosition(x, y, z - 1)))) { - returnset.add(v); - } - } - return returnset; - } - - static Set referenceMasked(Set vectors, Set masks, double radius) { - Set vset = new LinkedHashSet<>(); - int ceil = (int) Math.ceil(radius); - double r2 = Math.pow(radius, 2); - - for (IrisPosition v : vectors) { - int tipX = v.getX(); - int tipY = v.getY(); - int tipZ = v.getZ(); - - for (int x = -ceil; x <= ceil; x++) { - for (int y = -ceil; y <= ceil; y++) { - for (int z = -ceil; z <= ceil; z++) { - if (referenceHypot(x, y, z) > r2 || !masks.contains(new IrisPosition(x, y, z))) { - continue; - } - vset.add(new IrisPosition(tipX + x, tipY + y, tipZ + z)); - } - } - } - } - - return vset; - } - - private static double referenceHypot(double... pars) { - double sum = 0; - for (double d : pars) { - sum += Math.pow(d, 2); - } - return sum; - } -} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java index 840a0cab9..ec805cfaa 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleWriterPreObjectJournalTest.java @@ -3,6 +3,9 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.integration.Identifier; +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.world.storage.matter.TileWrapper; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; @@ -41,6 +44,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -97,6 +101,232 @@ public class MantleWriterPreObjectJournalTest { IrisPlatforms.unbind(); } + @Test + public void acceptedBlockReplacementClearsOldTileInDirectAndReplayedPlacement() { + NativeBlockState replacement = mock(NativeBlockState.class); + writer.setTile(X, Y, Z, mock(TileData.class)); + writer.set(X, Y, Z, replacement); + assertNull(writer.getDataIfPresent(X, Y, Z, TileWrapper.class)); + + writer.setTile(X, Y, Z, mock(TileData.class)); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0); + source.set(X, Y, Z, replacement); + assertNull(source.getDataIfPresent(X, Y, Z, TileWrapper.class)); + ObjectDestinationTransaction replay = new ObjectDestinationTransaction(writer, 0, 0); + replay.apply(source.sourcePlanSince(0)); + replay.commit(); + assertNull(writer.getDataIfPresent(X, Y, Z, TileWrapper.class)); + } + + @Test + public void customBlockReplacementClearsOldTileDuringReplay() { + NativeBlockState custom = mock(NativeBlockState.class); + NativeBlockState base = mock(NativeBlockState.class); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn("test:replacement"); + when(custom.placementBaseState()).thenReturn(base); + writer.setTile(X, Y, Z, mock(TileData.class)); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0); + source.set(X, Y, Z, custom); + assertNull(source.getDataIfPresent(X, Y, Z, TileWrapper.class)); + ObjectDestinationTransaction replay = new ObjectDestinationTransaction(writer, 0, 0); + replay.apply(source.sourcePlanSince(0)); + replay.commit(); + assertSame(base, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertNull(writer.getDataIfPresent(X, Y, Z, TileWrapper.class)); + } + + @Test + public void continuationPayloadKeepsOnlyTileForLastBlock() { + Matter payload = new IrisMatter(16, 16, 16); + TileData oldTile = mock(TileData.class); + NativeBlockState replacement = mock(NativeBlockState.class); + ObjectContinuationPersistence.put(payload, X, Y, Z, oldTile); + ObjectContinuationPersistence.put(payload, X, Y, Z, replacement); + assertNull(payload.slice(TileWrapper.class).get(X, Y, Z)); + TileData newTile = mock(TileData.class); + ObjectContinuationPersistence.put(payload, X, Y, Z, newTile); + assertSame(newTile, payload.slice(TileWrapper.class).get(X, Y, Z).getData()); + } + + @Test + public void failedBlockPublicationRestoresPublishedBlockAndTile() { + NativeBlockState original = mock(NativeBlockState.class); + NativeBlockState replacement = mock(NativeBlockState.class); + TileData tile = mock(TileData.class); + writer.set(X, Y, Z, original); + writer.setTile(X, Y, Z, tile); + TileWrapper publishedTile = writer.getDataIfPresent(X, Y, Z, TileWrapper.class); + MantleWriter failingWriter = spy(writer); + doThrow(new IllegalStateException("publication failed")) + .when(failingWriter).setData(X + 1, Y, Z, "fail"); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(failingWriter, 0, 0); + transaction.set(X, Y, Z, replacement); + transaction.setData(X + 1, Y, Z, "fail"); + + assertThrows(IllegalStateException.class, transaction::commit); + + assertSame(original, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertSame(publishedTile, writer.getDataIfPresent(X, Y, Z, TileWrapper.class)); + } + + @Test + public void dryBlockRollbackDoesNotRestoreUntouchedTileMetadata() { + NativeBlockState original = mock(NativeBlockState.class); + NativeBlockState replacement = mock(NativeBlockState.class); + writer.set(X, Y, Z, original); + MantleWriter failingWriter = spy(writer); + doThrow(new IllegalStateException("publication failed")) + .when(failingWriter).setData(X + 1, Y, Z, "fail"); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(failingWriter, 0, 0); + transaction.set(X, Y, Z, replacement); + transaction.setData(X + 1, Y, Z, "fail"); + + assertThrows(IllegalStateException.class, transaction::commit); + + assertSame(original, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertNull(writer.getDataIfPresent(X, Y, Z, TileWrapper.class)); + verify(failingWriter, never()).restoreData(X, Y, Z, TileWrapper.class, null); + } + + @Test + public void rollbackRetainsFirstTileStateAcrossRepeatedBlockAndTileWrites() { + for (boolean originallyTiled : new boolean[]{false, true}) { + for (boolean tileFirst : new boolean[]{false, true}) { + NativeBlockState original = mock(NativeBlockState.class); + NativeBlockState replacement = mock(NativeBlockState.class); + writer.set(X, Y, Z, original); + if (originallyTiled) { + writer.setTile(X, Y, Z, mock(TileData.class)); + } + TileWrapper originalTile = writer.getDataIfPresent(X, Y, Z, TileWrapper.class); + MantleWriter failingWriter = spy(writer); + doThrow(new IllegalStateException("publication failed")) + .when(failingWriter).setData(X + 1, Y, Z, "fail"); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(failingWriter, 0, 0); + TileData replacementTile = mock(TileData.class); + if (tileFirst) { + transaction.setTile(X, Y, Z, replacementTile); + transaction.set(X, Y, Z, replacement); + } else { + transaction.set(X, Y, Z, replacement); + transaction.setTile(X, Y, Z, replacementTile); + } + transaction.set(X, Y, Z, replacement); + transaction.setTile(X, Y, Z, mock(TileData.class)); + transaction.setData(X + 1, Y, Z, "fail"); + + assertThrows(IllegalStateException.class, transaction::commit); + + assertSame(original, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertSame(originalTile, writer.getDataIfPresent(X, Y, Z, TileWrapper.class)); + } + } + } + + @Test + public void ordinaryBlockRollbackDoesNotRestoreUntouchedIdentifierMetadata() { + NativeBlockState original = mock(NativeBlockState.class); + writer.set(X, Y, Z, original); + MantleWriter failingWriter = spy(writer); + doThrow(new IllegalStateException("publication failed")) + .when(failingWriter).setData(X + 1, Y, Z, "fail"); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(failingWriter, 0, 0); + transaction.set(X, Y, Z, mock(NativeBlockState.class)); + transaction.setData(X + 1, Y, Z, "fail"); + + assertThrows(IllegalStateException.class, transaction::commit); + + assertSame(original, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertNull(writer.getDataIfPresent(X, Y, Z, Identifier.class)); + verify(failingWriter, never()).restoreData(X, Y, Z, Identifier.class, null); + } + + @Test + public void customBlockRollbackClearsNewIdentifierAndRestoresExistingIdentifier() { + NativeBlockState original = mock(NativeBlockState.class); + NativeBlockState custom = mock(NativeBlockState.class); + NativeBlockState base = mock(NativeBlockState.class); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn("test:replacement"); + when(custom.placementBaseState()).thenReturn(base); + for (boolean originallyCustom : new boolean[]{false, true}) { + writer.set(X, Y, Z, original); + if (originallyCustom) { + writer.setData(X, Y, Z, Identifier.fromString("test:original")); + } + Identifier originalIdentifier = writer.getDataIfPresent(X, Y, Z, Identifier.class); + MantleWriter failingWriter = spy(writer); + doThrow(new IllegalStateException("publication failed")) + .when(failingWriter).setData(X + 1, Y, Z, "fail"); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(failingWriter, 0, 0); + transaction.set(X, Y, Z, custom); + transaction.set(X, Y, Z, mock(NativeBlockState.class)); + transaction.set(X, Y, Z, custom); + transaction.setData(X + 1, Y, Z, "fail"); + + assertThrows(IllegalStateException.class, transaction::commit); + + assertSame(original, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertSame(originalIdentifier, writer.getDataIfPresent(X, Y, Z, Identifier.class)); + } + } + + @Test + public void rollbackRetainsFirstIdentifierAcrossExplicitMetadataAndBlockWrites() { + for (boolean originallyCustom : new boolean[]{false, true}) { + for (boolean identifierFirst : new boolean[]{false, true}) { + NativeBlockState original = mock(NativeBlockState.class); + NativeBlockState replacement = mock(NativeBlockState.class); + writer.set(X, Y, Z, original); + if (originallyCustom) { + writer.setData(X, Y, Z, Identifier.fromString("test:original")); + } + Identifier originalIdentifier = writer.getDataIfPresent(X, Y, Z, Identifier.class); + MantleWriter failingWriter = spy(writer); + doThrow(new IllegalStateException("publication failed")) + .when(failingWriter).setData(X + 1, Y, Z, "fail"); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(failingWriter, 0, 0); + Identifier replacementIdentifier = Identifier.fromString("test:replacement"); + if (identifierFirst) { + transaction.setData(X, Y, Z, replacementIdentifier); + transaction.set(X, Y, Z, replacement); + } else { + transaction.set(X, Y, Z, replacement); + transaction.setData(X, Y, Z, replacementIdentifier); + } + transaction.set(X, Y, Z, replacement); + transaction.setData(X, Y, Z, replacementIdentifier); + transaction.setData(X + 1, Y, Z, "fail"); + + assertThrows(IllegalStateException.class, transaction::commit); + + assertSame(original, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertSame(originalIdentifier, writer.getDataIfPresent(X, Y, Z, Identifier.class)); + } + } + } + + @Test + public void failedDestinationRestoresPreviouslyPublishedObjectRatherThanTerrainBaseline() { + String terrain = "terrain"; + String published = "previous-object"; + matter.slice(String.class).set(X, Y, Z, terrain); + writer.withComponentPriority(2, () -> writer.setData(X, Y, Z, published)); + MantleWriter failingWriter = spy(writer); + doThrow(new IllegalStateException("publication failed")) + .when(failingWriter).setData(X + 1, Y, Z, "fail"); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(failingWriter, 0, 0); + transaction.setData(X, Y, Z, "replacement"); + transaction.setData(X + 1, Y, Z, "fail"); + + assertThrows(IllegalStateException.class, + () -> failingWriter.withComponentPriority(2, transaction::commit)); + + assertEquals(published, writer.getDataIfPresent(X, Y, Z, String.class)); + assertEquals(terrain, writer.getPrerequisiteDataIfPresent(X, Y, Z, String.class)); + } + @Test public void concurrentTransactionsKeepOverlaysPrivateDuringObjectPublication() throws Exception { NativeBlockState original = mock(NativeBlockState.class); @@ -287,13 +517,15 @@ public class MantleWriterPreObjectJournalTest { writer.setData(X, Y, Z, replacementCavern); }); - assertTrue(writer.restorePrerequisiteData(X, Y, Z, String.class)); + writer.restoreData(X, Y, Z, String.class, null); assertNull(writer.getDataIfPresent(X, Y, Z, String.class)); - assertTrue(writer.restorePrerequisiteCell(X, Y, Z)); + writer.restoreData(X, Y, Z, NativeBlockState.class, originalBlock); + writer.restoreData(X, Y, Z, MatterCavern.class, originalCavern); assertSame(originalBlock, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class)); assertEquals(originalCavern, writer.getDataIfPresent(X, Y, Z, MatterCavern.class)); - assertFalse(writer.restorePrerequisiteData(X, Y, Z, Integer.class)); - assertFalse(writer.restorePrerequisiteCell(X + 1, Y, Z)); + assertSame(originalBlock, writer.getPrerequisiteDataIfPresent(X, Y, Z, NativeBlockState.class)); + assertEquals(originalCavern, writer.getPrerequisiteDataIfPresent(X, Y, Z, MatterCavern.class)); + assertNull(writer.getPrerequisiteDataIfPresent(X, Y, Z, String.class)); } private void verifyTransactionReads( diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java index addaff5dd..f4165d6c5 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java @@ -181,6 +181,8 @@ public class ObjectDestinationTransactionTest { Marker failure = new Marker("failure"); when(writer.getPrerequisiteDataIfPresent(0, 4, 0, String.class)).thenReturn("original"); when(writer.getPrerequisiteDataIfPresent(0, 4, 0, TreeBlockMaterial.class)).thenReturn(originalMaterial); + when(writer.getDataIfPresent(0, 4, 0, String.class)).thenReturn("original"); + when(writer.getDataIfPresent(0, 4, 0, TreeBlockMaterial.class)).thenReturn(originalMaterial); doThrow(new IllegalStateException("publication failed")) .when(writer).setData(1, 4, 0, failure); ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); @@ -193,11 +195,9 @@ public class ObjectDestinationTransactionTest { assertThrows(IllegalStateException.class, transaction::commit); - verify(writer).clearData(0, 4, 0, String.class); - verify(writer).setData(0, 4, 0, "original"); - verify(writer).clearData(0, 4, 0, TreeBlockMaterial.class); - verify(writer).setData(0, 4, 0, originalMaterial); - verify(writer).clearData(0, 4, 0, Marker.class); + verify(writer).restoreData(0, 4, 0, String.class, "original"); + verify(writer).restoreData(0, 4, 0, TreeBlockMaterial.class, originalMaterial); + verify(writer).restoreData(0, 4, 0, Marker.class, null); verify(writer).setData(0, 4, 0, generic); } @@ -488,8 +488,8 @@ public class ObjectDestinationTransactionTest { Marker secondOriginal = new Marker("second-original"); Marker first = new Marker("first"); Marker second = new Marker("second"); - when(writer.getPrerequisiteDataIfPresent(0, 4, 0, Marker.class)).thenReturn(firstOriginal); - when(writer.getPrerequisiteDataIfPresent(1, 4, 0, Marker.class)).thenReturn(secondOriginal); + when(writer.getDataIfPresent(0, 4, 0, Marker.class)).thenReturn(firstOriginal); + when(writer.getDataIfPresent(1, 4, 0, Marker.class)).thenReturn(secondOriginal); doThrow(new IllegalStateException("publication failed")) .when(writer).setData(1, 4, 0, second); ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); @@ -498,10 +498,8 @@ public class ObjectDestinationTransactionTest { assertThrows(IllegalStateException.class, transaction::commit); - verify(writer).clearData(0, 4, 0, Marker.class); - verify(writer).clearData(1, 4, 0, Marker.class); - verify(writer).setData(0, 4, 0, firstOriginal); - verify(writer).setData(1, 4, 0, secondOriginal); + verify(writer).restoreData(0, 4, 0, Marker.class, firstOriginal); + verify(writer).restoreData(1, 4, 0, Marker.class, secondOriginal); } @Test @@ -579,6 +577,7 @@ public class ObjectDestinationTransactionTest { Marker failure = new Marker("failure"); Identifier originalIdentifier = Identifier.fromString("iris:deferred"); when(writer.getPrerequisiteDataIfPresent(0, 4, 0, Identifier.class)).thenReturn(originalIdentifier); + when(writer.getDataIfPresent(0, 4, 0, Identifier.class)).thenReturn(originalIdentifier); doThrow(new IllegalStateException("publication failed")) .when(writer).setData(1, 4, 0, failure); ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); @@ -593,8 +592,7 @@ public class ObjectDestinationTransactionTest { assertEquals(plan.mutationsFor(0, 0).getFirst(), destination.sourcePlanSince(0).mutationsFor(0, 0).getFirst()); assertThrows(IllegalStateException.class, destination::commit); - verify(writer).clearData(0, 4, 0, Identifier.class); - verify(writer).setData(0, 4, 0, originalIdentifier); + verify(writer).restoreData(0, 4, 0, Identifier.class, originalIdentifier); } @SuppressWarnings("unchecked") diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/StructureCarvingFootprintTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/StructureCarvingFootprintTest.java index 2d004e858..c9c0fc1cd 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/StructureCarvingFootprintTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/StructureCarvingFootprintTest.java @@ -169,6 +169,62 @@ public class StructureCarvingFootprintTest { assertEquals(30, footprint.maxZ()); } + @Test + public void cursorFootprintMatchesReferenceForRotatedSparseOverlappingPieces() { + NativeBlockState solid = mock(NativeBlockState.class); + NativeBlockState air = mock(NativeBlockState.class); + when(air.isAir()).thenReturn(true); + IrisObject object = new IrisObject(1, 1, 1); + for (int x = -8; x <= 8; x++) { + for (int y = -5; y <= 5; y++) { + for (int z = -8; z <= 8; z++) { + if ((x + y + z) % 3 == 0) { + object.getBlocks().put(new IrisBlockVector(x, y, z), + (x - y + z) % 5 == 0 ? air : solid); + } + } + } + } + int initialSize = object.getBlocks().size(); + long initialRevision = object.getBlocks().modificationRevision(); + for (int[] angles : new int[][]{{0, 0, 0}, {0, 90, 0}, {90, 0, 0}, + {0, 0, 270}, {33, -45, 19}, {180, 270, 90}}) { + KList pieces = pieces( + new PlacedStructurePiece(null, object, -17, 60, -33, + IrisObjectRotation.of(angles[0], angles[1], angles[2]), 0, 0, 0, 0, 0, 0), + new PlacedStructurePiece(null, object, -9, 70, -28, + IrisObjectRotation.of(0, 180, 0), 0, 0, 0, 0, 0, 0)); + StructureCarvingFootprint reference = StructureCarvingFootprint.fromColumns(sink -> { + for (PlacedStructurePiece piece : pieces) { + for (IrisBlockVector local : piece.getObject().getBlocks().keys()) { + NativeBlockState state = piece.getObject().getBlocks().get(local); + if (state == null || state.isAir()) { + continue; + } + IrisBlockVector rotated = piece.getRotation().rotate(local.clone()); + int worldY = piece.getY() + rotated.getBlockY(); + sink.column(piece.getX() + rotated.getBlockX(), + piece.getZ() + rotated.getBlockZ(), worldY, worldY); + } + } + return true; + }, 3, 4096); + StructureCarvingFootprint actual = StructurePieceColumns.from(pieces, 3, 4096); + assertNotNull(actual); + assertEquals(reference.minX(), actual.minX()); + assertEquals(reference.minZ(), actual.minZ()); + assertEquals(reference.maxX(), actual.maxX()); + assertEquals(reference.maxZ(), actual.maxZ()); + for (int index = 0; index < actual.width() * actual.depth(); index++) { + assertEquals(reference.distanceSquaredAt(index), actual.distanceSquaredAt(index)); + assertEquals(reference.sourceMinYAt(index), actual.sourceMinYAt(index)); + assertEquals(reference.sourceMaxYAt(index), actual.sourceMaxYAt(index)); + } + } + assertEquals(initialSize, object.getBlocks().size()); + assertEquals(initialRevision, object.getBlocks().modificationRevision()); + } + private static PlacedStructurePiece piece(int x, int y, int z, IrisObjectRotation rotation, int[]... blocks) { IrisObject object = new IrisObject(1, 1, 1); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/StructureFoundationPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/StructureFoundationPlannerTest.java index 9850f25da..97e3151ba 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/StructureFoundationPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/StructureFoundationPlannerTest.java @@ -61,6 +61,29 @@ public class StructureFoundationPlannerTest { assertEquals(36, columns.get(StructureFoundationPlanner.pack(12, 44))); } + @Test + public void baseColumnMinimaMatchReferenceAcrossSignedCoordinatesAndDefaultValues() { + NativeBlockState solid = mock(NativeBlockState.class); + when(solid.isOccluding()).thenReturn(true); + Long2IntOpenHashMap reference = new Long2IntOpenHashMap(); + Long2IntOpenHashMap actual = new Long2IntOpenHashMap(); + reference.defaultReturnValue(97); + actual.defaultReturnValue(97); + int[] heights = {97, 0, 15, -7, Integer.MAX_VALUE, Integer.MIN_VALUE, 28}; + for (int x = -32; x <= 32; x++) { + for (int z = -32; z <= 32; z++) { + for (int height : heights) { + long key = StructureFoundationPlanner.pack(x, z); + if (!reference.containsKey(key) || height < reference.get(key)) { + reference.put(key, height); + } + StructureFoundationPlanner.recordBaseCell(actual, x, height, z, solid, false); + } + } + } + assertEquals(reference, actual); + } + @Test public void scansThroughAirAndFluidUntilSolidGround() { int groundY = StructureFoundationPlanner.findGroundY(10, 10, 0, y -> y == 3); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/TerrainMatterViewTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/TerrainMatterViewTest.java index 2a7a543c4..17fd5ff60 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/TerrainMatterViewTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/TerrainMatterViewTest.java @@ -8,6 +8,7 @@ import art.arcane.iris.world.storage.matter.PreObjectMatterCell; import art.arcane.iris.world.storage.matter.PreObjectMatterTest; import art.arcane.volmlib.util.function.Consumer4; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.matter.IrisMatter; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterCavern; @@ -31,6 +32,8 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -44,6 +47,52 @@ public class TerrainMatterViewTest { IrisMatterSupport.ensureRegistered(); } + @Test + @SuppressWarnings("unchecked") + public void mantleReadsPinOneChunkForJournalAndPublishedValue() { + Mantle mantle = mock(Mantle.class); + Matter matter = new IrisMatter(16, 16, 16); + matter.slice(String.class).set(15, 3, 14, "content"); + matter.slice(PreObjectMatterCell.class) + .set(15, 3, 14, PreObjectMatterCell.string("terrain")); + MantleChunk pinned = chunk(matter); + when(mantle.getWorldHeight()).thenReturn(16); + when(mantle.useChunk(-1, -2)).thenReturn(pinned); + + assertEquals("terrain", TerrainMatterView.get(mantle, -1, 3, -18, String.class)); + + verify(mantle).useChunk(-1, -2); + verify(mantle, never()).getChunk(-1, -2); + verify(pinned).release(); + } + + @Test + @SuppressWarnings("unchecked") + public void mantleReadFailureReleasesPinnedChunk() { + Mantle mantle = mock(Mantle.class); + MantleChunk pinned = mock(MantleChunk.class); + when(mantle.getWorldHeight()).thenReturn(16); + when(mantle.useChunk(0, 0)).thenReturn(pinned); + doThrow(new IllegalStateException("read failure")).when(pinned).exists(0); + + assertThrows(IllegalStateException.class, () -> TerrainMatterView.get(mantle, 0, 3, 0, String.class)); + + verify(pinned).release(); + } + + @Test + @SuppressWarnings("unchecked") + public void outsideWorldHeightDoesNotAcquireChunk() { + Mantle mantle = mock(Mantle.class); + when(mantle.getWorldHeight()).thenReturn(16); + + assertNull(TerrainMatterView.get(mantle, -1, -1, -18, String.class)); + assertNull(TerrainMatterView.get(mantle, -1, 16, -18, String.class)); + + verify(mantle, never()).useChunk(-1, -2); + verify(mantle, never()).getChunk(-1, -2); + } + @Test public void persistedJournalRestoresDeletedAndReplacedTerrainAndExcludesNewContent() throws IOException { Matter matter = new IrisMatter(16, 16, 16); diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionCoordinateTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionCoordinateTest.java new file mode 100644 index 000000000..d903d2fce --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionCoordinateTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class IrisExpressionCoordinateTest { + @Test + public void planarEvaluationBindsHorizontalAxesAndAbsentHeight() { + RNG rng = new RNG(17L); + assertEquals(23.5D, expression("x").evaluate(rng, 23.5D, -94.75D), 0D); + assertEquals(-94.75D, expression("y").evaluate(rng, 23.5D, -94.75D), 0D); + assertEquals(-1D, expression("z").evaluate(rng, 23.5D, -94.75D), 0D); + } + + @Test + public void volumetricEvaluationBindsEachAxis() { + RNG rng = new RNG(17L); + assertEquals(23.5D, expression("x").evaluate(rng, 23.5D, 61D, -94.75D), 0D); + assertEquals(61D, expression("y").evaluate(rng, 23.5D, 61D, -94.75D), 0D); + assertEquals(-94.75D, expression("z").evaluate(rng, 23.5D, 61D, -94.75D), 0D); + } + + @Test + public void loadedVariablesPreserveCoordinateBindings() { + IrisExpression expression = expression("value + x + 10 * y + 100 * z") + .setVariables(new KList<>(new IrisExpressionLoad().setName("value").setStaticValue(7D))); + RNG rng = new RNG(17L); + assertEquals(-1017D, expression.evaluate(rng, 23.5D, -94.75D), 0D); + assertEquals(-8834.5D, expression.evaluate(rng, 23.5D, 61D, -94.75D), 0D); + } + + @Test + public void expressionStreamsAndNoiseUsePlanarCoordinates() { + IrisExpression expression = expression("x + 100 * y"); + RNG rng = new RNG(17L); + assertEquals(-9451.5D, expression.stream(rng).getDouble(23.5D, -94.75D), 0D); + assertEquals(-9451.5D, new ExpressionNoise(rng, expression).noise(23.5D, -94.75D), 0D); + } + + private static IrisExpression expression(String source) { + return new IrisExpression().setExpression(source); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionScratchTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionScratchTest.java new file mode 100644 index 000000000..5cdcbba6e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisExpressionScratchTest.java @@ -0,0 +1,185 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import com.dfsek.paralithic.Expression; +import com.dfsek.paralithic.functions.dynamic.Context; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +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.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +public class IrisExpressionScratchTest { + @Test + public void sequentialPlanarAndVolumetricSamplesReuseTheArgumentArrayAndKeepRngState() { + IrisExpression expression = new IrisExpression(); + Expression compiled = mock(Expression.class); + expression.getExpressionCache().aquire(() -> compiled); + AtomicReference first = new AtomicReference<>(); + doAnswer(invocation -> { + double[] arguments = (double[]) invocation.getRawArguments()[1]; + if (first.get() == null) { + first.set(arguments); + } else { + assertSame(first.get(), arguments); + } + return arguments[0] + 10D * arguments[1] + 100D * arguments[2]; + }).when(compiled).evaluate(any(Context.class), any(double[].class)); + RNG rng = new RNG(17L); + RNG control = new RNG(17L); + + for (int i = 0; i < 64; i++) { + assertEquals(i + 30D - 100D, expression.evaluate(rng, i, 3D), 0D); + assertEquals(i + 70D + 300D, expression.evaluate(rng, i, 7D, 3D), 0D); + } + assertEquals(control.nextLong(), rng.nextLong()); + } + + @Test + public void recursiveVariableEvaluationKeepsOuterArgumentsBeyondTheRetainedDepth() { + AtomicReference expression = new AtomicReference<>(); + IrisExpressionLoad recursive = new IrisExpressionLoad() { + @Override + public double getValue(RNG rng, IrisData data, double x, double z) { + return x <= 0D ? 7D : expression.get().evaluate(rng, x - 1D, z); + } + }.setName("prior"); + expression.set(new IrisExpression().setExpression("prior + 17 * x + 31 * y + z") + .setVariables(new KList<>(recursive))); + RNG rng = new RNG(78264193L); + RNG control = new RNG(78264193L); + double expected = 7D; + for (int x = 0; x <= 31; x++) { + expected = expected + 17D * x + 31D * 3.75D - 1D; + } + + assertEquals(expected, expression.get().evaluate(rng, 31D, 3.75D), 0D); + assertEquals(expected, expression.get().evaluate(rng, 31D, 3.75D), 0D); + assertEquals(control.nextLong(), rng.nextLong()); + } + + @Test + public void recursiveFunctionEvaluationDoesNotOverwriteTheOuterCoordinates() { + AtomicReference expression = new AtomicReference<>(); + IrisExpressionFunction recursive = new IrisExpressionFunction() { + @Override + public boolean isValid() { + return true; + } + + @Override + public double eval(Context context, double... coordinates) { + RNG rng = ((FunctionContext) context).rng(); + return coordinates[0] <= 0D ? 7D + : expression.get().evaluate(rng, coordinates[0] - 1D, coordinates[1]); + } + }.setName("prior"); + expression.set(new IrisExpression().setExpression("prior(x, y) + 17 * x + 31 * y + z") + .setFunctions(new KList<>(recursive))); + double expected = 7D; + for (int x = 0; x <= 31; x++) { + expected = expected + 17D * x + 31D * 3.75D - 1D; + } + + assertEquals(expected, expression.get().evaluate(new RNG(17L), 31D, 3.75D), 0D); + assertEquals(expected, expression.get().evaluate(new RNG(17L), 31D, 3.75D), 0D); + } + + @Test + public void failedEvaluationUnwindsTheRetainedFrame() { + IrisExpression expression = new IrisExpression(); + Expression compiled = mock(Expression.class); + expression.getExpressionCache().aquire(() -> compiled); + AtomicBoolean fail = new AtomicBoolean(true); + AtomicReference failedArguments = new AtomicReference<>(); + doAnswer(invocation -> { + double[] arguments = (double[]) invocation.getRawArguments()[1]; + if (fail.getAndSet(false)) { + failedArguments.set(arguments); + throw new IllegalStateException("sample failed"); + } + assertSame(failedArguments.get(), arguments); + return arguments[0]; + }).when(compiled).evaluate(any(Context.class), any(double[].class)); + RNG rng = new RNG(17L); + + assertThrows(IllegalStateException.class, () -> expression.evaluate(rng, 7D, 9D)); + assertEquals(31D, expression.evaluate(rng, 31D, 9D), 0D); + } + + @Test + public void largeArgumentListsRemainSupportedWithoutRetainingTheirArrays() { + IrisExpression expression = new IrisExpression(); + KList variables = new KList<>(); + for (int i = 0; i < 300; i++) { + variables.add(new IrisExpressionLoad().setName("value" + i).setStaticValue(i)); + } + expression.setVariables(variables); + Expression compiled = mock(Expression.class); + expression.getExpressionCache().aquire(() -> compiled); + AtomicReference first = new AtomicReference<>(); + doAnswer(invocation -> { + double[] arguments = (double[]) invocation.getRawArguments()[1]; + if (first.get() == null) { + first.set(arguments); + } else { + assertNotSame(first.get(), arguments); + } + for (int i = 0; i < 300; i++) { + assertEquals(i, arguments[i], 0D); + } + return arguments[300] + arguments[301] + arguments[302]; + }).when(compiled).evaluate(any(Context.class), any(double[].class)); + + assertEquals(11D, expression.evaluate(new RNG(17L), 7D, 5D), 0D); + assertEquals(31D, expression.evaluate(new RNG(17L), 7D, 19D, 5D), 0D); + } + + @Test(timeout = 15000) + public void sharedExpressionKeepsExactSamplesAcrossThreadsAndDimensions() throws Exception { + IrisExpression expression = new IrisExpression().setExpression("sin(x) + cos(y) + z / 7"); + RNG control = new RNG(17L); + long[] expected = new long[128]; + for (int i = 0; i < expected.length; i++) { + expected[i] = Double.doubleToRawLongBits((i & 1) == 0 + ? expression.evaluate(control, i * 0.17D, i * -0.31D) + : expression.evaluate(control, i * 0.17D, i * 0.23D, i * -0.31D)); + } + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + List> tasks = new ArrayList<>(); + for (int task = 0; task < 16; task++) { + tasks.add(executor.submit(() -> { + RNG rng = new RNG(17L); + for (int i = 0; i < expected.length; i++) { + double actual = (i & 1) == 0 + ? expression.evaluate(rng, i * 0.17D, i * -0.31D) + : expression.evaluate(rng, i * 0.17D, i * 0.23D, i * -0.31D); + assertEquals(expected[i], Double.doubleToRawLongBits(actual)); + } + })); + } + for (Future task : tasks) { + task.get(); + } + } finally { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorStyleOverrideTest.java b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorStyleOverrideTest.java new file mode 100644 index 000000000..ffecaa1a6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/noise/IrisGeneratorStyleOverrideTest.java @@ -0,0 +1,72 @@ +package art.arcane.iris.generation.noise; + +import art.arcane.iris.generation.image.IrisImage; +import art.arcane.iris.generation.image.IrisImageMap; +import art.arcane.iris.generation.image.IrisImageMapOutOfBounds; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.awt.image.BufferedImage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisGeneratorStyleOverrideTest { + @Test + public void authoredExpressionAndImageMapOverrideEvenAnExplicitFlatBaseStyle() { + for (NoiseStyle base : new NoiseStyle[]{null, NoiseStyle.FLAT, NoiseStyle.SIMPLEX}) { + IrisGeneratorStyle expression = new IrisGeneratorStyle(base).setExpression("height"); + IrisGeneratorStyle imageMap = new IrisGeneratorStyle(base).setImageMap("height"); + assertFalse(expression.isFlat()); + assertFalse(imageMap.isFlat()); + assertFalse(new IrisStyledRange().setStyle(expression).isFlat()); + assertFalse(new IrisShapedGeneratorStyle().setMin(1).setMax(2).setGenerator(imageMap).isFlat()); + } + assertTrue(new IrisGeneratorStyle(NoiseStyle.FLAT).isFlat()); + assertTrue(new IrisGeneratorStyle(null).isFlat()); + } + + @Test + public void expressionBackedRangeSamplesTheAuthoredExpressionInsteadOfTheMidpoint() { + IrisData data = mock(IrisData.class); + ResourceLoader expressions = mock(ResourceLoader.class); + when(data.getExpressionLoader()).thenReturn(expressions); + when(expressions.load("height")).thenReturn(new IrisExpression().setExpression("x / 4 + 0.125")); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.FLAT).setExpression("height"); + IrisStyledRange range = new IrisStyledRange().setMin(2D).setMax(14D).setStyle(style); + RNG rng = new RNG(78264193L); + double first = style.create(rng, data).fitDouble(2D, 14D, 0D, 0D); + double second = style.create(rng, data).fitDouble(2D, 14D, 1D, 0D); + + assertNotEquals(8D, first, 0D); + assertNotEquals(first, second, 0D); + assertEquals(first, range.get(rng, 0D, 0D, data), 0D); + assertEquals(second, range.get(rng, 1D, 0D, data), 0D); + } + + @Test + public void imageBackedRangeSamplesPixelsInsteadOfTheMidpoint() { + IrisData data = mock(IrisData.class); + ResourceLoader maps = mock(ResourceLoader.class); + ResourceLoader images = mock(ResourceLoader.class); + when(data.getImageMapLoader()).thenReturn(maps); + when(data.getImageLoader()).thenReturn(images); + BufferedImage pixels = new BufferedImage(2, 1, BufferedImage.TYPE_BYTE_GRAY); + pixels.getRaster().setSample(1, 0, 0, 255); + when(maps.load("height")).thenReturn(new IrisImageMap().setSource("height") + .setOutOfBounds(IrisImageMapOutOfBounds.CLAMP)); + when(images.load("height")).thenReturn(new IrisImage(pixels)); + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.FLAT).setImageMap("height"); + IrisStyledRange range = new IrisStyledRange().setMin(2D).setMax(14D).setStyle(style); + RNG rng = new RNG(78264193L); + + assertEquals(2D, range.get(rng, 0D, 0D, data), 0D); + assertEquals(14D, range.get(rng, 1D, 0D, data), 0D); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java index 1db758460..8a77db249 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/DimensionTerrain3DCompositionTest.java @@ -69,6 +69,60 @@ public class DimensionTerrain3DCompositionTest { } } + @Test + public void stackFloorWithoutBedrockKeepsItsBiomePalette() { + IrisBiome biome = paletteBiome(false, new IrisSlopeClip(), "grass"); + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(0, 0, 0); + DimensionStackLayout layout = DimensionStackLayout.create(32, 0, + List.of(input(10, 0, null), materialInput(terrain, biome, 0D)), new int[]{0}); + + assertSame(state("grass"), render(layout, 32).getRaw(0, 11, 0)); + DimensionStackLayout.LayerInput flat = input(0, 0, null); + DimensionStackLayout.LayerInput flatPalette = new DimensionStackLayout.LayerInput( + flat.terrainContext(), biome, null, flat.rockBlock(), flat.fluidBlock(), null, 0, 0, null); + DimensionStackLayout flatLayout = DimensionStackLayout.create(32, 0, + List.of(input(10, 0, null), flatPalette), new int[]{0}); + assertSame(state("grass"), render(flatLayout, 32).getRaw(0, 11, 0)); + } + + @Test + public void naturalFloorWithoutBedrockKeepsItsBiomePalette() { + IrisBiome biome = paletteBiome(false, new IrisSlopeClip(), "grass", "dirt", "clay", "sand"); + Hunk blocks = renderTerrain(null, biome, 3); + + assertSame(state("grass"), blocks.getRaw(0, 3, 0)); + assertSame(state("sand"), blocks.getRaw(0, 0, 0)); + } + + @Test + public void mirroredFloorWithoutBedrockKeepsItsBiomePaletteAtTheWorldCeiling() { + IrisBiome biome = paletteBiome(false, new IrisSlopeClip(), "grass", "dirt", "clay", "sand"); + UpperDimensionContext upper = materialUpper(Terrain3DColumnFixtures.spans(3, 0, 3), biome, 0D); + Hunk blocks = renderUpper(upper); + + assertSame(state("grass"), blocks.getRaw(0, 60, 0)); + assertSame(state("sand"), blocks.getRaw(0, 63, 0)); + } + + @Test + public void stackOverhangsUseCeilingPalettesWhileThinLedgesKeepSurfaceLayers() { + IrisBiome biome = paletteBiome(false, new IrisSlopeClip(), "grass", "dirt"); + biome.setCaveCeilingLayers(paletteBiome(false, new IrisSlopeClip(), "ceiling", "ceiling-deep").getLayers()); + Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(16, 0, 3, 7, 11, 15, 16); + DimensionStackLayout layout = DimensionStackLayout.create(40, 0, + List.of(input(10, 0, null), materialInput(terrain, biome, 0D)), new int[]{0}); + Hunk blocks = render(layout, 40); + + assertSame(state("ceiling"), blocks.getRaw(0, 18, 0)); + assertSame(state("ceiling-deep"), blocks.getRaw(0, 19, 0)); + assertSame(state("rock"), blocks.getRaw(0, 20, 0)); + assertSame(state("dirt"), blocks.getRaw(0, 21, 0)); + assertSame(state("grass"), blocks.getRaw(0, 22, 0)); + assertSame(state("dirt"), blocks.getRaw(0, 26, 0)); + assertSame(state("grass"), blocks.getRaw(0, 27, 0)); + assertSame(state("rock"), blocks.getRaw(0, 11, 0)); + } + @Test public void actualStackPassClearsGapsAndPaintsEveryExposedFloor() { Terrain3DColumn terrain = Terrain3DColumnFixtures.spans(6, 0, 3, 7, 9); @@ -129,7 +183,7 @@ public class DimensionTerrain3DCompositionTest { List.of(input(10, 0, null), materialInput(terrain, biome, 0D)), new int[]{0}); Hunk blocks = render(layout, 64); assertSame(state("a"), blocks.getRaw(0, 16, 0)); - assertSame(state("c"), blocks.getRaw(0, 15, 0)); + assertSame(state("b"), blocks.getRaw(0, 15, 0)); assertSame(state("c"), blocks.getRaw(0, 32, 0)); } @@ -145,7 +199,7 @@ public class DimensionTerrain3DCompositionTest { upper = materialUpper(terrain, paletteBiome(true, new IrisSlopeClip(), "a", "b", "c"), 0D); Hunk lockedBlocks = renderUpper(upper); assertSame(state("a"), lockedBlocks.getRaw(0, 58, 0)); - assertSame(state("c"), lockedBlocks.getRaw(0, 59, 0)); + assertSame(state("b"), lockedBlocks.getRaw(0, 59, 0)); assertSame(state("c"), lockedBlocks.getRaw(0, 42, 0)); } @@ -268,6 +322,11 @@ public class DimensionTerrain3DCompositionTest { @SuppressWarnings("unchecked") private Hunk renderUpper(UpperDimensionContext upper) { + return renderTerrain(upper, null, 8); + } + + @SuppressWarnings("unchecked") + private Hunk renderTerrain(UpperDimensionContext upper, IrisBiome rootBiome, int rootHeight) { NativeBlockState rootRock = state("root"); Engine engine = mock(Engine.class); IrisDimension dimension = new IrisDimension().setBedrock(false).setHideOresForHiddenOre(true); @@ -278,9 +337,11 @@ public class DimensionTerrain3DCompositionTest { when(engine.getComplex()).thenReturn(complex); when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 8D)); when(complex.getImageMapRuntime()).thenReturn(mock(IrisImageMapRuntime.class)); - IrisBiome biome = mock(IrisBiome.class); - when(biome.generateLayers(any(), anyDouble(), anyDouble(), any(), anyInt(), anyInt(), any(), any())) - .thenReturn(new KList<>()); + IrisBiome biome = rootBiome == null ? mock(IrisBiome.class) : rootBiome; + if (rootBiome == null) { + when(biome.generateLayers(any(), anyDouble(), anyDouble(), any(), anyInt(), anyInt(), any(), any())) + .thenReturn(new KList<>()); + } ChunkedDataCache biomes = mock(ChunkedDataCache.class); when(biomes.get(0, 0)).thenReturn(biome); ChunkedDataCache regions = mock(ChunkedDataCache.class); @@ -290,7 +351,7 @@ public class DimensionTerrain3DCompositionTest { when(context.getBiome()).thenReturn(biomes); when(context.getRegion()).thenReturn(regions); when(context.getRock()).thenReturn(rocks); - when(context.getRoundedHeight(0, 0)).thenReturn(8); + when(context.getRoundedHeight(0, 0)).thenReturn(rootHeight); Hunk blocks = Hunk.newArrayHunk(1, 64, 1); new IrisTerrainNormalActuator(engine).paint(0, 0, blocks, context); return blocks; diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/GenerationCacheWarmerParallelTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationCacheWarmerParallelTest.java new file mode 100644 index 000000000..18f3085cc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/GenerationCacheWarmerParallelTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.runtime.GenerationCacheWarmer.ProceduralWarmTask; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +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.junit.Assume.assumeTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class GenerationCacheWarmerParallelTest { + @Test + public void repeatedIdentitiesKeepTheirSeedOrderWithoutConcurrentCalls() { + IrisData data = mock(IrisData.class); + IrisProceduralPlacement first = mock(IrisProceduralPlacement.class); + IrisProceduralPlacement second = mock(IrisProceduralPlacement.class); + List firstSeeds = new ArrayList<>(); + List secondSeeds = new ArrayList<>(); + AtomicInteger activeFirst = new AtomicInteger(); + when(first.getVariantObject(same(data), any(RNG.class), isNull())).thenAnswer(invocation -> { + assertEquals(1, activeFirst.incrementAndGet()); + firstSeeds.add(((RNG) invocation.getArgument(1)).getSeed()); + activeFirst.decrementAndGet(); + return null; + }); + when(second.getVariantObject(same(data), any(RNG.class), isNull())).thenAnswer(invocation -> { + secondSeeds.add(((RNG) invocation.getArgument(1)).getSeed()); + return null; + }); + GenerationCacheWarmer.warmProcedural(List.of( + new ProceduralWarmTask(first, new RNG(11)), + new ProceduralWarmTask(second, new RNG(12)), + new ProceduralWarmTask(first, new RNG(13)), + new ProceduralWarmTask(second, new RNG(14))), data); + assertEquals(List.of(11L, 13L), firstSeeds); + assertEquals(List.of(12L, 14L), secondSeeds); + } + + @Test(timeout = 10000) + public void workerLimitAndReadinessCoverAllBanks() throws Exception { + assumeTrue(Runtime.getRuntime().availableProcessors() > 1); + int workers = Math.min(4, Runtime.getRuntime().availableProcessors()); + CountDownLatch started = new CountDownLatch(workers); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger active = new AtomicInteger(); + AtomicInteger maximum = new AtomicInteger(); + AtomicInteger completed = new AtomicInteger(); + List tasks = new ArrayList<>(); + for (int index = 0; index < 12; index++) { + IrisProceduralPlacement placement = mock(IrisProceduralPlacement.class); + when(placement.getVariantObject(isNull(), any(RNG.class), isNull())).thenAnswer(invocation -> { + maximum.accumulateAndGet(active.incrementAndGet(), Math::max); + started.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS)); + completed.incrementAndGet(); + return null; + } finally { + active.decrementAndGet(); + } + }); + tasks.add(new ProceduralWarmTask(placement, new RNG(index))); + } + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + Future warming = executor.submit(() -> GenerationCacheWarmer.warmProcedural(tasks, null)); + try { + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> warming.get(50, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + } + warming.get(5, TimeUnit.SECONDS); + } + assertEquals(workers, maximum.get()); + assertEquals(12, completed.get()); + } + + @Test(timeout = 10000) + public void failureWaitsForOtherBanksAndKeepsOriginalCause() throws Exception { + assumeTrue(Runtime.getRuntime().availableProcessors() > 1); + IrisProceduralPlacement failing = mock(IrisProceduralPlacement.class); + IrisProceduralPlacement waiting = mock(IrisProceduralPlacement.class); + IllegalStateException failure = new IllegalStateException("variant bank failed"); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(failing.getVariantObject(isNull(), any(RNG.class), isNull())).thenThrow(failure); + when(waiting.getVariantObject(isNull(), any(RNG.class), isNull())).thenAnswer(invocation -> { + started.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }); + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + Future warming = executor.submit(() -> GenerationCacheWarmer.warmProcedural(List.of( + new ProceduralWarmTask(failing, new RNG(1)), + new ProceduralWarmTask(waiting, new RNG(2))), null)); + try { + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> warming.get(50, TimeUnit.MILLISECONDS)); + } finally { + release.countDown(); + } + assertSame(failure, assertThrows(ExecutionException.class, + () -> warming.get(5, TimeUnit.SECONDS)).getCause()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/stream/ProvisionalSamplingTest.java b/core/src/test/java/art/arcane/iris/generation/stream/ProvisionalSamplingTest.java index 745c981fc..f6b08e0d0 100644 --- a/core/src/test/java/art/arcane/iris/generation/stream/ProvisionalSamplingTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stream/ProvisionalSamplingTest.java @@ -7,14 +7,69 @@ import art.arcane.volmlib.util.stream.ProceduralStream; import org.junit.Test; import org.mockito.MockedStatic; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; public class ProvisionalSamplingTest { + @Test + public void nestedFinalSamplesIgnoreEarlierMarksWithoutClearingTheOuterMark() { + ProvisionalSampling.mark(); + assertEquals("final", ProvisionalSampling.memoizable(() -> "final")); + + ProvisionalSampling.Unmemoizable result = assertThrows(ProvisionalSampling.Unmemoizable.class, + () -> ProvisionalSampling.memoizable(() -> { + ProvisionalSampling.mark(); + return ProvisionalSampling.memoizable(() -> "nested"); + })); + + assertEquals("nested", result.value()); + assertEquals("final", ProvisionalSampling.memoizable(() -> "final")); + } + + @Test + public void caughtSamplingFailureRetainsItsMarkOnlyForEnclosingResolvers() { + IllegalStateException failure = new IllegalStateException("sample failed"); + ProvisionalSampling.Unmemoizable result = assertThrows(ProvisionalSampling.Unmemoizable.class, + () -> ProvisionalSampling.memoizable(() -> { + assertSame(failure, assertThrows(IllegalStateException.class, + () -> ProvisionalSampling.memoizable(() -> { + ProvisionalSampling.mark(); + throw failure; + }))); + return ProvisionalSampling.memoizable(() -> "recovered"); + })); + + assertEquals("recovered", result.value()); + assertEquals("final", ProvisionalSampling.memoizable(() -> "final")); + } + + @Test + public void provisionalMarksStayOnTheirSamplingThread() { + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + assertEquals("worker", ProvisionalSampling.memoizable(() -> CompletableFuture.supplyAsync(() -> { + ProvisionalSampling.mark(); + return "worker"; + }, executor).join())); + + ProvisionalSampling.Unmemoizable result = assertThrows(ProvisionalSampling.Unmemoizable.class, + () -> ProvisionalSampling.memoizable(() -> { + ProvisionalSampling.mark(); + return CompletableFuture.supplyAsync( + () -> ProvisionalSampling.memoizable(() -> "worker"), executor).join(); + })); + assertEquals("worker", result.value()); + } + } + @Test public void provisionalValuesReachTheCallerWithoutBeingMemoized() { try (MockedStatic services = mockStatic(IrisServices.class)) { diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DDensityPlaneReuseTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DDensityPlaneReuseTest.java new file mode 100644 index 000000000..2b784b23e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DDensityPlaneReuseTest.java @@ -0,0 +1,152 @@ +package art.arcane.iris.generation.terrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +public class Terrain3DDensityPlaneReuseTest { + @Test + public void densityPlanesPreservePointwiseOccupancyAcrossPartialBandsAndNegativeCoordinates() throws Exception { + for (int height : new int[]{2, 3, 5, 63, 64, 256, 257, 4096}) { + double baseHeight = Math.min(96.375D, height - 2.625D); + for (double fluid : new double[]{0D, 48.25D, baseHeight + 2D}) { + IrisTerrain3D profile = profile(); + IrisBiome biome = new IrisBiome().setTerrain3D(profile); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> baseHeight, (x, z) -> biome), + new Terrain3DRuntime.Options(328473L, height, fluid, null, true, 4096), + (style, seed) -> style == profile.getDensityStyle() + ? Terrain3DDensityPlaneReuseTest::density : Terrain3DDensityPlaneReuseTest::cracks); + Method createColumn = Terrain3DRuntime.class.getDeclaredMethod("createColumn", int.class, int.class); + createColumn.setAccessible(true); + for (int x : new int[]{-9, -8, -7, -1, 0, 3}) { + for (int z : new int[]{-7, -4, -1, 2}) { + Terrain3DColumn actual = (Terrain3DColumn) createColumn.invoke(runtime, x, z); + double strength = smooth((baseHeight - fluid) / 24D); + if (strength == 0D) { + assertEquals(Terrain3DColumn.unshaped(baseHeight, height), actual); + continue; + } + int minimum = Math.max(Math.max(1, (int) Math.floor(fluid) + 1), + (int) Math.floor(baseHeight - (33.5D * strength + 24.5D * strength))); + int maximum = Math.min(height - 1, (int) Math.ceil(baseHeight + 33.5D * strength)); + if (minimum > maximum) { + assertEquals(Terrain3DColumn.unshaped(baseHeight, height), actual); + continue; + } + assertEquals(minimum, actual.minY()); + assertEquals(Double.doubleToRawLongBits(baseHeight), Double.doubleToRawLongBits(actual.baseHeight())); + for (int y = 0; y < height; y++) { + boolean expected = y < minimum || y <= maximum + && referenceSolid(x, y, z, baseHeight, fluid, strength); + assertEquals("at " + x + "," + y + "," + z + " height=" + height + " fluid=" + fluid, + expected, actual.isSolid(y)); + } + } + } + } + } + } + + @Test + public void reusedPlanesPreserveOrderedColdNoiseEvaluationAndWarmAnchorValues() throws Exception { + IrisTerrain3D profile = profile(); + IrisBiome biome = new IrisBiome().setTerrain3D(profile); + List samples = new ArrayList<>(); + Terrain3DRuntime runtime = new Terrain3DRuntime( + new Terrain3DRuntime.Sources((x, z) -> 96.375D, (x, z) -> biome), + new Terrain3DRuntime.Options(328473L, 256, 0D, null, true, 4096), + (style, seed) -> { + boolean fissure = style == profile.getCrackStyle(); + return (x, y, z) -> { + samples.add(new Sample(fissure, x, y, z)); + return fissure ? cracks(x, y, z) : density(x, y, z); + }; + }); + Method createColumn = Terrain3DRuntime.class.getDeclaredMethod("createColumn", int.class, int.class); + createColumn.setAccessible(true); + Terrain3DColumn first = (Terrain3DColumn) createColumn.invoke(runtime, -7, -1); + List expected = new ArrayList<>(); + for (int y = 36; y <= 132; y += 4) { + for (int z : new int[]{-4, 0}) { + for (int x : new int[]{-8, -4}) { + expected.add(new Sample(false, x, y, z)); + expected.add(new Sample(true, x, y * 0.25D, z)); + } + } + } + assertEquals(expected, samples); + assertEquals(first, createColumn.invoke(runtime, -7, -1)); + assertEquals(expected, samples); + runtime.clear(); + samples.clear(); + assertEquals(first, createColumn.invoke(runtime, -7, -1)); + assertEquals(expected, samples); + } + + private static IrisTerrain3D profile() { + return new IrisTerrain3D().setAmplitude(33.5D).setCrackDepth(24.5D) + .setCrackWidth(4.75D).setCrackScale(64D).setHorizontalScale(64D).setVerticalScale(64D) + .setMinimumSlope(0D).setFluidClearance(0D).setFluidFade(24D); + } + + private static boolean referenceSolid(int x, int y, int z, double baseHeight, double fluid, double strength) { + int gridY = Math.floorDiv(y, 4) * 4; + double dy = (y - gridY) / 4D; + double displacement = lerp(plane(x, gridY, z, fluid, strength, 0), + plane(x, gridY + 4, z, fluid, strength, 0), dy); + double distance = Math.abs(lerp(plane(x, gridY, z, fluid, strength, 1), + plane(x, gridY + 4, z, fluid, strength, 1), dy)); + double ridge = Math.max(0D, 1D - distance / 4.75D); + double depth = lerp(plane(x, gridY, z, fluid, strength, 2), + plane(x, gridY + 4, z, fluid, strength, 2), dy); + return baseHeight + 0.5D - y + displacement - depth * ridge * ridge >= 0D; + } + + private static double plane(int x, int y, int z, double fluid, double strength, int component) { + int gx = Math.floorDiv(x, 4) * 4; + int gz = Math.floorDiv(z, 4) * 4; + double dx = (x - gx) / 4D; + double dz = (z - gz) / 4D; + double north = lerp(node(gx, y, gz, fluid, strength, component), + node(gx + 4, y, gz, fluid, strength, component), dx); + double south = lerp(node(gx, y, gz + 4, fluid, strength, component), + node(gx + 4, y, gz + 4, fluid, strength, component), dx); + return lerp(north, south, dz); + } + + private static double node(int x, int y, int z, double fluid, double strength, int component) { + double fade = strength * smooth((y - fluid) / 24D); + return switch (component) { + case 0 -> fade == 0D ? 0D : 33.5D * fade * density(x, y, z); + case 1 -> fade == 0D ? 0D : 32D * cracks(x, y * 0.25D, z); + case 2 -> 24.5D * fade; + default -> throw new AssertionError(component); + }; + } + + private static double density(double x, double y, double z) { + return StrictMath.sin(y * 0.31D) * StrictMath.cos(x * 0.08D) * StrictMath.cos(z * 0.06D); + } + + private static double cracks(double x, double y, double z) { + return StrictMath.sin(x * 0.37D + y * 0.21D - z * 0.29D); + } + + private static double smooth(double value) { + double bounded = Math.clamp(value, 0D, 1D); + return bounded * bounded * (3D - 2D * bounded); + } + + private static double lerp(double lower, double upper, double fraction) { + return lower + (upper - lower) * fraction; + } + + private record Sample(boolean fissure, double x, double y, double z) { + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/AtomicDirectoryPublisherTest.java b/core/src/test/java/art/arcane/iris/pack/AtomicDirectoryPublisherTest.java index 5395af1c0..ecbe7b6e0 100644 --- a/core/src/test/java/art/arcane/iris/pack/AtomicDirectoryPublisherTest.java +++ b/core/src/test/java/art/arcane/iris/pack/AtomicDirectoryPublisherTest.java @@ -3,21 +3,122 @@ package art.arcane.iris.pack; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import java.nio.file.AccessDeniedException; +import java.nio.file.DirectoryNotEmptyException; import java.nio.file.Files; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; public class AtomicDirectoryPublisherTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @Test + public void deletionRemovesOperatingSystemMetadataCreatedAfterTheWalkSnapshot() throws Exception { + Path root = temporaryFolder.newFolder("late-metadata").toPath(); + Path pack = Files.createDirectory(root.resolve("pack")); + List snapshot; + try (Stream stream = Files.walk(root)) { + snapshot = stream.toList(); + } + try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.walk(eq(root))).thenAnswer(invocation -> { + Files.writeString(pack.resolve(".DS_Store"), "finder"); + Files.writeString(pack.resolve("._pack"), "metadata"); + return snapshot.stream(); + }); + + AtomicDirectoryPublisher.deleteTree(root); + } + + assertFalse(Files.exists(root)); + } + + @Test + public void lateRealPayloadIsNotDeletedDuringMetadataRecovery() throws Exception { + Path root = temporaryFolder.newFolder("late-payload").toPath(); + List snapshot = List.of(root); + try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.walk(eq(root))).thenAnswer(invocation -> { + Files.writeString(root.resolve(".DS_Store"), "finder"); + Files.writeString(root.resolve("terrain.dat"), "keep"); + return snapshot.stream(); + }); + + assertThrows(DirectoryNotEmptyException.class, () -> AtomicDirectoryPublisher.deleteTree(root)); + } + + assertEquals("keep", Files.readString(root.resolve("terrain.dat"))); + assertEquals("finder", Files.readString(root.resolve(".DS_Store"))); + } + + @Test + public void lateMetadataSymlinkIsNotFollowedOrTreatedAsMetadata() throws Exception { + Path root = temporaryFolder.newFolder("late-link").toPath(); + Path outside = temporaryFolder.getRoot().toPath().resolve("outside.txt"); + Files.writeString(outside, "keep"); + try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.walk(eq(root))).thenAnswer(invocation -> { + Files.createSymbolicLink(root.resolve(".DS_Store"), outside); + return Stream.of(root); + }); + + assertThrows(DirectoryNotEmptyException.class, () -> AtomicDirectoryPublisher.deleteTree(root)); + } + + assertTrue(Files.isSymbolicLink(root.resolve(".DS_Store"))); + assertEquals("keep", Files.readString(outside)); + } + + @Test + public void continuouslyRecreatedMetadataCannotCauseAnUnboundedRetry() throws Exception { + Path root = temporaryFolder.newFolder("persistent-metadata").toPath(); + AtomicInteger attempts = new AtomicInteger(); + try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.deleteIfExists(eq(root))).thenAnswer(invocation -> { + attempts.incrementAndGet(); + Files.writeString(root.resolve(".DS_Store"), "finder"); + return invocation.callRealMethod(); + }); + + assertThrows(DirectoryNotEmptyException.class, () -> AtomicDirectoryPublisher.deleteTree(root)); + } + + assertEquals(4, attempts.get()); + assertTrue(Files.exists(root)); + } + + @Test + public void permissionFailuresKeepTheirOriginalCauseWithoutRetries() throws Exception { + Path root = temporaryFolder.newFolder("denied-deletion").toPath(); + AccessDeniedException denied = new AccessDeniedException(root.toString()); + AtomicInteger attempts = new AtomicInteger(); + try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.deleteIfExists(eq(root))).thenAnswer(invocation -> { + attempts.incrementAndGet(); + throw denied; + }); + + assertSame(denied, assertThrows(AccessDeniedException.class, () -> AtomicDirectoryPublisher.deleteTree(root))); + } + + assertEquals(1, attempts.get()); + } + @Test public void retainedBackupSurvivesCommitAndRemainsAvailableForRollback() throws Exception { Path root = temporaryFolder.getRoot().toPath(); diff --git a/core/src/test/java/art/arcane/iris/pack/PackBiomeLayerValidatorTest.java b/core/src/test/java/art/arcane/iris/pack/PackBiomeLayerValidatorTest.java index 8fbdf396d..2e548174e 100644 --- a/core/src/test/java/art/arcane/iris/pack/PackBiomeLayerValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/pack/PackBiomeLayerValidatorTest.java @@ -54,6 +54,17 @@ public class PackBiomeLayerValidatorTest { ), PackBiomeLayerValidator.validateLayers(new File(pack, "biomes"))); } + @Test + public void rejectsNullLayerArraysAndNonObjectElements() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "biomes/bad.json", "{\"layers\":null,\"caveCeilingLayers\":[null,7,{},\"snippet/biome-palette/valid\"]}"); + assertEquals(List.of( + "Biome 'bad' layers must be an array.", + "Biome 'bad' caveCeilingLayers[0] must be an object or snippet reference.", + "Biome 'bad' caveCeilingLayers[1] must be an object or snippet reference." + ), PackBiomeLayerValidator.validateLayers(new File(pack, "biomes"))); + } + @Test public void independentCeilingLayersPassFullPackValidation() throws Exception { File pack = temporaryFolder.newFolder("pack"); diff --git a/core/src/test/java/art/arcane/iris/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/pack/PackDownloaderTest.java index d9c48a162..5ac2acf03 100644 --- a/core/src/test/java/art/arcane/iris/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/pack/PackDownloaderTest.java @@ -45,6 +45,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -196,6 +197,18 @@ public class PackDownloaderTest { assertTransactionStateClean(packsFolder); } + @Test + public void truncatedArchiveCannotBeExtractedAsCompletePack() throws Exception { + Path archive = temp.newFile("truncated.zip").toPath(); + writeArchive(archive, Map.of("dimensions/main.json", "{}")); + byte[] complete = Files.readAllBytes(archive); + Files.write(archive, Arrays.copyOf(complete, complete.length - 22)); + + assertThrows(IOException.class, () -> PackDownloader.unpackArchive( + archive, temp.newFolder("truncated-extraction").toPath(), + new PackDownloader.ArchiveLimits(4096L, 10, 4096L, 4096L))); + } + @Test public void recognizesOnlyHttpZipUrlsAsDirectSources() { assertTrue(PackDownloader.isDirectZipUrl("https://packs.example.test/overworld.zip")); @@ -585,6 +598,23 @@ public class PackDownloaderTest { assertFalse(feedback.isEmpty()); } + @Test + public void replacementInvalidatesRootValidationAndRejectsOlderTickets() throws Exception { + File packsFolder = temp.newFolder("validation-packs"); + File target = writePack(packsFolder.toPath().resolve("replaceable"), "replaceable", "old"); + File extracted = writePack(temp.newFolder("validation-source").toPath(), "replaceable", "new"); + PackValidationResult original = new PackValidationResult("replaceable", List.of(), List.of(), 1L); + PackValidationRegistry.publish(target.toPath(), original); + PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(target.toPath()); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, extracted, true, "replaceable", ignored -> {}); + + assertNotNull(result); + assertNull(PackValidationRegistry.get(target.toPath())); + assertFalse(PackValidationRegistry.publishIfCurrent(ticket, original)); + } + @Test public void forceOverwritePublishesValidatedPackAndCleansTransactionState() throws Exception { File packsFolder = temp.newFolder("force-packs"); diff --git a/core/src/test/java/art/arcane/iris/pack/PackFingerprintPlatformIsolationTest.java b/core/src/test/java/art/arcane/iris/pack/PackFingerprintPlatformIsolationTest.java new file mode 100644 index 000000000..592f96754 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/PackFingerprintPlatformIsolationTest.java @@ -0,0 +1,98 @@ +package art.arcane.iris.pack; + +import art.arcane.iris.spi.IrisPlatform; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; + +public class PackFingerprintPlatformIsolationTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void validationContentFingerprintRunsWithoutBukkitClasses() throws Exception { + File packs = temporary.newFolder("packs"); + File resource = new File(packs, "content.json"); + Files.writeString(resource.toPath(), "first"); + URL[] sources = { + PackValidationCache.class.getProtectionDomain().getCodeSource().getLocation(), + IrisPlatform.class.getProtectionDomain().getCodeSource().getLocation() + }; + try (URLClassLoader loader = new WithoutBukkitLoader(sources)) { + try { + loader.loadClass("org.bukkit.command.CommandSender"); + fail("Bukkit must be unavailable to this runtime"); + } catch (ClassNotFoundException expected) { + assertEquals("org.bukkit.command.CommandSender", expected.getMessage()); + } + Class cache = Class.forName("art.arcane.iris.pack.PackValidationCache", true, loader); + assertSame(loader, cache.getClassLoader()); + String first = (String) cache.getMethod("contentFingerprint", File.class).invoke(null, packs); + assertEquals("1915829f796381467340a94bd3581569cb1a8694d59f077bf14c849c39b20bb9", first); + Files.writeString(resource.toPath(), "second"); + String second = (String) cache.getMethod("contentFingerprint", File.class).invoke(null, packs); + assertNotEquals(first, second); + assertEquals("9b21d3b13634fb96c41b442e2b886b15b62d10bbdea5258d76782eb28289d52d", second); + } + } + + @Test + public void treeAndWorkspaceSnapshotRunWithoutBukkitAndKeepCanonicalHashes() throws Exception { + File packs = temporary.newFolder("snapshot-packs"); + File pack = new File(packs, "overworld"); + Files.createDirectories(pack.toPath()); + Files.writeString(new File(pack, "content.json").toPath(), "first"); + URL[] sources = {PackFingerprints.class.getProtectionDomain().getCodeSource().getLocation()}; + try (URLClassLoader loader = new WithoutBukkitLoader(sources)) { + Class fingerprints = Class.forName("art.arcane.iris.pack.PackFingerprints", true, loader); + assertSame(loader, fingerprints.getClassLoader()); + String tree = (String) fingerprints.getMethod("computePackTreeFingerprint", File.class).invoke(null, pack); + assertEquals("1915829f796381467340a94bd3581569cb1a8694d59f077bf14c849c39b20bb9", tree); + Object snapshot = fingerprints.getMethod("computePackContentSnapshot", File.class).invoke(null, packs); + String content = (String) snapshot.getClass().getMethod("content").invoke(snapshot); + assertEquals(PackFingerprints.computePackFingerprint(packs), content); + assertEquals(PackFingerprints.computePackContentSnapshot(packs).packContents(), + snapshot.getClass().getMethod("packContents").invoke(snapshot)); + String metadata = (String) fingerprints.getMethod("computePackMetadataDigest", File.class).invoke(null, packs); + assertEquals(PackFingerprints.computePackMetadataDigest(packs), metadata); + } + } + + private static final class WithoutBukkitLoader extends URLClassLoader { + private WithoutBukkitLoader(URL[] sources) { + super(sources, PackFingerprintPlatformIsolationTest.class.getClassLoader()); + } + + @Override + protected synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("org.bukkit.")) { + throw new ClassNotFoundException(name); + } + if (!name.startsWith("art.arcane.iris.")) { + return super.loadClass(name, resolve); + } + Class loaded = findLoadedClass(name); + if (loaded == null) { + try { + loaded = findClass(name); + } catch (ClassNotFoundException missing) { + return super.loadClass(name, resolve); + } + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/PackResourceCleanupTest.java b/core/src/test/java/art/arcane/iris/pack/PackResourceCleanupTest.java index 7957e8d54..465975180 100644 --- a/core/src/test/java/art/arcane/iris/pack/PackResourceCleanupTest.java +++ b/core/src/test/java/art/arcane/iris/pack/PackResourceCleanupTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.pack; import org.junit.Rule; +import org.junit.After; +import org.mockito.MockedStatic; import org.junit.Test; import org.junit.Assume; import org.junit.rules.TemporaryFolder; @@ -17,17 +19,89 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; public class PackResourceCleanupTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + @After + public void clearValidation() { + PackValidationRegistry.clear(); + } + + @Test + public void cleanupScansReplacementTreeAfterAcquiringMutationOwnership() throws Exception { + File pack = createPack("replacement-target"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/future.json", "{}"); + File staged = createPack("replacement-source"); + write(staged, "dimensions/main.json", "{\"biome\":\"future\"}"); + write(staged, "biomes/future.json", "{\"name\":\"Required\"}"); + Path root = pack.toPath().toAbsolutePath().normalize(); + try (MockedStatic registry = replaceAtMutationAdmission(staged, root)) { + PackResourceCleanup.ApplyResult result = PackResourceCleanup.apply(pack); + + assertTrue(result.error(), result.success()); + assertFalse(result.changed()); + assertTrue(new File(pack, "biomes/future.json").isFile()); + } + } + + @Test + public void restoreScansReplacementTreeAfterAcquiringMutationOwnership() throws Exception { + File pack = createPack("restore-target"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/old.json", "{}"); + assertTrue(PackResourceCleanup.apply(pack).changed()); + File staged = createPack("restore-source"); + write(staged, "dimensions/main.json", "{}"); + write(staged, ".iris-trash/new-dump/biomes/current.json", "{}"); + Path root = pack.toPath().toAbsolutePath().normalize(); + try (MockedStatic registry = replaceAtMutationAdmission(staged, root)) { + PackResourceCleanup.RestoreResult result = PackResourceCleanup.restoreLatest(pack); + + assertTrue(result.error(), result.success()); + assertTrue(result.changed()); + assertEquals(List.of("biomes/current.json"), result.restoredPaths()); + assertTrue(new File(pack, "biomes/current.json").isFile()); + assertFalse(new File(pack, "biomes/old.json").exists()); + } + } + + @Test + public void cleanupAndRestoreInvalidateValidationAndOlderTickets() throws Exception { + File pack = createPack("validation"); + write(pack, "dimensions/main.json", "{}"); + write(pack, "biomes/unused.json", "{}"); + PackValidationResult original = new PackValidationResult(pack.getName(), List.of(), List.of(), 1L); + PackValidationRegistry.publish(pack.toPath(), original); + PackValidationRegistry.publish(original); + PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(pack.toPath()); + + assertTrue(PackResourceCleanup.apply(pack).changed()); + + assertNull(PackValidationRegistry.get(pack.toPath())); + assertNull(PackValidationRegistry.get(pack.getName())); + assertFalse(PackValidationRegistry.publishIfCurrent(ticket, original)); + PackValidationRegistry.publish(pack.toPath(), original); + PackValidationRegistry.publish(original); + ticket = PackValidationRegistry.tryBeginValidation(pack.toPath()); + + assertTrue(PackResourceCleanup.restoreLatest(pack).changed()); + + assertNull(PackValidationRegistry.get(pack.toPath())); + assertNull(PackValidationRegistry.get(pack.getName())); + assertFalse(PackValidationRegistry.publishIfCurrent(ticket, original)); + } + @Test public void previewIsReadOnlyAndReturnsSortedCandidates() throws Exception { File pack = createPack("preview"); @@ -234,6 +308,20 @@ public class PackResourceCleanupTest { } } + private static MockedStatic replaceAtMutationAdmission(File staged, Path root) { + AtomicBoolean replaced = new AtomicBoolean(); + return mockStatic(PackValidationRegistry.class, invocation -> { + if (invocation.getMethod().getName().equals("beginRootMutation") + && root.equals(invocation.getArgument(0)) && replaced.compareAndSet(false, true)) { + try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staged.toPath(), root)) { + publication.commit(); + publication.cleanupBackup(); + } + } + return invocation.callRealMethod(); + }); + } + private File createPack(String name) throws Exception { return temporaryFolder.newFolder(name); } diff --git a/core/src/test/java/art/arcane/iris/pack/PackValidatorDimensionHeightTest.java b/core/src/test/java/art/arcane/iris/pack/PackValidatorDimensionHeightTest.java index 30726b6ee..805f2cb10 100644 --- a/core/src/test/java/art/arcane/iris/pack/PackValidatorDimensionHeightTest.java +++ b/core/src/test/java/art/arcane/iris/pack/PackValidatorDimensionHeightTest.java @@ -93,6 +93,63 @@ public class PackValidatorDimensionHeightTest { "Dimension 'main' dimensionHeight.min is -60; it must be a multiple of 16.")); } + @Test + public void rejectsNullAndUnresolvedHeightRanges() throws Exception { + for (String height : List.of("null", "\"missing\"", "\"snippet/range/missing\"", "[]")) { + PackValidationResult result = validate("{\"regions\":[\"region\"],\"dimensionHeight\":" + height + "}"); + assertFalse(height + " " + result.getBlockingErrors(), result.isLoadable()); + assertTrue(result.getBlockingErrors().toString(), result.getBlockingErrors().stream() + .anyMatch(error -> error.contains("dimensionHeight"))); + } + } + + @Test + public void rejectsMalformedHeightSnippet() throws Exception { + File pack = pack("{\"regions\":[\"region\"],\"dimensionHeight\":\"snippet/range/broken\"}"); + write(pack, "snippet/range/broken.json", "{"); + assertFalse(PackValidator.validate(pack).isLoadable()); + } + + @Test + public void rejectsInvalidRangeNumbersWithoutInventingDefaults() throws Exception { + for (String value : List.of("\"NaN\"", "\"Infinity\"", "\"not-a-number\"", "{}", "true")) { + PackValidationResult result = validate("{\"regions\":[\"region\"],\"logicalHeight\":16," + + "\"dimensionHeight\":{\"min\":" + value + ",\"max\":32}}"); + assertFalse(value + " " + result.getBlockingErrors(), result.isLoadable()); + } + } + + @Test + public void rejectsMalformedLogicalHeightWithoutTruncatingOrDefaulting() throws Exception { + for (String value : List.of("16.5", "4294967552", "\"not-a-number\"", "{}", "true")) { + PackValidationResult result = validate("{\"regions\":[\"region\"],\"logicalHeight\":" + value + "}"); + assertTrue(value + " " + result.getBlockingErrors(), result.getBlockingErrors().contains( + "Dimension 'main' logicalHeight must be an integer.")); + } + } + + @Test + public void acceptsNumericRangeStringsAndSnippetRerooting() throws Exception { + File pack = pack("{\"regions\":[\"region\"],\"dimensionHeight\":\"snippet/tall\"}"); + write(pack, "snippet/range/tall.json", "{\"min\":\"-64\",\"max\":\"320\"}"); + PackValidationResult result = PackValidator.validate(pack); + assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); + PackValidationResult numericLogical = validate("{\"regions\":[\"region\"],\"logicalHeight\":\"16.0\"," + + "\"dimensionHeight\":{\"min\":16,\"max\":32}}"); + assertTrue(numericLogical.getBlockingErrors().toString(), numericLogical.isLoadable()); + } + + @Test + public void rejectsMissingUpperDimensionButAllowsSelfReferenceAndDisabledUpper() throws Exception { + PackValidationResult missing = validate("{\"regions\":[\"region\"],\"upperDimension\":\"absent\"}"); + assertTrue(missing.getBlockingErrors().contains( + "Dimension 'main' upperDimension references missing dimension 'absent'.")); + for (String upper : List.of("main", "", "none", "NONE")) { + PackValidationResult result = validate("{\"regions\":[\"region\"],\"upperDimension\":\"" + upper + "\"}"); + assertTrue(upper + " " + result.getBlockingErrors(), result.isLoadable()); + } + } + @Test public void validatorAgreesWithIrisDimensionTypeExactly() { int[][] triples = { diff --git a/core/src/test/java/art/arcane/iris/pack/PackValidatorReadOnlyTest.java b/core/src/test/java/art/arcane/iris/pack/PackValidatorReadOnlyTest.java index 96ef39825..19119a00f 100644 --- a/core/src/test/java/art/arcane/iris/pack/PackValidatorReadOnlyTest.java +++ b/core/src/test/java/art/arcane/iris/pack/PackValidatorReadOnlyTest.java @@ -1,5 +1,10 @@ package art.arcane.iris.pack; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisLogging; +import org.mockito.MockedStatic; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -16,6 +21,9 @@ import static org.junit.Assert.assertArrayEquals; 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.mockStatic; +import static org.mockito.Mockito.when; public class PackValidatorReadOnlyTest { @Rule @@ -41,6 +49,29 @@ public class PackValidatorReadOnlyTest { assertFalse(new File(pack, ".iris-trash").exists()); } + @Test + public void contentGateInitializationFailureBlocksPackPublication() throws Exception { + File pack = temporaryFolder.newFolder("failed-gate"); + write(pack, "dimensions/main.json", "{\"regions\":[\"region\"]}"); + write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); + write(pack, "biomes/biome.json", "{\"name\":\"Biome\"}"); + assertTrue(PackValidator.validateForPackaging(pack).isLoadable()); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.minecraftVersion()).thenReturn("26.3"); + try (MockedStatic platforms = mockStatic(IrisPlatforms.class); + MockedStatic data = mockStatic(IrisData.class); + MockedStatic logging = mockStatic(IrisLogging.class)) { + platforms.when(IrisPlatforms::isBound).thenReturn(true); + platforms.when(IrisPlatforms::get).thenReturn(platform); + data.when(() -> IrisData.openRuntime(pack)).thenThrow(new IllegalStateException("Registry unavailable")); + + PackValidationResult result = PackValidator.validate(pack); + + assertFalse(result.isLoadable()); + assertTrue(result.getBlockingErrors().toString().contains("Registry unavailable")); + } + } + private void write(File root, String relative, String content) throws Exception { Path path = root.toPath().resolve(relative); Files.createDirectories(path.getParent()); diff --git a/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorDatapackFingerprintTest.java index 33630df43..14ce681ff 100644 --- a/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorDatapackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/pack/datapack/ServerConfiguratorDatapackFingerprintTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.pack.datapack; +import art.arcane.iris.pack.PackFingerprints; + import org.junit.Rule; import org.junit.Test; import org.junit.Assume; @@ -9,7 +11,6 @@ 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; import java.nio.file.Path; @@ -27,20 +28,15 @@ public class ServerConfiguratorDatapackFingerprintTest { @Rule public TemporaryFolder tmp = new TemporaryFolder(); - private Method fingerprintMethod() throws Exception { - return ServerConfigurator.class.getMethod("computePackFingerprint", File.class); - } - @Test public void computePackFingerprintReturnsSameHashForUnchangedFiles() throws Exception { - Method method = fingerprintMethod(); File packsDir = tmp.newFolder("packs"); File dimFile = new File(packsDir, "testpack/dimensions/overworld.json"); dimFile.getParentFile().mkdirs(); dimFile.createNewFile(); - String fp1 = (String) method.invoke(null, packsDir); - String fp2 = (String) method.invoke(null, packsDir); + String fp1 = PackFingerprints.computePackFingerprint(packsDir); + String fp2 = PackFingerprints.computePackFingerprint(packsDir); assertNotNull("Fingerprint must not be null", fp1); assertEquals("Same unchanged files must produce identical fingerprint", fp1, fp2); @@ -48,15 +44,14 @@ public class ServerConfiguratorDatapackFingerprintTest { @Test public void computePackFingerprintIgnoresMetadataOnlyChanges() throws Exception { - Method method = fingerprintMethod(); File packsDir = tmp.newFolder("packs"); File dimFile = new File(packsDir, "testpack/dimensions/overworld.json"); dimFile.getParentFile().mkdirs(); dimFile.createNewFile(); - String fp1 = (String) method.invoke(null, packsDir); + String fp1 = PackFingerprints.computePackFingerprint(packsDir); dimFile.setLastModified(dimFile.lastModified() + 2000L); - String fp2 = (String) method.invoke(null, packsDir); + String fp2 = PackFingerprints.computePackFingerprint(packsDir); assertEquals("Metadata-only changes must not alter a content fingerprint", fp1, fp2); } @@ -68,11 +63,11 @@ public class ServerConfiguratorDatapackFingerprintTest { Files.createDirectories(dimension.getParent()); Files.writeString(dimension, "aaaa", StandardCharsets.UTF_8); FileTime originalMtime = Files.getLastModifiedTime(dimension); - String before = ServerConfigurator.computePackFingerprint(packsDir); + String before = PackFingerprints.computePackFingerprint(packsDir); Files.writeString(dimension, "bbbb", StandardCharsets.UTF_8); Files.setLastModifiedTime(dimension, originalMtime); - String after = ServerConfigurator.computePackFingerprint(packsDir); + String after = PackFingerprints.computePackFingerprint(packsDir); assertNotEquals("Equal-size content changes must alter the fingerprint", before, after); } @@ -89,18 +84,18 @@ public class ServerConfiguratorDatapackFingerprintTest { Files.writeString(alphaDimension, "alpha-a", StandardCharsets.UTF_8); Files.writeString(betaDimension, "beta-a", StandardCharsets.UTF_8); - ServerConfigurator.PackContentSnapshot before = - ServerConfigurator.computePackContentSnapshot(packsDir); + PackFingerprints.PackContentSnapshot before = + PackFingerprints.computePackContentSnapshot(packsDir); - assertEquals(ServerConfigurator.computePackFingerprint(packsDir), before.content()); - assertEquals(ServerConfigurator.computePackTreeFingerprint(alphaPack.toFile()), + assertEquals(PackFingerprints.computePackFingerprint(packsDir), before.content()); + assertEquals(PackFingerprints.computePackTreeFingerprint(alphaPack.toFile()), before.packContents().get("alpha")); - assertEquals(ServerConfigurator.computePackTreeFingerprint(betaPack.toFile()), + assertEquals(PackFingerprints.computePackTreeFingerprint(betaPack.toFile()), before.packContents().get("beta")); Files.writeString(alphaDimension, "alpha-b", StandardCharsets.UTF_8); - ServerConfigurator.PackContentSnapshot after = - ServerConfigurator.computePackContentSnapshot(packsDir); + PackFingerprints.PackContentSnapshot after = + PackFingerprints.computePackContentSnapshot(packsDir); assertNotEquals(before.content(), after.content()); assertNotEquals(before.packContents().get("alpha"), after.packContents().get("alpha")); @@ -109,17 +104,16 @@ public class ServerConfiguratorDatapackFingerprintTest { @Test public void computePackFingerprintChangesWhenFileIsAdded() throws Exception { - Method method = fingerprintMethod(); File packsDir = tmp.newFolder("packs"); File dimDir = new File(packsDir, "testpack/dimensions"); dimDir.mkdirs(); File dimFile = new File(dimDir, "overworld.json"); dimFile.createNewFile(); - String fp1 = (String) method.invoke(null, packsDir); + String fp1 = PackFingerprints.computePackFingerprint(packsDir); File extraFile = new File(dimDir, "nether.json"); extraFile.createNewFile(); - String fp2 = (String) method.invoke(null, packsDir); + String fp2 = PackFingerprints.computePackFingerprint(packsDir); assertNotEquals("Adding a file must produce a different fingerprint", fp1, fp2); } @@ -133,11 +127,11 @@ public class ServerConfiguratorDatapackFingerprintTest { Files.createDirectories(hidden.getParent()); Files.writeString(visible, "visible", StandardCharsets.UTF_8); Files.writeString(hidden, "stage-one", StandardCharsets.UTF_8); - String before = ServerConfigurator.computePackFingerprint(packsDir); + String before = PackFingerprints.computePackFingerprint(packsDir); Files.writeString(hidden, "stage-two", StandardCharsets.UTF_8); - assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir)); + assertEquals(before, PackFingerprints.computePackFingerprint(packsDir)); } @Test @@ -149,12 +143,12 @@ public class ServerConfiguratorDatapackFingerprintTest { Files.createDirectories(visible.getParent()); Files.writeString(visible, "visible", StandardCharsets.UTF_8); Files.writeString(hidden, "hidden-one", StandardCharsets.UTF_8); - ServerConfigurator.PackContentSnapshot before = - ServerConfigurator.computePackContentSnapshot(packsDir); + PackFingerprints.PackContentSnapshot before = + PackFingerprints.computePackContentSnapshot(packsDir); Files.writeString(hidden, "hidden-two", StandardCharsets.UTF_8); - ServerConfigurator.PackContentSnapshot after = - ServerConfigurator.computePackContentSnapshot(packsDir); + PackFingerprints.PackContentSnapshot after = + PackFingerprints.computePackContentSnapshot(packsDir); assertNotEquals(before.content(), after.content()); assertNotEquals( @@ -162,7 +156,7 @@ public class ServerConfiguratorDatapackFingerprintTest { after.packContents().get("testpack")); assertEquals( after.packContents().get("testpack"), - ServerConfigurator.computePackTreeFingerprint(pack.toFile())); + PackFingerprints.computePackTreeFingerprint(pack.toFile())); } @Test @@ -171,20 +165,20 @@ public class ServerConfiguratorDatapackFingerprintTest { Path dimension = packsDir.toPath().resolve("overworld/dimensions/overworld.json"); Files.createDirectories(dimension.getParent()); Files.writeString(dimension, "authored", StandardCharsets.UTF_8); - String before = ServerConfigurator.computePackFingerprint(packsDir); - String packBefore = ServerConfigurator.computePackTreeFingerprint( + String before = PackFingerprints.computePackFingerprint(packsDir); + String packBefore = PackFingerprints.computePackTreeFingerprint( packsDir.toPath().resolve("overworld").toFile()); Path workspace = packsDir.toPath().resolve("overworld/overworld.code-workspace"); Files.writeString(workspace, "{\"folders\":[]}", StandardCharsets.UTF_8); assertEquals("Iris-generated workspace files must not alter the fingerprint", - before, ServerConfigurator.computePackFingerprint(packsDir)); + before, PackFingerprints.computePackFingerprint(packsDir)); Files.writeString(workspace, "{\"folders\":[{\"path\":\".\"}]}", StandardCharsets.UTF_8); assertEquals("Reordered workspace bytes must not alter the fingerprint", - before, ServerConfigurator.computePackFingerprint(packsDir)); + before, PackFingerprints.computePackFingerprint(packsDir)); Path schema = packsDir.toPath().resolve("overworld/.iris/schema/dimension.json"); Path repositoryObject = packsDir.toPath().resolve("overworld/.git/objects/blob"); @@ -193,8 +187,8 @@ public class ServerConfiguratorDatapackFingerprintTest { Files.writeString(schema, "generated schema", StandardCharsets.UTF_8); Files.writeString(repositoryObject, "repository metadata", StandardCharsets.UTF_8); - assertEquals(before, ServerConfigurator.computePackFingerprint(packsDir)); - assertEquals(packBefore, ServerConfigurator.computePackTreeFingerprint( + assertEquals(before, PackFingerprints.computePackFingerprint(packsDir)); + assertEquals(packBefore, PackFingerprints.computePackTreeFingerprint( packsDir.toPath().resolve("overworld").toFile())); } @@ -207,16 +201,16 @@ public class ServerConfiguratorDatapackFingerprintTest { Files.createDirectories(dimension.getParent()); Files.createDirectories(metadata.getParent()); Files.writeString(dimension, "authored", StandardCharsets.UTF_8); - ServerConfigurator.PackContentSnapshot before = ServerConfigurator.computePackContentSnapshot(packsDir); - String metadataBefore = ServerConfigurator.computePackMetadataDigest(packsDir); + PackFingerprints.PackContentSnapshot before = PackFingerprints.computePackContentSnapshot(packsDir); + String metadataBefore = PackFingerprints.computePackMetadataDigest(packsDir); for (String content : new String[]{"initial Finder layout", "updated Finder layout with different length"}) { Files.writeString(metadata, content, StandardCharsets.UTF_8); - ServerConfigurator.PackContentSnapshot after = ServerConfigurator.computePackContentSnapshot(packsDir); + PackFingerprints.PackContentSnapshot after = PackFingerprints.computePackContentSnapshot(packsDir); assertEquals(before, after); assertEquals(before.packContents().get("overworld"), - ServerConfigurator.computePackTreeFingerprint(pack.toFile())); - assertEquals(metadataBefore, ServerConfigurator.computePackMetadataDigest(packsDir)); + PackFingerprints.computePackTreeFingerprint(pack.toFile())); + assertEquals(metadataBefore, PackFingerprints.computePackMetadataDigest(packsDir)); } } @@ -229,16 +223,16 @@ public class ServerConfiguratorDatapackFingerprintTest { Files.writeString(resource, "aaaa", StandardCharsets.UTF_8); Files.writeString(resource.resolveSibling(".DS_Store"), "Finder layout", StandardCharsets.UTF_8); FileTime originalMtime = Files.getLastModifiedTime(resource); - ServerConfigurator.PackContentSnapshot before = ServerConfigurator.computePackContentSnapshot(packsDir); + PackFingerprints.PackContentSnapshot before = PackFingerprints.computePackContentSnapshot(packsDir); Files.writeString(resource, "bbbb", StandardCharsets.UTF_8); Files.setLastModifiedTime(resource, originalMtime); - ServerConfigurator.PackContentSnapshot after = ServerConfigurator.computePackContentSnapshot(packsDir); + PackFingerprints.PackContentSnapshot after = PackFingerprints.computePackContentSnapshot(packsDir); assertNotEquals(before.content(), after.content()); assertNotEquals(before.packContents().get("overworld"), after.packContents().get("overworld")); assertEquals(after.packContents().get("overworld"), - ServerConfigurator.computePackTreeFingerprint(pack.toFile())); + PackFingerprints.computePackTreeFingerprint(pack.toFile())); } @Test @@ -249,7 +243,7 @@ public class ServerConfiguratorDatapackFingerprintTest { Files.writeString(dimension, "aaaa", StandardCharsets.UTF_8); ServerConfigurator.PackFingerprint first = ServerConfigurator.resolvePackFingerprint(packsDir, "", ""); - assertEquals(ServerConfigurator.computePackFingerprint(packsDir), first.content()); + assertEquals(PackFingerprints.computePackFingerprint(packsDir), first.content()); assertNotEquals("", first.metadata()); FileTime originalMtime = Files.getLastModifiedTime(dimension); @@ -267,7 +261,7 @@ public class ServerConfiguratorDatapackFingerprintTest { assertNotEquals("Changed metadata must re-hash pack contents", first.content(), rehashed.content()); - assertEquals(ServerConfigurator.computePackFingerprint(packsDir), rehashed.content()); + assertEquals(PackFingerprints.computePackFingerprint(packsDir), rehashed.content()); } @Test @@ -298,7 +292,7 @@ public class ServerConfiguratorDatapackFingerprintTest { assertEquals(cached.content(), reused.content()); assertNotEquals(cached.content(), recovered.content()); - assertEquals(ServerConfigurator.computePackFingerprint(packsDir), recovered.content()); + assertEquals(PackFingerprints.computePackFingerprint(packsDir), recovered.content()); } @Test @@ -337,12 +331,12 @@ public class ServerConfiguratorDatapackFingerprintTest { Path dimension = packsDir.toPath().resolve("overworld/dimensions/overworld.json"); Files.createDirectories(dimension.getParent()); Files.writeString(dimension, "authored", StandardCharsets.UTF_8); - String before = ServerConfigurator.computePackMetadataDigest(packsDir); + String before = PackFingerprints.computePackMetadataDigest(packsDir); Files.writeString(packsDir.toPath().resolve("overworld/overworld.code-workspace"), "{\"folders\":[]}", StandardCharsets.UTF_8); - assertEquals(before, ServerConfigurator.computePackMetadataDigest(packsDir)); + assertEquals(before, PackFingerprints.computePackMetadataDigest(packsDir)); } @Test @@ -359,7 +353,7 @@ public class ServerConfiguratorDatapackFingerprintTest { } try { - ServerConfigurator.computePackFingerprint(packsDir); + PackFingerprints.computePackFingerprint(packsDir); fail("Symbolic links must be rejected"); } catch (UncheckedIOException expected) { assertTrue(expected.getMessage().contains("fingerprint")); @@ -379,11 +373,11 @@ public class ServerConfiguratorDatapackFingerprintTest { } catch (IOException | UnsupportedOperationException | SecurityException exception) { Assume.assumeNoException(exception); } - String before = ServerConfigurator.computePackFingerprint(packsDir); + String before = PackFingerprints.computePackFingerprint(packsDir); Files.writeString(dimension, "other", StandardCharsets.UTF_8); - assertNotEquals(before, ServerConfigurator.computePackFingerprint(packsDir)); + assertNotEquals(before, PackFingerprints.computePackFingerprint(packsDir)); } @Test @@ -401,12 +395,12 @@ public class ServerConfiguratorDatapackFingerprintTest { } catch (IOException | UnsupportedOperationException | SecurityException exception) { Assume.assumeNoException(exception); } - String before = ServerConfigurator.computePackFingerprint(workspaceLink.toFile()); - assertEquals(ServerConfigurator.computePackFingerprint(workspace.toFile()), before); + String before = PackFingerprints.computePackFingerprint(workspaceLink.toFile()); + assertEquals(PackFingerprints.computePackFingerprint(workspace.toFile()), before); Files.writeString(dimension, "other", StandardCharsets.UTF_8); - assertNotEquals(before, ServerConfigurator.computePackFingerprint(workspaceLink.toFile())); + assertNotEquals(before, PackFingerprints.computePackFingerprint(workspaceLink.toFile())); } @Test @@ -419,7 +413,7 @@ public class ServerConfiguratorDatapackFingerprintTest { } try { - ServerConfigurator.computePackFingerprint(workspaceLink.toFile()); + PackFingerprints.computePackFingerprint(workspaceLink.toFile()); fail("Dangling symbolic workspace roots must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("missing or unsafe")); diff --git a/core/src/test/java/art/arcane/iris/pack/loading/IrisDataAuthoringCacheTest.java b/core/src/test/java/art/arcane/iris/pack/loading/IrisDataAuthoringCacheTest.java index 8ed816504..4792f054d 100644 --- a/core/src/test/java/art/arcane/iris/pack/loading/IrisDataAuthoringCacheTest.java +++ b/core/src/test/java/art/arcane/iris/pack/loading/IrisDataAuthoringCacheTest.java @@ -91,7 +91,8 @@ public class IrisDataAuthoringCacheTest { public void resourceKeyDiscoveryPreservesNestedNamesAndCachedArrays() throws Exception { Path root = temporary.newFolder("resource-keys").toPath(); for (String name : List.of( - "generators/plain.json", "generators/nested.json/ridge.json.alt.json", "generators/ignored.JSON", + "generators/plain.json", "generators/nested.json/ridge.json.alt.json", "generators/dotless", "generators/ignored.JSON", + "objects/plain.iob", "objects/nested.iob/ridge.iob.alt.iob", "objects/ignored.IOB", "images/plain.png", "images/nested.png/ridge.png.alt.png", "images/ignored.PNG", "matter/plain.mat", "matter/nested.mat/ridge.mat.alt.mat", "matter/ignored.MAT")) { Path file = root.resolve(name); @@ -100,18 +101,59 @@ public class IrisDataAuthoringCacheTest { } source = IrisData.get(root.toFile()); Map, Set> expected = Map.of( - source.getGeneratorLoader(), Set.of("plain", "nested/ridge.alt"), - source.getImageLoader(), Set.of("plain", "nested.png/ridge.alt"), - source.getMatterLoader(), Set.of("plain", "nested.mat/ridge.alt")); + source.getGeneratorLoader(), Set.of("plain", "nested.json/ridge.json.alt"), + source.getObjectLoader(), Set.of("plain", "nested.iob/ridge.iob.alt"), + source.getImageLoader(), Set.of("plain", "nested.png/ridge.png.alt"), + source.getMatterLoader(), Set.of("plain", "nested.mat/ridge.mat.alt")); for (Map.Entry, Set> entry : expected.entrySet()) { String[] keys = entry.getKey().getPossibleKeys(); assertEquals(entry.getValue(), Set.of(keys)); assertEquals(entry.getValue().size(), keys.length); assertSame(keys, entry.getKey().getPossibleKeys()); + for (String key : keys) { + File resolved = entry.getKey().findFile(key); + assertTrue("Discovered resource must resolve: " + key, resolved != null && resolved.isFile()); + assertEquals(key, source.toLoadKey(resolved)); + Path relative = root.resolve(entry.getKey().getFolderName()).relativize(resolved.toPath()); + String relativeName = relative.toString().replace(File.separatorChar, '/'); + assertEquals(key, relativeName.substring(0, relativeName.lastIndexOf('.'))); + } } } + @Test + public void fileKeyConversionPreservesDotlessNamesAndNormalizesRelativePaths() throws Exception { + Path root = temporary.newFolder("file-keys").toPath(); + Files.createDirectories(root.resolve("generators/nested.json")); + source = IrisData.get(root.toFile()); + + assertEquals("nested.json/dotless", source.toLoadKey(root.resolve("generators/nested.json/dotless").toFile())); + assertEquals("nested.json/ridge.json.alt", source.toLoadKey( + root.resolve("generators/nested.json/../nested.json/ridge.json.alt.json").toFile())); + } + + @Test + public void snippetDiscoveryPreservesDotsAndResolvesItsSourceFiles() throws Exception { + Path root = temporary.newFolder("snippet-keys").toPath(); + Path snippets = root.resolve("snippet/generator"); + for (String name : List.of("plain.json", "nested.json/ridge.json.alt.json", "ignored.JSON")) { + Path file = snippets.resolve(name); + Files.createDirectories(file.getParent()); + Files.writeString(file, "{}"); + } + source = IrisData.get(root.toFile()); + + List keys = source.getPossibleSnippets("generator"); + + assertEquals(Set.of("snippet/plain", "snippet/nested.json/ridge.json.alt"), Set.copyOf(keys)); + for (String key : keys) { + Path resolved = snippets.resolve(key.substring("snippet/".length()) + ".json"); + assertTrue("Discovered snippet must resolve: " + key, Files.isRegularFile(resolved)); + } + assertSame(keys, source.getPossibleSnippets("generator")); + } + @Test public void dropRulePresenceIncludesEachScopeAndIsResetByPackReload() throws Exception { for (String folder : List.of("dimensions", "regions", "biomes")) { diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index fb882a67f..cfb640175 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "cc10defa73f5288d10714cdbdef6bda866acfc656efe54e062fac9995bc5607f"; + private static final String EXPECTED_SCHEMA_DIGEST = "767e66a7a52bdd6ae9ad3f0ccfdcaf2cb509fc89c70bfffb4dcd54b565e643f9"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorSpawnHydrologyTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorSpawnHydrologyTest.java index b3317b817..feec7d939 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorSpawnHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorSpawnHydrologyTest.java @@ -25,6 +25,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; @@ -39,6 +40,84 @@ import static org.mockito.Mockito.when; import static org.mockito.Mockito.times; public class BukkitChunkGeneratorSpawnHydrologyTest { + @Test + public void studioDeferralSurvivesStructureBootstrapAndEndsOnlyAfterEntryCompletion() throws Exception { + Fixture fixture = new Fixture(); + setField(fixture.generator, "studio", true); + setField(fixture.generator, "studioEntryBootstrapActive", new AtomicBoolean(true)); + fixture.generator.setEngine(null); + fixture.generator.beginInitialEntry(false); + fixture.generator.publishInitializedEngine(fixture.engine); + verify(fixture.hydrology).setNeighbourPrefetchEnabled(false); + + CompletableFuture prefetch = fixture.prefetch(); + fixture.lookup.complete(null); + prefetch.get(5L, TimeUnit.SECONDS); + verify(fixture.engine).startEntryHydrology(0, 0); + fixture.generator.endStudioEntryBootstrap(); + verify(fixture.hydrology, never()).setNeighbourPrefetchEnabled(true); + + fixture.generator.completeInitialEntry(); + fixture.generator.completeInitialEntry(); + verify(fixture.hydrology, times(1)).setNeighbourPrefetchEnabled(true); + } + + @Test + public void studioCompletionBeforePublicationDoesNotSuspendNewRuntime() throws Exception { + Fixture fixture = new Fixture(); + setField(fixture.generator, "studio", true); + fixture.generator.setEngine(null); + fixture.generator.beginInitialEntry(true); + fixture.generator.completeInitialEntry(); + fixture.generator.publishInitializedEngine(fixture.engine); + + verifyNoInteractions(fixture.hydrology); + } + + @Test + public void studioEntryDeferralAppliesToReplacementEngineUntilEntryCompletes() throws Exception { + Fixture fixture = new Fixture(); + setField(fixture.generator, "studio", true); + fixture.generator.setEngine(null); + fixture.generator.beginInitialEntry(false); + fixture.generator.publishInitializedEngine(fixture.engine); + IrisEngine replacement = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + when(replacement.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(hydrology); + + fixture.generator.publishInitializedEngine(replacement); + verify(fixture.hydrology).setNeighbourPrefetchEnabled(false); + verify(hydrology).setNeighbourPrefetchEnabled(false); + fixture.generator.completeInitialEntry(); + verify(hydrology).setNeighbourPrefetchEnabled(true); + verify(fixture.hydrology, never()).setNeighbourPrefetchEnabled(true); + } + + @Test + public void studioWatcherAndExplicitHotloadWaitUntilInitialEntryCompletes() throws Exception { + Fixture fixture = new Fixture(); + setField(fixture.generator, "studio", true); + fixture.generator.setEngine(null); + fixture.generator.beginInitialEntry(false); + fixture.generator.publishInitializedEngine(fixture.engine); + Method watcher = BukkitChunkGenerator.class.getDeclaredMethod("hotloadFromWatcher"); + watcher.setAccessible(true); + + watcher.invoke(fixture.generator); + fixture.generator.hotload(); + verify(fixture.engine, never()).hotload(); + + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(fixture.generator).withExclusiveControl(any(Runnable.class)); + fixture.generator.completeInitialEntry(); + fixture.generator.hotload(); + verify(fixture.engine).hotload(); + } + @Test public void creationDefersNeighboursBeforePublishingAndKeepsEntryDemandTracked() throws Exception { Fixture fixture = new Fixture(); diff --git a/core/src/test/java/art/arcane/iris/platform/protocol/IrisProtocolServerTest.java b/core/src/test/java/art/arcane/iris/platform/protocol/IrisProtocolServerTest.java index bdbc7faec..7683ba5b7 100644 --- a/core/src/test/java/art/arcane/iris/platform/protocol/IrisProtocolServerTest.java +++ b/core/src/test/java/art/arcane/iris/platform/protocol/IrisProtocolServerTest.java @@ -40,6 +40,39 @@ public class IrisProtocolServerTest { private static final String BRAND = "TestBrand"; private static final long SERVER_CAPABILITIES = IrisProtocol.CAPABILITY_PREGEN; + @Test + public void clientCannotPublishWorldOrPackStateThroughServerMessages() { + RecordingTransport transport = new RecordingTransport(); + IrisSessionRegistry registry = new IrisSessionRegistry(); + long capabilities = IrisProtocol.CAPABILITY_PREGEN | IrisProtocol.CAPABILITY_VISION + | IrisProtocol.CAPABILITY_CURSOR | IrisProtocol.CAPABILITY_STUDIO; + IrisProtocolServer server = new IrisProtocolServer(registry, capabilities, BRAND, true); + IrisSession session = new IrisSession("s1", transport); + registry.register(session); + server.onClientFrame("s1", IrisMessageCodec.encode(new IrisMessage.ClientHello(IrisProtocol.PROTOCOL_VERSION, capabilities))); + server.setEngineResolver(sessionId -> { + throw new AssertionError("client-supplied server state must not reach a world engine"); + }); + List messages = List.of( + new IrisMessage.ServerHello(IrisProtocol.PROTOCOL_VERSION, 0L, "other", false), + new IrisMessage.DimensionStatus("other", "other-pack", 2L, 0, 256, true), + new IrisMessage.StudioHotload("other-pack", 10, false, "replace pack"), + new IrisMessage.PregenProgress(1L, 2L, 3L, 1D, 1000L, IrisMessage.PregenProgress.STATE_RUNNING), + new IrisMessage.PregenEnd(1L, true), + new IrisMessage.Toast(IrisMessage.Toast.KIND_SUCCESS, "title", "body")); + + for (IrisMessage message : messages) { + server.onClientFrame("s1", IrisMessageCodec.encode(message)); + } + + assertEquals(IrisSession.State.READY, session.state()); + assertEquals(capabilities, session.capabilities()); + assertEquals(1, transport.sent.size()); + assertEquals(0L, server.studioHotloadsBroadcastCount()); + assertEquals(0L, server.pregenRegionDeltasBroadcastCount()); + assertEquals(0L, server.toastsBroadcastCount()); + } + @Test public void frameBeforeHelloIsDroppedAndCounted() { RecordingTransport transport = new RecordingTransport(); diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisAxisRotationClampPurityTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisAxisRotationClampPurityTest.java index 6d89d9eaa..d1af8b251 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisAxisRotationClampPurityTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisAxisRotationClampPurityTest.java @@ -9,6 +9,16 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class IrisAxisRotationClampPurityTest { + @Test + public void fixedRotationsPreserveSignedFractionalAngles() { + for (double degrees : new double[]{-359.75, -90, -45.5, 0, 12.25, 90, 359.75}) { + IrisObjectRotation rotation = IrisObjectRotation.of(degrees, degrees, degrees); + assertEquals(Math.toRadians(degrees), rotation.getXRotation(217), 0D); + assertEquals(Math.toRadians(degrees), rotation.getYRotation(217), 0D); + assertEquals(Math.toRadians(degrees), rotation.getZRotation(217), 0D); + } + } + private static IrisAxisRotationClamp unlimited(double interval) { IrisAxisRotationClamp clamp = new IrisAxisRotationClamp(); clamp.setEnabled(true); @@ -40,6 +50,38 @@ public class IrisAxisRotationClampPurityTest { } } + @Test + public void boundedZeroIntervalProducesTheRequestedFiniteAngle() { + IrisAxisRotationClamp clamp = unlimited(0).setMin(25).setMax(75); + + assertEquals(Math.toRadians(37), clamp.getRadians(37), 0D); + assertEquals(0D, clamp.getInterval(), 0D); + } + + @Test + public void boundedPositiveFractionalIntervalPreservesItsAuthoredRotationStep() { + IrisAxisRotationClamp clamp = unlimited(0.3).setMin(25).setMax(75); + + assertEquals(Math.toRadians(37.2), clamp.getRadians(37), 1E-12); + assertEquals(0.3D, clamp.getInterval(), 0D); + } + + @Test + public void boundedSubdegreeIntervalsStayFiniteWithoutMutatingAuthoredSettings() { + for (double interval : new double[]{0, 0.3, 0.5, 1}) { + IrisAxisRotationClamp clamp = unlimited(interval).setMin(25).setMax(75); + IrisAxisRotationClamp authored = unlimited(interval).setMin(25).setMax(75); + for (int rng : new int[]{Integer.MIN_VALUE, -13, 0, 37, 359, Integer.MAX_VALUE}) { + double radians = clamp.getRadians(rng); + assertTrue("bounded rotation must stay finite", Double.isFinite(radians)); + assertTrue(radians >= Math.toRadians(25)); + assertTrue(radians <= Math.toRadians(75)); + } + assertEquals(authored, clamp); + assertEquals(authored.hashCode(), clamp.hashCode()); + } + } + @Test public void equalsAndHashCodeSurviveRepeatedGetRadians() { IrisAxisRotationClamp a = unlimited(0); diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoReadValidationTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoReadValidationTest.java new file mode 100644 index 000000000..5cc35d25e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoReadValidationTest.java @@ -0,0 +1,293 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.geometry.AxisAlignedBB; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.iris.testsupport.KeyedBlockState; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.volmlib.util.collection.KMap; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.Arrays; + +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.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +public class IrisObjectIoReadValidationTest { + private static final IrisBlockVector RETAINED_POSITION = new IrisBlockVector(3, 2, -1); + private static final TypeToken> TILE_PROPERTIES = new TypeToken<>() { }; + + @Rule + public final PlatformBinding platform = PlatformBinding.mockPlatform(); + @Rule + public final TemporaryFolder temporary = new TemporaryFolder(); + + private TileData.TileReader previousReader; + + @Before + public void bindStatesAndTiles() { + when(platform.registries().blockOrNull(anyString(), eq(false))) + .thenAnswer(invocation -> new KeyedBlockState(invocation.getArgument(0))); + Gson gson = new Gson(); + previousReader = TileData.bindPlatformReader(input -> new TileData(input.readUTF(), + gson.fromJson(input.readUTF(), TILE_PROPERTIES))); + } + + @After + public void restoreTileReader() { + TileData.restorePlatformReader(previousReader); + } + + @Test + public void negativeCountsFailWithoutReplacingPriorObjectContents() throws IOException { + for (boolean legacy : new boolean[]{false, true}) { + assertRejected(legacy, emptyFixture(legacy, new Counts(0, -1, 0))); + assertRejected(legacy, emptyFixture(legacy, new Counts(0, 0, -1))); + } + assertRejected(false, emptyFixture(false, new Counts(-1, 0, 0))); + } + + @Test + public void invalidDimensionsFailWithoutReplacingPriorObjectContents() throws IOException { + for (boolean legacy : new boolean[]{false, true}) { + for (int offset : new int[]{0, 4, 8}) { + for (int dimension : new int[]{0, -1}) { + byte[] fixture = emptyFixture(legacy, new Counts(0, 0, 0)); + ByteBuffer.wrap(fixture).putInt(offset, dimension); + assertRejected(legacy, fixture); + } + } + } + } + + @Test + public void everyTruncatedModernReadRetainsPriorContents() throws IOException { + byte[] complete = fixture(false, true); + for (int length = 0; length < complete.length; length++) { + assertRejected(false, Arrays.copyOf(complete, length)); + } + } + + @Test + public void truncatedLegacyTileDataFailsInsteadOfReturningPartialObject() throws IOException { + byte[] complete = fixture(true, true); + byte[] withoutTiles = fixture(true, false); + for (int length = withoutTiles.length + 1; length < complete.length; length++) { + assertRejected(true, Arrays.copyOf(complete, length)); + } + } + + @Test + public void legacyOptionalTilesUseEofRatherThanAvailableAndDoNotCloseCallerStream() throws IOException { + for (boolean tiles : new boolean[]{false, true}) { + CallerStream input = new CallerStream(fixture(true, tiles)); + IrisObject object = retainedObject(); + + IrisObjectIO.readLegacy(object, input); + + assertEquals(2, object.getBlocks().size()); + assertEquals(tiles ? 1 : 0, object.getStates().size()); + assertFalse(input.closed); + if (tiles) { + assertEquals("kept", object.getStates().get(new IrisBlockVector(-1, 0, 0)) + .getProperties().get("payload")); + } + } + } + + @Test + public void fileReadsSupportHeaderlessLegacyObjectsWithAndWithoutTiles() throws IOException { + for (boolean tiles : new boolean[]{false, true}) { + File file = temporary.newFile("legacy-" + tiles + ".iob"); + Files.write(file.toPath(), fixture(true, tiles)); + IrisObject object = retainedObject(); + + IrisObjectIO.read(object, file); + + assertEquals(3, object.getW()); + assertEquals(2, object.getBlocks().size()); + assertEquals(tiles ? 1 : 0, object.getStates().size()); + } + } + + @Test + public void malformedModernFileReportsPaletteFailureWithoutLegacyRetry() throws IOException { + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(encoded)) { + writeDimensions(output); + output.writeUTF("Iris V2 IOB;"); + output.writeShort(0); + output.writeInt(1); + writePosition(output, 0); + output.writeShort(0); + output.writeInt(0); + } + File file = temporary.newFile("invalid-modern.iob"); + Files.write(file.toPath(), encoded.toByteArray()); + IrisObject object = retainedObject(); + + try { + IrisObjectIO.read(object, file); + fail("expected invalid palette reference"); + } catch (IOException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("palette index")); + } + + assertRetained(object); + } + + @Test + public void successfulReadReplacesPriorContentsAndInvalidatesDerivedBounds() throws IOException { + IrisObject object = retainedObject(); + AxisAlignedBB previousBounds = object.getAABB(); + CallerStream input = new CallerStream(fixture(false, true)); + + IrisObjectIO.read(object, input); + + assertEquals(3, object.getW()); + assertEquals(3, object.getH()); + assertEquals(3, object.getD()); + assertEquals(2, object.getBlocks().size()); + assertEquals(1, object.getStates().size()); + assertFalse(object.getBlocks().containsKey(RETAINED_POSITION)); + assertFalse(object.isSmartBored()); + assertNotSame(previousBounds, object.getAABB()); + assertFalse(input.closed); + } + + private static void assertRejected(boolean legacy, byte[] fixture) throws IOException { + IrisObject object = retainedObject(); + AxisAlignedBB bounds = object.getAABB(); + try { + if (legacy) { + IrisObjectIO.readLegacy(object, new ByteArrayInputStream(fixture)); + } else { + IrisObjectIO.read(object, new ByteArrayInputStream(fixture)); + } + fail("expected malformed object to fail"); + } catch (IOException expected) { + } + assertRetained(object); + assertSame(bounds, object.getAABB()); + } + + private static IrisObject retainedObject() { + IrisObject object = new IrisObject(7, 9, 11); + object.getBlocks().put(RETAINED_POSITION, new KeyedBlockState("minecraft:chest")); + KMap properties = new KMap<>(); + properties.put("payload", "previous"); + object.getStates().put(RETAINED_POSITION, new TileData("minecraft:chest", properties)); + object.setSmartBored(true); + return object; + } + + private static void assertRetained(IrisObject object) { + assertEquals(7, object.getW()); + assertEquals(9, object.getH()); + assertEquals(11, object.getD()); + assertEquals(1, object.getBlocks().size()); + assertEquals("minecraft:chest", object.getBlocks().get(RETAINED_POSITION).key()); + assertEquals(1, object.getStates().size()); + assertEquals("previous", object.getStates().get(RETAINED_POSITION).getProperties().get("payload")); + assertTrue(object.isSmartBored()); + } + + private static byte[] emptyFixture(boolean legacy, Counts counts) throws IOException { + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(encoded)) { + writeDimensions(output); + if (!legacy) { + output.writeUTF("Iris V2 IOB;"); + output.writeShort(counts.palette()); + } + output.writeInt(counts.blocks()); + output.writeInt(counts.tiles()); + } + return encoded.toByteArray(); + } + + private static byte[] fixture(boolean legacy, boolean tiles) throws IOException { + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(encoded)) { + writeDimensions(output); + if (!legacy) { + output.writeUTF("Iris V2 IOB;"); + output.writeShort(2); + output.writeUTF("minecraft:chest"); + output.writeUTF("minecraft:stone"); + } + output.writeInt(2); + for (int index = 0; index < 2; index++) { + writePosition(output, index - 1); + if (legacy) { + output.writeUTF(index == 0 ? "minecraft:chest" : "minecraft:stone"); + } else { + output.writeShort(index); + } + } + if (!legacy || tiles) { + output.writeInt(tiles ? 1 : 0); + } + if (tiles) { + writePosition(output, -1); + output.writeUTF("minecraft:chest"); + output.writeUTF("{\"payload\":\"kept\"}"); + } + } + return encoded.toByteArray(); + } + + private static void writeDimensions(DataOutputStream output) throws IOException { + output.writeInt(3); + output.writeInt(3); + output.writeInt(3); + } + + private static void writePosition(DataOutputStream output, int x) throws IOException { + output.writeShort(x); + output.writeShort(0); + output.writeShort(0); + } + + private record Counts(int palette, int blocks, int tiles) { + } + + private static final class CallerStream extends ByteArrayInputStream { + private boolean closed; + + private CallerStream(byte[] bytes) { + super(bytes); + } + + @Override + public int available() { + return 0; + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoWriteLimitsTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoWriteLimitsTest.java index 5c37bdca9..c9555f2b9 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoWriteLimitsTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectIoWriteLimitsTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.structure.object; import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.platform.bukkit.plugin.VolmitSender; +import art.arcane.iris.world.task.J; import art.arcane.iris.testsupport.KeyedBlockState; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; @@ -10,18 +12,35 @@ import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; +import java.io.DataOutputStream; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.stream.Stream; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.junit.Assume.assumeTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.doThrow; public class IrisObjectIoWriteLimitsTest { private static IrisObject oversizedPaletteObject; @@ -145,4 +164,219 @@ public class IrisObjectIoWriteLimitsTest { assertArrayEquals("a rejected write must not truncate the previous object", before, Files.readAllBytes(file.toPath())); } + @Test + public void invalidDimensionsLeaveExistingFileAndTemporaryDirectoryUntouched() throws IOException { + File file = folder.newFile("existing.iob"); + IrisObjectIO.write(objectWithDistinctStates(3), file); + byte[] before = Files.readAllBytes(file.toPath()); + for (int axis = 0; axis < 3; axis++) { + for (int dimension : new int[]{0, -1}) { + IrisObject invalid = new IrisObject(axis == 0 ? dimension : 1, + axis == 1 ? dimension : 1, axis == 2 ? dimension : 1); + try { + IrisObjectIO.write(invalid, file); + fail("expected invalid object dimensions to fail"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("dimensions")); + } + assertArrayEquals(before, Files.readAllBytes(file.toPath())); + assertOnlyDestinationExists(file); + } + } + } + + @Test + public void serializationFailurePreservesExistingFileAndRemovesTemporaryFile() throws IOException { + File file = folder.newFile("existing.iob"); + IrisObjectIO.write(objectWithDistinctStates(3), file); + byte[] before = Files.readAllBytes(file.toPath()); + + try { + IrisObjectIO.write(objectWithOversizedTilePayload(), file); + fail("expected oversized tile payload serialization to fail"); + } catch (IOException expected) { + } + + assertArrayEquals(before, Files.readAllBytes(file.toPath())); + assertOnlyDestinationExists(file); + } + + @Test + public void progressSerializationFailurePreservesExistingFileAndRemovesTemporaryFile() throws IOException { + File file = folder.newFile("existing.iob"); + IrisObjectIO.write(objectWithDistinctStates(3), file); + byte[] before = Files.readAllBytes(file.toPath()); + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.afut(any(Runnable.class))).thenAnswer(invocation -> { + Runnable work = invocation.getArgument(0); + work.run(); + return CompletableFuture.completedFuture(null); + }); + try { + IrisObjectIO.write(objectWithOversizedTilePayload(), file, mock(VolmitSender.class)); + fail("expected oversized tile payload serialization to fail"); + } catch (IOException expected) { + } + } + + assertArrayEquals(before, Files.readAllBytes(file.toPath())); + assertOnlyDestinationExists(file); + } + + @Test + public void progressUncheckedSerializationFailurePreservesExistingFile() throws IOException { + File file = folder.newFile("existing.iob"); + IrisObjectIO.write(objectWithDistinctStates(3), file); + byte[] before = Files.readAllBytes(file.toPath()); + IrisObject object = new IrisObject(1, 1, 1); + TileData tile = mock(TileData.class); + doThrow(new IllegalStateException("Tile serialization failed")).when(tile).toBinary(any(DataOutputStream.class)); + object.states.put(new IrisBlockVector(0, 0, 0), tile); + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.afut(any(Runnable.class))).thenAnswer(invocation -> { + Runnable work = invocation.getArgument(0); + work.run(); + return CompletableFuture.completedFuture(null); + }); + try { + IrisObjectIO.write(object, file, mock(VolmitSender.class)); + fail("expected unchecked tile serialization failure"); + } catch (IllegalStateException expected) { + assertEquals("Tile serialization failed", expected.getMessage()); + } + } + + assertArrayEquals(before, Files.readAllBytes(file.toPath())); + assertOnlyDestinationExists(file); + } + + @Test(timeout = 5_000L) + public void rejectedProgressSchedulingPreservesExistingFileWithoutWaitingForWork() throws IOException { + File file = folder.newFile("existing.iob"); + IrisObjectIO.write(objectWithDistinctStates(3), file); + byte[] before = Files.readAllBytes(file.toPath()); + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.afut(any(Runnable.class))).thenReturn( + CompletableFuture.failedFuture(new RejectedExecutionException("Rejected object save"))); + try { + IrisObjectIO.write(objectWithDistinctStates(1), file, mock(VolmitSender.class)); + fail("expected rejected object-save scheduling"); + } catch (RejectedExecutionException expected) { + assertEquals("Rejected object save", expected.getMessage()); + } + } + + assertArrayEquals(before, Files.readAllBytes(file.toPath())); + assertOnlyDestinationExists(file); + } + + @Test + public void fileReplacementPreservesStreamEncodingAndSymbolicLink() throws IOException { + File file = folder.newFile("existing.iob"); + Path link = folder.getRoot().toPath().resolve("linked.iob"); + Files.createSymbolicLink(link, file.toPath()); + IrisObject replacement = objectWithDistinctStates(3); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + IrisObjectIO.write(replacement, encoded); + + IrisObjectIO.write(replacement, link.toFile()); + + assertTrue(Files.isSymbolicLink(link)); + assertArrayEquals(encoded.toByteArray(), Files.readAllBytes(file.toPath())); + try (Stream entries = Files.list(folder.getRoot().toPath())) { + assertEquals(2L, entries.count()); + } + } + + @Test + public void replacementPreservesExistingPosixPermissions() throws IOException { + assumeTrue(Files.getFileStore(folder.getRoot().toPath()).supportsFileAttributeView(PosixFileAttributeView.class)); + File file = folder.newFile("existing.iob"); + Set permissions = PosixFilePermissions.fromString("rw-r-----"); + Files.setPosixFilePermissions(file.toPath(), permissions); + + IrisObjectIO.write(objectWithDistinctStates(3), file); + + assertEquals(permissions, Files.getPosixFilePermissions(file.toPath())); + assertOnlyDestinationExists(file); + } + + @Test + public void newFileUsesNormalPosixCreationPermissions() throws IOException { + assumeTrue(Files.getFileStore(folder.getRoot().toPath()).supportsFileAttributeView(PosixFileAttributeView.class)); + Path reference = folder.getRoot().toPath().resolve("reference.iob"); + try (FileOutputStream output = new FileOutputStream(reference.toFile())) { + output.write(0); + } + Path destination = folder.getRoot().toPath().resolve("new.iob"); + + IrisObjectIO.write(objectWithDistinctStates(3), destination.toFile()); + + assertEquals(Files.getPosixFilePermissions(reference), Files.getPosixFilePermissions(destination)); + try (Stream entries = Files.list(folder.getRoot().toPath())) { + assertEquals(2L, entries.count()); + } + } + + @Test + public void danglingSymbolicLinkChainCreatesItsTargetWithoutReplacingLinks() throws IOException { + Path root = folder.getRoot().toPath(); + Path targetDirectory = folder.newFolder("objects").toPath(); + Path target = targetDirectory.resolve("created.iob"); + Path second = root.resolve("second.iob"); + Path first = root.resolve("first.iob"); + Files.createSymbolicLink(second, root.relativize(target)); + Files.createSymbolicLink(first, second.getFileName()); + IrisObject replacement = objectWithDistinctStates(3); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + IrisObjectIO.write(replacement, encoded); + + IrisObjectIO.write(replacement, first.toFile()); + + assertTrue(Files.isSymbolicLink(first)); + assertTrue(Files.isSymbolicLink(second)); + assertArrayEquals(encoded.toByteArray(), Files.readAllBytes(target)); + try (Stream entries = Files.list(targetDirectory)) { + assertEquals(List.of(target), entries.toList()); + } + } + + @Test(timeout = 5_000L) + public void symbolicLinkCycleFailsWithoutCreatingTemporaryFiles() throws IOException { + Path root = folder.getRoot().toPath(); + Path first = root.resolve("first.iob"); + Path second = root.resolve("second.iob"); + Files.createSymbolicLink(first, second.getFileName()); + Files.createSymbolicLink(second, first.getFileName()); + + try { + IrisObjectIO.write(objectWithDistinctStates(3), first.toFile()); + fail("expected symbolic-link cycle to fail"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("symbolic-link cycle")); + } + + assertTrue(Files.isSymbolicLink(first)); + assertTrue(Files.isSymbolicLink(second)); + try (Stream entries = Files.list(root)) { + assertEquals(2L, entries.count()); + } + } + + private static IrisObject objectWithOversizedTilePayload() { + IrisObject object = new IrisObject(1, 1, 1); + IrisBlockVector position = new IrisBlockVector(0, 0, 0); + object.blocks.put(position, state("minecraft:chest")); + KMap properties = new KMap<>(); + properties.put("text", "x".repeat(65_536)); + object.states.put(position, new TileData("minecraft:chest", properties)); + return object; + } + + private void assertOnlyDestinationExists(File file) throws IOException { + try (Stream entries = Files.list(folder.getRoot().toPath())) { + assertEquals(List.of(file.toPath()), entries.toList()); + } + } + } diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java index fba5fe5c3..ceffcfbb0 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java @@ -668,6 +668,53 @@ public class IrisObjectPlacementRunnerRegressionTest { } } + @Test + public void dryObjectBlocksDoNotReadDestinationWaterlogging() { + RecordingPlacer placer = new RecordingPlacer(null); + IrisObject object = lineObject(8); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.STRUCTURE_PIECE); + List observed = new ArrayList<>(); + TileData tile = new TileData("minecraft:stone", new KMap<>()); + object.setUnsignedTile(0, 0, 0, tile); + + object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), + (position, state) -> observed.add(state), null, data); + + assertEquals(0, placer.blockReads); + assertEquals(8, placer.writes().size()); + assertEquals(Collections.nCopies(8, solid), observed); + assertTrue(placer.data.containsValue(tile)); + } + + @Test + public void waterloggedBlocksPreserveAuthoredAndEnabledSubmersionRules() { + NativeBlockState wet = state("minecraft:oak_slab[type=bottom,waterlogged=true]", true); + NativeBlockState dry = state("minecraft:oak_slab[type=bottom,waterlogged=false]", true); + NativeBlockState water = state("minecraft:water", false); + when(water.isWater()).thenReturn(true); + when(wet.withProperty("waterlogged", "false")).thenReturn(dry); + when(dry.withProperty("waterlogged", "true")).thenReturn(wet); + for (boolean authored : new boolean[]{false, true}) { + for (boolean enabled : new boolean[]{false, true}) { + for (boolean submerged : new boolean[]{false, true}) { + RecordingPlacer placer = new RecordingPlacer(null); + placer.world.put("0:" + ANCHOR_Y + ":0", submerged ? water : solid); + IrisObject object = new IrisObject(1, 1, 1); + object.setUnsigned(0, 0, 0, authored ? wet : dry); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.STRUCTURE_PIECE) + .setWaterloggable(enabled); + + object.place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + assertEquals(1, placer.blockReads); + assertEquals(1, placer.writes().size()); + assertSame(submerged && (enabled || authored) ? wet : dry, + placer.writes().get(0).state()); + } + } + } + } + private IrisObjectPlacement placement() { IrisObjectPlacement placement = new IrisObjectPlacement(); placement.setMode(ObjectPlaceMode.CENTER_HEIGHT); @@ -718,6 +765,7 @@ public class IrisObjectPlacementRunnerRegressionTest { private final Map terrain = new HashMap<>(); private final Engine engine; private int failAfterWrites = Integer.MAX_VALUE; + private int blockReads; private boolean debugSmartBore; private boolean highestFollowsWrites; @@ -778,6 +826,7 @@ public class IrisObjectPlacementRunnerRegressionTest { @Override public NativeBlockState get(int x, int y, int z) { + blockReads++; return world.get(x + ":" + y + ":" + z); } diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectTransformsTileTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectTransformsTileTest.java index 8265738f4..49557da01 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectTransformsTileTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectTransformsTileTest.java @@ -84,6 +84,38 @@ public class IrisObjectTransformsTileTest { assertEquals(2, source.getStates().size()); } + @Test + public void fractionalRotationKeepsTileDataOnlyForTheWinningBlock() { + IrisObject source = new IrisObject(9, 1, 9); + for (int x = 0; x < 9; x++) { + for (int z = 0; z < 9; z++) { + boolean chest = (x + z) % 2 == 0; + source.setUnsigned(x, 0, z, state(chest ? "minecraft:chest" : "minecraft:stone")); + if (chest) { + source.setUnsignedTile(x, 0, z, tile("minecraft:chest", x + ":" + z)); + } + } + } + IrisObjectRotation.StateRotator previous = IrisObjectRotation.bindPlatformRotator( + (rotation, block, x, y, z) -> block); + IrisObject rotated; + try { + rotated = source.rotateCopy(IrisObjectRotation.of(0, 45, 0)); + } finally { + IrisObjectRotation.restorePlatformRotator(previous); + } + + assertTrue(rotated.getBlocks().size() < source.getBlocks().size()); + for (Map.Entry entry : rotated.getStates()) { + assertEquals("minecraft:chest", rotated.getBlocks().get(entry.getKey()).materialKey()); + } + for (Map.Entry entry : rotated.getBlocks()) { + if (entry.getValue().materialKey().equals("minecraft:chest")) { + assertNotNull(rotated.getStates().get(entry.getKey())); + } + } + } + @Test public void scalingWaitsForMatchingGeometryAndVolume() throws Exception { assertConsistentScaling(false); diff --git a/core/src/test/java/art/arcane/iris/structure/placement/StructureCaveAnchorColumnScanTest.java b/core/src/test/java/art/arcane/iris/structure/placement/StructureCaveAnchorColumnScanTest.java new file mode 100644 index 000000000..5514bdbee --- /dev/null +++ b/core/src/test/java/art/arcane/iris/structure/placement/StructureCaveAnchorColumnScanTest.java @@ -0,0 +1,166 @@ +package art.arcane.iris.structure.placement; + +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveStorage; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.mantle.EngineMantle; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +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; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +public class StructureCaveAnchorColumnScanTest { + private static final int HEIGHT = 384; + private static final int WORLD_MIN = -64; + + @Rule + public final PlatformBinding platform = PlatformBinding.mockPlatform(); + + @Test + public void columnMatchesIndependentRunReferenceAcrossModesAndScanSteps() { + MatterCavern[] baseline = new MatterCavern[HEIGHT]; + HydrologyCaveCell[] hydrology = new HydrologyCaveCell[HEIGHT]; + RNG rng = new RNG(583764L); + for (int y = 0; y < HEIGHT; y++) { + if (rng.nextInt(6) != 0 || y >= 40 && y <= 125 || y >= 180 && y <= 280) { + baseline[y] = new MatterCavern(true, "", (byte) (y % 31 == 0 ? 1 : 0)); + } + } + hydrology[55] = HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE); + hydrology[56] = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD); + hydrology[200] = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR); + Engine engine = fixture(baseline); + try (MockedStatic storage = mockStatic(HydrologyCaveStorage.class)) { + storage.when(() -> HydrologyCaveStorage.getIfPresent(any(Mantle.class), anyInt(), anyInt(), anyInt())) + .thenAnswer(invocation -> hydrology[(int) invocation.getArgument(2)]); + for (IrisStructureAnchorMode mode : IrisStructureAnchorMode.values()) { + if (!mode.isCave()) { + continue; + } + for (int step : new int[]{1, 2, 3, 7, 16}) { + for (int clearance : new int[]{1, 2, 3, 16, 64}) { + for (boolean underwater : new boolean[]{false, true}) { + for (int minimum : new int[]{1, 71, 129}) { + IrisStructurePlacement placement = new IrisStructurePlacement().setUnderwater(underwater); + List actual = StructureCaveAnchorResolver.anchorsInColumn( + engine, placement, mode, -47, -103, minimum + WORLD_MIN, + 382 + WORLD_MIN, step, clearance); + assertEquals(mode + " step=" + step + " clearance=" + clearance + + " underwater=" + underwater + " minimum=" + minimum, + reference(baseline, hydrology, mode, minimum, 382, step, clearance, underwater), actual); + storage.clearInvocations(); + } + } + } + } + } + } + } + + @SuppressWarnings("unchecked") + private static Engine fixture(MatterCavern[] baseline) { + Engine engine = mock(Engine.class, withSettings().stubOnly()); + EngineMantle engineMantle = mock(EngineMantle.class, withSettings().stubOnly()); + Mantle mantle = mock(Mantle.class, withSettings().stubOnly()); + IrisDimension dimension = mock(IrisDimension.class, withSettings().stubOnly()); + when(engine.getHeight()).thenReturn(HEIGHT); + when(engine.getMinHeight()).thenReturn(WORLD_MIN); + when(engine.getMantle()).thenReturn(engineMantle); + when(engineMantle.getMantle()).thenReturn(mantle); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getCaveLavaHeight()).thenReturn(8); + when(mantle.get(anyInt(), anyInt(), anyInt(), eq(MatterCavern.class))) + .thenAnswer(invocation -> { + int y = invocation.getArgument(1); + return y < 0 || y >= HEIGHT ? null : baseline[y]; + }); + return engine; + } + + private static List reference( + MatterCavern[] baseline, HydrologyCaveCell[] hydrology, IrisStructureAnchorMode mode, + int minimum, int maximum, int step, int clearance, boolean underwater + ) { + boolean[] carved = new boolean[HEIGHT]; + for (int y = 0; y < HEIGHT; y++) { + MatterCavern cavern = hydrology[y] == null ? baseline[y] : hydrology[y].asCavern(); + carved[y] = cavern != null && cavern.isCavern(); + } + List anchors = new ArrayList<>(); + if (mode == IrisStructureAnchorMode.CAVE_CENTER) { + for (int lower = 0; lower < HEIGHT; lower++) { + if (!carved[lower]) { + continue; + } + int upper = lower; + while (upper + 1 < HEIGHT && carved[upper + 1]) { + upper++; + } + int firstSample = minimum + Math.ceilDiv(Math.max(lower, minimum) - minimum, step) * step; + if (upper - lower + 1 >= clearance && firstSample <= Math.min(upper, maximum)) { + int lowCenter = (lower + upper) / 2; + int highCenter = (lower + upper + 1) / 2; + addReferenceAnchor(anchors, baseline, hydrology, lowCenter, minimum, maximum, underwater); + if (highCenter != lowCenter) { + addReferenceAnchor(anchors, baseline, hydrology, highCenter, minimum, maximum, underwater); + } + } + lower = upper; + } + return anchors; + } + for (int y = minimum; y <= maximum; y += step) { + int lower = switch (mode) { + case CAVE_FLOOR -> y; + case CAVE_CEILING -> y - clearance + 1; + case CAVE_ANY -> y - (clearance - 1) / 2; + default -> throw new AssertionError(mode); + }; + boolean valid = carved[y] && lower >= 0 && lower + clearance <= HEIGHT; + if (mode == IrisStructureAnchorMode.CAVE_FLOOR) { + valid &= y == 0 || !carved[y - 1]; + } else if (mode == IrisStructureAnchorMode.CAVE_CEILING) { + valid &= y + 1 == HEIGHT || !carved[y + 1]; + } + for (int offset = 0; valid && offset < clearance; offset++) { + valid = carved[lower + offset]; + } + if (valid) { + addReferenceAnchor(anchors, baseline, hydrology, y, minimum, maximum, underwater); + } + } + return anchors; + } + + private static void addReferenceAnchor( + List anchors, MatterCavern[] baseline, HydrologyCaveCell[] hydrology, + int y, int minimum, int maximum, boolean underwater + ) { + if (y < minimum || y > maximum || hydrology[y] != null) { + return; + } + MatterCavern cavern = baseline[y]; + if (cavern != null && cavern.isCavern() + && (underwater || cavern.getLiquid() == 3 || cavern.isAir() && y > 8)) { + anchors.add(y + WORLD_MIN); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/structure/placement/StructureCaveAnchorResolverTest.java b/core/src/test/java/art/arcane/iris/structure/placement/StructureCaveAnchorResolverTest.java index d522b1897..675819788 100644 --- a/core/src/test/java/art/arcane/iris/structure/placement/StructureCaveAnchorResolverTest.java +++ b/core/src/test/java/art/arcane/iris/structure/placement/StructureCaveAnchorResolverTest.java @@ -5,16 +5,20 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.generation.hydrology.cave.HydrologyCaveStorage; import art.arcane.iris.testsupport.PlatformBinding; import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.matter.MatterCavern; import org.junit.Rule; import org.junit.Test; +import org.mockito.MockedStatic; import java.util.Arrays; import java.util.HashSet; import java.util.Set; import java.util.function.IntPredicate; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -22,10 +26,13 @@ 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.any; 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.mockStatic; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -145,6 +152,54 @@ public class StructureCaveAnchorResolverTest { verify(engine, never()).getHeight(anyInt(), anyInt(), eq(true)); } + @Test + public void anchorFluidReadsOverlayOnlyOnceAfterGeometry() { + Engine engine = engineWithFloorAnchor(new MatterCavern(true, "", (byte) 0), 0); + AtomicInteger overlayReads = new AtomicInteger(); + try (MockedStatic storage = mockStatic(HydrologyCaveStorage.class)) { + storage.when(() -> HydrologyCaveStorage.getIfPresent(any(Mantle.class), anyInt(), anyInt(), anyInt())) + .thenAnswer(invocation -> { + overlayReads.incrementAndGet(); + return null; + }); + RNG actualRandom = new RNG(87234L); + RNG referenceRandom = new RNG(87234L); + int referenceColumn = referenceRandom.nextInt(256); + referenceRandom.nextInt(128); + referenceRandom.nextInt(1); + StructureCaveAnchorResolver.Anchor anchor = StructureCaveAnchorResolver.resolve( + engine, floorPlacement(false), -3, -7, actualRandom); + + assertEquals(new StructureCaveAnchorResolver.Anchor( + (-3 << 4) + (referenceColumn & 15), -54, + (-7 << 4) + (referenceColumn >>> 4)), anchor); + assertEquals(referenceRandom.nextLong(), actualRandom.nextLong()); + assertEquals(6, overlayReads.get()); + verify(engine.getMantle().getMantle(), times(6)).get( + anyInt(), anyInt(), anyInt(), eq(MatterCavern.class)); + } + } + + @Test + public void protectedHydrologyRejectsBeforeReadingAnchorCavernAgain() { + Engine engine = engineWithFloorAnchor(new MatterCavern(true, "", (byte) 0), 0); + HydrologyCaveCell protectedCell = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR); + AtomicInteger overlayReads = new AtomicInteger(); + try (MockedStatic storage = mockStatic(HydrologyCaveStorage.class)) { + storage.when(() -> HydrologyCaveStorage.getIfPresent(any(Mantle.class), anyInt(), anyInt(), anyInt())) + .thenAnswer(invocation -> { + overlayReads.incrementAndGet(); + int mantleY = invocation.getArgument(2); + return mantleY == 10 ? protectedCell : null; + }); + assertNull(StructureCaveAnchorResolver.resolve( + engine, floorPlacement(true), -3, -7, new RNG(87234L))); + assertEquals(6, overlayReads.get()); + verify(engine.getMantle().getMantle(), times(5)).get( + anyInt(), anyInt(), anyInt(), eq(MatterCavern.class)); + } + } + @Test public void dryPolicyHonorsExplicitLiquidsAndDefaultLavaHeight() { MatterCavern air = new MatterCavern(true, "", (byte) 0); diff --git a/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java b/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java index d505f077b..c9a20cc7b 100644 --- a/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java +++ b/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.studio; +import art.arcane.iris.pack.PackFingerprints; + import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.AtomicDirectoryPublisher; @@ -288,7 +290,7 @@ public class StudioSVCWorldPackPublishTest { PackValidationResult validation = new PackValidationResult( "validated-generation-source", List.of(), List.of("retained warning"), 31L); PackValidationRegistry.publish(source, validation, - ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); + PackFingerprints.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); try (MockedStatic validator = mockStatic(PackValidator.class)) { @@ -298,13 +300,75 @@ public class StudioSVCWorldPackPublishTest { assertSame(validation, PackValidationRegistry.getMatching(source, fingerprint)); } + @Test + public void newlyValidatedGenerationCandidateReusesSemanticValidationForExactCopy() throws Exception { + Path source = temporaryFolder.newFolder("fresh-validation-source").toPath(); + Path target = temporaryFolder.getRoot().toPath().resolve("fresh-validation-target"); + writeValidPack(source); + String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); + PackValidationResult sourceValidation = new PackValidationResult( + "fresh-validation-source", List.of(), List.of("source warning"), 43L); + PackValidationResult duplicateValidation = new PackValidationResult( + "fresh-validation-target", List.of(), List.of("duplicate warning"), 47L); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(source.toFile())).thenReturn(sourceValidation); + validator.when(() -> PackValidator.validate(target.toFile())).thenReturn(duplicateValidation); + assertSame(sourceValidation, StudioSVC.validateGenerationCandidate(source, fingerprint)); + StudioSVC.copyPackTree(source, target); + String copiedFingerprint = PackFingerprints.computePackTreeFingerprint(target.toFile()); + + assertSame(sourceValidation, StudioSVC.validatePublishedPack(target, source, copiedFingerprint)); + validator.verify(() -> PackValidator.validate(source.toFile())); + validator.verifyNoMoreInteractions(); + } + } + + @Test + public void sourceChangedDuringSemanticValidationCannotPublishReusableResult() throws Exception { + Path source = temporaryFolder.newFolder("changing-validation-source").toPath(); + writeValidPack(source); + String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); + PackValidationResult validation = new PackValidationResult( + "changing-validation-source", List.of(), List.of(), 53L); + try (MockedStatic validator = mockStatic(PackValidator.class)) { + validator.when(() -> PackValidator.validate(source.toFile())).thenAnswer(invocation -> { + Files.writeString(source.resolve("dimensions/main.json"), "{\"name\":\"changed\"}"); + return validation; + }); + + assertThrows(IllegalStateException.class, + () -> StudioSVC.validateGenerationCandidate(source, fingerprint)); + assertNull(PackValidationRegistry.get(source)); + assertNull(PackValidationRegistry.getMatching(source, + PackFingerprints.computePackTreeFingerprint(source.toFile()))); + } + } + + @Test + public void registryContextChangedDuringSemanticValidationCannotPublishReusableResult() throws Exception { + Path source = temporaryFolder.newFolder("changing-context-source").toPath(); + writeValidPack(source); + String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); + PackValidationResult validation = new PackValidationResult( + "changing-context-source", List.of(), List.of(), 59L); + try (MockedStatic validator = mockStatic(PackValidator.class); + MockedStatic context = mockStatic(PackValidationCache.class)) { + validator.when(() -> PackValidator.validate(source.toFile())).thenReturn(validation); + context.when(PackValidationCache::contextFingerprint).thenReturn("before", "after"); + + assertThrows(IllegalStateException.class, + () -> StudioSVC.validateGenerationCandidate(source, fingerprint)); + assertNull(PackValidationRegistry.get(source)); + } + } + @Test public void editedGenerationCandidateCannotReuseStartupValidation() throws Exception { Path source = temporaryFolder.newFolder("edited-generation-source").toPath(); writeValidPack(source); PackValidationRegistry.publish(source, new PackValidationResult( "edited-generation-source", List.of(), List.of(), 37L), - ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); + PackFingerprints.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); Files.writeString(source.resolve("dimensions/main.json"), "{"); String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); @@ -320,7 +384,7 @@ public class StudioSVCWorldPackPublishTest { PackValidationResult validation = new PackValidationResult( "rejected-generation-source", List.of("registry content unavailable"), List.of(), 41L); PackValidationRegistry.publish(source, validation, - ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); + PackFingerprints.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); try (MockedStatic validator = mockStatic(PackValidator.class)) { @@ -338,8 +402,8 @@ public class StudioSVCWorldPackPublishTest { Path target = root.resolve("target"); writeValidPack(source); StudioSVC.copyPackTree(source, target); - String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(source.toFile()); - String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile()); + String sourceFingerprint = PackFingerprints.computePackTreeFingerprint(source.toFile()); + String copiedFingerprint = PackFingerprints.computePackTreeFingerprint(target.toFile()); PackValidationResult sourceValidation = new PackValidationResult( "source", List.of(), List.of("preserved source warning"), 17L); PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint, PackValidationCache.contextFingerprint()); @@ -363,7 +427,7 @@ public class StudioSVCWorldPackPublishTest { PackValidationResult sourceValidation = new PackValidationResult( "snapshot-source", List.of(), List.of("source warning"), 23L); PackValidationRegistry.publish(source, sourceValidation, - ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); + PackFingerprints.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); IrisData installed = IrisData.openDatapackCompiler(target.toFile()); try (MockedStatic data = mockStatic(IrisData.class, CALLS_REAL_METHODS)) { @@ -383,7 +447,7 @@ public class StudioSVCWorldPackPublishTest { writeValidPack(source); StudioSVC.copyPackTree(source, target); PackValidationRegistry.publish(source, new PackValidationResult("snapshot-source", List.of(), List.of(), 29L), - ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); + PackFingerprints.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); Files.writeString(target.resolve("dimensions/main.json"), "{"); assertThrows(BrokenPackException.class, () -> StudioSVC.loadInstalledDimension(target, source, "main")); @@ -397,12 +461,12 @@ public class StudioSVCWorldPackPublishTest { Path target = root.resolve("target"); writeValidPack(source); StudioSVC.copyPackTree(source, target); - String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(source.toFile()); + String sourceFingerprint = PackFingerprints.computePackTreeFingerprint(source.toFile()); PackValidationResult sourceValidation = new PackValidationResult( "source", List.of(), List.of(), 19L); PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint, PackValidationCache.contextFingerprint()); Files.writeString(target.resolve("dimensions/main.json"), "{"); - String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile()); + String copiedFingerprint = PackFingerprints.computePackTreeFingerprint(target.toFile()); assertFalse(sourceFingerprint.equals(copiedFingerprint)); assertThrows(BrokenPackException.class, () -> StudioSVC.validatePublishedPack( @@ -422,7 +486,7 @@ public class StudioSVCWorldPackPublishTest { writeValidPack(sourcePack); writeValidPack(target); StudioSVC.copyPackTree(sourcePack, stage); - String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(sourcePack.toFile()); + String sourceFingerprint = PackFingerprints.computePackTreeFingerprint(sourcePack.toFile()); PackValidationResult sourceValidation = new PackValidationResult( "source", List.of(), List.of(), 23L); PackValidationResult staleTargetValidation = new PackValidationResult( @@ -438,7 +502,7 @@ public class StudioSVCWorldPackPublishTest { assertThrows(BrokenPackException.class, () -> PackValidationRegistry.requireLoadable(target)); publication = AtomicDirectoryPublisher.publish(stage, target); assertNull(PackValidationRegistry.get(target)); - String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile()); + String copiedFingerprint = PackFingerprints.computePackTreeFingerprint(target.toFile()); assertNull(PackValidationRegistry.get(target)); assertThrows(BrokenPackException.class, () -> PackValidationRegistry.requireLoadable(target)); diff --git a/core/src/test/java/art/arcane/iris/studio/object/StudioCloseRecoveryTest.java b/core/src/test/java/art/arcane/iris/studio/object/StudioCloseRecoveryTest.java index 5b01757d9..2ae87266e 100644 --- a/core/src/test/java/art/arcane/iris/studio/object/StudioCloseRecoveryTest.java +++ b/core/src/test/java/art/arcane/iris/studio/object/StudioCloseRecoveryTest.java @@ -17,6 +17,8 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -42,6 +44,9 @@ public class StudioCloseRecoveryTest { StudioOpenCoordinator.StudioCloseResult result = close(provider, null).join(); assertSame(failure, result.failureCause()); + logging.verify(() -> IrisLogging.reportError( + contains("unloadCompletedLive=true, folderDeletionCompletedLive=true, startupCleanupQueued=false"), + same(failure))); configurator.verifyNoInteractions(); verify(lease, never()).close(); } diff --git a/core/src/test/java/art/arcane/iris/world/IrisCreatorInitialSpawnTest.java b/core/src/test/java/art/arcane/iris/world/IrisCreatorInitialSpawnTest.java index ac1c977c0..a5392581d 100644 --- a/core/src/test/java/art/arcane/iris/world/IrisCreatorInitialSpawnTest.java +++ b/core/src/test/java/art/arcane/iris/world/IrisCreatorInitialSpawnTest.java @@ -14,8 +14,28 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; public class IrisCreatorInitialSpawnTest { + @Test + public void successfulStudioCreationLeavesEntryPreparationWithTheCoordinator() { + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + + IrisCreator.completeCreationEntry(generator, true); + + verifyNoInteractions(generator); + } + + @Test + public void failedStudioCreationReleasesEntryPreparation() { + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + + IrisCreator.completeCreationEntry(generator, false); + + verify(generator).completeInitialEntry(); + } + @Test public void worldCreationWaitsForInitialSpawnCompletion() throws Exception { PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); diff --git a/core/src/test/java/art/arcane/iris/world/IrisCreatorRollbackTest.java b/core/src/test/java/art/arcane/iris/world/IrisCreatorRollbackTest.java new file mode 100644 index 000000000..d93b2b615 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/IrisCreatorRollbackTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.world; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.platform.generation.PlatformChunkGenerator; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.world.runtime.WorldDeletionQueue; +import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +public class IrisCreatorRollbackTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + @Test + public void failedStagedGeneratorClosePreservesWorldFilesForStartupDeletion() throws Exception { + Path storage = temporary.newFolder("failed-world").toPath(); + Path region = storage.resolve("region.mca"); + byte[] bytes = new byte[]{4, 9, 2}; + Files.write(region, bytes); + IOException closeFailure = new IOException("generator close failed"); + IllegalStateException creationFailure = new IllegalStateException("creation failed"); + WorldDeletionQueue queue = mock(WorldDeletionQueue.class); + + rollback(storage, CompletableFuture.failedFuture(closeFailure), creationFailure, queue); + + assertArrayEquals(bytes, Files.readAllBytes(region)); + assertSame(closeFailure, creationFailure.getSuppressed()[0]); + verify(queue).queueExactForStartupDeletion(List.of("rollback_test")); + } + + @Test + public void successfulStagedGeneratorCloseStillDeletesPartialWorldImmediately() throws Exception { + Path storage = temporary.newFolder("closed-world").toPath(); + Files.write(storage.resolve("region.mca"), new byte[]{4, 9, 2}); + WorldDeletionQueue queue = mock(WorldDeletionQueue.class); + + rollback(storage, CompletableFuture.completedFuture(null), new IllegalStateException("creation failed"), queue); + + assertFalse(Files.exists(storage)); + verifyNoInteractions(queue); + } + + private static void rollback(Path storage, CompletableFuture close, Throwable failure, + WorldDeletionQueue queue) throws Exception { + NamespacedKey key = new NamespacedKey("iris", "rollback_test"); + PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class); + IrisWorlds worlds = mock(IrisWorlds.class); + doReturn(close).when(generator).closeAsync(); + try (MockedStatic settings = mockStatic(IrisSettings.class); + MockedStatic identity = mockStatic(WorldIdentity.class); + MockedStatic registry = mockStatic(IrisWorlds.class); + MockedStatic services = mockStatic(IrisServices.class)) { + settings.when(IrisSettings::get).thenReturn(new IrisSettings()); + identity.when(() -> WorldIdentity.resolve(key)).thenReturn(Optional.empty()); + registry.when(IrisWorlds::get).thenReturn(worlds); + services.when(() -> IrisServices.get(WorldDeletionQueue.class)).thenReturn(queue); + IrisCreator creator = new IrisCreator().name(key.getKey()); + Method method = IrisCreator.class.getDeclaredMethod("rollbackWorldCreation", NamespacedKey.class, + World.class, PlatformChunkGenerator.class, File.class, boolean.class, Throwable.class); + method.setAccessible(true); + method.invoke(creator, key, null, generator, storage.toFile(), false, failure); + } + verify(generator).closeAsync(); + verify(worlds).remove(key.toString()); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/IrisCreatorTeleportTest.java b/core/src/test/java/art/arcane/iris/world/IrisCreatorTeleportTest.java index f32bd3944..c04ef0b7d 100644 --- a/core/src/test/java/art/arcane/iris/world/IrisCreatorTeleportTest.java +++ b/core/src/test/java/art/arcane/iris/world/IrisCreatorTeleportTest.java @@ -129,6 +129,48 @@ public class IrisCreatorTeleportTest { verifyNoMoreInteractions(runtimeControl); } + @Test + public void cancellingEntryCancelsAnAlreadyStartedRuntimeTeleport() { + Player player = mock(Player.class); + World world = mock(World.class); + WorldRuntimeControlService runtimeControl = mock(WorldRuntimeControlService.class); + Location anchor = new Location(world, 0.5D, 80D, 0.5D); + CompletableFuture teleport = new CompletableFuture<>(); + doReturn(anchor).when(runtimeControl).resolveEntryAnchor(world); + doReturn(CompletableFuture.completedFuture(mock(Chunk.class))) + .when(runtimeControl).requestChunkAsync(world, 0, 0, true); + doReturn(CompletableFuture.completedFuture(anchor)).when(runtimeControl).resolveSafeEntry(world, anchor); + doReturn(teleport).when(runtimeControl).teleport(player, anchor); + + CompletableFuture result = IrisCreator.teleportSenderToCreatedWorld(player, world, runtimeControl); + assertTrue(result.cancel(false)); + + assertTrue(teleport.isCancelled()); + } + + @Test + public void cancellingEntryBeforeSafeResolutionPreventsTeleport() { + Player player = mock(Player.class); + World world = mock(World.class); + WorldRuntimeControlService runtimeControl = mock(WorldRuntimeControlService.class); + Location anchor = new Location(world, 0.5D, 80D, 0.5D); + CompletableFuture safeEntry = new CompletableFuture<>(); + doReturn(anchor).when(runtimeControl).resolveEntryAnchor(world); + doReturn(CompletableFuture.completedFuture(mock(Chunk.class))) + .when(runtimeControl).requestChunkAsync(world, 0, 0, true); + doReturn(safeEntry).when(runtimeControl).resolveSafeEntry(world, anchor); + + CompletableFuture result = IrisCreator.teleportSenderToCreatedWorld(player, world, runtimeControl); + assertTrue(result.cancel(false)); + safeEntry.complete(anchor); + + InOrder order = inOrder(runtimeControl); + order.verify(runtimeControl).resolveEntryAnchor(world); + order.verify(runtimeControl).requestChunkAsync(world, 0, 0, true); + order.verify(runtimeControl).resolveSafeEntry(world, anchor); + verifyNoMoreInteractions(runtimeControl); + } + @Test public void awaitTeleportFailure_returnsNullForSuccessfulTeleport() { CompletableFuture teleport = CompletableFuture.completedFuture(true); diff --git a/core/src/test/java/art/arcane/iris/world/IrisToolbeltEvacuationTest.java b/core/src/test/java/art/arcane/iris/world/IrisToolbeltEvacuationTest.java index cc0927b61..feb024e66 100644 --- a/core/src/test/java/art/arcane/iris/world/IrisToolbeltEvacuationTest.java +++ b/core/src/test/java/art/arcane/iris/world/IrisToolbeltEvacuationTest.java @@ -1,12 +1,27 @@ package art.arcane.iris.world; +import art.arcane.iris.platform.bukkit.nms.INMS; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Player; import org.junit.Test; +import org.mockito.MockedStatic; import java.util.List; import java.util.concurrent.CompletableFuture; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verifyNoInteractions; public class IrisToolbeltEvacuationTest { @Test @@ -39,4 +54,45 @@ public class IrisToolbeltEvacuationTest { assertFalse(evacuation.join()); } + @Test + public void rejectedEntitySchedulingDoesNotRunPlayerWorkOnCallerThread() { + assertSchedulerFailureSettles(false); + } + + @Test + public void retiredEntitySchedulingSettlesWithoutWaitingForTeleport() { + assertSchedulerFailureSettles(true); + } + + private static void assertSchedulerFailureSettles(boolean retired) { + World source = mock(World.class); + World target = mock(World.class); + Player player = mock(Player.class); + doReturn(List.of(player)).when(source).getPlayers(); + doReturn(new Location(target, 0.5D, 80D, 0.5D)).when(target).getSpawnLocation(); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + MockedStatic identity = mockStatic(WorldIdentity.class); + MockedStatic nms = mockStatic(INMS.class); + MockedStatic scheduler = mockStatic(J.class)) { + bukkit.when(Bukkit::getWorlds).thenReturn(List.of(source, target)); + identity.when(() -> WorldIdentity.key(source)).thenReturn(new NamespacedKey("iris", "evacuate_source")); + identity.when(() -> WorldIdentity.key(target)).thenReturn(NamespacedKey.minecraft("overworld")); + scheduler.when(() -> J.runEntity(eq(player), any(Runnable.class), eq(0), any(Runnable.class))) + .thenAnswer(invocation -> { + if (retired) { + Runnable retirement = invocation.getArgument(3); + retirement.run(); + } + return retired; + }); + + CompletableFuture result = IrisToolbelt.evacuateAsync(source); + + assertTrue(result.isDone()); + assertFalse(result.join()); + scheduler.verify(() -> J.runEntity(eq(player), any(Runnable.class), eq(0), any(Runnable.class))); + verifyNoInteractions(player); + } + } + } diff --git a/core/src/test/java/art/arcane/iris/world/lifecycle/WorldUnloadBoundaryRegistryTest.java b/core/src/test/java/art/arcane/iris/world/lifecycle/WorldUnloadBoundaryRegistryTest.java index cd6301a72..811fd2fba 100644 --- a/core/src/test/java/art/arcane/iris/world/lifecycle/WorldUnloadBoundaryRegistryTest.java +++ b/core/src/test/java/art/arcane/iris/world/lifecycle/WorldUnloadBoundaryRegistryTest.java @@ -64,4 +64,20 @@ public class WorldUnloadBoundaryRegistryTest { WorldUnloadBoundaryRegistry.complete(boundary, false, null); } } + @Test + public void claimedBoundaryStillExcludesAnotherUnloadUntilRawCompletion() { + String worldIdentity = "iris:boundary_claimed_duplicate"; + WorldUnloadBoundaryRegistry.Boundary boundary = WorldUnloadBoundaryRegistry.begin(worldIdentity); + try { + CompletionStage claimed = WorldUnloadBoundaryRegistry.claim(worldIdentity); + assertFalse(claimed.toCompletableFuture().isDone()); + assertNull(WorldUnloadBoundaryRegistry.claim(worldIdentity)); + assertThrows(IllegalStateException.class, () -> WorldUnloadBoundaryRegistry.begin(worldIdentity)); + } finally { + WorldUnloadBoundaryRegistry.complete(boundary, false, null); + } + WorldUnloadBoundaryRegistry.Boundary next = WorldUnloadBoundaryRegistry.begin(worldIdentity); + WorldUnloadBoundaryRegistry.complete(next, true, null); + } + } diff --git a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenServerSaveOwnershipTest.java b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenServerSaveOwnershipTest.java index 45117342b..7e5b32cb4 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenServerSaveOwnershipTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/AsyncPregenServerSaveOwnershipTest.java @@ -16,6 +16,7 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Queue; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -26,7 +27,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -56,11 +56,59 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public class AsyncPregenServerSaveOwnershipTest { + @Test + public void adjacentRegionEvictionsShareQueuedFlushButActiveArrivalNeedsAnother() throws Exception { + Fixture fixture = new Fixture(); + for (int regionX = 1; regionX <= 2; regionX++) { + Chunk chunk = mock(Chunk.class); + when(chunk.getX()).thenReturn(regionX << 5); + when(chunk.getZ()).thenReturn(3); + when(fixture.binding.saveAndUnloadChunk(fixture.world, regionX << 5, 3)).thenReturn(true); + Queue chunks = new ConcurrentLinkedQueue<>(); + chunks.add(chunk); + fixture.chunks.put((long) regionX << 32, chunks); + } + Queue scheduled = new ArrayDeque<>(); + fixture.set("chunkIoFlush", new PregenChunkFlush(scheduled::add, + () -> fixture.binding.flushChunkIO(fixture.world))); + AtomicInteger calls = new AtomicInteger(); + AtomicReference> third = new AtomicReference<>(); + AtomicReference> first = new AtomicReference<>(); + AtomicReference> second = new AtomicReference<>(); + doAnswer(invocation -> { + if (calls.incrementAndGet() == 1) { + third.set((CompletableFuture) invoke(fixture.method, "evictRegion", 2L << 32)); + assertFalse(third.get().isDone()); + } else { + assertTrue(first.get().isDone()); + assertTrue(second.get().isDone()); + assertFalse(third.get().isDone()); + } + return null; + }).when(fixture.binding).flushChunkIO(fixture.world); + try (SchedulingContext context = new SchedulingContext(fixture)) { + first.set((CompletableFuture) invoke(fixture.method, "evictRegion", 0L)); + second.set((CompletableFuture) invoke(fixture.method, "evictRegion", 1L << 32)); + assertEquals(1, scheduled.size()); + assertFalse(first.get().isDone()); + assertFalse(second.get().isDone()); + scheduled.remove().run(); + first.get().join(); + second.get().join(); + third.get().join(); + verify(fixture.binding, times(2)).flushChunkIO(fixture.world); + verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3); + verify(fixture.binding).saveAndUnloadChunk(fixture.world, 32, 3); + verify(fixture.binding).saveAndUnloadChunk(fixture.world, 64, 3); + assertTrue(scheduled.isEmpty()); + } + } + @Test public void ownerReturnsAfterUnloadWhileEvictionTracksTheOffOwnerIoDrain() throws Exception { Fixture fixture = new Fixture(); ExecutorService io = Executors.newSingleThreadExecutor(); - fixture.set("chunkIoExecutor", io); + fixture.set("chunkIoFlush", new PregenChunkFlush(io, () -> fixture.binding.flushChunkIO(fixture.world))); Thread ownerThread = Thread.currentThread(); CountDownLatch draining = new CountDownLatch(1); CountDownLatch finishDrain = new CountDownLatch(1); @@ -95,7 +143,7 @@ public class AsyncPregenServerSaveOwnershipTest { Fixture fixture = new Fixture(); fixture.set("foliaRuntime", true); ExecutorService io = Executors.newSingleThreadExecutor(); - fixture.set("chunkIoExecutor", io); + fixture.set("chunkIoFlush", new PregenChunkFlush(io, () -> fixture.binding.flushChunkIO(fixture.world))); Chunk second = mock(Chunk.class); when(second.getX()).thenReturn(17); when(second.getZ()).thenReturn(3); @@ -318,7 +366,7 @@ public class AsyncPregenServerSaveOwnershipTest { public void nativeFlushRunsOnThePregenFlushWorkerNotTheSharedIoPool() throws Exception { Fixture fixture = new Fixture(); PregenSerialWorker flush = new PregenSerialWorker("Iris Pregen Chunk Flush", "world"); - fixture.set("chunkIoExecutor", flush.executor()); + fixture.set("chunkIoFlush", new PregenChunkFlush(flush.executor(), () -> fixture.binding.flushChunkIO(fixture.world))); AtomicReference ioThread = new AtomicReference<>(); doAnswer(invocation -> { ioThread.set(Thread.currentThread()); @@ -517,7 +565,7 @@ public class AsyncPregenServerSaveOwnershipTest { set("regionChunks", chunks); set("regionPending", pendingRegions); set("pendingEvictions", evictions); - set("chunkIoExecutor", (Executor) Runnable::run); + set("chunkIoFlush", new PregenChunkFlush(Runnable::run, () -> binding.flushChunkIO(world))); set("generationFailure", new AtomicReference<>()); set("inFlightRequests", new PregenInFlightRequests()); when(chunk.getX()).thenReturn(2); diff --git a/core/src/test/java/art/arcane/iris/world/pregen/PregenChunkFlushTest.java b/core/src/test/java/art/arcane/iris/world/pregen/PregenChunkFlushTest.java new file mode 100644 index 000000000..7e9816048 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/pregen/PregenChunkFlushTest.java @@ -0,0 +1,164 @@ +package art.arcane.iris.world.pregen; + +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; + +public class PregenChunkFlushTest { + @Test + public void queuedRequestsShareOneFlushAndCancellationIsIndependent() { + Queue tasks = new ArrayDeque<>(); + AtomicInteger calls = new AtomicInteger(); + PregenChunkFlush flush = new PregenChunkFlush(tasks::add, calls::incrementAndGet); + CompletableFuture first = flush.request(); + CompletableFuture second = flush.request(); + List> burst = new ArrayList<>(); + for (int index = 0; index < 1024; index++) { + burst.add(flush.request()); + } + CompletableFuture cancelled = flush.request(); + cancelled.cancel(false); + assertEquals(1, tasks.size()); + assertFalse(first.isDone()); + assertFalse(second.isDone()); + tasks.remove().run(); + first.join(); + second.join(); + for (CompletableFuture request : burst) { + request.join(); + } + assertEquals(1, calls.get()); + flush.request(); + assertEquals(1, tasks.size()); + } + + @Test + public void requestDuringFlushRequiresAnotherFlushAndFailuresDoNotDropNextBatch() { + Queue tasks = new ArrayDeque<>(); + AtomicInteger calls = new AtomicInteger(); + AtomicReference owner = new AtomicReference<>(); + AtomicReference> next = new AtomicReference<>(); + PregenChunkFlush flush = new PregenChunkFlush(tasks::add, () -> { + int call = calls.incrementAndGet(); + if (call == 1) { + next.set(owner.get().request()); + assertFalse(next.get().isDone()); + throw new IllegalStateException("flush failed"); + } + assertFalse(next.get().isDone()); + }); + owner.set(flush); + CompletableFuture first = flush.request(); + CompletableFuture peer = flush.request(); + tasks.remove().run(); + assertThrows(CompletionException.class, first::join); + assertThrows(CompletionException.class, peer::join); + next.get().join(); + assertEquals(2, calls.get()); + assertTrue(tasks.isEmpty()); + } + + @Test + public void completedBatchDoesNotWaitForRequestAdmittedDuringItsFlush() { + Queue tasks = new ArrayDeque<>(); + AtomicInteger calls = new AtomicInteger(); + AtomicReference owner = new AtomicReference<>(); + AtomicReference> first = new AtomicReference<>(); + AtomicReference> second = new AtomicReference<>(); + PregenChunkFlush flush = new PregenChunkFlush(tasks::add, () -> { + if (calls.incrementAndGet() == 1) { + second.set(owner.get().request()); + assertFalse(first.get().isDone()); + assertFalse(second.get().isDone()); + } else { + assertTrue(first.get().isDone()); + assertFalse(second.get().isDone()); + } + }); + owner.set(flush); + first.set(flush.request()); + tasks.remove().run(); + first.get().join(); + second.get().join(); + assertEquals(2, calls.get()); + } + + @Test + public void schedulingFailuresCompleteRequestsAndAllowRetry() { + for (Throwable failure : new Throwable[]{new RejectedExecutionException("closed"), + new IllegalStateException("thread creation failed"), new OutOfMemoryError("thread unavailable")}) { + Queue tasks = new ArrayDeque<>(); + AtomicInteger submissions = new AtomicInteger(); + Executor executor = task -> { + if (submissions.getAndIncrement() == 0) { + if (failure instanceof Error error) { + throw error; + } + throw (RuntimeException) failure; + } + tasks.add(task); + }; + AtomicInteger calls = new AtomicInteger(); + PregenChunkFlush flush = new PregenChunkFlush(executor, calls::incrementAndGet); + CompletableFuture rejected = flush.request(); + CompletionException completed = assertThrows(CompletionException.class, rejected::join); + assertEquals(failure, completed.getCause()); + CompletableFuture retry = flush.request(); + tasks.remove().run(); + retry.join(); + assertEquals(1, calls.get()); + } + } + + @Test + public void runningFlushDoesNotBlockRequestAndWorkerCloseDrainsBothBatches() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch finish = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + PregenSerialWorker worker = new PregenSerialWorker("flush test", "synthetic", + (message, failure) -> { throw new AssertionError(message, failure); }, + message -> { throw new AssertionError(message); }); + try { + PregenChunkFlush flush = new PregenChunkFlush(worker.executor(), () -> { + if (calls.incrementAndGet() == 1) { + started.countDown(); + try { + assertTrue(finish.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } + }); + CompletableFuture first = flush.request(); + assertTrue(started.await(5, TimeUnit.SECONDS)); + CompletableFuture second = flush.request(); + assertFalse(first.isDone()); + assertFalse(second.isDone()); + finish.countDown(); + assertTrue(worker.close(5, TimeUnit.SECONDS)); + first.join(); + second.join(); + assertEquals(2, calls.get()); + } finally { + finish.countDown(); + worker.close(5, TimeUnit.SECONDS); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/world/runtime/WorldRuntimeControlServiceSafeEntryTest.java b/core/src/test/java/art/arcane/iris/world/runtime/WorldRuntimeControlServiceSafeEntryTest.java index 9777d854d..6a38e66a0 100644 --- a/core/src/test/java/art/arcane/iris/world/runtime/WorldRuntimeControlServiceSafeEntryTest.java +++ b/core/src/test/java/art/arcane/iris/world/runtime/WorldRuntimeControlServiceSafeEntryTest.java @@ -2,6 +2,8 @@ package art.arcane.iris.world.runtime; import art.arcane.iris.platform.generation.BukkitChunkGenerator; import art.arcane.iris.platform.generation.PlatformChunkGenerator; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.world.task.J; import org.bukkit.HeightMap; import org.bukkit.Location; import org.bukkit.Material; @@ -11,24 +13,56 @@ import org.bukkit.block.data.Waterlogged; import org.bukkit.util.BoundingBox; import org.bukkit.util.VoxelShape; import org.junit.Test; +import org.mockito.MockedStatic; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.CompletableFuture; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; public class WorldRuntimeControlServiceSafeEntryTest { private static final BoundingBox FULL_BLOCK = new BoundingBox(0D, 0D, 0D, 1D, 1D, 1D); + @Test + public void scheduledSafeEntryKeepsItsOriginalRegionAndCoordinates() { + World world = loadedWorld(0, 0); + Block stone = block(Material.STONE, false, false, FULL_BLOCK); + Block air = block(Material.AIR, false, true); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> (int) invocation.getArgument(1) == 62 ? stone : air) + .when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + AtomicReference task = new AtomicReference<>(); + try (MockedStatic scheduling = mockStatic(J.class); + MockedStatic platform = mockStatic(BukkitPlatform.class)) { + scheduling.when(() -> J.runRegion(eq(world), eq(0), eq(0), any(Runnable.class))) + .thenAnswer(invocation -> { + task.set(invocation.getArgument(3)); + return true; + }); + WorldRuntimeControlService service = mock(WorldRuntimeControlService.class, CALLS_REAL_METHODS); + Location source = new Location(world, 0.5D, 63D, 0.5D); + CompletableFuture result = service.resolveSafeEntry(world, source); + source.setX(32.5D); + task.get().run(); + assertNotNull(result.join()); + assertEquals(0.5D, result.join().getX(), 0D); + } + } + @Test public void resolvesStudioEntryAnchorFromGeneratorInsteadOfMutableWorldSpawn() { World world = mock(World.class); diff --git a/core/src/test/java/art/arcane/iris/world/runtime/WorldRuntimeControlServiceTeleportTest.java b/core/src/test/java/art/arcane/iris/world/runtime/WorldRuntimeControlServiceTeleportTest.java index 4dd410c76..4df8d1c3b 100644 --- a/core/src/test/java/art/arcane/iris/world/runtime/WorldRuntimeControlServiceTeleportTest.java +++ b/core/src/test/java/art/arcane/iris/world/runtime/WorldRuntimeControlServiceTeleportTest.java @@ -11,12 +11,15 @@ import org.mockito.MockedStatic; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -25,6 +28,50 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class WorldRuntimeControlServiceTeleportTest { + @Test + public void scheduledTeleportKeepsTheRequestedDestination() { + Player player = mock(Player.class); + Location destination = new Location(null, 12.5D, 70D, -30.5D); + Location expected = destination.clone(); + AtomicReference task = new AtomicReference<>(); + AtomicReference received = new AtomicReference<>(); + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.runEntity(any(Player.class), any(Runnable.class), eq(0), any(Runnable.class))).thenAnswer(invocation -> { + task.set(invocation.getArgument(1)); + return true; + }); + CompletableFuture result = WorldRuntimeControlService.scheduleTeleport(player, destination, + null, (target, location) -> { + received.set(location); + return CompletableFuture.completedFuture(false); + }); + destination.setX(900D); + destination.setY(-64D); + task.get().run(); + assertFalse(result.join()); + assertEquals(expected, received.get()); + } + } + + @Test + public void retiredPlayerCompletesTeleportWithoutApplyingGameMode() { + Player player = mock(Player.class); + AtomicReference retired = new AtomicReference<>(); + try (MockedStatic scheduling = mockStatic(J.class)) { + scheduling.when(() -> J.runEntity(any(Player.class), any(Runnable.class), eq(0), any(Runnable.class))) + .thenAnswer(invocation -> { + retired.set(invocation.getArgument(3)); + return true; + }); + CompletableFuture result = WorldRuntimeControlService.scheduleTeleport(player, + new Location(null, 1D, 70D, 2D), GameMode.SPECTATOR, + (target, location) -> { throw new AssertionError("Retired player was teleported"); }); + retired.get().run(); + assertFalse(result.join()); + verify(player, never()).setGameMode(any()); + } + } + @Test public void failedModeTeleportRestoresThePreviousGameMode() { Player player = mock(Player.class); @@ -116,6 +163,10 @@ public class WorldRuntimeControlServiceTeleportTest { private static MockedStatic immediateEntityScheduling() { MockedStatic scheduling = mockStatic(J.class); + scheduling.when(() -> J.runEntity(any(Player.class), any(Runnable.class), eq(0), any(Runnable.class))).thenAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return true; + }); scheduling.when(() -> J.runEntity(any(Player.class), any(Runnable.class))).thenAnswer(invocation -> { invocation.getArgument(1, Runnable.class).run(); return true;