From 6dbe208c42186776b244f699f96c0216ddb0ed23 Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Mon, 5 Oct 2026 11:56:48 -0400 Subject: [PATCH] Port authored Obelisks age feature (#56) --- docs/AGE_PROFILES.md | 15 +++ docs/LEGACY_AGE_GRAMMAR_AUDIT.md | 2 +- gradle.properties | 2 +- .../rivencraft/book/AgeFeatureSelection.java | 19 ++- .../book/FeatureWritingGameTests.java | 6 +- .../world/AgeFeatureChunkGenerator.java | 5 + .../world/AgeFlatChunkGenerator.java | 7 +- .../rivencraft/world/ObelisksFeature.java | 95 +++++++++++++++ .../rivencraft/world/ObelisksGameTests.java | 110 ++++++++++++++++++ .../rivencraft/writing/WritingGrammar.java | 2 +- .../assets/rivencraft/lang/en_us.json | 2 + .../rivencraft/symbols/feature/obelisks.json | 8 ++ 12 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 src/main/java/net/dnorberg/rivencraft/world/ObelisksFeature.java create mode 100644 src/main/java/net/dnorberg/rivencraft/world/ObelisksGameTests.java create mode 100644 src/main/resources/data/rivencraft/rivencraft/symbols/feature/obelisks.json diff --git a/docs/AGE_PROFILES.md b/docs/AGE_PROFILES.md index 31ad1ad..7fc6899 100644 --- a/docs/AGE_PROFILES.md +++ b/docs/AGE_PROFILES.md @@ -109,6 +109,21 @@ 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. +**Obelisks** adds Legacy-shaped, rare 4×4 plinths with a 2×2 shaft rising 12 +blocks, at most one attempt per 128 chunks per page. A preceding full-block +Block page chooses the material; otherwise the Age seed chooses from valid +block pages. Repeated Obelisks pages make independent attempts. Generation is +deterministic and stays within one chunk; it requires a reasonably level, +exposed solid surface, so Cave and Void terrain reject the page. Standard, +Amplified, Sky Islands, and Flat terrain permit it. Page-free random surface +Ages can roll Obelisks as one of their material features. + +For a visual check, write a Standard or Flat Age with an Obelisks page and +search newly generated terrain for the 4×4 base and 12-block shaft. Try a +Block page such as Obsidian immediately before Obelisks to verify its material +changes. The Binder should reject Obelisks in Cave and Void Ages. Placement +is deliberately rare, so several hundred fresh chunks may be needed. + 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/docs/LEGACY_AGE_GRAMMAR_AUDIT.md b/docs/LEGACY_AGE_GRAMMAR_AUDIT.md index 2527f14..2a64fc1 100644 --- a/docs/LEGACY_AGE_GRAMMAR_AUDIT.md +++ b/docs/LEGACY_AGE_GRAMMAR_AUDIT.md @@ -18,7 +18,7 @@ Legacy does **not** reject a book just because it omits terrain, biome, weather, | `WeatherNorm`, `Fast`, `Slow`, `Off`, `On`, `Cloudy`, `Rain`, `Snow`, `Storm` | `AgeTraits.Weather` has matching names except Legacy `On` maps to `ALWAYS` | Compile weather semantics separately from biome selection. `Snow` must remain deferred until optional season integration; do not silently substitute another weather mode. | | Suns, moons, starfields; angle, phase, period, color/gradient/sunset modifiers | One `celestialPeriod`, `celestialAngle`, `celestialPhase` on `AgeTraits` | Compile only the representable single-cycle subset first. Multiple celestial bodies, independent periods, custom stars, gradients and sunsets need a richer definition and renderer, not flattening into one value. | | Sky/fog/cloud/grass/foliage/water colors, natural variants, horizon, rainbow | Existing sky/dimension visuals and preview | Require an explicit modern client/biome-color design; metadata-only symbol pages must not pretend to change rendering. | -| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, authored Floating Islands/Tendrils/Skylands/Spheres/Spikes, and page-gated vanilla Dungeons/Mineshafts/Nether Fortresses/Strongholds/Villages | The five Legacy vanilla structure symbols now gate Minecraft 1.21.1 generation. Dungeons use monster-room placement; the others use vanilla structure sets. Bespoke obelisks, fissures, crystals, and exact 1.12 structure geometry remain separate work. | +| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, authored Floating Islands/Tendrils/Skylands/Spheres/Spikes/Obelisks and Dense Ores, and page-gated vanilla Dungeons/Mineshafts/Nether Fortresses/Strongholds/Villages | The five Legacy vanilla structure symbols gate Minecraft 1.21.1 generation. Dungeons use monster-room placement; the others use vanilla structure sets. Obelisks use a bespoke 4×4 plinth and 2×12×2 shaft with safe modern placement. Fissures, crystals, and exact 1.12 structure geometry remain separate work. | | `EnvAccel`, explosions, lightning, meteors, scorching; PvP and dummy suppression pages | No equivalent Age-definition effect | Defer environmental hazards/instability effects to milestone 6, PvP rule to a separately scoped server policy; retain authored IDs without fake behavior. | The Legacy symbol list and page names come from [ModSymbols](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/ModSymbols.java#L129-L240) and [SymbolRules](https://github.com/Mystcraft/Mystcraft-Legacy/blob/9bc8ddc061845df0cd5ea47f4fdef773cff786ae/src/main/java/com/xcompwiz/mystcraft/data/SymbolRules.java#L19-L137). Rivencraft's [catalog](../src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java) currently holds display metadata only; its built-in set is just standard terrain, native biomes, normal weather, normal lighting, normal time, and caves. [OrderedWriting](../src/main/java/net/dnorberg/rivencraft/writing/OrderedWriting.java) preserves page order, but no compiler yet connects it to [AgeTraits](../src/main/java/net/dnorberg/rivencraft/book/AgeTraits.java) or [Age profiles](AGE_PROFILES.md). diff --git a/gradle.properties b/gradle.properties index 54da9cf..d8ac410 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.16.0 +mod_version=0.4.17.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 db6b0cb..1cf746f 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java @@ -27,7 +27,8 @@ import net.minecraft.world.level.block.Blocks; /** Validates and freezes supported feature pages and their preceding material modifiers. */ public final class AgeFeatureSelection { private static final List BLOCK_FEATURES = List.of( - id("rocks"), id("floating_islands"), id("tendrils"), id("spheres"), id("spikes")); + id("rocks"), id("floating_islands"), id("tendrils"), id("spheres"), id("spikes"), + id("obelisks")); private static final List FLUID_FEATURES = List.of( id("surface_lakes"), id("deep_lakes"), id("lava_lakes")); private static final long RANDOM_SKYLANDS_SALT = 0x534B594C414E4453L; @@ -121,6 +122,8 @@ public final class AgeFeatureSelection { private static boolean compatible(ResourceLocation feature, AgeRuntimeProfile profile) { if (tendrils(feature) || spheres(feature)) return true; + if (obelisks(feature)) return !AgeBiomeSelection.isCave(profile) + && profile.terrain() != AgeRuntimeProfile.Terrain.VOID; if (AgeBiomeSelection.isCave(profile)) return false; if (spikes(feature)) return profile.terrain() != AgeRuntimeProfile.Terrain.VOID; return floatingIslands(feature) || profile.terrain() == AgeRuntimeProfile.Terrain.NOISE; @@ -136,7 +139,8 @@ public final class AgeFeatureSelection { if (choice.material().isPresent() || !isRock(choice.id()) && !isLake(choice.id()) && !floatingIslands(choice.id()) && !tendrils(choice.id()) - && !spheres(choice.id()) && !spikes(choice.id())) { + && !spheres(choice.id()) && !spikes(choice.id()) + && !obelisks(choice.id())) { resolved.add(choice); continue; } @@ -208,6 +212,11 @@ public final class AgeFeatureSelection { return "Feature " + choice + " needs an exposed solid surface, not Cave or Void terrain"; return null; } + if (obelisks(choice)) { + if (AgeBiomeSelection.isCave(profile) || profile.terrain() == AgeRuntimeProfile.Terrain.VOID) + return "Feature " + choice + " needs exposed solid surface terrain, not Cave or Void"; + return null; + } if (floatingIslands(choice)) { if (AgeBiomeSelection.isCave(profile)) return "Feature " + choice + " cannot form above Cave terrain"; @@ -298,9 +307,13 @@ public final class AgeFeatureSelection { return choice.equals(id("spikes")); } + public static boolean obelisks(ResourceLocation choice) { + return choice.equals(id("obelisks")); + } + private static boolean solidFeature(ResourceLocation choice) { return isRock(choice) || floatingIslands(choice) || tendrils(choice) - || spheres(choice) || spikes(choice); + || spheres(choice) || spikes(choice) || obelisks(choice); } public static boolean skylands(ResourceLocation choice) { diff --git a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java index e345192..aeba2e6 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java @@ -40,7 +40,7 @@ public final class FeatureWritingGameTests { "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/dense_ores"); + "feature/dense_ores", "feature/obelisks"); helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages), "Supported feature pages must appear in the creative and JEI symbol feed"); for (String path : pages) { @@ -72,6 +72,7 @@ public final class FeatureWritingGameTests { || AgeFeatureSelection.skylands(choice.id()) || AgeFeatureSelection.spheres(choice.id()) || AgeFeatureSelection.spikes(choice.id()) + || AgeFeatureSelection.obelisks(choice.id()) || choice.id().equals(AgeDenseOres.ID)) continue; var placement = AgeFeatureSelection.placedFeature(choice.id()); helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE) @@ -419,7 +420,8 @@ public final class FeatureWritingGameTests { || AgeFeatureSelection.floatingIslands(choices.getFirst().id()) || AgeFeatureSelection.tendrils(choices.getFirst().id()) || AgeFeatureSelection.spheres(choices.getFirst().id()) - || AgeFeatureSelection.spikes(choices.getFirst().id()), + || AgeFeatureSelection.spikes(choices.getFirst().id()) + || AgeFeatureSelection.obelisks(choices.getFirst().id()), "The inferred feature must accept the material type"); helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), choices, "The inferred feature must be deterministic across a plan reload"); diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java index d56bfea..4bb7d6a 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java @@ -162,6 +162,7 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { BlockPos origin = new BlockPos(x, level.getMinBuildHeight(), z); Map occurrences = new HashMap<>(); Map spikeOccurrences = new HashMap<>(); + int obeliskOccurrence = 0; for (int index = 0; index < features.size(); index++) { AgeFeatureSelection.Choice choice = features.get(index); int occurrence = occurrences.merge(choice, 1, Integer::sum) - 1; @@ -170,6 +171,10 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { spikeOccurrences.merge(choice.id(), 1, Integer::sum) - 1); continue; } + if (AgeFeatureSelection.obelisks(choice.id())) { + ObelisksFeature.generate(level, chunk, ageSeed, choice, obeliskOccurrence++); + continue; + } if (choice.id().equals(AgeDenseOres.ID)) { AgeDenseOres.place(level, chunk, this, ageSeed, occurrence); continue; diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java index aad8ae2..d2d910c 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java @@ -124,10 +124,13 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structures) { super.applyBiomeDecoration(level, chunk, structures); - int occurrence = 0; + int spikeOccurrence = 0; + int obeliskOccurrence = 0; for (AgeFeatureSelection.Choice choice : features) { if (AgeFeatureSelection.spikes(choice.id())) - SpikesFeature.generate(level, chunk, ageSeed, choice, occurrence++); + SpikesFeature.generate(level, chunk, ageSeed, choice, spikeOccurrence++); + if (AgeFeatureSelection.obelisks(choice.id())) + ObelisksFeature.generate(level, chunk, ageSeed, choice, obeliskOccurrence++); } } diff --git a/src/main/java/net/dnorberg/rivencraft/world/ObelisksFeature.java b/src/main/java/net/dnorberg/rivencraft/world/ObelisksFeature.java new file mode 100644 index 0000000..f76ea63 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/ObelisksFeature.java @@ -0,0 +1,95 @@ +package net.dnorberg.rivencraft.world; + +import net.dnorberg.rivencraft.book.AgeFeatureSelection; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.util.RandomSource; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** Rare one-chunk obelisk adapted from Legacy's 4x4 plinth and 2x12x2 shaft. */ +final class ObelisksFeature { + private static final long SALT = 0x4F42454C49534B53L; + private static final int CHANCE = 128; + private static final int BASE = 4; + private static final int HEIGHT = 12; + + private ObelisksFeature() {} + + record Site(int x, int z) {} + + static Site site(long ageSeed, ChunkPos chunk, int occurrence) { + RandomSource random = RandomSource.create(mix(ageSeed ^ SALT + ^ (long) chunk.x * 341873128712L ^ (long) chunk.z * 132897987541L + ^ (long) occurrence * 0x9E3779B97F4A7C15L)); + if (random.nextInt(CHANCE) != 0) return null; + return new Site(chunk.getMinBlockX() + 4 + random.nextInt(8), + chunk.getMinBlockZ() + 4 + random.nextInt(8)); + } + + static void generate(WorldGenLevel level, ChunkAccess chunk, long ageSeed, + AgeFeatureSelection.Choice choice, int occurrence) { + Site site = site(ageSeed, chunk.getPos(), occurrence); + if (site == null || choice.material().isEmpty()) return; + BlockState material = BuiltInRegistries.BLOCK.get(choice.material().orElseThrow()).defaultBlockState(); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int minSurface = Integer.MAX_VALUE; + int maxSurface = Integer.MIN_VALUE; + int[][] surface = new int[BASE][BASE]; + for (int dx = 0; dx < BASE; dx++) { + for (int dz = 0; dz < BASE; dz++) { + int x = site.x() + dx; + int z = site.z() + dz; + int y = level.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z); + if (y <= level.getMinBuildHeight() + 1) return; + BlockState ground = level.getBlockState(pos.set(x, y - 1, z)); + if (!ground.isFaceSturdy(level, pos, Direction.UP) + || !ground.getFluidState().isEmpty() || ground.hasBlockEntity()) return; + surface[dx][dz] = y; + minSurface = Math.min(minSurface, y); + maxSurface = Math.max(maxSurface, y); + } + } + if (maxSurface - minSurface > 2 || maxSurface + HEIGHT >= level.getMaxBuildHeight()) return; + for (int dx = 0; dx < BASE; dx++) { + for (int dz = 0; dz < BASE; dz++) { + for (int y = surface[dx][dz]; y <= maxSurface; y++) { + if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return; + } + } + } + for (int dx = 1; dx <= 2; dx++) { + for (int dz = 1; dz <= 2; dz++) { + for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++) { + if (!open(level.getBlockState(pos.set(site.x() + dx, y, site.z() + dz)))) return; + } + } + } + for (int dx = 0; dx < BASE; dx++) { + for (int dz = 0; dz < BASE; dz++) { + for (int y = surface[dx][dz]; y <= maxSurface; y++) + level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2); + } + } + for (int dx = 1; dx <= 2; dx++) { + for (int dz = 1; dz <= 2; dz++) { + for (int y = maxSurface + 1; y <= maxSurface + HEIGHT; y++) + level.setBlock(pos.set(site.x() + dx, y, site.z() + dz), material, 2); + } + } + } + + private static boolean open(BlockState state) { + return state.getFluidState().isEmpty() && (state.isAir() || state.canBeReplaced()); + } + + 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/ObelisksGameTests.java b/src/main/java/net/dnorberg/rivencraft/world/ObelisksGameTests.java new file mode 100644 index 0000000..42cc48c --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/ObelisksGameTests.java @@ -0,0 +1,110 @@ +package net.dnorberg.rivencraft.world; + +import java.util.List; +import java.util.Optional; +import net.dnorberg.rivencraft.Rivencraft; +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.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.ChunkPos; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +/** Obelisks must retain ordered materials and generate a stable Legacy-sized shape. */ +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class ObelisksGameTests { + private ObelisksGameTests() {} + + @GameTest(template = "empty") + public static void authoredObelisksKeepTheirMaterialAndTerrainRules(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + helper.assertTrue(PageItem.bundledCreativeSymbols().contains("feature/obelisks"), + "Obelisks must appear in the Creative and JEI symbol feed"); + OrderedWriting writing = new OrderedWriting(List.of(WritingEntry.linkPanel(), page("terrain/standard"), + block("obsidian"), page("feature/obelisks"), page("feature/obelisks"))); + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 56L, AgeGenerationProfiles.STANDARD, + AgeTraits.select(56L), registries); + var choices = AgeFeatureSelection.resolved(plan); + helper.assertValueEqual(choices.size(), 2, "Repeated Obelisks pages must remain separate"); + helper.assertValueEqual(choices.getFirst().material(), + Optional.of(ResourceLocation.withDefaultNamespace("obsidian")), + "A preceding Block page must set the first obelisk material"); + helper.assertTrue(choices.getLast().material().isPresent() + && AgeFeatureSelection.materialError(choices.getLast()) == null, + "An unmodified Obelisks page must choose a valid material"); + helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), choices, + "Obelisk materials and order must survive Age-plan reload"); + for (String terrain : List.of("standard", "amplified", "island", "flat")) + helper.assertTrue(AgeFeatureSelection.error(id("obelisks"), + AgeRuntimeProfiles.resolve(id(terrain)), registries) == null, + "Obelisks should be valid on exposed " + terrain + " terrain"); + for (String terrain : List.of("void", "cave")) + helper.assertTrue(AgeFeatureSelection.error(id("obelisks"), + AgeRuntimeProfiles.resolve(id(terrain)), registries) != null, + "Obelisks must reject " + terrain + " terrain without an exposed surface"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void randomAgesOnlyChooseObelisksOnSupportedTerrain(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + boolean selected = false; + for (long seed = 0; seed < 128; seed++) { + selected |= AgeFeatureSelection.randomChoices(seed, + AgeRuntimeProfiles.resolve(id("standard")), registries).stream() + .anyMatch(choice -> AgeFeatureSelection.obelisks(choice.id())); + for (String terrain : List.of("void", "cave")) + helper.assertTrue(AgeFeatureSelection.randomChoices(seed, + AgeRuntimeProfiles.resolve(id(terrain)), registries).stream() + .noneMatch(choice -> AgeFeatureSelection.obelisks(choice.id())), + "Random " + terrain + " Ages must not gain Obelisks"); + } + helper.assertTrue(selected, "Random surface Ages should be able to select Obelisks"); + helper.succeed(); + } + + @GameTest(template = "empty") + public static void obeliskSitesAreSeededAndStayInsideTheirChunk(GameTestHelper helper) { + ChunkPos chunk = new ChunkPos(123, -45); + int selected = 0; + for (long seed = 0; seed < 2048; seed++) { + ObelisksFeature.Site site = ObelisksFeature.site(seed, chunk, 0); + helper.assertTrue(java.util.Objects.equals(ObelisksFeature.site(seed, chunk, 0), site), + "An Age seed must reproduce the same obelisk site"); + if (site == null) continue; + selected++; + helper.assertTrue(site.x() >= chunk.getMinBlockX() + 4 + && site.x() + 3 < chunk.getMaxBlockX(), + "The full 4x4 plinth must remain inside its source chunk"); + helper.assertTrue(site.z() >= chunk.getMinBlockZ() + 4 + && site.z() + 3 < chunk.getMaxBlockZ(), + "The full 4x4 plinth must remain inside its source chunk"); + } + helper.assertTrue(selected > 0 && selected < 64, + "The 1-in-128 roll should remain rare but reachable"); + helper.succeed(); + } + + 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))); + } + + private static WritingEntry block(String path) { + return WritingEntry.symbol(new SymbolReference(net.dnorberg.rivencraft.writing.BlockSymbolPages.symbolId( + ResourceLocation.withDefaultNamespace(path)))); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java index a330ff5..43ba753 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/WritingGrammar.java @@ -78,7 +78,7 @@ public final class WritingGrammar { } else if (role == Role.FEATURE && pendingBlock != null) { ResourceLocation feature = ((SymbolSemantics.ResourceValue) parsed.semantics().value()).id(); if (!Set.of("rocks", "surface_lakes", "deep_lakes", "lava_lakes", - "floating_islands", "tendrils", "spheres", "spikes") + "floating_islands", "tendrils", "spheres", "spikes", "obelisks") .contains(feature.getPath()) || !feature.getNamespace().equals("rivencraft")) { diagnostics.add(new Diagnostic(Code.MISPLACED_MODIFIER, pendingBlock.index(), "This feature cannot consume a material page")); diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 1f527b5..23cf6dd 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -285,6 +285,8 @@ "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.obelisks": "Obelisks", + "symbol.rivencraft.feature.obelisks.description": "Add rare tall obelisks on exposed solid ground, with a random or selected block material.", "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/obelisks.json b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/obelisks.json new file mode 100644 index 0000000..c4dbff3 --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/obelisks.json @@ -0,0 +1,8 @@ +{ + "schema_version": 2, + "name": "symbol.rivencraft.feature.obelisks", + "description": "symbol.rivencraft.feature.obelisks.description", + "category": "rivencraft:feature", + "semantics": { "role": "feature", "value": "rivencraft:obelisks" }, + "poem": "Civilization Structure Power Obelisk" +}