diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java index 0e013e8e0..07eb4ba53 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java @@ -450,7 +450,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } @Test - public void flattenSupportsProjectedRoadsAcrossRoofCavitiesWithoutFillingAdjacentColumns() throws Exception { + public void flattenSupportsProjectedRoadsAndTheirSurroundingGradeAcrossRoofCavities() throws Exception { ProtoChunk chunk = new ProtoChunk(new ChunkPos(0, 0), UpgradeData.EMPTY, LevelHeightAccessor.create(0, 256), containerFactory(), null); chunk.setPersistedStatus(ChunkStatus.CARVERS); @@ -504,11 +504,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } assertTrue(chunk.getBlockState(new BlockPos(4, roadY - 33, 0)).isAir()); int adjacentY = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 5, 0); - assertTrue(chunk.getBlockState(new BlockPos(5, adjacentY - 1, 0)).isAir()); + for (int y = adjacentY - 32; y < adjacentY; y++) { + assertTrue("Missing ground between road cells at " + y, + chunk.getBlockState(new BlockPos(5, y, 0)).isSolid()); + } assertEquals(Blocks.WATER.defaultBlockState(), chunk.getBlockState(new BlockPos(6, 50, 0))); assertTrue(chunk.getBlockState(new BlockPos(6, 49, 0)).isAir()); } + @Test + public void flattenBuildsAContinuousVillageGradeBetweenSparseFoundations() throws Exception { + StructureTemplate foundation = template(List.of(block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + List pieces = new ArrayList<>(); + for (int x : List.of(0, 8)) { + for (int z : List.of(0, 8)) { + pieces.add(rigidTemplatePiece(new InlineSinglePoolElement(foundation), + new BoundingBox(x, 65, z, x, 75, z), 0, Rotation.NONE)); + } + } + StructureStart start = rigidSurfaceStart(pieces); + NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN); + target.terrain().setHorizontalPadding(24).setFlattenRange(64); + BoundingBox area = new BoundingBox(0, 0, 0, 8, 127, 8); + Map blocks = new HashMap<>(); + for (int x = 0; x <= 8; x++) { + for (int z = 0; z <= 8; z++) { + int originalY = (x == 0 || x == 8) && (z == 0 || z == 8) ? 64 : 96; + put(blocks, x, 20, z, Blocks.STONE.defaultBlockState()); + put(blocks, x, originalY - 1, z, Blocks.DIRT.defaultBlockState()); + put(blocks, x, originalY, z, Blocks.GRASS_BLOCK.defaultBlockState()); + } + } + NativeStructureSurfaceFitter.SurfaceTerrainPlan plan = NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, List.of(target), + (x, z) -> (x == 0 || x == 8) && (z == 0 || z == 8) ? 64 : 96); + + for (int x = 0; x <= 8; x++) { + for (int z = 0; z <= 8; z++) { + int groundY = plan.flattenedHeights().get(NativeStructureTemplateOccupancy.columnKey(x, z)) - 1; + assertTrue("Village grade at " + x + "," + z + ": " + groundY, groundY >= 64 && groundY <= 70); + for (int y = 20; y <= groundY; y++) { + assertTrue("Disconnected village ground at " + x + "," + y + "," + z, + state(blocks, x, y, z).isSolid()); + } + for (int y = groundY + 1; y <= 96; y++) { + assertTrue("Roof remains above village at " + x + "," + y + "," + z, + state(blocks, x, y, z).isAir()); + } + } + } + } + @Test public void sourceBeardAdjustmentsPrepareOnlyAtTheSurfaceStructuresStep() { assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java index 3aa5c7595..d4cc7ebab 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java @@ -750,14 +750,14 @@ public final class NativeStructureSurfaceFitter { if (selected != null) { int targetY = originalY + Math.max(-selected.range(), Math.min(selected.range(), selected.source().meetY() - originalY)); - return new FlattenResolution(targetY, selected.range(), selected.source().strength() > 1); + return new FlattenResolution(targetY, selected.range()); } if (totalWeight == 0) { - return new FlattenResolution(originalY, 0, false); + return new FlattenResolution(originalY, 0); } int targetY = blendSurfaceTarget(originalY, weightedY / (double) totalWeight, maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE); - return new FlattenResolution(targetY, range, false); + return new FlattenResolution(targetY, range); } private static Map fitFlattenTerrain( @@ -780,9 +780,7 @@ public final class NativeStructureSurfaceFitter { int minimumY = Math.max(area.minY(), targetY - range); applySurfaceColumn(world, position, x, z, originalY, targetY, minimumY, Math.min(area.maxY(), originalY + range)); - if (resolution.foundation() || projectedRange > 0) { - fillFlattenFoundation(world, position, x, z, targetY, minimumY); - } + fillFlattenFoundation(world, position, x, z, targetY, minimumY); heights.put(column, targetY + 1); } } @@ -1104,7 +1102,7 @@ public final class NativeStructureSurfaceFitter { record FlattenAnchor(SurfaceAnchor source, int radius, int range) { } - record FlattenResolution(int targetY, int range, boolean foundation) { + record FlattenResolution(int targetY, int range) { } private record FlattenFootprint(List anchors, Map projectedSupport) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java index 60160389e..7e882a604 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java @@ -273,10 +273,33 @@ public final class ModdedEntitySpawner { if (type == null) { return true; } + if (irisEntity.getSurface().isFluid()) { + return isFluidAreaClearForSpawn(blockX, blockY, blockZ, type.getWidth(), type.getHeight(), + position -> ModdedWorldManager.matchesSurface(irisEntity.getSurface(), level.getBlockState(position))); + } return isAreaClearForSpawn(blockX, blockY, blockZ, type.getWidth(), type.getHeight(), position -> level.getBlockState(position).is(Blocks.AIR)); } + static boolean isFluidAreaClearForSpawn(int blockX, int blockY, int blockZ, + float width, float height, Predicate matchesFluid) { + int startX = (int) Math.floor(blockX + 0.5 - width / 2D); + int endX = (int) Math.floor(Math.nextDown(blockX + 0.5 + width / 2D)); + int endY = (int) Math.floor(Math.nextDown(blockY + 0.5 + height)); + int startZ = (int) Math.floor(blockZ + 0.5 - width / 2D); + int endZ = (int) Math.floor(Math.nextDown(blockZ + 0.5 + width / 2D)); + for (int x = startX; x <= endX; x++) { + for (int y = blockY; y <= endY; y++) { + for (int z = startZ; z <= endZ; z++) { + if (!matchesFluid.test(new BlockPos(x, y, z))) { + return false; + } + } + } + } + return true; + } + static boolean isAreaClearForSpawn(int blockX, int blockY, int blockZ, float width, float height, Predicate isAir) { int radius = (int) (width / 2F); int endY = blockY + (int) height; 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 2e16198b3..68e4caeaa 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 @@ -604,9 +604,11 @@ public final class ModdedWorldManager implements EngineWorldManager { worldZ = (chunkZ << 4) + RNG.r.i(16); int surfaceY = level.getHeight(Heightmap.Types.WORLD_SURFACE, worldX, worldZ) - 1; int solidY = level.getHeight(Heightmap.Types.OCEAN_FLOOR, worldX, worldZ) - 1; - worldY = group == IrisSpawnGroup.NORMAL - ? surfaceY + 1 - : RNG.r.i(solidY + 1, surfaceY); + Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, RNG.r); + if (selectedY == null) { + continue; + } + worldY = selectedY; } if (worldY <= level.getMinY() || worldY >= level.getMaxY()) { continue; @@ -653,6 +655,11 @@ public final class ModdedWorldManager implements EngineWorldManager { if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { continue; } + if (irisEntity.getSurface().isFluid() + && (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ) + || !ModdedEntitySpawner.isAreaClearForSpawn(level, irisEntity, worldX, worldY, worldZ))) { + continue; + } if (ModdedEntitySpawner.spawn(engine, irisEntity, level, worldX, worldY, worldZ, entityRng) != null) { spawned++; } @@ -681,11 +688,11 @@ public final class ModdedWorldManager implements EngineWorldManager { } private boolean surfaceMatches(IrisSurface surface, ServerLevel level, int worldX, int worldY, int worldZ) { - BlockState below = level.getBlockState(new BlockPos(worldX, worldY - 1, worldZ)); + BlockState below = level.getBlockState(new BlockPos(worldX, worldY - (surface.isFluid() ? 0 : 1), worldZ)); return matchesSurface(surface, below); } - private static boolean matchesSurface(IrisSurface surface, BlockState below) { + static boolean matchesSurface(IrisSurface surface, BlockState below) { if (ModdedBlockResolution.isSolid(below)) { return surface == IrisSurface.LAND || surface == IrisSurface.OVERWORLD || (surface == IrisSurface.ANIMAL && isAnimalGround(below)); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java new file mode 100644 index 000000000..cf9f478c7 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWaterEntitySpawnTest.java @@ -0,0 +1,101 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.object.IrisEntity; +import art.arcane.iris.engine.object.IrisEntitySpawn; +import art.arcane.iris.engine.object.IrisSpawnGroup; +import art.arcane.iris.engine.object.IrisSurface; +import art.arcane.volmlib.util.math.RNG; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.properties.BlockStateProperties; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ModdedWaterEntitySpawnTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void tadpoleVolumeRequiresWaterAndAcceptsAquaticPlants() { + ServerLevel level = mock(ServerLevel.class); + IrisEntity entity = new IrisEntity().setType("tadpole").setSurface(IrisSurface.WATER); + when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.WATER.defaultBlockState()); + assertTrue(ModdedEntitySpawner.isAreaClearForSpawn(level, entity, 0, 78, 0)); + when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.SEAGRASS.defaultBlockState()); + assertTrue(ModdedEntitySpawner.isAreaClearForSpawn(level, entity, 0, 78, 0)); + when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.AIR.defaultBlockState()); + assertFalse(ModdedEntitySpawner.isAreaClearForSpawn(level, entity, 0, 79, 0)); + when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.LAVA.defaultBlockState()); + assertFalse(ModdedEntitySpawner.isAreaClearForSpawn(level, entity, 0, 78, 0)); + } + + @Test + public void lavaSurfaceRequiresLavaInsteadOfAirOrWater() { + ServerLevel level = mock(ServerLevel.class); + IrisEntity entity = new IrisEntity().setType("tadpole").setSurface(IrisSurface.LAVA); + when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.LAVA.defaultBlockState()); + assertTrue(ModdedEntitySpawner.isAreaClearForSpawn(level, entity, 0, 78, 0)); + when(level.getBlockState(any(BlockPos.class))).thenReturn(Blocks.WATER.defaultBlockState()); + assertFalse(ModdedEntitySpawner.isAreaClearForSpawn(level, entity, 0, 78, 0)); + } + + @Test + public void waterloggedSolidBlocksStillObstructWaterSpawns() { + assertTrue(ModdedWorldManager.matchesSurface(IrisSurface.WATER, + Blocks.SEA_PICKLE.defaultBlockState().setValue(BlockStateProperties.WATERLOGGED, true))); + assertFalse(ModdedWorldManager.matchesSurface(IrisSurface.WATER, + Blocks.OAK_FENCE.defaultBlockState().setValue(BlockStateProperties.WATERLOGGED, true))); + } + + @Test + public void fractionalFluidVolumeIncludesHorizontalEdgesAndCenteredHeight() { + Set checked = new HashSet<>(); + assertTrue(ModdedEntitySpawner.isFluidAreaClearForSpawn(-1, 78, -1, 1.3F, 0.8F, position -> { + checked.add(position); + return true; + })); + assertEquals(18, checked.size()); + assertTrue(checked.contains(new BlockPos(-2, 79, -2))); + assertTrue(checked.contains(new BlockPos(0, 78, 0))); + assertFalse(ModdedEntitySpawner.isFluidAreaClearForSpawn(-1, 78, -1, 1.3F, 0.8F, + position -> !position.equals(new BlockPos(-2, 79, -2)))); + } + + @Test + public void exactUpperBoundaryDoesNotRequireWaterOutsideTheBody() { + Set checked = new HashSet<>(); + assertTrue(ModdedEntitySpawner.isFluidAreaClearForSpawn(0, 78, 0, 1F, 0.5F, position -> { + checked.add(position); + return true; + })); + assertEquals(Set.of(new BlockPos(0, 78, 0)), checked); + } + + @Test + public void mixedSpawnerUsesFluidDepthWithoutChangingLandMembers() { + RNG rng = new RNG(1L); + assertEquals(Integer.valueOf(78), IrisEntitySpawn.selectSurfaceSpawnY( + IrisSpawnGroup.NORMAL, IrisSurface.WATER, 77, 78, rng)); + assertEquals(Integer.valueOf(79), IrisEntitySpawn.selectSurfaceSpawnY( + IrisSpawnGroup.NORMAL, IrisSurface.LAND, 77, 78, rng)); + assertNull(IrisEntitySpawn.selectSurfaceSpawnY(IrisSpawnGroup.NORMAL, IrisSurface.WATER, 78, 78, rng)); + assertNull(IrisEntitySpawn.selectSurfaceSpawnY(IrisSpawnGroup.UNDERWATER, IrisSurface.WATER, 78, 78, rng)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java index ad62c23e0..006167250 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java @@ -74,6 +74,10 @@ public class IrisEntitySpawn implements IRare { private transient IrisMarker referenceMarker; public int spawn(Engine gen, Chunk c, RNG rng) { + IrisEntity definition = getRealEntity(gen); + if (definition == null) { + return 0; + } int spawns = LootResolver.inclusive(rng, minSpawns, maxSpawns); int s = 0; @@ -84,11 +88,10 @@ public class IrisEntitySpawn implements IRare { World world = c.getWorld(); int h = world.getHighestBlockYAt(x, z, HeightMap.OCEAN_FLOOR); int hf = world.getHighestBlockYAt(x, z, HeightMap.WORLD_SURFACE); - Location l = switch (getReferenceSpawner().getGroup()) { - case NORMAL -> new Location(c.getWorld(), x, hf + 1, z); - case CAVE -> findCaveSpawnLocation(gen, c, rng); - case UNDERWATER, BEACH -> new Location(c.getWorld(), x, rng.i(h + 1, hf), z); - }; + IrisSpawnGroup group = getReferenceSpawner().getGroup(); + Integer y = selectSurfaceSpawnY(group, definition.getSurface(), h, hf, rng); + Location l = group == IrisSpawnGroup.CAVE ? findCaveSpawnLocation(gen, c, rng) + : y == null ? null : new Location(world, x, y, z); if (l != null) { if (referenceSpawner.getAllowedLightLevels().getMin() > 0 || referenceSpawner.getAllowedLightLevels().getMax() < 15) { @@ -109,6 +112,17 @@ public class IrisEntitySpawn implements IRare { return s; } + public static Integer selectSurfaceSpawnY(IrisSpawnGroup group, IrisSurface surface, + int floor, int top, RNG rng) { + if (group == IrisSpawnGroup.CAVE) { + return null; + } + if (group == IrisSpawnGroup.NORMAL && !surface.isFluid()) { + return top + 1; + } + return top > floor ? LootResolver.inclusive(rng, floor + 1, top) : null; + } + public int spawn(Engine gen, IrisPosition c, RNG rng) { int spawns = LootResolver.inclusive(rng, minSpawns, maxSpawns); int s = 0; @@ -198,19 +212,21 @@ public class IrisEntitySpawn implements IRare { return null; } - if (!ignoreSurfaces && !irisEntity.getSurface().matches(at.clone().subtract(0, 1, 0).getBlock())) { + IrisSurface surface = irisEntity.getSurface(); + boolean checkPosition = !ignoreSurfaces || surface.isFluid(); + if (checkPosition && !surface.matches(at.clone().subtract(0, surface.isFluid() ? 0 : 1, 0).getBlock())) { return null; } Vector3d boundingBox = BukkitPlatform.entityBoundingBox(irisEntity.getBukkitType()); - if (!ignoreSurfaces && boundingBox != null) { - boolean isClearForSpawn = isAreaClearForSpawn(at, boundingBox); + if (checkPosition && boundingBox != null) { + boolean isClearForSpawn = isAreaClearForSpawn(at, boundingBox, surface); if (!isClearForSpawn) { return null; } } - Entity e = irisEntity.spawn(g, at.add(0.5, 0.5, 0.5), rng.aquire(() -> new RNG(g.getSeedManager().getEntity()))); + Entity e = irisEntity.spawn(g, at.clone().add(0.5, 0.5, 0.5), rng.aquire(() -> new RNG(g.getSeedManager().getEntity()))); if (e != null) { IrisLogging.debug("Spawned " + C.DARK_AQUA + "Entity<" + getEntity() + "> " + C.GREEN + e.getType() + C.LIGHT_PURPLE + " @ " + C.GRAY + e.getLocation().getX() + ", " + e.getLocation().getY() + ", " + e.getLocation().getZ()); } @@ -223,19 +239,26 @@ public class IrisEntitySpawn implements IRare { } } - private boolean isAreaClearForSpawn(Location center, Vector3d boundingBox) { + private boolean isAreaClearForSpawn(Location center, Vector3d boundingBox, IrisSurface surface) { World world = center.getWorld(); - int startX = center.getBlockX() - (int) (boundingBox.x / 2); - int endX = center.getBlockX() + (int) (boundingBox.x / 2); - int startY = center.getBlockY(); - int endY = center.getBlockY() + (int) boundingBox.y; - int startZ = center.getBlockZ() - (int) (boundingBox.z / 2); - int endZ = center.getBlockZ() + (int) (boundingBox.z / 2); + boolean fluid = surface.isFluid(); + int startX = fluid ? (int) Math.floor(center.getX() + 0.5 - boundingBox.x / 2) + : center.getBlockX() - (int) (boundingBox.x / 2); + int endX = fluid ? (int) Math.floor(Math.nextDown(center.getX() + 0.5 + boundingBox.x / 2)) + : center.getBlockX() + (int) (boundingBox.x / 2); + int startY = fluid ? (int) Math.floor(center.getY() + 0.5) : center.getBlockY(); + int endY = fluid ? (int) Math.floor(Math.nextDown(center.getY() + 0.5 + boundingBox.y)) + : center.getBlockY() + (int) boundingBox.y; + int startZ = fluid ? (int) Math.floor(center.getZ() + 0.5 - boundingBox.z / 2) + : center.getBlockZ() - (int) (boundingBox.z / 2); + int endZ = fluid ? (int) Math.floor(Math.nextDown(center.getZ() + 0.5 + boundingBox.z / 2)) + : center.getBlockZ() + (int) (boundingBox.z / 2); for (int x = startX; x <= endX; x++) { for (int y = startY; y <= endY; y++) { for (int z = startZ; z <= endZ; z++) { - if (world.getBlockAt(x, y, z).getType() != Material.AIR) { + if (fluid ? !surface.matches(world.getBlockAt(x, y, z)) + : world.getBlockAt(x, y, z).getType() != Material.AIR) { return false; } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java index eb4dedb48..fadfc1543 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java @@ -22,7 +22,7 @@ public class IrisStructureTerrain { private static final double MAX_EROSION_FREQUENCY = 1D; private static final double MAX_LOBE_FREQUENCY = 1D; - @Desc("Terrain operation. SOURCE applies the registered native structure's authored terrain adaptation and is a no-op for editable Iris structures. PRESERVE disables terrain integration. FLATTEN cuts and fills exposed native foundations within flattenRange, blending across horizontalPadding blocks. VACUUM raises terrain from processed rigid-template foundations with a fixed 12-block falloff without lowering ground. BORE and FORCE_CARVE clear the requested envelope, while ENCASE fills it before placement.") + @Desc("Terrain operation. SOURCE applies the registered native structure's authored terrain adaptation and is a no-op for editable Iris structures. PRESERVE disables terrain integration. FLATTEN cuts and fills exposed native structure terrain within flattenRange, blending across horizontalPadding blocks. VACUUM raises terrain from processed rigid-template foundations with a fixed 12-block falloff without lowering ground. BORE and FORCE_CARVE clear the requested envelope, while ENCASE fills it before placement.") private IrisStructureTerrainMode mode = IrisStructureTerrainMode.SOURCE; @MinNumber(0) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurface.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurface.java index 0b073f40a..c8670968a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSurface.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurface.java @@ -41,6 +41,10 @@ public enum IrisSurface { @Desc("Within lava") LAVA; + public boolean isFluid() { + return this == WATER || this == LAVA; + } + /** * Check if this Iris surface matches the blockstate provided * @@ -49,7 +53,7 @@ public enum IrisSurface { */ public boolean matches(Block state) { Material type = state.getType(); - if (type.isSolid()) { + if (state.isSolid()) { return this == LAND || this == OVERWORLD || (this == ANIMAL && (type == Material.GRASS_BLOCK || type == Material.DIRT || type == Material.DIRT_PATH || type == Material.COARSE_DIRT @@ -60,7 +64,7 @@ public enum IrisSurface { if (type == Material.WATER || type == Material.SEAGRASS || type == Material.TALL_SEAGRASS || type == Material.KELP_PLANT || type == Material.KELP || - (state instanceof Waterlogged && ((Waterlogged) state).isWaterlogged())) + (state.getBlockData() instanceof Waterlogged waterlogged && waterlogged.isWaterlogged())) return this == WATER || this == OVERWORLD; return false; diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java index ac62362d8..ce9723145 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/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 = "b205ce33025fa36115f9ddb38c704803875043950967e31449e2b08ae364a7fe"; + private static final String EXPECTED_SCHEMA_DIGEST = "43b3add0a559f7006e2973344f39db933bbfb1be889f3c5d616a7bf035a9db39"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisEntitySpawnTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisEntitySpawnTest.java index 021e807e5..c05659b0d 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisEntitySpawnTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisEntitySpawnTest.java @@ -1,14 +1,221 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.math.Vector3d; +import org.bukkit.Chunk; +import org.bukkit.HeightMap; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.Waterlogged; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; import org.junit.Test; +import org.mockito.MockedStatic; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; public class IrisEntitySpawnTest { @Test public void caveSpawnWithoutSafeMarkerIsSkipped() { assertNull(IrisEntitySpawn.selectCaveSpawnLocation(new KList<>(), null, new RNG(1L))); } + + @Test + public void mixedNormalSpawnerPlacesEveryTadpoleInsideOneBlockDeepWater() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 77, 78))) { + assertEquals(3, fixture.spawn(IrisSpawnGroup.NORMAL)); + assertEquals(3, fixture.positions.size()); + assertTrue(fixture.positions.stream().allMatch(position -> position.getY() == 78.5)); + } + } + + @Test + public void underwaterSpawnerAcceptsWaterInsteadOfRequiringAir() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 65, 78))) { + assertEquals(3, fixture.spawn(IrisSpawnGroup.UNDERWATER)); + assertTrue(fixture.positions.stream().allMatch(position -> position.getBlockY() > 65 + && position.getBlockY() <= 78)); + } + } + + @Test + public void waterEntityCannotSpawnInAirAboveWater() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 77, 78))) { + assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 79, 0))); + assertTrue(fixture.positions.isEmpty()); + } + } + + @Test + public void dryColumnsDoNotSpawnWaterEntities() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 78, 78))) { + assertEquals(0, fixture.spawn(IrisSpawnGroup.NORMAL)); + assertEquals(0, fixture.spawn(IrisSpawnGroup.UNDERWATER)); + } + } + + @Test + public void mixedNormalSpawnerRetainsLandEntityPlacement() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) { + assertEquals(3, fixture.spawn(IrisSpawnGroup.NORMAL)); + assertTrue(fixture.positions.stream().allMatch(position -> position.getY() == 79.5)); + } + } + + @Test + public void lavaEntityOccupiesLavaAndRejectsWater() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAVA, Material.LAVA, 77, 78))) { + assertEquals(3, fixture.spawn(IrisSpawnGroup.NORMAL)); + } + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAVA, Material.WATER, 77, 78))) { + assertEquals(0, fixture.spawn(IrisSpawnGroup.NORMAL)); + } + } + + @Test + public void fluidVolumeIncludesTheCenteredEntityHeight() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 77, 78))) { + fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE)) + .thenReturn(new Vector3d(0.4, 0.8, 0.4)); + assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 78, 0))); + fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE)) + .thenReturn(new Vector3d(0.4, 0.5, 0.4)); + fixture.entry.spawn(fixture.engine, new Location(fixture.world, 0, 78, 0)); + assertEquals(1, fixture.positions.size()); + } + } + + @Test + public void fluidVolumeRejectsWaterloggedSolidObstructionBesideWideEntity() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 77, 79))) { + fixture.platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE)) + .thenReturn(new Vector3d(1.3, 0.8, 1.3)); + Block obstruction = mock(Block.class); + Waterlogged data = mock(Waterlogged.class); + when(data.isWaterlogged()).thenReturn(true); + when(obstruction.getBlockData()).thenReturn(data); + when(obstruction.getType()).thenReturn(Material.OAK_FENCE); + when(obstruction.isSolid()).thenReturn(true); + when(fixture.world.getBlockAt(-2, 78, -1)).thenReturn(obstruction); + assertNull(fixture.entry.spawn(fixture.engine, new Location(fixture.world, -1, 78, -1))); + } + } + + @Test + public void waterloggedNonSolidPlantsCountAsWater() { + Block block = mock(Block.class); + Waterlogged data = mock(Waterlogged.class); + when(block.getType()).thenReturn(Material.SEA_PICKLE); + when(block.getBlockData()).thenReturn(data); + when(data.isWaterlogged()).thenReturn(true); + assertTrue(IrisSurface.WATER.matches(block)); + } + + @Test + public void markerWaterSpawnsStillRequireSubmergedBodies() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.WATER, Material.WATER, 77, 78)); + MockedStatic binding = mockStatic(BukkitWorldBinding.class)) { + binding.when(() -> BukkitWorldBinding.tryBind(null)).thenReturn(true); + binding.when(() -> BukkitWorldBinding.world(null)).thenReturn(fixture.world); + fixture.platform.when(() -> BukkitPlatform.toLocation(any(IrisPosition.class), eq(fixture.world))) + .thenAnswer(invocation -> { + IrisPosition position = invocation.getArgument(0); + return new Location(fixture.world, position.getX(), position.getY(), position.getZ()); + }); + fixture.entry.setReferenceSpawner(new IrisSpawner()); + assertEquals(0, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 78, 0), new RNG(1L))); + assertEquals(3, fixture.entry.spawn(fixture.engine, new IrisPosition(0, 77, 0), new RNG(1L))); + } + } + + @Test + public void repeatedExplicitSpawnsDoNotMoveTheCallerLocation() { + try (SpawnFixture fixture = new SpawnFixture(new SpawnOptions(IrisSurface.LAND, Material.WATER, 78, 78))) { + Location location = new Location(fixture.world, 0, 79, 0); + fixture.entry.spawn(fixture.engine, location); + fixture.entry.spawn(fixture.engine, location); + assertEquals(new Location(fixture.world, 0, 79, 0), location); + assertEquals(List.of(new Location(fixture.world, 0.5, 79.5, 0.5), + new Location(fixture.world, 0.5, 79.5, 0.5)), fixture.positions); + } + } + + private record SpawnOptions(IrisSurface surface, Material fluid, int floor, int top) { + } + + private static final class SpawnFixture implements AutoCloseable { + private final Engine engine = mock(Engine.class); + private final World world = mock(World.class); + private final Chunk chunk = mock(Chunk.class); + private final IrisEntitySpawn entry = spy(new IrisEntitySpawn()); + private final List positions = new ArrayList<>(); + private final MockedStatic platform = mockStatic(BukkitPlatform.class); + + private SpawnFixture(SpawnOptions options) { + IrisSurface surface = options.surface(); + Material fluid = options.fluid(); + int floor = options.floor(); + int top = options.top(); + IrisEntity definition = mock(IrisEntity.class); + when(definition.getSurface()).thenReturn(surface); + when(definition.getBukkitType()).thenReturn(EntityType.TADPOLE); + when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); + when(chunk.getWorld()).thenReturn(world); + when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.OCEAN_FLOOR))).thenReturn(floor); + when(world.getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.WORLD_SURFACE))).thenReturn(top); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + int y = invocation.getArgument(1); + Block block = mock(Block.class); + when(block.getType()).thenReturn(y <= floor ? Material.STONE : y <= top ? fluid : Material.AIR); + when(block.isSolid()).thenReturn(y <= floor); + return block; + }); + when(world.getBlockAt(any(Location.class))).thenAnswer(invocation -> { + Location location = invocation.getArgument(0); + return world.getBlockAt(location.getBlockX(), location.getBlockY(), location.getBlockZ()); + }); + when(definition.spawn(eq(engine), any(Location.class), any(RNG.class))).thenAnswer(invocation -> { + Location location = invocation.getArgument(1); + positions.add(location.clone()); + Entity spawned = mock(Entity.class); + when(spawned.getLocation()).thenReturn(location.clone()); + when(spawned.getType()).thenReturn(EntityType.TADPOLE); + return spawned; + }); + platform.when(() -> BukkitPlatform.entityBoundingBox(EntityType.TADPOLE)) + .thenReturn(new Vector3d(0.4, 0.3, 0.4)); + doReturn(definition).when(entry).getRealEntity(engine); + entry.setMinSpawns(3).setMaxSpawns(3); + } + + private int spawn(IrisSpawnGroup group) { + entry.setReferenceSpawner(new IrisSpawner().setGroup(group)); + return entry.spawn(engine, chunk, new RNG(1337L)); + } + + @Override + public void close() { + platform.close(); + } + } }