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 cb92dd94d..6fb450f27 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 @@ -245,9 +245,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { for (int y = groundY + 1; y <= groundY + 32; y++) { assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir()); } - for (int y = 12; y < groundY; y++) { + int supportY = x < 2 ? groundY - 33 : 12; + for (int y = supportY; y < groundY; y++) { assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid()); } + if (x < 2) { + for (int y = 13; y < supportY; y++) { + assertTrue("Cave beneath support at " + x + "," + y, state(blocks, x, y, 0).isAir()); + } + } } } @@ -268,6 +274,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { assertTrue(state(blocks, 0, groundY + 1, 0).isAir()); } + @Test + public void flattenPreservesCavesBelowIntactGroundAtTheSkirtEdge() { + StructureStart start = desertStart(); + int groundY = start.getBoundingBox().minY() - 1; + int x = start.getBoundingBox().maxX() + 23; + int z = start.getBoundingBox().minZ(); + BoundingBox area = new BoundingBox(x, 0, z, x, 127, z); + Map blocks = new HashMap<>(); + for (int y = groundY - 35; y <= groundY; y++) { + BlockState block = y >= groundY - 34 && y <= groundY - 13 + ? Blocks.CAVE_AIR.defaultBlockState() : Blocks.STONE.defaultBlockState(); + put(blocks, x, y, z, block); + } + put(blocks, x, groundY - 1, z, Blocks.DIRT.defaultBlockState()); + put(blocks, x, groundY, z, Blocks.GRASS_BLOCK.defaultBlockState()); + NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, StructureTerrainMode.FLATTEN); + ((IrisStructureTerrain) target.terrain()).setHorizontalPadding(24).setFlattenRange(64); + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, List.of(target), (px, pz) -> groundY); + + for (int y = groundY - 34; y <= groundY - 13; y++) { + assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, x, y, z)); + } + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, x, groundY - 1, z)); + } + + @Test + public void flattenBridgesTheFoundationGapWithoutFillingACaveBelowItsSupport() { + StructureStart start = desertStart(); + int groundY = start.getBoundingBox().minY() - 1; + BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0); + Map blocks = new HashMap<>(); + put(blocks, 0, groundY, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + for (int y = groundY - 11; y <= groundY - 9; y++) { + put(blocks, 0, y, 0, Blocks.STONE.defaultBlockState()); + } + for (int y = groundY - 20; y <= groundY - 12; y++) { + put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState()); + } + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, + List.of(surfaceTarget(start, StructureTerrainMode.FLATTEN)), (x, z) -> groundY); + + for (int y = groundY - 8; y < groundY; y++) { + assertEquals("Foundation at " + y, Blocks.STONE.defaultBlockState(), state(blocks, 0, y, 0)); + } + for (int y = groundY - 20; y <= groundY - 12; y++) { + assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, 0, y, 0)); + } + } + @Test public void flattenLeavesBuriedAndSubmergedInstancesUntouched() { StructureStart start = desertStart(); @@ -619,10 +676,18 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { 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++) { + boolean originalFoundation = (x == 0 || x == 8) && (z == 0 || z == 8); + int supportY = originalFoundation ? 63 : 20; + for (int y = supportY; y <= groundY; y++) { assertTrue("Disconnected village ground at " + x + "," + y + "," + z, state(blocks, x, y, z).isSolid()); } + if (originalFoundation) { + for (int y = 21; y < supportY; y++) { + assertTrue("Cave beneath foundation at " + x + "," + y + "," + z, + state(blocks, x, y, z).isAir()); + } + } for (int y = groundY + 1; y <= 96; y++) { assertTrue("Roof remains above village at " + x + "," + y + "," + z, state(blocks, x, y, z).isAir()); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java index 535db2eb4..4b980eb35 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java @@ -9,6 +9,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; +import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.volmlib.util.collection.KList; import net.minecraft.SharedConstants; @@ -69,7 +70,8 @@ public class NativeStructureVolumeIndexTest { StructureStart start = swampHut(0, 0, 4, 6); BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); - KList volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start); + KList volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start, + StructureTerrainSettings.SOURCE, (x, z) -> 63); assertEquals(1, volumes.size()); NativeStructureVolume volume = volumes.getFirst(); @@ -84,8 +86,10 @@ public class NativeStructureVolumeIndexTest { @Test public void invalidStartsContributeNoVolumes() { - assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START)); - assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null)); + assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START, + StructureTerrainSettings.SOURCE, (x, z) -> 63)); + assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null, + StructureTerrainSettings.SOURCE, (x, z) -> 63)); } @Test @@ -323,7 +327,7 @@ public class NativeStructureVolumeIndexTest { BlockingResolver coordination = new BlockingResolver(); NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) -> new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, - -256, 0, -256, 256, 255, 256)); + -256, 0, -256, 256, 255, 256, null)); NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { coordination.volumesAt(engine, chunkX, chunkZ); return volumes.volumesAt(engine, chunkX, chunkZ); @@ -369,7 +373,7 @@ public class NativeStructureVolumeIndexTest { terrain.join(); } return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, - -256, 0, -256, 256, 100, 256)); + -256, 0, -256, 256, 100, 256, null)); }); ExecutorService owner = Executors.newSingleThreadExecutor(); ForkJoinPool workers = new ForkJoinPool(2); @@ -544,7 +548,8 @@ public class NativeStructureVolumeIndexTest { if (chunkX != originChunkX || chunkZ != originChunkZ) { return NativeStructureVolume.NONE; } - return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); + return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6), + StructureTerrainSettings.SOURCE, (x, z) -> 63); } } @@ -596,7 +601,7 @@ public class NativeStructureVolumeIndexTest { return new KList<>(new NativeStructureVolume( chunkX + ":" + chunkZ, 0, 0, 0, - 15, 255, 15)); + 15, 255, 15, null)); } } private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) { diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java index f644ab532..da482dfce 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java @@ -245,9 +245,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { for (int y = groundY + 1; y <= groundY + 32; y++) { assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir()); } - for (int y = 12; y < groundY; y++) { + int supportY = x < 2 ? groundY - 33 : 12; + for (int y = supportY; y < groundY; y++) { assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid()); } + if (x < 2) { + for (int y = 13; y < supportY; y++) { + assertTrue("Cave beneath support at " + x + "," + y, state(blocks, x, y, 0).isAir()); + } + } } } @@ -268,6 +274,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { assertTrue(state(blocks, 0, groundY + 1, 0).isAir()); } + @Test + public void flattenPreservesCavesBelowIntactGroundAtTheSkirtEdge() { + StructureStart start = desertStart(); + int groundY = start.getBoundingBox().minY() - 1; + int x = start.getBoundingBox().maxX() + 23; + int z = start.getBoundingBox().minZ(); + BoundingBox area = new BoundingBox(x, 0, z, x, 127, z); + Map blocks = new HashMap<>(); + for (int y = groundY - 35; y <= groundY; y++) { + BlockState block = y >= groundY - 34 && y <= groundY - 13 + ? Blocks.CAVE_AIR.defaultBlockState() : Blocks.STONE.defaultBlockState(); + put(blocks, x, y, z, block); + } + put(blocks, x, groundY - 1, z, Blocks.DIRT.defaultBlockState()); + put(blocks, x, groundY, z, Blocks.GRASS_BLOCK.defaultBlockState()); + NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, StructureTerrainMode.FLATTEN); + ((IrisStructureTerrain) target.terrain()).setHorizontalPadding(24).setFlattenRange(64); + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, List.of(target), (px, pz) -> groundY); + + for (int y = groundY - 34; y <= groundY - 13; y++) { + assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, x, y, z)); + } + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, x, groundY - 1, z)); + } + + @Test + public void flattenBridgesTheFoundationGapWithoutFillingACaveBelowItsSupport() { + StructureStart start = desertStart(); + int groundY = start.getBoundingBox().minY() - 1; + BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0); + Map blocks = new HashMap<>(); + put(blocks, 0, groundY, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + for (int y = groundY - 11; y <= groundY - 9; y++) { + put(blocks, 0, y, 0, Blocks.STONE.defaultBlockState()); + } + for (int y = groundY - 20; y <= groundY - 12; y++) { + put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState()); + } + + NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area, + List.of(surfaceTarget(start, StructureTerrainMode.FLATTEN)), (x, z) -> groundY); + + for (int y = groundY - 8; y < groundY; y++) { + assertEquals("Foundation at " + y, Blocks.STONE.defaultBlockState(), state(blocks, 0, y, 0)); + } + for (int y = groundY - 20; y <= groundY - 12; y++) { + assertEquals("Cave at " + y, Blocks.CAVE_AIR.defaultBlockState(), state(blocks, 0, y, 0)); + } + } + @Test public void flattenLeavesBuriedAndSubmergedInstancesUntouched() { StructureStart start = desertStart(); @@ -619,10 +676,18 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { 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++) { + boolean originalFoundation = (x == 0 || x == 8) && (z == 0 || z == 8); + int supportY = originalFoundation ? 63 : 20; + for (int y = supportY; y <= groundY; y++) { assertTrue("Disconnected village ground at " + x + "," + y + "," + z, state(blocks, x, y, z).isSolid()); } + if (originalFoundation) { + for (int y = 21; y < supportY; y++) { + assertTrue("Cave beneath foundation at " + x + "," + y + "," + z, + state(blocks, x, y, z).isAir()); + } + } for (int y = groundY + 1; y <= 96; y++) { assertTrue("Roof remains above village at " + x + "," + y + "," + z, state(blocks, x, y, z).isAir()); diff --git a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java index bb504508d..b26cc1540 100644 --- a/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java +++ b/adapters/bukkit/nms/v26_3_R1/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndexTest.java @@ -9,6 +9,7 @@ import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex; import art.arcane.iris.generation.runtime.IrisEngine; import art.arcane.iris.generation.runtime.Engine; import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; +import art.arcane.volmlib.nativelib.terrain.structure.StructureTerrainSettings; import art.arcane.iris.world.history.GenerationHistoryRuntimeRouter; import art.arcane.volmlib.util.collection.KList; import net.minecraft.SharedConstants; @@ -69,7 +70,8 @@ public class NativeStructureVolumeIndexTest { StructureStart start = swampHut(0, 0, 4, 6); BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); - KList volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start); + KList volumes = NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, start, + StructureTerrainSettings.SOURCE, (x, z) -> 63); assertEquals(1, volumes.size()); NativeStructureVolume volume = volumes.getFirst(); @@ -84,8 +86,10 @@ public class NativeStructureVolumeIndexTest { @Test public void invalidStartsContributeNoVolumes() { - assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START)); - assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null)); + assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, StructureStart.INVALID_START, + StructureTerrainSettings.SOURCE, (x, z) -> 63)); + assertNull(NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, null, + StructureTerrainSettings.SOURCE, (x, z) -> 63)); } @Test @@ -323,7 +327,7 @@ public class NativeStructureVolumeIndexTest { BlockingResolver coordination = new BlockingResolver(); NativeStructureVolumeIndex.OriginResolver volumes = (engine, chunkX, chunkZ) -> new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, - -256, 0, -256, 256, 255, 256)); + -256, 0, -256, 256, 255, 256, null)); NativeStructureVolumeIndex index = NativeStructureVolumeIndex.forTesting((engine, chunkX, chunkZ) -> { coordination.volumesAt(engine, chunkX, chunkZ); return volumes.volumesAt(engine, chunkX, chunkZ); @@ -369,7 +373,7 @@ public class NativeStructureVolumeIndexTest { terrain.join(); } return new KList<>(new NativeStructureVolume(chunkX + ":" + chunkZ, - -256, 0, -256, 256, 100, 256)); + -256, 0, -256, 256, 100, 256, null)); }); ExecutorService owner = Executors.newSingleThreadExecutor(); ForkJoinPool workers = new ForkJoinPool(2); @@ -544,7 +548,8 @@ public class NativeStructureVolumeIndexTest { if (chunkX != originChunkX || chunkZ != originChunkZ) { return NativeStructureVolume.NONE; } - return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6)); + return NativeStructureVolumeSource.appendPieces(null, STRUCTURE_KEY, swampHut(chunkX, chunkZ, 4, 6), + StructureTerrainSettings.SOURCE, (x, z) -> 63); } } @@ -596,7 +601,7 @@ public class NativeStructureVolumeIndexTest { return new KList<>(new NativeStructureVolume( chunkX + ":" + chunkZ, 0, 0, 0, - 15, 255, 15)); + 15, 255, 15, null)); } } private record ScopedEngine(IrisEngine engine, AtomicInteger scopes) { diff --git a/adapters/bukkit/plugin/src/test/gameplay/native-terrain-support.mjs b/adapters/bukkit/plugin/src/test/gameplay/native-terrain-support.mjs new file mode 100644 index 000000000..2586c3405 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/native-terrain-support.mjs @@ -0,0 +1,61 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +const world = 'native-terrain-support' +const air = new Set(['air', 'cave_air', 'void_air']) + +export default { + name: 'iris-native-terrain-support', + description: 'Verify native terrain fitting preserves buried caves and excludes trees from lowered ground.', + async run(context) { + const bot = context.bot + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Requires an isolated instance') + const position = bot.entity.position.clone() + const at = (x, y, z) => bot.blockAt(position.clone().set(x, y, z)) + const teleport = async (x, y, z) => { + await context.command(`/minecraft:tp @s ${x + 0.5} ${y} ${z + 0.5}`, /Teleported/i, 60000) + await context.waitUntil(() => Math.abs(bot.entity.position.x - x - 0.5) < 1 + && Math.abs(bot.entity.position.z - z - 0.5) < 1 && at(x, y, z), + { label: 'Coordinate chunk received', timeoutMs: 90000, intervalMs: 100 }) + } + await context.step('enter the seeded overworld', async () => { + if (context.options.command !== 'reuse') { + await context.command(`/iris create name=${world} type=overworld seed=1337`, + /Successfully created your world/i, 240000) + } + await context.command(`/iris tp ${world}`) + await context.waitUntil(async () => { + const snapshot = await context.observe() + const player = snapshot.players.find(entry => entry.username === bot.username) + return snapshot.worlds.some(entry => entry.id === player?.world && entry.name.endsWith(world)) + }, { label: 'Entered seeded overworld', timeoutMs: 90000, intervalMs: 100 }) + await context.command('/minecraft:gamemode spectator @s', /Spectator Mode/i) + await context.command('/minecraft:seed', /Seed:.*1337/) + }) + await context.step('preserve caves beneath the ruined portal terrain skirt', async () => { + await teleport(995, 44, -3611) + await context.waitUntil(() => at(990, 43, -3626) && at(992, 43, -3627), + { label: 'Cave columns received', timeoutMs: 90000, intervalMs: 100 }) + for (const [x, z, bottom, top] of [[990, -3626, 33, 54], [992, -3627, 34, 55]]) { + for (let y = bottom; y <= top; y++) { + const block = at(x, y, z) + context.expect(block && air.has(block.name), 'Native terrain fitting filled a buried cave', + { x, y, z, material: block?.name }) + } + await context.command(`/execute if block ${x} 43 ${z} minecraft:cave_air run say CAVE_PRESERVED_${x}`, + new RegExp(`CAVE_PRESERVED_${x}`)) + } + }) + await context.step('exclude the pine whose support is removed by village grading', async () => { + await teleport(-2601, 174, -7063) + await context.waitUntil(() => at(-2601, 167, -7063), + { label: 'Pine column received', timeoutMs: 90000, intervalMs: 100 }) + const block = at(-2601, 167, -7063) + context.expect(air.has(block.name), 'Tree remains above terrain removed by native grading', + { x: -2601, y: 167, z: -7063, material: block.name }) + await context.command('/execute if block -2601 167 -7063 minecraft:air run say PINE_EXCLUDED', + /PINE_EXCLUDED/) + }) + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/biomes/flat.json b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/biomes/flat.json new file mode 100644 index 000000000..3f0aae65e --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/biomes/flat.json @@ -0,0 +1,20 @@ +{ + "name": "Flat", + "derivative": "minecraft:plains", + "vanillaDerivative": "minecraft:plains", + "layers": [ + {"minHeight": 1, "maxHeight": 1, "palette": [{"block": "minecraft:lime_wool"}]}, + {"minHeight": 128, "maxHeight": 128, "palette": [{"block": "minecraft:stone"}]} + ], + "generators": [{"generator": "flat", "min": 32, "max": 32}], + "objects": [{ + "place": ["smolshroom1"], + "chance": 1, + "density": 8, + "mode": "ORGANIC_STILT", + "rotation": {"enabled": false}, + "scale": {"size": 0.75, "interpolation": "NONE"}, + "translate": {"y": 6}, + "stiltSettings": {"organicMaxScan": 8, "organicJitter": 0, "organicScratch": 0} + }] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/dimensions/object-scaling-fixture.json b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/dimensions/object-scaling-fixture.json new file mode 100644 index 000000000..5adde3f51 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/dimensions/object-scaling-fixture.json @@ -0,0 +1,15 @@ +{ + "name": "Object Scaling Fixture", + "version": 1, + "mode": {"type": "OVERWORLD"}, + "regions": ["flat"], + "focusRegion": "flat", + "focus": "flat", + "landChance": 1, + "environment": "NORMAL", + "dimensionHeight": {"min": 0, "max": 128}, + "logicalHeight": 128, + "fluidHeight": 0, + "bedrock": false, + "caveProfile": {"enabled": false} +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/generators/flat.json b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/generators/flat.json new file mode 100644 index 000000000..eda4f4611 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/generators/flat.json @@ -0,0 +1,5 @@ +{ + "seed": 310, + "interpolator": {"function": "NONE", "horizontalScale": 1}, + "composite": [{"seed": 310, "style": {"style": "FLAT"}}] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/objects/smolshroom1.iob b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/objects/smolshroom1.iob new file mode 100644 index 000000000..145e8409d Binary files /dev/null and b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/objects/smolshroom1.iob differ diff --git a/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/regions/flat.json b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/regions/flat.json new file mode 100644 index 000000000..8c3693b9d --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/object-scaling-pack/regions/flat.json @@ -0,0 +1,6 @@ +{ + "name": "Flat", + "landBiomes": ["flat"], + "seaBiomes": ["flat"], + "shoreBiomes": ["flat"] +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/object-scaling.mjs b/adapters/bukkit/plugin/src/test/gameplay/object-scaling.mjs new file mode 100644 index 000000000..27f723460 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/object-scaling.mjs @@ -0,0 +1,105 @@ +import { createHash } from 'node:crypto' +import { readFile, realpath } from 'node:fs/promises' +import path from 'node:path' + +export default { + name: 'iris-object-scaling', + description: 'Verify fractional mushroom scaling retains its cap and uses its stem for organic supports.', + async run(context) { + const directory = await realpath(context.server.directory) + const source = await readFile(path.join(directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Object scaling requires an isolated instance') + const pack = await realpath(path.join(directory, 'plugins', 'Iris', 'packs', 'object-scaling-fixture')) + context.expect(pack.startsWith(`${directory}${path.sep}`), 'Object scaling requires a local fixture pack') + const object = await readFile(path.join(pack, 'objects', 'smolshroom1.iob')) + const objectHash = createHash('sha256').update(object).digest('hex') + const biome = JSON.parse(await readFile(path.join(pack, 'biomes', 'flat.json'), 'utf8')) + const surfaceY = biome.generators[0].min + const originalStemY = surfaceY + biome.objects[0].translate.y + const expectedCapY = originalStemY + 1 + const maximumExtension = biome.objects[0].stiltSettings.organicMaxScan + context.expect(surfaceY === 32 && expectedCapY === 39 && maximumExtension === 8, + 'Expected the bounded organic support fixture', { surfaceY, expectedCapY, maximumExtension }) + const world = context.options.command ?? 'object_scaling_probe' + context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier') + await context.step('create the fractional mushroom fixture world', async () => { + const response = await context.command(`/iris create name=${world} type=object-scaling-fixture seed=1337`, + /Successfully created your world|World ready|Failed to create|Exception raised/i, 180000) + context.expect(/Successfully created your world|World ready/i.test(response), response) + await context.command(`/iris tp ${world}`) + await context.waitUntil(async () => { + const observation = await context.observe() + const player = observation.players.find(entry => entry.username === context.bot.username) + const current = observation.worlds.find(entry => entry.id === player?.world) + return current?.key === `iris:${world}` || current?.name.endsWith(world) + }, { label: 'Enter the scaling fixture world', timeoutMs: 60000, intervalMs: 100 }) + await context.command('/minecraft:gamemode spectator @s') + await context.waitUntil(() => context.bot.game.gameMode === 'spectator', + { label: 'Spectator mode', timeoutMs: 30000, intervalMs: 100 }) + await context.command('/minecraft:tp @s 0.5 48 0.5', /Teleported/i, 30000) + }) + const columns = [] + await context.step('client sees one cap and continuous stem supports', async () => { + const position = context.bot.entity.position.clone() + await context.waitUntil(() => { + if (Math.abs(context.bot.entity.position.x - 0.5) > 1 + || Math.abs(context.bot.entity.position.z - 0.5) > 1) return false + for (const x of [-16, 0, 16, 31]) { + for (const z of [-16, 0, 16, 31]) { + for (const y of [0, 32, 63]) { + if (!context.bot.blockAt(position.set(x, y, z))) return false + } + } + } + return true + }, { label: 'Complete mushroom fixture chunks', timeoutMs: 60000, intervalMs: 100 }) + for (let x = -16; x < 32; x++) { + for (let z = -16; z < 32; z++) { + const caps = [] + const stems = [] + for (let y = 1; y < 63; y++) { + const block = context.bot.blockAt(position.set(x, y, z)) + context.expect(block !== null, 'Every sampled mushroom block must be loaded', { x, y, z }) + if (block.name === 'red_mushroom_block') caps.push(y) + if (block.name === 'mushroom_stem') stems.push(y) + } + if (caps.length === 0) continue + context.expect(caps.length === 1, 'Mushroom cap was copied into its stem or support', { x, z, caps }) + const capY = caps[0] + context.expect(capY === expectedCapY, 'Fractional scaling changed the expected mushroom height', + { x, z, capY, expectedCapY }) + const bottomY = stems[0] + const stemBlocks = stems.length + context.expect(bottomY >= originalStemY - maximumExtension && bottomY <= surfaceY + 1, + 'Mushroom support must reach the surface within its configured extension limit', + { x, z, capY, bottomY, surfaceY, maximumExtension, stems }) + context.expect(stemBlocks === capY - bottomY + && stems.every((y, index) => y === bottomY + index), + 'Scaled mushroom must have a continuous stem from its cap to the ground', { x, z, capY, stems }) + const supportY = bottomY - 1 + const support = context.bot.blockAt(position.set(x, supportY, z))?.name + const above = context.bot.blockAt(position.set(x, capY + 1, z))?.name + context.expect(support === (supportY === surfaceY ? 'lime_wool' : 'stone'), + 'Mushroom stem must terminate on the fixture terrain', { x, z, capY, supportY, support }) + context.expect(above === 'air', 'Scaled mushroom has unexpected blocks above its cap', { x, z, capY, above }) + columns.push({ x, z, surfaceY, capY, bottomY, stemBlocks, + buriedStemBlocks: Math.max(0, surfaceY - bottomY + 1) }) + } + } + context.expect(columns.length >= 8, 'Expected at least eight scaled mushroom columns', { columns }) + }) + await context.step('server confirms the sampled stem and cap materials', async () => { + for (const [index, column] of columns.slice(0, 8).entries()) { + const { x, z, surfaceY, capY, bottomY } = column + const marker = `IRIS_SCALING_OK_${index}` + await context.command(`/minecraft:execute if block ${x} ${surfaceY + 1} ${z} minecraft:mushroom_stem` + + ` if block ${x} ${bottomY} ${z} minecraft:mushroom_stem` + + ` if block ${x} ${capY} ${z} minecraft:red_mushroom_block run minecraft:say ${marker}`, + new RegExp(marker), 10000) + } + }) + context.report.objectScaling = { world, scale: 0.75, object: 'smolshroom1', objectHash, + surfaceY, maximumExtension, checkedColumns: columns.length, + serverConfirmedColumns: Math.min(8, columns.length), columns } + } +} diff --git a/adapters/bukkit/plugin/src/test/gameplay/seed-selection.mjs b/adapters/bukkit/plugin/src/test/gameplay/seed-selection.mjs new file mode 100644 index 000000000..b2bfc299f --- /dev/null +++ b/adapters/bukkit/plugin/src/test/gameplay/seed-selection.mjs @@ -0,0 +1,82 @@ +import { createHash } from 'node:crypto' +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +const worlds = [ + { name: 'seed-selection-default', seed: '1337', argument: '' }, + { name: 'seed-selection-positive', seed: '78264193', argument: ' seed=78264193' }, + { name: 'seed-selection-negative', seed: '-734829104958217364', argument: ' seed=-734829104958217364' }, + { name: 'seed-selection-zero', seed: '0', argument: ' seed=0' } +] + +export default { + name: 'iris-seed-selection', + description: 'Verify omitted, positive, negative, and zero creation seeds against visible terrain and a cold restart.', + async run(context) { + const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8') + context.expect(/^isolated=true\s*$/m.test(source), 'Seed selection requires an isolated instance') + const wait = (predicate, label) => context.waitUntil(predicate, { label, timeoutMs: 60000, intervalMs: 100 }) + const restart = context.options.command === 'restart-check' + const stateFile = path.join(context.server.directory, '.seed-selection.json') + const saved = restart ? JSON.parse(await readFile(stateFile, 'utf8')) : undefined + const jarHash = createHash('sha256').update(await readFile(path.join(context.server.directory, 'plugins', 'Iris.jar'))).digest('hex') + const processId = (await context.observe()).processId + if (restart) { + context.expect(saved.processId !== processId, 'Restart-check requires a cold server restart') + context.expect(saved.jarHash === jarHash, 'Restart-check requires the same Iris jar') + } + const outputs = {} + for (const world of worlds) { + await context.step(`${restart ? 'Reload' : 'Create'} ${world.name} with seed ${world.seed}`, async () => { + if (!restart) { + await context.command(`/iris create name=${world.name} type=cohesion${world.argument}`, + /Successfully created your world/i, 180000) + } + await context.command(`/iris tp ${world.name}`) + await wait(async () => { + const observation = await context.observe() + const player = observation.players.find(entry => entry.username === context.bot.username) + const current = observation.worlds.find(entry => entry.id === player?.world) + return current?.key === `iris:${world.name}` || current?.name.endsWith(world.name) + }, `Enter ${world.name}`) + await context.command('/minecraft:gamemode spectator @s') + await wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode') + await context.command('/minecraft:seed', new RegExp(`Seed:\\s*\\[?${world.seed}\\]?(?:\\s|$)`)) + const x = 1024 + const z = -1024 + await context.command(`/minecraft:tp @s ${x + 8.5} 48 ${z + 8.5}`, /Teleported/i, 30000) + await wait(() => Math.abs(context.bot.entity.position.x - x - 8.5) < 1 + && Math.abs(context.bot.entity.position.z - z - 8.5) < 1, 'Sample teleport') + const position = context.bot.entity.position.clone() + await wait(() => context.bot.blockAt(position.clone().set(x, 0, z)) + && context.bot.blockAt(position.clone().set(x + 15, 63, z + 15)), 'Complete sample chunk') + const digest = createHash('sha256') + let stone = 0 + for (let dx = 0; dx < 16; dx++) { + for (let dz = 0; dz < 16; dz++) { + for (let y = 0; y < 64; y++) { + const block = context.bot.blockAt(position.set(x + dx, y, z + dz)) + context.expect(block !== null, 'Missing sampled block', { x: x + dx, y, z: z + dz }) + digest.update(`${block.stateId},`) + if (block.name === 'stone') stone++ + } + } + } + context.expect(stone > 1000, 'Fixture terrain was not generated', { world: world.name, stone }) + const blocks = digest.digest('hex') + outputs[world.name] = { seed: world.seed, blocks } + if (restart) { + context.expect(saved.worlds[world.name].seed === world.seed, 'Seed expectation changed after restart') + context.expect(saved.worlds[world.name].blocks === blocks, 'Persisted terrain changed after restart', + { world: world.name, before: saved.worlds[world.name].blocks, after: blocks }) + } + }) + } + const signatures = new Set(Object.values(outputs).map(output => output.blocks)) + context.expect(signatures.size === worlds.length, 'Different requested seeds generated identical sample chunks', outputs) + context.report.seedSelection = { restart, jarHash, worlds: outputs } + if (!restart) { + await writeFile(stateFile, JSON.stringify({ processId, jarHash, worlds: outputs })) + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java index baa041b15..8bce7fdc5 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/IrisCaveCarver3D.java @@ -316,6 +316,11 @@ public class IrisCaveCarver3D { for (int lz = 0; lz < 16; lz++) { int z = z0 + lz; int index = PowerOfTwoCoordinates.packLocal16(lx, lz); + clampedWeights[index] = clampColumnWeight(columnWeights[index]); + if (clampedWeights[index] <= resolvedMinWeight) { + columnMaxY[index] = minY - 1; + continue; + } int columnSurfaceY; if (precomputedSurfaceHeights != null && precomputedSurfaceHeights.length > index) { columnSurfaceY = precomputedSurfaceHeights[index]; @@ -340,7 +345,6 @@ public class IrisCaveCarver3D { columnThreshold[index] = (thresholdDensity == null ? constantThreshold : thresholdDensity.fitDouble(thresholdMin, thresholdMax, x, z)) - thresholdBias; - clampedWeights[index] = clampColumnWeight(columnWeights[index]); } } 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 cc32d1d6e..a04d02033 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 @@ -56,7 +56,6 @@ public class MantleCarvingComponent extends IrisMantleComponent { private static final int FIELD_SIZE = CHUNK_SIZE + (BLEND_RADIUS * 2); private static final double MIN_WEIGHT = 0.08D; private static final double THRESHOLD_PENALTY = 0.24D; - private static final int MAX_BLENDED_PROFILE_PASSES = 2; private static final int MAX_POOLED_WEIGHT_BUFFERS = 64; /** Lattice columns kept for neighbouring chunks: about 3 KB each on land, roughly 50 MB when full. */ private static final int DENSITY_GRID_COLUMNS = 16_384; @@ -348,9 +347,9 @@ public class MantleCarvingComponent extends IrisMantleComponent { columnWeightedProfiles.add(new WeightedProfile(profile, weights, averageWeight, null, columnWeightedProfiles.size())); } - List blendedProfiles = limitAndMergeBlendedProfiles(columnWeightedProfiles, MAX_BLENDED_PROFILE_PASSES, CHUNK_AREA); + columnWeightedProfiles.sort(MantleCarvingComponent::compareByCarveOrder); List resolvedProfiles = resolveDimensionCarvingProfiles(chunkX, chunkZ, resolverState, blendScratch); - resolvedProfiles.addAll(blendedProfiles); + resolvedProfiles.addAll(columnWeightedProfiles); return resolvedProfiles; } @@ -619,67 +618,6 @@ public class MantleCarvingComponent extends IrisMantleComponent { return surfaceHeights; } - private static List limitAndMergeBlendedProfiles(List blendedProfiles, int maxProfiles) { - return limitAndMergeBlendedProfiles(blendedProfiles, maxProfiles, CHUNK_AREA); - } - - private static List limitAndMergeBlendedProfiles(List blendedProfiles, int maxProfiles, int areaSize) { - if (blendedProfiles == null || blendedProfiles.isEmpty()) { - return new ArrayList<>(); - } - - int clampedLimit = Math.max(1, maxProfiles); - List rankedProfiles = new ArrayList<>(blendedProfiles); - rankedProfiles.sort(MantleCarvingComponent::compareBySelectionRank); - List keptProfiles = new ArrayList<>(); - int keptCount = Math.min(clampedLimit, rankedProfiles.size()); - for (int index = 0; index < keptCount; index++) { - keptProfiles.add(rankedProfiles.get(index)); - } - - if (rankedProfiles.size() > keptCount) { - for (int columnIndex = 0; columnIndex < areaSize; columnIndex++) { - int dominantIndex = 0; - double dominantWeight = Double.NEGATIVE_INFINITY; - for (int keptIndex = 0; keptIndex < keptProfiles.size(); keptIndex++) { - double keptWeight = keptProfiles.get(keptIndex).columnWeights[columnIndex]; - if (keptWeight > dominantWeight) { - dominantWeight = keptWeight; - dominantIndex = keptIndex; - } - } - - double droppedWeight = 0D; - for (int droppedIndex = keptCount; droppedIndex < rankedProfiles.size(); droppedIndex++) { - droppedWeight += rankedProfiles.get(droppedIndex).columnWeights[columnIndex]; - } - if (droppedWeight <= 0D) { - continue; - } - - WeightedProfile dominantProfile = keptProfiles.get(dominantIndex); - double mergedWeight = dominantProfile.columnWeights[columnIndex] + droppedWeight; - dominantProfile.columnWeights[columnIndex] = clampWeight(mergedWeight); - } - } - - List mergedProfiles = new ArrayList<>(); - for (WeightedProfile keptProfile : keptProfiles) { - double averageWeight = computeAverageWeight(keptProfile.columnWeights, areaSize); - mergedProfiles.add(new WeightedProfile(keptProfile.profile, keptProfile.columnWeights, averageWeight, keptProfile.worldYRange, keptProfile.sequence)); - } - mergedProfiles.sort(MantleCarvingComponent::compareByCarveOrder); - return mergedProfiles; - } - - private static int compareBySelectionRank(WeightedProfile a, WeightedProfile b) { - int weightOrder = Double.compare(b.averageWeight, a.averageWeight); - if (weightOrder != 0) { - return weightOrder; - } - return Integer.compare(a.sequence, b.sequence); - } - private static int compareByCarveOrder(WeightedProfile a, WeightedProfile b) { int weightOrder = Double.compare(a.averageWeight, b.averageWeight); if (weightOrder != 0) { @@ -689,10 +627,6 @@ public class MantleCarvingComponent extends IrisMantleComponent { } private static double computeAverageWeight(double[] weights) { - return computeAverageWeight(weights, CHUNK_AREA); - } - - private static double computeAverageWeight(double[] weights, int areaSize) { if (weights == null || weights.length == 0) { return 0D; } @@ -700,7 +634,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { for (double weight : weights) { sum += weight; } - return sum / Math.max(1, areaSize); + return sum / CHUNK_AREA; } private static double clampWeight(double value) { @@ -730,10 +664,6 @@ public class MantleCarvingComponent extends IrisMantleComponent { this.worldYRange = worldYRange; this.sequence = sequence; } - - private double averageWeight() { - return averageWeight; - } } private static final class BlendScratch { 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 3074fbe9f..2db689d42 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 @@ -930,7 +930,7 @@ public class IrisEngine implements Engine { queryMaximumX, queryMaximumZ); for (NativeStructureVolume volume : resolved) { - if (!volume.intersectsRect(minimumX, minimumZ, maximumX, maximumZ)) { + if (!volume.intersectsInfluenceRect(minimumX, minimumZ, maximumX, maximumZ)) { continue; } if (matches == null) { diff --git a/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndex.java b/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndex.java index 6d8458987..39da749e9 100644 --- a/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndex.java +++ b/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeIndex.java @@ -115,7 +115,7 @@ public final class NativeStructureVolumeIndex { for (int chunkX = fromChunkX; chunkX <= toChunkX; chunkX++) { for (int chunkZ = fromChunkZ; chunkZ <= toChunkZ; chunkZ++) { for (NativeStructureVolume volume : chunkVolumes(engine, runtimeId, chunkX, chunkZ)) { - if (!volume.intersectsRect(minX, minZ, maxX, maxZ)) { + if (!volume.intersectsInfluenceRect(minX, minZ, maxX, maxZ)) { continue; } if (matches == null) { @@ -150,7 +150,7 @@ public final class NativeStructureVolumeIndex { for (int originX = chunkX - ORIGIN_REACH_CHUNKS; originX <= chunkX + ORIGIN_REACH_CHUNKS; originX++) { for (int originZ = chunkZ - ORIGIN_REACH_CHUNKS; originZ <= chunkZ + ORIGIN_REACH_CHUNKS; originZ++) { for (NativeStructureVolume volume : scopedOriginVolumes(engine, originX, originZ, pinnedRuntimeId)) { - if (!volume.intersectsRect(minX, minZ, maxX, maxZ)) { + if (!volume.intersectsInfluenceRect(minX, minZ, maxX, maxZ)) { continue; } if (volumes == null) { diff --git a/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeMemo.java b/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeMemo.java index e9b762a78..96d523c76 100644 --- a/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeMemo.java +++ b/core/src/main/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeMemo.java @@ -54,7 +54,7 @@ public final class NativeStructureVolumeMemo { for (int chunkX = fromChunkX; chunkX <= toChunkX; chunkX++) { for (int chunkZ = fromChunkZ; chunkZ <= toChunkZ; chunkZ++) { for (NativeStructureVolume volume : chunkVolumes(engine, hooks, chunkX, chunkZ)) { - if (!volume.intersectsRect(minX, minZ, maxX, maxZ)) { + if (!volume.intersectsInfluenceRect(minX, minZ, maxX, maxZ)) { continue; } if (matches == null) { 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 06f295b60..6a7a4b651 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 @@ -49,6 +49,7 @@ import art.arcane.volmlib.util.math.BlockPosition; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterMarker; +import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; import java.util.Comparator; import java.util.Objects; @@ -1135,10 +1136,14 @@ final class IrisObjectPlacementRunner { int envelopeMinY = projection != null ? Integer.MIN_VALUE : worldY + bounds.minY() - warpMargin; int envelopeMaxY = projection != null ? Integer.MAX_VALUE : worldY + bounds.maxY() + warpMargin; boolean envelopeMeetsPiece = false; + boolean includesSurfaceCuts = false; for (NativeStructureVolume volume : volumes) { if (volume.intersects(minX, envelopeMinY, minZ, maxX, envelopeMaxY, maxZ)) { envelopeMeetsPiece = true; - break; + } + if (volume.surfaceAdjustment() != null && volume.intersectsInfluenceRect(minX, minZ, maxX, maxZ)) { + envelopeMeetsPiece = true; + includesSurfaceCuts = true; } } @@ -1148,6 +1153,7 @@ final class IrisObjectPlacementRunner { self.readLock.lock(); try { + Long2LongOpenHashMap surfaceCuts = null; VectorMap.Cursor cursor = self.blocks.cursor(); while (cursor.next()) { NativeBlockState state = cursor.value(); @@ -1187,6 +1193,28 @@ final class IrisObjectPlacementRunner { return true; } } + if (!includesSurfaceCuts) { + continue; + } + if (surfaceCuts == null) { + surfaceCuts = new Long2LongOpenHashMap(); + surfaceCuts.defaultReturnValue(Long.MIN_VALUE); + } + long column = ((long) xx << 32) ^ (zz & 0xffffffffL); + long cut = surfaceCuts.get(column); + if (cut == Long.MIN_VALUE) { + int surfaceY = Engine.hostHeight(engine, xx, zz, true) + engine.getMinHeight(); + int targetY = NativeStructureVolume.surfaceCutY(volumes, xx, zz, surfaceY); + cut = ((long) surfaceY << 32) ^ (targetY & 0xffffffffL); + surfaceCuts.put(column, cut); + } + int surfaceY = (int) (cut >> 32); + int targetY = (int) cut; + int margin = projection == null ? warpMargin : 0; + if (targetY < surfaceY && worldBlockY > (long) targetY - margin + && worldBlockY <= (long) surfaceY + 1 + margin) { + return true; + } } } finally { self.readLock.unlock(); diff --git a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java index d44c74a18..804f2e484 100644 --- a/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java +++ b/core/src/main/java/art/arcane/iris/structure/object/IrisObjectTransforms.java @@ -124,6 +124,7 @@ final class IrisObjectTransforms { interpolation = IrisObjectPlacementScaleInterpolator.NONE; } boolean savedOrigin = origin == ScaleOrigin.SAVED; + boolean fractional = scale != Math.rint(scale); List voxels; IrisObject output; TreeGrounding grounding; @@ -148,26 +149,33 @@ final class IrisObjectTransforms { sourcePosition.getBlockZ() + sourceCenter.getZ()); IrisBlockVector minimum = scaledMinimum(position, scale); IrisBlockVector maximum = scaledMaximum(position, scale); + IrisVector corner = fractional ? new IrisVector(position.getX() * scale, + position.getY() * scale, position.getZ() * scale) : minimum; if (!savedOrigin) { minimum.subtract(targetCenter); maximum.subtract(targetCenter); + if (fractional) { + corner.subtract(targetCenter); + } } TileData tile = self.states.get(sourcePosition); - voxels.add(new ScaledVoxel(sourcePosition.clone(), minimum, maximum, entry.getValue(), + voxels.add(new ScaledVoxel(sourcePosition.clone(), minimum, maximum, corner, entry.getValue(), tile == null ? null : tile.clone(), grounding != null && grounding.isCanopy( sourcePosition.getBlockX(), sourcePosition.getBlockY(), sourcePosition.getBlockZ()))); } } finally { self.readLock.unlock(); } - if (scale != Math.rint(scale)) { + Set canopy = grounding == null ? null : new HashSet<>(); + if (fractional) { voxels.sort(Comparator.comparingInt((ScaledVoxel voxel) -> voxel.source().getBlockX()) .thenComparingInt(voxel -> voxel.source().getBlockY()) .thenComparingInt(voxel -> voxel.source().getBlockZ())); - } - Set canopy = grounding == null ? null : new HashSet<>(); - for (ScaledVoxel voxel : voxels) { - writeScaledVoxel(output, voxel, canopy); + writeResampledVoxels(output, voxels, scale, canopy); + } else { + for (ScaledVoxel voxel : voxels) { + writeScaledVoxel(output, voxel, canopy); + } } VectorMap scaledBlocks = output.blocks; @@ -206,7 +214,7 @@ final class IrisObjectTransforms { for (int x = minimum.getBlockX(); x <= maximum.getBlockX(); x++) { for (int z = minimum.getBlockZ(); z <= maximum.getBlockZ(); z++) { NativeBlockState state = output.blocks.get(new IrisBlockVector(x, y, z)); - if (state != null) { + if (state != null && !canopy.contains(new TreeBlockCanvas.Vec(x, y, z))) { roots.add(new TreeGrounding.Root(x, y, z, state)); } } @@ -225,23 +233,53 @@ final class IrisObjectTransforms { position.setZ(z); for (int y = minimum.getBlockY(); y <= maximum.getBlockY(); y++) { position.setY(y); - output.blocks.put(position, voxel.block()); - if (canopy != null) { - TreeBlockCanvas.Vec cell = new TreeBlockCanvas.Vec(x, y, z); - if (voxel.canopy()) { - canopy.add(cell); - } else { - canopy.remove(cell); + writeScaledBlock(output, position, voxel, canopy); + } + } + } + } + + private static void writeResampledVoxels(IrisObject output, List voxels, double scale, + Set canopy) { + VectorMap samples = new VectorMap<>(); + IrisBlockVector position = new IrisBlockVector(); + for (ScaledVoxel voxel : voxels) { + for (int x = voxel.minimum().getBlockX(); x <= voxel.maximum().getBlockX(); x++) { + position.setX(x); + for (int z = voxel.minimum().getBlockZ(); z <= voxel.maximum().getBlockZ(); z++) { + position.setZ(z); + for (int y = voxel.minimum().getBlockY(); y <= voxel.maximum().getBlockY(); y++) { + position.setY(y); + ScaledVoxel previous = samples.get(position); + if (previous == null || voxel.overlap(position, scale) >= previous.overlap(position, scale)) { + samples.put(position, voxel); } } - if (voxel.tile() == null) { - output.states.remove(position); - } else { - output.states.put(position, voxel.tile().clone()); - } } } } + for (Map.Entry entry : samples) { + writeScaledBlock(output, entry.getKey(), entry.getValue(), canopy); + } + } + + private static void writeScaledBlock(IrisObject output, IrisBlockVector position, ScaledVoxel voxel, + Set canopy) { + output.blocks.put(position, voxel.block()); + if (canopy != null) { + TreeBlockCanvas.Vec cell = new TreeBlockCanvas.Vec(position.getBlockX(), + position.getBlockY(), position.getBlockZ()); + if (voxel.canopy()) { + canopy.add(cell); + } else { + canopy.remove(cell); + } + } + if (voxel.tile() == null) { + output.states.remove(position); + } else { + output.states.put(position, voxel.tile().clone()); + } } private static IrisObject createCenteredScaledObject(IrisObject source, double scale) { @@ -541,7 +579,16 @@ final class IrisObjectTransforms { } private record ScaledVoxel(IrisBlockVector source, IrisBlockVector minimum, IrisBlockVector maximum, - NativeBlockState block, TileData tile, boolean canopy) { + IrisVector corner, NativeBlockState block, TileData tile, boolean canopy) { + private double overlap(IrisBlockVector position, double scale) { + return overlap(corner.getX(), position.getX(), scale) + * overlap(corner.getY(), position.getY(), scale) + * overlap(corner.getZ(), position.getZ(), scale); + } + + private static double overlap(double source, double target, double scale) { + return Math.max(0D, Math.min(source + scale, target + 1D) - Math.max(source, target)); + } } private enum ScaleOrigin { diff --git a/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolderTest.java b/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolderTest.java index 348aaba58..020f6db50 100644 --- a/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolderTest.java +++ b/core/src/test/java/art/arcane/iris/generation/chunk/ChunkDataHunkHolderTest.java @@ -1,17 +1,26 @@ package art.arcane.iris.generation.chunk; +import art.arcane.iris.platform.bukkit.nms.INMS; +import art.arcane.iris.platform.bukkit.nms.INMSBinding; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import org.bukkit.block.data.BlockData; import org.bukkit.generator.ChunkGenerator; import org.junit.Test; +import org.mockito.MockedStatic; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CountDownLatch; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; public class ChunkDataHunkHolderTest { @@ -90,6 +99,53 @@ public class ChunkDataHunkHolderTest { } } + @Test + public void fallbackAppliesExplicitCaveAirBetweenSolidRunsWithNegativeMinimumHeight() { + ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); + when(chunk.getMinHeight()).thenReturn(-256); + when(chunk.getMaxHeight()).thenReturn(512); + ChunkDataHunkHolder holder = new ChunkDataHunkHolder(chunk); + BlockData dirt = mock(BlockData.class); + BlockData coal = mock(BlockData.class); + BlockData caveAir = mock(BlockData.class); + NativeBlockState dirtState = state(dirt); + NativeBlockState coalState = state(coal); + NativeBlockState caveAirState = state(caveAir); + BlockData[] applied = new BlockData[768]; + Arrays.fill(applied, dirt); + for (int y = 0; y <= 75; y++) { + holder.setRaw(14, y + 256, 6, y < 33 ? dirtState : y <= 54 ? caveAirState : coalState); + } + doAnswer(call -> { + int y = call.getArgument(1); + applied[y + 256] = call.getArgument(3); + return null; + }).when(chunk).setBlock(anyInt(), anyInt(), anyInt(), any(BlockData.class)); + doAnswer(call -> { + int from = call.getArgument(1); + int to = call.getArgument(4); + BlockData block = call.getArgument(6); + Arrays.fill(applied, from + 256, to + 256, block); + return null; + }).when(chunk).setRegion(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(BlockData.class)); + + try (MockedStatic nms = mockStatic(INMS.class)) { + nms.when(INMS::get).thenReturn(mock(INMSBinding.class)); + holder.apply(); + } + + for (int y = -256; y < 512; y++) { + BlockData expected = y >= 33 && y <= 54 ? caveAir : y >= 55 && y <= 75 ? coal : dirt; + assertSame("column at " + y, expected, applied[y + 256]); + } + } + + private static NativeBlockState state(BlockData data) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.nativeHandle()).thenReturn(data); + return state; + } + private static ChunkDataHunkHolder holder(int height) { ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); when(chunk.getMinHeight()).thenReturn(0); diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java index 421f58386..ced1764da 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/IrisCaveCarver3DNearParityTest.java @@ -231,6 +231,53 @@ public class IrisCaveCarver3DNearParityTest { } } + @Test + public void sparseProfilesSampleOnlyActiveColumnsAndDiscardPreviousPassColumns() { + for (int sampleStep : new int[]{1, 2, 3}) { + for (boolean adaptive : new boolean[]{false, true}) { + Engine engine = createEngine(128, 110); + AtomicInteger heightSamples = new AtomicInteger(); + doAnswer(invocation -> { + heightSamples.incrementAndGet(); + return 110; + }).when(engine).getHeight(anyInt(), anyInt(), anyBoolean()); + IrisCaveProfile profile = createProfile(false, false) + .setSampleStep(sampleStep).setAdaptiveSampling(adaptive) + .setAllowFluid(false).setAllowSurfaceBreak(false) + .setDensityThreshold(new IrisStyledRange(2D, 2D, new IrisGeneratorStyle(NoiseStyle.FLAT))); + IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile); + double[] weights = new double[256]; + weights[0] = 1D; + weights[255] = 1D; + WriterCapture first = createWriterCapture(128); + + carver.carve(first.writer, 0, 0, weights, 0.08D, 0.24D); + + assertEquals(2, heightSamples.get()); + assertFalse(first.carvedCells.isEmpty()); + for (String cell : first.carvedCells) { + int x = coordinate(cell, 0); + int z = coordinate(cell, 2); + assertTrue(x == 0 && z == 0 || x == 15 && z == 15); + } + + Arrays.fill(weights, 0D); + weights[(7 << 4) | 3] = 1D; + heightSamples.set(0); + WriterCapture second = createWriterCapture(128); + + carver.carve(second.writer, 0, 0, weights, 0.08D, 0.24D); + + assertEquals(1, heightSamples.get()); + assertFalse(second.carvedCells.isEmpty()); + for (String cell : second.carvedCells) { + assertEquals(7, coordinate(cell, 0)); + assertEquals(3, coordinate(cell, 2)); + } + } + } + } + @Test public void verticalDensityPreservesTwoBlockModuleTunnelsWithoutBleedingAcrossBands() throws Exception { for (int step : new int[]{2, 4, 8}) { diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleCarvingComponentJunctionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleCarvingComponentJunctionTest.java new file mode 100644 index 000000000..c3bccb138 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleCarvingComponentJunctionTest.java @@ -0,0 +1,173 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.cave.IrisCaveProfile; +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EngineMetrics; +import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.world.IrisWorld; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.stream.ProceduralStream; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MantleCarvingComponentJunctionTest { + @Test + public void twoCaveProfilesBlendAcrossBothSidesOfAChunkBoundary() throws Exception { + Fixture fixture = fixture(false); + fixture.generate(false); + + assertTrue(fixture.weight(-1, fixture.west(), 15, 12) > 0D); + assertTrue(fixture.weight(-1, fixture.east(), 15, 12) > 0D); + assertTrue(fixture.weight(0, fixture.west(), 0, 12) > 0D); + assertTrue(fixture.weight(0, fixture.east(), 0, 12) > 0D); + assertEquals(fixture.weight(-1, fixture.west(), 15, 12), + fixture.weight(0, fixture.east(), 0, 12), 0D); + assertEquals(fixture.weight(-1, fixture.east(), 15, 12), + fixture.weight(0, fixture.west(), 0, 12), 0D); + } + + @Test + public void thirdProfileOutsideTheBlendRadiusDoesNotChangeBoundaryWeights() throws Exception { + Fixture control = fixture(false); + Fixture junction = fixture(true); + control.generate(false); + junction.generate(false); + + assertEquals("A third biome outside the local blend radius must not remove the east cave from the west chunk", + control.weight(-1, control.east(), 15, 12), + junction.weight(-1, junction.east(), 15, 12), 0D); + assertEquals("A third biome outside the local blend radius must not remove the west cave from the east chunk", + control.weight(0, control.west(), 0, 12), + junction.weight(0, junction.west(), 0, 12), 0D); + } + + @Test + public void threeWayJunctionRetainsEachProfileOnlyInsideItsLocalBlend() throws Exception { + Fixture fixture = fixture(true); + fixture.generate(false); + + for (int chunkX : new int[]{-1, 0}) { + int boundaryX = chunkX == -1 ? 15 : 0; + assertEquals(3, fixture.captured().get(chunkX).size()); + assertTrue(fixture.weight(chunkX, fixture.west(), boundaryX, 6) > 0D); + assertTrue(fixture.weight(chunkX, fixture.east(), boundaryX, 6) > 0D); + assertTrue(fixture.weight(chunkX, fixture.north(), boundaryX, 6) > 0D); + assertEquals(0D, fixture.weight(chunkX, fixture.north(), boundaryX, 12), 0D); + assertEquals(0D, fixture.weight(chunkX, fixture.west(), boundaryX, 0), 0D); + assertEquals(0D, fixture.weight(chunkX, fixture.east(), boundaryX, 0), 0D); + assertEquals(1D, fixture.weight(chunkX, fixture.north(), boundaryX, 0), 0D); + } + } + + @Test + public void junctionWeightsRemainStableAcrossGenerationOrder() throws Exception { + Fixture forward = fixture(true); + Fixture reverse = fixture(true); + forward.generate(false); + reverse.generate(true); + + for (int chunkX : new int[]{-1, 0}) { + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + assertEquals(forward.weight(chunkX, forward.west(), x, z), + reverse.weight(chunkX, reverse.west(), x, z), 0D); + assertEquals(forward.weight(chunkX, forward.east(), x, z), + reverse.weight(chunkX, reverse.east(), x, z), 0D); + assertEquals(forward.weight(chunkX, forward.north(), x, z), + reverse.weight(chunkX, reverse.north(), x, z), 0D); + } + } + } + } + + @SuppressWarnings("unchecked") + private static Fixture fixture(boolean thirdProfile) throws ReflectiveOperationException { + Engine engine = mock(Engine.class); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getEngine()).thenReturn(engine); + MantleCarvingComponent component = new MantleCarvingComponent(engineMantle); + when(engine.getDimension()).thenReturn(new IrisDimension()); + when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(0).maxHeight(128).build()); + when(engine.getMetrics()).thenReturn(new EngineMetrics(16)); + IrisComplex complex = mock(IrisComplex.class); + when(complex.allowsMantleChunkWrite(anyInt(), anyInt())).thenReturn(true); + ProceduralStream heights = mock(ProceduralStream.class); + when(heights.getDouble(anyDouble(), anyDouble())).thenReturn(100D); + when(complex.getNaturalHeightStream()).thenReturn(heights); + ProceduralStream regions = mock(ProceduralStream.class); + ProceduralStream surfaces = mock(ProceduralStream.class); + ProceduralStream caves = mock(ProceduralStream.class); + ProceduralStream fluids = mock(ProceduralStream.class); + when(complex.getRegionStream()).thenReturn(regions); + when(complex.getNaturalTrueBiomeStream()).thenReturn(surfaces); + when(complex.getCaveBiomeStream()).thenReturn(caves); + when(complex.getFluidStream()).thenReturn(fluids); + IrisCaveProfile west = new IrisCaveProfile().setEnabled(true); + IrisCaveProfile east = new IrisCaveProfile().setEnabled(true); + IrisCaveProfile north = new IrisCaveProfile().setEnabled(true); + IrisBiome westBiome = new IrisBiome().setCaveProfile(west); + IrisBiome eastBiome = new IrisBiome().setCaveProfile(east); + IrisBiome northBiome = new IrisBiome().setCaveProfile(north); + when(caves.get(anyDouble(), anyDouble())).thenAnswer(call -> { + double x = call.getArgument(0); + double z = call.getArgument(1); + return thirdProfile && z < 7D ? northBiome : x < 0D ? westBiome : eastBiome; + }); + ChunkContext context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(complex); + MantleWriter writer = mock(MantleWriter.class); + Mantle storage = mock(Mantle.class); + when(storage.getWorldHeight()).thenReturn(128); + when(writer.getMantle()).thenReturn(storage); + Map> captured = new HashMap<>(); + Map carvers = new IdentityHashMap<>(); + for (IrisCaveProfile profile : new IrisCaveProfile[]{west, east, north}) { + IrisCaveCarver3D carver = mock(IrisCaveCarver3D.class); + doAnswer(call -> { + int chunkX = call.getArgument(1); + double[] weights = call.getArgument(3); + captured.computeIfAbsent(chunkX, ignored -> new IdentityHashMap<>()).put(profile, weights.clone()); + return 0; + }).when(carver).carve(any(), anyInt(), anyInt(), any(), anyDouble(), anyDouble(), + any(), any(), any(), any(), any()); + carvers.put(profile, carver); + } + Field profileCarvers = MantleCarvingComponent.class.getDeclaredField("profileCarvers"); + profileCarvers.setAccessible(true); + profileCarvers.set(component, carvers); + return new Fixture(component, context, writer, west, east, north, captured); + } + + private record Fixture(MantleCarvingComponent component, ChunkContext context, MantleWriter writer, + IrisCaveProfile west, IrisCaveProfile east, IrisCaveProfile north, + Map> captured) { + private void generate(boolean reverse) { + component.generateLayer(writer, reverse ? 0 : -1, 0, context); + component.generateLayer(writer, reverse ? -1 : 0, 0, context); + } + + private double weight(int chunkX, IrisCaveProfile profile, int x, int z) { + double[] weights = captured.get(chunkX).get(profile); + return weights == null ? 0D : weights[(x << 4) | z]; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleCarvingComponentTop2BlendTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleCarvingComponentTop2BlendTest.java deleted file mode 100644 index b4cb8cf64..000000000 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleCarvingComponentTop2BlendTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package art.arcane.iris.generation.mantle; - -import art.arcane.iris.generation.cave.IrisCaveProfile; -import org.junit.BeforeClass; -import org.junit.Test; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.Assert.assertEquals; - -public class MantleCarvingComponentTop2BlendTest { - private static Constructor weightedProfileConstructor; - private static Method limitMethod; - private static Field profileField; - private static Field columnWeightsField; - - @BeforeClass - public static void setup() throws Exception { - Class weightedProfileClass = Class.forName("art.arcane.iris.generation.mantle.MantleCarvingComponent$WeightedProfile"); - weightedProfileConstructor = weightedProfileClass.getDeclaredConstructor(IrisCaveProfile.class, double[].class, double.class, Class.forName("art.arcane.iris.pack.value.IrisRange"), int.class); - weightedProfileConstructor.setAccessible(true); - limitMethod = MantleCarvingComponent.class.getDeclaredMethod("limitAndMergeBlendedProfiles", List.class, int.class, int.class); - limitMethod.setAccessible(true); - profileField = weightedProfileClass.getDeclaredField("profile"); - profileField.setAccessible(true); - columnWeightsField = weightedProfileClass.getDeclaredField("columnWeights"); - columnWeightsField.setAccessible(true); - } - - @Test - public void topTwoProfilesAreKeptAndDroppedWeightsAreMergedIntoDominantColumns() throws Exception { - WeightedInput input = createWeightedProfiles(); - List limited = invokeLimit(input.weightedProfiles(), 2); - assertEquals(2, limited.size()); - - Map byProfile = extractWeightsByProfile(limited); - IrisCaveProfile first = input.profiles().first(); - IrisCaveProfile second = input.profiles().second(); - - assertEquals(1.0D, byProfile.get(first)[1], 0D); - assertEquals(1.0D, byProfile.get(second)[0], 0D); - } - - @Test - public void topTwoMergeIsDeterministicAcrossRuns() throws Exception { - WeightedInput firstInput = createWeightedProfiles(); - WeightedInput secondInput = createWeightedProfiles(); - List first = invokeLimit(firstInput.weightedProfiles(), 2); - List second = invokeLimit(secondInput.weightedProfiles(), 2); - - Map firstByProfile = extractWeightsByProfile(first); - Map secondByProfile = extractWeightsByProfile(second); - - assertEquals(firstByProfile.get(firstInput.profiles().first())[0], secondByProfile.get(secondInput.profiles().first())[0], 0D); - assertEquals(firstByProfile.get(firstInput.profiles().first())[1], secondByProfile.get(secondInput.profiles().first())[1], 0D); - assertEquals(firstByProfile.get(firstInput.profiles().second())[0], secondByProfile.get(secondInput.profiles().second())[0], 0D); - assertEquals(firstByProfile.get(firstInput.profiles().second())[1], secondByProfile.get(secondInput.profiles().second())[1], 0D); - } - - @Test - public void topTwoMergePreservesIndependentWeightsAtHighColumnIndexes() throws Exception { - WeightedInput input = createWeightedProfiles(); - List limited = invokeLimit(input.weightedProfiles(), 2); - - Map byProfile = extractWeightsByProfile(limited); - IrisCaveProfile first = input.profiles().first(); - IrisCaveProfile second = input.profiles().second(); - - assertEquals(1.0D, byProfile.get(first)[255], 0D); - assertEquals(1.0D, byProfile.get(second)[254], 0D); - } - - @Test - public void topTwoTieBreaksBySequenceNotIdentity() throws Exception { - IrisCaveProfile first = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.0D); - IrisCaveProfile second = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.0D); - IrisCaveProfile third = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.0D); - - double[] weightsA = new double[256]; - double[] weightsB = new double[256]; - double[] weightsC = new double[256]; - weightsA[0] = 0.5D; - weightsB[0] = 0.5D; - weightsC[0] = 0.5D; - - List weighted = new ArrayList<>(); - weighted.add(weightedProfileConstructor.newInstance(first, weightsA, average(weightsA), null, 0)); - weighted.add(weightedProfileConstructor.newInstance(second, weightsB, average(weightsB), null, 1)); - weighted.add(weightedProfileConstructor.newInstance(third, weightsC, average(weightsC), null, 2)); - - List limited = invokeLimit(weighted, 2); - assertEquals(2, limited.size()); - - Map byProfile = extractWeightsByProfile(limited); - assertEquals(true, byProfile.containsKey(first)); - assertEquals(true, byProfile.containsKey(second)); - assertEquals(false, byProfile.containsKey(third)); - } - - private WeightedInput createWeightedProfiles() throws Exception { - IrisCaveProfile first = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.31D); - IrisCaveProfile second = new IrisCaveProfile().setEnabled(true).setBaseWeight(1.17D); - IrisCaveProfile third = new IrisCaveProfile().setEnabled(true).setBaseWeight(0.93D); - Profiles profiles = new Profiles(first, second, third); - - double[] firstWeights = new double[256]; - firstWeights[0] = 0.2D; - firstWeights[1] = 0.8D; - firstWeights[255] = 0.6D; - - double[] secondWeights = new double[256]; - secondWeights[0] = 0.7D; - secondWeights[1] = 0.1D; - secondWeights[254] = 0.7D; - - double[] thirdWeights = new double[256]; - thirdWeights[0] = 0.3D; - thirdWeights[1] = 0.4D; - thirdWeights[254] = 0.3D; - thirdWeights[255] = 0.4D; - - List weighted = new ArrayList<>(); - weighted.add(weightedProfileConstructor.newInstance(first, firstWeights, average(firstWeights), null, 0)); - weighted.add(weightedProfileConstructor.newInstance(second, secondWeights, average(secondWeights), null, 1)); - weighted.add(weightedProfileConstructor.newInstance(third, thirdWeights, average(thirdWeights), null, 2)); - return new WeightedInput(weighted, profiles); - } - - private List invokeLimit(List weightedProfiles, int limit) throws Exception { - return (List) limitMethod.invoke(null, weightedProfiles, limit, 256); - } - - private Map extractWeightsByProfile(List weightedProfiles) throws Exception { - Map byProfile = new IdentityHashMap<>(); - for (Object weightedProfile : weightedProfiles) { - IrisCaveProfile profile = (IrisCaveProfile) profileField.get(weightedProfile); - double[] weights = (double[]) columnWeightsField.get(weightedProfile); - byProfile.put(profile, weights); - } - return byProfile; - } - - private double average(double[] weights) { - double total = 0D; - for (double weight : weights) { - total += weight; - } - return total / weights.length; - } - - private record Profiles(IrisCaveProfile first, IrisCaveProfile second, IrisCaveProfile third) { - } - - private record WeightedInput(List weightedProfiles, Profiles profiles) { - } -} diff --git a/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureObjectVetoTest.java b/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureObjectVetoTest.java index 8f4fa88b1..6028d12c9 100644 --- a/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureObjectVetoTest.java +++ b/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureObjectVetoTest.java @@ -13,6 +13,7 @@ import art.arcane.iris.structure.object.ObjectPlaceMode; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; import art.arcane.iris.testsupport.PlatformBinding; import art.arcane.volmlib.util.math.Vector3i; @@ -30,7 +31,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class NativeStructureObjectVetoTest { @@ -52,6 +56,9 @@ public class NativeStructureObjectVetoTest { when(complex.getHeightStream()).thenReturn(heightStream); engine = mock(Engine.class); when(engine.getHeight()).thenReturn(256); + when(engine.getHeight(anyInt(), anyInt())).thenReturn(SURFACE_Y); + when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenAnswer(invocation -> + engine.getHeight(invocation.getArgument(0), invocation.getArgument(1))); when(engine.getComplex()).thenReturn(complex); when(engine.getDimension()).thenReturn(mock(IrisDimension.class)); volumes(); @@ -67,6 +74,79 @@ public class NativeStructureObjectVetoTest { assertFalse(placer.written().isEmpty()); } + @Test + public void flattenSkirtRejectsATreeWhoseGroundWillBeLoweredOutsideThePiece() { + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(new NativeStructureVolume("minecraft:village_plains", 1, 64, -1, 11, 65, 7, + new NativeStructureVolume.SurfaceAdjustment(List.of( + new NativeStructureVolume.SurfaceAnchor(1, 11, -1, 7, 64, 2)), 24, 64))); + + assertEquals(-1, place(placer)); + assertTrue(placer.written().isEmpty()); + } + + @Test + public void flattenSkirtKeepsATreeWhereTheGradeDoesNotLowerItsGround() { + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(new NativeStructureVolume("minecraft:village_plains", 1, 96, -1, 11, 97, 7, + new NativeStructureVolume.SurfaceAdjustment(List.of( + new NativeStructureVolume.SurfaceAnchor(1, 11, -1, 7, 96, 2)), 24, 64))); + + assertTrue(place(placer) >= 0); + assertFalse(placer.written().isEmpty()); + } + + @Test + public void flattenCutKeepsObjectsBelowTheRemainingCaveRoof() { + when(engine.getHeight(anyInt(), anyInt())).thenReturn(140); + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(new NativeStructureVolume("minecraft:village_plains", 1, 120, -1, 11, 121, 7, + new NativeStructureVolume.SurfaceAdjustment(List.of( + new NativeStructureVolume.SurfaceAnchor(1, 11, -1, 7, 120, 2)), 24, 64))); + + assertTrue(place(placer) >= 0); + assertFalse(placer.written().isEmpty()); + verify(engine, times(1)).getHeight(0, 0); + } + + @Test + public void flattenCutKeepsObjectsAboveTheRemovedSurfaceBand() { + when(engine.getHeight(anyInt(), anyInt())).thenReturn(40); + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(new NativeStructureVolume("minecraft:village_plains", 1, 20, -1, 11, 21, 7, + new NativeStructureVolume.SurfaceAdjustment(List.of( + new NativeStructureVolume.SurfaceAnchor(1, 11, -1, 7, 20, 2)), 24, 64))); + + assertTrue(place(placer) >= 0); + assertFalse(placer.written().isEmpty()); + } + + @Test + public void flattenCutUsesHostGroundBelowAnUpperTerrainLayer() { + EngineMantle mantle = mock(EngineMantle.class); + when(engine.getMantle()).thenReturn(mantle); + when(mantle.trueHeight(anyInt(), anyInt())).thenReturn(40); + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(new NativeStructureVolume("minecraft:village_plains", 1, 20, -1, 11, 21, 7, + new NativeStructureVolume.SurfaceAdjustment(List.of( + new NativeStructureVolume.SurfaceAnchor(1, 11, -1, 7, 20, 2)), 24, 64))); + + assertTrue(place(placer) >= 0); + assertFalse(placer.written().isEmpty()); + } + + @Test + public void flattenCutConvertsTheHostGroundToNegativeWorldCoordinates() { + when(engine.getMinHeight()).thenReturn(-256); + RecordingPlacer placer = new RecordingPlacer(engine); + volumes(new NativeStructureVolume("minecraft:village_plains", 1, -190, -1, 11, -189, 7, + new NativeStructureVolume.SurfaceAdjustment(List.of( + new NativeStructureVolume.SurfaceAnchor(1, 11, -1, 7, -190, 2)), 24, 64))); + + assertEquals(-1, place(placer)); + assertTrue(placer.written().isEmpty()); + } + @Test public void canopyBlockInsideAPieceRejectsTheWholeObject() { int canopyY = plantedTopY(); @@ -250,7 +330,7 @@ public class NativeStructureObjectVetoTest { } private NativeStructureVolume volume(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { - return new NativeStructureVolume("minecraft:village_plains", minX, minY, minZ, maxX, maxY, maxZ); + return new NativeStructureVolume("minecraft:village_plains", minX, minY, minZ, maxX, maxY, maxZ, null); } private static NativeBlockState state(String key, boolean solid) { diff --git a/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeMemoTest.java b/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeMemoTest.java index 0440eb2ee..c34acc7b3 100644 --- a/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeMemoTest.java +++ b/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeMemoTest.java @@ -105,7 +105,7 @@ public class NativeStructureVolumeMemoTest { } private static NativeStructureVolume volume(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { - return new NativeStructureVolume("minecraft:village_swamp", minX, minY, minZ, maxX, maxY, maxZ); + return new NativeStructureVolume("minecraft:village_swamp", minX, minY, minZ, maxX, maxY, maxZ, null); } private static final class RecordingHooks implements EnginePlatformHooks { diff --git a/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeTest.java b/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeTest.java index ffa340429..9cc75e3f1 100644 --- a/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeTest.java +++ b/core/src/test/java/art/arcane/iris/structure/nativegen/NativeStructureVolumeTest.java @@ -4,13 +4,16 @@ import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; import org.junit.Test; +import java.util.List; +import java.util.ArrayList; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class NativeStructureVolumeTest { private static final NativeStructureVolume PIECE = - new NativeStructureVolume("minecraft:village_plains", 10, 64, 20, 25, 78, 40); + new NativeStructureVolume("minecraft:village_plains", 10, 64, 20, 25, 78, 40, null); @Test public void rectQueriesIgnoreTheVerticalAxis() { @@ -53,4 +56,46 @@ public class NativeStructureVolumeTest { assertEquals(PIECE.maxY(), normalized.maxY()); assertEquals(PIECE.maxZ(), normalized.maxZ()); } + + @Test + public void terrainInfluenceDoesNotChangePhysicalIntersection() { + NativeStructureVolume volume = adjustedPoint(0, 90, 0, 24); + assertTrue(volume.intersectsInfluenceRect(-1, 0, -1, 0)); + assertFalse(volume.intersects(-1, 40, 0, -1, 160, 0)); + assertFalse(volume.intersectsRect(-1, 0, -1, 0)); + } + + @Test + public void oppositeGradesBlendWithoutCuttingTheSurface() { + List volumes = List.of(adjustedPoint(0, 90, 0, 20), adjustedPoint(20, 110, 0, 20)); + assertEquals(100, NativeStructureVolume.surfaceCutY(volumes, 10, 0, 100)); + } + + @Test + public void halfBlockCutsRoundTheirSignedDepthLikeTheFitter() { + assertEquals(99, NativeStructureVolume.surfaceCutY(List.of(adjustedPoint(0, 99, 0, 24)), 12, 0, 100)); + } + + @Test + public void realVillageJunctionsLowerTheReportedPineGround() { + int[][] junctions = {{-2591,144,-7062},{-2591,144,-7061},{-2593,141,-7055},{-2592,141,-7055}, + {-2591,163,-7070},{-2591,163,-7071},{-2608,149,-7075},{-2609,149,-7075}, + {-2591,136,-7051},{-2591,137,-7051},{-2602,126,-7047},{-2591,136,-7050}, + {-2591,137,-7050},{-2602,126,-7046},{-2602,122,-7042},{-2602,122,-7041}, + {-2624,124,-7067},{-2624,124,-7068}}; + List anchors = new ArrayList<>(); + for (int[] junction : junctions) { + anchors.add(new NativeStructureVolume.SurfaceAnchor(junction[0], junction[0], junction[2], junction[2], junction[1], 1)); + } + NativeStructureVolume volume = new NativeStructureVolume("minecraft:village_plains", -2600, 144, -7070, -2590, 145, -7062, + new NativeStructureVolume.SurfaceAdjustment(anchors, 24, 64)); + assertEquals(151, NativeStructureVolume.surfaceCutY(List.of(volume), -2601, -7063, 162)); + assertEquals(162, NativeStructureVolume.surfaceCutY(List.of(volume), -2700, -7063, 162)); + } + + private static NativeStructureVolume adjustedPoint(int x, int groundY, int z, int padding) { + return new NativeStructureVolume("minecraft:village_plains", x, groundY, z, x, groundY, z, + new NativeStructureVolume.SurfaceAdjustment(List.of( + new NativeStructureVolume.SurfaceAnchor(x, x, z, z, groundY, 1)), padding, 64)); + } } diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectFractionalScalingTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectFractionalScalingTest.java new file mode 100644 index 000000000..bf58ae9e5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectFractionalScalingTest.java @@ -0,0 +1,184 @@ +package art.arcane.iris.structure.object; + +import art.arcane.iris.generation.block.TileData; +import art.arcane.iris.generation.decoration.tree.TreeBlockCanvas; +import art.arcane.iris.generation.decoration.tree.TreeGrounding; +import art.arcane.iris.generation.geometry.IrisBlockVector; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.math.Vector3i; +import org.junit.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisObjectFractionalScalingTest { + @Test + public void fractionalReductionRetainsBothLayersWhenTwoOutputVoxelsRemain() { + NativeBlockState stem = state("minecraft:mushroom_stem"); + NativeBlockState cap = state("minecraft:red_mushroom_block"); + IrisObject source = layers(stem, cap); + for (boolean savedOrigin : new boolean[]{false, true}) { + for (double factor : new double[]{0.6D, 0.75D, 0.9D}) { + IrisObject scaled = scale(source, factor, savedOrigin); + + assertEquals(2, scaled.getH()); + assertEquals(2, scaled.getBlocks().size()); + assertSame(stem, scaled.getBlocks().get(scaled.getSigned(0, 0, 0))); + assertSame(cap, scaled.getBlocks().get(scaled.getSigned(0, 1, 0))); + } + } + assertSame(stem, source.getBlocks().get(source.getSigned(0, 0, 0))); + assertSame(cap, source.getBlocks().get(source.getSigned(0, 1, 0))); + } + + @Test + public void fractionalEnlargementUsesTheDominantMaterialInSharedOutputVoxels() { + NativeBlockState lower = state("minecraft:stone"); + NativeBlockState upper = state("minecraft:dirt"); + IrisObject source = layers(lower, upper); + for (boolean savedOrigin : new boolean[]{false, true}) { + IrisObject scaled = scale(source, 1.75D, savedOrigin); + + assertEquals(4, scaled.getH()); + for (int y = 0; y < 4; y++) { + assertSame(y < 2 ? lower : upper, scaled.getBlocks().get(scaled.getSigned(0, y, 0))); + } + } + } + + @Test + public void fractionalReductionResolvesMaterialCoverageAcrossAllThreeAxes() { + IrisObject source = new IrisObject(2, 2, 2); + for (int x = 0; x < 2; x++) { + for (int y = 0; y < 2; y++) { + for (int z = 0; z < 2; z++) { + source.setUnsigned(x, y, z, state("test:voxel_" + x + y + z)); + } + } + } + + IrisObject scaled = source.scaled(0.75D, IrisObjectPlacementScaleInterpolator.NONE); + + assertEquals(8, scaled.getBlocks().size()); + for (int x = 0; x < 2; x++) { + for (int y = 0; y < 2; y++) { + for (int z = 0; z < 2; z++) { + assertSame(source.getBlocks().get(source.getSigned(x, y, z)), + scaled.getBlocks().get(scaled.getSigned(x, y, z))); + } + } + } + } + + @Test + public void fractionalSavedOriginPreservesNegativeCoordinatesAndTheirTiles() { + NativeBlockState lower = state("minecraft:chest"); + NativeBlockState upper = state("minecraft:barrel"); + IrisObject source = new IrisObject(1, 4, 1); + source.setCenter(new Vector3i(0, 3, 0)); + source.setUnsigned(0, 1, 0, lower); + source.setUnsignedTile(0, 1, 0, tile("minecraft:chest", "lower")); + source.setUnsigned(0, 2, 0, upper); + source.setUnsignedTile(0, 2, 0, tile("minecraft:barrel", "upper")); + + IrisObject scaled = source.scaledAroundOrigin(0.75D, IrisObjectPlacementScaleInterpolator.NONE); + + assertEquals(new Vector3i(0, 3, 0), scaled.getCenter()); + assertEquals(-3, scaled.getAABB().min().getY()); + assertEquals(0, scaled.getAABB().max().getY()); + assertSame(lower, scaled.getBlocks().get(new IrisBlockVector(0, -2, 0))); + assertSame(upper, scaled.getBlocks().get(new IrisBlockVector(0, -1, 0))); + assertEquals("lower", scaled.getStates().get(new IrisBlockVector(0, -2, 0)).getProperties().get("name")); + assertEquals("upper", scaled.getStates().get(new IrisBlockVector(0, -1, 0)).getProperties().get("name")); + } + + @Test + public void sameMaterialTilesFollowTheirDominantSourceVoxel() { + NativeBlockState chest = state("minecraft:chest"); + IrisObject source = layers(chest, chest); + TileData lower = tile("minecraft:chest", "lower"); + TileData upper = tile("minecraft:chest", "upper"); + source.setUnsignedTile(0, 0, 0, lower); + source.setUnsignedTile(0, 1, 0, upper); + + IrisObject scaled = source.scaled(0.75D, IrisObjectPlacementScaleInterpolator.NONE); + + TileData scaledLower = scaled.getStates().get(scaled.getSigned(0, 0, 0)); + TileData scaledUpper = scaled.getStates().get(scaled.getSigned(0, 1, 0)); + assertEquals(lower, scaledLower); + assertEquals(upper, scaledUpper); + assertNotSame(lower, scaledLower); + assertNotSame(lower.getProperties(), scaledLower.getProperties()); + assertNotSame(upper, scaledUpper); + assertNotSame(scaledLower.getProperties(), scaledUpper.getProperties()); + } + + @Test + public void sameMaterialCanopyRolesAndRootContactsFollowTheDominantSourceVoxel() { + NativeBlockState wood = state("minecraft:spruce_wood"); + IrisObject source = layers(wood, wood); + TreeGrounding grounding = TreeGrounding.create(List.of(new TreeGrounding.Root(0, 0, 0, wood)), + 8, Set.of(new TreeBlockCanvas.Vec(0, 1, 0))); + source.setTreeGrounding(grounding); + for (boolean savedOrigin : new boolean[]{false, true}) { + IrisObject scaled = scale(source, 0.75D, savedOrigin); + int bottom = -scaled.getCenter().getY(); + TreeGrounding result = scaled.getTreeGrounding(); + + assertEquals(List.of(new TreeGrounding.Root(0, bottom, 0, wood)), result.roots()); + assertFalse(result.isCanopy(0, bottom, 0)); + assertTrue(result.isCanopy(0, bottom + 1, 0)); + assertEquals(6, result.maxExtension()); + } + assertSame(grounding, source.getTreeGrounding()); + } + + @Test + public void canopyWinningAnUnresolvableRootCollisionDoesNotBecomeAGroundContact() { + NativeBlockState wood = state("minecraft:spruce_wood"); + IrisObject source = layers(wood, wood); + source.setTreeGrounding(TreeGrounding.create(List.of(new TreeGrounding.Root(0, 0, 0, wood)), + 8, Set.of(new TreeBlockCanvas.Vec(0, 1, 0)))); + + IrisObject scaled = source.scaled(0.5D, IrisObjectPlacementScaleInterpolator.NONE); + + assertEquals(1, scaled.getBlocks().size()); + assertTrue(scaled.getTreeGrounding().roots().isEmpty()); + assertTrue(scaled.getTreeGrounding().isCanopy(0, 0, 0)); + } + + private static IrisObject layers(NativeBlockState lower, NativeBlockState upper) { + IrisObject source = new IrisObject(1, 2, 1); + source.setCenter(new Vector3i(0, 0, 0)); + source.setUnsigned(0, 0, 0, lower); + source.setUnsigned(0, 1, 0, upper); + return source; + } + + private static IrisObject scale(IrisObject source, double scale, boolean savedOrigin) { + return savedOrigin ? source.scaledAroundOrigin(scale, IrisObjectPlacementScaleInterpolator.NONE) + : source.scaled(scale, IrisObjectPlacementScaleInterpolator.NONE); + } + + private static TileData tile(String material, String name) { + KMap properties = new KMap<>(); + properties.put("name", name); + return new TileData(material, properties); + } + + private static NativeBlockState state(String key) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + when(state.materialKey()).thenReturn(key); + return state; + } +} diff --git a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java index ceffcfbb0..4aed16c6f 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/IrisObjectPlacementRunnerRegressionTest.java @@ -620,6 +620,91 @@ public class IrisObjectPlacementRunnerRegressionTest { assertSame(machine, placer.get(0, ANCHOR_Y + 9, 0)); } + @Test + public void organicMushroomStiltsUseTheStemAndStopAtTheCaveFloor() { + NativeBlockState stem = state("minecraft:mushroom_stem", true); + NativeBlockState cap = state("minecraft:red_mushroom_block", true); + IrisObject mushroom = new IrisObject(1, 2, 1); + mushroom.setUnsigned(0, 0, 0, stem); + mushroom.setUnsigned(0, 1, 0, cap); + RecordingPlacer placer = new RecordingPlacer(null); + placer.terrain(0, 0, 0, 30, 120, 140); + placer.set(0, 30, 0, solid); + placer.writes().clear(); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.ORGANIC_STILT) + .setStiltSettings(new IrisStiltSettings().setOrganicJitter(0).setOrganicScratch(0)); + + mushroom.place(0, 60, 0, placer, placement, new RNG(2L), data); + + assertEquals(1, placer.writesOf(cap).size()); + for (int y = 31; y < 60; y++) { + assertSame(stem, placer.get(0, y, 0)); + } + assertSame(solid, placer.get(0, 30, 0)); + assertTrue(placer.writes().stream().allMatch(write -> write.y() >= 31)); + } + + @Test + public void defaultOrganicStiltsDoNotReachAFloorMoreThanOneHundredBlocksBelow() { + NativeBlockState stem = state("minecraft:mushroom_stem", true); + IrisObject mushroom = new IrisObject(1, 1, 1); + mushroom.setUnsigned(0, 0, 0, stem); + RecordingPlacer placer = new RecordingPlacer(null); + placer.set(0, 30, 0, solid); + placer.writes().clear(); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.ORGANIC_STILT) + .setStiltSettings(new IrisStiltSettings().setOrganicJitter(0).setOrganicScratch(0)); + + mushroom.place(0, 160, 0, placer, placement, new RNG(2L), data); + + assertEquals(9, placer.world.values().stream().filter(block -> block == stem).count()); + assertTrue(placer.writesOf(stem).stream().allMatch(write -> write.y() >= 152)); + assertNull(placer.get(0, 151, 0)); + assertSame(solid, placer.get(0, 30, 0)); + } + + @Test + public void shrinkingTwoBlockMushroomsPreservesTheStemUsedByOrganicStilts() { + NativeBlockState stem = state("minecraft:mushroom_stem", true); + NativeBlockState cap = state("minecraft:red_mushroom_block", true); + IrisObject mushroom = new IrisObject(1, 2, 1); + mushroom.setUnsigned(0, 0, 0, stem); + mushroom.setUnsigned(0, 1, 0, cap); + IrisObject scaled = mushroom.scaled(0.75, IrisObjectPlacementScaleInterpolator.NONE); + RecordingPlacer placer = new RecordingPlacer(null); + placer.set(0, ANCHOR_Y, 0, solid); + placer.writes().clear(); + IrisObjectPlacement placement = placement().setMode(ObjectPlaceMode.ORGANIC_STILT) + .setStiltSettings(new IrisStiltSettings().setOrganicJitter(0).setOrganicScratch(0)); + + scaled.place(0, ANCHOR_Y + 10, 0, placer, placement, new RNG(2L), data); + + assertSame(stem, placer.get(0, ANCHOR_Y + 1, 0)); + assertEquals(1, placer.writesOf(cap).size()); + } + + @Test + public void centerHeightSurfaceSupportAllowsAnImportedRootAboveALowerAdjacentColumn() { + NativeBlockState wood = state("minecraft:spruce_wood", true); + IrisObject tree = new IrisObject(7, 27, 7); + tree.getBlocks().put(new IrisBlockVector(0, -13, 0), wood); + tree.getBlocks().put(new IrisBlockVector(1, -13, 0), wood); + tree.getBlocks().put(new IrisBlockVector(1, -13, -1), wood); + RecordingPlacer placer = new RecordingPlacer(null); + placer.terrain(0, 0, 0, 167); + placer.terrain(1, 0, 0, 163); + placer.terrain(1, -1, 0, 163); + IrisObjectPlacement placement = new IrisObjectPlacement() + .setRotation(IrisObjectRotation.of(0, 0, 0)); + + assertTrue(tree.place(0, -1, 0, placer, placement, new RNG(2L), data) >= 0); + + assertSame(wood, placer.get(1, 167, 0)); + assertNull(placer.get(1, 166, 0)); + assertFalse(placer.isSurfaceSolid(1, 166, 0)); + assertEquals(163, placer.highest(1, 0)); + } + @Test public void stiltPaletteCannotIntroduceExcludedMaterialsOrContainers() { NativeBlockState calcite = state("minecraft:calcite", true); diff --git a/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java b/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java index 23b6050a2..965ba322d 100644 --- a/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/structure/object/TreeGroundingPlacementTest.java @@ -10,6 +10,8 @@ import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.testsupport.PlatformBinding; import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume; +import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import org.junit.Before; import org.junit.Rule; @@ -84,6 +86,30 @@ public class TreeGroundingPlacementTest { assertFalse(terrain.written.containsKey(new Position(2, 79, 0))); } + @Test + public void nativeFlattenInfluenceDoesNotBlockRootExtensionsBelowItsPiece() { + Terrain terrain = new Terrain(); + terrain.heights.put(new Column(1, 0), 77); + Engine engine = data.getEngine(); + when(terrain.placer.getEngine()).thenReturn(engine); + when(engine.getDimension()).thenReturn(new IrisDimension()); + when(engine.getHeight(anyInt(), anyInt(), anyBoolean())).thenAnswer(invocation -> + terrain.surface(invocation.getArgument(0), invocation.getArgument(1))); + NativeStructureVolume volume = new NativeStructureVolume("minecraft:village_plains", + 10, 100, 0, 12, 110, 2, + new NativeStructureVolume.SurfaceAdjustment(List.of( + new NativeStructureVolume.SurfaceAnchor(10, 12, 0, 2, 100, 4)), 16, 16)); + when(engine.getNativeStructureVolumes(anyInt(), anyInt(), anyInt(), anyInt())) + .thenReturn(new KList<>(List.of(volume))); + + assertTrue(place(tree(true), terrain, placement()) >= 0); + + for (int y = 77; y <= 81; y++) { + assertSame(wood, terrain.written.get(new Position(1, y, 0))); + } + assertFalse(terrain.written.containsKey(new Position(1, 76, 0))); + } + @Test public void cliffRejectionWritesNoPartOfTheTreeOrListener() { Terrain terrain = new Terrain(); diff --git a/core/src/test/java/art/arcane/iris/world/IrisWorldCreatorSeedTest.java b/core/src/test/java/art/arcane/iris/world/IrisWorldCreatorSeedTest.java new file mode 100644 index 000000000..dfbbee362 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/world/IrisWorldCreatorSeedTest.java @@ -0,0 +1,65 @@ +package art.arcane.iris.world; + +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.platform.generation.BukkitChunkGenerator; +import art.arcane.iris.world.lifecycle.WorldLifecycleCaller; +import art.arcane.iris.world.lifecycle.WorldLifecycleRequest; +import org.bukkit.NamespacedKey; +import org.bukkit.WorldCreator; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; + +public class IrisWorldCreatorSeedTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void explicitSeedsReachTheGeneratorAndLifecycleRequestWithoutDefaultSubstitution() throws Exception { + for (long seed : new long[]{0L, 78264193L, -734829104958217364L, Long.MIN_VALUE, Long.MAX_VALUE}) { + assertCreatedSeed(new IrisWorldCreator().seed(seed), seed); + } + } + + @Test + public void omittedSeedUsesTheCreationDefault() throws Exception { + assertCreatedSeed(new IrisWorldCreator(), 1337L); + } + + private void assertCreatedSeed(IrisWorldCreator creator, long expectedSeed) throws Exception { + NamespacedKey key = new NamespacedKey("iris", "seed-selection"); + File root = temporaryFolder.newFolder(); + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("cohesion"); + WorldCreator bukkitCreator = WorldCreatorCompat.ofKey(key); + List worlds = new ArrayList<>(); + try (MockedStatic storage = mockStatic(IrisWorldStorage.class); + MockedStatic compatibility = mockStatic(WorldCreatorCompat.class); + MockedConstruction generators = mockConstruction(BukkitChunkGenerator.class, + (generator, context) -> worlds.add((IrisWorld) context.arguments().getFirst()))) { + storage.when(() -> IrisWorldStorage.keyFromName("seed-selection")).thenReturn(key); + storage.when(() -> IrisWorldStorage.dimensionRoot(key)).thenReturn(root); + compatibility.when(() -> WorldCreatorCompat.ofKey(key)).thenReturn(bukkitCreator); + compatibility.when(() -> WorldCreatorCompat.keyOf(bukkitCreator)).thenReturn(key); + + WorldCreator created = creator.name("seed-selection").dimension(dimension).create(); + WorldLifecycleRequest request = WorldLifecycleRequest.fromCreator( + created, false, false, WorldLifecycleCaller.CREATE); + + assertEquals(1, generators.constructed().size()); + assertEquals(expectedSeed, worlds.getFirst().getRawWorldSeed()); + assertEquals(expectedSeed, created.seed()); + assertEquals(expectedSeed, request.seed()); + } + } +}