diff --git a/docs/AGE_PROFILES.md b/docs/AGE_PROFILES.md index b151916..31ad1ad 100644 --- a/docs/AGE_PROFILES.md +++ b/docs/AGE_PROFILES.md @@ -90,6 +90,25 @@ only the selected type should locate. Ocean structures need their matching ocean or beach biome page; Ancient Cities need Deep Dark. Check the new pages in Rivencraft's Feature creative tab and JEI as well. +**Dense Ores** is an authored-only Feature page. Each copy replays one extra +pass of every ore placement registered in the Age's nearby biomes, using that +placement's existing height, vein-size, rarity, and biome rules. It therefore +also densifies conventional mod-added ores without hard-coded mod IDs. Output +blocks tagged `c:ores` qualify; OreConfiguration outputs with `_ore` in their +registry name also qualify. A mod with a custom, non-OreConfiguration placed +feature can opt in by tagging that feature `rivencraft:dense_ores`. Non-ore +stone/dirt blobs are not repeated. Flat and Void reject the page, as does a +biome pool without any ore placements. Page-free random Ages never roll Dense +Ores. The extra pass applies only as new chunks generate; it does not retrofit +existing chunks or discover ore systems that bypass biome placed features. + +For an in-game check, write a Standard/Plains/Single Age with Dense Ores and +verify that new underground chunks contain visibly more vanilla ore while +stone/dirt blob frequency remains unchanged. With an ore-adding mod installed, +write an Age using a biome where that mod registers its ore and verify the ore +also appears more often. Check that Flat plus Dense Ores is rejected by the +Binder and that a random, Link-Panel-only Age has no Dense Ores feature page. + For an in-game visual check, bind Descriptive Books with a Link Panel, one of Standard, Amplified, Sky Islands, Flat, or Void terrain, and the Floating Islands feature page in that order. Fly through each Age at roughly Y=170–220 and diff --git a/gradle.properties b/gradle.properties index 7532417..54da9cf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.4.15.0 +mod_version=0.4.16.0 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=B6nTjh4b diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java index ad2a24a..db6b0cb 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java @@ -13,6 +13,7 @@ import net.dnorberg.rivencraft.writing.BlockSymbolPages; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.SymbolSemantics; import net.dnorberg.rivencraft.writing.WritingGrammar; +import net.dnorberg.rivencraft.world.AgeDenseOres; import net.minecraft.core.Holder; import net.minecraft.core.RegistryAccess; import net.minecraft.core.registries.BuiltInRegistries; @@ -52,6 +53,8 @@ public final class AgeFeatureSelection { String error = error(choice.id(), profile, registries); if (error == null && AgeStructureSelection.feature(choice.id())) error = AgeStructureSelection.error(choice.id(), profile, biomes, registries); + if (error == null && choice.id().equals(AgeDenseOres.ID)) + error = AgeDenseOres.error(profile, biomes, registries); if (error == null) error = materialError(choice); if (error != null) throw new IllegalArgumentException(error); if (surfaceVegetation(choice.id()) && biomes.biomes().stream() @@ -195,6 +198,8 @@ public final class AgeFeatureSelection { public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) { if (AgeStructureSelection.feature(choice)) return AgeStructureSelection.error(choice, profile, null, registries); + if (choice.equals(AgeDenseOres.ID)) + return AgeDenseOres.error(profile, null, registries); if (skylands(choice)) return null; if (tendrils(choice)) return null; if (spheres(choice)) return null; diff --git a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java index c205eb8..e345192 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java @@ -7,6 +7,7 @@ import java.util.UUID; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.world.AgeFeatureChunkGenerator; +import net.dnorberg.rivencraft.world.AgeDenseOres; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.BlockSymbolPages; import net.dnorberg.rivencraft.writing.SymbolCatalog; @@ -38,7 +39,8 @@ public final class FeatureWritingGameTests { "feature/strongholds", "feature/villages", "feature/trial_chambers", "feature/bastions", "feature/buried_treasure", "feature/shipwrecks", "feature/ocean_ruins", "feature/pillager_outposts", "feature/ruined_portals", - "feature/nether_fossils", "feature/ancient_cities", "feature/trail_ruins"); + "feature/nether_fossils", "feature/ancient_cities", "feature/trail_ruins", + "feature/dense_ores"); helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages), "Supported feature pages must appear in the creative and JEI symbol feed"); for (String path : pages) { @@ -69,7 +71,8 @@ public final class FeatureWritingGameTests { || AgeFeatureSelection.tendrils(choice.id()) || AgeFeatureSelection.skylands(choice.id()) || AgeFeatureSelection.spheres(choice.id()) - || AgeFeatureSelection.spikes(choice.id())) continue; + || AgeFeatureSelection.spikes(choice.id()) + || choice.id().equals(AgeDenseOres.ID)) continue; var placement = AgeFeatureSelection.placedFeature(choice.id()); helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE) .get(ResourceKey.create(Registries.PLACED_FEATURE, placement)).isPresent(), diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeDenseOreGameTests.java b/src/main/java/net/dnorberg/rivencraft/world/AgeDenseOreGameTests.java new file mode 100644 index 0000000..ee78edf --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeDenseOreGameTests.java @@ -0,0 +1,99 @@ +package net.dnorberg.rivencraft.world; + +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.book.AgeBiomeSelection; +import net.dnorberg.rivencraft.book.AgeFeatureSelection; +import net.dnorberg.rivencraft.book.AgeGenerationPlan; +import net.dnorberg.rivencraft.book.AgeGenerationProfiles; +import net.dnorberg.rivencraft.book.AgeRuntimeProfiles; +import net.dnorberg.rivencraft.book.AgeTraits; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.writing.OrderedWriting; +import net.dnorberg.rivencraft.writing.SymbolReference; +import net.dnorberg.rivencraft.writing.WritingEntry; +import net.minecraft.core.Holder; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.configurations.OreConfiguration; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlockMatchTest; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +/** Dense Ores remains authored-only and discovers ore configurations without a namespace allow-list. */ +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class AgeDenseOreGameTests { + private AgeDenseOreGameTests() {} + + @GameTest(template = "empty") + public static void denseOresPageCompilesAndPersists(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + helper.assertTrue(PageItem.bundledCreativeSymbols().contains("feature/dense_ores"), + "Dense Ores must appear in the Creative and JEI page feed"); + OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(), + page("terrain/standard"), page("biome/plains"), page("biome/single"), + page("feature/dense_ores"))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 55L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(55L), registries); + helper.assertValueEqual(AgeFeatureSelection.resolved(plan).stream() + .map(AgeFeatureSelection.Choice::id).toList(), List.of(AgeDenseOres.ID), + "Dense Ores must be saved as an authored feature"); + helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), + AgeFeatureSelection.resolved(plan), "Dense Ores must survive Age-plan reload"); + for (long seed = 0; seed < 32; seed++) { + helper.assertTrue(AgeFeatureSelection.randomChoices(seed, + AgeRuntimeProfiles.resolve(id("standard")), registries).stream() + .noneMatch(choice -> choice.id().equals(AgeDenseOres.ID)), + "Random Ages must not roll an authored-only Dense Ores page"); + } + helper.succeed(); + } + + @GameTest(template = "empty") + public static void denseOresRejectsUnsuitableTerrainAndBiome(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + helper.assertTrue(AgeDenseOres.error(AgeRuntimeProfiles.resolve(id("flat")), null, registries) + .contains("noise terrain"), "Flat terrain must reject Dense Ores"); + helper.assertTrue(AgeDenseOres.error(AgeRuntimeProfiles.resolve(id("void")), null, registries) + .contains("noise terrain"), "Void terrain must reject Dense Ores"); + helper.assertTrue(AgeDenseOres.error(AgeRuntimeProfiles.resolve(id("standard")), + new AgeBiomeSelection(AgeTraits.BiomeDistribution.SINGLE, + List.of(ResourceLocation.withDefaultNamespace("the_end"))), registries) + .contains("biome with registered ore"), + "A biome without ore placements must not accept a no-op Dense Ores page"); + helper.assertTrue(AgeDenseOres.error(AgeRuntimeProfiles.resolve(id("cave")), + new AgeBiomeSelection(AgeTraits.BiomeDistribution.SINGLE, + List.of(ResourceLocation.withDefaultNamespace("nether_wastes"))), registries) == null, + "Nether ore placements must qualify for a Cave Age"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void recognizesOreTargetsButNotStoneBlobs(GameTestHelper helper) { + helper.assertTrue(AgeDenseOres.isOre(ore(Blocks.IRON_ORE.defaultBlockState())), + "Any namespace's OreConfiguration yielding an ore block must qualify"); + helper.assertTrue(!AgeDenseOres.isOre(ore(Blocks.STONE.defaultBlockState())), + "Ordinary stone replacement must not be doubled as an ore"); + helper.succeed(); + } + + private static Holder ore(net.minecraft.world.level.block.state.BlockState output) { + var config = new OreConfiguration(new BlockMatchTest(Blocks.STONE), output, 8); + var feature = new ConfiguredFeature<>(Feature.ORE, config); + return Holder.direct(new PlacedFeature(Holder.direct(feature), List.of())); + } + + private static ResourceLocation id(String path) { + return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, path); + } + + private static WritingEntry page(String path) { + return WritingEntry.symbol(new SymbolReference(id(path))); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeDenseOres.java b/src/main/java/net/dnorberg/rivencraft/world/AgeDenseOres.java new file mode 100644 index 0000000..710e22a --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeDenseOres.java @@ -0,0 +1,101 @@ +package net.dnorberg.rivencraft.world; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.book.AgeBiomeSelection; +import net.dnorberg.rivencraft.book.AgeRuntimeProfile; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.TagKey; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.feature.configurations.OreConfiguration; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraft.world.level.ChunkPos; +import net.neoforged.neoforge.common.Tags; + +/** Replays eligible biome ore placements once per authored Dense Ores page. */ +public final class AgeDenseOres { + public static final ResourceLocation ID = ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "dense_ores"); + public static final TagKey EXTRA_ORES = TagKey.create(Registries.PLACED_FEATURE, ID); + + private AgeDenseOres() {} + + public static String error(AgeRuntimeProfile profile, AgeBiomeSelection biomes, RegistryAccess registries) { + if (profile.terrain() != AgeRuntimeProfile.Terrain.NOISE) + return "Dense Ores needs noise terrain with replaceable underground stone or netherrack"; + if (biomes == null) return null; + var registry = registries.lookupOrThrow(Registries.BIOME); + boolean eligible = biomes.biomes().stream() + .map(id -> registry.get(ResourceKey.create(Registries.BIOME, id))) + .flatMap(java.util.Optional::stream) + .anyMatch(holder -> oreFeatures(holder).stream().anyMatch(AgeDenseOres::isOre)); + return eligible ? null : "Dense Ores needs at least one biome with registered ore placements"; + } + + /** Vanilla-style and modded OreConfiguration targets, or an explicit opt-in tag. */ + public static boolean isOre(Holder holder) { + if (holder.is(EXTRA_ORES)) return true; + if (!(holder.value().feature().value().config() instanceof OreConfiguration ore)) return false; + return ore.targetStates.stream().anyMatch(target -> isOreBlock(target.state)); + } + + private static boolean isOreBlock(BlockState state) { + if (state.is(Tags.Blocks.ORES)) return true; + // Some third-party ores omit the common tag; require an ore configuration + // as well so similarly named decorative features cannot be selected. + String path = BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath(); + return path.endsWith("_ore") || path.contains("_ore_"); + } + + private static java.util.List> oreFeatures(Holder biome) { + // A mod may register its ore in a non-vanilla decoration step. Its + // configuration and output, not its stage or namespace, decide eligibility. + return biome.value().getGenerationSettings().features().stream() + .flatMap(net.minecraft.core.HolderSet::stream).toList(); + } + + public static void place(WorldGenLevel level, ChunkAccess chunk, ChunkGenerator generator, + long ageSeed, int occurrence) { + Set> biomes = new HashSet<>(); + ChunkPos.rangeClosed(chunk.getPos(), 1).forEach(pos -> { + for (var section : level.getChunk(pos.x, pos.z).getSections()) + section.getBiomes().getAll(biomes::add); + }); + biomes.retainAll(generator.getBiomeSource().possibleBiomes()); + Map ores = new TreeMap<>(); + for (Holder biome : biomes) { + for (Holder feature : oreFeatures(biome)) { + if (!isOre(feature)) continue; + feature.unwrapKey().ifPresent(key -> ores.putIfAbsent(key.location(), feature.value())); + } + } + BlockPos origin = new BlockPos(chunk.getPos().getMinBlockX(), level.getMinBuildHeight(), + chunk.getPos().getMinBlockZ()); + for (var entry : ores.entrySet()) { + long seed = mix(ageSeed ^ (long) origin.getX() * 341873128712L + ^ (long) origin.getZ() * 132897987541L + ^ (long) entry.getKey().hashCode() * 0x9E3779B97F4A7C15L + ^ (long) occurrence * 0xD1B54A32D192ED03L); + entry.getValue().placeWithBiomeCheck(level, generator, RandomSource.create(seed), origin); + } + } + + private static long mix(long value) { + value = (value ^ value >>> 30) * 0xBF58476D1CE4E5B9L; + value = (value ^ value >>> 27) * 0x94D049BB133111EBL; + return value ^ value >>> 31; + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java index 1d8abb4..d56bfea 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java @@ -170,6 +170,10 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { spikeOccurrences.merge(choice.id(), 1, Integer::sum) - 1); continue; } + if (choice.id().equals(AgeDenseOres.ID)) { + AgeDenseOres.place(level, chunk, this, ageSeed, occurrence); + continue; + } if (AgeFeatureSelection.surfaceVegetation(choice.id())) { int surface = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 8); if (!AgeFeatureSelection.supportsSurfaceVegetation( diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index e64c563..1f527b5 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -283,6 +283,8 @@ "symbol.rivencraft.feature.ancient_cities.description": "Allow ancient cities in compatible Deep Dark biomes.", "symbol.rivencraft.feature.trail_ruins": "Trail Ruins", "symbol.rivencraft.feature.trail_ruins.description": "Allow buried trail ruins in compatible surface biomes.", + "symbol.rivencraft.feature.dense_ores": "Dense Ores", + "symbol.rivencraft.feature.dense_ores.description": "Repeat compatible vanilla and modded ore placements once per page in this Age.", "symbol.rivencraft.feature.lava_lakes": "Small Lakes", "symbol.rivencraft.feature.lava_lakes.description": "Add small surface lakes using a random fluid, or specify one with a fluid page.", "generator.rivencraft.standard": "Standard", diff --git a/src/main/resources/data/rivencraft/rivencraft/symbols/feature/dense_ores.json b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/dense_ores.json new file mode 100644 index 0000000..a20e148 --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/dense_ores.json @@ -0,0 +1,8 @@ +{ + "schema_version": 2, + "name": "symbol.rivencraft.feature.dense_ores", + "description": "symbol.rivencraft.feature.dense_ores.description", + "category": "rivencraft:feature", + "semantics": { "role": "feature", "value": "rivencraft:dense_ores" }, + "poem": "Nature Material Treasure Abundance" +} diff --git a/src/main/resources/data/rivencraft/tags/worldgen/placed_feature/dense_ores.json b/src/main/resources/data/rivencraft/tags/worldgen/placed_feature/dense_ores.json new file mode 100644 index 0000000..5d341ba --- /dev/null +++ b/src/main/resources/data/rivencraft/tags/worldgen/placed_feature/dense_ores.json @@ -0,0 +1,4 @@ +{ + "replace": false, + "values": [] +}