diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java index bdd0fdf05..d6f734c16 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/command/CommandFind.java @@ -25,6 +25,8 @@ import art.arcane.iris.localization.IrisLanguage; import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.studio.object.ObjectStudioSaveService; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.locator.SubterrainLocator; +import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.placement.StructureReachability; @@ -89,6 +91,78 @@ public class CommandFind implements DirectorExecutor { EngineBukkitOps.gotoBiome(e, biome, player(), teleport); } + @Director(description = "Find an authored underground feature by id or geometry family", sync = true) + public void subterrain( + @Param(description = "Feature definition id or family: tectonic_fault, cenote, lava_tube, travertine_terraces") + String feature, + @Param(description = "Search radius in blocks", defaultValue = "8192") + int radius, + @Param(description = "Should you be teleported", defaultValue = "true") + boolean teleport + ) { + String key = feature.trim(); + IrisSubterrainFamily family = null; + try { + family = IrisSubterrainFamily.valueOf(key.toUpperCase(Locale.ROOT).replace('-', '_')); + } catch (IllegalArgumentException ignored) { + } + locateSubterrain(new SubterrainLocator.Query(family == null ? key : "", family, ""), radius, teleport); + } + + @Director(name = "underground-biome", description = "Find a bounded authored underground biome", sync = true) + public void undergroundBiome( + @Param(description = "The underground biome to look for", customHandler = ReachableBiomeHandler.class) + IrisBiome biome, + @Param(description = "Search radius in blocks", defaultValue = "8192") + int radius, + @Param(description = "Should you be teleported", defaultValue = "true") + boolean teleport + ) { + locateSubterrain(new SubterrainLocator.Query("", null, biome.getLoadKey()), radius, teleport); + } + + private void locateSubterrain(SubterrainLocator.Query query, int radius, boolean teleport) { + Engine activeEngine = engine(); + VolmitSender commandSender = sender(); + Player target = player(); + if (activeEngine == null || commandSender == null || target == null) { + if (commandSender != null) { + commandSender.sendMessage(C.RED + "Run this command from an Iris world."); + } + return; + } + if (radius < 0 || radius > 32768) { + commandSender.sendMessage(C.RED + "Search radius must be between 0 and 32768 blocks."); + return; + } + Location origin = target.getLocation(); + World world = origin.getWorld(); + commandSender.sendMessage(C.GRAY + "Searching authored underground plans..."); + J.a(() -> { + try { + SubterrainLocator.Result found = GenerationSemanticQueries.nearestSubterrain(activeEngine, query, + origin.getBlockX(), origin.getBlockY(), origin.getBlockZ(), radius).orElse(null); + if (found == null) { + sendStructureMessage(target, commandSender, + C.YELLOW + "No matching authored underground volume found within " + radius + " blocks."); + return; + } + String label = found.family().name().toLowerCase(Locale.ROOT) + " " + found.featureId(); + if (!teleport) { + sendStructureMessage(target, commandSender, C.GREEN + "Found " + label + " at " + + found.x() + ", " + found.y() + ", " + found.z() + "."); + return; + } + prepareStructureTeleport(target, world, commandSender, label, + new Location(world, found.x(), found.y(), found.z()), true); + } catch (Throwable error) { + sendStructureMessage(target, commandSender, + C.RED + "Could not locate authored underground volume: " + error.getMessage()); + Iris.reportError("Could not locate authored underground volume.", error); + } + }); + } + @Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region") public void region( @Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class) diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java index 7cba27969..d7266c6a2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomePolicy.java @@ -325,7 +325,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ, S sampler) { - if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { + if (isGuaranteedSurfaceBiome(engine, quartY)) { return getSurfaceStructureBiome(engine, quartX, quartZ, sampler); } boolean cacheable = isBiomeCacheable( @@ -512,7 +512,7 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { } boolean monumentQuery = isMonumentSurfaceBiomeQuery( y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); - surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight()); + surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(engine, minQuartY); } if (!surfaceQuery) { return query.fallback().get(); @@ -701,6 +701,11 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { int blockZ = quartZ << 2; int internalY = blockY - engine.getMinHeight(); int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40); + IrisBiome featureBiome = engine.getSubterrainBiome(blockX, internalY, blockZ); + if (featureBiome != null) { + return createBiomeResolution(engine, featureBiome, true, engine.getDimension(), engine.getData(), + blockX, blockY, blockZ); + } DimensionStackLayout.Layer stackLayer = includeDimensionStack ? stackLayout == null ? resolveDimensionStackLayer(engine, blockX, internalY, blockZ) @@ -846,7 +851,13 @@ final class ModdedBiomePolicy implements NativeModdedBiomePolicy { return configuredBiomeKeys; } - static boolean isGuaranteedSurfaceBiome(int quartY, int minHeight) { + static boolean isGuaranteedSurfaceBiome(Engine engine, int quartY) { + IrisDimension dimension = engine.getDimension(); + if (dimension != null && dimension.allowsSubterrainFeatures() + && !dimension.getSubterrainFeatures().isEmpty()) { + return false; + } + int minHeight = engine.getMinHeight(); int internalY = (quartY << 2) - minHeight; int caveSwitchY = Math.max(-8 - minHeight, 40); return internalY > caveSwitchY; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index 09b02654a..e1441d522 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -58,6 +58,7 @@ import art.arcane.volmlib.util.matter.MatterMarker; import art.arcane.volmlib.util.matter.slices.MarkerMatter; import java.util.Map; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.ArrayList; @@ -511,14 +512,18 @@ public final class ModdedWorldManager implements EngineWorldManager { } IrisBiome biome = environment.biome(); - Map chunkMobs = initial ? Map.of() : NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ); + Map chunkMobs = new HashMap<>(initial ? Map.of() + : NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ)); RNG rng = initial ? EntitySpawnSeed.chunk(engine.getSeedManager().getEntity(), chunkX, chunkZ) : new RNG(RNG.r.nextLong()); KList pool = new KList<>(); - collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial); - collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); - collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial, false); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial, false); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial, false); + if (environment.dimension().hasUndergroundSpawners(engine)) { + spawnUnderground(level, chunkX, chunkZ, initial, chunkMobs, rng.nextParallelRNG(0x5A17D3B48269C0EFL)); + } if (pool.isEmpty()) { return; } @@ -533,14 +538,111 @@ public final class ModdedWorldManager implements EngineWorldManager { } int spawned = spawnEntry(level, chosen.entry(), chunkX, chunkZ, remainingCapacity(chosen.entry(), spawner, chunkMobs), rng, new SpawnContext(spawner, null, initial)); + recordSpawned(chosen.entry(), chunkMobs, spawned); if (spawned > 0 && !initial) { spawner.spawn(engine, chunkX, chunkZ); } } - private void collectSpawns(KList pool, KList spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map chunkMobs, boolean initial) { + private void spawnUnderground(NativeWorld level, int chunkX, int chunkZ, boolean initial, + Map chunkMobs, RNG random) { + for (IrisSurface surface : List.of(IrisSurface.LAND, IrisSurface.WATER, IrisSurface.LAVA)) { + IrisPosition candidate = findUndergroundCandidate(level, chunkX, chunkZ, + random.nextParallelRNG(surface.ordinal()), surface); + if (candidate == null) { + continue; + } + BiomeEnvironment environment; + try { + environment = engine.getBiomeOrMantleEnvironment(candidate.getX(), + candidate.getY() - engine.getWorld().minHeight(), candidate.getZ()); + } catch (SavedBiomeUnavailableException unavailable) { + continue; + } + try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) { + KList pool = new KList<>(); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), + environment.biome(), chunkX, chunkZ, chunkMobs, initial, true); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), + null, chunkX, chunkZ, chunkMobs, initial, true); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.biome().getEntitySpawners()), + null, chunkX, chunkZ, chunkMobs, initial, true); + pool.removeIf(selection -> !matchesUndergroundSurface(selection.entry(), surface)); + SpawnCandidate selected = rarityPick(pool, random); + if (selected == null) { + continue; + } + int capacity = remainingCapacity(selected.entry(), selected.spawner(), chunkMobs); + int spawned = spawnUndergroundEntry(level, candidate, selected, capacity, random, initial); + recordSpawned(selected.entry(), chunkMobs, spawned); + if (spawned > 0 && !initial) { + selected.spawner().spawn(engine, chunkX, chunkZ); + } + return; + } + } + } + + private boolean matchesUndergroundSurface(IrisEntitySpawn entry, IrisSurface surface) { + IrisEntity entity = entry.getRealEntity(engine); + return entity != null && entity.getSurface().isFluid() == surface.isFluid() + && (!surface.isFluid() || entity.getSurface() == surface); + } + + static IrisPosition findUndergroundCandidate(NativeWorld level, int chunkX, int chunkZ, RNG random, IrisSurface surface) { + int minimumY = level.minHeight() + 1; + for (int attempt = 0; attempt < 8; attempt++) { + int x = (chunkX << 4) + random.i(1, 15); + int z = (chunkZ << 4) + random.i(1, 15); + int maximumY = Math.min(level.maxHeight() - 3, NativeSpawnQueries.surfaceHeight(level, x, z, true) - 1); + if (maximumY < minimumY) { + continue; + } + int span = maximumY - minimumY + 1; + int startY = LootResolver.inclusive(random, minimumY, maximumY); + for (int sample = 0; sample < Math.min(span, 128); sample++) { + int y = minimumY + Math.floorMod(startY - minimumY - sample, span); + NativeBlockState state = level.getBlock(x, y, z); + boolean acceptable = surface.isFluid() ? matchesSurface(surface, state) + : state != null && state.isAir() && NativeSpawnQueries.solid(level.getBlock(x, y - 1, z)) + && level.getBlock(x, y + 1, z).isAir(); + if (acceptable) { + return new IrisPosition(x, y, z); + } + } + } + return null; + } + + private int spawnUndergroundEntry(NativeWorld level, IrisPosition candidate, SpawnCandidate selection, + int capacity, RNG random, boolean initial) { + IrisEntity entity = selection.entry().getRealEntity(engine); + if (entity == null) { + return 0; + } + int x = candidate.getX(); + int y = candidate.getY(); + int z = candidate.getZ(); + int count = Math.min(Math.max(0, capacity), LootResolver.inclusive(random, + selection.entry().getMinSpawns(), selection.entry().getMaxSpawns())); + int spawned = 0; + for (int ordinal = 0; ordinal < count; ordinal++) { + if (!initial && !lightAllowed(selection.spawner(), level, x, y, z) + || !surfaceMatches(entity.getSurface(), level, x, y, z) + || !ModdedEntitySpawner.isAreaClearForSpawn(entityRuntime(level), entity, x, y, z)) { + continue; + } + if (ModdedEntitySpawner.spawn(engine, entity, entityRuntime(level), x, y, z, + EntitySpawnSeed.entity(random.getSeed(), ordinal)) != null) { + spawned++; + } + } + return spawned; + } + + private void collectSpawns(KList pool, KList spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map chunkMobs, boolean initial, boolean underground) { for (IrisSpawner spawner : spawners) { - if (spawner == null) { + if (spawner == null || (spawner.getGroup() == IrisSpawnGroup.CAVE) != underground) { continue; } if (biomeFilter != null && !spawner.isValid(biomeFilter)) { @@ -558,6 +660,13 @@ public final class ModdedWorldManager implements EngineWorldManager { } } + private void recordSpawned(IrisEntitySpawn entry, Map counts, int spawned) { + IrisEntity entity = entry.getRealEntity(engine); + if (spawned > 0 && entity != null) { + counts.merge(entity.spawnCategory(), spawned, Integer::sum); + } + } + private int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Map counts) { IrisEntity entity = entry.getRealEntity(engine); return spawner.remainingCapacity(entity, counts); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java index 6d88cb54f..5a49f9093 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java @@ -38,12 +38,18 @@ public class IrisModdedStructureParityTest { Bootstrap.bootStrap(); } + private static Engine surfaceEngine(int minHeight) { + Engine engine = mock(Engine.class); + when(engine.getMinHeight()).thenReturn(minHeight); + return engine; + } + @Test public void surfaceBiomeFastPathBeginsAboveTheCaveSwitch() { - assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(-2, -256)); - assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(-1, -256)); - assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(10, 0)); - assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(11, 0)); + assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(-256), -2)); + assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(-256), -1)); + assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(0), 10)); + assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(0), 11)); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java index 4511d8a3a..59a5d4d88 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomePolicyTest.java @@ -2,6 +2,11 @@ package art.arcane.iris.modded; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.DimensionStackLayout; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.IrisComplex; @@ -11,20 +16,54 @@ import art.arcane.volmlib.util.stream.interpolation.Interpolated; import org.junit.Test; import java.util.ArrayList; +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.List; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; public class ModdedBiomePolicyTest { + @Test + public void authoredBiomeWinsAboveSurfaceGateAndBeforeStackOwnership() throws Exception { + Engine engine = mock(Engine.class); + IrisBiome feature = mock(IrisBiome.class); + IrisDimension host = new IrisDimension().setSubterrainFeatures(new KList<>( + new IrisSubterrainFeature().setId("room"))); + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(new File("synthetic-pack")); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(host); + when(engine.getComplex()).thenReturn(mock(IrisComplex.class)); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getSubterrainBiome(12, 224, -20)).thenReturn(feature); + assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(engine, 40)); + ModdedBiomePolicy policy = boundTo(engine); + DimensionStackLayout stack = mock(DimensionStackLayout.class); + Method method = ModdedBiomePolicy.class.getDeclaredMethod("resolveBiomeResolution", Engine.class, + int.class, int.class, int.class, boolean.class, DimensionStackLayout.class); + method.setAccessible(true); + Object resolution = method.invoke(policy, engine, 3, 40, -5, true, stack); + Field biome = resolution.getClass().getDeclaredField("irisBiome"); + biome.setAccessible(true); + assertSame(feature, biome.get(resolution)); + Field dimension = resolution.getClass().getDeclaredField("dimension"); + dimension.setAccessible(true); + assertSame(host, dimension.get(resolution)); + verifyNoInteractions(stack); + } + @Test public void registryOrderAndHolderIdentitySurviveThePolicyBoundaryAndRefreshOnRepoint() { BiomeToken ocean = new BiomeToken("minecraft:ocean"); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedInitialEntitySpawnTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedInitialEntitySpawnTest.java index c5ed2e078..639e4bd92 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedInitialEntitySpawnTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedInitialEntitySpawnTest.java @@ -18,6 +18,7 @@ import art.arcane.iris.world.IrisWorld; import art.arcane.iris.world.entity.IrisEntity; import art.arcane.iris.world.entity.IrisEntitySpawn; import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.iris.world.entity.IrisSpawnGroup; import art.arcane.iris.world.entity.IrisMarker; import art.arcane.iris.world.entity.EntitySpawnSeed; import art.arcane.iris.structure.placement.LootResolver; @@ -97,6 +98,33 @@ public class ModdedInitialEntitySpawnTest { } } + @Test + public void undergroundAndSurfacePassesShareTheSuccessfulPopulationBudget() { + try (Fixture fixture = new Fixture()) { + BiomeEnvironment environment = fixture.engine.getSurfaceBiomeEnvironment(0, 0); + when(environment.dimension().hasUndergroundSpawners(fixture.engine)).thenReturn(true); + when(fixture.engine.getBiomeOrMantleEnvironment(anyInt(), anyInt(), anyInt())).thenReturn(environment); + NativeBlockState stone = mock(NativeBlockState.class); + NativeBlockState air = mock(NativeBlockState.class); + when(air.isAir()).thenReturn(true); + when(fixture.world.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + int y = invocation.getArgument(1); + return y <= -21 || y == 50 ? stone : air; + }); + fixture.queries.when(() -> NativeSpawnQueries.solid(stone)).thenReturn(true); + IrisEntity entity = new IrisEntity().setType("minecraft:cow"); + IrisEntitySpawn entry = spy(new IrisEntitySpawn().setMinSpawns(2).setMaxSpawns(2)); + doReturn(entity).when(entry).getRealEntity(fixture.engine); + fixture.definitions.add(new IrisSpawner().setGroup(IrisSpawnGroup.CAVE) + .setInitialSpawns(new KList<>(entry)).setMaxEntitiesPerChunk(3)); + assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3)); + assertEquals(3, fixture.spawned.size()); + assertEquals(2, fixture.spawned.stream().filter(spawn -> spawn.y() == -20).count()); + assertEquals(1, fixture.spawned.stream().filter(spawn -> spawn.y() == 51).count()); + fixture.queries.verify(() -> NativeSpawnQueries.livingEntityCategories(eq(fixture.world), anyInt(), anyInt()), never()); + } + } + @Test public void initialCompletionFollowsOwnedSpawnPassAndDoesNotRepeatIt() { try (Fixture fixture = new Fixture()) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedUndergroundSpawnOwnershipTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedUndergroundSpawnOwnershipTest.java new file mode 100644 index 000000000..819994c4a --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedUndergroundSpawnOwnershipTest.java @@ -0,0 +1,114 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.decoration.IrisSurface; +import art.arcane.iris.generation.runtime.BiomeEnvironment; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.pack.value.IrisPosition; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.entity.IrisEntity; +import art.arcane.iris.world.entity.IrisEntitySpawn; +import art.arcane.iris.world.entity.IrisSpawnGroup; +import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime; +import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.NativeWorld; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ModdedUndergroundSpawnOwnershipTest { + @BeforeClass + public static void bootstrap() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void candidateSamplingAndPoolSelectionUseAbsoluteUndergroundOwner() throws Exception { + NativeWorld world = mock(NativeWorld.class); + when(world.minHeight()).thenReturn(-64); + when(world.maxHeight()).thenReturn(320); + NativeBlockState stone = mock(NativeBlockState.class); + NativeBlockState air = mock(NativeBlockState.class); + when(air.isAir()).thenReturn(true); + when(world.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(call -> + (int) call.getArgument(1) <= -21 ? stone : air); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()); + IrisBiome cave = new IrisBiome().setEntitySpawners(new KList<>("residents")); + IrisDimension dimension = new IrisDimension(); + IrisRegion region = new IrisRegion(); + IrisData data = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader loader = mock(ResourceLoader.class); + when(data.getSpawnerLoader()).thenReturn(loader); + when(loader.loadAll(dimension.getEntitySpawners())).thenReturn(new KList<>()); + when(loader.loadAll(region.getEntitySpawners())).thenReturn(new KList<>()); + IrisEntitySpawn entry = mock(IrisEntitySpawn.class); + IrisEntity entity = mock(IrisEntity.class); + when(entry.getRealEntity(engine)).thenReturn(entity); + when(entry.getRarity()).thenReturn(1); + when(entry.getMinSpawns()).thenReturn(1); + when(entry.getMaxSpawns()).thenReturn(1); + when(entity.getSurface()).thenReturn(IrisSurface.LAND); + when(entity.spawnCategory()).thenReturn("creature"); + IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE).setInitialSpawns(new KList<>(entry)); + when(loader.loadAll(cave.getEntitySpawners())).thenReturn(new KList<>(spawner)); + BiomeEnvironment environment = new BiomeEnvironment(9L, cave, region, dimension, data); + when(engine.getBiomeOrMantleEnvironment(anyInt(), eq(44), anyInt())).thenReturn(environment); + BiomeEnvironment.Scope scope = mock(BiomeEnvironment.Scope.class); + when(engine.openBiomeEnvironmentScope(environment)).thenReturn(scope); + NativeEntityRuntime runtime = mock(NativeEntityRuntime.class); + when(runtime.world()).thenReturn(world); + ModdedWorldManager manager = new ModdedWorldManager(engine); + try (MockedStatic queries = mockStatic(NativeSpawnQueries.class); + MockedStatic entities = mockStatic(ModdedEntitySpawner.class)) { + queries.when(() -> NativeSpawnQueries.surfaceHeight(eq(world), anyInt(), anyInt(), eq(true))).thenReturn(64); + queries.when(() -> NativeSpawnQueries.solid(stone)).thenReturn(true); + IrisPosition first = ModdedWorldManager.findUndergroundCandidate(world, 2, -1, new RNG(88771L), IrisSurface.LAND); + IrisPosition second = ModdedWorldManager.findUndergroundCandidate(world, 2, -1, new RNG(88771L), IrisSurface.LAND); + assertNotNull(first); + assertEquals(first, second); + assertEquals(-20, first.getY()); + assertTrue(first.getX() >= 33 && first.getX() <= 47 && first.getZ() >= -15 && first.getZ() <= -1); + Field field = ModdedWorldManager.class.getDeclaredField("entityRuntime"); + field.setAccessible(true); + field.set(manager, runtime); + entities.when(() -> ModdedEntitySpawner.isAreaClearForSpawn(eq(runtime), eq(entity), anyInt(), eq(-20), anyInt())) + .thenReturn(true); + Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnUnderground", NativeWorld.class, + int.class, int.class, boolean.class, Map.class, RNG.class); + spawn.setAccessible(true); + spawn.invoke(manager, world, 2, -1, true, Map.of(), new RNG(88771L)); + entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), eq(runtime), anyInt(), eq(-20), anyInt(), any(RNG.class))); + } finally { + manager.close(); + } + verify(scope).close(); + } +} 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 1a372cd96..255f75846 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 @@ -163,7 +163,7 @@ final class DecoratorCore { bd.withProperty("half", bottomHalfValue(half)), data, x, z, realX, lowerY, realZ, mantle); data.set(x, lowerY, z, lower); - data.set(x, upperY, z, upper); + data.set(x, upperY, z, IrisProceduralBlocks.normalizeWaterlogging(upper, data.get(x, upperY, z), true)); } catch (Throwable e) { GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); @@ -210,7 +210,7 @@ final class DecoratorCore { if (applyFixFaces) { bd = fixFacesForHunk(bd, data, x, z, realX, height, realZ, mantle); } - data.set(x, height, z, bd); + data.set(x, height, z, IrisProceduralBlocks.normalizeWaterlogging(bd, data.get(x, height, z), true)); } static void placeStackUp(IrisDecorator decorator, int x, int z, int realX, int realZ, @@ -258,7 +258,7 @@ final class DecoratorCore { } else if (i == 0 && !opts.underwater && !canGoOn(block, support)) { break; } - data.set(x, y, z, stackedVineBlock(block, stack, i)); + data.set(x, y, z, IrisProceduralBlocks.normalizeWaterlogging(stackedVineBlock(block, stack, i), existing, true)); placed++; } if (placed > 0 && placed < stack) { @@ -356,8 +356,8 @@ final class DecoratorCore { try { NativeBlockState upper = bd.withProperty("half", topHalfValue(half)); NativeBlockState lower = bd.withProperty("half", bottomHalfValue(half)); - data.set(xf, lowerY, zf, lower); - data.set(xf, upperY, zf, upper); + 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) { GenerationFailures.rethrowEngineFailure(e); IrisLogging.reportError(e); @@ -366,7 +366,7 @@ final class DecoratorCore { } if (max > 1 && height + 1 < data.getHeight()) { - data.set(xf, height + 1, zf, bd); + data.set(xf, height + 1, zf, IrisProceduralBlocks.normalizeWaterlogging(bd, data.get(xf, height + 1, zf), true)); } } @@ -411,7 +411,7 @@ final class DecoratorCore { hasSpikes = true; } bd = stackedVineBlock(bd, stack, i); - data.set(xf, h, zf, bd); + data.set(xf, h, zf, IrisProceduralBlocks.normalizeWaterlogging(bd, data.get(xf, h, zf), true)); placed++; } if (placed > 0 && placed < stack) { @@ -433,6 +433,7 @@ final class DecoratorCore { static NativeBlockState fixFacesForHunk(NativeBlockState b, Hunk hunk, int rX, int rZ, int x, int y, int z, EngineMantle mantle) { + b = IrisProceduralBlocks.normalizeWaterlogging(b, hunk.get(rX, y, rZ), true); if (!B.isVineBlock(b)) { return b; } @@ -539,7 +540,8 @@ final class DecoratorCore { if (!IrisSpeleothems.canPlace(spike, data, x, z, y, upward, allowWater)) { return; } - data.set(x, y, z, IrisSpeleothems.orient(spike, data.get(x, y, z), upward)); + NativeBlockState existing = data.get(x, y, z); + data.set(x, y, z, IrisProceduralBlocks.normalizeWaterlogging(IrisSpeleothems.orient(spike, existing, upward), existing, true)); IrisSpeleothems.finishColumn(data, x, z, y, 1, upward); } 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 4159143f1..f7ec8d7dd 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 @@ -123,6 +123,9 @@ public final class IrisProceduralBlocks { * rather than building the search needles, and prefers a "[" match over a "," match. */ private static int propertyStart(String key, String property) { + if (key == null) { + return -1; + } int bracket = key.indexOf('['); if (bracket < 0) { return -1; @@ -142,6 +145,16 @@ public final class IrisProceduralBlocks { return -1; } + public static NativeBlockState normalizeWaterlogging(NativeBlockState state, NativeBlockState existing, boolean enabled) { + if (state == null || !hasProperty(state, "waterlogged")) { + return state; + } + boolean authored = "true".equals(propertyValue(state, "waterlogged")); + 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); + } + public static IrisObject assemble(Map blocks) { if (blocks == null || blocks.isEmpty()) { return null; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisProceduralPlacement.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisProceduralPlacement.java index 120a420d0..fb2909315 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisProceduralPlacement.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisProceduralPlacement.java @@ -19,6 +19,7 @@ package art.arcane.iris.generation.decoration; import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.structure.object.IrisObjectPlacement; import art.arcane.iris.pack.loading.IrisData; @@ -43,7 +44,7 @@ public interface IrisProceduralPlacement { IrisObjectPlacement asPlacement(); - IrisObject getVariantObject(IrisData data, RNG rng); + IrisObject getVariantObject(IrisData data, RNG rng, SubterrainRoom room); KList getVariantObjects(IrisData data); } diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSpeleothems.java b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSpeleothems.java index 63b74ddc4..fcf6d6761 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/IrisSpeleothems.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/IrisSpeleothems.java @@ -81,10 +81,7 @@ public final class IrisSpeleothems { } static NativeBlockState orient(NativeBlockState state, NativeBlockState existing, boolean upward) { - if (existing != null && existing.isWater() && !state.isWaterLogged()) { - state = state.withProperty("waterlogged", "true"); - } - return spikeBlock(state, upward, 0); + return spikeBlock(IrisProceduralBlocks.normalizeWaterlogging(state, existing, true), upward, 0); } static boolean isSturdy(NativeBlockState surface, boolean upward) { diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/coral/IrisCoral.java b/core/src/main/java/art/arcane/iris/generation/decoration/coral/IrisCoral.java index 233d4eb0d..bddc41fdd 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/coral/IrisCoral.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/coral/IrisCoral.java @@ -20,6 +20,7 @@ package art.arcane.iris.generation.decoration.coral; import art.arcane.iris.generation.cave.CarvingMode; import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.generation.decoration.IrisVacuumSettings; import art.arcane.iris.generation.terrain.IrisMaterialPalette; @@ -220,7 +221,7 @@ public class IrisCoral implements IrisProceduralPlacement { }); } - public IrisObject getVariantObject(IrisData data, RNG rng) { + public IrisObject getVariantObject(IrisData data, RNG rng, SubterrainRoom room) { KList baked = getVariantObjects(data); if (baked == null || baked.isEmpty()) { return null; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/crystal/IrisCrystal.java b/core/src/main/java/art/arcane/iris/generation/decoration/crystal/IrisCrystal.java index 6c0dc7152..dfddb8cac 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/crystal/IrisCrystal.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/crystal/IrisCrystal.java @@ -20,6 +20,7 @@ package art.arcane.iris.generation.decoration.crystal; import art.arcane.iris.generation.cave.CarvingMode; import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.generation.decoration.IrisVacuumSettings; import art.arcane.iris.generation.terrain.IrisMaterialPalette; @@ -201,7 +202,7 @@ public class IrisCrystal implements IrisProceduralPlacement { }); } - public IrisObject getVariantObject(IrisData data, RNG rng) { + public IrisObject getVariantObject(IrisData data, RNG rng, SubterrainRoom room) { KList baked = getVariantObjects(data); if (baked == null || baked.isEmpty()) { return null; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/formation/FormationGenerator.java b/core/src/main/java/art/arcane/iris/generation/decoration/formation/FormationGenerator.java index 7d1f7b59c..98e17bcb4 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/formation/FormationGenerator.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/formation/FormationGenerator.java @@ -37,6 +37,10 @@ public final class FormationGenerator { int hi = Math.max(f.getHeightMin(), f.getHeightMax()); int height = Math.max(3, rng.i(lo, hi + 1)); + return generate(f, variantIndex, rng, data, height); + } + + public static IrisObject generate(IrisFormation f, int variantIndex, RNG rng, IrisData data, int height) { int wLo = Math.min(f.getBaseWidthMin(), f.getBaseWidthMax()); int wHi = Math.max(f.getBaseWidthMin(), f.getBaseWidthMax()); double baseRadius = Math.max(1, rng.i(wLo, wHi + 1)); diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/formation/IrisFormation.java b/core/src/main/java/art/arcane/iris/generation/decoration/formation/IrisFormation.java index 6cae80b83..b1eeeef8d 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/formation/IrisFormation.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/formation/IrisFormation.java @@ -19,6 +19,7 @@ package art.arcane.iris.generation.decoration.formation; import art.arcane.iris.generation.cave.CarvingMode; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.generation.decoration.IrisProceduralPlacement; import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.generation.decoration.IrisVacuumSettings; @@ -255,6 +256,26 @@ public class IrisFormation implements IrisProceduralPlacement { @Description("How many blocks the tip of an OVERHANG curls downward.") private int overhangDrop = 3; + @MinNumber(0) + @MaxNumber(1) + @Description("For cave placements in an authored subterrain room, size the formation to this fraction of the room vault. Zero uses heightMin and heightMax.") + private double roomHeightFraction = 0; + + @Override + public IrisObject getVariantObject(IrisData data, RNG rng, SubterrainRoom room) { + if (room == null || roomHeightFraction <= 0) { + KList baked = getVariantObjects(data); + return baked == null || baked.isEmpty() ? null : baked.get(rng.i(baked.size())); + } + IrisObject object = FormationGenerator.generate(this, rng.i(Math.max(1, variants)), rng, data, + Math.max(1, (int) Math.floor(room.vaultHeight() * Math.min(1D, roomHeightFraction)))); + if (object != null) { + object.setLoadKey("procedural/" + name + "/" + room.featureId()); + object.setLoader(data); + } + return object; + } + public KList getVariantObjects(IrisData data) { return variantCache.aquire(() -> { KList baked = new KList<>(); @@ -274,14 +295,6 @@ public class IrisFormation implements IrisProceduralPlacement { }); } - public IrisObject getVariantObject(IrisData data, RNG rng) { - KList baked = getVariantObjects(data); - if (baked == null || baked.isEmpty()) { - return null; - } - return baked.get(rng.i(baked.size())); - } - public IrisObjectPlacement asPlacement() { IrisObjectPlacement placement = new IrisObjectPlacement(); placement.setMode(mode); diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/fungus/IrisFungus.java b/core/src/main/java/art/arcane/iris/generation/decoration/fungus/IrisFungus.java index 1089e1a0b..f777a34e5 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/fungus/IrisFungus.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/fungus/IrisFungus.java @@ -20,6 +20,7 @@ package art.arcane.iris.generation.decoration.fungus; import art.arcane.iris.generation.cave.CarvingMode; import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.generation.decoration.IrisVacuumSettings; import art.arcane.iris.generation.terrain.IrisMaterialPalette; @@ -233,7 +234,7 @@ public class IrisFungus implements IrisProceduralPlacement { }); } - public IrisObject getVariantObject(IrisData data, RNG rng) { + public IrisObject getVariantObject(IrisData data, RNG rng, SubterrainRoom room) { KList baked = getVariantObjects(data); if (baked == null || baked.isEmpty()) { return null; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/ruin/IrisRuin.java b/core/src/main/java/art/arcane/iris/generation/decoration/ruin/IrisRuin.java index 3606bdf14..a00c3941e 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/ruin/IrisRuin.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/ruin/IrisRuin.java @@ -20,6 +20,7 @@ package art.arcane.iris.generation.decoration.ruin; import art.arcane.iris.generation.cave.CarvingMode; import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.generation.decoration.IrisVacuumSettings; import art.arcane.iris.generation.terrain.IrisMaterialPalette; @@ -189,7 +190,7 @@ public class IrisRuin implements IrisProceduralPlacement { }); } - public IrisObject getVariantObject(IrisData data, RNG rng) { + public IrisObject getVariantObject(IrisData data, RNG rng, SubterrainRoom room) { KList baked = getVariantObjects(data); if (baked == null || baked.isEmpty()) { return null; diff --git a/core/src/main/java/art/arcane/iris/generation/decoration/tree/IrisProceduralTree.java b/core/src/main/java/art/arcane/iris/generation/decoration/tree/IrisProceduralTree.java index af850ea5d..1bb503f6d 100644 --- a/core/src/main/java/art/arcane/iris/generation/decoration/tree/IrisProceduralTree.java +++ b/core/src/main/java/art/arcane/iris/generation/decoration/tree/IrisProceduralTree.java @@ -20,6 +20,7 @@ package art.arcane.iris.generation.decoration.tree; import art.arcane.iris.generation.cave.CarvingMode; import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.generation.decoration.IrisStiltSettings; import art.arcane.iris.generation.decoration.IrisVacuumSettings; import art.arcane.iris.generation.terrain.IrisMaterialPalette; @@ -311,7 +312,7 @@ public class IrisProceduralTree implements IrisProceduralPlacement { }); } - public IrisObject getVariantObject(IrisData data, RNG rng) { + public IrisObject getVariantObject(IrisData data, RNG rng, SubterrainRoom room) { KList baked = getVariantObjects(data); if (baked == null || baked.isEmpty()) { return null; diff --git a/core/src/main/java/art/arcane/iris/generation/locator/SubterrainLocator.java b/core/src/main/java/art/arcane/iris/generation/locator/SubterrainLocator.java new file mode 100644 index 000000000..23190b111 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/locator/SubterrainLocator.java @@ -0,0 +1,125 @@ +package art.arcane.iris.generation.locator; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainPosition; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +public final class SubterrainLocator { + private static final int SEARCH_TILE_SIZE = 512; + + private SubterrainLocator() { + } + + public static Optional nearest(Engine engine, Query query, int x, int worldY, int z, int maximumDistance) { + return nearest(engine, query, x, worldY, z, maximumDistance, ignored -> true); + } + + public static Optional nearest(Engine engine, Query query, int x, int worldY, int z, + int maximumDistance, Predicate allowed) { + Objects.requireNonNull(engine, "engine"); + return nearest(engine.getComplex().getSubterrainPlanner(), query, x, worldY, z, maximumDistance, allowed); + } + + public static Optional nearest(SubterrainPlanner planner, Query query, int x, int worldY, int z, + int maximumDistance, Predicate allowed) { + Objects.requireNonNull(planner, "planner"); + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(allowed, "allowed"); + if (maximumDistance < 0 || maximumDistance > 1_048_576) { + throw new IllegalArgumentException("Subterrain search distance must be between 0 and 1048576 blocks"); + } + if (planner.isEmpty()) { + return Optional.empty(); + } + long centerTileX = Math.floorDiv(x, SEARCH_TILE_SIZE); + long centerTileZ = Math.floorDiv(z, SEARCH_TILE_SIZE); + int tileRadius = maximumDistance / SEARCH_TILE_SIZE + 1; + Set visited = new HashSet<>(); + Result nearest = null; + double nearestDistance = (double) maximumDistance * maximumDistance; + int inspectedTiles = 0; + for (int ring = 0; ring <= tileRadius; ring++) { + for (int offsetX = -ring; offsetX <= ring; offsetX++) { + int offsetZStep = Math.abs(offsetX) == ring ? 1 : Math.max(1, ring * 2); + for (int offsetZ = -ring; offsetZ <= ring; offsetZ += offsetZStep) { + long tileX = (centerTileX + offsetX) * SEARCH_TILE_SIZE; + long tileZ = (centerTileZ + offsetZ) * SEARCH_TILE_SIZE; + long tileMaxX = tileX + SEARCH_TILE_SIZE - 1L; + long tileMaxZ = tileZ + SEARCH_TILE_SIZE - 1L; + if (tileMaxX < Integer.MIN_VALUE || tileX > Integer.MAX_VALUE + || tileMaxZ < Integer.MIN_VALUE || tileZ > Integer.MAX_VALUE) { + continue; + } + double deltaTileX = x < tileX ? tileX - x : x > tileMaxX ? (long) x - tileMaxX : 0; + double deltaTileZ = z < tileZ ? tileZ - z : z > tileMaxZ ? (long) z - tileMaxZ : 0; + if (deltaTileX * deltaTileX + deltaTileZ * deltaTileZ > nearestDistance) { + continue; + } + if (++inspectedTiles > 65_536) { + throw new IllegalStateException("Subterrain search exceeded 65536 analytic tiles; use a smaller search radius"); + } + for (SubterrainPlan plan : planner.plansForBounds( + (int) Math.max(Integer.MIN_VALUE, tileX), (int) Math.max(Integer.MIN_VALUE, tileZ), + (int) Math.min(Integer.MAX_VALUE, tileMaxX), (int) Math.min(Integer.MAX_VALUE, tileMaxZ))) { + if (!visited.add(plan.id()) || !query.matches(plan)) { + continue; + } + SubterrainPosition anchor = plan.anchor(); + double deltaX = (double) anchor.x() - x; + double deltaY = (double) anchor.y() - worldY; + double deltaZ = (double) anchor.z() - z; + double distance = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ; + if (distance > nearestDistance) { + continue; + } + SubterrainCell owner = planner.sample(anchor.x(), anchor.y(), anchor.z()); + if (!owner.occupied() || !owner.room().featureId().equals(plan.id())) { + continue; + } + Result candidate = new Result(plan.id(), plan.family(), plan.biome(), anchor.x(), anchor.y(), anchor.z()); + if (!allowed.test(candidate) || nearest != null && distance == nearestDistance + && candidate.featureId().compareTo(nearest.featureId()) >= 0) { + continue; + } + nearest = candidate; + nearestDistance = distance; + } + } + } + long leftDistance = (long) x - (centerTileX - ring) * SEARCH_TILE_SIZE + 1; + long rightDistance = (centerTileX + ring + 1) * SEARCH_TILE_SIZE - x; + long frontDistance = (long) z - (centerTileZ - ring) * SEARCH_TILE_SIZE + 1; + long backDistance = (centerTileZ + ring + 1) * SEARCH_TILE_SIZE - z; + long remainingDistance = Math.min(Math.min(leftDistance, rightDistance), Math.min(frontDistance, backDistance)); + if ((double) remainingDistance * remainingDistance > nearestDistance) { + break; + } + } + return Optional.ofNullable(nearest); + } + + public record Query(String featureId, IrisSubterrainFamily family, String biome) { + public Query { + featureId = Objects.requireNonNullElse(featureId, "").trim(); + biome = Objects.requireNonNullElse(biome, "").trim(); + } + + public boolean matches(SubterrainPlan plan) { + return (featureId.isEmpty() || plan.id().equals(featureId) || plan.id().startsWith(featureId + ":")) + && (family == null || plan.family() == family) + && (biome.isEmpty() || plan.biome().equals(biome)); + } + } + + public record Result(String featureId, IrisSubterrainFamily family, String biome, int x, int y, int z) { + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java index 61d53bff4..9430c523d 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java @@ -19,6 +19,8 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; import art.arcane.iris.generation.cache.Cache; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.IrisComplex; @@ -79,6 +81,11 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { discard(); return CommitResult.REJECTED_BOUNDS; } + SubterrainCell feature = engine.getSubterrainCell(mutation.x(), mutation.y(), mutation.z()); + if (SubterrainRasterizer.protectsPlacement(feature)) { + discard(); + return CommitResult.REJECTED_SUBTERRAIN; + } HydrologyCaveCell hydrology = delegate.getData( mutation.x(), mutation.y(), mutation.z(), HydrologyCaveCell.class); if (hydrology != null && hydrology.protectsPlacement()) { @@ -173,6 +180,15 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { return delegate.getFluidHeight(); } + @Override + public int getFluidHeight(int x, int z) { + SubterrainCell feature = engine == null ? null : engine.getSubterrainCell(x, anchorY, z); + if (feature != null && feature.fluid()) { + return feature.room().fluidHeadY() + 1 - engine.getMinHeight(); + } + return delegate.getFluidHeight(x, z); + } + @Override public boolean isDebugSmartBore() { return delegate.isDebugSmartBore(); @@ -261,7 +277,8 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { EMPTY, REJECTED_TRANSITION, REJECTED_BOUNDS, - REJECTED_HYDROLOGY + REJECTED_HYDROLOGY, + REJECTED_SUBTERRAIN } private interface BufferedMutation { diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleCarvingComponent.java index 9c8dbf454..f4c3e7e1a 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleCarvingComponent.java @@ -19,6 +19,9 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; +import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.UpperDimensionContext; import art.arcane.iris.generation.biome.IrisBiome; @@ -38,6 +41,7 @@ import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.math.PowerOfTwoCoordinates; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; +import java.util.TreeSet; import java.util.ArrayList; import java.util.Arrays; import java.util.IdentityHashMap; @@ -160,6 +164,31 @@ public class MantleCarvingComponent extends IrisMantleComponent { surfaceFluidBoundaries ); } + rasterizeSubterrain(writer, x, z, complex); + } + + private void rasterizeSubterrain(MantleWriter writer, int chunkX, int chunkZ, IrisComplex complex) { + SubterrainPlanner planner = complex.getSubterrainPlanner(); + if (planner == null || planner.isEmpty()) { + return; + } + int minimumY = getEngineMantle().getEngine().getWorld().minHeight(); + SubterrainRasterizer.rasterize(planner, chunkX, chunkZ, (x, worldY, z, cell) -> { + int y = worldY - minimumY; + if (y <= 0 || y >= writer.getMantle().getWorldHeight()) { + return; + } + if (cell.solid()) { + writer.clearData(x, y, z, MatterCavern.class); + } else { + writer.setData(x, y, z, SubterrainRasterizer.cavern(cell)); + } + if (cell.solid()) { + writer.setData(x, y, z, SubterrainRasterizer.state(cell)); + } else { + writer.clearData(x, y, z, NativeBlockState.class); + } + }); } private static int maxSurfaceBreakDepth(List weightedProfiles) { @@ -343,33 +372,45 @@ public class MantleCarvingComponent extends IrisMantleComponent { continue; } - Map rootProfileColumnWeights = new IdentityHashMap<>(); - List rootProfileOrder = new ArrayList<>(); - IrisRange worldYRange = entry.getWorldYRange(); - for (int columnIndex = 0; columnIndex < CHUNK_AREA; columnIndex++) { - IrisDimensionCarvingEntry resolvedEntry = IrisDimensionCarvingResolver.resolveFromRoot( - engine, entry, baseX + (columnIndex >> 4), baseZ + (columnIndex & 15), resolverState); - IrisBiome resolvedBiome = IrisDimensionCarvingResolver.resolveEntryBiome(engine, resolvedEntry, resolverState); - if (resolvedBiome == null) { - continue; + TreeSet boundaries = new TreeSet<>(); + int minimumY = engine.getWorld().minHeight(); + int maximumY = engine.getWorld().maxHeight(); + boundaries.add(minimumY); + boundaries.add(maximumY); + for (IrisDimensionCarvingEntry candidate : entries) { + if (candidate != null && candidate.getWorldYRange() != null) { + boundaries.add(Math.max(minimumY, Math.min(maximumY, (int) Math.ceil(candidate.getWorldYRange().getMin())))); + boundaries.add(Math.max(minimumY, Math.min(maximumY, (int) Math.floor(candidate.getWorldYRange().getMax()) + 1))); } - - IrisCaveProfile profile = resolvedBiome.getCaveProfile(); - if (!isProfileEnabled(profile)) { - continue; - } - - double[] columnWeights = rootProfileColumnWeights.get(profile); - if (columnWeights == null) { - columnWeights = blendScratch.acquireWeights(); - rootProfileColumnWeights.put(profile, columnWeights); - rootProfileOrder.add(profile); - } - columnWeights[columnIndex] = 1D; } - - for (IrisCaveProfile profile : rootProfileOrder) { - weightedProfiles.add(new WeightedProfile(profile, rootProfileColumnWeights.get(profile), -1D, worldYRange, weightedProfiles.size())); + Integer previous = null; + for (int boundary : boundaries) { + if (previous != null && boundary > previous + && (entry.getWorldYRange() == null || entry.getWorldYRange().contains(previous))) { + Map rootProfileColumnWeights = new IdentityHashMap<>(); + List rootProfileOrder = new ArrayList<>(); + for (int columnIndex = 0; columnIndex < CHUNK_AREA; columnIndex++) { + IrisDimensionCarvingEntry resolvedEntry = IrisDimensionCarvingResolver.resolveFromRoot( + engine, entry, baseX + (columnIndex >> 4), previous, baseZ + (columnIndex & 15), resolverState); + IrisBiome resolvedBiome = IrisDimensionCarvingResolver.resolveEntryBiome(engine, resolvedEntry, resolverState); + IrisCaveProfile profile = resolvedBiome == null ? null : resolvedBiome.getCaveProfile(); + if (!isProfileEnabled(profile)) { + continue; + } + double[] columnWeights = rootProfileColumnWeights.get(profile); + if (columnWeights == null) { + columnWeights = blendScratch.acquireWeights(); + rootProfileColumnWeights.put(profile, columnWeights); + rootProfileOrder.add(profile); + } + columnWeights[columnIndex] = 1D; + } + IrisRange worldYRange = new IrisRange(previous, boundary - 1); + for (IrisCaveProfile profile : rootProfileOrder) { + weightedProfiles.add(new WeightedProfile(profile, rootProfileColumnWeights.get(profile), -1D, worldYRange, weightedProfiles.size())); + } + } + previous = boundary; } } @@ -444,7 +485,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { IrisDimensionCarvingEntry rootCarvingEntry = IrisDimensionCarvingResolver.resolveRootEntry(getEngineMantle().getEngine(), worldY, resolverState); IrisBiome resolvedCarvingBiome = null; if (rootCarvingEntry != null) { - IrisDimensionCarvingEntry resolvedCarvingEntry = IrisDimensionCarvingResolver.resolveFromRoot(getEngineMantle().getEngine(), rootCarvingEntry, worldX, worldZ, resolverState); + IrisDimensionCarvingEntry resolvedCarvingEntry = IrisDimensionCarvingResolver.resolveFromRoot(getEngineMantle().getEngine(), rootCarvingEntry, worldX, worldY, worldZ, resolverState); resolvedCarvingBiome = IrisDimensionCarvingResolver.resolveEntryBiome(getEngineMantle().getEngine(), resolvedCarvingEntry, resolverState); if (resolvedCarvingBiome != null) { caveBiome = resolvedCarvingBiome; diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java index 0fda648ec..bf2ddabee 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleHydrologyComponent.java @@ -12,6 +12,7 @@ import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; import art.arcane.iris.generation.hydrology.IrisDeepFluidConfig; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.hydrology.IrisHydrology; @@ -765,17 +766,33 @@ public final class MantleHydrologyComponent extends IrisMantleComponent { } for (Map.Entry entry : resolvedSurfaceWrites.entrySet()) { CavePosition position = entry.getKey(); + if (featureOwns(writer, context, position)) { + continue; + } writer.setData(position.x(), position.y(), position.z(), entry.getValue()); } for (CavePosition position : sortedPositions(publication.fluidUpdates())) { + if (featureOwns(writer, context, position)) { + continue; + } writer.setData(position.x(), position.y(), position.z(), UpdateMatter.ON); } for (Map.Entry entry : sortedEntries(publication.caveCells())) { CavePosition position = entry.getKey(); + if (featureOwns(writer, context, position)) { + continue; + } writer.setData(position.x(), position.y(), position.z(), entry.getValue()); } } + private static boolean featureOwns(MantleWriter writer, ChunkContext context, CavePosition position) { + IrisComplex complex = context.getComplex(); + SubterrainPlanner planner = complex.getSubterrainPlanner(); + return planner != null && !planner.isEmpty() + && planner.sample(position.x(), position.y() + writer.getEngine().getWorld().minHeight(), position.z()).owned(); + } + private static void validateLayer(HydrologyColumnLayer layer, int worldHeight) { if (layer.bedY() < 0 || layer.fluidHeadY() < layer.bedY() diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java index ca4dad733..f64111b18 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java @@ -37,6 +37,8 @@ import art.arcane.iris.generation.cave.IrisCaveProfile; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.structure.object.IrisObject; import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.structure.object.IrisObjectPlacementAlignment; +import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.iris.structure.object.IrisObjectRotation; import art.arcane.iris.structure.object.IrisObjectTranslate; import art.arcane.iris.structure.object.IrisObjectVacuum; @@ -46,6 +48,10 @@ import art.arcane.iris.generation.decoration.tree.IrisProceduralTree; import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.structure.object.ObjectPlaceMode; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainRoom; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; +import art.arcane.iris.generation.subterrain.SubterrainPlan; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -69,9 +75,11 @@ import java.io.IOException; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.HashSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiConsumer; @ComponentFlag(ReservedFlag.OBJECT) public class MantleObjectComponent extends IrisMantleComponent { @@ -250,7 +258,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " regionCavePlacers=" + region.getCarvingObjects().size()); } ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, complex, traceRegen); - placeProceduralObjects(writer, rng, x, z, surfaceBiome, caveBiome, region); + placeProceduralObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, complex); UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); IrisDimension dimension = getDimension(); if (upperCtx != null && dimension.isUpperDimensionObjects()) { @@ -603,7 +611,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } @ChunkCoordinates - private void placeProceduralObjects(ObjectPassPlacer writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region) { + private void placeProceduralObjects(ObjectPassPlacer writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, IrisComplex complex) { IrisCaveProfile surfaceCaveProfile = resolveCaveProfile(surfaceBiome.getCaveProfile(), region.getCaveProfile()); IrisCaveProfile regionCaveProfile = resolveCaveProfile(region.getCaveProfile(), caveBiome == null ? null : caveBiome.getCaveProfile()); placeProceduralFrom(writer, rng, x, z, surfaceBiome.getProceduralObjects(), surfaceBiome.getName(), surfaceCaveProfile, surfaceBiome.getLoadKey()); @@ -612,6 +620,23 @@ public class MantleObjectComponent extends IrisMantleComponent { IrisCaveProfile caveProfile = resolveCaveProfile(caveBiome.getCaveProfile(), region.getCaveProfile()); placeProceduralFrom(writer, rng, x, z, caveBiome.getProceduralObjects(), caveBiome.getName(), caveProfile, caveBiome.getLoadKey()); } + if (complex.getSubterrainPlanner() != null && !complex.getSubterrainPlanner().isEmpty()) { + Set placed = new HashSet<>(); + placed.add(surfaceBiome.getLoadKey()); + if (caveBiome != null) { + placed.add(caveBiome.getLoadKey()); + } + for (SubterrainPlan plan : complex.getSubterrainPlanner().plansForBounds(x << 4, z << 4, (x << 4) + 15, (z << 4) + 15)) { + if (plan.biome().isEmpty() || !placed.add(plan.biome())) { + continue; + } + IrisBiome featureBiome = getData().getBiomeLoader().load(plan.biome()); + if (featureBiome != null) { + placeProceduralFrom(writer, rng, x, z, featureBiome.getProceduralObjects(), featureBiome.getName(), + resolveCaveProfile(featureBiome.getCaveProfile(), region.getCaveProfile()), featureBiome.getLoadKey()); + } + } + } } @ChunkCoordinates @@ -660,16 +685,6 @@ public class MantleObjectComponent extends IrisMantleComponent { IObjectPlacer placer = golden ? new GoldenDebugObjectPlacer(basePlacer, scope + "/" + p.getName()) : basePlacer; int density = Math.max(1, p.getDensity()); for (int i = 0; i < density; i++) { - IrisObject variant = placement.scaleObject(rng, p.getVariantObject(getData(), rng), getDimension()); - if (variant == null) { - if (golden) { - IrisLogging.debug("Goldendebug procedural pick: chunk=" + x + "," + z - + " placement=" + p.getName() - + " densityIndex=" + i - + " variant=null"); - } - continue; - } CavePlacementAnchor caveAnchor = null; if (carving) { caveAnchor = findCavePlacementAnchor( @@ -694,6 +709,16 @@ public class MantleObjectComponent extends IrisMantleComponent { } continue; } + IrisObject variant = placement.scaleObject(rng, p.getVariantObject(getData(), rng, proceduralRoom(caveAnchor)), getDimension()); + if (variant == null) { + if (golden) { + IrisLogging.debug("Goldendebug procedural pick: chunk=" + x + "," + z + + " placement=" + p.getName() + + " densityIndex=" + i + + " variant=null"); + } + continue; + } int xx = caveAnchor == null ? rng.i(blockX, blockX + 16) : caveAnchor.x(); int zz = caveAnchor == null ? rng.i(blockZ, blockZ + 16) : caveAnchor.z(); int id = rng.i(0, Integer.MAX_VALUE); @@ -730,6 +755,8 @@ public class MantleObjectComponent extends IrisMantleComponent { caveFloorY, zz, effectivePlacement, + anchorMode == IrisCaveAnchorMode.FLOOR && effectivePlacement.getMode() != ObjectPlaceMode.CEILING_HANG + ? IrisObjectPlacementAlignment.FLOOR : IrisObjectPlacementAlignment.CENTER, minDepthBelowSurface, id, "procedural", @@ -773,6 +800,14 @@ public class MantleObjectComponent extends IrisMantleComponent { } } + private SubterrainRoom proceduralRoom(CavePlacementAnchor anchor) { + if (anchor == null) { + return null; + } + SubterrainCell cell = getEngineMantle().getEngine().getSubterrainCell(anchor.x(), anchor.y(), anchor.z()); + return cell == null ? null : cell.room(); + } + static boolean passesProceduralChance(RNG rng, double chance) { if (chance <= 0.0) { return false; @@ -827,6 +862,11 @@ public class MantleObjectComponent extends IrisMantleComponent { private boolean acceptsCaveAnchorAt(int x, int y, int z, boolean underwater, MatterCavern cavern, HydrologyCaveCell hydrology) { + SubterrainCell feature = getEngineMantle().getEngine().getSubterrainCell(x, y, z); + if (feature != null && feature.owned()) { + return !SubterrainRasterizer.protectsPlacement(feature) + && (underwater ? feature.fluid() : feature.kind() == SubterrainCell.Kind.AIR); + } return acceptsCaveAnchorFluid(underwater, hydrology == null ? cavern : hydrology.asCavern(), hydrology, y, getDimension().getCaveLavaHeight()); } @@ -862,6 +902,7 @@ public class MantleObjectComponent extends IrisMantleComponent { int anchorY, int z, IrisObjectPlacement placement, + IrisObjectPlacementAlignment alignment, int minDepthBelowSurface, int id, String markerContext, @@ -875,12 +916,13 @@ public class MantleObjectComponent extends IrisMantleComponent { CaveObjectPlacementTransaction.CommitResult.REJECTED_TRANSITION); } CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(placer, anchorY, minDepthBelowSurface); - int placeY = anchorY; + int placeY = alignment == IrisObjectPlacementAlignment.FLOOR + ? proceduralFloorY(placer, x, anchorY, z) : anchorY; if (placement.getMode() == ObjectPlaceMode.CEILING_HANG) { placeY = Math.max(1, transaction.getCaveCeiling(x, z) - 1 - Math.floorDiv(object.getH(), 2)); } String marker = placementMarker(object, id, markerContext); - int result = object.place(x, placeY, z, transaction, placement, rng, (block, data) -> { + BiConsumer listener = (block, data) -> { if (marker != null) { transaction.setData(block.getX(), block.getY(), block.getZ(), marker); } @@ -890,7 +932,10 @@ public class MantleObjectComponent extends IrisMantleComponent { if (placement.isDolphinTarget() && placement.isUnderwater() && B.isStorageChest(data)) { transaction.setData(block.getX(), block.getY(), block.getZ(), MatterStructurePOI.BURIED_TREASURE); } - }, null, getData()); + }; + int result = alignment == IrisObjectPlacementAlignment.FLOOR + ? object.placeOnFloor(x, placeY, z, transaction, placement, rng, listener, getData()) + : object.place(x, placeY, z, transaction, placement, rng, listener, null, getData()); if (result < 0) { transaction.discard(); return new ContainedPlacementResult(result, CaveObjectPlacementTransaction.CommitResult.EMPTY); @@ -1147,6 +1192,7 @@ public class MantleObjectComponent extends IrisMantleComponent { y, z, effectivePlacement, + IrisObjectPlacementAlignment.CENTER, objectMinDepthBelowSurface, id, "cave", @@ -1502,6 +1548,17 @@ public class MantleObjectComponent extends IrisMantleComponent { return resolvedPlacement; } + static int proceduralFloorY(IObjectPlacer placer, int x, int scannedY, int z) { + int floorY = scannedY; + for (int depth = 0; depth < 3 && floorY > 1; depth++) { + if (!placer.isCarved(x, floorY - 1, z)) { + break; + } + floorY--; + } + return floorY; + } + static int caveAnchorScanUpperBound(int worldHeight, int surfaceY, int minDepthBelowSurface) { int maxAnchorY = CaveObjectPlacementTransaction.maxBuriedY(worldHeight, surfaceY, minDepthBelowSurface); if (maxAnchorY <= 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 7afe46959..6b2afdf5a 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 @@ -38,6 +38,8 @@ 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; @@ -364,10 +366,13 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { MantleChunk chunk = acquireChunk(cx, cz); if (chunk == null) return; + if (protectsSubterrainPlacement(x, y, z)) { + return; + } synchronized (chunk) { Matter matter = chunk.getOrCreate(y >> 4); if ((t instanceof NativeBlockState || t instanceof MatterCavern) - && hasProtectedHydrology(matter, x, y, z)) { + && (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z))) { return; } if (t instanceof NativeBlockState) { @@ -399,7 +404,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { synchronized (chunk) { Matter matter = chunk.getOrCreate(y >> 4); - if (hasProtectedHydrology(matter, x, y, z)) { + if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) { return false; } MatterSlice cavernSlice = matter.getSlice(MatterCavern.class); @@ -432,7 +437,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { synchronized (chunk) { Matter matter = chunk.getOrCreate(y >> 4); - if (hasProtectedHydrology(matter, x, y, z)) { + if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) { return false; } MatterSlice blockSlice = matter.getSlice(NativeBlockState.class); @@ -467,7 +472,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } synchronized (chunk) { Matter matter = chunk.getOrCreate(y >> 4); - if (hasProtectedHydrology(matter, x, y, z)) { + if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) { return; } capturePreObjectOriginal(matter, x, y, z, NativeBlockState.class); @@ -497,7 +502,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { if (matter == null) { return; } - if (hasProtectedHydrology(matter, x, y, z)) { + if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) { return; } MatterSlice blockSlice = matter.getSlice(NativeBlockState.class); @@ -578,10 +583,14 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { public NativeBlockState getPrerequisiteBlock(int x, int y, int z) { NativeBlockState block = getPrerequisiteDataIfPresent(x, y, z, NativeBlockState.class); - return block == null ? AIR.get() : block; + return block == null ? subterrainFallback(x, y, z) : block; } public boolean isPrerequisiteCarved(int x, int y, int z) { + SubterrainCell feature = getEngine().getSubterrainCell(x, y, z); + if (feature != null && feature.owned()) { + return feature.carve(); + } Terrain3DColumn transformed = engineMantle.getComplex().transformedColumn(x, z); if (transformed != null) { return y >= 0 && y < transformed.topY() && !transformed.isSolid(y); @@ -729,7 +738,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } public void clearData(int x, int y, int z, Class type) { - if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight()) { + if (!allowsWrite(x, z) || y < 0 || y >= mantle.getWorldHeight() || protectsSubterrainPlacement(x, y, z)) { return; } @@ -745,7 +754,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { return; } if ((type == NativeBlockState.class || type == MatterCavern.class) - && hasProtectedHydrology(matter, x, y, z)) { + && (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z))) { return; } capturePreObjectOriginal(matter, x, y, z, type); @@ -754,6 +763,12 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { } } + private boolean protectsSubterrainPlacement(int x, int y, int z) { + Integer priority = activeComponentPriority.get(); + return priority != null && priority >= OBJECT_COMPONENT_PRIORITY + && SubterrainRasterizer.protectsPlacement(getEngine().getSubterrainCell(x, y, z)); + } + private static boolean hasProtectedHydrology(Matter matter, int x, int y, int z) { MatterSlice slice = matter.getSlice(HydrologyCaveCell.class); if (slice == null) { @@ -785,7 +800,7 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { synchronized (chunk) { Matter matter = chunk.getOrCreate(y >> 4); - if (hasProtectedHydrology(matter, x, y, z)) { + if (hasProtectedHydrology(matter, x, y, z) || protectsSubterrainPlacement(x, y, z)) { return; } MatterSlice blockSlice = matter.slice(NativeBlockState.class); @@ -957,9 +972,12 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { // Read-only probe: getDataIfPresent returns the identical answer without materializing // a 16^3 section + slice on a miss the way getData's getOrCreate path does. NativeBlockState block = getDataIfPresent(x, y, z, NativeBlockState.class); - if (block == null) - return AIR.get(); - return block; + return block == null ? subterrainFallback(x, y, z) : block; + } + + private NativeBlockState subterrainFallback(int x, int y, int z) { + SubterrainCell cell = getEngine().getSubterrainCell(x, y, z); + return cell != null && cell.owned() ? SubterrainRasterizer.state(cell) : AIR.get(); } @Override @@ -969,6 +987,10 @@ public class MantleWriter implements ObjectPassPlacer, AutoCloseable { @Override public boolean isCarved(int x, int y, int z) { + SubterrainCell feature = getEngine().getSubterrainCell(x, y, z); + if (feature != null && feature.owned()) { + return feature.carve(); + } Terrain3DColumn transformed = engineMantle.getComplex().transformedColumn(x, z); if (transformed != null) { return y >= 0 && y < transformed.topY() && !transformed.isSolid(y); 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 722e736b7..8fa049015 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 @@ -6,6 +6,7 @@ import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; import art.arcane.iris.generation.block.TileData; import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; @@ -228,7 +229,8 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { } private void setData(int x, int y, int z, Object data, SetMutation replayed) { - if (data == null || y < 0 || y >= worldHeight) { + if (data == null || y < 0 || y >= worldHeight + || protectsSubterrain(x, y, z)) { return; } if (data instanceof NativeBlockState && !canSetBlock(x, y, z)) { @@ -351,7 +353,13 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { if (y == 0 && writer.getEngine().getDimension().isBedrock()) { return false; } - return !hasProtectedHydrology(x, y, z); + return !hasProtectedHydrology(x, y, z) + && !protectsSubterrain(x, y, z); + } + + private boolean protectsSubterrain(int x, int y, int z) { + Engine engine = getEngine(); + return engine != null && SubterrainRasterizer.protectsPlacement(engine.getSubterrainCell(x, y, z)); } private boolean hasProtectedHydrology(int x, int y, int z) { diff --git a/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java index 743f897d4..dc5e4981e 100644 --- a/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/generation/mode/ModeOverworld.java @@ -82,6 +82,7 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { if (getEngine().getDimensionStackContext() == null) { registerStage(sCustom); } + registerStage((x, z, k, p, m, c) -> cave.preserveSubterrain(x, z, k)); } void generateTerrainMatterForChunk(int x, int z, boolean multicore, ChunkContext context) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java index 5c0c3728c..fa4187e59 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java @@ -19,6 +19,8 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.world.history.ChunkGenerationSemantics; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; import art.arcane.iris.structure.nativegen.NativeStructureOwnershipStore; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; @@ -328,6 +330,9 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisRegion getRegion(int x, int y, int z) { + if (getSubterrainCell(x, y, z).occupied()) { + return getComplex().getRegionStream().get(x, z); + } DimensionStackContext dimensionStackContext = getDimensionStackContext(); if (dimensionStackContext != null) { DimensionStackLayout.Layer layer = dimensionStackContext.getLayout(x, z).layerAt(y); @@ -340,8 +345,30 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, void generateMatter(int x, int z, boolean multicore, ChunkContext context); + @BlockCoordinates + default SubterrainCell getSubterrainCell(int x, int y, int z) { + if (getComplex() == null || getDimension() == null + || !getDimension().allowsSubterrainFeatures() + || getDimension().getSubterrainFeatures() == null || getDimension().getSubterrainFeatures().isEmpty()) { + return SubterrainCell.OUTSIDE; + } + SubterrainPlanner planner = getComplex().getSubterrainPlanner(); + return planner == null ? SubterrainCell.OUTSIDE : planner.sample(x, y + getWorld().minHeight(), z); + } + + @BlockCoordinates + default IrisBiome getSubterrainBiome(int x, int y, int z) { + SubterrainCell cell = getSubterrainCell(x, y, z); + return cell.occupied() && !cell.room().biome().isEmpty() + ? getData().getBiomeLoader().load(cell.room().biome()) : null; + } + @BlockCoordinates default IrisBiome getCaveOrMantleBiome(int x, int y, int z) { + IrisBiome featureBiome = getSubterrainBiome(x, y, z); + if (featureBiome != null) { + return featureBiome; + } IrisBiome mantleBiome = mantleCaveBiome(x, y, z); return mantleBiome != null ? mantleBiome : getCaveBiome(x, y, z); } @@ -401,6 +428,11 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, for (int index = 0; index < biomes.length; index++) { int y = index * step; regions[index] = region; + IrisBiome featureBiome = getSubterrainBiome(x, y, z); + if (featureBiome != null) { + biomes[index] = featureBiome; + continue; + } if (y > caveTop || complex.isTerrain3DSurface(x, y, z)) { if (surface == null) { surface = getSurfaceBiome(x, z); @@ -458,6 +490,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisBiome getCaveBiome(int x, int y, int z, IrisDimensionCarvingResolver.State state) { + IrisBiome featureBiome = getSubterrainBiome(x, y, z); + if (featureBiome != null) { + return featureBiome; + } IrisBiome configuredBiome = resolveConfiguredCaveBiome(x, y, z, state); if (configuredBiome != null) { return configuredBiome; @@ -490,6 +526,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, IrisBiome surfaceBiome, int surfaceY ) { + IrisBiome featureBiome = getSubterrainBiome(x, y, z); + if (featureBiome != null) { + return featureBiome; + } IrisBiome configuredBiome = resolveConfiguredCaveBiome(x, y, z, state); if (configuredBiome != null) { return configuredBiome; @@ -719,6 +759,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, boolean isStudio(); default IrisBiome getBiome(int x, int y, int z) { + IrisBiome featureBiome = getSubterrainBiome(x, y, z); + if (featureBiome != null) { + return featureBiome; + } DimensionStackContext dimensionStackContext = getDimensionStackContext(); if (dimensionStackContext != null) { DimensionStackLayout layout = dimensionStackContext.getLayout(x, z); @@ -743,6 +787,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, } default IrisBiome getBiomeOrMantle(int x, int y, int z) { + IrisBiome featureBiome = getSubterrainBiome(x, y, z); + if (featureBiome != null) { + return featureBiome; + } DimensionStackContext dimensionStackContext = getDimensionStackContext(); if (dimensionStackContext != null) { DimensionStackLayout layout = dimensionStackContext.getLayout(x, z); 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 125243be7..c616a23af 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 @@ -118,7 +118,7 @@ public final class GenerationCacheWarmer { if (placement == null) { continue; } - placement.getVariantObject(data, root.nextParallelRNG(counter[0]++)); + placement.getVariantObject(data, root.nextParallelRNG(counter[0]++), null); } } } 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 56c35773e..cc6677a82 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 @@ -22,6 +22,8 @@ import art.arcane.iris.generation.stream.GenerationStreams; import art.arcane.iris.generation.stream.CachedDoubleStream2D; import art.arcane.iris.generation.stream.CachedStream2D; import art.arcane.iris.generation.stream.ProvisionalSampling; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; import art.arcane.iris.configuration.IrisSettings; @@ -243,6 +245,8 @@ public class IrisComplex implements DataProvider { private Map> generatorBounds; private Set generatorBiomes; private IrisHydrologyRuntime hydrologyRuntime; + @Setter(AccessLevel.NONE) + private final transient SubterrainPlanner subterrainPlanner; private transient IrisImageMapRuntime imageMapRuntime; // Copy-on-write: reads happen per column on every burst thread; the synchronizedMap // monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map @@ -264,6 +268,15 @@ public class IrisComplex implements DataProvider { IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan, boolean detached) { terrainEngine = engine; + if (engine.getDimension().allowsSubterrainFeatures()) { + SubterrainRasterizer.validateMaterials(engine.getDimension().getSubterrainFeatures()); + } + subterrainPlanner = new SubterrainPlanner(new SubterrainPlanner.Options( + engine.getDimension().allowsSubterrainFeatures() ? engine.getDimension().getSubterrainFeatures() : List.of(), + engine.getSeedManager().getBodies(), + engine.getDimension().getMinHeight(), engine.getDimension().getMaxHeight()), + bounds -> transitionGenerationPlan == null || transitionGenerationPlan.allowsNewFootprint( + bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ())); biomeBoundsSamplingStep = engine.getDimension().getBiomeBoundsSamplingStep(); this.detached = detached; this.transitionGenerationPlan = transitionGenerationPlan; diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index 905f4ff78..c8b129e70 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -18,6 +18,7 @@ package art.arcane.iris.generation.runtime; +import art.arcane.iris.generation.subterrain.SubterrainCell; import art.arcane.iris.generation.runtime.EngineBackgroundTasks.BackgroundTaskDrain; import art.arcane.iris.generation.runtime.EngineRuntimeBuilder.RuntimeAssembly; import art.arcane.iris.pack.loading.IrisData; @@ -589,6 +590,31 @@ public class IrisEngine implements Engine { return context == null || context.getEngine() != this || context.getChunkContext() == null; } + @BlockCoordinates + @Override + public SubterrainCell getSubterrainCell(int x, int y, int z) { + if (!hasGenerationRuntimeScope() && generationHistoryRuntimeRouter != null) { + Optional saved = generationHistoryRuntimeRouter.biomes() + .subterrainCell(x, y + getWorld().minHeight(), z); + if (saved.isPresent()) { + return saved.get(); + } + } + return Engine.super.getSubterrainCell(x, y, z); + } + + @BlockCoordinates + @Override + public IrisBiome getSubterrainBiome(int x, int y, int z) { + SubterrainCell cell = getSubterrainCell(x, y, z); + if (!cell.occupied() || cell.room().biome().isEmpty()) { + return null; + } + Optional saved = resolveSavedBiomeEnvironment(x, y, z, false); + IrisData data = saved.isPresent() ? saved.get().data() : getData(); + return data.getBiomeLoader().load(cell.room().biome()); + } + @BlockCoordinates @Override public IrisBiome getBiome(int x, int y, int z) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/WorldEntitySpawner.java b/core/src/main/java/art/arcane/iris/generation/runtime/WorldEntitySpawner.java index dad046b5e..5cd0f7e94 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/WorldEntitySpawner.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/WorldEntitySpawner.java @@ -24,6 +24,9 @@ import art.arcane.iris.world.IrisToolbelt; import art.arcane.volmlib.util.math.Rarity; import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.decoration.IrisSurface; +import art.arcane.iris.world.entity.IrisSpawnGroup; +import org.bukkit.Location; import art.arcane.iris.world.entity.IrisEntitySpawn; import art.arcane.iris.world.entity.EntitySpawnSeed; import art.arcane.iris.world.entity.IrisMarker; @@ -404,41 +407,94 @@ final class WorldEntitySpawner { if (!ambientAllowed(chunk, initial)) { return; } - ChunkCounter counter = initial ? null : new ChunkCounter(chunk.getEntities()); + ChunkCounter counter = new ChunkCounter(initial ? new Entity[0] : chunk.getEntities()); List pool = new ArrayList<>(); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), - environment.biome(), chunk, initial, counter); + environment.biome(), chunk, initial, counter, false); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), - null, chunk, initial, counter); + null, chunk, initial, counter, false); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.biome().getEntitySpawners()), - null, chunk, initial, counter); + null, chunk, initial, counter, false); RNG random = initial ? EntitySpawnSeed.chunk(manager.getEngine().getSeedManager().getEntity(), chunk.getX(), chunk.getZ()) : new RNG(RNG.r.nextLong()); + if (environment.dimension().hasUndergroundSpawners(manager.getEngine())) { + spawnUnderground(chunk, initial, counter, random.nextParallelRNG(0x5A17D3B48269C0EFL)); + } SpawnSelection selected = selectSpawn(pool, random); if (selected == null) { return; } - int capacity = initial ? Math.max(0, selected.spawner().getMaxEntitiesPerChunk()) - : counter.remainingCapacity(selected.entry(), selected.spawner(), manager.getEngine()); + int capacity = counter.remainingCapacity(selected.entry(), selected.spawner(), manager.getEngine()); int spawned = selected.entry().spawn(manager.getEngine(), chunk, random, capacity, new SpawnContext(selected.spawner(), null, initial)); actuallySpawned.addAndGet(spawned); + counter.recordSpawned(selected.entry(), manager.getEngine(), spawned); if (spawned > 0 && !initial) { selected.spawner().spawn(manager.getEngine(), chunk.getX(), chunk.getZ()); } } + private static boolean matchesUndergroundSurface(IrisEntitySpawn entry, Engine engine, IrisSurface surface) { + IrisEntity entity = entry.getRealEntity(engine); + return entity != null && entity.getSurface().isFluid() == surface.isFluid() + && (!surface.isFluid() || entity.getSurface() == surface); + } + + private void spawnUnderground(Chunk chunk, boolean initial, ChunkCounter counter, RNG random) { + if (J.isFolia() && !J.isOwnedByCurrentRegion(chunk.getWorld(), chunk.getX(), chunk.getZ())) { + return; + } + for (IrisSurface surface : List.of(IrisSurface.LAND, IrisSurface.WATER, IrisSurface.LAVA)) { + Location candidate = IrisEntitySpawn.findLiveCaveSpawnLocation( + chunk, random.nextParallelRNG(surface.ordinal()), surface); + if (candidate == null) { + continue; + } + Engine engine = manager.getEngine(); + BiomeEnvironment environment; + try { + environment = engine.getBiomeOrMantleEnvironment(candidate.getBlockX(), + candidate.getBlockY() - engine.getWorld().minHeight(), candidate.getBlockZ()); + } catch (SavedBiomeUnavailableException unavailable) { + continue; + } + try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) { + List pool = new ArrayList<>(); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), + environment.biome(), chunk, initial, counter, true); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), + null, chunk, initial, counter, true); + collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.biome().getEntitySpawners()), + null, chunk, initial, counter, true); + pool.removeIf(selection -> !matchesUndergroundSurface(selection.entry(), engine, surface)); + SpawnSelection selected = selectSpawn(pool, random); + if (selected == null) { + continue; + } + int capacity = counter.remainingCapacity(selected.entry(), selected.spawner(), engine); + int spawned = selected.entry().spawn(engine, candidate, random, capacity, + new SpawnContext(selected.spawner(), null, initial)); + actuallySpawned.addAndGet(spawned); + counter.recordSpawned(selected.entry(), engine, spawned); + if (spawned > 0 && !initial) { + selected.spawner().spawn(engine, chunk.getX(), chunk.getZ()); + } + return; + } + } + } + private void collectSpawns(List pool, KList spawners, IrisBiome biome, - Chunk chunk, boolean initial, ChunkCounter counter) { + Chunk chunk, boolean initial, ChunkCounter counter, boolean underground) { for (IrisSpawner spawner : spawners) { - if (!spawnerAllowed(spawner, manager.getEngine(), chunk.getX(), chunk.getZ(), initial) + if ((spawner.getGroup() == IrisSpawnGroup.CAVE) != underground + || !spawnerAllowed(spawner, manager.getEngine(), chunk.getX(), chunk.getZ(), initial) || biome != null && !spawner.isValid(biome)) { continue; } for (IrisEntitySpawn entry : initial ? spawner.getInitialSpawns() : spawner.getSpawns()) { IrisEntity entity = entry.getRealEntity(manager.getEngine()); - int capacity = initial ? Math.max(0, spawner.getMaxEntitiesPerChunk()) - : counter.remainingCapacity(entry, spawner, manager.getEngine()); + int capacity = counter.remainingCapacity(entry, spawner, manager.getEngine()); if (entity != null && capacity > 0) { pool.add(new SpawnSelection(entry, spawner)); } @@ -503,6 +559,13 @@ final class WorldEntitySpawner { } } + void recordSpawned(IrisEntitySpawn entry, Engine engine, int spawned) { + IrisEntity entity = entry.getRealEntity(engine); + if (spawned > 0 && entity != null) { + counts.merge(entity.spawnCategory(), spawned, Integer::sum); + } + } + int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Engine engine) { IrisEntity entity = entry.getRealEntity(engine); return spawner.remainingCapacity(entity, counts); diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java index fcfa0e216..e7029ea7d 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java @@ -19,6 +19,12 @@ package art.arcane.iris.generation.stage; import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import java.util.List; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; +import art.arcane.iris.generation.subterrain.SubterrainSurfaceMaterials; import art.arcane.iris.generation.runtime.DimensionStackContext; import art.arcane.iris.generation.runtime.DimensionStackLayout; import art.arcane.iris.generation.runtime.UpperDimensionContext; @@ -144,7 +150,9 @@ public class IrisCarveModifier extends EngineAssignedModifier worldHeightSpan, caveLavaHeight, chunkBlockX, - chunkBlockZ + chunkBlockZ, + getComplex().getSubterrainPlanner(), + getComplex().getSubterrainPlanner() == null ? List.of() : getComplex().getSubterrainPlanner().plansForBounds(chunkBlockX - 1, chunkBlockZ - 1, chunkBlockX + 16, chunkBlockZ + 16) ); CarveResolver carveResolver = new CarveResolver(resolutionContext); terrain.forEachCell(carveResolver::apply); @@ -161,7 +169,8 @@ public class IrisCarveModifier extends EngineAssignedModifier IrisData wallData = getData(); walls.forEach((rx, yy, rz, cavern) -> { HydrologyCaveCell hydrology = terrain.hydrology(rx, yy, rz); - if (hydrology != null && hydrology.protectsPlacement()) { + if (hydrology != null && hydrology.protectsPlacement() + || subterrainCell(rx + chunkBlockX, yy, rz + chunkBlockZ).owned()) { return; } int worldX = rx + chunkBlockX; @@ -233,6 +242,7 @@ public class IrisCarveModifier extends EngineAssignedModifier output.setRaw(localX, surfaceY, localZ, AIR); } } + restoreSubterrainSolids(output, x, z); } finally { getEngine().getMetrics().getCarveApply().put(applyStopwatch.getMilliseconds()); } @@ -242,6 +252,58 @@ public class IrisCarveModifier extends EngineAssignedModifier } } + private SubterrainCell subterrainCell(int x, int y, int z) { + SubterrainCell cell = getEngine().getSubterrainCell(x, y, z); + return cell == null ? SubterrainCell.OUTSIDE : cell; + } + + public void preserveSubterrain(int blockX, int blockZ, Hunk output) { + SubterrainPlanner planner = getComplex().getSubterrainPlanner(); + if (planner == null || planner.isEmpty()) { + return; + } + int minimumY = getEngine().getWorld().minHeight(); + SubterrainSurfaceMaterials materials = subterrainMaterials(planner, blockX >> 4, blockZ >> 4); + SubterrainRasterizer.rasterize(planner, blockX >> 4, blockZ >> 4, (worldX, worldY, worldZ, cell) -> { + int y = worldY - minimumY; + if (y <= 0 || y >= output.getHeight()) { + return; + } + int x = worldX & 15; + int z = worldZ & 15; + NativeBlockState existing = output.getRaw(x, y, z); + NativeBlockState planned = materials.state(worldX, worldY, worldZ, cell); + if (cell.solid() || cell.room().reservedPassage() || cell.fluid() && (existing == null || B.isAir(existing) || B.isFluid(existing))) { + output.setRaw(x, y, z, planned); + } else { + output.setRaw(x, y, z, IrisProceduralBlocks.normalizeWaterlogging(existing, cell.kind() == SubterrainCell.Kind.WATER ? planned : null, true)); + } + }); + } + + private void restoreSubterrainSolids(Hunk output, int chunkX, int chunkZ) { + SubterrainPlanner planner = getComplex().getSubterrainPlanner(); + if (planner == null || planner.isEmpty()) { + return; + } + int minimumY = getEngine().getWorld().minHeight(); + SubterrainSurfaceMaterials materials = subterrainMaterials(planner, chunkX, chunkZ); + SubterrainRasterizer.rasterize(planner, chunkX, chunkZ, (worldX, worldY, worldZ, cell) -> { + int y = worldY - minimumY; + if (cell.solid() && y > 0 && y < output.getHeight()) { + output.setRaw(worldX & 15, y, worldZ & 15, materials.state(worldX, worldY, worldZ, cell)); + } + }); + } + + private SubterrainSurfaceMaterials subterrainMaterials(SubterrainPlanner planner, int chunkX, int chunkZ) { + int blockX = chunkX << 4; + int blockZ = chunkZ << 4; + return new SubterrainSurfaceMaterials(planner, + planner.plansForBounds(blockX - 1, blockZ - 1, blockX + 16, blockZ + 16), + getEngine().getDimension(), getComplex(), getData(), rng, getEngine().getWorld().minHeight()); + } + static boolean hasExplicitCarveIntent(MatterCavern cavern) { return cavern != null && (isFluidIntent(cavern) || cavern.isLava() || cavern.getLiquid() == 3); } @@ -357,6 +419,19 @@ public class IrisCarveModifier extends EngineAssignedModifier int localX = x & 15; int localZ = z & 15; int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); + SubterrainCell feature = context.subterrainPlanner() == null ? SubterrainCell.OUTSIDE : context.subterrainPlanner().sample(context.subterrainPlans(), + context.chunkBlockX() + localX, y + getEngine().getWorld().minHeight(), context.chunkBlockZ() + localZ); + if (feature.owned()) { + context.output().setRaw(localX, y, localZ, SubterrainRasterizer.state(feature)); + if (feature.carve()) { + context.columnMasks()[columnIndex].add(y); + if (!feature.room().biome().isEmpty()) { + context.scratch().customCaveBiomePresent = true; + } + } + return; + } + if (context.upperSurfaceHeights() != null && y >= context.upperSurfaceHeights()[columnIndex]) { return; } @@ -432,7 +507,9 @@ public class IrisCarveModifier extends EngineAssignedModifier int worldHeightSpan, int caveLavaHeight, int chunkBlockX, - int chunkBlockZ + int chunkBlockZ, + SubterrainPlanner subterrainPlanner, + List subterrainPlans ) { } @@ -904,7 +981,8 @@ public class IrisCarveModifier extends EngineAssignedModifier zone.setFloor(floor); zone.setCeiling(y - 1); if (zone.isValid(resolver.height) - && !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ)) { + && (subterrainCell(worldX, floor, worldZ).occupied() + || !isNaturalTerrainOpening(getComplex(), worldX, floor, worldZ))) { if (markerRoll(worldX, zone.ceiling, worldZ, 0x9E3779B97F4A7C15L)) { mantle.set(worldX, zone.ceiling, worldZ, MarkerMatter.CAVE_CEILING); } @@ -929,10 +1007,15 @@ public class IrisCarveModifier extends EngineAssignedModifier } } } + restoreSubterrainSolids(output, blockX >> 4, blockZ >> 4); } private void decorateZone(Hunk output, CaveZone zone, int rx, int rz, int xx, int zz, IrisBiome floorBiome, IrisBiome ceilingBiome) { + SubterrainCell feature = subterrainCell(xx, zone.getFloor(), zz); + if (feature.owned() && feature.room().reservedPassage()) { + return; + } int maxY = output.getHeight(); IrisDecorator[] surfaceDecorators = floorBiome == null ? new IrisDecorator[0] 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 75530bb34..1020a9c91 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 @@ -26,6 +26,7 @@ import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.decoration.FloatingDecorator; import art.arcane.iris.generation.decoration.IrisSeaSurfaceDecorator; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.subterrain.SubterrainCell; import art.arcane.iris.generation.runtime.EngineAssignedModifier; import art.arcane.iris.generation.runtime.EngineDecorator; import art.arcane.iris.generation.biome.FloatingBottomPaletteMode; @@ -244,7 +245,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier= chunkHeight) { + if (y < 0 || y >= chunkHeight || featureOwns(wx, y, wz)) { continue; } IrisFloatingChildBiomes entry = sample.entryAt(k); @@ -275,7 +276,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier= chunkHeight) { + if (y < 0 || y >= chunkHeight || featureOwns(wx, y, wz)) { continue; } boolean hasSolidBelow = false; @@ -340,7 +341,7 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier= chunkHeight) { + if (y < 0 || y >= chunkHeight || featureOwns(wx, y, wz)) { continue; } boolean hasSolidBelow = false; @@ -376,7 +377,9 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier 0 && fluidTopY + 1 < chunkHeight && B.isAir(output.get(xf, fluidTopY + 1, zf))) { + if (fluidTopY > 0 && fluidTopY + 1 < chunkHeight + && !featureOwns(wx, fluidTopY, wz) && !featureOwns(wx, fluidTopY + 1, wz) + && B.isAir(output.get(xf, fluidTopY + 1, zf))) { try { seaSurfaceDecorator.decorate(xf, zf, wx, wx + 1, wx - 1, wz, wz + 1, wz - 1, output, target, fluidTopY, chunkHeight); } catch (Throwable e) { @@ -389,6 +392,11 @@ public class IrisFloatingChildBiomeModifier extends EngineAssignedModifier= chunkHeight) { + if (y < 0 || y >= chunkHeight || featureOwns(wx, y, wz)) { continue; } if (!shouldWriteHostBiomeMarker(stackLayout, y)) { diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFamily.java b/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFamily.java new file mode 100644 index 000000000..df26b8292 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFamily.java @@ -0,0 +1,8 @@ +package art.arcane.iris.generation.subterrain; + +public enum IrisSubterrainFamily { + TECTONIC_FAULT, + CENOTE, + LAVA_TUBE, + TRAVERTINE_TERRACES +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFeature.java b/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFeature.java new file mode 100644 index 000000000..96ba768ba --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFeature.java @@ -0,0 +1,77 @@ +package art.arcane.iris.generation.subterrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.pack.schema.annotation.MaxNumber; +import art.arcane.iris.pack.schema.annotation.MinNumber; +import art.arcane.iris.pack.schema.annotation.RegistryListResource; +import art.arcane.iris.pack.schema.annotation.Snippet; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.volmlib.util.documentation.Description; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +@Snippet("subterrain-feature") +@Description("Bounded, deterministic underground rooms, passages, retained fluids and formations.") +public class IrisSubterrainFeature { + @Description("Unique stable feature definition id. Placement is derived from this id and the world seed.") + private String id = ""; + @Description("Enable this feature definition.") + private boolean enabled = true; + @Description("Deliberate underground geometry family.") + private IrisSubterrainFamily family = IrisSubterrainFamily.CENOTE; + @RegistryListResource(IrisBiome.class) + @Description("Biome that owns occupied cells in this feature. Solid rims and surrounding terrain retain their biome.") + private String biome = ""; + @Description("Inclusive absolute world Y bounds. The entire feature including seals must fit inside this band.") + private IrisRange worldYRange = new IrisRange(-48, 48); + @MinNumber(32) + @MaxNumber(8192) + @Description("Placement grid spacing in blocks. Each grid cell supplies at most one seed-derived candidate.") + private int spacing = 512; + @MinNumber(0) + @MaxNumber(1) + @Description("Probability that each placement grid cell contains this feature.") + private double probability = 0.35; + @MinNumber(16) + @MaxNumber(2048) + @Description("Passage or fault length in blocks. Tectonic faults require at least 200 blocks.") + private int length = 256; + @MinNumber(6) + @MaxNumber(256) + @Description("Cenote horizontal radius, or half-width of faults, tubes and terraces.") + private int radius = 32; + @MinNumber(8) + @MaxNumber(192) + @Description("Main room height in blocks.") + private int height = 32; + @Description("Retained fluid. When omitted, lava tubes use LAVA and cenotes and terraces use WATER. Faults remain dry.") + private IrisSubterrainFluid fluid; + @MinNumber(0) + @MaxNumber(32) + @Description("Depth of retained water or lava above the basin floor. Faults remain dry.") + private int fluidDepth = 4; + @MinNumber(0) + @MaxNumber(96) + @Description("Height of connected lava-tube hornito shafts above the tube vault.") + private int chimneyHeight = 16; + @MinNumber(2) + @MaxNumber(16) + @Description("Number of stepped travertine basins.") + private int terraceCount = 6; + @MinNumber(0) + @MaxNumber(128) + @Description("Grid distance between continuous floor-to-ceiling pillars. Zero disables pillars.") + private int pillarSpacing = 24; + @MinNumber(0) + @MaxNumber(0.4) + @Description("Fraction of available vault height occupied by each stalactite or stalagmite. Zero disables formations.") + private double formationFraction = 0.15; + @Description("Block state used for seals, pillars, rims, walkways and formations.") + private String solid = "minecraft:stone"; + @Description("Priority between authored features. Higher values win; stable feature ids break ties.") + private int priority = 0; +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFluid.java b/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFluid.java new file mode 100644 index 000000000..02fa5cc3a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFluid.java @@ -0,0 +1,36 @@ +package art.arcane.iris.generation.subterrain; + +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +@JsonAdapter(IrisSubterrainFluid.Adapter.class) +public enum IrisSubterrainFluid { + WATER, + LAVA; + + public static final class Adapter extends TypeAdapter { + @Override + public void write(JsonWriter writer, IrisSubterrainFluid value) throws IOException { + writer.value(value.name()); + } + + @Override + public IrisSubterrainFluid read(JsonReader reader) throws IOException { + if (reader.peek() != JsonToken.STRING) { + throw new JsonParseException("Subterrain fluid must be WATER or LAVA"); + } + String value = reader.nextString(); + try { + return IrisSubterrainFluid.valueOf(value); + } catch (IllegalArgumentException error) { + throw new JsonParseException("Unknown subterrain fluid: " + value + "; expected WATER or LAVA", error); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainBounds.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainBounds.java new file mode 100644 index 000000000..0869a17cd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainBounds.java @@ -0,0 +1,11 @@ +package art.arcane.iris.generation.subterrain; + +public record SubterrainBounds(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + public boolean contains(int x, int y, int z) { + return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ; + } + + public boolean intersects(int lowX, int lowZ, int highX, int highZ) { + return minX <= highX && maxX >= lowX && minZ <= highZ && maxZ >= lowZ; + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainCell.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainCell.java new file mode 100644 index 000000000..8a2a0529b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainCell.java @@ -0,0 +1,29 @@ +package art.arcane.iris.generation.subterrain; + +public record SubterrainCell(Kind kind, String material, SubterrainRoom room) { + public static final SubterrainCell OUTSIDE = new SubterrainCell(Kind.OUTSIDE, "", null); + + public boolean owned() { + return kind != Kind.OUTSIDE; + } + + public boolean occupied() { + return kind == Kind.AIR || fluid(); + } + + public boolean solid() { + return kind == Kind.SOLID; + } + + public boolean carve() { + return occupied(); + } + + public boolean fluid() { + return kind == Kind.WATER || kind == Kind.LAVA; + } + + public enum Kind { + OUTSIDE, SOLID, AIR, WATER, LAVA + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java new file mode 100644 index 000000000..337e5f7b3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java @@ -0,0 +1,265 @@ +package art.arcane.iris.generation.subterrain; + +import java.util.Objects; + +public final class SubterrainPlan { + private final SubterrainPlanner.Definition definition; + private final int centerX; + private final int centerY; + private final int centerZ; + private final boolean rotated; + private final String id; + private final SubterrainBounds bounds; + + SubterrainPlan(Options options) { + definition = Objects.requireNonNull(options.definition()); + centerX = options.centerX(); + centerY = options.centerY(); + centerZ = options.centerZ(); + rotated = options.rotated(); + id = options.id(); + int along = family() == IrisSubterrainFamily.CENOTE ? definition.radius() : definition.length() / 2; + int across = definition.radius(); + int extentX = (rotated ? across : along) + 2; + int extentZ = (rotated ? along : across) + 2; + bounds = new SubterrainBounds(centerX - extentX, baseY() - 2, centerZ - extentZ, + centerX + extentX, baseY() + definition.height() + 2 + + (family() == IrisSubterrainFamily.LAVA_TUBE ? definition.chimneyHeight() : 0), centerZ + extentZ); + } + + public String id() { + return id; + } + + public IrisSubterrainFamily family() { + return definition.family(); + } + + public String biome() { + return definition.biome(); + } + + public int priority() { + return definition.priority(); + } + + public SubterrainBounds bounds() { + return bounds; + } + + public int centerX() { + return centerX; + } + + public int centerY() { + return centerY; + } + + public int centerZ() { + return centerZ; + } + + public SubterrainPosition anchor() { + int y = family() == IrisSubterrainFamily.LAVA_TUBE ? centerY : baseY() + definition.fluidDepth() + 2; + SubterrainPosition anchor = occupiedAnchor(centerX, y, centerZ); + if (anchor != null) { + return anchor; + } + if (family() == IrisSubterrainFamily.TRAVERTINE_TERRACES) { + int path = definition.radius() - 4; + anchor = occupiedAnchor(rotated ? centerX + path : centerX, y, + rotated ? centerZ : centerZ + path); + if (anchor != null) { + return anchor; + } + int firstTerrace = -definition.length() / 2 + 1; + anchor = occupiedAnchor(rotated ? centerX + path : centerX + firstTerrace, y, + rotated ? centerZ + firstTerrace : centerZ + path); + if (anchor != null) { + return anchor; + } + } + throw new IllegalStateException("Subterrain plan has no occupied anchor: " + id); + } + + public SubterrainCell sample(int x, int y, int z) { + if (!bounds.contains(x, y, z)) { + return SubterrainCell.OUTSIDE; + } + int u = rotated ? z - centerZ : x - centerX; + int v = rotated ? x - centerX : z - centerZ; + Column column = switch (family()) { + case CENOTE -> cenote(u, v); + case TECTONIC_FAULT -> fault(u, v); + case LAVA_TUBE -> tube(u, v, y); + case TRAVERTINE_TERRACES -> terraces(u, v); + }; + if (column == null || y < column.floor() - 2 || y > column.ceiling() + 2) { + return SubterrainCell.OUTSIDE; + } + SubterrainCell.Kind kind = classify(column, u, v, y); + int pathX = rotated ? centerX + column.pathOffset() : x; + int pathZ = rotated ? z : centerZ + column.pathOffset(); + boolean passage = kind == SubterrainCell.Kind.AIR && column.pathDistance() <= 2.0; + SubterrainRoom room = new SubterrainRoom(id, biome(), family(), centerX, centerY, centerZ, + pathX, Math.max(column.floor() + 2, column.head() + 2), pathZ, + column.floor(), column.ceiling(), column.boundary(), column.head(), passage, + kind == SubterrainCell.Kind.SOLID, kind); + String material = switch (kind) { + case SOLID -> definition.solid(); + case AIR -> "minecraft:cave_air"; + case WATER -> "minecraft:water"; + case LAVA -> "minecraft:lava"; + case OUTSIDE -> ""; + }; + return new SubterrainCell(kind, material, room); + } + + private SubterrainCell.Kind classify(Column column, int u, int v, int y) { + if (!column.interior() || y <= column.floor() || y >= column.ceiling() || column.rim()) { + return SubterrainCell.Kind.SOLID; + } + if (formation(column, u, v, y)) { + return SubterrainCell.Kind.SOLID; + } + if (y <= column.head()) { + return switch (definition.fluid()) { + case WATER -> SubterrainCell.Kind.WATER; + case LAVA -> SubterrainCell.Kind.LAVA; + }; + } + return SubterrainCell.Kind.AIR; + } + + private boolean formation(Column column, int u, int v, int y) { + if (column.pathDistance() <= 3 || column.boundary() < 4) { + return false; + } + int spacing = definition.pillarSpacing(); + if (spacing > 0 && Math.abs(Math.floorMod(u + spacing / 2, spacing) - spacing / 2) <= 1 + && Math.abs(Math.floorMod(v + spacing / 2, spacing) - spacing / 2) <= 1) { + return true; + } + if (column.head() >= y) { + return false; + } + int cellU = Math.floorDiv(u, 12); + int cellV = Math.floorDiv(v, 12); + long hash = SubterrainPlanner.mix(definition.seedSalt() ^ cellU * 73428767L ^ cellV * 912931L); + int anchorU = cellU * 12 + 3 + (int) Math.floorMod(hash, 6); + int anchorV = cellV * 12 + 3 + (int) Math.floorMod(hash >>> 8, 6); + int distance = Math.max(Math.abs(u - anchorU), Math.abs(v - anchorV)); + int height = (int) Math.floor((column.ceiling() - column.floor() - 1) * definition.formationFraction()); + int taperHeight = Math.max(0, height - distance * Math.max(1, height / 3)); + return distance <= 2 && taperHeight > 0 + && (y <= column.floor() + taperHeight || y >= column.ceiling() - taperHeight); + } + + private Column cenote(int u, int v) { + double distance = Math.hypot(u, v); + int radius = definition.radius(); + if (distance > radius + 2) { + return null; + } + double normalized = Math.min(1, distance / radius); + int ceiling = baseY() + Math.max(3, (int) Math.floor(definition.height() * Math.sqrt(1 - normalized * normalized))); + int head = baseY() + definition.fluidDepth(); + boolean rim = definition.fluidDepth() > 0 && ceiling <= head + 2; + return new Column(baseY(), ceiling, head, distance < radius, rim, + radius - distance, Math.abs(v), 0); + } + + private Column fault(int u, int v) { + int halfLength = definition.length() / 2; + int radius = definition.radius(); + if (Math.abs(u) > halfLength + 2 || Math.abs(v) > radius + 2) { + return null; + } + int step = Math.max(2, radius / 4); + int shelf = Math.max(0, (Math.abs(v) - radius / 2) / step) * Math.max(2, definition.height() / 8); + return new Column(baseY() + shelf, baseY() + definition.height(), baseY() - 1, + Math.abs(u) < halfLength && Math.abs(v) < radius, false, + Math.min(halfLength - Math.abs(u), radius - Math.abs(v)), Math.abs(v), 0); + } + + private Column tube(int u, int v, int y) { + int halfLength = definition.length() / 2; + int path = (int) Math.round(Math.sin(u * Math.PI / Math.max(1, halfLength)) * definition.radius() * 0.15); + int width = Math.max(6, (int) Math.floor(definition.radius() * 0.8)); + int offset = v - path; + if (Math.abs(u) > halfLength + 2 || Math.abs(offset) > width + 2) { + return null; + } + double arch = Math.sqrt(Math.max(0, 1 - Math.pow(Math.min(1, Math.abs(offset) / (double) width), 2))); + int floor = centerY - Math.max(2, (int) Math.floor(definition.height() * 0.5 * arch)); + int ceiling = centerY + Math.max(2, (int) Math.floor(definition.height() * 0.5 * arch)); + int head = baseY() + definition.fluidDepth(); + if (Math.abs(offset) >= width * 0.55) { + floor = Math.max(floor, head + 2); + } + int chimneyAlong = Math.min(Math.abs(u - halfLength / 2), Math.abs(u + halfLength / 2)); + double chimneyRadius = Math.hypot(chimneyAlong, offset); + if (definition.chimneyHeight() > 0 && chimneyRadius <= 5) { + int tubeCeiling = ceiling; + if (chimneyRadius < 3) { + ceiling = baseY() + definition.height() + definition.chimneyHeight(); + } else if (y >= tubeCeiling - 1) { + return new Column(tubeCeiling - 1, baseY() + definition.height() + definition.chimneyHeight(), + head, false, true, 5 - chimneyRadius, Math.abs(offset), path); + } + } + boolean rim = definition.fluidDepth() > 0 && floor >= head && floor < head + 2; + return new Column(floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < width, + rim, Math.min(halfLength - Math.abs(u), width - Math.abs(offset)), Math.abs(offset), path); + } + + private Column terraces(int u, int v) { + int halfLength = definition.length() / 2; + int radius = definition.radius(); + if (Math.abs(u) > halfLength + 2 || Math.abs(v) > radius + 2) { + return null; + } + int segment = Math.max(4, definition.length() / definition.terraceCount()); + int index = Math.max(0, Math.min(definition.terraceCount() - 1, Math.floorDiv(u + halfLength, segment))); + int rise = Math.max(1, (definition.height() / 3) / definition.terraceCount()); + int floor = baseY() + index * rise; + int head = floor + definition.fluidDepth(); + int local = Math.floorMod(u + halfLength, segment); + boolean rim = definition.fluidDepth() > 0 && (local <= 1 || local >= segment - 2 || Math.abs(v) >= radius - 2); + int ceiling = baseY() + definition.height(); + if (rim) { + floor = head + 1; + } + double pathDistance = Math.abs(Math.abs(v) - (radius - 4)); + if (pathDistance <= 2) { + floor = Math.max(floor, head + 1); + } + return new Column(floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(v) < radius, + false, Math.min(halfLength - Math.abs(u), radius - Math.abs(v)), pathDistance, + v < 0 ? -radius + 4 : radius - 4); + } + + private SubterrainPosition occupiedAnchor(int x, int preferredY, int z) { + if (sample(x, preferredY, z).occupied()) { + return new SubterrainPosition(x, preferredY, z); + } + for (int candidateY = bounds.minY(); candidateY <= bounds.maxY(); candidateY++) { + if (sample(x, candidateY, z).occupied()) { + return new SubterrainPosition(x, candidateY, z); + } + } + return null; + } + + private int baseY() { + return centerY - definition.height() / 2; + } + + record Options(SubterrainPlanner.Definition definition, int centerX, int centerY, int centerZ, + boolean rotated, String id) { + } + + private record Column(int floor, int ceiling, int head, boolean interior, boolean rim, + double boundary, double pathDistance, int pathOffset) { + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java new file mode 100644 index 000000000..2b739996e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java @@ -0,0 +1,253 @@ +package art.arcane.iris.generation.subterrain; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +public final class SubterrainPlanner { + private final List definitions; + private final long seed; + private final int minY; + private final int maxYExclusive; + private final ThreadLocal sampleTile = new ThreadLocal<>(); + private final Predicate allowedBounds; + + public SubterrainPlanner(Options options) { + this(options, bounds -> true); + } + + public SubterrainPlanner(Options options, Predicate allowedBounds) { + Objects.requireNonNull(options); + this.allowedBounds = Objects.requireNonNull(allowedBounds); + seed = options.seed(); + minY = options.minY(); + maxYExclusive = options.maxYExclusive(); + if (minY >= maxYExclusive) { + throw new IllegalArgumentException("Subterrain world height is empty"); + } + if (options.features().size() > 64) { + throw new IllegalArgumentException("Subterrain supports at most 64 feature definitions"); + } + List loaded = new ArrayList<>(); + Set identifiers = new HashSet<>(); + for (IrisSubterrainFeature feature : options.features()) { + if (feature == null || !feature.isEnabled()) { + continue; + } + Definition definition = Definition.from(feature); + if (!identifiers.add(definition.id())) { + throw new IllegalArgumentException("Duplicate subterrain id: " + definition.id()); + } + loaded.add(definition); + } + definitions = List.copyOf(loaded); + } + + private SubterrainPlanner(SubterrainPlanner source, Predicate allowedBounds) { + definitions = source.definitions; + seed = source.seed; + minY = source.minY; + maxYExclusive = source.maxYExclusive; + this.allowedBounds = source.allowedBounds.and(Objects.requireNonNull(allowedBounds)); + } + + public SubterrainPlanner restrictTo(Predicate allowedBounds) { + return new SubterrainPlanner(this, allowedBounds); + } + + public boolean isEmpty() { + return definitions.isEmpty(); + } + + public List plansForBounds(int minX, int minZ, int maxX, int maxZ) { + if (minX > maxX || minZ > maxZ) { + throw new IllegalArgumentException("Subterrain query bounds are inverted"); + } + List result = new ArrayList<>(); + for (Definition definition : definitions) { + long firstX = Math.floorDiv(minX, definition.spacing()) - 1L; + long lastX = Math.floorDiv(maxX, definition.spacing()) + 1L; + long firstZ = Math.floorDiv(minZ, definition.spacing()) - 1L; + long lastZ = Math.floorDiv(maxZ, definition.spacing()) + 1L; + if ((lastX - firstX + 1L) * (lastZ - firstZ + 1L) > 65_536L) { + throw new IllegalArgumentException("Subterrain candidate query exceeds 65536 grid cells; use bounded search tiles"); + } + for (long gridX = firstX; gridX <= lastX; gridX++) { + for (long gridZ = firstZ; gridZ <= lastZ; gridZ++) { + SubterrainPlan plan = create(definition, gridX, gridZ); + if (plan != null && plan.bounds().intersects(minX, minZ, maxX, maxZ)) { + result.add(plan); + } + } + } + } + result.sort(Comparator.comparingInt(SubterrainPlan::priority).reversed().thenComparing(SubterrainPlan::id)); + return List.copyOf(result); + } + + public SubterrainCell sample(int x, int y, int z) { + if (isEmpty() || y < minY || y >= maxYExclusive) { + return SubterrainCell.OUTSIDE; + } + int chunkX = Math.floorDiv(x, 16); + int chunkZ = Math.floorDiv(z, 16); + CandidateTile tile = sampleTile.get(); + if (tile == null || tile.chunkX() != chunkX || tile.chunkZ() != chunkZ) { + long baseX = (long) chunkX * 16; + long baseZ = (long) chunkZ * 16; + tile = new CandidateTile(chunkX, chunkZ, plansForBounds( + (int) Math.max(Integer.MIN_VALUE, baseX - 1), + (int) Math.max(Integer.MIN_VALUE, baseZ - 1), + (int) Math.min(Integer.MAX_VALUE, baseX + 16), + (int) Math.min(Integer.MAX_VALUE, baseZ + 16))); + sampleTile.set(tile); + } + return sample(tile.plans(), x, y, z); + } + + public SubterrainCell sample(List candidates, int x, int y, int z) { + SubterrainCell selected = raw(candidates, x, y, z); + if (!selected.fluid()) { + return selected; + } + if (exposed(raw(candidates, x - 1, y, z), selected) + || exposed(raw(candidates, x + 1, y, z), selected) + || exposed(raw(candidates, x, y, z - 1), selected) + || exposed(raw(candidates, x, y, z + 1), selected) + || exposed(raw(candidates, x, y - 1, z), selected)) { + return solid(selected); + } + return selected; + } + + private SubterrainCell raw(List candidates, int x, int y, int z) { + SubterrainCell selected = SubterrainCell.OUTSIDE; + for (SubterrainPlan plan : candidates) { + SubterrainCell cell = plan.sample(x, y, z); + if (cell.solid()) { + return cell; + } + if (!selected.owned() && cell.owned()) { + selected = cell; + } + } + return selected; + } + + private boolean exposed(SubterrainCell neighbor, SubterrainCell fluid) { + return !neighbor.solid() && neighbor.kind() != fluid.kind(); + } + + private SubterrainCell solid(SubterrainCell fluid) { + SubterrainRoom room = fluid.room(); + SubterrainRoom sealed = new SubterrainRoom(room.featureId(), room.biome(), room.family(), + room.centerX(), room.centerY(), room.centerZ(), room.pathX(), room.pathY(), room.pathZ(), + room.floorY(), room.ceilingY(), room.boundaryDistance(), room.fluidHeadY(), false, true, + SubterrainCell.Kind.SOLID); + for (Definition definition : definitions) { + if (room.featureId().startsWith(definition.id() + ":")) { + return new SubterrainCell(SubterrainCell.Kind.SOLID, definition.solid(), sealed); + } + } + throw new IllegalStateException("Unknown subterrain fluid owner: " + room.featureId()); + } + + private SubterrainPlan create(Definition definition, long gridX, long gridZ) { + long value = mix(seed ^ definition.seedSalt() ^ mix(gridX) ^ Long.rotateLeft(mix(gridZ), 23)); + if (unit(value) >= definition.probability()) { + return null; + } + int spacing = definition.spacing(); + long x = gridX * spacing + spacing / 4L + Math.floorMod(mix(value + 1), Math.max(1, spacing / 2)); + long z = gridZ * spacing + spacing / 4L + Math.floorMod(mix(value + 2), Math.max(1, spacing / 2)); + int bottomMargin = definition.height() / 2 + 3; + int topMargin = definition.height() - definition.height() / 2 + 3 + + (definition.family() == IrisSubterrainFamily.LAVA_TUBE ? definition.chimneyHeight() : 0); + long low = (long) Math.max(minY, definition.minY()) + bottomMargin; + long high = (long) Math.min(maxYExclusive - 1, definition.maxY()) - topMargin; + if (low > high || x < Integer.MIN_VALUE + spacing || x > Integer.MAX_VALUE - spacing + || z < Integer.MIN_VALUE + spacing || z > Integer.MAX_VALUE - spacing) { + return null; + } + int y = (int) (low + Math.floorMod(mix(value + 3), high - low + 1)); + SubterrainPlan plan = new SubterrainPlan(new SubterrainPlan.Options(definition, (int) x, y, (int) z, + (value & 1L) != 0, definition.id() + ":" + Long.toUnsignedString(value, 16))); + return allowedBounds.test(plan.bounds()) ? plan : null; + } + + static long mix(long value) { + value = (value ^ (value >>> 30)) * 0xbf58476d1ce4e5b9L; + value = (value ^ (value >>> 27)) * 0x94d049bb133111ebL; + return value ^ (value >>> 31); + } + + private static double unit(long value) { + return (value >>> 11) * 0x1.0p-53; + } + + public record Options(List features, long seed, int minY, int maxYExclusive) { + public Options { + features = List.copyOf(Objects.requireNonNull(features)); + } + } + + private record CandidateTile(int chunkX, int chunkZ, List plans) { + } + + record Definition(String id, IrisSubterrainFamily family, IrisSubterrainFluid fluid, String biome, int spacing, double probability, + int length, int radius, int height, int fluidDepth, int chimneyHeight, int terraceCount, + int pillarSpacing, double formationFraction, String solid, int priority, + int minY, int maxY, long seedSalt) { + static Definition from(IrisSubterrainFeature feature) { + String id = Objects.requireNonNull(feature.getId(), "Subterrain id").trim(); + IrisSubterrainFamily family = Objects.requireNonNull(feature.getFamily(), "Subterrain family"); + IrisSubterrainFluid fluid = Objects.requireNonNullElse(feature.getFluid(), + family == IrisSubterrainFamily.LAVA_TUBE ? IrisSubterrainFluid.LAVA : IrisSubterrainFluid.WATER); + int length = feature.getLength(); + int radius = feature.getRadius(); + int height = feature.getHeight(); + int chimney = feature.getChimneyHeight(); + int horizontalReach = family == IrisSubterrainFamily.CENOTE ? radius : Math.max(length / 2, radius); + if (!id.matches("[a-zA-Z0-9_/-]+") || length < 16 || length > 2048 || radius < 6 || radius > 256 + || height < 8 || height > 192 || chimney < 0 || chimney > 96 + || feature.getSpacing() < Math.max(32, 2 * horizontalReach + 8) || feature.getSpacing() > 8192 + || !Double.isFinite(feature.getProbability()) || feature.getProbability() < 0 || feature.getProbability() > 1 + || feature.getFluidDepth() < 0 || feature.getFluidDepth() > Math.min(32, height / 3) + || feature.getTerraceCount() < 2 || feature.getTerraceCount() > 16 + || feature.getPillarSpacing() < 0 || feature.getPillarSpacing() > 128 + || !Double.isFinite(feature.getFormationFraction()) || feature.getFormationFraction() < 0 + || feature.getFormationFraction() > 0.4 || (family == IrisSubterrainFamily.TECTONIC_FAULT && length < 200)) { + throw new IllegalArgumentException("Invalid subterrain dimensions or placement settings: " + id); + } + if (feature.getWorldYRange() == null || !Double.isFinite(feature.getWorldYRange().getMin()) + || !Double.isFinite(feature.getWorldYRange().getMax()) + || feature.getWorldYRange().getMin() < Integer.MIN_VALUE + || feature.getWorldYRange().getMax() > Integer.MAX_VALUE + || feature.getWorldYRange().getMin() > feature.getWorldYRange().getMax()) { + throw new IllegalArgumentException("Invalid subterrain world Y range: " + id); + } + String solid = Objects.requireNonNull(feature.getSolid(), "Subterrain solid").trim(); + if (!solid.matches("(?:[a-z0-9_.-]+:)?[a-z0-9_/.-]+(?:\\[[a-z0-9_=,.-]+\\])?") + || solid.matches("(?:minecraft:)?(?:air|cave_air|void_air|water|lava)(?:\\[.*\\])?")) { + throw new IllegalArgumentException("Invalid subterrain solid block state: " + id); + } + String biome = Objects.requireNonNullElse(feature.getBiome(), "").trim(); + if (!biome.isEmpty() && !biome.matches("[a-zA-Z0-9_/-]+")) { + throw new IllegalArgumentException("Invalid subterrain biome key: " + id); + } + long salt = 0xcbf29ce484222325L; + for (int i = 0; i < id.length(); i++) { + salt = (salt ^ id.charAt(i)) * 0x100000001b3L; + } + return new Definition(id, family, fluid, biome, + feature.getSpacing(), feature.getProbability(), length, radius, height, feature.getFluidDepth(), + chimney, feature.getTerraceCount(), feature.getPillarSpacing(), feature.getFormationFraction(), + solid, feature.getPriority(), (int) Math.ceil(feature.getWorldYRange().getMin()), + (int) Math.floor(feature.getWorldYRange().getMax()), salt); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPosition.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPosition.java new file mode 100644 index 000000000..041621f1f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPosition.java @@ -0,0 +1,4 @@ +package art.arcane.iris.generation.subterrain; + +public record SubterrainPosition(int x, int y, int z) { +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRasterizer.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRasterizer.java new file mode 100644 index 000000000..59fe4306c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRasterizer.java @@ -0,0 +1,84 @@ +package art.arcane.iris.generation.subterrain; + +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.decoration.IrisProceduralBlocks; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.matter.MatterCavern; + +import java.util.List; +import java.util.Objects; + +public final class SubterrainRasterizer { + private SubterrainRasterizer() { + } + + public static void validateMaterials(List features) { + for (IrisSubterrainFeature feature : features) { + if (feature == null || !feature.isEnabled()) { + continue; + } + NativeBlockState state = B.getStateOrNull(feature.getSolid(), false); + if (!isRetainedSolid(state)) { + throw new IllegalArgumentException("Subterrain feature " + feature.getId() + + " requires a full, opaque, dry vanilla solid block without gravity: " + feature.getSolid()); + } + } + } + + public static boolean isRetainedSolid(NativeBlockState state) { + return state != null && state.isSolid() && state.isOccluding() && !state.isFluid() + && !state.isWaterLogged() && !state.isCustom() && !IrisProceduralBlocks.isGravityAffected(state); + } + + public static void rasterize(SubterrainPlanner planner, int chunkX, int chunkZ, CellConsumer consumer) { + Objects.requireNonNull(planner); + Objects.requireNonNull(consumer); + int baseX = chunkX << 4; + int baseZ = chunkZ << 4; + List plans = planner.plansForBounds(baseX - 1, baseZ - 1, baseX + 16, baseZ + 16); + if (plans.isEmpty()) { + return; + } + int lowY = Integer.MAX_VALUE; + int highY = Integer.MIN_VALUE; + for (SubterrainPlan plan : plans) { + lowY = Math.min(lowY, plan.bounds().minY()); + highY = Math.max(highY, plan.bounds().maxY()); + } + for (int x = baseX; x < baseX + 16; x++) { + for (int z = baseZ; z < baseZ + 16; z++) { + for (int y = lowY; y <= highY; y++) { + SubterrainCell cell = planner.sample(plans, x, y, z); + if (cell.owned()) { + consumer.accept(x, y, z, cell); + } + } + } + } + } + + public static MatterCavern cavern(SubterrainCell cell) { + if (!cell.carve()) { + return null; + } + byte liquid = switch (cell.kind()) { + case WATER -> 1; + case LAVA -> 2; + default -> 3; + }; + return new MatterCavern(true, cell.room().biome(), liquid); + } + + public static NativeBlockState state(SubterrainCell cell) { + return IrisProceduralBlocks.normalizeWaterlogging(B.getState(cell.material()), null, false); + } + + public static boolean protectsPlacement(SubterrainCell cell) { + return cell != null && cell.owned() && (cell.solid() || cell.room().reservedPassage()); + } + + @FunctionalInterface + public interface CellConsumer { + void accept(int x, int worldY, int z, SubterrainCell cell); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRoom.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRoom.java new file mode 100644 index 000000000..b7a63a26c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRoom.java @@ -0,0 +1,10 @@ +package art.arcane.iris.generation.subterrain; + +public record SubterrainRoom(String featureId, String biome, IrisSubterrainFamily family, + int centerX, int centerY, int centerZ, int pathX, int pathY, int pathZ, + int floorY, int ceilingY, double boundaryDistance, int fluidHeadY, + boolean reservedPassage, boolean reservedSolid, SubterrainCell.Kind occupancy) { + public int vaultHeight() { + return Math.max(0, ceilingY - floorY - 1); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterials.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterials.java new file mode 100644 index 000000000..9973c5597 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterials.java @@ -0,0 +1,95 @@ +package art.arcane.iris.generation.subterrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.IrisDimension; +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 java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class SubterrainSurfaceMaterials { + private final SubterrainPlanner planner; + private final List plans; + private final IrisDimension dimension; + private final IrisComplex complex; + private final IrisData data; + private final RNG random; + private final int minimumY; + private final Map biomes = new HashMap<>(); + + public SubterrainSurfaceMaterials(SubterrainPlanner planner, List plans, + IrisDimension dimension, IrisComplex complex, IrisData data, + RNG random, int minimumY) { + this.planner = planner; + this.plans = plans; + this.dimension = dimension; + this.complex = complex; + this.data = data; + this.random = random; + this.minimumY = minimumY; + } + + public NativeBlockState state(int x, int worldY, int z, SubterrainCell cell) { + NativeBlockState fallback = SubterrainRasterizer.state(cell); + if (!cell.solid() || cell.room().biome().isEmpty()) { + return fallback; + } + Surface surface = surface(x, worldY, z, cell); + if (surface == Surface.INTERIOR) { + return fallback; + } + String key = cell.room().biome(); + if (!biomes.containsKey(key)) { + biomes.put(key, data.getBiomeLoader().load(key)); + } + IrisBiome biome = biomes.get(key); + if (biome == null) { + return fallback; + } + NativeBlockState candidate; + if (surface == Surface.WALL) { + candidate = biome.getWall().get(random, x, worldY - minimumY, z, data); + } else { + KList layers = surface == Surface.FLOOR + ? biome.generateLayers(dimension, x, z, random, 1, worldY + 1 - minimumY, data, complex) + : biome.generateCeilingLayers(dimension, x, z, random, 1, worldY - 1 - minimumY, data, complex); + candidate = layers.isEmpty() ? null : layers.getFirst(); + } + return SubterrainRasterizer.isRetainedSolid(candidate) ? candidate : fallback; + } + + public Surface surface(int x, int worldY, int z, SubterrainCell cell) { + if (!cell.solid()) { + return Surface.INTERIOR; + } + if (sameRoom(planner.sample(plans, x, worldY + 1, z), cell)) { + return Surface.FLOOR; + } + if (sameRoom(planner.sample(plans, x, worldY - 1, z), cell)) { + return Surface.CEILING; + } + if (sameRoom(planner.sample(plans, x - 1, worldY, z), cell) + || sameRoom(planner.sample(plans, x + 1, worldY, z), cell) + || sameRoom(planner.sample(plans, x, worldY, z - 1), cell) + || sameRoom(planner.sample(plans, x, worldY, z + 1), cell)) { + return Surface.WALL; + } + return Surface.INTERIOR; + } + + private static boolean sameRoom(SubterrainCell neighbor, SubterrainCell cell) { + return neighbor.occupied() && neighbor.room().featureId().equals(cell.room().featureId()); + } + + public enum Surface { + FLOOR, + CEILING, + WALL, + INTERIOR + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java index 650cf2bd4..e98bb5880 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimension.java @@ -18,6 +18,7 @@ package art.arcane.iris.generation.terrain; +import art.arcane.iris.world.entity.IrisSpawnGroup; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiomeCustom; import art.arcane.iris.generation.biome.IrisCustomBiomeAliasResolver; @@ -30,6 +31,7 @@ import art.arcane.iris.generation.decoration.IrisOreGenerator; import art.arcane.iris.generation.decoration.IrisOreBands; import art.arcane.iris.generation.decoration.tree.IrisTreeSettings; import art.arcane.iris.generation.hydrology.IrisDeepFluidConfig; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; import art.arcane.iris.generation.hydrology.IrisHydrology; import art.arcane.iris.generation.hydrology.IrisRiverHydrology; import art.arcane.iris.generation.hydrology.IrisRiverPolicy; @@ -86,6 +88,7 @@ import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.noise.CNG; @@ -143,6 +146,7 @@ public class IrisDimension extends IrisRegistrant { private final transient AtomicCache sinr = new AtomicCache<>(); private final transient AtomicCache cosr = new AtomicCache<>(); private final transient AtomicCache rad = new AtomicCache<>(); + private final transient AtomicCache undergroundSpawnTables = new AtomicCache<>(); private final transient AtomicCache featuresUsed = new AtomicCache<>(); private final transient AtomicCache> carvingEntryIndex = new AtomicCache<>(); private final transient AtomicCache> surfaceOreCache = new AtomicCache<>(); @@ -222,6 +226,9 @@ public class IrisDimension extends IrisRegistrant { @ArrayType(type = IrisDimensionCarvingEntry.class, min = 1) @Description("Dimension-level cave biome carving overrides with absolute world Y ranges") private KList carving = new KList<>(); + @ArrayType(type = IrisSubterrainFeature.class, min = 1) + @Description("Deterministic bounded underground features with owned geometry, biome, fluids and room formations.") + private KList subterrainFeatures = new KList<>(); @Description("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); @Description("Dimension-owned surface, underground, grotto, and deep-fluid hydrology configuration.") @@ -733,6 +740,14 @@ public class IrisDimension extends IrisRegistrant { } } + if (getSubterrainFeatures() != null) { + for (IrisSubterrainFeature feature : getSubterrainFeatures()) { + if (feature != null && feature.isEnabled()) { + addReachableBiomeKey(pending, feature.getBiome()); + } + } + } + Set visited = new HashSet<>(); Map carvingEntryIndex = getCarvingEntryIndex(); while (!pending.isEmpty()) { @@ -1187,6 +1202,44 @@ public class IrisDimension extends IrisRegistrant { return new IrisDimensionType(getBaseDimension(), options, getLogicalHeight(), getMaxHeight() - getMinHeight(), getMinHeight()); } + public boolean hasUndergroundSpawners(DataProvider provider) { + return undergroundSpawnTables.aquire(() -> { + IrisData data = provider.getData(); + if (hasCaveSpawners(data, entitySpawners)) { + return true; + } + for (IrisRegion region : getAllRegions(provider)) { + if (region != null && hasCaveSpawners(data, region.getEntitySpawners())) { + return true; + } + } + for (IrisBiome biome : getReachableBiomes(provider)) { + if (biome != null && hasCaveSpawners(data, biome.getEntitySpawners())) { + return true; + } + } + return false; + }); + } + + private static boolean hasCaveSpawners(IrisData data, KList keys) { + if (keys == null || keys.isEmpty() || data.getSpawnerLoader() == null) { + return false; + } + KList spawners = data.getSpawnerLoader().loadAll(keys); + for (IrisSpawner spawner : spawners) { + if (spawner != null && !spawner.isCompatExcluded() && spawner.getGroup() == IrisSpawnGroup.CAVE) { + return true; + } + } + return false; + } + + public boolean allowsSubterrainFeatures() { + return useMantle && carvingEnabled && mode != null && mode.getType() == IrisDimensionModeType.OVERWORLD + && !disabledComponents.contains(ReservedFlag.CARVED); + } + public boolean hasUpperDimension() { return upperDimension != null && !upperDimension.isEmpty() && !upperDimension.equalsIgnoreCase("none"); } diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolver.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolver.java index 0ef34aff0..b977e8614 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolver.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolver.java @@ -40,7 +40,7 @@ public final class IrisDimensionCarvingResolver { if (root == null) { return null; } - IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldZ, data, resolvedState); + IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldY, worldZ, data, resolvedState); return resolved == null ? null : resolveBoundEntryBiome(data, resolved, resolvedState); } @@ -54,14 +54,14 @@ public final class IrisDimensionCarvingResolver { return resolveBoundRootEntry(engine, worldY, data, resolvedState); } - public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) { - return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState()); + public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldY, int worldZ) { + return resolveFromRoot(engine, rootEntry, worldX, worldY, worldZ, threadState()); } - public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) { + public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldY, int worldZ, State state) { State resolvedState = state == null ? threadState() : state; IrisData data = resolvedState.bind(engine); - return resolveBoundFromRoot(engine, rootEntry, worldX, worldZ, data, resolvedState); + return resolveBoundFromRoot(engine, rootEntry, worldX, worldY, worldZ, data, resolvedState); } public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry) { @@ -109,7 +109,7 @@ public final class IrisDimensionCarvingResolver { return resolved; } - private static IrisDimensionCarvingEntry resolveBoundFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, IrisData data, State resolvedState) { + private static IrisDimensionCarvingEntry resolveBoundFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldY, int worldZ, IrisData data, State resolvedState) { if (rootEntry == null) { return null; } @@ -127,7 +127,7 @@ public final class IrisDimensionCarvingResolver { ColumnResolutions columns = resolvedState.columns; int slot = ((worldX & 31) << 5) | (worldZ & 31); long coordinate = ((long) worldX << 32) | (worldZ & 0xffffffffL); - if (columns != null && columns.roots[slot] == rootEntry && columns.coordinates[slot] == coordinate) { + if (columns != null && columns.roots[slot] == rootEntry && columns.coordinates[slot] == coordinate && columns.worldYs[slot] == worldY) { return columns.entries[slot]; } @@ -138,7 +138,8 @@ public final class IrisDimensionCarvingResolver { while (depth > 0) { cacheable &= fixedChildStyle(current); IrisDimensionCarvingEntry selected = selectChild(engine, data, current, worldX, worldZ, entryIndex, resolvedState); - if (selected == null || selected == current) { + if (selected == null || selected == current || selected.getWorldYRange() != null + && !selected.getWorldYRange().contains(worldY)) { break; } @@ -156,6 +157,7 @@ public final class IrisDimensionCarvingResolver { resolvedState.columns = columns; } columns.coordinates[slot] = coordinate; + columns.worldYs[slot] = worldY; columns.entries[slot] = current; columns.roots[slot] = rootEntry; } @@ -332,7 +334,7 @@ public final class IrisDimensionCarvingResolver { if (root == null) { return null; } - IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldZ, data, state); + IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldY, worldZ, data, state); return resolved == null ? null : resolveBoundEntryBiome(data, resolved, state); } } @@ -399,6 +401,7 @@ public final class IrisDimensionCarvingResolver { private static final class ColumnResolutions { private final long[] coordinates = new long[1024]; + private final int[] worldYs = new int[1024]; private final IrisDimensionCarvingEntry[] roots = new IrisDimensionCarvingEntry[1024]; private final IrisDimensionCarvingEntry[] entries = new IrisDimensionCarvingEntry[1024]; } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java index ac57005c9..ce9320045 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/nms/BukkitBiomePolicy.java @@ -538,6 +538,10 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy if (engine == null || engine.isClosed() || engine.getComplex() == null) { return false; } + if (engine.getDimension().allowsSubterrainFeatures() + && engine.getDimension().getSubterrainFeatures() != null && !engine.getDimension().getSubterrainFeatures().isEmpty()) { + return false; + } int worldMinHeight = engine.getWorld().minHeight(); int internalY = (quartY << 2) - worldMinHeight; int caveSwitchInternalY = Math.max(-8 - worldMinHeight, 40); @@ -888,6 +892,10 @@ public final class BukkitBiomePolicy implements NativeBiomeSourcePolicy int worldMinHeight = engine.getWorld().minHeight(); int internalY = blockY - worldMinHeight; int caveSwitchInternalY = Math.max(-8 - worldMinHeight, 40); + IrisBiome featureBiome = engine.getSubterrainBiome(blockX, internalY, blockZ); + if (featureBiome != null) { + return createBiomeResolution(featureBiome, true, engine.getDimension(), blockX, blockY, blockZ); + } DimensionStackLayout.Layer stackLayer = includeDimensionStack ? stackLayout == null ? resolveDimensionStackLayer(engine, blockX, internalY, blockZ) diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java index c489d8d90..d0fb4e650 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObject.java @@ -337,7 +337,14 @@ public class IrisObject extends IrisRegistrant { public int place(int x, int yv, int z, IObjectPlacer oplacer, IrisObjectPlacement config, RNG rng, BiConsumer listener, CarveResult c, IrisData rdata) { IrisObject placedObject = config.isSmartBore() ? IrisObjectShaping.smartBoredVariant(this) : this; - return new IrisObjectPlacementRunner(placedObject).place(x, yv, z, oplacer, config, rng, listener, c, rdata); + return new IrisObjectPlacementRunner(placedObject, IrisObjectPlacementAlignment.CENTER).place(x, yv, z, oplacer, config, rng, listener, c, rdata); + } + + public int placeOnFloor(int x, int firstAirY, int z, IObjectPlacer placer, IrisObjectPlacement config, + RNG rng, BiConsumer listener, IrisData data) { + IrisObject placedObject = config.isSmartBore() ? IrisObjectShaping.smartBoredVariant(this) : this; + return new IrisObjectPlacementRunner(placedObject, IrisObjectPlacementAlignment.FLOOR) + .place(x, firstAirY, z, placer, config, rng, listener, null, data); } KList getSurfaceSupportOffsets() { diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementAlignment.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementAlignment.java new file mode 100644 index 000000000..2a3b2340b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectPlacementAlignment.java @@ -0,0 +1,6 @@ +package art.arcane.iris.structure.object; + +public enum IrisObjectPlacementAlignment { + CENTER, + FLOOR +} 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 6f09fa225..541eed333 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 @@ -68,9 +68,11 @@ final class IrisObjectPlacementRunner { private static final ConcurrentHashMap IMPLAUSIBLE_BEDROCK_WARNS = new ConcurrentHashMap<>(); private final IrisObject self; + private final IrisObjectPlacementAlignment alignment; - IrisObjectPlacementRunner(IrisObject self) { + IrisObjectPlacementRunner(IrisObject self, IrisObjectPlacementAlignment alignment) { this.self = self; + this.alignment = Objects.requireNonNull(alignment); } int place(int x, int yv, int z, IObjectPlacer oplacer, IrisObjectPlacement config, RNG rng, BiConsumer listener, CarveResult c, IrisData rdata) { @@ -169,7 +171,19 @@ final class IrisObjectPlacementRunner { TransformedBounds placementBounds = transformedBounds(spin, translating, translateOffset, ceilingHang, warpMargin); boolean bail = false; - if (config.isFromBottom()) { + if (alignment == IrisObjectPlacementAlignment.FLOOR) { + if (yv < 0 || ceilingHang || rawStructurePiece) { + throw new IllegalArgumentException("Floor alignment requires an explicit floor Y and a floor placement mode."); + } + int minimumY = minimumOccupiedY(spin); + if (minimumY == Integer.MAX_VALUE) { + return -1; + } + y = yv - minimumY; + if (!config.isForcePlace() && shouldBailForCarvingAnchor(placer, config, x, yv, z)) { + bail = true; + } + } else if (config.isFromBottom()) { // todo Convert this to a dedicated mode. y = (self.getH() + 1) + rty; if (!config.isForcePlace()) { @@ -290,7 +304,7 @@ final class IrisObjectPlacementRunner { } } - if (yv >= 0 && config.isBottom() && !rawStructurePiece) { + if (yv >= 0 && config.isBottom() && !rawStructurePiece && alignment != IrisObjectPlacementAlignment.FLOOR) { y += Math.floorDiv(self.h, 2); CarvingMode carvingMode = config.getCarvingSupport(); if (!config.isForcePlace() && !carvingMode.equals(CarvingMode.CARVING_ONLY)) { @@ -608,9 +622,7 @@ final class IrisObjectPlacementRunner { continue; } - if (waterlogCandidate && IrisProceduralBlocks.hasProperty(data, "waterlogged") && shouldAutoWaterlogBlock(placer, config, yv, xx, yy, zz)) { - data = data.withProperty("waterlogged", "true"); - } + data = IrisProceduralBlocks.normalizeWaterlogging(data, placer.get(xx, yy, zz), waterlogCandidate); if (!rawStructurePiece && B.isVineBlock(data)) { data = attachVineFaces(placer, data, xx, yy, zz); @@ -810,7 +822,7 @@ final class IrisObjectPlacementRunner { } } } - placer.set(xx, j, zz, d); + placer.set(xx, j, zz, IrisProceduralBlocks.normalizeWaterlogging(d, placer.get(xx, j, zz), waterlogCandidate)); } } else { int scan = 0; @@ -832,7 +844,7 @@ final class IrisObjectPlacementRunner { } } } - placer.set(xx, j, zz, d); + placer.set(xx, j, zz, IrisProceduralBlocks.normalizeWaterlogging(d, placer.get(xx, j, zz), waterlogCandidate)); } } continue; @@ -840,9 +852,7 @@ final class IrisObjectPlacementRunner { int highest = placer.getHighest(xx, zz, self.getLoader(), true); - if (IrisProceduralBlocks.hasProperty(d, "waterlogged") && shouldAutoWaterlogBlock(placer, config, yv, xx, highest, zz)) { - d = d.withProperty("waterlogged", "true"); - } + int lowerBound = highest - 1; if (settings != null) { @@ -882,7 +892,7 @@ final class IrisObjectPlacementRunner { if (B.isVineBlock(d)) { d = attachVineFaces(placer, d, xx, j, zz); } - placer.set(xx, j, zz, d); + placer.set(xx, j, zz, IrisProceduralBlocks.normalizeWaterlogging(d, placer.get(xx, j, zz), waterlogCandidate)); } } @@ -940,6 +950,26 @@ final class IrisObjectPlacementRunner { return offsets; } + private int minimumOccupiedY(SpinKernel spin) { + int minimumY = Integer.MAX_VALUE; + self.readLock.lock(); + try { + VectorMap.Cursor cursor = self.blocks.cursor(); + while (cursor.next()) { + NativeBlockState state = cursor.value(); + if (state == null || state.isAir()) { + continue; + } + IrisBlockVector position = cursor.key().clone(); + spin.rotate(position); + minimumY = Math.min(minimumY, (int) Math.round(position.getY())); + } + } finally { + self.readLock.unlock(); + } + return minimumY; + } + private TransformedBounds transformedBounds(SpinKernel spin, boolean translating, IrisBlockVector translateOffset, boolean ceilingHang, int margin) { int sourceMinX = -self.getCenter().getBlockX(); @@ -1218,23 +1248,6 @@ final class IrisObjectPlacementRunner { || placer.isCarved(x, y - 3, z); } - private boolean shouldAutoWaterlogBlock(IObjectPlacer placer, IrisObjectPlacement placement, int yv, int x, int y, int z) { - if (!(placement.isWaterloggable() || placement.isUnderwater())) { - return false; - } - - if (yv >= 0 && placement.getCarvingSupport().equals(CarvingMode.CARVING_ONLY)) { - return false; - } - - NativeBlockState existing = placer.get(x, y, z); - if (existing == null) { - return false; - } - - return B.isWater(existing) || B.isWaterLogged(existing); - } - private static NativeBlockState attachVineFaces(IObjectPlacer placer, NativeBlockState data, int x, int y, int z) { NativeBlockState result = data; for (String face : IrisProceduralBlocks.FACE_PROPERTIES) { diff --git a/core/src/main/java/art/arcane/iris/world/entity/IrisEntitySpawn.java b/core/src/main/java/art/arcane/iris/world/entity/IrisEntitySpawn.java index 322f2b4c7..ede76c61b 100644 --- a/core/src/main/java/art/arcane/iris/world/entity/IrisEntitySpawn.java +++ b/core/src/main/java/art/arcane/iris/world/entity/IrisEntitySpawn.java @@ -124,6 +124,22 @@ public class IrisEntitySpawn implements Rarity { return spawned; } + public int spawn(Engine engine, Location candidate, RNG random, int remainingCapacity, SpawnContext context) { + if (remainingCapacity <= 0) { + return 0; + } + int count = Math.min(remainingCapacity, LootResolver.inclusive(random, minSpawns, maxSpawns)); + long batchSeed = random.getSeed(); + int spawned = 0; + for (int ordinal = 0; ordinal < count; ordinal++) { + if (lightAllowed(candidate, context) + && spawn100(engine, candidate, EntitySpawnSeed.entity(batchSeed, ordinal), false) != null) { + spawned++; + } + } + return spawned; + } + public static Integer selectSurfaceSpawnY(IrisSpawnGroup group, IrisSurface surface, int floor, int top, RNG rng) { if (group == IrisSpawnGroup.CAVE) { @@ -206,7 +222,7 @@ public class IrisEntitySpawn implements Rarity { return selectCaveSpawnLocation(markers, chunk.getWorld(), rng); } - static Location findLiveCaveSpawnLocation(Chunk chunk, RNG rng, IrisSurface surface) { + public static Location findLiveCaveSpawnLocation(Chunk chunk, RNG rng, IrisSurface surface) { boolean fluid = surface.isFluid(); World world = chunk.getWorld(); int minimumY = world.getMinHeight() + 1; diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java index 8e8c8cb9a..192f3e3dc 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticCapture.java @@ -306,6 +306,10 @@ public final class GenerationSemanticCapture { snapshot = IrisDimensionCarvingResolver.snapshot(engine); minimumY = engine.getWorld().minHeight(); } + IrisBiome featureBiome = engine.getSubterrainBiome(x, y, z); + if (featureBiome != null) { + return featureBiome; + } IrisBiome configured = snapshot.resolveBiome(x, y + minimumY, z); if (configured != null) { return configured; diff --git a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java index 653c16b03..c7eee0905 100644 --- a/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java +++ b/core/src/main/java/art/arcane/iris/world/history/GenerationSemanticQueries.java @@ -2,6 +2,8 @@ package art.arcane.iris.world.history; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.locator.SubterrainLocator; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery; import art.arcane.iris.generation.hydrology.HydrologyFeatureRef; @@ -9,6 +11,8 @@ import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime; import java.math.BigInteger; +import java.io.IOException; +import java.io.UncheckedIOException; import java.util.Objects; import java.util.Optional; import java.util.OptionalLong; @@ -19,6 +23,57 @@ public final class GenerationSemanticQueries { private GenerationSemanticQueries() { } + public static Optional nearestSubterrain( + Engine engine, SubterrainLocator.Query query, int x, int worldY, int z, int maximumDistance) { + Engine requiredEngine = Objects.requireNonNull(engine, "engine"); + Objects.requireNonNull(query, "query"); + GenerationHistoryRuntimeRouter router = requiredEngine instanceof IrisEngine irisEngine + ? irisEngine.getGenerationHistoryRuntimeRouter().orElse(null) : null; + if (router == null) { + return SubterrainLocator.nearest(requiredEngine, query, x, worldY, z, maximumDistance); + } + GenerationHistory history = router.history(); + SubterrainLocator.Result nearest = null; + try { + for (GenerationActivation activation : history.manifest().activations()) { + if (history.manifest().pendingActivation().map(pending -> + pending.activationId() == activation.activationId()).orElse(false)) { + continue; + } + SubterrainPlanner planner = router.biomes().subterrainPlanner(activation.activationId()); + SubterrainLocator.Result candidate = SubterrainLocator.nearest(planner, query, x, worldY, z, + maximumDistance, result -> allowsSubterrainPrediction(requiredEngine, history, + activation.activationId(), result)).orElse(null); + if (candidate != null && (nearest == null || distanceSquared(x, worldY, z, candidate) + .compareTo(distanceSquared(x, worldY, z, nearest)) < 0)) { + nearest = candidate; + } + } + } catch (IOException failure) { + throw new UncheckedIOException("Unable to locate saved subterrain features", failure); + } + return Optional.ofNullable(nearest); + } + + private static boolean allowsSubterrainPrediction(Engine engine, GenerationHistory history, + long activationId, SubterrainLocator.Result result) { + int chunkX = Math.floorDiv(result.x(), 16); + int chunkZ = Math.floorDiv(result.z(), 16); + GenerationActivation owner = history.resolveActivation(chunkX, chunkZ); + if (owner.activationId() != activationId) { + return false; + } + if (history.isActiveUnowned(chunkX, chunkZ)) { + return engine.getComplex().allowsNewDiscreteContentAt(result.x(), result.z()); + } + return true; + } + + private static BigInteger distanceSquared(int x, int y, int z, SubterrainLocator.Result result) { + BigInteger deltaY = BigInteger.valueOf((long) y - result.y()); + return horizontalDistanceSquared(x, z, result.x(), result.z()).add(deltaY.multiply(deltaY)); + } + public static IrisStructureLocator.LocateResult nearestStructure( Engine engine, String key, diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeCapture.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeCapture.java index 90d8d63db..1964053f1 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeCapture.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeCapture.java @@ -3,6 +3,7 @@ package art.arcane.iris.world.history; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; import java.io.IOException; import java.util.ArrayList; @@ -30,9 +31,12 @@ public final class SavedBiomeCapture { stage.chunkX(), stage.chunkZ(), activationId, minimumY, height)); TransitionGenerationPlan transition = engine.getComplex().getTransitionGenerationPlan(); Map> historical = new HashMap<>(); - Map> vertical = new HashMap<>(16); + SubterrainPlanner planner = engine.getComplex().getSubterrainPlanner(); + boolean exactVolume = planner != null && !planner.plansForBounds(startX, startZ, + Math.addExact(startX, 15), Math.addExact(startZ, 15)).isEmpty(); + Map> vertical = new HashMap<>(exactVolume ? 256 : 16); Cells cells = new Cells(activationId); - Column column = new Column(engine, minimumY, height); + Column column = new Column(engine, minimumY, height, exactVolume ? 1 : 4); for (int localX = 0; localX < 16; localX++) { for (int localZ = 0; localZ < 16; localZ++) { int blockX = startX + localX; @@ -51,13 +55,13 @@ public final class SavedBiomeCapture { if (transition != null) { surfaceCell = historicalCell(new Sample(blockX, surfaceY, blockZ, true), surfaceCell, transition, saved, historical); } - int quartX = localX & ~3; - int quartZ = localZ & ~3; - int quartKey = quartX * 16 + quartZ; - List spans = vertical.get(quartKey); + int sampleX = exactVolume ? localX : localX & ~3; + int sampleZ = exactVolume ? localZ : localZ & ~3; + int columnKey = sampleX * 16 + sampleZ; + List spans = vertical.get(columnKey); if (spans == null) { - spans = captureVertical(column, startX + quartX, startZ + quartZ, cells, transition, saved, historical, floating); - vertical.put(quartKey, spans); + spans = captureVertical(column, startX + sampleX, startZ + sampleZ, cells, transition, saved, historical, floating); + vertical.put(columnKey, spans); } SavedBiomeChunk.Cell caveBase = cells.cell(engine.getCaveBiome(blockX, blockZ), region); result.column(localX, localZ, new SavedBiomeChunk.Column(surfaceCell, caveBase, spans)); @@ -69,16 +73,16 @@ public final class SavedBiomeCapture { private static List captureVertical(Column column, int blockX, int blockZ, Cells cells, TransitionGenerationPlan transition, SavedBiomeRuntime saved, Map> historical, FloatingBiomeOverlay floating) throws IOException { - column.engine().getBiomeOrMantleColumn(blockX, blockZ, 4, column.biomes(), column.regions()); + column.engine().getBiomeOrMantleColumn(blockX, blockZ, column.step(), column.biomes(), column.regions()); int minimumY = column.minimumY(); int maximumY = minimumY + column.height(); List spans = new ArrayList<>(); SavedBiomeChunk.Cell previous = null; int startY = minimumY; for (int index = 0; index < column.biomes().length; index++) { - int worldY = minimumY + index * 4; + int worldY = minimumY + index * column.step(); SavedBiomeChunk.Cell current = cells.cell(column.biomes()[index], column.regions()[index]); - if (floating != null) { + if (floating != null && !column.engine().getSubterrainCell(blockX, index * column.step(), blockZ).occupied()) { current = overlay(current, floating.volumeAt(blockX & 15, worldY - minimumY, blockZ & 15)); } if (transition != null) { @@ -135,9 +139,10 @@ public final class SavedBiomeCapture { Objects.requireNonNull(region, "region").getLoadKey()); } - private record Column(Engine engine, int minimumY, int height, IrisBiome[] biomes, IrisRegion[] regions) { - private Column(Engine engine, int minimumY, int height) { - this(engine, minimumY, height, new IrisBiome[(height + 3) / 4], new IrisRegion[(height + 3) / 4]); + private record Column(Engine engine, int minimumY, int height, int step, IrisBiome[] biomes, IrisRegion[] regions) { + private Column(Engine engine, int minimumY, int height, int step) { + this(engine, minimumY, height, step, new IrisBiome[(height + step - 1) / step], + new IrisRegion[(height + step - 1) / step]); } } diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java index 403b36621..3122b1f19 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java @@ -3,6 +3,10 @@ package art.arcane.iris.world.history; import art.arcane.iris.generation.runtime.GenerationClosedException; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.runtime.SeedManager; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainRasterizer; import art.arcane.iris.generation.runtime.BiomeEnvironment; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; @@ -10,6 +14,7 @@ import art.arcane.iris.generation.terrain.IrisRegion; import art.arcane.iris.spi.IrisLogging; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.Path; @@ -17,6 +22,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -39,6 +45,7 @@ public final class SavedBiomeRuntime implements AutoCloseable { private final GenerationHistory history; private final SavedBiomeStore store; private final Map definitions = new ConcurrentHashMap<>(); + private final Map subterrainPlanners = new ConcurrentHashMap<>(); private final LinkedHashMap queries = new LinkedHashMap<>(32, 0.75F, true); private final Map pending = new HashMap<>(); private final ReentrantReadWriteLock consumption = new ReentrantReadWriteLock(); @@ -135,6 +142,41 @@ public final class SavedBiomeRuntime implements AutoCloseable { return resolve(blockX, 0, blockZ, QueryKind.CAVE_BASE); } + public Optional subterrainCell(int blockX, int worldY, int blockZ) { + int chunkX = Math.floorDiv(blockX, 16); + int chunkZ = Math.floorDiv(blockZ, 16); + if (history.isActiveUnowned(chunkX, chunkZ) && history.semantics(chunkX, chunkZ).isEmpty()) { + return Optional.empty(); + } + try { + long activationId = history.resolveActivation(chunkX, chunkZ).activationId(); + return Optional.of(subterrainPlanner(activationId).sample(blockX, worldY, blockZ)); + } catch (IOException failure) { + throw new UncheckedIOException("Unable to read saved subterrain definitions", failure); + } + } + + public SubterrainPlanner subterrainPlanner(long activationId) throws IOException { + consumption.readLock().lock(); + try { + requireOpen(); + SubterrainPlanner cached = subterrainPlanners.get(activationId); + if (cached != null) { + return cached; + } + GenerationActivation activation = history.manifest().activation(activationId).orElseThrow(() -> + new IOException("Saved subterrain generation is missing: " + activationId)); + TransitionGenerationPlan transition = activation.isInitial() ? null : history.transitionPlan(activationId); + SubterrainPlanner planner = definitions(activationId).subterrainPlanner().restrictTo(bounds -> + transition == null || transition.allowsNewFootprint( + bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ())); + SubterrainPlanner existing = subterrainPlanners.putIfAbsent(activationId, planner); + return existing == null ? planner : existing; + } finally { + consumption.readLock().unlock(); + } + } + private Optional resolve(int blockX, int worldY, int blockZ, QueryKind kind) { int chunkX = Math.floorDiv(blockX, 16); int chunkZ = Math.floorDiv(blockZ, 16); @@ -254,6 +296,7 @@ public final class SavedBiomeRuntime implements AutoCloseable { } } definitions.clear(); + subterrainPlanners.clear(); } finally { consumption.writeLock().unlock(); } @@ -466,8 +509,14 @@ public final class SavedBiomeRuntime implements AutoCloseable { if (dimension == null) { throw new IOException("Historical dimension is no longer supported: " + epoch.dimensionContract().dimensionKey()); } + if (dimension.allowsSubterrainFeatures()) { + SubterrainRasterizer.validateMaterials(dimension.getSubterrainFeatures()); + } Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data), - resolveFocusRegions(data, dimension)); + resolveFocusRegions(data, dimension), new SubterrainPlanner(new SubterrainPlanner.Options( + dimension.allowsSubterrainFeatures() ? dimension.getSubterrainFeatures() : List.of(), + new SeedManager(epoch.worldSeed()).getBodies(), + dimension.getMinHeight(), dimension.getMaxHeight()))); data.prepareBlockDropRules(); definitions.put(epochId, loaded); return loaded; @@ -540,7 +589,7 @@ public final class SavedBiomeRuntime implements AutoCloseable { } private record Definitions(IrisData data, IrisDimension dimension, Map nativeDerivatives, - Map focusRegions) { + Map focusRegions, SubterrainPlanner subterrainPlanner) { private BiomeEnvironment environment(SavedBiomeChunk.Cell cell) { IrisBiome biome = data.getBiomeLoader().load(cell.biomeKey()); IrisRegion region = data.getRegionLoader().load(cell.regionKey()); 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 913b5b57e..71e734b3b 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 @@ -507,7 +507,7 @@ public class DecoratorCoreTest { @Test public void sulfurStalagmiteKeepsSulfurMaterialAndTapersToOneTip() { - Hunk output = placeSpikeColumn("minecraft:sulfur_spike", 5, true, false); + Hunk output = placeSpikeColumn("minecraft:sulfur_spike", 5, true, false, false); assertSpikeColumn(output, "minecraft:sulfur_spike", true, false, "base", "middle", "middle", "frustum", "tip"); @@ -515,7 +515,7 @@ public class DecoratorCoreTest { @Test public void sulfurStalactiteKeepsWaterloggingAndPointsDown() { - Hunk output = placeSpikeColumn("minecraft:sulfur_spike", 5, false, true); + Hunk output = placeSpikeColumn("minecraft:sulfur_spike", 5, false, true, true); assertSpikeColumn(output, "minecraft:sulfur_spike", false, true, "base", "middle", "middle", "frustum", "tip"); @@ -524,17 +524,26 @@ public class DecoratorCoreTest { @Test public void shortSulfurSpikesHaveCorrectTipsInBothDirections() { for (boolean upward : new boolean[]{true, false}) { - Hunk single = placeSpikeColumn("minecraft:sulfur_spike", 1, upward, false); - Hunk pair = placeSpikeColumn("minecraft:sulfur_spike", 2, upward, false); + Hunk single = placeSpikeColumn("minecraft:sulfur_spike", 1, upward, false, false); + Hunk pair = placeSpikeColumn("minecraft:sulfur_spike", 2, upward, false, false); assertSpikeColumn(single, "minecraft:sulfur_spike", upward, false, "tip"); assertSpikeColumn(pair, "minecraft:sulfur_spike", upward, false, "frustum", "tip"); } } + @Test + public void authoredWaterloggedSpikesBecomeDryInAirInBothDirections() { + for (boolean upward : new boolean[]{true, false}) { + Hunk output = placeSpikeColumn("minecraft:pointed_dripstone", 4, upward, true, false); + assertSpikeColumn(output, "minecraft:pointed_dripstone", upward, false, + "base", "middle", "frustum", "tip"); + } + } + @Test public void pointedDripstoneRetainsWaterloggingWhenStacked() { - Hunk output = placeSpikeColumn("minecraft:pointed_dripstone", 4, true, true); + Hunk output = placeSpikeColumn("minecraft:pointed_dripstone", 4, true, true, true); assertSpikeColumn(output, "minecraft:pointed_dripstone", true, true, "base", "middle", "frustum", "tip"); @@ -738,7 +747,7 @@ public class DecoratorCoreTest { return air; } - private Hunk placeSpikeColumn(String material, int height, boolean upward, boolean waterlogged) { + private Hunk placeSpikeColumn(String material, int height, boolean upward, boolean waterlogged, boolean submerged) { IrisDecorator decorator = mock(IrisDecorator.class); IrisData data = mock(IrisData.class); NativeBlockState spike = spikeState(material, Map.of( @@ -750,12 +759,21 @@ public class DecoratorCoreTest { when(decorator.pickBlockDataTop(any(RNG.class), eq(data), anyDouble(), anyDouble())).thenReturn(spike); Hunk output = Hunk.newArrayHunk(1, height + 2, 1); + NativeBlockState target = airState(); + if (submerged) { + target = mock(NativeBlockState.class); + when(target.key()).thenReturn("minecraft:water"); + when(target.isWater()).thenReturn(true); + when(target.isFluid()).thenReturn(true); + } for (int y = 1; y <= height; y++) { - output.set(0, y, 0, airState()); + output.set(0, y, 0, target); } NativeBlockState support = sturdyState(); output.set(0, upward ? 0 : height + 1, 0, support); DecoratorCore.PlaceOpts opts = new DecoratorCore.PlaceOpts(); + opts.underwater = submerged; + opts.fluidHeight = height; if (upward) { DecoratorCore.placeStackUp(decorator, 0, 0, 0, 0, 0, height, output, new RNG(1L), data, opts); } else { 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 new file mode 100644 index 000000000..abce341b2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/decoration/IrisPlacementWaterloggingTest.java @@ -0,0 +1,44 @@ +package art.arcane.iris.generation.decoration; + +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.hunk.Hunk; +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisPlacementWaterloggingTest { + @Test + public void authoredWetStateIsClearedForDryAndLavaTargets() { + NativeBlockState wet = state("minecraft:oak_slab[type=bottom,waterlogged=true]"); + NativeBlockState dry = state("minecraft:oak_slab[type=bottom,waterlogged=false]"); + when(wet.withProperty("waterlogged", "false")).thenReturn(dry); + assertSame(dry, IrisProceduralBlocks.normalizeWaterlogging(wet, state("minecraft:cave_air"), false)); + assertSame(dry, IrisProceduralBlocks.normalizeWaterlogging(wet, state("minecraft:lava"), true)); + } + + @Test + public void waterloggedDecorationRequiresActualWaterAtItsOwnVoxel() { + NativeBlockState dry = state("minecraft:oak_slab[type=bottom,waterlogged=false]"); + NativeBlockState wet = state("minecraft:oak_slab[type=bottom,waterlogged=true]"); + NativeBlockState water = state("minecraft:water"); + when(water.isWater()).thenReturn(true); + when(dry.withProperty("waterlogged", "true")).thenReturn(wet); + assertSame(wet, IrisProceduralBlocks.normalizeWaterlogging(dry, water, true)); + assertSame(dry, IrisProceduralBlocks.normalizeWaterlogging(dry, water, false)); + assertSame(wet, IrisProceduralBlocks.normalizeWaterlogging(wet, water, false)); + Hunk hunk = Hunk.newArrayHunk(16, 64, 16); + hunk.set(3, 28, 5, water); + hunk.set(3, 29, 5, state("minecraft:cave_air")); + when(wet.withProperty("waterlogged", "false")).thenReturn(dry); + assertSame(wet, DecoratorCore.fixFacesForHunk(dry, hunk, 3, 5, 3, 28, 5, null)); + assertSame(dry, DecoratorCore.fixFacesForHunk(wet, hunk, 3, 5, 3, 29, 5, null)); + } + + private NativeBlockState state(String key) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/decoration/formation/ContextualFormationTest.java b/core/src/test/java/art/arcane/iris/generation/decoration/formation/ContextualFormationTest.java new file mode 100644 index 000000000..de6279f58 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/decoration/formation/ContextualFormationTest.java @@ -0,0 +1,62 @@ +package art.arcane.iris.generation.decoration.formation; + +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainRoom; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +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 ContextualFormationTest { + @Test + public void roomVaultSizesFormationAfterAnchorWithoutUsingOrdinaryHeightRange() { + IrisFormation formation = new IrisFormation().setName("vault-spire").setRoomHeightFraction(0.5) + .setHeightMin(80).setHeightMax(80).setBaseWidthMin(2).setBaseWidthMax(2) + .setRoughness(0).setLean(0); + NativeBlockState rock = mock(NativeBlockState.class); + when(rock.key()).thenReturn("minecraft:stone"); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull("minecraft:stone", false)).thenReturn(rock); + IrisData data = mock(IrisData.class); + IrisObject shortRoom = formation.getVariantObject(data, new RNG(143L), room(12)); + IrisObject tallRoom = formation.getVariantObject(data, new RNG(143L), room(40)); + IrisObject repeat = formation.getVariantObject(data, new RNG(143L), room(40)); + assertNotNull(shortRoom); + assertNotNull(tallRoom); + assertTrue(shortRoom.getH() <= 12); + assertTrue(tallRoom.getH() <= 40); + assertTrue(tallRoom.getH() > shortRoom.getH()); + assertNotNull(repeat); + assertEquals(tallRoom.getW(), repeat.getW()); + assertEquals(tallRoom.getH(), repeat.getH()); + assertEquals(tallRoom.getD(), repeat.getD()); + assertEquals(tallRoom.getBlocks().size(), repeat.getBlocks().size()); + for (Map.Entry entry : tallRoom.getBlocks()) { + NativeBlockState repeated = repeat.getBlocks().get(entry.getKey()); + assertNotNull(repeated); + assertEquals(entry.getValue().key(), repeated.key()); + } + assertEquals("procedural/vault-spire/cenote-room", tallRoom.getLoadKey()); + } + } + + private SubterrainRoom room(int vaultHeight) { + return new SubterrainRoom("cenote-room", "cave-biome", IrisSubterrainFamily.CENOTE, + 0, 20, 0, 0, 12, 0, 10, 11 + vaultHeight, 20, 10, + false, false, SubterrainCell.Kind.AIR); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java b/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java new file mode 100644 index 000000000..68413a783 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java @@ -0,0 +1,74 @@ +package art.arcane.iris.generation.locator; + +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.pack.value.IrisRange; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +public class SubterrainLocatorTest { + @Test + public void wideDistanceSearchReturnsOwnedAbsolutePositionWithoutGenerating() { + SubterrainPlanner planner = planner(); + Engine engine = engine(planner); + SubterrainLocator.Result result = SubterrainLocator.nearest(engine, + new SubterrainLocator.Query("basin", null, "wet-cave"), 100_000, -24, -100_000, 8192).orElseThrow(); + SubterrainCell sampled = planner.sample(result.x(), result.y(), result.z()); + assertTrue(sampled.occupied()); + assertEquals(result.featureId(), sampled.room().featureId()); + assertEquals("wet-cave", result.biome()); + assertTrue(result.y() >= -48 && result.y() <= 48); + verify(engine).getComplex(); + verifyNoMoreInteractions(engine); + } + + @Test + public void filtersAndDistanceBoundsRejectUnavailableOwnership() { + Engine engine = engine(planner()); + assertFalse(SubterrainLocator.nearest(engine, + new SubterrainLocator.Query("missing", null, ""), 0, 0, 0, 1024).isPresent()); + assertFalse(SubterrainLocator.nearest(engine, + new SubterrainLocator.Query("", IrisSubterrainFamily.LAVA_TUBE, ""), 0, 0, 0, 1024).isPresent()); + assertFalse(SubterrainLocator.nearest(engine, + new SubterrainLocator.Query("", null, "missing"), 0, 0, 0, 1024).isPresent()); + assertFalse(SubterrainLocator.nearest(engine, + new SubterrainLocator.Query("", null, ""), 0, 10_000, 0, 1024).isPresent()); + assertFalse(SubterrainLocator.nearest(engine, + new SubterrainLocator.Query("", null, ""), 0, 0, 0, 1024, ignored -> false).isPresent()); + } + + @Test + public void repeatedSearchAndLargeNegativeCoordinatesAreStable() { + Engine engine = engine(planner()); + SubterrainLocator.Query query = new SubterrainLocator.Query("", null, "wet-cave"); + assertEquals(SubterrainLocator.nearest(engine, query, -1_500_000, -10, -2_000_000, 4096), + SubterrainLocator.nearest(engine, query, -1_500_000, -10, -2_000_000, 4096)); + } + + private static Engine engine(SubterrainPlanner planner) { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getSubterrainPlanner()).thenReturn(planner); + return engine; + } + + private static SubterrainPlanner planner() { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("wet-cave") + .setProbability(1).setWorldYRange(new IrisRange(-48, 48)); + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -64, 320)); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java index 068ae8b02..828b25a5a 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java @@ -19,6 +19,9 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainRoom; +import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.structure.object.IObjectPlacer; import art.arcane.iris.generation.block.TileData; @@ -112,6 +115,74 @@ public class CaveObjectPlacementTransactionTest { assertEquals(61, MantleObjectComponent.caveAnchorScanUpperBound(128, 80, 20)); } + @Test + public void ownedDryAndWetRoomDecorationCanCommitWithoutWeakeningSolidGuards() { + for (SubterrainCell.Kind kind : new SubterrainCell.Kind[]{SubterrainCell.Kind.AIR, SubterrainCell.Kind.WATER}) { + IObjectPlacer delegate = createPlacer(128, 80, 20, 60); + SubterrainRoom room = new SubterrainRoom("cenote", "wet-cave", IrisSubterrainFamily.CENOTE, + 4, 35, 7, 4, 21, 7, 19, 61, 8, 34, false, false, kind); + when(delegate.getEngine().getSubterrainCell(4, 30, 7)) + .thenReturn(new SubterrainCell(kind, kind == SubterrainCell.Kind.WATER ? "minecraft:water" : "minecraft:cave_air", room)); + NativeBlockState decoration = mock(NativeBlockState.class); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + transaction.set(4, 30, 7, decoration); + assertEquals(CaveObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + verify(delegate).set(4, 30, 7, decoration); + } + } + + @Test + public void ownedWaterAndLavaUseTheirExclusiveAbsoluteFluidHeadAtTheOriginalAnchor() { + for (int minimumY : new int[]{-64, 32}) { + for (SubterrainCell.Kind kind : new SubterrainCell.Kind[]{SubterrainCell.Kind.WATER, SubterrainCell.Kind.LAVA}) { + IObjectPlacer delegate = createPlacer(256, 220, 120, 160); + Engine engine = delegate.getEngine(); + when(engine.getMinHeight()).thenReturn(minimumY); + when(delegate.getFluidHeight(4, 7)).thenReturn(16); + SubterrainRoom room = new SubterrainRoom("raised-room", "wet-cave", IrisSubterrainFamily.CENOTE, + 4, minimumY + 130, 7, 4, minimumY + 121, 7, minimumY + 119, + minimumY + 161, 8, minimumY + 126, false, false, kind); + when(engine.getSubterrainCell(4, 123, 7)).thenReturn(new SubterrainCell(kind, + kind == SubterrainCell.Kind.WATER ? "minecraft:water" : "minecraft:lava", room)); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 123, 10); + assertEquals(127, transaction.getFluidHeight(4, 7)); + verify(engine).getSubterrainCell(4, 123, 7); + verify(delegate, never()).getFluidHeight(4, 7); + } + } + } + + @Test + public void nonFluidAnchorsPreserveTheDelegateCoordinateFluidHeight() { + for (SubterrainCell.Kind kind : new SubterrainCell.Kind[]{SubterrainCell.Kind.AIR, SubterrainCell.Kind.SOLID, SubterrainCell.Kind.OUTSIDE}) { + IObjectPlacer delegate = createPlacer(128, 80, 20, 60); + when(delegate.getFluidHeight(4, 7)).thenReturn(51); + SubterrainRoom room = new SubterrainRoom("dry-room", "dry-cave", IrisSubterrainFamily.CENOTE, + 4, 35, 7, 4, 21, 7, 19, 61, 8, 24, false, false, kind); + SubterrainCell cell = kind == SubterrainCell.Kind.OUTSIDE ? SubterrainCell.OUTSIDE + : new SubterrainCell(kind, kind == SubterrainCell.Kind.AIR ? "minecraft:cave_air" : "minecraft:stone", room); + when(delegate.getEngine().getSubterrainCell(4, 20, 7)).thenReturn(cell); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + assertEquals(51, transaction.getFluidHeight(4, 7)); + verify(delegate).getFluidHeight(4, 7); + } + } + + @Test + public void featurePillarRejectsAllObjectMutations() { + IObjectPlacer delegate = createPlacer(128, 80, 20, 60); + SubterrainRoom room = new SubterrainRoom("cenote", "wet-cave", IrisSubterrainFamily.CENOTE, + 4, 35, 7, 4, 21, 7, 19, 61, 8, 24, false, true, SubterrainCell.Kind.SOLID); + when(delegate.getEngine().getSubterrainCell(4, 30, 7)) + .thenReturn(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room)); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + transaction.set(4, 30, 7, mock(NativeBlockState.class)); + transaction.setData(5, 30, 7, "decoration"); + assertEquals(CaveObjectPlacementTransaction.CommitResult.REJECTED_SUBTERRAIN, transaction.commit()); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + } + @Test public void dryHydrologyHeadroomRejectsTheWholePlacement() { IObjectPlacer delegate = createPlacer(128, 80, 20, 90); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCaveAnchorTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCaveAnchorTest.java index 079a7be63..985069737 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCaveAnchorTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentCaveAnchorTest.java @@ -1,13 +1,21 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell; import art.arcane.volmlib.util.matter.MatterCavern; import org.junit.Test; +import java.lang.reflect.Method; + import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class MantleObjectComponentCaveAnchorTest { @Test @@ -37,8 +45,10 @@ public class MantleObjectComponentCaveAnchorTest { public void explicitWetAndForcedAirPlacementsRemainEligible() { MatterCavern water = new MatterCavern(true, "", (byte) 1); MatterCavern forcedAir = new MatterCavern(true, "", (byte) 3); + MatterCavern lava = new MatterCavern(true, "", (byte) 2); assertTrue(MantleObjectComponent.acceptsCaveAnchorFluid(true, water, 20, 8)); + assertTrue(MantleObjectComponent.acceptsCaveAnchorFluid(true, lava, 20, 8)); assertTrue(MantleObjectComponent.acceptsCaveAnchorFluid(false, forcedAir, 0, 8)); assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( true, @@ -63,6 +73,56 @@ public class MantleObjectComponentCaveAnchorTest { false, forcedAir, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR), 20, 8)); } + @Test + public void authoredWetAnchorsAcceptWaterAndLavaWhileDryAnchorsRequireAir() throws Exception { + for (SubterrainCell.Kind kind : new SubterrainCell.Kind[]{SubterrainCell.Kind.WATER, SubterrainCell.Kind.LAVA}) { + SubterrainCell cell = featureCell(kind, false); + assertTrue(acceptsAuthoredAnchor(cell, true)); + assertFalse(acceptsAuthoredAnchor(cell, false)); + } + SubterrainCell air = featureCell(SubterrainCell.Kind.AIR, false); + assertTrue(acceptsAuthoredAnchor(air, false)); + assertFalse(acceptsAuthoredAnchor(air, true)); + } + + @Test + public void authoredWetAnchorsPreserveReservedPassageAndSolidGuards() throws Exception { + for (SubterrainCell.Kind kind : new SubterrainCell.Kind[]{SubterrainCell.Kind.AIR, SubterrainCell.Kind.WATER, SubterrainCell.Kind.LAVA}) { + SubterrainCell reserved = featureCell(kind, true); + assertFalse(acceptsAuthoredAnchor(reserved, false)); + assertFalse(acceptsAuthoredAnchor(reserved, true)); + } + SubterrainCell solid = featureCell(SubterrainCell.Kind.SOLID, false); + assertFalse(acceptsAuthoredAnchor(solid, false)); + assertFalse(acceptsAuthoredAnchor(solid, true)); + } + + private static boolean acceptsAuthoredAnchor(SubterrainCell cell, boolean underwater) throws Exception { + Engine engine = mock(Engine.class); + EngineMantle mantle = mock(EngineMantle.class); + when(mantle.getEngine()).thenReturn(engine); + when(engine.getSubterrainCell(17, 4, -9)).thenReturn(cell); + MantleObjectComponent component = new MantleObjectComponent(mantle); + Method method = MantleObjectComponent.class.getDeclaredMethod("acceptsCaveAnchorAt", + int.class, int.class, int.class, boolean.class, MatterCavern.class, HydrologyCaveCell.class); + method.setAccessible(true); + return (boolean) method.invoke(component, 17, 4, -9, underwater, null, null); + } + + private static SubterrainCell featureCell(SubterrainCell.Kind kind, boolean reservedPassage) { + SubterrainRoom room = new SubterrainRoom("basin:0:0", "subterrain/basin", IrisSubterrainFamily.CENOTE, + 17, 4, -9, 17, 4, -9, 0, 20, 8, 8, + reservedPassage, kind == SubterrainCell.Kind.SOLID, kind); + String material = switch (kind) { + case AIR -> "minecraft:air"; + case WATER -> "minecraft:water"; + case LAVA -> "minecraft:lava"; + case SOLID -> "minecraft:stone"; + case OUTSIDE -> ""; + }; + return new SubterrainCell(kind, material, room); + } + private static IrisBiome biome(String loadKey) { IrisBiome biome = new IrisBiome(); biome.setLoadKey(loadKey); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/NaturalCaveProceduralPlacementTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/NaturalCaveProceduralPlacementTest.java new file mode 100644 index 000000000..03bad989e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/NaturalCaveProceduralPlacementTest.java @@ -0,0 +1,279 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.cave.CarvingMode; +import art.arcane.iris.generation.decoration.IrisProceduralPlacement; +import art.arcane.iris.generation.decoration.coral.IrisCoral; +import art.arcane.iris.generation.decoration.coral.IrisCoralForm; +import art.arcane.iris.generation.decoration.crystal.IrisCrystal; +import art.arcane.iris.generation.decoration.formation.IrisFormation; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.IrisSubterrainFluid; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.generation.subterrain.SubterrainRoom; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.structure.object.IObjectPlacer; +import art.arcane.iris.structure.object.IrisObject; +import art.arcane.iris.structure.object.IrisObjectPlacement; +import art.arcane.iris.structure.object.IrisObjectRotation; +import art.arcane.iris.structure.object.IrisObjectTranslate; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.nativelib.terrain.BlockStateKey; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.math.RNG; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NaturalCaveProceduralPlacementTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private final Map states = new HashMap<>(); + + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenAnswer(invocation -> state(invocation.getArgument(0))); + when(registries.blockOrNull(anyString())).thenAnswer(invocation -> state(invocation.getArgument(0))); + when(registries.blockOrNull(anyString(), eq(false))).thenAnswer(invocation -> state(invocation.getArgument(0))); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + states.clear(); + } + + @Test + public void roomProportionalFormationRestsOnActualFloorWithoutTranslation() { + Fixture fixture = fixture(new FixtureOptions(IrisSubterrainFluid.WATER, 0)); + IrisFormation formation = new IrisFormation().setName("natural-vault").setRoomHeightFraction(0.3) + .setHeightMin(90).setHeightMax(90).setBaseWidthMin(1).setBaseWidthMax(1) + .setRoughness(0).setLean(0).setJitter(0).setCarvingSupport(CarvingMode.CARVING_ONLY); + IrisObject object = variant(formation, fixture); + assertTrue(object.getH() <= Math.floor(fixture.room().vaultHeight() * 0.3) + 2); + assertTrue(object.getH() > 3); + assertTrue(object.getLoadKey().endsWith(fixture.room().featureId())); + assertFloorPlacement(object, formation.asPlacement().setRotation(IrisObjectRotation.of(0, 0, 0)), fixture); + } + + @Test + public void crystalBaseAndRotatedShardsRestAboveProtectedCenoteFloor() { + for (double angle : List.of(0D, 30D)) { + Fixture fixture = fixture(new FixtureOptions(IrisSubterrainFluid.WATER, 0)); + IrisCrystal crystal = new IrisCrystal().setName("natural-crystal").setVariants(1) + .setBaseRadius(1.5).setBaseNoise(0).setShardCountMin(3).setShardCountMax(3) + .setShardLengthMin(5).setShardLengthMax(5).setShardBaseRadius(0.5) + .setSpreadAngle(10).setJitter(0); + IrisObject object = variant(crystal, fixture); + assertTrue(object.getH() > 3); + IrisObjectPlacement placement = crystal.asPlacement().setRotation(IrisObjectRotation.of(angle, 0, 0)); + assertFloorPlacement(object, placement, fixture); + } + } + + @Test + public void submergedCoralUsesOwnedFluidHeadAboveGlobalWaterAndWaterlogsOnlyInWater() { + for (IrisSubterrainFluid fluid : IrisSubterrainFluid.values()) { + Fixture fixture = fixture(new FixtureOptions(fluid, 12)); + assertTrue(fixture.room().floorY() > fixture.placer().getFluidHeight(fixture.x(), fixture.z())); + IrisCoral coral = coral(); + IrisObject object = variant(coral, fixture); + assertFloorPlacement(object, coral.asPlacement().setRotation(IrisObjectRotation.of(0, 0, 0)), fixture); + SubterrainCell.Kind expected = fluid == IrisSubterrainFluid.WATER ? SubterrainCell.Kind.WATER : SubterrainCell.Kind.LAVA; + for (Map.Entry entry : fixture.writes().entrySet()) { + SubterrainPosition position = entry.getKey(); + assertEquals(expected, fixture.planner().sample(position.x(), position.y(), position.z()).kind()); + assertEquals(entry.getValue().key(), fluid == IrisSubterrainFluid.WATER, entry.getValue().isWaterLogged()); + } + } + } + + @Test + public void authoredDownwardTranslationStillRejectsWholeObjectAtProtectedFloor() { + Fixture fixture = fixture(new FixtureOptions(IrisSubterrainFluid.WATER, 0)); + IrisCrystal crystal = new IrisCrystal().setVariants(1).setBaseRadius(1) + .setShardCountMin(1).setShardCountMax(1).setShardLengthMin(4).setShardLengthMax(4) + .setSpreadAngle(0).setJitter(0); + IrisObject object = variant(crystal, fixture); + IrisObjectPlacement placement = crystal.asPlacement().setRotation(IrisObjectRotation.of(0, 0, 0)) + .setTranslate(new IrisObjectTranslate().setY(-1)); + CaveObjectPlacementTransaction transaction = transaction(fixture); + int firstAirY = MantleObjectComponent.proceduralFloorY(fixture.placer(), fixture.x(), fixture.floorY() + 3, fixture.z()); + int result = object.placeOnFloor(fixture.x(), firstAirY, fixture.z(), transaction, placement, new RNG(17L), + (position, block) -> transaction.setData(position.getX(), position.getY(), position.getZ(), "natural-crystal@1"), fixture.data()); + assertTrue(result >= 0); + assertEquals(CaveObjectPlacementTransaction.CommitResult.REJECTED_SUBTERRAIN, transaction.commit()); + assertTrue(fixture.writes().isEmpty()); + assertTrue(fixture.metadata().isEmpty()); + assertTrue(fixture.placer().get(fixture.x(), fixture.floorY(), fixture.z()).isSolid()); + } + + private void assertFloorPlacement(IrisObject object, IrisObjectPlacement placement, Fixture fixture) { + assertFalse(placement.isBottom()); + assertEquals(0, placement.getTranslate().getY()); + CaveObjectPlacementTransaction transaction = transaction(fixture); + int firstAirY = MantleObjectComponent.proceduralFloorY(fixture.placer(), fixture.x(), fixture.floorY() + 3, fixture.z()); + assertEquals(fixture.floorY() + 1, firstAirY); + int result = object.placeOnFloor(fixture.x(), firstAirY, fixture.z(), transaction, placement, new RNG(17L), + (position, block) -> transaction.setData(position.getX(), position.getY(), position.getZ(), "natural@1"), fixture.data()); + assertTrue(result >= 0); + assertTrue(fixture.writes().isEmpty()); + assertTrue(fixture.metadata().isEmpty()); + assertEquals(CaveObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + assertFalse(fixture.writes().isEmpty()); + int lowest = Integer.MAX_VALUE; + for (SubterrainPosition position : fixture.writes().keySet()) { + lowest = Math.min(lowest, position.y()); + SubterrainCell cell = fixture.planner().sample(position.x(), position.y(), position.z()); + assertTrue(cell.occupied()); + assertFalse(cell.room().reservedPassage()); + assertTrue(position.y() > cell.room().floorY()); + assertTrue(position.y() < cell.room().ceilingY()); + } + assertEquals(fixture.floorY() + 1, lowest); + boolean supported = false; + for (SubterrainPosition position : fixture.writes().keySet()) { + if (position.y() == lowest) { + assertTrue(fixture.placer().get(position.x(), lowest - 1, position.z()).isSolid()); + assertFalse(fixture.writes().containsKey(new SubterrainPosition(position.x(), lowest - 1, position.z()))); + supported = true; + } + } + assertTrue(supported); + assertFalse(fixture.metadata().isEmpty()); + assertFalse(placement.isBottom()); + } + + private CaveObjectPlacementTransaction transaction(Fixture fixture) { + return new CaveObjectPlacementTransaction(fixture.placer(), fixture.floorY() + 3, 6); + } + + private IrisObject variant(IrisProceduralPlacement procedural, Fixture fixture) { + IrisObject object = procedural.getVariantObject(fixture.data(), new RNG(819L), fixture.room()); + assertNotNull(object); + assertFalse(object.getBlocks().isEmpty()); + return object; + } + + private IrisCoral coral() { + return new IrisCoral().setName("natural-coral").setVariants(1).setForm(IrisCoralForm.PILLAR) + .setHeightMin(6).setHeightMax(6).setPillarRadius(1).setTipClusterRadius(0) + .setBlock("minecraft:tube_coral[waterlogged=true]").setCarvingSupport(CarvingMode.CARVING_ONLY); + } + + private Fixture fixture(FixtureOptions options) { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("natural-room").setProbability(1) + .setRadius(32).setHeight(40).setFluid(options.fluid()).setFluidDepth(options.fluidDepth()) + .setPillarSpacing(0).setFormationFraction(0).setWorldYRange(new IrisRange(20, 120)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, 0, 256)); + SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst(); + int x = plan.centerX() + 8; + int z = plan.centerZ() + 8; + SubterrainRoom room = plan.sample(x, plan.centerY(), z).room(); + assertNotNull(room); + int floorY = room.floorY(); + Engine engine = mock(Engine.class); + when(engine.getHeight()).thenReturn(256); + when(engine.getMinHeight()).thenReturn(0); + when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(220); + when(engine.getSubterrainCell(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> + planner.sample(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2))); + IrisData data = mock(IrisData.class); + when(data.getEngine()).thenReturn(engine); + Map writes = new HashMap<>(); + Map metadata = new HashMap<>(); + IObjectPlacer placer = mock(IObjectPlacer.class); + when(placer.getEngine()).thenReturn(engine); + when(placer.getFluidHeight()).thenReturn(0); + when(placer.getFluidHeight(anyInt(), anyInt())).thenReturn(0); + when(placer.isUnderwater(anyInt(), anyInt())).thenReturn(options.fluidDepth() > 0); + when(placer.get(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + SubterrainPosition position = new SubterrainPosition(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)); + NativeBlockState written = writes.get(position); + if (written != null) { + return written; + } + SubterrainCell cell = planner.sample(position.x(), position.y(), position.z()); + return state(cell.owned() ? cell.material() : "minecraft:stone"); + }); + when(placer.isCarved(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> + planner.sample(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)).occupied()); + when(placer.isSolid(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> + placer.get(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)).isSolid()); + when(placer.isSurfaceSolid(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> + placer.isSolid(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2))); + doAnswer(invocation -> { + writes.put(new SubterrainPosition(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)), invocation.getArgument(3)); + return null; + }).when(placer).set(anyInt(), anyInt(), anyInt(), any()); + doAnswer(invocation -> { + metadata.put(new SubterrainPosition(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)), invocation.getArgument(3)); + return null; + }).when(placer).setData(anyInt(), anyInt(), anyInt(), any()); + return new Fixture(planner, placer, data, room, x, z, floorY, writes, metadata); + } + + private NativeBlockState state(String key) { + NativeBlockState cached = states.get(key); + if (cached != null) { + return cached; + } + NativeBlockState block = mock(NativeBlockState.class); + int properties = key.indexOf('['); + String material = properties < 0 ? key : key.substring(0, properties); + boolean water = material.equals("minecraft:water"); + boolean lava = material.equals("minecraft:lava"); + boolean air = material.endsWith("air"); + when(block.key()).thenReturn(key); + when(block.materialKey()).thenReturn(material); + when(block.isSolid()).thenReturn(!air && !water && !lava); + when(block.isOccluding()).thenReturn(!air && !water && !lava); + when(block.isAir()).thenReturn(air); + when(block.isFluid()).thenReturn(water || lava); + when(block.isWater()).thenReturn(water); + when(block.isAirOrFluid()).thenReturn(air || water || lava); + when(block.isWaterLogged()).thenReturn(key.contains("waterlogged=true")); + when(block.withProperty(anyString(), anyString())).thenAnswer(invocation -> + state(BlockStateKey.withProperty(key, invocation.getArgument(0), invocation.getArgument(1)))); + states.put(key, block); + return block; + } + + private record FixtureOptions(IrisSubterrainFluid fluid, int fluidDepth) { + } + + private record Fixture(SubterrainPlanner planner, IObjectPlacer placer, IrisData data, SubterrainRoom room, + int x, int z, int floorY, Map writes, + Map metadata) { + } +} 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 9ebf6ad43..addaff5dd 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 @@ -1,6 +1,9 @@ package art.arcane.iris.generation.mantle; import art.arcane.iris.integration.Identifier; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainRoom; +import art.arcane.iris.generation.subterrain.IrisSubterrainFamily; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; @@ -48,6 +51,21 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class ObjectDestinationTransactionTest { + @Test + public void sourceCaptureCannotReplaceAuthoredRoomSolids() { + MantleWriter writer = writer(); + SubterrainRoom room = new SubterrainRoom("cenote", "wet-cave", IrisSubterrainFamily.CENOTE, + 4, 35, 7, 4, 21, 7, 19, 61, 8, 24, false, true, SubterrainCell.Kind.SOLID); + when(writer.getEngine().getSubterrainCell(4, 30, 7)) + .thenReturn(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room)); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + int checkpoint = transaction.mutationCheckpoint(); + transaction.set(4, 30, 7, mock(NativeBlockState.class)); + transaction.setData(4, 30, 7, "object@1"); + transaction.setData(4, 30, 7, new MatterCavern(true, "", (byte) 3)); + assertEquals(checkpoint, transaction.mutationCheckpoint()); + } + @Test public void commonAndGenericMetadataSurviveBlockReplacementAndSourceReplay() { MantleWriter writer = writer(); 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 c98fbac04..1db758460 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 @@ -248,6 +248,7 @@ public class DimensionTerrain3DCompositionTest { Engine engine = mock(Engine.class, CALLS_REAL_METHODS); IrisComplex complex = mock(IrisComplex.class); doReturn(complex).when(engine).getComplex(); + doReturn(new IrisDimension()).when(engine).getDimension(); DimensionStackContext stack = mock(DimensionStackContext.class); doReturn(stack).when(engine).getDimensionStackContext(); DimensionStackLayout.LayerInput bottom = input(24, 0, diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java index f7281edf0..2e351e5c1 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java @@ -893,7 +893,7 @@ public class IrisEngineGenerationRuntimeScopeTest { private static IrisBiome carvingBiome(IrisEngine engine, IrisDimensionCarvingResolver.State state) { IrisDimensionCarvingEntry root = IrisDimensionCarvingResolver.resolveRootEntry(engine, 80, state); - IrisDimensionCarvingEntry child = IrisDimensionCarvingResolver.resolveFromRoot(engine, root, 19, -3, state); + IrisDimensionCarvingEntry child = IrisDimensionCarvingResolver.resolveFromRoot(engine, root, 19, 80, -3, state); IrisBiome expected = IrisDimensionCarvingResolver.resolveEntryBiome(engine, child, state); assertSame(expected, IrisDimensionCarvingResolver.resolveBiome(engine, 19, 80, -3, state)); return expected; diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/SubterrainBiomeOwnershipTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/SubterrainBiomeOwnershipTest.java new file mode 100644 index 000000000..c22ca1dc2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/SubterrainBiomeOwnershipTest.java @@ -0,0 +1,64 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.world.IrisWorld; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +public class SubterrainBiomeOwnershipTest { + @Test + public void occupiedFeatureOwnsExactVolumeAndYWithoutSurfaceDepthGate() { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("basin-biome").setProbability(1); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320)); + SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst(); + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + IrisComplex complex = mock(IrisComplex.class); + IrisData data = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader loader = mock(ResourceLoader.class); + IrisBiome biome = new IrisBiome(); + IrisDimension dimension = new IrisDimension().setSubterrainFeatures(new KList<>(feature)); + doReturn(dimension).when(engine).getDimension(); + doReturn(complex).when(engine).getComplex(); + doReturn(planner).when(complex).getSubterrainPlanner(); + doReturn(data).when(engine).getData(); + doReturn(mock(DimensionStackContext.class)).when(engine).getDimensionStackContext(); + doReturn(loader).when(data).getBiomeLoader(); + doReturn(biome).when(loader).load("basin-biome"); + doReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()).when(engine).getWorld(); + SubterrainPosition anchor = plan.anchor(); + int internalY = anchor.y() + 64; + assertSame(biome, engine.getBiome(anchor.x(), internalY, anchor.z())); + assertSame(biome, engine.getBiomeOrMantle(anchor.x(), internalY, anchor.z())); + assertEquals(planner.sample(anchor.x(), anchor.y(), anchor.z()), engine.getSubterrainCell(anchor.x(), internalY, anchor.z())); + assertNull(engine.getSubterrainBiome(anchor.x(), plan.bounds().maxY() + 65, anchor.z())); + assertNull(engine.getSubterrainBiome(plan.bounds().maxX() + 1, internalY, anchor.z())); + for (int y = plan.bounds().minY(); y <= plan.bounds().maxY(); y++) { + SubterrainCell cell = planner.sample(anchor.x(), y, anchor.z()); + if (cell.solid()) { + assertNull(engine.getSubterrainBiome(anchor.x(), y + 64, anchor.z())); + } + } + verify(engine, never()).getMantle(); + verify(engine, never()).getDimensionStackContext(); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerInitialSeedTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerInitialSeedTest.java index 88b65137d..ed762500a 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerInitialSeedTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerInitialSeedTest.java @@ -9,13 +9,19 @@ import art.arcane.iris.world.entity.IrisEntity; import art.arcane.iris.world.entity.IrisEntitySpawn; import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext; import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.iris.world.entity.IrisSpawnGroup; +import art.arcane.iris.generation.decoration.IrisSurface; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.task.J; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import org.bukkit.Chunk; +import org.bukkit.Location; import org.bukkit.GameRules; import org.bukkit.World; import org.junit.Test; import org.junit.BeforeClass; +import org.mockito.MockedStatic; import java.lang.reflect.Method; import java.util.HashMap; @@ -31,6 +37,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.mockStatic; public class WorldEntitySpawnerInitialSeedTest { @BeforeClass @@ -52,6 +60,38 @@ public class WorldEntitySpawnerInitialSeedTest { verify(fixture.lastChunk, never()).getEntities(); } + @Test + public void successfulUndergroundPopulationReducesSurfaceCapacityWithoutCountingFailures() throws Exception { + for (int undergroundSuccesses : new int[]{0, 2, 4}) { + Fixture fixture = new Fixture(); + IrisEntitySpawn undergroundEntry = mock(IrisEntitySpawn.class); + IrisEntity entity = mock(IrisEntity.class); + when(entity.getSurface()).thenReturn(IrisSurface.LAND); + when(entity.spawnCategory()).thenReturn("creature"); + when(undergroundEntry.getRealEntity(fixture.engine)).thenReturn(entity); + when(undergroundEntry.getRarity()).thenReturn(1); + IrisSpawner cave = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE) + .setMaxEntitiesPerChunk(4).setInitialSpawns(new KList<>(undergroundEntry)); + when(fixture.environment.data().getSpawnerLoader().loadAll(fixture.environment.biome().getEntitySpawners())) + .thenReturn(new KList<>(fixture.definition, cave)); + when(fixture.environment.dimension().hasUndergroundSpawners(fixture.engine)).thenReturn(true); + when(fixture.engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()); + when(fixture.engine.getBiomeOrMantleEnvironment(anyInt(), anyInt(), anyInt())).thenReturn(fixture.environment); + when(fixture.engine.openBiomeEnvironmentScope(fixture.environment)).thenReturn(mock(BiomeEnvironment.Scope.class)); + Location candidate = new Location(fixture.world, 35, -20, -45); + when(undergroundEntry.spawn(eq(fixture.engine), eq(candidate), any(RNG.class), eq(4), any(SpawnContext.class))) + .thenReturn(undergroundSuccesses); + try (MockedStatic scheduler = mockStatic(J.class); + MockedStatic positions = mockStatic(IrisEntitySpawn.class)) { + positions.when(() -> IrisEntitySpawn.findLiveCaveSpawnLocation(any(Chunk.class), any(RNG.class), eq(IrisSurface.LAND))) + .thenReturn(candidate); + fixture.spawn(2, -3); + } + assertEquals(4 - undergroundSuccesses, fixture.observed.get("2,-3").capacity()); + verify(fixture.lastChunk, never()).getEntities(); + } + } + @Test public void chunkBatchSeedsAreIndependentOfVisitOrderAndUnrelatedRandomActivity() throws Exception { Fixture fixture = new Fixture(); @@ -86,7 +126,7 @@ public class WorldEntitySpawnerInitialSeedTest { private final Engine engine = mock(Engine.class); private final IrisWorldManager manager = mock(IrisWorldManager.class); private final World world = mock(World.class); - private final IrisSpawner definition = mock(IrisSpawner.class); + private final IrisSpawner definition = spy(new IrisSpawner().setMaxEntitiesPerChunk(4)); private final IrisEntitySpawn entry = mock(IrisEntitySpawn.class); private final Map observed = new HashMap<>(); private final BiomeEnvironment environment; @@ -114,7 +154,9 @@ public class WorldEntitySpawnerInitialSeedTest { when(definition.getInitialSpawns()).thenReturn(new KList<>(entry)); when(definition.getMaxEntitiesPerChunk()).thenReturn(4); when(entry.getRarity()).thenReturn(1); - when(entry.getRealEntity(engine)).thenReturn(mock(IrisEntity.class)); + IrisEntity entity = mock(IrisEntity.class); + when(entity.spawnCategory()).thenReturn("creature"); + when(entry.getRealEntity(engine)).thenReturn(entity); when(world.getGameRuleValue(GameRules.SPAWN_MOBS)).thenReturn(true); when(entry.spawn(eq(engine), any(Chunk.class), any(RNG.class), anyInt(), any(SpawnContext.class))) .thenAnswer(invocation -> { @@ -123,7 +165,7 @@ public class WorldEntitySpawnerInitialSeedTest { int capacity = invocation.getArgument(3); SpawnContext context = invocation.getArgument(4); observed.put(chunk.getX() + "," + chunk.getZ(), new Observation(rng.getSeed(), capacity, context.initial())); - return 4; + return capacity; }); environment = new BiomeEnvironment(1L, biome, region, dimension, data); spawner = new WorldEntitySpawner(manager); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerUndergroundOwnershipTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerUndergroundOwnershipTest.java new file mode 100644 index 000000000..435c2c631 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerUndergroundOwnershipTest.java @@ -0,0 +1,116 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.decoration.IrisSurface; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.world.entity.IrisEntity; +import art.arcane.iris.world.entity.IrisEntitySpawn; +import art.arcane.iris.world.entity.IrisSpawnGroup; +import art.arcane.iris.world.entity.IrisSpawner; +import art.arcane.iris.world.task.J; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.junit.Assert.assertSame; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class WorldEntitySpawnerUndergroundOwnershipTest { + @Test + public void cavePoolUsesActualCandidateBiomeAndPlacesInsideItsVolume() throws Exception { + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + IrisWorldManager manager = mock(IrisWorldManager.class); + when(manager.getEngine()).thenReturn(engine); + doReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()).when(engine).getWorld(); + World world = mock(World.class); + Chunk chunk = mock(Chunk.class); + when(chunk.getWorld()).thenReturn(world); + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("basin-biome").setProbability(1); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320)); + SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst(); + SubterrainPosition anchor = plan.anchor(); + Location candidate = new Location(world, anchor.x(), anchor.y(), anchor.z()); + IrisBiome underground = new IrisBiome(); + IrisDimension dimension = new IrisDimension().setSubterrainFeatures(new KList<>(feature)); + IrisRegion region = new IrisRegion().setEntitySpawners(new KList<>("cave-residents")); + IrisRegion stackedRegion = new IrisRegion().setEntitySpawners(new KList<>("wrong-pack-residents")); + IrisComplex complex = mock(IrisComplex.class); + DimensionStackContext stack = mock(DimensionStackContext.class); + DimensionStackLayout layout = mock(DimensionStackLayout.class); + DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class); + @SuppressWarnings("unchecked") + ProceduralStream regionStream = mock(ProceduralStream.class); + doReturn(complex).when(engine).getComplex(); + doReturn(dimension).when(engine).getDimension(); + doReturn(planner).when(complex).getSubterrainPlanner(); + doReturn(regionStream).when(complex).getRegionStream(); + when(regionStream.get(anchor.x(), anchor.z())).thenReturn(region); + doReturn(stack).when(engine).getDimensionStackContext(); + when(stack.getLayout(anchor.x(), anchor.z())).thenReturn(layout); + when(layout.layerAt(anyInt())).thenReturn(layer); + when(layer.region()).thenReturn(stackedRegion); + IrisData data = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader loader = mock(ResourceLoader.class); + doReturn(data).when(engine).getData(); + @SuppressWarnings("unchecked") + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(biomeLoader.load("basin-biome")).thenReturn(underground); + when(data.getSpawnerLoader()).thenReturn(loader); + when(loader.loadAll(dimension.getEntitySpawners())).thenReturn(new KList<>()); + when(loader.loadAll(underground.getEntitySpawners())).thenReturn(new KList<>()); + IrisEntitySpawn entry = mock(IrisEntitySpawn.class); + IrisEntity entity = mock(IrisEntity.class); + when(entity.getSurface()).thenReturn(IrisSurface.LAND); + when(entry.getRealEntity(engine)).thenReturn(entity); + when(entry.getRarity()).thenReturn(1); + IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE).setInitialSpawns(new KList<>(entry)); + when(loader.loadAll(region.getEntitySpawners())).thenReturn(new KList<>(spawner)); + BiomeEnvironment environment = engine.getBiomeOrMantleEnvironment(anchor.x(), anchor.y() + 64, anchor.z()); + assertSame(underground, environment.biome()); + assertSame(region, environment.region()); + BiomeEnvironment.Scope scope = mock(BiomeEnvironment.Scope.class); + when(engine.openBiomeEnvironmentScope(environment)).thenReturn(scope); + try (MockedStatic scheduler = mockStatic(J.class); + MockedStatic positions = mockStatic(IrisEntitySpawn.class)) { + positions.when(() -> IrisEntitySpawn.findLiveCaveSpawnLocation(eq(chunk), any(RNG.class), eq(IrisSurface.LAND))) + .thenReturn(candidate); + Method method = WorldEntitySpawner.class.getDeclaredMethod("spawnUnderground", + Chunk.class, boolean.class, WorldEntitySpawner.ChunkCounter.class, RNG.class); + method.setAccessible(true); + method.invoke(new WorldEntitySpawner(manager), chunk, true, new WorldEntitySpawner.ChunkCounter(new org.bukkit.entity.Entity[0]), new RNG(17719L)); + } + verify(loader, never()).loadAll(stackedRegion.getEntitySpawners()); + verify(engine, never()).getSurfaceBiomeEnvironment(anchor.x(), anchor.z()); + assertSame(stackedRegion, engine.getRegion(anchor.x(), plan.bounds().maxY() + 65, anchor.z())); + verify(entry).spawn(eq(engine), eq(candidate), any(RNG.class), eq(spawner.getMaxEntitiesPerChunk()), + any(IrisEntitySpawn.SpawnContext.class)); + verify(scope).close(); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java new file mode 100644 index 000000000..f1ca9ded1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java @@ -0,0 +1,382 @@ +package art.arcane.iris.generation.subterrain; + +import art.arcane.iris.pack.value.IrisRange; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class SubterrainPlannerTest { + @Test + public void terraceAnchorsUseOccupiedPathsWhenCenterIsPillarOrUpperStep() { + for (IrisSubterrainFluid fluid : IrisSubterrainFluid.values()) { + IrisSubterrainFeature feature = compactFeature(IrisSubterrainFamily.TRAVERTINE_TERRACES).setFluid(fluid); + SubterrainPlan pillar = direct(feature); + for (int y = pillar.bounds().minY(); y <= pillar.bounds().maxY(); y++) { + assertFalse(pillar.sample(0, y, 0).occupied()); + } + SubterrainPosition pillarAnchor = pillar.anchor(); + assertTrue(pillar.sample(pillarAnchor.x(), pillarAnchor.y(), pillarAnchor.z()).occupied()); + assertTrue(pillar.sample(pillarAnchor.x(), pillarAnchor.y(), pillarAnchor.z()).room().reservedPassage()); + feature.setHeight(8).setFluidDepth(2).setTerraceCount(16); + for (boolean rotated : List.of(false, true)) { + SubterrainPlan lowVault = new SubterrainPlan(new SubterrainPlan.Options( + SubterrainPlanner.Definition.from(feature), 0, 0, 0, rotated, "terrace:low-vault")); + SubterrainPosition anchor = lowVault.anchor(); + SubterrainCell cell = lowVault.sample(anchor.x(), anchor.y(), anchor.z()); + assertTrue(cell.occupied()); + assertTrue(cell.room().reservedPassage()); + assertTrue((rotated ? anchor.z() : anchor.x()) < 0); + assertEquals(anchor, lowVault.anchor()); + } + } + } + + @Test + public void explicitFluidChangesOnlyLiquidKindAndMaterialForEveryWetFamily() { + for (IrisSubterrainFamily family : List.of(IrisSubterrainFamily.CENOTE, + IrisSubterrainFamily.LAVA_TUBE, IrisSubterrainFamily.TRAVERTINE_TERRACES)) { + IrisSubterrainFeature water = compactFeature(family).setFluid(IrisSubterrainFluid.WATER); + IrisSubterrainFeature lava = compactFeature(family).setFluid(IrisSubterrainFluid.LAVA); + SubterrainPlanner waterPlanner = planner(water); + SubterrainPlanner lavaPlanner = planner(lava); + SubterrainPlan waterPlaced = first(waterPlanner); + SubterrainPlan lavaPlaced = first(lavaPlanner); + assertEquals(waterPlaced.id(), lavaPlaced.id()); + assertEquals(waterPlaced.bounds(), lavaPlaced.bounds()); + assertEquals(waterPlaced.anchor(), lavaPlaced.anchor()); + SubterrainPlan waterPlan = direct(water); + SubterrainPlan lavaPlan = direct(lava); + SubterrainBounds bounds = waterPlan.bounds(); + int fluidCells = 0; + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + for (int y = bounds.minY(); y <= bounds.maxY(); y++) { + SubterrainCell original = waterPlanner.sample(List.of(waterPlan), x, y, z); + SubterrainCell changed = lavaPlanner.sample(List.of(lavaPlan), x, y, z); + if (original.fluid()) { + fluidCells++; + assertEquals(SubterrainCell.Kind.WATER, original.kind()); + assertEquals("minecraft:water", original.material()); + assertEquals(SubterrainCell.Kind.LAVA, changed.kind()); + assertEquals("minecraft:lava", changed.material()); + SubterrainRoom room = original.room(); + assertEquals(new SubterrainRoom(room.featureId(), room.biome(), room.family(), + room.centerX(), room.centerY(), room.centerZ(), room.pathX(), room.pathY(), room.pathZ(), + room.floorY(), room.ceilingY(), room.boundaryDistance(), room.fluidHeadY(), + room.reservedPassage(), room.reservedSolid(), SubterrainCell.Kind.LAVA), changed.room()); + } else { + assertEquals(original, changed); + } + } + } + } + assertTrue(family.toString(), fluidCells > 100); + assertEquals(assertRetained(waterPlanner, List.of(waterPlan), SubterrainCell.Kind.WATER), + assertRetained(lavaPlanner, List.of(lavaPlan), SubterrainCell.Kind.LAVA)); + } + } + + @Test + public void omittedAndNullFluidsPreserveFamilyDefaults() { + Gson gson = new Gson(); + for (IrisSubterrainFamily family : IrisSubterrainFamily.values()) { + IrisSubterrainFluid expected = family == IrisSubterrainFamily.LAVA_TUBE + ? IrisSubterrainFluid.LAVA : IrisSubterrainFluid.WATER; + IrisSubterrainFeature omitted = gson.fromJson("{\"family\":\"" + family + "\"}", IrisSubterrainFeature.class); + IrisSubterrainFeature nullable = gson.fromJson("{\"family\":\"" + family + "\",\"fluid\":null}", IrisSubterrainFeature.class); + assertNull(omitted.getFluid()); + assertNull(nullable.getFluid()); + omitted.setId("default"); + nullable.setId("default"); + assertEquals(expected, SubterrainPlanner.Definition.from(omitted).fluid()); + assertEquals(SubterrainPlanner.Definition.from(omitted), SubterrainPlanner.Definition.from(nullable)); + SubterrainPlan implicit = direct(feature(family)); + SubterrainPlan explicit = direct(feature(family).setFluid(expected)); + assertEquals(implicit.bounds(), explicit.bounds()); + for (int x = implicit.bounds().minX(); x <= implicit.bounds().maxX(); x += 7) { + for (int z = implicit.bounds().minZ(); z <= implicit.bounds().maxZ(); z += 7) { + for (int y = implicit.bounds().minY(); y <= implicit.bounds().maxY(); y++) { + assertEquals(implicit.sample(x, y, z), explicit.sample(x, y, z)); + } + } + } + } + } + + @Test + public void faultRemainsDryForEveryConfiguredFluid() { + SubterrainPlan original = direct(feature(IrisSubterrainFamily.TECTONIC_FAULT)); + for (IrisSubterrainFluid fluid : IrisSubterrainFluid.values()) { + SubterrainPlan configured = direct(feature(IrisSubterrainFamily.TECTONIC_FAULT).setFluid(fluid)); + for (int x = original.bounds().minX(); x <= original.bounds().maxX(); x += 2) { + for (int z = original.bounds().minZ(); z <= original.bounds().maxZ(); z += 2) { + for (int y = original.bounds().minY(); y <= original.bounds().maxY(); y++) { + SubterrainCell cell = configured.sample(x, y, z); + assertFalse(cell.fluid()); + assertEquals(original.sample(x, y, z), cell); + } + } + } + } + } + + @Test + public void plansSnapshotResolvedFluidAcrossConfigurationMutation() { + IrisSubterrainFeature feature = compactFeature(IrisSubterrainFamily.CENOTE); + SubterrainPlan implicit = direct(feature); + feature.setFluid(IrisSubterrainFluid.LAVA); + SubterrainPlan explicit = direct(feature); + feature.setFluid(IrisSubterrainFluid.WATER); + assertEquals(SubterrainCell.Kind.WATER, implicit.sample(0, -10, 0).kind()); + assertEquals(SubterrainCell.Kind.LAVA, explicit.sample(0, -10, 0).kind()); + } + + @Test + public void fluidJsonOnlyAcceptsTypedWaterOrLava() { + Gson gson = new Gson(); + for (IrisSubterrainFluid fluid : IrisSubterrainFluid.values()) { + assertEquals(fluid, gson.fromJson("{\"fluid\":\"" + fluid + "\"}", IrisSubterrainFeature.class).getFluid()); + assertEquals("\"" + fluid + "\"", gson.toJson(fluid)); + } + for (String invalid : List.of("\"OIL\"", "\"minecraft:water\"", "\"water\"", "5", "true", "{}", "[]")) { + assertThrows(invalid, JsonParseException.class, + () -> gson.fromJson("{\"fluid\":" + invalid + "}", IrisSubterrainFeature.class)); + } + } + + @Test + public void allFamiliesHaveBoundedOccupiedAnchorsAndAbsoluteOwnership() { + for (IrisSubterrainFamily family : IrisSubterrainFamily.values()) { + SubterrainPlanner planner = planner(feature(family)); + SubterrainPlan plan = first(planner); + SubterrainPosition anchor = plan.anchor(); + assertTrue(family.toString(), plan.sample(anchor.x(), anchor.y(), anchor.z()).occupied()); + assertTrue(plan.bounds().minY() >= -64); + assertTrue(plan.bounds().maxY() <= 96); + assertFalse(planner.sample(anchor.x(), -65, anchor.z()).owned()); + assertFalse(planner.sample(anchor.x(), 97, anchor.z()).owned()); + assertFalse(plan.sample(plan.bounds().maxX() + 1, anchor.y(), anchor.z()).owned()); + assertNotNull(plan.sample(anchor.x(), anchor.y(), anchor.z()).room()); + } + } + + @Test + public void faultWallsRemainParallelBeyondTwoHundredBlocksAndHaveShelves() { + SubterrainPlan plan = direct(feature(IrisSubterrainFamily.TECTONIC_FAULT)); + for (int x = -110; x <= 110; x++) { + assertTrue(plan.sample(x, 0, 0).occupied()); + assertTrue(plan.sample(x, 0, 32).solid()); + assertTrue(plan.sample(x, 0, -32).solid()); + } + int centralFloor = plan.sample(0, 0, 0).room().floorY(); + int ledgeFloor = plan.sample(0, 0, 28).room().floorY(); + assertTrue(ledgeFloor > centralFloor); + assertTrue(plan.sample(0, ledgeFloor, 28).solid()); + } + + @Test + public void cenoteDomeAndSealedWaterTableHaveNoExposedFluidSidesOrBottom() { + SubterrainPlan plan = direct(feature(IrisSubterrainFamily.CENOTE)); + SubterrainPlanner planner = planner(feature(IrisSubterrainFamily.CENOTE)); + assertTrue(plan.sample(0, 0, 0).room().ceilingY() > plan.sample(20, 0, 0).room().ceilingY()); + assertTrue(assertRetained(planner, List.of(plan), SubterrainCell.Kind.WATER) > 1000); + assertTrue(plan.sample(0, -16, 0).solid()); + } + + @Test + public void lavaTubeHasConnectedHornitosAndDryElevatedWalkways() { + SubterrainPlan plan = direct(feature(IrisSubterrainFamily.LAVA_TUBE)); + int chimneyX = 64; + int pathZ = 5; + for (int y = 0; y < 32; y++) { + assertEquals("chimney y=" + y, SubterrainCell.Kind.AIR, plan.sample(chimneyX, y, pathZ).kind()); + } + assertTrue(plan.sample(chimneyX + 4, 25, pathZ).solid()); + SubterrainCell walkway = plan.sample(0, 0, 18); + assertTrue(walkway.room().floorY() > walkway.room().fluidHeadY()); + assertTrue(plan.sample(0, walkway.room().floorY(), 18).solid()); + SubterrainPlanner planner = planner(feature(IrisSubterrainFamily.LAVA_TUBE)); + assertTrue(assertRetained(planner, List.of(plan), SubterrainCell.Kind.LAVA) > 1000); + } + + @Test + public void travertineBasinsStepAndRetainFluidBehindRims() { + SubterrainPlan plan = direct(feature(IrisSubterrainFamily.TRAVERTINE_TERRACES)); + assertTrue(plan.sample(90, 0, 0).room().floorY() > plan.sample(-90, 0, 0).room().floorY()); + SubterrainPlanner planner = planner(feature(IrisSubterrainFamily.TRAVERTINE_TERRACES)); + assertTrue(assertRetained(planner, List.of(plan), SubterrainCell.Kind.WATER) > 1000); + int rimX = -128 + 42; + assertTrue(plan.sample(rimX, -10, 0).solid()); + assertTrue(plan.sample(0, 0, 28).room().floorY() > plan.sample(0, 0, 28).room().fluidHeadY()); + } + + @Test + public void pillarsSpanTheWholeVaultAndFormationsScaleWithVault() { + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE).setPillarSpacing(12).setFormationFraction(0.3); + SubterrainPlan plan = direct(feature); + SubterrainRoom room = plan.sample(12, 0, 12).room(); + for (int y = room.floorY(); y <= room.ceilingY(); y++) { + assertTrue("pillar y=" + y, plan.sample(12, y, 12).solid()); + } + assertTrue(plan.sample(0, 0, 0).room().reservedPassage()); + assertFalse(plan.sample(0, 0, 0).solid()); + int formationCells = 0; + for (int x = -20; x <= 20; x++) { + for (int z = -20; z <= 20; z++) { + SubterrainCell middle = plan.sample(x, 0, z); + if (!middle.occupied()) { + continue; + } + SubterrainRoom context = middle.room(); + int quarter = Math.max(1, context.vaultHeight() / 4); + if (plan.sample(x, context.ceilingY() - quarter, z).solid()) { + formationCells++; + } + } + } + assertTrue(formationCells > 0); + } + + @Test + public void configOrderAndChunkTraversalDoNotChangeIdentityOrCells() { + IrisSubterrainFeature cenote = feature(IrisSubterrainFamily.CENOTE); + IrisSubterrainFeature fault = feature(IrisSubterrainFamily.TECTONIC_FAULT); + List features = new ArrayList<>(List.of(cenote, fault)); + SubterrainPlanner first = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97)); + Collections.reverse(features); + SubterrainPlanner reordered = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97)); + List plans = first.plansForBounds(-1024, -1024, 1024, 1024); + assertEquals(plans.stream().map(SubterrainPlan::id).toList(), + reordered.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList()); + for (SubterrainPlan plan : plans) { + for (int x = plan.bounds().minX(); x <= plan.bounds().maxX(); x += 13) { + for (int z = plan.bounds().minZ(); z <= plan.bounds().maxZ(); z += 13) { + int chunkX = Math.floorDiv(x, 16) * 16; + int chunkZ = Math.floorDiv(z, 16) * 16; + List chunk = first.plansForBounds(chunkX - 1, chunkZ - 1, chunkX + 16, chunkZ + 16); + for (int y = plan.bounds().minY(); y <= plan.bounds().maxY(); y += 7) { + assertEquals(first.sample(x, y, z), first.sample(chunk, x, y, z)); + assertEquals(first.sample(x, y, z), reordered.sample(x, y, z)); + } + } + } + } + } + + @Test + public void immutableSnapshotsAndSeedsControlPlans() { + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE); + SubterrainPlanner planner = planner(feature); + List before = planner.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList(); + feature.setRadius(8).setBiome("changed").setId("changed"); + assertEquals(before, planner.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList()); + assertEquals("subterranean/example", first(planner).biome()); + SubterrainPlanner anotherSeed = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 999, -64, 97)); + assertNotEquals(before, anotherSeed.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList()); + } + + @Test + public void narrowYBandNeverClipsSealsIntoWorld() { + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE).setWorldYRange(new IrisRange(0, 10)); + assertTrue(planner(feature).plansForBounds(-2048, -2048, 2048, 2048).isEmpty()); + } + + @Test + public void solidSealsWinOverHigherPriorityUnrelatedCarving() { + SubterrainPlan lower = direct(feature(IrisSubterrainFamily.CENOTE)); + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.TECTONIC_FAULT).setPriority(100); + SubterrainPlanner.Definition definition = SubterrainPlanner.Definition.from(feature); + SubterrainPlan upper = new SubterrainPlan(new SubterrainPlan.Options(definition, 32, 0, 0, false, "fault:other")); + SubterrainPlanner planner = planner(feature); + assertTrue(upper.sample(32, -14, 0).occupied()); + assertTrue(lower.sample(32, -14, 0).solid()); + assertTrue(planner.sample(List.of(upper, lower), 32, -14, 0).solid()); + } + + @Test(expected = IllegalArgumentException.class) + public void shortFaultDefinitionsAreRejected() { + planner(feature(IrisSubterrainFamily.TECTONIC_FAULT).setLength(120)); + } + + @Test + public void footprintAdmissionRejectsTheEntireFeatureAcrossSavedOwnership() { + SubterrainPlanner original = planner(feature(IrisSubterrainFamily.CENOTE)); + SubterrainPlan plan = first(original); + SubterrainPosition anchor = plan.anchor(); + int protectedX = plan.bounds().maxX(); + int protectedZ = plan.bounds().maxZ(); + SubterrainPlanner restricted = original.restrictTo(bounds -> + !bounds.intersects(protectedX, protectedZ, protectedX, protectedZ)); + assertTrue(original.sample(anchor.x(), anchor.y(), anchor.z()).occupied()); + assertFalse(restricted.plansForBounds(plan.bounds().minX(), plan.bounds().minZ(), + plan.bounds().maxX(), plan.bounds().maxZ()).stream().anyMatch(candidate -> candidate.id().equals(plan.id()))); + assertFalse(restricted.sample(anchor.x(), anchor.y(), anchor.z()).owned()); + assertTrue(original.sample(anchor.x(), anchor.y(), anchor.z()).occupied()); + } + + @Test(expected = IllegalArgumentException.class) + public void duplicateIdentifiersAreRejected() { + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE); + new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature, feature), 1, -64, 97)); + } + + private int assertRetained(SubterrainPlanner planner, List candidates, SubterrainCell.Kind kind) { + int count = 0; + SubterrainBounds bounds = candidates.getFirst().bounds(); + int[][] offsets = {{-1, 0, 0}, {1, 0, 0}, {0, 0, -1}, {0, 0, 1}, {0, -1, 0}}; + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + for (int y = bounds.minY(); y <= bounds.maxY(); y++) { + SubterrainCell cell = planner.sample(candidates, x, y, z); + if (cell.kind() != kind) { + continue; + } + count++; + for (int[] offset : offsets) { + SubterrainCell neighbor = planner.sample(candidates, x + offset[0], y + offset[1], z + offset[2]); + assertTrue("fluid at " + x + "," + y + "," + z, neighbor.solid() || neighbor.kind() == kind); + } + } + } + } + return count; + } + + private IrisSubterrainFeature feature(IrisSubterrainFamily family) { + return new IrisSubterrainFeature().setId(family.name().toLowerCase()).setFamily(family) + .setProbability(1).setWorldYRange(new IrisRange(-64, 96)).setBiome("subterranean/example") + .setPillarSpacing(0).setFormationFraction(0); + } + + private IrisSubterrainFeature compactFeature(IrisSubterrainFamily family) { + return feature(family).setLength(64).setRadius(12).setHeight(24).setChimneyHeight(8) + .setPillarSpacing(12).setFormationFraction(0.2); + } + + private SubterrainPlanner planner(IrisSubterrainFeature feature) { + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 7123, -64, 97)); + } + + private SubterrainPlan first(SubterrainPlanner planner) { + List plans = planner.plansForBounds(-1024, -1024, 1024, 1024); + assertFalse(plans.isEmpty()); + return plans.getFirst(); + } + + private SubterrainPlan direct(IrisSubterrainFeature feature) { + return new SubterrainPlan(new SubterrainPlan.Options(SubterrainPlanner.Definition.from(feature), + 0, 0, 0, false, feature.getId() + ":test")); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRasterizerTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRasterizerTest.java new file mode 100644 index 000000000..041f8b14c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRasterizerTest.java @@ -0,0 +1,129 @@ +package art.arcane.iris.generation.subterrain; + +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.iris.generation.block.B; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.mockito.MockedStatic; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class SubterrainRasterizerTest { + @Test + public void lavaOverridePublishesLavaBlocksAndCavernStateForCenotesAndTerraces() { + NativeBlockState lava = mock(NativeBlockState.class); + when(lava.key()).thenReturn("minecraft:lava"); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getState("minecraft:lava")).thenReturn(lava); + for (IrisSubterrainFamily family : List.of(IrisSubterrainFamily.CENOTE, IrisSubterrainFamily.TRAVERTINE_TERRACES)) { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("lava-room").setFamily(family) + .setFluid(IrisSubterrainFluid.LAVA).setProbability(1).setLength(64).setRadius(12) + .setHeight(24).setPillarSpacing(0).setFormationFraction(0) + .setBiome("underground/lava").setWorldYRange(new IrisRange(-48, 60)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128)); + SubterrainPlan plan = planner.plansForBounds(-512, -512, 512, 512).getFirst(); + int[] fluids = {0}; + SubterrainRasterizer.rasterize(planner, plan.centerX() >> 4, plan.centerZ() >> 4, (x, y, z, cell) -> { + if (cell.fluid()) { + fluids[0]++; + assertEquals(SubterrainCell.Kind.LAVA, cell.kind()); + assertEquals("minecraft:lava", cell.material()); + assertEquals(lava, SubterrainRasterizer.state(cell)); + MatterCavern cavern = SubterrainRasterizer.cavern(cell); + assertEquals(2, cavern.getLiquid()); + assertTrue(cavern.isLava()); + assertFalse(cavern.isWater()); + assertEquals("underground/lava", cavern.getCustomBiome()); + assertEquals(cell, planner.sample(x, y, z)); + } + }); + assertTrue(family.toString(), fluids[0] > 0); + } + } + } + + @Test + public void sealingMaterialRejectsPartialAndUnresolvedBlocks() { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("sealed-room").setSolid("minecraft:oak_slab"); + NativeBlockState partial = mock(NativeBlockState.class); + when(partial.isSolid()).thenReturn(true); + try (MockedStatic blocks = mockStatic(B.class)) { + blocks.when(() -> B.getStateOrNull("minecraft:oak_slab", false)).thenReturn(partial); + assertThrows(IllegalArgumentException.class, () -> SubterrainRasterizer.validateMaterials(List.of(feature))); + feature.setSolid("minecraft:missing_block"); + assertThrows(IllegalArgumentException.class, () -> SubterrainRasterizer.validateMaterials(List.of(feature))); + NativeBlockState full = mock(NativeBlockState.class); + when(full.key()).thenReturn("minecraft:stone"); + when(full.isSolid()).thenReturn(true); + when(full.isOccluding()).thenReturn(true); + feature.setSolid("minecraft:stone"); + blocks.when(() -> B.getStateOrNull("minecraft:stone", false)).thenReturn(full); + SubterrainRasterizer.validateMaterials(List.of(feature)); + when(full.isWaterLogged()).thenReturn(true); + assertThrows(IllegalArgumentException.class, () -> SubterrainRasterizer.validateMaterials(List.of(feature))); + when(full.isWaterLogged()).thenReturn(false); + when(full.key()).thenReturn("minecraft:gravel"); + feature.setSolid("minecraft:gravel"); + blocks.when(() -> B.getStateOrNull("minecraft:gravel", false)).thenReturn(full); + assertThrows(IllegalArgumentException.class, () -> SubterrainRasterizer.validateMaterials(List.of(feature))); + } + } + + @Test + public void chunksRasterizeTheSameCellsInEitherOrderAndMatchQueries() { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("retained-cenote") + .setProbability(1).setRadius(20).setHeight(24).setPillarSpacing(12) + .setWorldYRange(new IrisRange(-48, 60)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128)); + SubterrainPlan plan = planner.plansForBounds(-512, -512, 512, 512).getFirst(); + int firstX = plan.centerX() >> 4; + int firstZ = plan.centerZ() >> 4; + Map forward = new HashMap<>(); + Map reverse = new HashMap<>(); + for (int x = firstX - 1; x <= firstX + 1; x++) { + SubterrainRasterizer.rasterize(planner, x, firstZ, (xx, y, z, cell) -> forward.put(new SubterrainPosition(xx, y, z), cell)); + } + for (int x = firstX + 1; x >= firstX - 1; x--) { + SubterrainRasterizer.rasterize(planner, x, firstZ, (xx, y, z, cell) -> reverse.put(new SubterrainPosition(xx, y, z), cell)); + } + assertFalse(forward.isEmpty()); + assertEquals(forward, reverse); + int wet = 0; + for (Map.Entry entry : forward.entrySet()) { + SubterrainPosition position = entry.getKey(); + SubterrainCell cell = entry.getValue(); + assertEquals(cell, planner.sample(position.x(), position.y(), position.z())); + if (cell.fluid()) { + wet++; + for (int[] offset : List.of(new int[]{1, 0, 0}, new int[]{-1, 0, 0}, new int[]{0, 0, 1}, new int[]{0, 0, -1}, new int[]{0, -1, 0})) { + SubterrainCell neighbor = planner.sample(position.x() + offset[0], position.y() + offset[1], position.z() + offset[2]); + assertTrue(neighbor.solid() || neighbor.kind() == cell.kind()); + } + } + } + assertTrue(wet > 0); + } + + @Test + public void protectionKeepsSolidAndPassageWhileAllowingRoomDecoration() { + SubterrainRoom room = new SubterrainRoom("room", "biome", IrisSubterrainFamily.CENOTE, + 0, 0, 0, 0, 0, 0, -8, 12, 10, -4, false, false, SubterrainCell.Kind.AIR); + assertFalse(SubterrainRasterizer.protectsPlacement(SubterrainCell.OUTSIDE)); + assertFalse(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.AIR, "minecraft:cave_air", room))); + assertTrue(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room))); + SubterrainRoom passage = new SubterrainRoom("room", "biome", IrisSubterrainFamily.CENOTE, + 0, 0, 0, 0, 0, 0, -8, 12, 10, -4, true, false, SubterrainCell.Kind.AIR); + assertTrue(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.AIR, "minecraft:cave_air", passage))); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterialsTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterialsTest.java new file mode 100644 index 000000000..071beace4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterialsTest.java @@ -0,0 +1,304 @@ +package art.arcane.iris.generation.subterrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.biome.IrisBiomePaletteLayer; +import art.arcane.iris.generation.block.B; +import art.arcane.iris.generation.block.IrisBlockData; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.stage.IrisCarveModifier; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.pack.loading.ResourceLoader; +import art.arcane.iris.pack.value.IrisRange; +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.hunk.Hunk; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +public class SubterrainSurfaceMaterialsTest { + @Test + public void everyGeometryUsesOwningBiomeFloorCeilingAndWallWithoutChangingOccupancy() { + for (IrisSubterrainFamily family : IrisSubterrainFamily.values()) { + try (Fixture fixture = new Fixture(family)) { + Map surfaces = fixture.surfaces(); + for (SubterrainSurfaceMaterials.Surface surface : List.of(SubterrainSurfaceMaterials.Surface.FLOOR, + SubterrainSurfaceMaterials.Surface.CEILING, SubterrainSurfaceMaterials.Surface.WALL)) { + SubterrainPosition position = surfaces.get(surface); + assertTrue(family + " " + surface, position != null); + SubterrainCell before = fixture.cell(position); + NativeBlockState expected = switch (surface) { + case FLOOR -> fixture.floor; + case CEILING -> fixture.ceiling; + case WALL -> fixture.wall; + default -> fixture.fallback; + }; + assertSame(expected, fixture.materials.state(position.x(), position.y(), position.z(), before)); + assertTrue(SubterrainRasterizer.protectsPlacement(before)); + assertEquals(before, fixture.cell(position)); + int chunkX = position.x() >> 4; + int chunkZ = position.z() >> 4; + List local = fixture.planner.plansForBounds((chunkX << 4) - 1, (chunkZ << 4) - 1, + (chunkX << 4) + 16, (chunkZ << 4) + 16); + SubterrainSurfaceMaterials chunk = fixture.materials(local); + assertSame(expected, chunk.state(position.x(), position.y(), position.z(), before)); + } + SubterrainPosition anchor = fixture.plan.anchor(); + SubterrainCell occupied = fixture.cell(anchor); + assertTrue(occupied.occupied()); + assertSame(SubterrainRasterizer.state(occupied), fixture.materials.state(anchor.x(), anchor.y(), anchor.z(), occupied)); + } + } + } + + @Test + public void finalPreservationRestoresBiomeMaterialRatherThanErasingIt() throws Exception { + try (Fixture fixture = new Fixture(IrisSubterrainFamily.CENOTE)) { + Engine engine = mock(Engine.class); + when(engine.getComplex()).thenReturn(fixture.complex); + when(engine.getData()).thenReturn(fixture.data); + when(engine.getDimension()).thenReturn(fixture.dimension); + when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(128).build()); + when(fixture.complex.getSubterrainPlanner()).thenReturn(fixture.planner); + IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + doReturn(engine).when(modifier).getEngine(); + Field random = IrisCarveModifier.class.getDeclaredField("rng"); + random.setAccessible(true); + random.set(modifier, fixture.random); + for (Map.Entry entry : fixture.surfaces().entrySet()) { + SubterrainPosition position = entry.getValue(); + Hunk output = Hunk.newArrayHunk(16, 192, 16); + int x = position.x() & 15; + int y = position.y() + 64; + int z = position.z() & 15; + output.setRaw(x, y, z, null); + modifier.preserveSubterrain((position.x() >> 4) << 4, (position.z() >> 4) << 4, output); + NativeBlockState expected = fixture.materials.state(position.x(), position.y(), position.z(), fixture.cell(position)); + assertSame(expected, output.getRaw(x, y, z)); + output.setRaw(x, y, z, fixture.fallback); + modifier.preserveSubterrain((position.x() >> 4) << 4, (position.z() >> 4) << 4, output); + assertSame(expected, output.getRaw(x, y, z)); + } + } + } + + @Test + public void realMultiEntryPalettesRestoreDeterministicallyAcrossChunkVisitOrders() throws Exception { + try (Fixture fixture = new Fixture(IrisSubterrainFamily.CENOTE)) { + Map forward = fixture.restoredMaterials(false); + Map reverse = fixture.restoredMaterials(true); + Map repeated = fixture.restoredMaterials(false); + assertEquals(forward, reverse); + assertEquals(forward, repeated); + assertTrue(forward.size() > 100); + assertTrue(forward.values().stream().distinct().count() >= 6); + } + } + + @Test + public void missingBiomeRetainsTheShellAndIsLookedUpOncePerResolver() { + try (Fixture fixture = new Fixture(IrisSubterrainFamily.CENOTE)) { + when(fixture.data.getBiomeLoader().load("room-biome")).thenReturn(null); + for (SubterrainPosition position : fixture.surfaces().values()) { + assertSame(fixture.fallback, fixture.materials.state(position.x(), position.y(), position.z(), fixture.cell(position))); + } + verify(fixture.data.getBiomeLoader(), times(1)).load("room-biome"); + } + } + + @Test + public void unsafeSurfaceChoicesFallBackToTheRetainedSolid() { + try (Fixture fixture = new Fixture(IrisSubterrainFamily.CENOTE)) { + SubterrainPosition position = fixture.surfaces().get(SubterrainSurfaceMaterials.Surface.FLOOR); + NativeBlockState partial = solid("minecraft:oak_slab"); + when(partial.isOccluding()).thenReturn(false); + NativeBlockState wet = solid("minecraft:stone[waterlogged=true]"); + when(wet.isWaterLogged()).thenReturn(true); + NativeBlockState fluid = solid("minecraft:water"); + when(fluid.isFluid()).thenReturn(true); + NativeBlockState custom = solid("minecraft:stone"); + when(custom.isCustom()).thenReturn(true); + NativeBlockState gravity = solid("minecraft:gravel"); + for (NativeBlockState unsafe : List.of(partial, wet, fluid, custom, gravity)) { + fixture.floorPalette(unsafe); + assertSame(fixture.fallback, fixture.materials.state(position.x(), position.y(), position.z(), fixture.cell(position))); + } + fixture.floorPalette(null); + assertSame(fixture.fallback, fixture.materials.state(position.x(), position.y(), position.z(), fixture.cell(position))); + } + } + + private static NativeBlockState solid(String key) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + when(state.isSolid()).thenReturn(true); + when(state.isOccluding()).thenReturn(true); + return state; + } + + private static final class Fixture implements AutoCloseable { + private final NativeBlockState fallback = solid("minecraft:stone"); + private final NativeBlockState floor = solid("minecraft:clay"); + private final NativeBlockState ceiling = solid("minecraft:calcite"); + private final NativeBlockState wall = solid("minecraft:andesite"); + private final IrisDimension dimension = new IrisDimension(); + private final IrisComplex complex = mock(IrisComplex.class); + private final IrisData data = mock(IrisData.class); + private final IrisBiome biome = mock(IrisBiome.class); + private final RNG random = new RNG(44339L); + private final SubterrainPlanner planner; + private final SubterrainPlan plan; + private final List plans; + private final SubterrainSurfaceMaterials materials; + private final MockedStatic blocks = mockStatic(B.class); + + @SuppressWarnings("unchecked") + private Fixture(IrisSubterrainFamily family) { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("room").setBiome("room-biome") + .setFamily(family).setProbability(1).setRadius(12).setHeight(16).setLength(200) + .setChimneyHeight(8).setWorldYRange(new IrisRange(-48, 64)); + planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128)); + plan = planner.plansForBounds(-512, -512, 512, 512).getFirst(); + plans = planner.plansForBounds(plan.bounds().minX() - 1, plan.bounds().minZ() - 1, + plan.bounds().maxX() + 1, plan.bounds().maxZ() + 1); + ResourceLoader loader = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(loader); + when(loader.load("room-biome")).thenReturn(biome); + IrisBiomePaletteLayer wallPalette = mock(IrisBiomePaletteLayer.class); + when(biome.getWall()).thenReturn(wallPalette); + when(wallPalette.get(eq(random), anyDouble(), anyDouble(), anyDouble(), eq(data))).thenReturn(wall); + when(biome.generateCeilingLayers(eq(dimension), anyDouble(), anyDouble(), eq(random), eq(1), anyInt(), eq(data), eq(complex))) + .thenReturn(new KList<>(ceiling)); + floorPalette(floor); + NativeBlockState air = mock(NativeBlockState.class); + when(air.key()).thenReturn("minecraft:cave_air"); + NativeBlockState water = mock(NativeBlockState.class); + when(water.key()).thenReturn("minecraft:water"); + NativeBlockState lava = mock(NativeBlockState.class); + when(lava.key()).thenReturn("minecraft:lava"); + blocks.when(() -> B.getState(anyString())).thenAnswer(invocation -> switch ((String) invocation.getArgument(0)) { + case "minecraft:water" -> water; + case "minecraft:lava" -> lava; + case "minecraft:cave_air" -> air; + default -> fallback; + }); + materials = materials(plans); + } + + private Map restoredMaterials(boolean reverse) throws Exception { + IrisBiome authored = new IrisBiome().setLayers(new KList<>(palette("minecraft:clay", "minecraft:terracotta"))) + .setCaveCeilingLayers(new KList<>(palette("minecraft:calcite", "minecraft:white_concrete"))) + .setWall(palette("minecraft:andesite", "minecraft:tuff")); + when(data.getBiomeLoader().load("room-biome")).thenReturn(authored); + Engine engine = mock(Engine.class); + when(engine.getComplex()).thenReturn(complex); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(128).build()); + when(complex.getSubterrainPlanner()).thenReturn(planner); + IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + doReturn(engine).when(modifier).getEngine(); + Field randomField = IrisCarveModifier.class.getDeclaredField("rng"); + randomField.setAccessible(true); + randomField.set(modifier, random); + List chunks = new ArrayList<>(); + SubterrainBounds bounds = plan.bounds(); + for (int x = bounds.minX() >> 4; x <= bounds.maxX() >> 4; x++) { + for (int z = bounds.minZ() >> 4; z <= bounds.maxZ() >> 4; z++) { + chunks.add(new SubterrainPosition(x, 0, z)); + } + } + if (reverse) { + Collections.reverse(chunks); + } + Map result = new HashMap<>(); + for (SubterrainPosition chunk : chunks) { + Hunk output = Hunk.newArrayHunk(16, 192, 16); + for (int draw = 0; draw < 19; draw++) { + random.nextLong(); + } + modifier.preserveSubterrain(chunk.x() << 4, chunk.z() << 4, output); + modifier.preserveSubterrain(chunk.x() << 4, chunk.z() << 4, output); + SubterrainRasterizer.rasterize(planner, chunk.x(), chunk.z(), (x, y, z, cell) -> { + if (cell.solid() && materials.surface(x, y, z, cell) != SubterrainSurfaceMaterials.Surface.INTERIOR) { + result.put(new SubterrainPosition(x, y, z), output.getRaw(x & 15, y + 64, z & 15).key()); + } + }); + } + return result; + } + + private IrisBiomePaletteLayer palette(String first, String second) { + NativeBlockState firstState = solid(first); + NativeBlockState secondState = solid(second); + blocks.when(() -> B.getStateOrNull(first, false)).thenReturn(firstState); + blocks.when(() -> B.getStateOrNull(second, false)).thenReturn(secondState); + return new IrisBiomePaletteLayer().setZoom(1) + .setPalette(new KList<>(new IrisBlockData(first), new IrisBlockData(second))); + } + + private void floorPalette(NativeBlockState state) { + when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), eq(random), eq(1), anyInt(), eq(data), eq(complex))) + .thenReturn(state == null ? new KList<>() : new KList<>(state)); + } + + private SubterrainSurfaceMaterials materials(List candidates) { + return new SubterrainSurfaceMaterials(planner, candidates, dimension, complex, data, random, -64); + } + + private SubterrainCell cell(SubterrainPosition position) { + return planner.sample(plans, position.x(), position.y(), position.z()); + } + + private Map surfaces() { + Map result = new EnumMap<>(SubterrainSurfaceMaterials.Surface.class); + SubterrainBounds bounds = plan.bounds(); + for (int x = bounds.minX(); x <= bounds.maxX(); x++) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { + for (int y = bounds.minY(); y <= bounds.maxY(); y++) { + SubterrainCell cell = planner.sample(plans, x, y, z); + if (!cell.solid()) { + continue; + } + result.putIfAbsent(materials.surface(x, y, z, cell), new SubterrainPosition(x, y, z)); + if (result.size() == SubterrainSurfaceMaterials.Surface.values().length) { + return result; + } + } + } + } + return result; + } + + @Override + public void close() { + blocks.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java index 504be0ec7..ecaf8687a 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java @@ -116,7 +116,7 @@ public class IrisDimensionCarvingResolverParityTest { doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class)); IrisDimensionCarvingResolver.Snapshot snapshot = IrisDimensionCarvingResolver.snapshot(fixture.engine); - assertSame(fixture.engine.getData().getBiomeLoader().load("child-a"), snapshot.resolveBiome(-17, 40, 23)); + assertSame(fixture.engine.getData().getBiomeLoader().load("root-low"), snapshot.resolveBiome(-17, 40, 23)); assertSame(fixture.engine.getData().getBiomeLoader().load("root-low"), snapshot.resolveBiome(-17, 41, 23)); verify(generator, times(2)).noiseFast2D(-17D, 23D); } @@ -140,13 +140,30 @@ public class IrisDimensionCarvingResolverParityTest { doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class)); IrisDimensionCarvingEntry selected = IrisDimensionCarvingResolver.resolveFromRoot( - engine, parent, -17, 23, new IrisDimensionCarvingResolver.State()); + engine, parent, -17, -1500, 23, new IrisDimensionCarvingResolver.State()); assertSame(engine.getDimension().getCarvingEntryIndex().get("child-a"), selected); verify(generator).noiseFast2D(-17D, 23D); verify(generator, never()).fit2D(anyInt(), anyInt(), anyDouble(), anyDouble()); } + @Test + public void childWorldYBandIsCheckedAndCachedPerHeight() { + Fixture fixture = createFixture(); + IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low")); + parent.setChildRecursionDepth(1); + parent.setChildren(new KList<>("child-a")); + IrisDimensionCarvingEntry child = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a"); + child.setWorldYRange(new IrisRange(-20, 20)); + CNG generator = mock(CNG.class); + doReturn(0.5D).when(generator).noiseFast2D(anyDouble(), anyDouble()); + doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class)); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, 7, 0, 9, state)); + assertSame(parent, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, 7, 30, 9, state)); + assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, 7, 0, 9, state)); + } + @Test public void repeatedColumnResolutionSamplesChildrenOnceAcrossHeights() { Fixture fixture = createFixture(); @@ -161,7 +178,7 @@ public class IrisDimensionCarvingResolverParityTest { for (int height = 0; height < 16; height++) { for (int x = -16; x < 0; x++) { for (int z = 0; z < 16; z++) { - assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, x, z, state)); + assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, x, -1500, z, state)); } } } @@ -186,8 +203,8 @@ public class IrisDimensionCarvingResolverParityTest { IrisDimensionCarvingEntry child = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a"); IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); - assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state)); - assertSame(parent, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state)); + assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, -1500, 23, state)); + assertSame(parent, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, -1500, 23, state)); } } @@ -204,11 +221,11 @@ public class IrisDimensionCarvingResolverParityTest { IrisDimensionCarvingEntry firstChild = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a"); IrisDimensionCarvingEntry replacementChild = replacement.engine.getDimension().getCarvingEntryIndex().get("child-a"); - assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state)); + assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, -1500, 23, state)); doReturn(replacement.engine.getData()).when(fixture.engine).getData(); - assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state)); - assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, 23, state)); - assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, 23, state)); + assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, -1500, 23, state)); + assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, -1500, 23, state)); + assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, -1500, 23, state)); verify(generator, times(3)).noiseFast2D(-17D, 23D); } @@ -225,8 +242,8 @@ public class IrisDimensionCarvingResolverParityTest { for (int worldX = -384; worldX <= 384; worldX += 29) { for (int worldZ = -384; worldZ <= 384; worldZ += 31) { - IrisDimensionCarvingEntry legacyResolved = legacyResolveFromRoot(fixture.engine, legacyRoot, worldX, worldZ); - IrisDimensionCarvingEntry statefulResolved = IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, statefulRoot, worldX, worldZ, state); + IrisDimensionCarvingEntry legacyResolved = legacyResolveFromRoot(fixture.engine, legacyRoot, worldX, worldY, worldZ); + IrisDimensionCarvingEntry statefulResolved = IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, statefulRoot, worldX, worldY, worldZ, state); assertSame("entry mismatch at worldY=" + worldY + " worldX=" + worldX + " worldZ=" + worldZ, legacyResolved, statefulResolved); assertSame(legacyResolveEntryBiome(fixture.engine, legacyResolved), IrisDimensionCarvingResolver.resolveBiome(fixture.engine, worldX, worldY, worldZ, state)); @@ -248,8 +265,8 @@ public class IrisDimensionCarvingResolverParityTest { for (int worldX = -640; worldX <= 640; worldX += 79) { for (int worldZ = -640; worldZ <= 640; worldZ += 83) { - IrisDimensionCarvingEntry legacyResolved = legacyResolveFromRoot(fixture.engine, legacyRoot, worldX, worldZ); - IrisDimensionCarvingEntry statefulResolved = IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, statefulRoot, worldX, worldZ, state); + IrisDimensionCarvingEntry legacyResolved = legacyResolveFromRoot(fixture.engine, legacyRoot, worldX, worldY, worldZ); + IrisDimensionCarvingEntry statefulResolved = IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, statefulRoot, worldX, worldY, worldZ, state); assertSame("mixed entry mismatch at worldY=" + worldY + " worldX=" + worldX + " worldZ=" + worldZ, legacyResolved, statefulResolved); assertSame(legacyResolveEntryBiome(fixture.engine, legacyResolved), IrisDimensionCarvingResolver.resolveBiome(fixture.engine, worldX, worldY, worldZ, state)); @@ -406,7 +423,7 @@ public class IrisDimensionCarvingResolverParityTest { Fixture replacement = createMixedDepthFixture(); IrisDimensionCarvingEntry expectedEntry = legacyResolveFromRoot(replacement.engine, - legacyResolveRootEntry(replacement.engine, 83), -17, 23); + legacyResolveRootEntry(replacement.engine, 83), -17, 83, 23); IrisBiome expected = legacyResolveEntryBiome(replacement.engine, expectedEntry); assertSame(expected, IrisDimensionCarvingResolver.resolveBiome(replacement.engine, -17, 83, 23, state)); assertSame(null, IrisDimensionCarvingResolver.resolveBiome(fixture.engine, -17, 83, 23, state)); @@ -692,7 +709,7 @@ public class IrisDimensionCarvingResolverParityTest { for (int localZ = 0; localZ < 16; localZ++) { int worldZ = (chunkZ << 4) + localZ; int columnIndex = (localX << 4) | localZ; - plan[columnIndex] = IrisDimensionCarvingResolver.resolveFromRoot(engine, rootEntry, worldX, worldZ, state); + plan[columnIndex] = IrisDimensionCarvingResolver.resolveFromRoot(engine, rootEntry, worldX, 40, worldZ, state); } } return plan; @@ -728,7 +745,7 @@ public class IrisDimensionCarvingResolverParityTest { return resolved; } - private IrisDimensionCarvingEntry legacyResolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) { + private IrisDimensionCarvingEntry legacyResolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldY, int worldZ) { if (rootEntry == null) { return null; } @@ -748,7 +765,8 @@ public class IrisDimensionCarvingResolverParityTest { int depth = remainingDepth; while (depth > 0) { IrisDimensionCarvingEntry selected = legacySelectChild(engine, current, worldX, worldZ, entryIndex); - if (selected == null || selected == current) { + if (selected == null || selected == current || selected.getWorldYRange() != null + && !selected.getWorldYRange().contains(worldY)) { break; } 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 1760076d1..37bf7d3f0 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "d77cc4ea8255cbfdc4f8c49e04a3c422ca3e094399a514799ec703217f7797da"; + private static final String EXPECTED_SCHEMA_DIGEST = "6f6dae0cd1077c45fe3f5ba512c1da4b0ab43bc8516f2d759bc8f7a798eb1c58"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitSubterrainBiomePrecedenceTest.java b/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitSubterrainBiomePrecedenceTest.java new file mode 100644 index 000000000..3020de2d8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/bukkit/nms/BukkitSubterrainBiomePrecedenceTest.java @@ -0,0 +1,55 @@ +package art.arcane.iris.platform.bukkit.nms; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.runtime.DimensionStackLayout; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; +import art.arcane.iris.world.IrisWorld; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class BukkitSubterrainBiomePrecedenceTest { + @Test + public void featureOwnsPublishedBiomeAboveDepthGateAndBeforeStackOwnership() throws Exception { + Engine engine = mock(Engine.class); + IrisBiome feature = mock(IrisBiome.class); + IrisDimension host = new IrisDimension(); + when(engine.getComplex()).thenReturn(mock(IrisComplex.class)); + when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()); + when(engine.getDimension()).thenReturn(host); + when(engine.getSubterrainBiome(12, 224, -20)).thenReturn(feature); + @SuppressWarnings("unchecked") + BukkitBiomePolicy policy = mock(BukkitBiomePolicy.class, CALLS_REAL_METHODS); + Field owner = BukkitBiomePolicy.class.getDeclaredField("engine"); + owner.setAccessible(true); + owner.set(policy, engine); + DimensionStackLayout stack = mock(DimensionStackLayout.class); + Method resolve = BukkitBiomePolicy.class.getDeclaredMethod("resolveBiomeResolution", int.class, int.class, + int.class, IrisDimensionCarvingResolver.State.class, boolean.class, DimensionStackLayout.class); + resolve.setAccessible(true); + Object resolved = resolve.invoke(policy, 3, 40, -5, null, true, stack); + assertSame(feature, field(resolved, "irisBiome")); + assertSame(host, field(resolved, "dimension")); + assertEquals(true, field(resolved, "underground")); + verify(engine).getSubterrainBiome(12, 224, -20); + verifyNoInteractions(stack); + } + + private static Object field(Object object, String name) throws Exception { + Field field = object.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(object); + } +} diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeCaptureTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeCaptureTest.java index ea6dbe2f2..3bff72bf5 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeCaptureTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeCaptureTest.java @@ -1,6 +1,15 @@ package art.arcane.iris.world.history; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.DimensionStackContext; +import art.arcane.iris.generation.runtime.DimensionStackLayout; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainPosition; + +import java.util.List; import art.arcane.iris.generation.terrain.InferredType; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisRegion; @@ -77,6 +86,7 @@ public class SavedBiomeCaptureTest { @Test public void retainsFloatingChildIdentityWhenItSharesTheHostDerivative() throws Exception { Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(engine.getComplex().getSubterrainPlanner()).thenReturn(null); GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class); SavedBiomeRuntime historical = mock(SavedBiomeRuntime.class); IrisBiome host = biome("host"); @@ -118,6 +128,7 @@ public class SavedBiomeCaptureTest { @Test public void recordsExactSurfaceColumnsAndIndependentCaveAndVerticalIdentities() throws Exception { Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(engine.getComplex().getSubterrainPlanner()).thenReturn(null); GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class); SavedBiomeRuntime historical = mock(SavedBiomeRuntime.class); IrisBiome left = biome("left"); @@ -155,12 +166,117 @@ public class SavedBiomeCaptureTest { verifyNoInteractions(historical); } + @Test + public void preservesHostFeatureBiomeAndRegionAgainstStackAndFloatingOverlay() throws Exception { + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(engine.getComplex().getSubterrainPlanner()).thenReturn(null); + IrisBiome upper = biome("upper-biome"); + IrisBiome authored = biome("authored-biome"); + IrisRegion upperRegion = region("upper-region"); + IrisRegion hostRegion = region("host-region"); + DimensionStackContext stack = mock(DimensionStackContext.class); + DimensionStackLayout layout = mock(DimensionStackLayout.class); + DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class); + when(engine.getDimensionStackContext()).thenReturn(stack); + when(stack.getLayout(anyInt(), anyInt())).thenReturn(layout); + when(layout.layerAt(anyInt())).thenReturn(layer); + when(layer.region()).thenReturn(upperRegion); + when(engine.getComplex().getRegionStream().get(anyDouble(), anyDouble())).thenReturn(hostRegion); + when(engine.getComplex().getTransitionGenerationPlan()).thenReturn(null); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(16); + when(engine.getRegion(anyInt(), anyInt())).thenReturn(upperRegion); + doCallRealMethod().when(engine).getRegion(anyInt(), anyInt(), anyInt()); + when(engine.getSubterrainCell(anyInt(), anyInt(), anyInt())).thenAnswer(call -> + (int) call.getArgument(1) == 8 ? new SubterrainCell(SubterrainCell.Kind.AIR, "", null) : SubterrainCell.OUTSIDE); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenReturn(upper); + when(engine.getCaveBiome(anyInt(), anyInt())).thenReturn(upper); + when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenAnswer(call -> + (int) call.getArgument(1) == 8 ? authored : upper); + columnsFromPointLookups(engine); + FloatingBiomeOverlay floating = new FloatingBiomeOverlay(16); + floating.record(0, 8, 0, new FloatingBiomeOverlay.Identity("upper-biome", "upper-region")); + + SavedBiomeChunk chunk = SavedBiomeCapture.capture(engine, stage(), mock(SavedBiomeRuntime.class), floating); + + assertEquals("authored-biome", chunk.biomeAt(0, -56, 0).biomeKey()); + assertEquals("host-region", chunk.biomeAt(0, -56, 0).regionKey()); + assertEquals("upper-region", chunk.biomeAt(0, -60, 0).regionKey()); + assertEquals("upper-region", chunk.surfaceAt(0, 0).regionKey()); + } + + @Test + public void capturesFeatureBoundariesAtExactBlockCoordinates() throws Exception { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("exact-basin").setBiome("authored-biome") + .setProbability(1).setPillarSpacing(0).setFormationFraction(0); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320)); + SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst(); + SubterrainPosition anchor = plan.anchor(); + int edgeX = plan.bounds().minX(); + while (!planner.sample(edgeX, anchor.y(), anchor.z()).occupied()) { + edgeX++; + } + int chunkX = Math.floorDiv(edgeX, 16); + int chunkZ = Math.floorDiv(anchor.z(), 16); + Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + IrisBiome fallback = biome("base-biome"); + IrisBiome authored = biome("authored-biome"); + IrisRegion host = region("host-region"); + IrisRegion stacked = region("stacked-region"); + DimensionStackContext stack = mock(DimensionStackContext.class); + DimensionStackLayout layout = mock(DimensionStackLayout.class); + DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class); + when(engine.getDimensionStackContext()).thenReturn(stack); + when(stack.getLayout(anyInt(), anyInt())).thenReturn(layout); + when(layout.layerAt(anyInt())).thenReturn(layer); + when(layer.region()).thenReturn(stacked); + when(engine.getComplex().getRegionStream().get(anyDouble(), anyDouble())).thenReturn(host); + when(engine.getComplex().getSubterrainPlanner()).thenReturn(planner); + when(engine.getComplex().getTransitionGenerationPlan()).thenReturn(null); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + when(engine.getRegion(anyInt(), anyInt())).thenReturn(stacked); + when(engine.getSubterrainCell(anyInt(), anyInt(), anyInt())).thenAnswer(call -> + planner.sample(call.getArgument(0), (int) call.getArgument(1) - 64, call.getArgument(2))); + doCallRealMethod().when(engine).getRegion(anyInt(), anyInt(), anyInt()); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenReturn(fallback); + when(engine.getCaveBiome(anyInt(), anyInt())).thenReturn(fallback); + when(engine.getBiomeOrMantle(anyInt(), anyInt(), anyInt())).thenAnswer(call -> + planner.sample(call.getArgument(0), (int) call.getArgument(1) - 64, call.getArgument(2)).occupied() + ? authored : fallback); + columnsFromPointLookups(engine); + GenerationHistory.GenerationStage stage = stage(); + when(stage.chunkX()).thenReturn(chunkX); + when(stage.chunkZ()).thenReturn(chunkZ); + + SavedBiomeChunk chunk = SavedBiomeCapture.capture(engine, stage, mock(SavedBiomeRuntime.class), null); + + boolean nonQuartOccupied = false; + boolean outsideOccupiedVolume = false; + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + for (int y = plan.bounds().minY(); y <= plan.bounds().maxY(); y++) { + boolean occupied = planner.sample(chunkX * 16 + localX, y, chunkZ * 16 + localZ).occupied(); + SavedBiomeChunk.Cell recorded = chunk.biomeAt(localX, y, localZ); + assertEquals(occupied ? "authored-biome" : "base-biome", recorded.biomeKey()); + assertEquals(occupied ? "host-region" : "stacked-region", recorded.regionKey()); + nonQuartOccupied |= occupied && (localX % 4 != 0 || localZ % 4 != 0 || Math.floorMod(y, 4) != 0); + outsideOccupiedVolume |= !occupied; + } + } + } + assertTrue(nonQuartOccupied); + assertTrue(outsideOccupiedVolume); + } + private static Engine caveCaptureEngine(IrisBiome surface, IrisBiome cave) { Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); + when(engine.getComplex().getSubterrainPlanner()).thenReturn(null); IrisRegion region = region("flat-region"); when(engine.getMinHeight()).thenReturn(-64); when(engine.getHeight()).thenReturn(16); when(engine.getHeight(anyInt(), anyInt())).thenReturn(8); + when(engine.getSubterrainBiome(anyInt(), anyInt(), anyInt())).thenReturn(null); when(engine.getDimensionStackContext()).thenReturn(null); when(engine.getComplex().getTransitionGenerationPlan()).thenReturn(null); when(engine.getComplex().getCaveBiomeStream().get(anyDouble(), anyDouble())).thenReturn(cave); diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java new file mode 100644 index 000000000..9e01eaa03 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java @@ -0,0 +1,97 @@ +package art.arcane.iris.world.history; + +import art.arcane.iris.generation.locator.SubterrainLocator; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.generation.subterrain.IrisSubterrainFeature; +import art.arcane.iris.generation.subterrain.SubterrainPlan; +import art.arcane.iris.generation.subterrain.SubterrainPlanner; +import art.arcane.iris.generation.subterrain.SubterrainPosition; +import org.junit.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SavedSubterrainQueriesTest { + @Test + public void locateUsesRetainedDefinitionOnlyInsideItsOwnedChunks() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + GenerationManifest manifest = mock(GenerationManifest.class); + GenerationActivation old = GenerationActivation.initial("a".repeat(64), 1L); + GenerationActivation active = GenerationActivation.next(2L, "b".repeat(64), 1L, 2L, 64); + SubterrainPlanner historical = planner("old-basin"); + SubterrainPlan plan = historical.plansForBounds(0, 0, 511, 511).getFirst(); + SubterrainPosition anchor = plan.anchor(); + when(engine.getComplex()).thenReturn(complex); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(router.biomes()).thenReturn(saved); + when(history.manifest()).thenReturn(manifest); + when(manifest.activations()).thenReturn(List.of(old, active)); + when(manifest.pendingActivation()).thenReturn(Optional.empty()); + when(saved.subterrainPlanner(1L)).thenReturn(historical); + when(saved.subterrainPlanner(2L)).thenReturn(planner("new-basin")); + when(history.resolveActivation(anyInt(), anyInt())).thenReturn(active); + when(history.resolveActivation(Math.floorDiv(anchor.x(), 16), Math.floorDiv(anchor.z(), 16))).thenReturn(old); + SubterrainLocator.Query query = new SubterrainLocator.Query("old-basin", null, ""); + + SubterrainLocator.Result found = GenerationSemanticQueries.nearestSubterrain(engine, query, + anchor.x(), anchor.y(), anchor.z(), 256).orElseThrow(); + assertEquals(plan.id(), found.featureId()); + assertEquals(anchor.y(), found.y()); + verify(engine, never()).getMantle(); + verify(router, never()).openRoute(anyInt(), anyInt()); + verify(router, never()).openReadScope(anyInt(), anyInt()); + + when(history.resolveActivation(anyInt(), anyInt())).thenReturn(active); + assertTrue(GenerationSemanticQueries.nearestSubterrain(engine, query, + anchor.x(), anchor.y(), anchor.z(), 256).isEmpty()); + } + + @Test + public void newPredictionCannotCrossProtectedGenerationTransition() throws Exception { + IrisEngine engine = mock(IrisEngine.class); + IrisComplex complex = mock(IrisComplex.class); + GenerationHistory history = mock(GenerationHistory.class); + GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class); + SavedBiomeRuntime saved = mock(SavedBiomeRuntime.class); + GenerationManifest manifest = mock(GenerationManifest.class); + GenerationActivation active = GenerationActivation.initial("a".repeat(64), 1L); + SubterrainPlanner planner = planner("basin"); + SubterrainPosition anchor = planner.plansForBounds(0, 0, 511, 511).getFirst().anchor(); + when(engine.getComplex()).thenReturn(complex); + when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router)); + when(router.history()).thenReturn(history); + when(router.biomes()).thenReturn(saved); + when(history.manifest()).thenReturn(manifest); + when(manifest.activations()).thenReturn(List.of(active)); + when(manifest.pendingActivation()).thenReturn(Optional.empty()); + when(saved.subterrainPlanner(1L)).thenReturn(planner); + when(history.resolveActivation(anyInt(), anyInt())).thenReturn(active); + when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true); + SubterrainLocator.Query query = new SubterrainLocator.Query("basin", null, ""); + assertTrue(GenerationSemanticQueries.nearestSubterrain(engine, query, + anchor.x(), anchor.y(), anchor.z(), 64).isEmpty()); + when(complex.allowsNewDiscreteContentAt(anchor.x(), anchor.z())).thenReturn(true); + assertTrue(GenerationSemanticQueries.nearestSubterrain(engine, query, + anchor.x(), anchor.y(), anchor.z(), 64).isPresent()); + verify(engine, never()).getMantle(); + } + + private static SubterrainPlanner planner(String id) { + IrisSubterrainFeature feature = new IrisSubterrainFeature().setId(id).setProbability(1D); + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320)); + } +}