Compare commits

...
1 Commits
Author SHA1 Message Date
dnorberg 6dbe208c42 Port authored Obelisks age feature (#56)
Build / build (push) Canceled after 0s
2026-10-05 11:56:48 -04:00
12 changed files with 263 additions and 10 deletions

No files matched your search

+15
View File
@@ -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
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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
@@ -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<ResourceLocation> 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<ResourceLocation> 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) {
@@ -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");
@@ -162,6 +162,7 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator {
BlockPos origin = new BlockPos(x, level.getMinBuildHeight(), z);
Map<AgeFeatureSelection.Choice, Integer> occurrences = new HashMap<>();
Map<ResourceLocation, Integer> 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;
@@ -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++);
}
}
@@ -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;
}
}
@@ -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))));
}
}
@@ -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"));
@@ -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",
@@ -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"
}