From 66b4c56eb68236c90e853b7f9adcbf225b0fd68b Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Sun, 4 Oct 2026 15:54:54 -0400 Subject: [PATCH] Port Skylands terrain filter (#47) --- docs/AGE_PROFILES.md | 20 ++++- docs/LEGACY_AGE_GRAMMAR_AUDIT.md | 2 +- gradle.properties | 2 +- .../rivencraft/book/AgeFeatureSelection.java | 11 ++- .../book/AgePersistenceGameTests.java | 12 ++- .../book/FeatureWritingGameTests.java | 40 ++++++++- .../world/AgeFeatureChunkGenerator.java | 7 +- .../world/AgeFlatChunkGenerator.java | 2 + .../rivencraft/world/SkylandsFeature.java | 90 +++++++++++++++++++ .../world/SkylandsFeatureGameTests.java | 31 +++++++ .../assets/rivencraft/lang/en_us.json | 2 + .../rivencraft/symbols/feature/skylands.json | 8 ++ 12 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 src/main/java/net/dnorberg/rivencraft/world/SkylandsFeature.java create mode 100644 src/main/java/net/dnorberg/rivencraft/world/SkylandsFeatureGameTests.java create mode 100644 src/main/resources/data/rivencraft/rivencraft/symbols/feature/skylands.json diff --git a/docs/AGE_PROFILES.md b/docs/AGE_PROFILES.md index 3a6cc35..34c800d 100644 --- a/docs/AGE_PROFILES.md +++ b/docs/AGE_PROFILES.md @@ -56,7 +56,7 @@ An unpaired Block or Fluid page chooses a compatible feature from the Age seed: full blocks can become Rocks or Floating Islands; source fluids can become one of the lake features. Flat and Void terrain can only use Floating Islands from this pool, while Cave terrain has no compatible material feature. A page-free -random Age rolls zero, one, or two distinct compatible features, choosing each +random Age rolls zero, one, or two distinct compatible material features, choosing each material from the Block or Fluid Pages pool. The choices are saved on the Age definition, so opening the Age again does not reroll them. Repeated Block/Fluid and Feature pages remain separate entries in authored @@ -73,6 +73,24 @@ common than in the first test build (one source chunk in 128 instead of 64). Sky Islands should retain its terrain islands and gain occasional additional feature islands. Try Cave terrain too; the Binder should reject that pairing. +**Skylands** is a separate subtractive feature, not the Sky Islands terrain +profile or the additive Floating Islands feature. Following Legacy's primer +filter, it removes base terrain below a smooth, seven-octave cutoff around +world Y=76 and removes liquid above the cutoff. The selected terrain still +generates first; Floating Islands and Tendrils are added afterward, so they +can coexist with Skylands. Skylands has no Block-page modifier. It can be +authored on every terrain, though Flat loses its low ground and Void has no +base terrain to change. Page-free random noise Ages have a seeded one-in-eight +chance to gain Skylands; random Flat and Void Ages do not roll this ineffective +filter. The cloud-height and horizon changes from Legacy are not included yet. + +For an in-game check, compare Standard terrain with and without a Skylands +page. Skylands should leave upper hills suspended over open space, while a +Floating Islands page instead adds isolated high islands without removing the +ground below. Try Sky Islands terrain plus Skylands to confirm the page filters +the terrain rather than replacing its generator. A book with both Skylands and +Floating Islands should retain the added feature islands above the cutoff. + The **Tendrils** feature adapts Legacy's winding, branching cave-node geometry into solid tubes. It is additive on all current terrain families, including Flat, Void, and Cave; it does not change biome selection. A preceding full- diff --git a/docs/LEGACY_AGE_GRAMMAR_AUDIT.md b/docs/LEGACY_AGE_GRAMMAR_AUDIT.md index 43259bf..ca45dc8 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, vanilla data-pack generation, and authored Floating Islands/Tendrils | Compile supported vanilla placements and port bespoke shapes explicitly. Remaining bespoke structures and polish stay in later issues. | +| Caves, ores, huge trees, lakes, floating islands, tendrils, skylands, spheres, spikes, crystals; vanilla villages, strongholds, mineshafts, dungeons, fortresses, obelisks, fissures | Current cave profile, vanilla data-pack generation, and authored Floating Islands/Tendrils/Skylands | Compile supported vanilla placements and port bespoke shapes explicitly. Remaining bespoke structures and polish stay in later issues. | | `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 94ac9ac..2ccd75a 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.12.0 +mod_version=0.4.13.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 419423e..c08633e 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeFeatureSelection.java @@ -28,6 +28,7 @@ public final class AgeFeatureSelection { id("rocks"), id("floating_islands"), id("tendrils")); 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; private AgeFeatureSelection() {} @@ -139,7 +140,7 @@ public final class AgeFeatureSelection { return List.copyOf(resolved); } - /** Page-free Ages roll up to two compatible material/feature pairs once at creation. */ + /** Page-free Ages roll up to two material features, plus rare Skylands, once at creation. */ public static List randomChoices(long ageSeed, AgeRuntimeProfile profile, RegistryAccess registries) { Random random = new Random(ageSeed ^ 0x4D4154455249414CL); int count = random.nextInt(3); @@ -157,6 +158,9 @@ public final class AgeFeatureSelection { if (!materials.isEmpty()) choices.add(new Choice(feature, Optional.of(materials.get(random.nextInt(materials.size()))))); } + if (profile.terrain() == AgeRuntimeProfile.Terrain.NOISE + && Math.floorMod(mix(ageSeed ^ RANDOM_SKYLANDS_SALT), 8) == 0) + choices.add(new Choice(id("skylands"), Optional.empty())); return List.copyOf(choices); } @@ -181,6 +185,7 @@ public final class AgeFeatureSelection { } public static String error(ResourceLocation choice, AgeRuntimeProfile profile, RegistryAccess registries) { + if (skylands(choice)) return null; if (tendrils(choice)) return null; if (floatingIslands(choice)) { if (AgeBiomeSelection.isCave(profile)) @@ -264,6 +269,10 @@ public final class AgeFeatureSelection { return choice.equals(id("tendrils")); } + public static boolean skylands(ResourceLocation choice) { + return choice.equals(id("skylands")); + } + public static boolean supportsSurfaceVegetation(ResourceLocation id, RegistryAccess registries) { var biome = registries.lookupOrThrow(Registries.BIOME) .get(ResourceKey.create(Registries.BIOME, id)); diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java index b73e480..0156bd7 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgePersistenceGameTests.java @@ -68,12 +68,18 @@ public final class AgePersistenceGameTests { helper.assertTrue(pageFree.plan() == null && panelOnly.plan() == null, "Page-free and Link-Panel-only books must retain the random Age path"); for (AgeDefinition age : List.of(pageFree, panelOnly)) { - helper.assertTrue(age.randomFeatures().size() <= 2, + helper.assertTrue(age.randomFeatures().stream().filter(choice -> choice.material().isPresent()) + .count() <= 2, "A random Age may roll at most two material features"); + helper.assertTrue(age.randomFeatures().stream().filter(choice -> + AgeFeatureSelection.skylands(choice.id())).count() <= 1, + "A random Age may roll at most one Skylands filter"); for (AgeFeatureSelection.Choice choice : age.randomFeatures()) { - helper.assertTrue(choice.material().isPresent() + helper.assertTrue(AgeFeatureSelection.skylands(choice.id()) + ? choice.material().isEmpty() + : choice.material().isPresent() && AgeFeatureSelection.materialError(choice) == null, - "Random features must each carry a valid block or fluid material"); + "Random material features need valid blocks or fluids; Skylands needs none"); } } AgeRegistrySavedData registry = AgeRegistrySavedData.get(helper.getLevel()); diff --git a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java index 0a6f2d9..4f076b3 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/FeatureWritingGameTests.java @@ -32,7 +32,7 @@ public final class FeatureWritingGameTests { Set pages = Set.of("feature/caves", "feature/flowers", "feature/trees", "feature/huge_trees", "feature/rocks", "feature/lava_lakes", "feature/surface_lakes", "feature/deep_lakes", "feature/ravines", - "feature/floating_islands", "feature/tendrils"); + "feature/floating_islands", "feature/tendrils", "feature/skylands"); helper.assertTrue(PageItem.bundledCreativeSymbols().containsAll(pages), "Supported feature pages must appear in the creative and JEI symbol feed"); for (String path : pages) { @@ -60,7 +60,8 @@ public final class FeatureWritingGameTests { for (AgeFeatureSelection.Choice choice : AgeFeatureSelection.resolved(plan)) { if (AgeFeatureSelection.caves(choice.id()) || AgeFeatureSelection.ravines(choice.id()) || AgeFeatureSelection.floatingIslands(choice.id()) - || AgeFeatureSelection.tendrils(choice.id())) continue; + || AgeFeatureSelection.tendrils(choice.id()) + || AgeFeatureSelection.skylands(choice.id())) continue; var placement = AgeFeatureSelection.placedFeature(choice.id()); helper.assertTrue(registries.lookupOrThrow(Registries.PLACED_FEATURE) .get(ResourceKey.create(Registries.PLACED_FEATURE, placement)).isPresent(), @@ -178,6 +179,41 @@ public final class FeatureWritingGameTests { helper.succeed(); } + @GameTest(template = "empty") + public static void skylandsFiltersBaseTerrainIndependentlyOfIslandFeatures(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + for (String terrain : List.of("standard", "amplified", "island", "flat", "void", "cave")) { + OrderedWriting writing = new OrderedWriting(List.of(panel(), page("terrain/" + terrain), + page("feature/skylands"), page("feature/floating_islands"))); + if (terrain.equals("cave")) { + writing = new OrderedWriting(List.of(panel(), page("terrain/cave"), + page("feature/skylands"))); + } + AgeGenerationPlan plan = AgeGenerationPlan.compile(writing, 707L, + AgeGenerationProfiles.STANDARD, AgeTraits.select(707L), registries); + List resolved = AgeFeatureSelection.resolved(plan); + helper.assertValueEqual(resolved.getFirst(), new AgeFeatureSelection.Choice( + id("skylands"), Optional.empty()), + "Skylands must be a separate material-free feature on " + terrain); + helper.assertValueEqual(AgeFeatureSelection.resolved(AgeGenerationPlan.load(plan.save())), resolved, + "Skylands and any Floating Islands page must survive a plan reload"); + } + rejects(helper, new OrderedWriting(List.of(panel(), page("terrain/standard"), + block("stone"), page("feature/skylands"))), "cannot consume a material page"); + List randomNoise = AgeFeatureSelection.randomChoices(7L, + AgeRuntimeProfiles.resolve(id("standard")), registries); + helper.assertTrue(randomNoise.stream().anyMatch(choice -> AgeFeatureSelection.skylands(choice.id()) + && choice.material().isEmpty()), + "A page-free random noise Age must be able to roll material-free Skylands"); + for (String terrain : List.of("flat", "void")) { + helper.assertTrue(AgeFeatureSelection.randomChoices(7L, + AgeRuntimeProfiles.resolve(id(terrain)), registries).stream() + .noneMatch(choice -> AgeFeatureSelection.skylands(choice.id())), + "Random " + terrain + " Ages must not roll a filter without useful upper terrain"); + } + helper.succeed(); + } + @GameTest(template = "empty") public static void voidBiomesAreIndependentOfFloatingIslands(GameTestHelper helper) { OrderedWriting voidDefault = new OrderedWriting(List.of(panel(), page("terrain/void"))); diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java index a996d6e..1c6a333 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFeatureChunkGenerator.java @@ -76,8 +76,13 @@ public final class AgeFeatureChunkGenerator extends NoiseBasedChunkGenerator { StructureManager structures, ChunkAccess chunk) { CompletableFuture filled = super.fillFromNoise(blender, random, structures, chunk); if (features.stream().noneMatch(choice -> AgeFeatureSelection.floatingIslands(choice.id()) - || AgeFeatureSelection.tendrils(choice.id()))) return filled; + || AgeFeatureSelection.tendrils(choice.id()) + || AgeFeatureSelection.skylands(choice.id()))) return filled; return filled.thenApply(result -> { + // Legacy's Skylands primer filter removes base terrain before large + // features add their own blocks to the chunk. + if (features.stream().anyMatch(choice -> AgeFeatureSelection.skylands(choice.id()))) + SkylandsFeature.filter(result, ageSeed); Map occurrences = new HashMap<>(); for (AgeFeatureSelection.Choice choice : features) { if (!AgeFeatureSelection.floatingIslands(choice.id()) diff --git a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java index 48f3881..780f392 100644 --- a/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java +++ b/src/main/java/net/dnorberg/rivencraft/world/AgeFlatChunkGenerator.java @@ -100,6 +100,8 @@ public final class AgeFlatChunkGenerator extends ChunkGenerator { } } } + if (features.stream().anyMatch(choice -> AgeFeatureSelection.skylands(choice.id()))) + SkylandsFeature.filter(chunk, ageSeed); Map occurrences = new HashMap<>(); for (AgeFeatureSelection.Choice choice : features) { if (!AgeFeatureSelection.floatingIslands(choice.id()) diff --git a/src/main/java/net/dnorberg/rivencraft/world/SkylandsFeature.java b/src/main/java/net/dnorberg/rivencraft/world/SkylandsFeature.java new file mode 100644 index 0000000..af8f7fe --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/SkylandsFeature.java @@ -0,0 +1,90 @@ +package net.dnorberg.rivencraft.world; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.levelgen.Heightmap; + +/** + * Subtractive Skylands terrain filter adapted from Mystcraft Legacy's + * SymbolSkylands (LGPL-3.0-or-later). Legacy keeps solid terrain above a + * seven-octave cutoff near Y=76 and discards liquid above that cutoff. + */ +final class SkylandsFeature { + private static final long SALT = 0x534B594C414E4453L; + private static final int BASE_CUTOFF = 76; + private static final BlockState AIR = Blocks.AIR.defaultBlockState(); + + private SkylandsFeature() {} + + static void filter(ChunkAccess chunk, long ageSeed) { + int originX = chunk.getPos().getMinBlockX(); + int originZ = chunk.getPos().getMinBlockZ(); + int minY = chunk.getMinBuildHeight(); + int maxY = chunk.getMaxBuildHeight(); + Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG); + Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG); + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + for (int localX = 0; localX < 16; localX++) { + for (int localZ = 0; localZ < 16; localZ++) { + int cutoff = cutoff(ageSeed, originX + localX, originZ + localZ); + for (int y = minY; y < maxY; y++) { + pos.set(originX + localX, y, originZ + localZ); + BlockState state = chunk.getBlockState(pos); + if (!removes(state, y, cutoff)) continue; + chunk.setBlockState(pos, AIR, false); + surface.update(localX, y, localZ, AIR); + floor.update(localX, y, localZ, AIR); + } + } + } + } + + static boolean removes(BlockState state, int y, int cutoff) { + return !state.isAir() && (y <= cutoff || state.liquid()); + } + + static int cutoff(long ageSeed, int x, int z) { + double variation = 0.0D; + int cellSize = 128; + double amplitude = 12.0D; + for (int octave = 0; octave < 7; octave++) { + variation += valueNoise(ageSeed ^ SALT ^ (long) octave * 0x9E3779B97F4A7C15L, + x, z, cellSize) * amplitude; + cellSize /= 2; + amplitude /= 2.0D; + } + return BASE_CUTOFF + (int) Math.round(variation); + } + + private static double valueNoise(long seed, int x, int z, int cellSize) { + int cellX = Math.floorDiv(x, cellSize); + int cellZ = Math.floorDiv(z, cellSize); + double u = smooth((x - cellX * cellSize) / (double) cellSize); + double v = smooth((z - cellZ * cellSize) / (double) cellSize); + double north = lerp(value(seed, cellX, cellZ), value(seed, cellX + 1, cellZ), u); + double south = lerp(value(seed, cellX, cellZ + 1), value(seed, cellX + 1, cellZ + 1), u); + return lerp(north, south, v); + } + + private static double value(long seed, int x, int z) { + long hash = mix(seed ^ (long) x * 0xD1B54A32D192ED03L + ^ (long) z * 0xABC98388FB8FAC03L); + return (hash >>> 11) * 0x1.0p-52 - 1.0D; + } + + private static double smooth(double value) { + return value * value * (3.0D - 2.0D * value); + } + + private static double lerp(double first, double second, double amount) { + return first + (second - first) * amount; + } + + 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/SkylandsFeatureGameTests.java b/src/main/java/net/dnorberg/rivencraft/world/SkylandsFeatureGameTests.java new file mode 100644 index 0000000..a0bd445 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/world/SkylandsFeatureGameTests.java @@ -0,0 +1,31 @@ +package net.dnorberg.rivencraft.world; + +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.level.block.Blocks; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +/** Checks the Legacy-style filter independently of a slow dimension-generation test. */ +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class SkylandsFeatureGameTests { + private SkylandsFeatureGameTests() {} + + @GameTest(template = "empty") + public static void skylandsOnlyKeepsUpperSolidTerrain(GameTestHelper helper) { + int cutoff = SkylandsFeature.cutoff(707L, 32, -48); + helper.assertValueEqual(SkylandsFeature.cutoff(707L, 32, -48), cutoff, + "Skylands cutoff must be deterministic for an Age seed and world column"); + helper.assertTrue(SkylandsFeature.removes(Blocks.STONE.defaultBlockState(), cutoff, cutoff), + "Solid terrain at the cutoff must be removed"); + helper.assertFalse(SkylandsFeature.removes(Blocks.STONE.defaultBlockState(), cutoff + 1, cutoff), + "Solid terrain above the cutoff must remain"); + helper.assertTrue(SkylandsFeature.removes(Blocks.WATER.defaultBlockState(), cutoff + 1, cutoff), + "Liquids above the cutoff must be removed"); + helper.assertFalse(SkylandsFeature.removes(Blocks.AIR.defaultBlockState(), cutoff - 1, cutoff), + "Air must remain air"); + helper.succeed(); + } +} diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 345e1ad..bc63038 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -247,6 +247,8 @@ "symbol.rivencraft.feature.floating_islands.description": "Add high floating islands above the chosen surface terrain.", "symbol.rivencraft.feature.tendrils": "Tendrils", "symbol.rivencraft.feature.tendrils.description": "Add winding solid tendrils through the Age, using a random block or a preceding Block page.", + "symbol.rivencraft.feature.skylands": "Skylands", + "symbol.rivencraft.feature.skylands.description": "Remove terrain below a shifting sky cutoff; unlike Floating Islands, this does not add land.", "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/skylands.json b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/skylands.json new file mode 100644 index 0000000..084b784 --- /dev/null +++ b/src/main/resources/data/rivencraft/rivencraft/symbols/feature/skylands.json @@ -0,0 +1,8 @@ +{ + "schema_version": 2, + "name": "symbol.rivencraft.feature.skylands", + "description": "symbol.rivencraft.feature.skylands.description", + "category": "rivencraft:feature", + "semantics": { "role": "feature", "value": "rivencraft:skylands" }, + "poem": "Terrain Sky Void Suspension" +}