diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f72e967..82c8835 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,13 @@ +Version 4.0.6 + +* Fix provider top and filler materials being generated one block below exposed ground. +* Apply underwater materials from the corrected ground and ceiling materials to roof undersides. +* Preserve trees, vegetation, structures and block entities by running surface replacement before late features. +* Honour exact biome-to-geome weights on dynamic biome registries. +* Stagger close Stable Layers geome transitions by layer instead of changing a whole rock column at one boundary. +* Recalibrate Stable Layers edge-detail presets so Average retains natural variation at later rock contacts. +* Existing chunks are not rewritten; the correction applies while generating new chunks. + Version 3.3.1 * Fix several bugs diff --git a/README.md b/README.md index 2e22497..b575a2e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ Important files: Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. +Stable Layers honours exact biome-ID geome influences on dynamic biome +registries and spreads close geome transitions across layers rather than +changing an entire vertical rock column at one boundary. To move a configured single-player world to a dedicated server, copy the world's `serverconfig/orespawn-worldgen.json` with the world and install the @@ -84,9 +87,12 @@ Use Java 25 from the repository root: ``` `build` runs the standard `check` lifecycle. In addition to the JUnit suite, -that lifecycle packages a test-only provider mod, loads its custom biome in -normal noise terrain, and verifies both fresh generation and reopening the -same saved world. The fixture is not included in OreSpawn's published jars. +that lifecycle packages a test-only provider mod and verifies exposed, +underwater, filler, and ceiling surfaces in open and ceiling normal-noise +dimensions. It also proves later vegetation, structures, and block entities +survive, verifies identifier-weighted geology in a dynamic custom biome, then +reopens and checks the exact saved world. The fixture is not included in +OreSpawn's published jars. Import or refresh the project with Eclipse Buildship. ForgeGradle 7's legacy `eclipse` task produces Java-only metadata and must not be used for this branch. diff --git a/build.gradle b/build.gradle index 1885a3e..f7a9c56 100644 --- a/build.gradle +++ b/build.gradle @@ -94,15 +94,15 @@ minecraft { register('gameTestServer') ['Fresh', 'Reload'].each { String phase -> - register("biomeIntegration${phase}") { + register("surfaceIntegration${phase}") { mainClass = 'net.minecraftforge.bootstrap.ForgeBootstrap' args '--launchTarget', 'forge_userdev_server_gametest', '--gameDir', '.' environment 'MCP_MAPPINGS', "official_${minecraft_version}" - workingDir = layout.buildDirectory.dir('biome-integration-run') + workingDir = layout.buildDirectory.dir('surface-integration-run') systemProperty 'forge.enableGameTest', 'true' - systemProperty 'forge.enabledGameTestNamespaces', 'cakeworldprobe' + systemProperty 'forge.enabledGameTestNamespaces', 'surfaceprobe' systemProperty 'forge.logging.console.level', 'info' - systemProperty 'cakeworld.biomeIntegrationPhase', phase.toLowerCase(Locale.ROOT) + systemProperty 'surfaceprobe.integrationPhase', phase.toLowerCase(Locale.ROOT) mods { create(mod_id) { source sourceSets.main @@ -224,79 +224,79 @@ tasks.withType(JavaCompile).configureEach { tasks.named('javadoc', Javadoc).configure { options.encoding = 'UTF-8' options.addStringOption('Xdoclint:none', '-quiet') + options.addBooleanOption('-no-fonts', true) } tasks.named('test', Test).configure { useJUnitPlatform() } -def biomeIntegrationClasses = layout.buildDirectory.dir('biome-integration-fixture/classes') -def compileBiomeIntegrationTestMod = tasks.register('compileBiomeIntegrationTestMod', JavaCompile) { +def surfaceIntegrationClasses = layout.buildDirectory.dir('surface-integration-fixture/classes') +def compileSurfaceIntegrationTestMod = tasks.register('compileSurfaceIntegrationTestMod', JavaCompile) { dependsOn tasks.named('classes') source fileTree('src/biomeIntegrationTest/java') classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory.set(biomeIntegrationClasses) + destinationDirectory.set(surfaceIntegrationClasses) javaCompiler.set(javaToolchains.compilerFor { languageVersion = JavaLanguageVersion.of(25) }) - options.release = 16 + options.release = 25 options.encoding = 'UTF-8' } -def biomeIntegrationTestModJar = tasks.register('biomeIntegrationTestModJar', Jar) { - dependsOn compileBiomeIntegrationTestMod - archiveFileName = 'cakeworldprobe.jar' - destinationDirectory = layout.buildDirectory.dir('biome-integration-fixture') +def surfaceIntegrationTestModJar = tasks.register('surfaceIntegrationTestModJar', Jar) { + dependsOn compileSurfaceIntegrationTestMod + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = layout.buildDirectory.dir('surface-integration-fixture') manifest { - attributes 'MixinConfigs': 'cakeworldprobe.mixins.json' + attributes 'MixinConfigs': 'surfaceprobe.mixins.json' } - from biomeIntegrationClasses + from surfaceIntegrationClasses from 'src/biomeIntegrationTest/resources' } -def biomeIntegrationRunDirectory = layout.buildDirectory.dir('biome-integration-run') -def prepareBiomeIntegrationTest = tasks.register('prepareBiomeIntegrationTest') { - dependsOn biomeIntegrationTestModJar +def surfaceIntegrationRunDirectory = layout.buildDirectory.dir('surface-integration-run') +def prepareSurfaceIntegrationTest = tasks.register('prepareSurfaceIntegrationTest') { + dependsOn surfaceIntegrationTestModJar doLast { - delete biomeIntegrationRunDirectory + delete surfaceIntegrationRunDirectory copy { - from biomeIntegrationTestModJar.flatMap { it.archiveFile } - into biomeIntegrationRunDirectory.map { it.dir('mods') } + from surfaceIntegrationTestModJar.flatMap { it.archiveFile } + into surfaceIntegrationRunDirectory.map { it.dir('mods') } } } } tasks.configureEach { - if (name == 'runBiomeIntegrationFresh') { - dependsOn prepareBiomeIntegrationTest - } else if (name == 'runBiomeIntegrationReload') { - dependsOn 'runBiomeIntegrationFresh' + if (name == 'runSurfaceIntegrationFresh') { + dependsOn prepareSurfaceIntegrationTest + } else if (name == 'runSurfaceIntegrationReload') { + dependsOn 'runSurfaceIntegrationFresh' } } -def biomeIntegrationTest = tasks.register('biomeIntegrationTest') { +def surfaceIntegrationTest = tasks.register('surfaceIntegrationTest') { group = 'verification' - description = 'Verifies a provider-owned custom biome in fresh and reloaded normal terrain.' - dependsOn 'runBiomeIntegrationReload' + description = 'Verifies provider surfaces and dynamic-biome geology across fresh and reloaded normal terrain.' + dependsOn 'runSurfaceIntegrationReload' doLast { - File marker = biomeIntegrationRunDirectory.get().file( - 'gametestserver/gametestworld/cakeworld-biome-integration.properties').asFile + File marker = surfaceIntegrationRunDirectory.get().file( + 'gametestserver/gametestworld/surfaceprobe-integration.properties').asFile if (!marker.isFile()) { - throw new GradleException("Biome integration completion marker is missing: ${marker}") + throw new GradleException("Surface integration completion marker is missing: ${marker}") } Properties result = new Properties() marker.withInputStream { result.load(it) } if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Biome integration reload was not verified: ${marker}") + throw new GradleException("Surface integration reload was not verified: ${marker}") } - logger.lifecycle('Custom-biome integration verified: {} chunks, {} top blocks, {} filler blocks, fresh + reload', - result.getProperty('matching_chunks'), result.getProperty('pink_surface'), - result.getProperty('white_filler')) + logger.lifecycle('Provider surfaces and dynamic-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) } } tasks.named('check') { - dependsOn biomeIntegrationTest + dependsOn surfaceIntegrationTest } // ForgeGradle 7 generates a launch for every source set, but its ordinary diff --git a/docs/AGENTS.md b/docs/AGENTS.md index bcf8347..5c7486a 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,7 +1,8 @@ -# OreSpawn Documentation For Coding Agents +# OreSpawn Documentation Map -This index is for coding agents working on other mods or modpacks that integrate -with OreSpawn. Start with [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). +This index is for navigating the documentation to learn how to integrate with +and use OreSpawn with a mod or modpack. Start with +[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). Use the focused guides for implementation details: diff --git a/docs/API.md b/docs/API.md index 66f14ac..fdb0bca 100644 --- a/docs/API.md +++ b/docs/API.md @@ -21,7 +21,7 @@ Submit declarations during `InterModEnqueueEvent`: ```java WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) - .rock(new ResourceLocation("examplemod", "slate"), GeologyFamily.METAMORPHIC, rock -> rock + .rock(Identifier.parse("examplemod:slate"), GeologyFamily.METAMORPHIC, rock -> rock .depth(12, 36) .weight(1.2) .oreReplaceable(true)) @@ -33,7 +33,7 @@ For a complete ore-only Java example, including dimensions, height curves, patterns, and host tags, see `DEVELOPER_GUIDE.md`. Definitions are immutable after `build()`. Registry references remain -`ResourceLocation` values until OreSpawn validates and bakes them. Provider +`Identifier` values until OreSpawn validates and bakes them. Provider messages are processed through Forge IMC and frozen at load completion; direct cross-mod mutation during parallel setup is unsupported. @@ -56,9 +56,9 @@ FormationDefinition formations = FormationDefinition.builder() .waviness(FormationPreset.LARGE) .build(); FluidDepositDefinition brine = FluidDepositDefinition.builder( - new ResourceLocation("examplemod", "fluid_deposit/brine"), - new ResourceLocation("examplemod", "brine")) - .dimension(new ResourceLocation("minecraft", "overworld"), placement -> placement + Identifier.parse("examplemod:fluid_deposit/brine"), + Identifier.parse("examplemod:brine")) + .dimension(Identifier.parse("minecraft:overworld"), placement -> placement .yRange(-48, 32) .attempts(0.05) .radius(4, 10) @@ -66,7 +66,7 @@ FluidDepositDefinition brine = FluidDepositDefinition.builder( .maxLobes(3) .minSolidCover(2) .minSolidShell(1) - .hostTag(new ResourceLocation("minecraft", "stone_ore_replaceables"))) + .hostTag(Identifier.parse("minecraft:stone_ore_replaceables"))) .build(); WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) @@ -77,7 +77,7 @@ WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) `OilDefinition` and template `.oil(...)` remain deprecated migration adapters for one legacy oil rule. New integrations should use `FluidDepositDefinition`. -Minecraft 26.1 biomes are dynamic registry entries. Ship them as +Minecraft 26.1.2 biomes are dynamic registry entries. Ship them as `data//worldgen/biome/.json`, or generate that data through a `RegistrySetBuilder`. `OreSpawnBiomes.copyAndRegister` is an optional bootstrap helper for cloning a known biome while generating the datapack entry: @@ -101,21 +101,21 @@ Then declare placement and materials through the same provider: ```java WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) - .biomePalette(new ResourceLocation("examplemod", "overworld"), - new ResourceLocation("minecraft", "overworld"), palette -> palette + .biomePalette(Identifier.parse("examplemod:overworld"), + Identifier.parse("minecraft:overworld"), palette -> palette .mode(BiomePlacementMode.REPLACE) .scope(BiomeReplacementScope.MINECRAFT_ONLY) .regionSize(BiomeRegionSize.LARGE) .coverage(1.0) .fallbackWeight(0.0) - .biome(new ResourceLocation("examplemod", "candy_plains"), biome -> biome + .biome(Identifier.parse("examplemod:candy_plains"), biome -> biome .weight(3.0) - .similarBiome(new ResourceLocation("minecraft", "plains")))) - .dimensionMaterials(new ResourceLocation("examplemod", "overworld_materials"), - new ResourceLocation("minecraft", "overworld"), materials -> materials - .defaultFluid(new ResourceLocation("examplemod", "lemonade")) - .snowBlock(new ResourceLocation("examplemod", "icing")) - .iceBlock(new ResourceLocation("examplemod", "frozen_lemonade"))) + .similarBiome(Identifier.parse("minecraft:plains")))) + .dimensionMaterials(Identifier.parse("examplemod:overworld_materials"), + Identifier.parse("minecraft:overworld"), materials -> materials + .defaultFluid(Identifier.parse("examplemod:lemonade")) + .snowBlock(Identifier.parse("examplemod:icing")) + .iceBlock(Identifier.parse("examplemod:frozen_lemonade"))) .build(); ``` @@ -135,9 +135,11 @@ OreSpawnApi.createSampler(server.overworld()).ifPresent(sampler -> { }); ``` -`sampleColumn` performs one biome/geome classification and reuses it for every -Y query. Sampling is read-only and is intended for gameplay decisions, -diagnostics, and compatible generation outside OreSpawn's block loops. +`sampleColumn` performs one biome/dominant-geome classification and reuses its +transition scores for every Y query. `rockAt` therefore matches Stable Layers +when a close geome transition is staggered by layer. Sampling is read-only and +is intended for gameplay decisions, diagnostics, and compatible generation +outside OreSpawn's block loops. Callbacks inside OreSpawn generation loops are intentionally unsupported. Custom pattern mods create a Forge `DeferredRegister` using diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 17e3e9f..8ee64ac 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -93,7 +93,7 @@ default states contain real fluids. ## Registering Biomes -Minecraft 26.1 loads biomes from the dynamic datapack registry. A provider mod +Minecraft 26.1.2 loads biomes from the dynamic datapack registry. A provider mod can ship a biome directly at `data//worldgen/biome/.json`. For generated data, `OreSpawnBiomes.copyAndRegister` copies a known biome's complete builder before @@ -114,7 +114,7 @@ Add the bootstrap to the `RegistrySetBuilder` passed to Forge's `DatapackBuiltinEntriesProvider`. `blankAndRegister` starts from an empty builder and is intended for advanced providers that deliberately supply every required climate, effects, spawn, and generation field. Do not use -`DeferredRegister` on 26.1: it runs before the live datapack biome +`DeferredRegister` on 26.1.2: it runs before the live datapack biome registry exists. Both bootstrap helpers only generate content; placement still belongs in the OreSpawn provider declaration. @@ -128,6 +128,16 @@ Biome surfaces support: - `ceiling_block`: optional underside material; - `filler_depth`: 0-16 blocks. +Provider surfaces run during `LOCAL_MODIFICATIONS`: after Minecraft has built +base surfaces and lakes, but before structures and vegetation. That ordering +lets OreSpawn replace the actual exposed ground while preserving later trees, +plants, authored structures, and block entities. In ceiling dimensions, +`ceiling_block` applies to the roof underside and does not replace the roof top. + +Surface correction is generation-only. Installing or updating OreSpawn does +not rewrite already generated chunks; travel into new terrain to see a changed +provider surface definition. + Dimension materials support the ordinary aquifer fluid, a deep aquifer fluid and threshold, and replacements for vanilla snow and ice. OreSpawn converts weather products in loaded chunks and around players; it does not replace every diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 58a128c..0ff794c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -75,6 +75,21 @@ When a control is `custom`, its value comes from `formations.custom`: | `edge_octaves` | 1-8 | Number of boundary-detail scales | | `continuity` | 0-1 | Proportion of formations retaining global identity | +For Stable Layers, the Edge Detail presets use these +`wavelength / amplitude / octaves` values: + +| Preset | Edge detail | +|---|---:| +| Tiny | `48 / 4 / 1` | +| Small | `64 / 12 / 2` | +| Average | `96 / 24 / 3` | +| Large | `128 / 48 / 4` | +| Huge | `192 / 96 / 5` | + +Average is calibrated to retain visible variation at later layer contacts. +Custom profiles keep their explicit values; these numbers are only used by the +named presets and as defaults for new Custom settings. + Cyano settings use `cyano.geome_size` (4-32767), `cyano.rock_layer_noise` (1-32767), and `cyano.rock_layer_thickness` (1-255). They are ignored by Sky. @@ -90,6 +105,10 @@ weight by province. A weight of zero prevents selection in that context. Geomes contain a non-negative `base` weight and non-negative weights for each rock family. Biome and biome-dictionary maps multiply those geome weights. Missing optional-mod biome IDs are ignored during baking. +Exact biome-ID maps remain effective when the target uses a dynamic biome +registry. With Stable Layers, a close contest between two geomes transitions +at a deterministic position per layer so the whole underground column does +not change on one sheer plane. Terrain dimensions require `enabled`, `host_blocks`, and `host_tags`. `biome_ids` and `biome_namespaces` can narrow a custom dimension. The Overworld diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 1156f3d..f5f0c1a 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -85,11 +85,11 @@ import zone.moddev.mc.orespawn.api.OreDimensionSelector; import zone.moddev.mc.orespawn.api.OrePattern; import zone.moddev.mc.orespawn.api.OreSpawnApi; import zone.moddev.mc.orespawn.api.WorldgenProvider; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.Identifier; import net.minecraftforge.fml.event.lifecycle.InterModEnqueueEvent; private void enqueueWorldgen(InterModEnqueueEvent event) { - ResourceLocation tin = new ResourceLocation("examplemod", "tin_ore"); + Identifier tin = Identifier.parse("examplemod:tin_ore"); WorldgenProvider provider = WorldgenProvider.builder("examplemod", 1) .ore(tin, ore -> ore .retrogen(false) @@ -100,7 +100,7 @@ private void enqueueWorldgen(InterModEnqueueEvent event) { .quantityRange(4, 11) .pattern(OrePattern.VEIN) .heightDistribution(OreHeightDistribution.TRIANGLE) - .hostTag(new ResourceLocation("minecraft", "stone_ore_replaceables")))) + .hostTag(Identifier.parse("minecraft:stone_ore_replaceables")))) .build(); OreSpawnApi.enqueue(provider); @@ -198,10 +198,11 @@ bounded ore or bedrock retrogen is enabled. 6. Confirm the provider appears in `/orespawn status`. 7. Test a new world; profile edits do not rewrite already generated terrain. -OreSpawn's own standard `check` lifecycle includes a consumer-style biome -integration test. It loads a separate test provider and datapack biome, proves -the provider is active, verifies biome selection, climate and configured -surface blocks in non-flat terrain, then reopens and rechecks the same saved -world. Run `gradlew check` (or `gradlew build`, which includes it) before -publishing any change to biome registration, palettes, surfaces or profile -persistence. +OreSpawn's own standard `check` lifecycle includes a consumer-style surface +integration test. A separate test provider creates independently marked +Grass/Dirt, underwater, filler, and roof columns in open and ceiling +normal-noise dimensions. The gate verifies biome and chunk edges, late tree, +vegetation, structure and chest sentinels, the roof underside, and exact save +reload behavior. Run `gradlew check` (or `gradlew build`, which includes it) +before publishing any change to biome registration, palettes, surfaces, +feature ordering, height handling, or profile persistence. diff --git a/gradle.properties b/gradle.properties index 7476ccb..4560ccb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,7 @@ loader_version_range=[64,) mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.5 +mod_version=4.0.6 mod_group_id=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/CakeWorldBiomeIntegrationTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/CakeWorldBiomeIntegrationTestMod.java deleted file mode 100644 index bb32a2f..0000000 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/CakeWorldBiomeIntegrationTestMod.java +++ /dev/null @@ -1,233 +0,0 @@ -package zone.moddev.mc.orespawn.testmod; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Properties; - -import zone.moddev.mc.orespawn.api.BiomePlacementMode; -import zone.moddev.mc.orespawn.api.BiomeRegionSize; -import zone.moddev.mc.orespawn.api.BiomeReplacementScope; -import zone.moddev.mc.orespawn.api.OreSpawnApi; -import zone.moddev.mc.orespawn.api.ProviderStatus; -import zone.moddev.mc.orespawn.api.WorldgenProvider; -import zone.moddev.mc.orespawn.api.WorldgenProvider.BiomeSurfaceDefinition; - -import net.minecraft.core.BlockPos; -import net.minecraft.resources.Identifier; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.level.Level; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.chunk.LevelChunk; -import net.minecraft.world.level.chunk.status.ChunkStatus; -import net.minecraft.world.level.levelgen.FlatLevelSource; -import net.minecraft.world.level.levelgen.Heightmap; -import net.minecraft.world.level.storage.LevelResource; -import net.minecraftforge.event.server.ServerStartedEvent; -import net.minecraftforge.eventbus.api.bus.BusGroup; -import net.minecraftforge.fml.common.Mod; -import net.minecraftforge.fml.event.lifecycle.InterModEnqueueEvent; -import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -/** - * Test-only provider mod which exercises the same custom-biome path used by - * CakeWorld. This source set is excluded from every published OreSpawn jar. - */ -@Mod(CakeWorldBiomeIntegrationTestMod.MODID) -public final class CakeWorldBiomeIntegrationTestMod { - static final String MODID = "cakeworldprobe"; - - private static final Logger LOGGER = LogManager.getLogger(); - private static final Identifier DIMENSION = Identifier.parse("minecraft:the_nether"); - private static final Identifier BIOME = Identifier.parse(MODID + ":cake_plains"); - private static final Identifier PINK_CONCRETE = Identifier.parse("minecraft:pink_concrete"); - private static final Identifier WHITE_CONCRETE = Identifier.parse("minecraft:white_concrete"); - private static final int MINIMUM_CHUNK = 63; - private static final int MAXIMUM_CHUNK = 65; - private static final String PHASE_PROPERTY = "cakeworld.biomeIntegrationPhase"; - private static final String MARKER_NAME = "cakeworld-biome-integration.properties"; - - public CakeWorldBiomeIntegrationTestMod(FMLJavaModLoadingContext context) { - BusGroup modBusGroup = context.getModBusGroup(); - InterModEnqueueEvent.getBus(modBusGroup).addListener(this::enqueueProvider); - ServerStartedEvent.BUS.addListener(this::auditGeneratedBiome); - } - - private void enqueueProvider(InterModEnqueueEvent event) { - BiomeSurfaceDefinition surface = BiomeSurfaceDefinition.builder() - .topBlock(PINK_CONCRETE) - .fillerBlock(WHITE_CONCRETE) - .fillerDepth(3) - .build(); - WorldgenProvider provider = WorldgenProvider.builder(MODID, 1) - .biomePalette(Identifier.parse(MODID + ":normal_terrain"), DIMENSION, - palette -> palette - .mode(BiomePlacementMode.REPLACE) - .scope(BiomeReplacementScope.MINECRAFT_ONLY) - .regionSize(BiomeRegionSize.TINY) - .coverage(1.0D) - .fallbackWeight(0.0D) - .biome(BIOME, biome -> biome - .weight(1.0D) - .temperature(-2.0D, 2.0D) - .downfall(0.0D, 1.0D) - .surface(surface))) - .build(); - if (!OreSpawnApi.enqueue(provider)) { - throw new IllegalStateException("Could not enqueue CakeWorld biome integration provider"); - } - } - - private void auditGeneratedBiome(ServerStartedEvent event) { - String phase = System.getProperty(PHASE_PROPERTY, "").trim(); - if (!phase.equals("fresh") && !phase.equals("reload")) { - throw new IllegalStateException("Missing or invalid " + PHASE_PROPERTY + ": " + phase); - } - if (OreSpawnApi.getProviderStatus(MODID) != ProviderStatus.ACTIVE) { - throw new IllegalStateException("CakeWorld biome integration provider is not active"); - } - - ServerLevel level = event.getServer().getLevel(Level.NETHER); - if (level == null) { - throw new IllegalStateException("Biome integration dimension is unavailable: " + DIMENSION); - } - if (level.getChunkSource().getGenerator() instanceof FlatLevelSource) { - throw new IllegalStateException("Biome integration test requires normal noise terrain"); - } - - Path marker = event.getServer().getWorldPath(LevelResource.ROOT).resolve(MARKER_NAME); - Properties previous = phase.equals("reload") ? readMarker(marker) : null; - if (phase.equals("fresh") && Files.exists(marker)) { - throw new IllegalStateException("Fresh biome integration world retained a reload marker"); - } - - AuditResult result = auditChunks(level); - if (previous != null) { - assertReloadValue(previous, "seed", level.getSeed()); - assertReloadValue(previous, "matching_chunks", result.matchingChunks()); - assertReloadValue(previous, "pink_surface", result.pinkSurface()); - assertReloadValue(previous, "white_filler", result.whiteFiller()); - previous.setProperty("reload_verified", "true"); - writeMarker(marker, previous); - } else { - writeMarker(marker, level.getSeed(), result); - } - - LOGGER.info("CAKEWORLD_BIOME_INTEGRATION PASS phase={} biome={} chunks={} " - + "pink_surface={} white_filler={} temperature={} downfall={}", - phase, BIOME, result.matchingChunks(), result.pinkSurface(), result.whiteFiller(), - result.temperature(), result.downfall()); - } - - private static AuditResult auditChunks(ServerLevel level) { - int matchingChunks = 0; - long pinkSurface = 0L; - long whiteFiller = 0L; - float temperature = Float.NaN; - float downfall = Float.NaN; - BlockPos.MutableBlockPos center = new BlockPos.MutableBlockPos(); - BlockPos.MutableBlockPos block = new BlockPos.MutableBlockPos(); - - for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { - for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { - level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, true); - LevelChunk chunk = level.getChunk(chunkX, chunkZ); - center.set((chunkX << 4) + 8, level.getSeaLevel(), (chunkZ << 4) + 8); - var biome = level.getBiome(center); - Identifier actual = biome.unwrapKey().map(key -> key.identifier()).orElse(null); - if (!BIOME.equals(actual)) { - throw new IllegalStateException("Expected " + BIOME + " at chunk " - + chunkX + "," + chunkZ + " but found " + actual); - } - matchingChunks++; - if (Float.isNaN(temperature)) { - temperature = biome.value().getModifiedClimateSettings().temperature(); - downfall = biome.value().getModifiedClimateSettings().downfall(); - } - - for (int localZ = 0; localZ < 16; localZ++) { - for (int localX = 0; localX < 16; localX++) { - int surfaceY = chunk.getHeight( - Heightmap.Types.WORLD_SURFACE_WG, localX, localZ) - 1; - int blockX = (chunkX << 4) + localX; - int blockZ = (chunkZ << 4) + localZ; - if (chunk.getBlockState(block.set(blockX, surfaceY, blockZ)) - .is(Blocks.PINK_CONCRETE)) { - pinkSurface++; - } - for (int depth = 1; depth <= 3; depth++) { - if (chunk.getBlockState(block.set(blockX, surfaceY - depth, blockZ)) - .is(Blocks.WHITE_CONCRETE)) { - whiteFiller++; - } - } - } - } - } - } - - int expectedChunks = (MAXIMUM_CHUNK - MINIMUM_CHUNK + 1) - * (MAXIMUM_CHUNK - MINIMUM_CHUNK + 1); - if (matchingChunks != expectedChunks || pinkSurface == 0L || whiteFiller == 0L) { - throw new IllegalStateException("Incomplete custom-biome generation: chunks=" - + matchingChunks + ", pink=" + pinkSurface + ", white=" + whiteFiller); - } - if (Float.compare(temperature, 1.35F) != 0 || Float.compare(downfall, 0.15F) != 0) { - throw new IllegalStateException("Custom biome climate was not loaded: temperature=" - + temperature + ", downfall=" + downfall); - } - return new AuditResult(matchingChunks, pinkSurface, whiteFiller, temperature, downfall); - } - - private static Properties readMarker(Path marker) { - if (!Files.isRegularFile(marker)) { - throw new IllegalStateException("Reload phase did not reuse the fresh test world: " + marker); - } - Properties values = new Properties(); - try (InputStream input = Files.newInputStream(marker)) { - values.load(input); - return values; - } catch (IOException exception) { - throw new IllegalStateException("Could not read biome integration marker", exception); - } - } - - private static void writeMarker(Path marker, long seed, AuditResult result) { - Properties values = new Properties(); - values.setProperty("seed", Long.toString(seed)); - values.setProperty("matching_chunks", Integer.toString(result.matchingChunks())); - values.setProperty("pink_surface", Long.toString(result.pinkSurface())); - values.setProperty("white_filler", Long.toString(result.whiteFiller())); - writeMarker(marker, values); - } - - private static void writeMarker(Path marker, Properties values) { - try (OutputStream output = Files.newOutputStream(marker)) { - values.store(output, "OreSpawn custom-biome integration test"); - } catch (IOException exception) { - throw new IllegalStateException("Could not write biome integration marker", exception); - } - } - - private static void assertReloadValue(Properties previous, String name, long actual) { - long expected; - try { - expected = Long.parseLong(previous.getProperty(name, "")); - } catch (NumberFormatException exception) { - throw new IllegalStateException("Invalid biome integration marker value: " + name, exception); - } - if (expected != actual) { - throw new IllegalStateException("Reloaded biome integration value changed for " + name - + ": expected " + expected + " but found " + actual); - } - } - - private record AuditResult(int matchingChunks, long pinkSurface, long whiteFiller, - float temperature, float downfall) { - } -} diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java new file mode 100644 index 0000000..d36f784 --- /dev/null +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/SurfaceProbeTestMod.java @@ -0,0 +1,609 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import zone.moddev.mc.orespawn.api.BiomePlacementMode; +import zone.moddev.mc.orespawn.api.BiomeRegionSize; +import zone.moddev.mc.orespawn.api.BiomeReplacementScope; +import zone.moddev.mc.orespawn.api.GeologyFamily; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.ProviderStatus; +import zone.moddev.mc.orespawn.api.WorldgenProvider; +import zone.moddev.mc.orespawn.api.WorldgenProvider.BiomeSurfaceDefinition; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.item.DyeColor; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.FlatLevelSource; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.FeaturePlaceContext; +import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraftforge.event.server.ServerAboutToStartEvent; +import net.minecraftforge.event.server.ServerStartedEvent; +import net.minecraftforge.eventbus.api.bus.BusGroup; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.InterModEnqueueEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** Independent, test-only provider surface fixture. */ +@Mod(SurfaceProbeTestMod.MODID) +public final class SurfaceProbeTestMod { + static final String MODID = "surfaceprobe"; + + private static final Logger LOGGER = LogManager.getLogger(); + private static final DeferredRegister> FEATURES = + DeferredRegister.create(ForgeRegistries.FEATURES, MODID); + private static final ResourceKey OPEN = Level.END; + private static final ResourceKey ROOFED = Level.NETHER; + private static final Identifier OPEN_ID = Identifier.parse("minecraft:the_end"); + private static final Identifier ROOFED_ID = Identifier.parse("minecraft:the_nether"); + private static final Identifier BIOME_A = Identifier.parse(MODID + ":surface_a"); + private static final Identifier BIOME_B = Identifier.parse(MODID + ":surface_b"); + private static final Identifier PROBE_GEOME = Identifier.parse(MODID + ":dynamic_biome_geome"); + private static final Identifier[] BUILT_IN_GEOMES = { + Identifier.parse("orespawn:stable_craton"), Identifier.parse("orespawn:mountain_belt"), + Identifier.parse("orespawn:volcanic_arc"), Identifier.parse("orespawn:sedimentary_basin"), + Identifier.parse("orespawn:coastal_shelf"), Identifier.parse("orespawn:arid_basin"), + Identifier.parse("orespawn:wetland_basin"), Identifier.parse("orespawn:glacial_highland") + }; + private static final int MINIMUM_CHUNK = 63; + private static final int MAXIMUM_CHUNK = 65; + private static final int EXPECTED_COLUMNS = 9 * 16 * 16; + private static final int EXPECTED_FILLER = EXPECTED_COLUMNS * 3; + private static final String PHASE_PROPERTY = "surfaceprobe.integrationPhase"; + private static final String MARKER_NAME = "surfaceprobe-integration.properties"; + private static final String CHEST_ITEM_NAME = "surfaceprobe sentinel"; + + static { + FEATURES.register("terrain_setup", () -> new ProbeFeature(ProbeStage.TERRAIN)); + FEATURES.register("structure_sentinels", () -> new ProbeFeature(ProbeStage.STRUCTURE)); + FEATURES.register("vegetation_sentinels", () -> new ProbeFeature(ProbeStage.VEGETATION)); + } + + public SurfaceProbeTestMod(FMLJavaModLoadingContext context) { + BusGroup modBusGroup = context.getModBusGroup(); + FEATURES.register(modBusGroup); + InterModEnqueueEvent.getBus(modBusGroup).addListener(this::enqueueProvider); + ServerAboutToStartEvent.BUS.addListener(this::enableGeologyProbe); + ServerStartedEvent.BUS.addListener(this::auditGeneratedSurfaces); + } + + private void enqueueProvider(InterModEnqueueEvent event) { + WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); + addDynamicBiomeGeology(provider); + addPalette(provider, "open_palette", OPEN_ID, false); + addPalette(provider, "roofed_palette", ROOFED_ID, true); + if (!OreSpawnApi.enqueue(provider.build())) { + throw new IllegalStateException("Could not enqueue surface probe provider"); + } + } + + private static void addDynamicBiomeGeology(WorldgenProvider.Builder provider) { + provider.geome(PROBE_GEOME, geome -> geome + .baseWeight(0.0D) + .familyWeight(GeologyFamily.SEDIMENTARY, 1.0D)); + provider.rock(Identifier.parse(MODID + ":rock/dynamic_biome"), blockId(Blocks.CALCITE), + GeologyFamily.SEDIMENTARY, rock -> { + rock.dimensions(java.util.Collections.singleton(OPEN_ID)); + rock.geomeWeight(PROBE_GEOME, 1.0D); + for (Identifier geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 0.0D); + }); + provider.rock(Identifier.parse(MODID + ":rock/fallback"), blockId(Blocks.BASALT), + GeologyFamily.SEDIMENTARY, rock -> { + rock.dimensions(java.util.Collections.singleton(OPEN_ID)); + rock.geomeWeight(PROBE_GEOME, 0.0D); + for (Identifier geome : BUILT_IN_GEOMES) rock.geomeWeight(geome, 1.0D); + }); + provider.biome(BIOME_A, java.util.Collections.singletonMap(PROBE_GEOME, 100.0D)); + provider.biome(BIOME_B, java.util.Collections.singletonMap(PROBE_GEOME, 100.0D)); + } + + private void enableGeologyProbe(ServerAboutToStartEvent event) { + Path profile = event.getServer().getWorldPath(LevelResource.ROOT).resolve("serverconfig") + .resolve("orespawn-worldgen.json"); + JsonObject root; + try (var reader = Files.newBufferedReader(profile)) { + root = new JsonParser().parse(reader).getAsJsonObject(); + } catch (IOException | RuntimeException exception) { + throw new IllegalStateException("Could not read the test-owned End geology profile", exception); + } + try { + JsonObject terrain = root.getAsJsonObject("terrain_dimensions"); + if (terrain == null) { + terrain = new JsonObject(); + root.add("terrain_dimensions", terrain); + } + JsonObject end = new JsonObject(); + end.addProperty("enabled", true); + end.add("biome_ids", new JsonArray()); + JsonArray namespaces = new JsonArray(); + namespaces.add(MODID); + end.add("biome_namespaces", namespaces); + JsonArray hosts = new JsonArray(); + hosts.add(blockId(Blocks.END_STONE).toString()); + end.add("host_blocks", hosts); + end.add("host_tags", new JsonArray()); + terrain.add(OPEN_ID.toString(), end); + try (var writer = Files.newBufferedWriter(profile)) { + new GsonBuilder().setPrettyPrinting().create().toJson(root, writer); + } + } catch (IOException | RuntimeException exception) { + throw new IllegalStateException("Could not write the test-owned End geology profile", exception); + } + if (!WorldGeologyProfileManager.reloadActiveProfile()) { + throw new IllegalStateException("Could not reload the test-owned End geology profile"); + } + } + + private static void addPalette(WorldgenProvider.Builder provider, String name, + Identifier dimension, boolean ceiling) { + BiomeSurfaceDefinition surfaceA = surface(DyeColor.PINK, DyeColor.WHITE, + DyeColor.BLUE, ceiling ? DyeColor.ORANGE : null); + BiomeSurfaceDefinition surfaceB = surface(DyeColor.LIME, DyeColor.YELLOW, + DyeColor.LIGHT_BLUE, ceiling ? DyeColor.MAGENTA : null); + provider.biomePalette(Identifier.parse(MODID + ":" + name), dimension, + palette -> palette + .mode(BiomePlacementMode.REPLACE) + .scope(BiomeReplacementScope.MINECRAFT_ONLY) + .regionSize(BiomeRegionSize.TINY) + .coverage(1.0D) + .fallbackWeight(0.0D) + .biome(BIOME_A, biome -> biome + .weight(1.0D) + .temperature(-2.0D, 2.0D) + .downfall(0.0D, 1.0D) + .surface(surfaceA)) + .biome(BIOME_B, biome -> biome + .weight(1.0D) + .temperature(-2.0D, 2.0D) + .downfall(0.0D, 1.0D) + .surface(surfaceB))); + } + + private static BiomeSurfaceDefinition surface(DyeColor top, DyeColor filler, + DyeColor underwater, DyeColor ceiling) { + BiomeSurfaceDefinition.Builder builder = BiomeSurfaceDefinition.builder() + .topBlock(blockId(concreteBlock(top))) + .fillerBlock(blockId(concreteBlock(filler))) + .fillerDepth(3) + .underwaterBlock(blockId(concreteBlock(underwater))); + if (ceiling != null) { + builder.ceilingBlock(blockId(concreteBlock(ceiling))); + } + return builder.build(); + } + + private static Identifier blockId(Block block) { + return ForgeRegistries.BLOCKS.getKey(block); + } + + private void auditGeneratedSurfaces(ServerStartedEvent event) { + String phase = System.getProperty(PHASE_PROPERTY, "").trim(); + if (!phase.equals("fresh") && !phase.equals("reload")) { + throw new IllegalStateException("Missing or invalid " + PHASE_PROPERTY + ": " + phase); + } + if (OreSpawnApi.getProviderStatus(MODID) != ProviderStatus.ACTIVE) { + throw new IllegalStateException("Surface probe provider is not active"); + } + + Path marker = event.getServer().getWorldPath(LevelResource.ROOT).resolve(MARKER_NAME); + Properties previous = phase.equals("reload") ? readMarker(marker) : null; + if (phase.equals("fresh") && Files.exists(marker)) { + throw new IllegalStateException("Fresh surface probe retained a reload marker"); + } + + Map results = new LinkedHashMap<>(); + results.put("open", auditDimension(requireLevel(event, OPEN), false)); + results.put("roofed", auditDimension(requireLevel(event, ROOFED), true)); + Properties current = properties(event.getServer().overworld().getSeed(), results); + if (previous == null) { + writeMarker(marker, current); + } else { + for (String name : current.stringPropertyNames()) { + String expected = previous.getProperty(name); + String actual = current.getProperty(name); + if (!actual.equals(expected)) { + throw new IllegalStateException("Reloaded surface value changed for " + name + + ": expected " + expected + " but found " + actual); + } + } + previous.setProperty("reload_verified", "true"); + writeMarker(marker, previous); + } + + LOGGER.info("SURFACEPROBE PASS phase={} open={} roofed={}", + phase, results.get("open"), results.get("roofed")); + } + + private static ServerLevel requireLevel(ServerStartedEvent event, ResourceKey key) { + ServerLevel level = event.getServer().getLevel(key); + if (level == null) throw new IllegalStateException("Surface probe dimension is unavailable: " + key.identifier()); + if (level.getChunkSource().getGenerator() instanceof FlatLevelSource) { + throw new IllegalStateException("Surface probe requires normal noise terrain: " + key.identifier()); + } + return level; + } + + private static AuditResult auditDimension(ServerLevel level, boolean roofed) { + long top = 0L; + long underwater = 0L; + long filler = 0L; + long geology = 0L; + long ceiling = 0L; + long roofTop = 0L; + int biomeA = 0; + int biomeB = 0; + int edgeChanges = 0; + int sentinels = 0; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + + for (int chunkZ = MINIMUM_CHUNK; chunkZ <= MAXIMUM_CHUNK; chunkZ++) { + Identifier previousChunkBiome = null; + for (int chunkX = MINIMUM_CHUNK; chunkX <= MAXIMUM_CHUNK; chunkX++) { + level.getChunk(chunkX, chunkZ, ChunkStatus.FULL, true); + LevelChunk chunk = level.getChunk(chunkX, chunkZ); + int chunkMinX = chunkX << 4; + int chunkMinZ = chunkZ << 4; + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + int x = chunkMinX + localX; + int z = chunkMinZ + localZ; + int groundY = findMarkedGround(chunk, pos, x, z, level.getMinY(), level.getMaxY()); + var biome = level.getBiome(pos.set(x, groundY, z)); + Identifier biomeId = biomeId(biome); + Material material = material(biomeId, roofed); + float expectedTemperature = BIOME_A.equals(biomeId) ? 1.35F : 0.7F; + float expectedDownfall = BIOME_A.equals(biomeId) ? 0.15F : 0.8F; + var climate = biome.value().getModifiedClimateSettings(); + if (Float.compare(climate.temperature(), expectedTemperature) != 0 + || Float.compare(climate.downfall(), expectedDownfall) != 0) { + throw new IllegalStateException("Provider climate changed for " + biomeId + + " at " + pos + ": " + climate); + } + if (BIOME_A.equals(biomeId)) biomeA++; else biomeB++; + boolean waterColumn = localX == 1 && localZ == 1; + BlockState expectedTop = waterColumn ? material.underwater() : material.top(); + assertBlock(chunk, pos, x, groundY, z, expectedTop, + "provider top at exposed marked ground"); + if (waterColumn) underwater++; else top++; + for (int depth = 1; depth <= 3; depth++) { + assertBlock(chunk, pos, x, groundY - depth, z, material.filler(), + "provider filler depth " + depth); + filler++; + } + if (!roofed) { + for (int depth = 6; depth <= 8; depth++) { + assertBlock(chunk, pos, x, groundY - depth, z, + Blocks.CALCITE.defaultBlockState(), "dynamic-biome geome rock"); + geology++; + } + } + if (roofed) { + Identifier ceilingBiome = biomeId(level.getBiome( + pos.set(x, groundY + 8, z))); + BlockState expectedCeiling = material(ceilingBiome, true).ceiling(); + assertBlock(chunk, pos, x, groundY + 8, z, expectedCeiling, + "roof underside"); + assertBlock(chunk, pos, x, groundY + 10, z, Blocks.STONE.defaultBlockState(), + "roof top"); + ceiling++; + roofTop++; + } + } + } + Identifier centerBiome = biomeId(level.getBiome(pos.set(chunkMinX + 8, + findMarkedGround(chunk, pos, chunkMinX + 8, chunkMinZ + 8, + level.getMinY(), level.getMaxY()), chunkMinZ + 8))); + if (previousChunkBiome != null && !previousChunkBiome.equals(centerBiome)) edgeChanges++; + previousChunkBiome = centerBiome; + sentinels += auditSentinels(level, chunk, pos, chunkMinX, chunkMinZ); + } + } + + if (top != EXPECTED_COLUMNS - 9 || underwater != 9 || filler != EXPECTED_FILLER + || biomeA == 0 || biomeB == 0 || edgeChanges == 0 || sentinels != 9 * 4 + || geology != (roofed ? 0 : EXPECTED_FILLER) + || (roofed && (ceiling != EXPECTED_COLUMNS || roofTop != EXPECTED_COLUMNS))) { + throw new IllegalStateException("Incomplete surface audit for " + level.dimension().identifier() + + ": top=" + top + ", underwater=" + underwater + ", filler=" + filler + + ", biomeA=" + biomeA + ", biomeB=" + biomeB + ", edges=" + edgeChanges + + ", sentinels=" + sentinels + ", geology=" + geology + + ", ceiling=" + ceiling + ", roofTop=" + roofTop); + } + return new AuditResult(top, underwater, filler, geology, ceiling, roofTop, + biomeA, biomeB, edgeChanges, sentinels); + } + + private static int auditSentinels(ServerLevel level, LevelChunk chunk, + BlockPos.MutableBlockPos pos, int minX, int minZ) { + int groundTree = findMarkedGround(chunk, pos, minX + 4, minZ + 4, + level.getMinY(), level.getMaxY()); + assertBlock(chunk, pos, minX + 4, groundTree + 1, minZ + 4, + Blocks.OAK_LOG.defaultBlockState(), "tree log"); + assertBlock(chunk, pos, minX + 4, groundTree + 3, minZ + 4, + Blocks.OAK_LEAVES.defaultBlockState(), "tree leaves"); + + int groundVegetation = findMarkedGround(chunk, pos, minX + 6, minZ + 6, + level.getMinY(), level.getMaxY()); + assertBlock(chunk, pos, minX + 6, groundVegetation + 1, minZ + 6, + Blocks.DIRT.defaultBlockState(), "vegetation substrate"); + assertBlock(chunk, pos, minX + 6, groundVegetation + 2, minZ + 6, + Blocks.OAK_SAPLING.defaultBlockState(), "vegetation"); + + int groundStructure = findMarkedGround(chunk, pos, minX + 8, minZ + 8, + level.getMinY(), level.getMaxY()); + assertBlock(chunk, pos, minX + 8, groundStructure + 1, minZ + 8, + Blocks.GOLD_BLOCK.defaultBlockState(), "authored structure"); + + int groundChest = findMarkedGround(chunk, pos, minX + 10, minZ + 10, + level.getMinY(), level.getMaxY()); + assertBlock(chunk, pos, minX + 10, groundChest + 1, minZ + 10, + Blocks.CHEST.defaultBlockState(), "chest sentinel"); + if (!(level.getBlockEntity(pos.set(minX + 10, groundChest + 1, minZ + 10)) + instanceof ChestBlockEntity chest) + || !chest.getItem(0).is(Items.DIAMOND) + || chest.getItem(0).getHoverName() == null + || !CHEST_ITEM_NAME.equals(chest.getItem(0).getHoverName().getString())) { + throw new IllegalStateException("Chest block entity data changed at " + pos); + } + return 4; + } + + private static int findMarkedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, + int x, int z, int minY, int maxY) { + for (int y = maxY - 1; y >= minY; y--) { + if (chunk.getBlockState(pos.set(x, y, z)).is(concreteBlock(DyeColor.BLACK))) return y + 5; + } + throw new IllegalStateException("Independent surface marker missing at " + x + "," + z); + } + + private static void assertBlock(ChunkAccess chunk, BlockPos.MutableBlockPos pos, + int x, int y, int z, BlockState expected, String purpose) { + BlockState actual = chunk.getBlockState(pos.set(x, y, z)); + if (!actual.is(expected.getBlock())) { + throw new IllegalStateException("Expected " + purpose + " " + expected.getBlock() + + " at " + pos + " but found " + actual.getBlock()); + } + } + + private static Material material(Identifier biome, boolean roofed) { + if (BIOME_A.equals(biome)) { + return new Material(concrete(DyeColor.PINK), concrete(DyeColor.WHITE), + concrete(DyeColor.BLUE), roofed ? concrete(DyeColor.ORANGE) : null); + } + if (BIOME_B.equals(biome)) { + return new Material(concrete(DyeColor.LIME), concrete(DyeColor.YELLOW), + concrete(DyeColor.LIGHT_BLUE), roofed ? concrete(DyeColor.MAGENTA) : null); + } + throw new IllegalStateException("Unexpected provider biome " + biome); + } + + private static BlockState concrete(DyeColor color) { + return concreteBlock(color).defaultBlockState(); + } + + private static Block concreteBlock(DyeColor color) { + return switch (color) { + case WHITE -> Blocks.WHITE_CONCRETE; + case ORANGE -> Blocks.ORANGE_CONCRETE; + case MAGENTA -> Blocks.MAGENTA_CONCRETE; + case LIGHT_BLUE -> Blocks.LIGHT_BLUE_CONCRETE; + case YELLOW -> Blocks.YELLOW_CONCRETE; + case LIME -> Blocks.LIME_CONCRETE; + case PINK -> Blocks.PINK_CONCRETE; + case GRAY -> Blocks.GRAY_CONCRETE; + case LIGHT_GRAY -> Blocks.LIGHT_GRAY_CONCRETE; + case CYAN -> Blocks.CYAN_CONCRETE; + case PURPLE -> Blocks.PURPLE_CONCRETE; + case BLUE -> Blocks.BLUE_CONCRETE; + case BROWN -> Blocks.BROWN_CONCRETE; + case GREEN -> Blocks.GREEN_CONCRETE; + case RED -> Blocks.RED_CONCRETE; + case BLACK -> Blocks.BLACK_CONCRETE; + }; + } + + private static Identifier biomeId(net.minecraft.core.Holder biome) { + return biome.unwrapKey().map(key -> key.identifier()).orElse(null); + } + + private static Properties properties(long seed, Map results) { + Properties values = new Properties(); + values.setProperty("seed", Long.toString(seed)); + values.setProperty("dimensions", Integer.toString(results.size())); + values.setProperty("columns_per_dimension", Integer.toString(EXPECTED_COLUMNS)); + for (Map.Entry entry : results.entrySet()) { + String prefix = entry.getKey() + "."; + AuditResult result = entry.getValue(); + values.setProperty(prefix + "top", Long.toString(result.top())); + values.setProperty(prefix + "underwater", Long.toString(result.underwater())); + values.setProperty(prefix + "filler", Long.toString(result.filler())); + values.setProperty(prefix + "geology", Long.toString(result.geology())); + values.setProperty(prefix + "ceiling", Long.toString(result.ceiling())); + values.setProperty(prefix + "roof_top", Long.toString(result.roofTop())); + values.setProperty(prefix + "biome_a", Integer.toString(result.biomeA())); + values.setProperty(prefix + "biome_b", Integer.toString(result.biomeB())); + values.setProperty(prefix + "edge_changes", Integer.toString(result.edgeChanges())); + values.setProperty(prefix + "sentinels", Integer.toString(result.sentinels())); + } + return values; + } + + private static Properties readMarker(Path marker) { + if (!Files.isRegularFile(marker)) { + throw new IllegalStateException("Reload phase did not reuse the fresh test world: " + marker); + } + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(marker)) { + values.load(input); + return values; + } catch (IOException exception) { + throw new IllegalStateException("Could not read surface integration marker", exception); + } + } + + private static void writeMarker(Path marker, Properties values) { + try (OutputStream output = Files.newOutputStream(marker)) { + values.store(output, "OreSpawn provider surface integration test"); + } catch (IOException exception) { + throw new IllegalStateException("Could not write surface integration marker", exception); + } + } + + private enum ProbeStage { TERRAIN, STRUCTURE, VEGETATION } + + private static final class ProbeFeature extends Feature { + private final ProbeStage stage; + + private ProbeFeature(ProbeStage stage) { + super(NoneFeatureConfiguration.CODEC); + this.stage = stage; + } + + @Override + public boolean place(FeaturePlaceContext context) { + WorldGenLevel world = context.level(); + ChunkAccess chunk = world.getChunk(context.origin()); + return switch (stage) { + case TERRAIN -> prepareTerrain(world, chunk); + case STRUCTURE -> placeStructureSentinels(world, chunk); + case VEGETATION -> placeVegetationSentinels(world, chunk); + }; + } + } + + private static boolean prepareTerrain(WorldGenLevel world, ChunkAccess chunk) { + boolean roofed = world.getLevel().dimension().equals(ROOFED); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + Heightmap surfaceHeight = chunk.getOrCreateHeightmapUnprimed( + Heightmap.Types.WORLD_SURFACE_WG); + int minX = chunk.getPos().getMinBlockX(); + int minZ = chunk.getPos().getMinBlockZ(); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int x = minX + localX; + int z = minZ + localZ; + int groundY = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, localX, localZ); + if (roofed) { + while (groundY > world.getMinY() + && solid(chunk.getBlockState(pos.set(x, groundY, z)))) groundY--; + } + while (groundY > world.getMinY() + && !solid(chunk.getBlockState(pos.set(x, groundY, z)))) groundY--; + if (!roofed && groundY <= world.getMinY()) groundY = 64; + chunk.setBlockState(pos.set(x, groundY, z), Blocks.GRASS_BLOCK.defaultBlockState(), 0); + surfaceHeight.update(localX, groundY, localZ, Blocks.GRASS_BLOCK.defaultBlockState()); + for (int depth = 1; depth <= 3; depth++) { + chunk.setBlockState(pos.set(x, groundY - depth, z), Blocks.DIRT.defaultBlockState(), 0); + } + if (!roofed) { + for (int depth = 6; depth <= 8; depth++) { + chunk.setBlockState(pos.set(x, groundY - depth, z), Blocks.END_STONE.defaultBlockState(), 0); + } + } + chunk.setBlockState(pos.set(x, groundY - 5, z), + concreteBlock(DyeColor.BLACK).defaultBlockState(), 0); + if (roofed) { + for (int openY = groundY + 1; openY < world.getMaxY(); openY++) { + chunk.setBlockState(pos.set(x, openY, z), Blocks.AIR.defaultBlockState(), 0); + } + for (int roofY = groundY + 8; roofY <= groundY + 10; roofY++) { + chunk.setBlockState(pos.set(x, roofY, z), Blocks.STONE.defaultBlockState(), 0); + } + surfaceHeight.update(localX, groundY + 10, localZ, + Blocks.STONE.defaultBlockState()); + } + if (localX == 1 && localZ == 1) { + chunk.setBlockState(pos.set(x, groundY + 1, z), Blocks.WATER.defaultBlockState(), 0); + surfaceHeight.update(localX, groundY + 1, localZ, Blocks.WATER.defaultBlockState()); + } + } + } + return true; + } + + private static boolean solid(BlockState state) { + return !state.isAir() && state.getFluidState().isEmpty(); + } + + private static boolean placeStructureSentinels(WorldGenLevel world, ChunkAccess chunk) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int minX = chunk.getPos().getMinBlockX(); + int minZ = chunk.getPos().getMinBlockZ(); + int structureY = markedGround(chunk, pos, minX + 8, minZ + 8, world); + world.setBlock(pos.set(minX + 8, structureY + 1, minZ + 8), + Blocks.GOLD_BLOCK.defaultBlockState(), 2); + int chestY = markedGround(chunk, pos, minX + 10, minZ + 10, world); + world.setBlock(pos.set(minX + 10, chestY + 1, minZ + 10), Blocks.CHEST.defaultBlockState(), 2); + if (world.getBlockEntity(pos) instanceof ChestBlockEntity chest) { + ItemStack sentinel = new ItemStack(Items.DIAMOND); + sentinel.set(net.minecraft.core.component.DataComponents.CUSTOM_NAME, + Component.literal(CHEST_ITEM_NAME)); + chest.setItem(0, sentinel); + chest.setChanged(); + } + return true; + } + + private static boolean placeVegetationSentinels(WorldGenLevel world, ChunkAccess chunk) { + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int minX = chunk.getPos().getMinBlockX(); + int minZ = chunk.getPos().getMinBlockZ(); + int treeY = markedGround(chunk, pos, minX + 4, minZ + 4, world); + for (int y = 1; y <= 2; y++) { + world.setBlock(pos.set(minX + 4, treeY + y, minZ + 4), Blocks.OAK_LOG.defaultBlockState(), 2); + } + world.setBlock(pos.set(minX + 4, treeY + 3, minZ + 4), Blocks.OAK_LEAVES.defaultBlockState(), 2); + int vegetationY = markedGround(chunk, pos, minX + 6, minZ + 6, world); + world.setBlock(pos.set(minX + 6, vegetationY + 1, minZ + 6), Blocks.DIRT.defaultBlockState(), 2); + world.setBlock(pos.set(minX + 6, vegetationY + 2, minZ + 6), Blocks.OAK_SAPLING.defaultBlockState(), 2); + return true; + } + + private static int markedGround(ChunkAccess chunk, BlockPos.MutableBlockPos pos, + int x, int z, WorldGenLevel world) { + return findMarkedGround(chunk, pos, x, z, world.getMinY(), world.getMaxY()); + } + + private record Material(BlockState top, BlockState filler, + BlockState underwater, BlockState ceiling) { } + + private record AuditResult(long top, long underwater, long filler, long geology, + long ceiling, long roofTop, int biomeA, int biomeB, + int edgeChanges, int sentinels) { } +} diff --git a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/mixin/GameTestMainUtilMixin.java b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/mixin/GameTestMainUtilMixin.java index e5817f2..5b0f80d 100644 --- a/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/mixin/GameTestMainUtilMixin.java +++ b/src/biomeIntegrationTest/java/zone/moddev/mc/orespawn/testmod/mixin/GameTestMainUtilMixin.java @@ -14,9 +14,9 @@ @Mixin(GameTestMainUtil.class) abstract class GameTestMainUtilMixin { @Inject(method = "createOrResetDir", at = @At("HEAD"), cancellable = true, remap = false) - private static void cakeworldprobe$preserveReloadUniverse(String universePath, + private static void surfaceprobe$preserveReloadUniverse(String universePath, CallbackInfo callback) { - if (!"reload".equals(System.getProperty("cakeworld.biomeIntegrationPhase"))) return; + if (!"reload".equals(System.getProperty("surfaceprobe.integrationPhase"))) return; Path universe = Path.of(universePath); if (!Files.isDirectory(universe)) { throw new IllegalStateException( diff --git a/src/biomeIntegrationTest/resources/META-INF/mods.toml b/src/biomeIntegrationTest/resources/META-INF/mods.toml index ff423ff..5f8442a 100644 --- a/src/biomeIntegrationTest/resources/META-INF/mods.toml +++ b/src/biomeIntegrationTest/resources/META-INF/mods.toml @@ -3,19 +3,19 @@ loaderVersion="[64,)" license="LGPL-2.1" [[mods]] -modId="cakeworldprobe" +modId="surfaceprobe" version="1" -displayName="CakeWorld Biome Integration Test" -description='''Test-only provider mod for OreSpawn's custom-biome integration gate.''' +displayName="OreSpawn Surface Integration Test" +description='''Test-only provider mod for OreSpawn's surface replacement gate.''' -[[dependencies.cakeworldprobe]] +[[dependencies.surfaceprobe]] modId="orespawn" mandatory=true -versionRange="[4.0.5,5.0.0)" +versionRange="[4.0.6,5.0.0)" ordering="AFTER" side="BOTH" -[[dependencies.cakeworldprobe]] +[[dependencies.surfaceprobe]] modId="minecraft" mandatory=true versionRange="[26.1.2,26.2,)" diff --git a/src/biomeIntegrationTest/resources/data/cakeworldprobe/worldgen/biome/cake_plains.json b/src/biomeIntegrationTest/resources/data/cakeworldprobe/worldgen/biome/cake_plains.json deleted file mode 100644 index 6d2563c..0000000 --- a/src/biomeIntegrationTest/resources/data/cakeworldprobe/worldgen/biome/cake_plains.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "attributes": { - "minecraft:audio/ambient_sounds": { - "additions": { - "sound": "minecraft:ambient.nether_wastes.additions", - "tick_chance": 0.0111 - }, - "loop": "minecraft:ambient.nether_wastes.loop", - "mood": { - "block_search_extent": 8, - "offset": 2.0, - "sound": "minecraft:ambient.nether_wastes.mood", - "tick_delay": 6000 - } - }, - "minecraft:audio/background_music": { - "default": { - "max_delay": 24000, - "min_delay": 12000, - "sound": "minecraft:music.nether.nether_wastes" - } - }, - "minecraft:visual/fog_color": "#330808" - }, - "carvers": "minecraft:nether_cave", - "downfall": 0.15, - "effects": { - "water_color": "#3f76e4" - }, - "features": [ - [], - [], - [], - [], - [], - [], - [], - [ - "minecraft:spring_open", - "minecraft:patch_fire", - "minecraft:patch_soul_fire", - "minecraft:glowstone_extra", - "minecraft:glowstone", - "minecraft:brown_mushroom_nether", - "minecraft:red_mushroom_nether", - "minecraft:ore_magma", - "minecraft:spring_closed", - "minecraft:ore_gravel_nether", - "minecraft:ore_blackstone", - "minecraft:ore_gold_nether", - "minecraft:ore_quartz_nether", - "minecraft:ore_ancient_debris_large", - "minecraft:ore_debris_small" - ], - [], - [ - "minecraft:spring_lava", - "minecraft:brown_mushroom_normal", - "minecraft:red_mushroom_normal" - ] - ], - "has_precipitation": false, - "spawn_costs": {}, - "spawners": { - "ambient": [], - "axolotls": [], - "creature": [ - { - "type": "minecraft:strider", - "maxCount": 2, - "minCount": 1, - "weight": 60 - } - ], - "misc": [], - "monster": [ - { - "type": "minecraft:ghast", - "maxCount": 4, - "minCount": 4, - "weight": 50 - }, - { - "type": "minecraft:zombified_piglin", - "maxCount": 4, - "minCount": 4, - "weight": 100 - }, - { - "type": "minecraft:magma_cube", - "maxCount": 4, - "minCount": 4, - "weight": 2 - }, - { - "type": "minecraft:enderman", - "maxCount": 4, - "minCount": 4, - "weight": 1 - }, - { - "type": "minecraft:piglin", - "maxCount": 4, - "minCount": 4, - "weight": 15 - } - ], - "underground_water_creature": [], - "water_ambient": [], - "water_creature": [] - }, - "temperature": 1.35 -} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/structure_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/structure_sentinels.json new file mode 100644 index 0000000..923ab4a --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/structure_sentinels.json @@ -0,0 +1,15 @@ +{ + "type": "forge:add_features", + "biomes": [ + "minecraft:the_end", + "minecraft:nether_wastes", + "minecraft:soul_sand_valley", + "minecraft:crimson_forest", + "minecraft:warped_forest", + "minecraft:basalt_deltas" + ], + "features": [ + "surfaceprobe:structure_sentinels" + ], + "step": "surface_structures" +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/terrain_setup.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/terrain_setup.json new file mode 100644 index 0000000..7a80035 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/terrain_setup.json @@ -0,0 +1,15 @@ +{ + "type": "forge:add_features", + "biomes": [ + "minecraft:the_end", + "minecraft:nether_wastes", + "minecraft:soul_sand_valley", + "minecraft:crimson_forest", + "minecraft:warped_forest", + "minecraft:basalt_deltas" + ], + "features": [ + "surfaceprobe:terrain_setup" + ], + "step": "raw_generation" +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/vegetation_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/vegetation_sentinels.json new file mode 100644 index 0000000..610d6b0 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/forge/biome_modifier/vegetation_sentinels.json @@ -0,0 +1,15 @@ +{ + "type": "forge:add_features", + "biomes": [ + "minecraft:the_end", + "minecraft:nether_wastes", + "minecraft:soul_sand_valley", + "minecraft:crimson_forest", + "minecraft:warped_forest", + "minecraft:basalt_deltas" + ], + "features": [ + "surfaceprobe:vegetation_sentinels" + ], + "step": "vegetal_decoration" +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_a.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_a.json new file mode 100644 index 0000000..b2567de --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_a.json @@ -0,0 +1,44 @@ +{ + "attributes": { + "minecraft:audio/ambient_sounds": { + "mood": { + "block_search_extent": 8, + "offset": 2.0, + "sound": "minecraft:ambient.cave", + "tick_delay": 6000 + } + }, + "minecraft:visual/fog_color": "#c0d8ff" + }, + "carvers": [], + "downfall": 0.15, + "effects": { + "water_color": "#3f76e4" + }, + "features": [ + ["surfaceprobe:terrain_setup"], + [], + [], + [], + ["surfaceprobe:structure_sentinels"], + [], + [], + [], + [], + ["surfaceprobe:vegetation_sentinels"], + [] + ], + "has_precipitation": true, + "spawn_costs": {}, + "spawners": { + "ambient": [], + "axolotls": [], + "creature": [], + "misc": [], + "monster": [], + "underground_water_creature": [], + "water_ambient": [], + "water_creature": [] + }, + "temperature": 1.35 +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_b.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_b.json new file mode 100644 index 0000000..fd8f56c --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/biome/surface_b.json @@ -0,0 +1,44 @@ +{ + "attributes": { + "minecraft:audio/ambient_sounds": { + "mood": { + "block_search_extent": 8, + "offset": 2.0, + "sound": "minecraft:ambient.cave", + "tick_delay": 6000 + } + }, + "minecraft:visual/fog_color": "#c0d8ff" + }, + "carvers": [], + "downfall": 0.8, + "effects": { + "water_color": "#3f76e4" + }, + "features": [ + ["surfaceprobe:terrain_setup"], + [], + [], + [], + ["surfaceprobe:structure_sentinels"], + [], + [], + [], + [], + ["surfaceprobe:vegetation_sentinels"], + [] + ], + "has_precipitation": true, + "spawn_costs": {}, + "spawners": { + "ambient": [], + "axolotls": [], + "creature": [], + "misc": [], + "monster": [], + "underground_water_creature": [], + "water_ambient": [], + "water_creature": [] + }, + "temperature": 0.7 +} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/structure_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/structure_sentinels.json new file mode 100644 index 0000000..52a252e --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/structure_sentinels.json @@ -0,0 +1 @@ +{"type":"surfaceprobe:structure_sentinels","config":{}} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/terrain_setup.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/terrain_setup.json new file mode 100644 index 0000000..8dc1ac0 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/terrain_setup.json @@ -0,0 +1 @@ +{"type":"surfaceprobe:terrain_setup","config":{}} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/vegetation_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/vegetation_sentinels.json new file mode 100644 index 0000000..676a113 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/configured_feature/vegetation_sentinels.json @@ -0,0 +1 @@ +{"type":"surfaceprobe:vegetation_sentinels","config":{}} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/structure_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/structure_sentinels.json new file mode 100644 index 0000000..7c80ccb --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/structure_sentinels.json @@ -0,0 +1 @@ +{"feature":"surfaceprobe:structure_sentinels","placement":[]} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/terrain_setup.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/terrain_setup.json new file mode 100644 index 0000000..63f998f --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/terrain_setup.json @@ -0,0 +1 @@ +{"feature":"surfaceprobe:terrain_setup","placement":[]} diff --git a/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/vegetation_sentinels.json b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/vegetation_sentinels.json new file mode 100644 index 0000000..01c8b35 --- /dev/null +++ b/src/biomeIntegrationTest/resources/data/surfaceprobe/worldgen/placed_feature/vegetation_sentinels.json @@ -0,0 +1 @@ +{"feature":"surfaceprobe:vegetation_sentinels","placement":[]} diff --git a/src/biomeIntegrationTest/resources/pack.mcmeta b/src/biomeIntegrationTest/resources/pack.mcmeta index d2c62da..53e92b3 100644 --- a/src/biomeIntegrationTest/resources/pack.mcmeta +++ b/src/biomeIntegrationTest/resources/pack.mcmeta @@ -1,6 +1,6 @@ { "pack": { - "description": "OreSpawn custom-biome integration fixtures", + "description": "OreSpawn provider-surface integration fixtures", "max_format": 101, "min_format": [ 101, diff --git a/src/biomeIntegrationTest/resources/cakeworldprobe.mixins.json b/src/biomeIntegrationTest/resources/surfaceprobe.mixins.json similarity index 100% rename from src/biomeIntegrationTest/resources/cakeworldprobe.mixins.json rename to src/biomeIntegrationTest/resources/surfaceprobe.mixins.json diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java index 396df0b..4d563a1 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BakedGeomeConfig.java @@ -48,7 +48,7 @@ public final class BakedGeomeConfig { BakedGeomeConfig(GeomeDefinition[] geomes, double geomeScale, double biomeInfluence, double regionalNoiseInfluence, double boundaryNoiseInfluence, Map biomeWeights, - RockEntry[] rocks, FormationSettings formations) { + Map biomeWeightsById, RockEntry[] rocks, FormationSettings formations) { this.geomes = geomes; this.geomeScale = geomeScale; this.biomeInfluence = biomeInfluence; @@ -57,7 +57,7 @@ public final class BakedGeomeConfig { this.formations = formations; this.familyDiversitySlots = formations.familyDiversitySlots(); this.biomeWeights = new IdentityHashMap<>(biomeWeights); - this.biomeWeightsById = new HashMap<>(); + this.biomeWeightsById = new HashMap<>(biomeWeightsById); for (Map.Entry entry : biomeWeights.entrySet()) { Identifier biomeId = ForgeRegistries.BIOMES.getKey(entry.getKey()); if (biomeId != null) { @@ -109,6 +109,23 @@ int pickGeome(Biome biome, Identifier biomeId, double[] regionalNoise, double bo return bestIndex; } + int scoreGeomes(Biome biome, Identifier biomeId, double[] regionalNoiseAndScores, double boundaryNoise) { + double[] weights = biomeWeightsFor(biome, biomeId); + double bestScore = Double.NEGATIVE_INFINITY; + int bestIndex = 0; + for (int i = 0; i < geomes.length; i++) { + double boundary = ((i & 1) == 0 ? boundaryNoise : -boundaryNoise) * boundaryNoiseInfluence; + double score = geomes[i].baseWeight + (weights[i] * biomeInfluence) + + (regionalNoiseAndScores[i] * regionalNoiseInfluence) + boundary; + regionalNoiseAndScores[i] = score; + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + return bestIndex; + } + public RockFamily pickFamily(int geomeIndex, int y, int formationValue) { return pickFamily(geomeIndex, y, formationValue, 0); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java index 17cc09d..7d16319 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeature.java @@ -13,7 +13,7 @@ import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; import net.minecraft.world.level.levelgen.placement.PlacedFeature; -/** Applies explicit provider surface blocks after the source surface is built. */ +/** Applies provider surfaces after base surfaces and lakes, before late features. */ public final class BiomeSurfaceFeature extends Feature { public static final BiomeSurfaceFeature FEATURE = new BiomeSurfaceFeature(); private static Holder placedFeature; @@ -40,57 +40,99 @@ public boolean place(FeaturePlaceContext context) { boolean changed = false; int minX = chunk.getPos().getMinBlockX(); int minZ = chunk.getPos().getMinBlockZ(); + boolean ceilingDimension = world.getLevel().dimensionType().hasCeiling(); for (int localX = 0; localX < 16; localX++) { for (int localZ = 0; localZ < 16; localZ++) { int x = minX + localX; int z = minZ + localZ; - int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, localX, localZ) - 1; - while (y > world.getMinY()) { - cursor.set(x, y, z); - BlockState state = chunk.getBlockState(cursor); - if (!state.isAir() && state.getFluidState().isEmpty()) break; - y--; + int groundY; + int ceilingY = Integer.MIN_VALUE; + if (ceilingDimension) { + long column = findCeilingAndGround(chunk, cursor, x, z, + world.getMaxY(), world.getMinY()); + ceilingY = (int) (column >> 32); + groundY = (int) column; + } else { + groundY = findOpenGround(chunk, cursor, x, z, localX, localZ, + world.getMinY()); } - if (y <= world.getMinY()) continue; - cursor.set(x, y, z); - Surface surface = config.surfaces.get(world.getBiome(cursor)); - if (surface == null) continue; - boolean underwater = !chunk.getBlockState(cursor.above()).getFluidState().isEmpty(); - BlockState top = underwater && surface.underwater != null - ? surface.underwater : surface.top; - if (top != null && replaceable(chunk.getBlockState(cursor))) { - chunk.setBlockState(cursor, top, 0); - changed = true; + if (groundY >= world.getMinY()) { + changed |= applyGround(chunk, world, config, cursor, x, z, + groundY, world.getMinY()); } - if (surface.filler != null) { - for (int depth = 1; depth <= surface.fillerDepth - && y - depth >= world.getMinY(); depth++) { - cursor.set(x, y - depth, z); - if (!replaceable(chunk.getBlockState(cursor))) break; - chunk.setBlockState(cursor, surface.filler, 0); - changed = true; - } - } - if (surface.ceiling != null) { - changed |= applyCeiling(chunk, cursor, x, z, world.getMaxY(), - world.getMinY(), surface.ceiling); + if (ceilingY >= world.getMinY()) { + changed |= applyCeiling(chunk, world, config, cursor, x, z, ceilingY); } } } return changed; } - private static boolean applyCeiling(ChunkAccess chunk, BlockPos.MutableBlockPos cursor, - int x, int z, int maxY, int minY, BlockState ceiling) { - for (int y = maxY - 1; y >= minY; y--) { - cursor.set(x, y, z); - BlockState state = chunk.getBlockState(cursor); - if (state.isAir() || !state.getFluidState().isEmpty()) continue; - if (!replaceable(state)) return false; - chunk.setBlockState(cursor, ceiling, 0); - return true; + private static int findOpenGround(ChunkAccess chunk, BlockPos.MutableBlockPos cursor, + int x, int z, int localX, int localZ, int minY) { + int y = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, localX, localZ); + while (y >= minY && open(chunk.getBlockState(cursor.set(x, y, z)))) y--; + return y; + } + + private static long findCeilingAndGround(ChunkAccess chunk, + BlockPos.MutableBlockPos cursor, int x, int z, int maxY, int minY) { + int y = maxY - 1; + while (y >= minY && open(chunk.getBlockState(cursor.set(x, y, z)))) y--; + if (y < minY) return pack(Integer.MIN_VALUE, Integer.MIN_VALUE); + while (y >= minY && !open(chunk.getBlockState(cursor.set(x, y, z)))) y--; + int ceilingY = y + 1; + while (y >= minY && open(chunk.getBlockState(cursor.set(x, y, z)))) y--; + return pack(ceilingY, y); + } + + private static boolean applyGround(ChunkAccess chunk, WorldGenLevel world, + BakedBiomeWorldgen config, BlockPos.MutableBlockPos cursor, + int x, int z, int y, int minY) { + cursor.set(x, y, z); + BlockState source = chunk.getBlockState(cursor); + if (!replaceable(source)) return false; + Surface surface = config.surfaces.get(world.getBiome(cursor)); + if (surface == null) return false; + cursor.set(x, y + 1, z); + boolean underwater = !chunk.getBlockState(cursor).getFluidState().isEmpty(); + BlockState top = underwater && surface.underwater != null + ? surface.underwater : surface.top; + boolean changed = false; + cursor.set(x, y, z); + if (top != null) { + chunk.setBlockState(cursor, top, 0); + changed = true; + } + if (surface.filler != null) { + for (int depth = 1; depth <= surface.fillerDepth && y - depth >= minY; depth++) { + cursor.set(x, y - depth, z); + if (!replaceable(chunk.getBlockState(cursor))) break; + chunk.setBlockState(cursor, surface.filler, 0); + changed = true; + } } - return false; + return changed; + } + + private static boolean applyCeiling(ChunkAccess chunk, WorldGenLevel world, + BakedBiomeWorldgen config, BlockPos.MutableBlockPos cursor, + int x, int z, int ceilingY) { + cursor.set(x, ceilingY, z); + BlockState source = chunk.getBlockState(cursor); + if (!replaceable(source)) return false; + Surface surface = config.surfaces.get(world.getBiome(cursor)); + if (surface == null || surface.ceiling == null) return false; + chunk.setBlockState(cursor, surface.ceiling, 0); + return true; + } + + private static boolean open(BlockState state) { + return state.isAir() || !state.getFluidState().isEmpty(); + } + + private static long pack(int high, int low) { + return ((long) high << 32) | (low & 0xFFFFFFFFL); } private static boolean replaceable(BlockState state) { diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java index 52aae40..b5ea7dc 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java @@ -117,8 +117,9 @@ public boolean place(FeaturePlaceContext context) { for (BakedDeposit deposit : deposits) { if (!deposit.acceptsBiome(biome)) continue; if (!geomeClassified && deposit.usesGeomeWeights && config != null) { + Identifier biomeId = biome.unwrapKey().map(ResourceKey::identifier).orElse(null); geome = classifier(dimension, world.getSeed(), config).classifyColumn( - biome.value(), centerX, centerZ, scratch.geomeValues(config.geomeCount())); + biome.value(), biomeId, centerX, centerZ, scratch.geomeValues(config.geomeCount())); geomeClassified = true; } double frequency = geome < 0 ? deposit.frequency : deposit.frequency * deposit.geomeWeights[geome]; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/FormationSettings.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/FormationSettings.java index 87c5822..45926cc 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/FormationSettings.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/FormationSettings.java @@ -26,12 +26,12 @@ public static Algorithm fromConfigName(String name) { } public enum Preset { - TINY("tiny", 64.0D, 25.0D, 128.0D, 1, 15.0D, 1, 0.00D, 12.0D, 32.0D, 0.0D, 0), - SMALL("small", 128.0D, 50.0D, 192.0D, 3, 30.0D, 2, 0.50D, 24.0D, 48.0D, 4.0D, 1), - AVERAGE("average", 256.0D, 100.0D, 256.0D, 8, 60.0D, 4, 0.85D, 48.0D, 64.0D, 12.0D, 2), - LARGE("large", 512.0D, 200.0D, 384.0D, 28, 90.0D, 5, 0.95D, 128.0D, 96.0D, 24.0D, 3), - HUGE("huge", 1024.0D, 640.0D, 512.0D, 128, 120.0D, 6, 1.00D, 288.0D, 128.0D, 48.0D, 4), - CUSTOM("custom", 256.0D, 100.0D, 256.0D, 8, 60.0D, 4, 0.85D, 48.0D, 64.0D, 12.0D, 2); + TINY("tiny", 64.0D, 25.0D, 128.0D, 1, 15.0D, 1, 0.00D, 12.0D, 48.0D, 4.0D, 1), + SMALL("small", 128.0D, 50.0D, 192.0D, 3, 30.0D, 2, 0.50D, 24.0D, 64.0D, 12.0D, 2), + AVERAGE("average", 256.0D, 100.0D, 256.0D, 8, 60.0D, 4, 0.85D, 48.0D, 96.0D, 24.0D, 3), + LARGE("large", 512.0D, 200.0D, 384.0D, 28, 90.0D, 5, 0.95D, 128.0D, 128.0D, 48.0D, 4), + HUGE("huge", 1024.0D, 640.0D, 512.0D, 128, 120.0D, 6, 1.00D, 288.0D, 192.0D, 96.0D, 5), + CUSTOM("custom", 256.0D, 100.0D, 256.0D, 8, 60.0D, 4, 0.85D, 48.0D, 96.0D, 24.0D, 3); final String configName; final double stratumWavelength; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java index 66afbd1..d5fca55 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java @@ -10,6 +10,7 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.LinkedHashMap; @@ -287,11 +288,14 @@ private static BakedGeomeConfig bake(JsonObject root, Identifier dimension) { return null; } Map biomeWeights = bakeBiomeWeights(geomeIndexes, biomeRules, dictionaryRules); + Map biomeWeightsById = bakeBiomeIdentifierWeights(geomeIndexes, biomeRules); - LOGGER.info("Baked OreSpawn geome config for '{}' with {} geomes, {} rock entries, {} biome profiles, and {} formations", - dimension, geomes.length, rocks.length, biomeWeights.size(), formations.algorithm.configName); + LOGGER.info("Baked OreSpawn geome config for '{}' with {} geomes, {} rock entries, " + + "{} resolved biome profiles, {} identifier profiles, and {} formations", + dimension, geomes.length, rocks.length, biomeWeights.size(), biomeWeightsById.size(), + formations.algorithm.configName); return new BakedGeomeConfig(geomes, geomeScale, biomeInfluence, regionalNoiseInfluence, - boundaryNoiseInfluence, biomeWeights, rocks, formations); + boundaryNoiseInfluence, biomeWeights, biomeWeightsById, rocks, formations); } private static JsonObject applyFreshWorldTemplate(JsonObject root) { @@ -445,16 +449,16 @@ private static FormationSettings readFormationSettings(JsonObject root) { : stableLayers ? waviness.stableWavinessAmplitude : waviness.wavinessAmplitude; double edgeWavelength = stableLayers ? irregularity == Preset.CUSTOM - ? getBoundedDouble(custom, "edge_wavelength", 64.0D, 8.0D, 512.0D) + ? getBoundedDouble(custom, "edge_wavelength", 96.0D, 8.0D, 512.0D) : irregularity.stableEdgeWavelength : 64.0D; double edgeAmplitude = !stableLayers ? 0.0D : irregularity == Preset.CUSTOM - ? getBoundedDouble(custom, "edge_amplitude", 12.0D, 0.0D, 256.0D) + ? getBoundedDouble(custom, "edge_amplitude", 24.0D, 0.0D, 256.0D) : irregularity.stableEdgeAmplitude; int edgeOctaves = irregularity == Preset.CUSTOM - ? getBoundedInt(custom, "edge_octaves", stableLayers ? 2 : 4, 1, 8) + ? getBoundedInt(custom, "edge_octaves", stableLayers ? 3 : 4, 1, 8) : stableLayers ? irregularity.stableEdgeOctaves : irregularity.edgeOctaves; double formationContinuity = continuity == Preset.CUSTOM ? getBoundedDouble(custom, "continuity", 0.85D, 0.0D, 1.0D) @@ -1034,11 +1038,33 @@ private static Map bakeBiomeWeights(Map geomeI return result; } + static Map bakeBiomeIdentifierWeights(Map geomeIndexes, + Map biomeRules) { + Map result = new LinkedHashMap<>(); + for (Entry entry : biomeRules.entrySet()) { + try { + Identifier biomeId = Identifier.parse(entry.getKey()); + double[] weights = new double[geomeIndexes.size()]; + Arrays.fill(weights, 1.0D); + merge(weights, entry.getValue()); + applyBiomeHeuristic(weights, geomeIndexes, biomeId, Float.NaN, Float.NaN); + result.put(biomeId, weights); + } catch (RuntimeException e) { + LOGGER.warn("Ignoring invalid OreSpawn biome rule ID '{}'", entry.getKey()); + } + } + return result; + } + private static void applyBiomeHeuristic(double[] weights, Map geomeIndexes, Identifier biomeId, Biome biome) { + applyBiomeHeuristic(weights, geomeIndexes, biomeId, + biome.getBaseTemperature(), biome.getModifiedClimateSettings().downfall()); + } + + private static void applyBiomeHeuristic(double[] weights, Map geomeIndexes, + Identifier biomeId, float temperature, float downfall) { String biomeName = biomeId == null ? "" : biomeId.getPath(); - float temperature = biome.getBaseTemperature(); - float downfall = biome.getModifiedClimateSettings().downfall(); if (biomeName.contains("ocean") || biomeName.contains("river") || biomeName.contains("beach") || biomeName.contains("shore") || biomeName.contains("coast") @@ -1521,8 +1547,8 @@ private static JsonObject defaultFormationConfig() { formations.addProperty("edge_irregularity", Preset.AVERAGE.configName); formations.addProperty("formation_continuity", Preset.AVERAGE.configName); formations.add("custom", customFormationConfig( - 256.0D, 100.0D, 8, 48.0D, 2, 0.85D, - 256.0D, 64.0D, 12.0D)); + 256.0D, 100.0D, 8, 48.0D, 3, 0.85D, + 256.0D, 96.0D, 24.0D)); return formations; } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeDistributionSampler.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeDistributionSampler.java index c802579..092c385 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeDistributionSampler.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeDistributionSampler.java @@ -132,15 +132,13 @@ public static String sampleTerrain(long seed, Path path) throws IOException { } Biome[] biomePalette = new Biome[paletteSize]; + Identifier[] biomeIds = new Identifier[paletteSize]; for (int index = 0; index < paletteSize; index++) { int length = input.readUnsignedShort(); byte[] encoded = new byte[length]; input.readFully(encoded); - Identifier biomeId = Identifier.parse(new String(encoded, StandardCharsets.UTF_8)); - biomePalette[index] = ForgeRegistries.BIOMES.getValue(biomeId); - if (biomePalette[index] == null) { - throw new IOException("Unknown biome " + biomeId + " in " + path); - } + biomeIds[index] = Identifier.parse(new String(encoded, StandardCharsets.UTF_8)); + biomePalette[index] = ForgeRegistries.BIOMES.getValue(biomeIds[index]); } int height = maxY - minY + 1; @@ -153,9 +151,10 @@ public static String sampleTerrain(long seed, Path path) throws IOException { } input.readFully(rockMask); Biome biome = biomePalette[biomeIndex]; + Identifier biomeId = biomeIds[biomeIndex]; int x = minX + xOffset; int z = minZ + zOffset; - int geomeIndex = geology.classifyColumn(biome, x, z, regionalValues); + int geomeIndex = geology.classifyColumn(biome, biomeId, x, z, regionalValues); int stratumOffset = geology.stratumOffsetAt(x, z); long formationRegion = geology.formationRegionAt(x, z); add(geomeCounts, config.geomeName(geomeIndex)); @@ -163,7 +162,7 @@ public static String sampleTerrain(long seed, Path path) throws IOException { if ((rockMask[yIndex >>> 3] & (1 << (yIndex & 7))) == 0) { continue; } - Block block = geology.getStoneAt(geomeIndex, stratumOffset, formationRegion, + Block block = geology.getStoneAt(geomeIndex, regionalValues, stratumOffset, formationRegion, x, minY + yIndex, z); Identifier id = ForgeRegistries.BLOCKS.getKey(block); String rockId = id == null ? "" : id.toString(); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java index 84841df..fdda0ef 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeGeology.java @@ -18,6 +18,7 @@ import net.minecraft.world.level.block.state.BlockState; public final class GeomeGeology { + private static final double GEOME_TRANSITION_SCORE_WIDTH = 0.125D; private static final int[] LITHOLOGY_PHASES = { 0, 2, 1, 3, 3, 1, 2, 0, @@ -34,6 +35,7 @@ public final class GeomeGeology { private final short[] whiteNoiseArray; private final boolean[] globallyContinuousLayers; private final boolean[] regionallyVariedRocks; + private final int geomeTransitionPhase; private final int layerThickness; private final int formationRegionScale; private final int familyDiversitySlots; @@ -67,6 +69,7 @@ public GeomeGeology(long seed, BakedGeomeConfig config) { } Random random = new Random(seed ^ 0x5EEDBEEFL); + geomeTransitionPhase = new Random(seed ^ 0x47454F4D4554524EL).nextInt(256); whiteNoiseArray = new short[256]; for (int i = 0; i < whiteNoiseArray.length; i++) { whiteNoiseArray[i] = (short) random.nextInt(0x7FFF); @@ -109,7 +112,8 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer long formationRegion = formationRegionAt(x, z); if (stableLayers) { - changed |= replaceStableColumn(chunk, cursor, geomeIndex, baseRockValue, + int secondGeome = runnerUpGeome(regionalValues, geomeIndex); + changed |= replaceStableColumn(chunk, cursor, geomeIndex, secondGeome, regionalValues, baseRockValue, formationRegion, x, z, surfaceY, terrain); } else { for (int y = surfaceY; y >= chunk.getMinY(); y--) { @@ -131,11 +135,13 @@ public void replaceStoneInChunk(LevelAccessor world, ChunkAccess chunk, BakedTer } private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos cursor, int geomeIndex, - int baseRockValue, long formationRegion, int x, int z, int surfaceY, + int secondGeome, double[] geomeScores, int baseRockValue, long formationRegion, int x, int z, int surfaceY, BakedTerrainDimension terrain) { int layerIndex = Math.floorDiv(baseRockValue + surfaceY, layerThickness); int layerStart = layerIndex * layerThickness; - BlockState replacement = pickStableReplacement(geomeIndex, formationRegion, layerIndex); + int layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, + layerIndex, geomeTransitionPhase); + BlockState replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); boolean changed = false; cursor.set(x, surfaceY, z); @@ -144,7 +150,9 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos if (stratum < layerStart) { layerIndex--; layerStart -= layerThickness; - replacement = pickStableReplacement(geomeIndex, formationRegion, layerIndex); + layerGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, + layerIndex, geomeTransitionPhase); + replacement = pickStableReplacement(layerGeome, formationRegion, layerIndex); } cursor.setY(y); BlockState current = chunk.getBlockState(cursor); @@ -159,7 +167,14 @@ private boolean replaceStableColumn(ChunkAccess chunk, BlockPos.MutableBlockPos public Block getStoneAt(Biome biome, int x, int y, int z, int surfaceY) { double[] regionalValues = new double[config.geomeCount()]; int geomeIndex = classifyColumn(biome, x, z, regionalValues); - return pickReplacement(geomeIndex, stratumOffsetAt(x, z), formationRegionAt(x, z), x, y, z).getBlock(); + int stratumOffset = stratumOffsetAt(x, z); + long formationRegion = formationRegionAt(x, z); + if (stableLayers) { + int layerIndex = Math.floorDiv(stratumOffset + y, layerThickness); + geomeIndex = pickStableLayerGeome(regionalValues, geomeIndex, + runnerUpGeome(regionalValues, geomeIndex), layerIndex, geomeTransitionPhase); + } + return pickReplacement(geomeIndex, stratumOffset, formationRegion, x, y, z).getBlock(); } public String getGeomeName(Biome biome, int x, int z) { @@ -178,18 +193,24 @@ int classifyColumn(Biome biome, int x, int z, double[] regionalValues) { public ColumnSample sampleColumn(Biome biome, Identifier biomeId, int x, int z) { double[] regionalValues = new double[config.geomeCount()]; int geomeIndex = classifyColumn(biome, biomeId, x, z, regionalValues); - return new ColumnSample(geomeIndex, stratumOffsetAt(x, z), formationRegionAt(x, z), x, z); + return new ColumnSample(geomeIndex, runnerUpGeome(regionalValues, geomeIndex), regionalValues, + stratumOffsetAt(x, z), formationRegionAt(x, z), x, z); } public final class ColumnSample { private final int geomeIndex; + private final int secondGeome; + private final double[] geomeScores; private final int stratumOffset; private final long formationRegion; private final int x; private final int z; - private ColumnSample(int geomeIndex, int stratumOffset, long formationRegion, int x, int z) { + private ColumnSample(int geomeIndex, int secondGeome, double[] geomeScores, + int stratumOffset, long formationRegion, int x, int z) { this.geomeIndex = geomeIndex; + this.secondGeome = secondGeome; + this.geomeScores = geomeScores; this.stratumOffset = stratumOffset; this.formationRegion = formationRegion; this.x = x; @@ -201,7 +222,13 @@ public String geomeName() { } public BlockState rockAt(int y) { - return pickReplacement(geomeIndex, stratumOffset, formationRegion, x, y, z); + int selectedGeome = geomeIndex; + if (stableLayers) { + int layerIndex = Math.floorDiv(stratumOffset + y, layerThickness); + selectedGeome = pickStableLayerGeome(geomeScores, geomeIndex, secondGeome, + layerIndex, geomeTransitionPhase); + } + return pickReplacement(selectedGeome, stratumOffset, formationRegion, x, y, z); } public RockFamily familyAt(int y) { @@ -209,13 +236,13 @@ public RockFamily familyAt(int y) { } } - private int classifyColumn(Biome biome, Identifier biomeId, int x, int z, double[] regionalValues) { + int classifyColumn(Biome biome, Identifier biomeId, int x, int z, double[] regionalValues) { for (int i = 0; i < regionalValues.length; i++) { regionalValues[i] = regionalNoise.valueAt(x + config.noiseOffsetX[i], z + config.noiseOffsetZ[i]); } double boundary = boundaryNoise.valueAt(x, z); - return config.pickGeome(biome, biomeId, regionalValues, boundary); + return config.scoreGeomes(biome, biomeId, regionalValues, boundary); } private net.minecraft.world.level.block.state.BlockState pickReplacement(int geomeIndex, int baseRockValue, @@ -271,7 +298,13 @@ int stratumLayerAt(int x, int y, int z) { return Math.floorDiv(stratumOffsetAt(x, z) + y, layerThickness); } - Block getStoneAt(int geomeIndex, int stratumOffset, long formationRegion, int x, int y, int z) { + Block getStoneAt(int geomeIndex, double[] geomeScores, int stratumOffset, + long formationRegion, int x, int y, int z) { + if (stableLayers) { + int layerIndex = Math.floorDiv(stratumOffset + y, layerThickness); + geomeIndex = pickStableLayerGeome(geomeScores, geomeIndex, + runnerUpGeome(geomeScores, geomeIndex), layerIndex, geomeTransitionPhase); + } return pickReplacement(geomeIndex, stratumOffset, formationRegion, x, y, z).getBlock(); } @@ -300,6 +333,44 @@ private static int mixRegion(int cellX, int cellZ, int contour) { return hash ^ (hash >>> 16); } + static int pickStableLayerGeome(double[] geomeScores, int firstGeome, int secondGeome, + int layerIndex, int phase) { + if (firstGeome == secondGeome) { + return firstGeome; + } + int lowerGeome = Math.min(firstGeome, secondGeome); + int higherGeome = Math.max(firstGeome, secondGeome); + double higherFraction = 0.5D + ((geomeScores[higherGeome] - geomeScores[lowerGeome]) + / (2.0D * GEOME_TRANSITION_SCORE_WIDTH)); + if (higherFraction <= 0.0D) { + return lowerGeome; + } + if (higherFraction >= 1.0D) { + return higherGeome; + } + + // Bit reversal supplies an allocation-free low-discrepancy sequence. Nearby + // layers therefore cross a close geome boundary at different horizontal + // positions instead of moving as one full-height wall. + int pairPhase = (lowerGeome * 53) + (higherGeome * 97); + int layerBucket = (layerIndex + phase + pairPhase) & 0xFF; + int threshold = Integer.reverse(layerBucket) >>> 24; + return ((threshold + 0.5D) / 256.0D) < higherFraction ? higherGeome : lowerGeome; + } + + private static int runnerUpGeome(double[] geomeScores, int bestGeome) { + if (geomeScores.length < 2) { + return bestGeome; + } + int second = bestGeome == 0 ? 1 : 0; + for (int i = 0; i < geomeScores.length; i++) { + if (i != bestGeome && geomeScores[i] > geomeScores[second]) { + second = i; + } + } + return second; + } + private static double faciesFraction(double regionScale) { if (regionScale <= 100.0D) { return Math.max(0.0D, (regionScale - 50.0D) / 350.0D); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java index da34130..5dc0b43 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnBiomeModifier.java @@ -49,10 +49,13 @@ static boolean apply(BiomeGenerationSettings.PlainBuilder generation) { generation.getFeatures(GenerationStep.Decoration.UNDERGROUND_DECORATION); changed |= VanillaOreFeatureGate.wrapFeatureList(undergroundDecoration); + List> local = + generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); + changed |= addUnique(local, BiomeSurfaceFeature.placedFeature()); + List> top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); changed |= addUnique(top, FlatBedrockFeature.placedFeature()); - changed |= addUnique(top, BiomeSurfaceFeature.placedFeature()); return changed; } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java index 8b2f19c..72978a6 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -140,7 +140,8 @@ private static boolean generateChunk(WorldGenLevel world, ChunkAccess chunk, Hol int centerZ = chunkPos.getMinBlockZ() + 8; int geome = -1; if (Level.OVERWORLD.equals(dimension)) { - geome = classifier(worldSeed).classifyColumn(biome.value(), centerX, centerZ, + Identifier biomeId = biome.unwrapKey().map(ResourceKey::identifier).orElse(null); + geome = classifier(worldSeed).classifyColumn(biome.value(), biomeId, centerX, centerZ, scratch.geomeValues(geomeConfig.geomeCount())); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java index 6a7f171..d441bba 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -289,9 +289,9 @@ private static JsonObject recommendedFormationJson() { custom.addProperty("vertical_thickness", 8); custom.addProperty("waviness_wavelength", 256.0D); custom.addProperty("waviness_amplitude", 48.0D); - custom.addProperty("edge_wavelength", 64.0D); - custom.addProperty("edge_amplitude", 12.0D); - custom.addProperty("edge_octaves", 2); + custom.addProperty("edge_wavelength", 96.0D); + custom.addProperty("edge_amplitude", 24.0D); + custom.addProperty("edge_octaves", 3); custom.addProperty("continuity", 0.85D); formations.add("custom", custom); return formations; diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java new file mode 100644 index 0000000..768e110 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomeSurfaceFeatureOrderTest.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import net.minecraft.core.Holder; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; + +import org.junit.jupiter.api.Test; + +class BiomeSurfaceFeatureOrderTest { + @Test + void surfacesRunBeforeStructuresAndVegetationWhileFlatBedrockStaysLast() { + BiomeSurfaceFeature.registerConfiguredFeature(); + FlatBedrockFeature.registerConfiguredFeature(); + BiomeGenerationSettings.PlainBuilder generation = + new BiomeGenerationSettings.PlainBuilder(); + + assertTrue(OreSpawnBiomeModifier.apply(generation)); + + Holder surfaces = BiomeSurfaceFeature.placedFeature(); + Holder bedrock = FlatBedrockFeature.placedFeature(); + var local = generation.getFeatures(GenerationStep.Decoration.LOCAL_MODIFICATIONS); + var top = generation.getFeatures(GenerationStep.Decoration.TOP_LAYER_MODIFICATION); + assertTrue(local.stream().anyMatch(feature -> feature.value() == surfaces.value())); + assertFalse(local.stream().anyMatch(feature -> feature.value() == bedrock.value())); + assertTrue(top.stream().anyMatch(feature -> feature.value() == bedrock.value())); + assertFalse(top.stream().anyMatch(feature -> feature.value() == surfaces.value())); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/FormationSettingsTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/FormationSettingsTest.java new file mode 100644 index 0000000..c1f3c51 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/FormationSettingsTest.java @@ -0,0 +1,36 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; +import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; + +class FormationSettingsTest { + @Test + void stableLayerEdgeDetailUsesTheRecalibratedPresetLadder() { + assertEdgeDetail(Preset.TINY, 48.0D, 4.0D, 1); + assertEdgeDetail(Preset.SMALL, 64.0D, 12.0D, 2); + assertEdgeDetail(Preset.AVERAGE, 96.0D, 24.0D, 3); + assertEdgeDetail(Preset.LARGE, 128.0D, 48.0D, 4); + assertEdgeDetail(Preset.HUGE, 192.0D, 96.0D, 5); + } + + @Test + void customAndRecommendedEdgeDefaultsMatchAverage() { + assertEdgeDetail(Preset.CUSTOM, 96.0D, 24.0D, 3); + + JsonObject custom = WorldGeologyProfile.recommended(false).toFormationJson() + .getAsJsonObject("custom"); + assertEquals(96.0D, custom.get("edge_wavelength").getAsDouble()); + assertEquals(24.0D, custom.get("edge_amplitude").getAsDouble()); + assertEquals(3, custom.get("edge_octaves").getAsInt()); + } + + private static void assertEdgeDetail(Preset preset, double wavelength, double amplitude, + int octaves) { + assertEquals(wavelength, preset.stableEdgeWavelength); + assertEquals(amplitude, preset.stableEdgeAmplitude); + assertEquals(octaves, preset.stableEdgeOctaves); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java new file mode 100644 index 0000000..05c283f --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/GeomeTransitionTest.java @@ -0,0 +1,124 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.block.Blocks; + +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.GeomeDefinition; +import zone.moddev.mc.orespawn.worldgen.BakedGeomeConfig.RockEntry; + +class GeomeTransitionTest { + private static final Identifier WINDSWEPT_HILLS = Identifier.parse("minecraft:windswept_hills"); + + @Test + void configuredBiomeWeightsWorkWithoutAForgeBiomeRegistryEntry() { + Map indexes = new LinkedHashMap<>(); + indexes.put("orespawn:first", 0); + indexes.put("orespawn:mountain_belt", 1); + Map weights = GeomeConfig.bakeBiomeIdentifierWeights(indexes, + Map.of(WINDSWEPT_HILLS.toString(), new double[] { 1.0D, 4.0D })); + BakedGeomeConfig config = config(weights); + + assertEquals(1, config.pickGeome(null, WINDSWEPT_HILLS, new double[2], 0.0D)); + } + + @Test + void savedWorldBoundaryUsesItsConfiguredBiomeInsteadOfEqualFallbackWeights() { + BakedGeomeConfig config = observedWorldConfig(); + GeomeGeology geology = new GeomeGeology(-4965128775892001975L, config); + double[] leftScores = new double[config.geomeCount()]; + double[] rightScores = new double[config.geomeCount()]; + + assertEquals(1, geology.classifyColumn(null, WINDSWEPT_HILLS, 225, -261, leftScores)); + assertEquals(1, geology.classifyColumn(null, WINDSWEPT_HILLS, 226, -261, rightScores)); + } + + @Test + void closeGeomeContestDoesNotMoveEveryStableLayerAtOneColumnBoundary() { + // These are the leading scores measured in New World 5 at z=-261. The + // fallback configuration changed winner between x=225 and x=226. + double[] leftScores = { 2.618658D, 2.618126D }; + double[] rightScores = { 2.619946D, 2.620774D }; + int changedLayers = 0; + for (int layer = -8; layer < 8; layer++) { + int left = GeomeGeology.pickStableLayerGeome(leftScores, 0, 1, layer, 37); + int right = GeomeGeology.pickStableLayerGeome(rightScores, 0, 1, layer, 37); + if (left != right) { + changedLayers++; + } + } + + assertTrue(changedLayers < 16, + "all stable layers changed geome together across the observed x=225/226 boundary"); + } + + @Test + void transitionBandUsesBothGeomesButKeepsClearDominanceOutsideIt() { + boolean sawFirst = false; + boolean sawSecond = false; + for (int layer = 0; layer < 16; layer++) { + int selected = GeomeGeology.pickStableLayerGeome(new double[] { 2.0D, 2.0D }, + 0, 1, layer, 91); + sawFirst |= selected == 0; + sawSecond |= selected == 1; + } + + assertTrue(sawFirst && sawSecond, "a tied geome boundary should be staggered by stable layer"); + assertEquals(0, GeomeGeology.pickStableLayerGeome(new double[] { 2.2D, 2.0D }, 0, 1, 3, 91)); + assertEquals(1, GeomeGeology.pickStableLayerGeome(new double[] { 2.0D, 2.2D }, 0, 1, 3, 91)); + } + + private static BakedGeomeConfig config(Map biomeWeightsById) { + double[] familyWeights = { 1.0D, 1.0D, 1.0D, 1.0D }; + GeomeDefinition[] geomes = { + new GeomeDefinition("orespawn:first", 1.0D, familyWeights.clone()), + new GeomeDefinition("orespawn:second", 1.0D, familyWeights.clone()) + }; + RockEntry[] rocks = { + new RockEntry(Blocks.STONE.defaultBlockState(), RockFamily.SEDIMENTARY, + 64, 64, -64, 319, 1.0D, true, new double[] { 1.0D, 1.0D }) + }; + FormationSettings formations = new FormationSettings(FormationSettings.Algorithm.STABLE_LAYERS, + 256.0D, 100.0D, 8, 48.0D, 64.0D, 12.0D, 2, 0.85D); + return new BakedGeomeConfig(geomes, 384.0D, 1.15D, 0.9D, 0.45D, + Collections.emptyMap(), biomeWeightsById, rocks, formations); + } + + private static BakedGeomeConfig observedWorldConfig() { + String[] names = { + "stable_craton", "mountain_belt", "volcanic_arc", "sedimentary_basin", + "coastal_shelf", "arid_basin", "wetland_basin", "glacial_highland" + }; + double[] bases = { 1.0D, 1.0D, 0.9D, 1.0D, 0.9D, 0.9D, 0.8D, 0.8D }; + GeomeDefinition[] geomes = new GeomeDefinition[names.length]; + Map indexes = new LinkedHashMap<>(); + for (int i = 0; i < names.length; i++) { + String id = "orespawn:" + names[i]; + indexes.put(id, i); + geomes[i] = new GeomeDefinition(id, bases[i], new double[] { 1.0D, 1.0D, 1.0D, 1.0D }); + } + double[] rule = new double[names.length]; + rule[0] = 1.0D; + rule[1] = 4.0D; + Map biomeWeights = GeomeConfig.bakeBiomeIdentifierWeights(indexes, + Map.of(WINDSWEPT_HILLS.toString(), rule)); + double[] rockWeights = new double[names.length]; + java.util.Arrays.fill(rockWeights, 1.0D); + RockEntry[] rocks = { + new RockEntry(Blocks.STONE.defaultBlockState(), RockFamily.SEDIMENTARY, + 64, 64, -64, 319, 1.0D, true, rockWeights) + }; + FormationSettings formations = new FormationSettings(FormationSettings.Algorithm.STABLE_LAYERS, + 256.0D, 100.0D, 8, 48.0D, 64.0D, 12.0D, 2, 0.85D); + return new BakedGeomeConfig(geomes, 384.0D, 1.15D, 0.9D, 0.45D, + Collections.emptyMap(), biomeWeights, rocks, formations); + } +}